feat(16-02): standard group handoff building block (D-06)

- DEFAULT_GROUP_NAME extracted as shared constant between
  ensureDefaultGroup() and the new reassignDefaultBeforeDelete()
- reassignDefaultBeforeDelete(tenantId, groupId) moves the default
  marker deterministically (DEFAULT_GROUP_NAME first, else oldest
  other group by createdAt asc), never deletes, never throws
- 6 test cases covering handoff, fallback ordering, no-other-group,
  non-default no-op, cross-tenant no-op, and P2002 race
This commit is contained in:
2026-08-06 15:55:53 +02:00
parent 1b19876c32
commit 2ef9b8638c
2 changed files with 198 additions and 12 deletions
+121 -10
View File
@@ -1,7 +1,7 @@
import { BadRequestException, ConflictException, NotFoundException } from '@nestjs/common';
import { MembershipSource } from '@prisma/client';
import { describe, expect, it } from 'vitest';
import { GroupsService } from './groups.service';
import { DEFAULT_GROUP_NAME, GroupsService } from './groups.service';
/**
* GroupsService.spec — TDD-RED-first Beweis für PERM-01 (D-13/D-17/D-19)
@@ -80,15 +80,26 @@ function makeFakePrisma() {
}
return rows;
},
findFirst: async ({ where }: any) => {
return (
Array.from(groups.values()).find(
(g) =>
(where.id === undefined || g.id === where.id) &&
g.tenantId === where.tenantId &&
(where.isDefault === undefined || g.isDefault === where.isDefault),
) ?? null
);
findFirst: async ({ where, orderBy }: any) => {
let rows = Array.from(groups.values()).filter((g) => {
if (where.tenantId !== undefined && g.tenantId !== where.tenantId) return false;
if (where.id !== undefined) {
if (typeof where.id === 'object' && where.id !== null) {
if (where.id.not !== undefined && g.id === where.id.not) return false;
} else if (g.id !== where.id) {
return false;
}
}
if (where.isDefault !== undefined && g.isDefault !== where.isDefault) return false;
if (where.name !== undefined && g.name !== where.name) return false;
return true;
});
if (orderBy?.createdAt === 'asc') {
rows = [...rows].sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime());
} else if (orderBy?.createdAt === 'desc') {
rows = [...rows].sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
}
return rows[0] ?? null;
},
create: async ({ data }: any) => {
if (findGroupByTenantAndName(data.tenantId, data.name)) throwUnique();
@@ -608,4 +619,104 @@ describe('GroupsService', () => {
expect(grants).toEqual([]);
});
});
// --- reassignDefaultBeforeDelete (D-06) -------------------------------
describe('GroupsService.reassignDefaultBeforeDelete (D-06)', () => {
it('verschiebt die Markierung auf die Gruppe DEFAULT_GROUP_NAME, wenn sie existiert', async () => {
const prisma = makeFakePrisma();
const service = new GroupsService(prisma as any);
const def = await service.create('t1', { name: DEFAULT_GROUP_NAME });
const toDelete = await service.create('t1', { name: 'Zu loeschen' });
await service.update('t1', toDelete.id, { isDefault: true });
const result = await service.reassignDefaultBeforeDelete('t1', toDelete.id);
expect(result).toBe(true);
const list = await service.listForTenant('t1');
expect(list.find((g) => g.id === def.id)!.isDefault).toBe(true);
expect(list.find((g) => g.id === toDelete.id)!.isDefault).toBe(false);
});
it('verschiebt die Markierung auf die aelteste andere Gruppe (createdAt asc), wenn DEFAULT_GROUP_NAME nicht existiert', async () => {
const prisma = makeFakePrisma();
const service = new GroupsService(prisma as any);
const older = await service.create('t1', { name: 'Aeltere' });
const newer = await service.create('t1', { name: 'Neuere' });
const toDelete = await service.create('t1', { name: 'Zu loeschen' });
await service.update('t1', toDelete.id, { isDefault: true });
// Deterministische createdAt-Reihenfolge statt auf Systemzeit-Aufloesung
// im Millisekundenbereich zu vertrauen.
await (prisma as any).group.update({
where: { id: older.id },
data: { createdAt: new Date(Date.now() - 20000) },
});
await (prisma as any).group.update({
where: { id: newer.id },
data: { createdAt: new Date(Date.now() - 10000) },
});
const result = await service.reassignDefaultBeforeDelete('t1', toDelete.id);
expect(result).toBe(true);
const list = await service.listForTenant('t1');
expect(list.find((g) => g.id === older.id)!.isDefault).toBe(true);
expect(list.find((g) => g.id === newer.id)!.isDefault).toBe(false);
});
it('gibt false zurueck und wirft nicht, wenn keine andere Gruppe im Mandanten existiert', async () => {
const prisma = makeFakePrisma();
const service = new GroupsService(prisma as any);
const only = await service.create('t1', { name: 'Einzige' });
await service.update('t1', only.id, { isDefault: true });
await expect(service.reassignDefaultBeforeDelete('t1', only.id)).resolves.toBe(false);
const list = await service.listForTenant('t1');
expect(list.find((g) => g.id === only.id)!.isDefault).toBe(true);
});
it('ist ein No-Op mit Rueckgabe false fuer eine Gruppe ohne Standardmarkierung', async () => {
const prisma = makeFakePrisma();
const service = new GroupsService(prisma as any);
const a = await service.create('t1', { name: 'A' });
const b = await service.create('t1', { name: 'B' });
await service.update('t1', b.id, { isDefault: true });
const result = await service.reassignDefaultBeforeDelete('t1', a.id);
expect(result).toBe(false);
const list = await service.listForTenant('t1');
expect(list.find((g) => g.id === b.id)!.isDefault).toBe(true);
});
it('gibt false zurueck fuer eine Gruppen-ID eines fremden Mandanten, statt zu werfen', async () => {
const prisma = makeFakePrisma();
const service = new GroupsService(prisma as any);
const group = await service.create('t1', { name: 'A' });
await service.update('t1', group.id, { isDefault: true });
await expect(
service.reassignDefaultBeforeDelete('t-fremd', group.id),
).resolves.toBe(false);
const list = await service.listForTenant('t1');
expect(list.find((g) => g.id === group.id)!.isDefault).toBe(true);
});
it('faengt einen P2002 aus der Transaktion ab und liefert false statt zu werfen', async () => {
const prisma = makeFakePrisma();
const service = new GroupsService(prisma as any);
await service.create('t1', { name: 'Ziel' });
const toDelete = await service.create('t1', { name: 'Zu loeschen' });
await service.update('t1', toDelete.id, { isDefault: true });
prisma.group.update = async () => {
const err: any = new Error('Unique constraint failed');
err.code = 'P2002';
throw err;
};
await expect(
service.reassignDefaultBeforeDelete('t1', toDelete.id),
).resolves.toBe(false);
});
});
});