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:
@@ -1,7 +1,7 @@
|
|||||||
import { BadRequestException, ConflictException, NotFoundException } from '@nestjs/common';
|
import { BadRequestException, ConflictException, NotFoundException } from '@nestjs/common';
|
||||||
import { MembershipSource } from '@prisma/client';
|
import { MembershipSource } from '@prisma/client';
|
||||||
import { describe, expect, it } from 'vitest';
|
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)
|
* GroupsService.spec — TDD-RED-first Beweis für PERM-01 (D-13/D-17/D-19)
|
||||||
@@ -80,15 +80,26 @@ function makeFakePrisma() {
|
|||||||
}
|
}
|
||||||
return rows;
|
return rows;
|
||||||
},
|
},
|
||||||
findFirst: async ({ where }: any) => {
|
findFirst: async ({ where, orderBy }: any) => {
|
||||||
return (
|
let rows = Array.from(groups.values()).filter((g) => {
|
||||||
Array.from(groups.values()).find(
|
if (where.tenantId !== undefined && g.tenantId !== where.tenantId) return false;
|
||||||
(g) =>
|
if (where.id !== undefined) {
|
||||||
(where.id === undefined || g.id === where.id) &&
|
if (typeof where.id === 'object' && where.id !== null) {
|
||||||
g.tenantId === where.tenantId &&
|
if (where.id.not !== undefined && g.id === where.id.not) return false;
|
||||||
(where.isDefault === undefined || g.isDefault === where.isDefault),
|
} else if (g.id !== where.id) {
|
||||||
) ?? null
|
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) => {
|
create: async ({ data }: any) => {
|
||||||
if (findGroupByTenantAndName(data.tenantId, data.name)) throwUnique();
|
if (findGroupByTenantAndName(data.tenantId, data.name)) throwUnique();
|
||||||
@@ -608,4 +619,104 @@ describe('GroupsService', () => {
|
|||||||
expect(grants).toEqual([]);
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,6 +7,15 @@ import {
|
|||||||
import { MembershipSource } from '@prisma/client';
|
import { MembershipSource } from '@prisma/client';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Name der automatisch angelegten Standardgruppe (D-13). Geteilte Wahrheit
|
||||||
|
* zwischen ensureDefaultGroup() (legt sie an, falls der Mandant noch keine
|
||||||
|
* Gruppe hat) und reassignDefaultBeforeDelete() (bevorzugtes Handoff-Ziel,
|
||||||
|
* D-06) — zwei getrennte Literale würden bei einer Umbenennung
|
||||||
|
* auseinanderlaufen.
|
||||||
|
*/
|
||||||
|
export const DEFAULT_GROUP_NAME = 'Alle Benutzer';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Service für Gruppen-CRUD, Mitgliederverwaltung und die automatische
|
* Service für Gruppen-CRUD, Mitgliederverwaltung und die automatische
|
||||||
* Standardgruppen-Mitgliedschaft (PERM-01/PERM-06, D-11/D-12/D-13/D-17/D-19).
|
* Standardgruppen-Mitgliedschaft (PERM-01/PERM-06, D-11/D-12/D-13/D-17/D-19).
|
||||||
@@ -254,7 +263,7 @@ export class GroupsService {
|
|||||||
* Stellt für einen Mandanten OHNE JEDE Gruppe denselben Endzustand her,
|
* Stellt für einen Mandanten OHNE JEDE Gruppe denselben Endzustand her,
|
||||||
* den die drei Backfill-INSERTs der Migration
|
* den die drei Backfill-INSERTs der Migration
|
||||||
* 20260804130130_add_groups_and_module_grants pro Mandant herstellen:
|
* 20260804130130_add_groups_and_module_grants pro Mandant herstellen:
|
||||||
* eine Gruppe 'Alle Benutzer' (isDefault:true), alle Bestandsbenutzer als
|
* eine Gruppe DEFAULT_GROUP_NAME (isDefault:true), alle Bestandsbenutzer als
|
||||||
* MANUAL-Mitglieder und Grants für alle aktiven Module. Aufrufer:
|
* MANUAL-Mitglieder und Grants für alle aktiven Module. Aufrufer:
|
||||||
* TenantService.create (frischer Mandant) und
|
* TenantService.create (frischer Mandant) und
|
||||||
* AdminSeedService.ensureDefaultGroupsForAllTenants (Startup-Reparatur
|
* AdminSeedService.ensureDefaultGroupsForAllTenants (Startup-Reparatur
|
||||||
@@ -283,7 +292,7 @@ export class GroupsService {
|
|||||||
try {
|
try {
|
||||||
return await this.prisma.$transaction(async (tx) => {
|
return await this.prisma.$transaction(async (tx) => {
|
||||||
const group = await tx.group.create({
|
const group = await tx.group.create({
|
||||||
data: { tenantId, name: 'Alle Benutzer', isDefault: true },
|
data: { tenantId, name: DEFAULT_GROUP_NAME, isDefault: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
const users = await tx.user.findMany({
|
const users = await tx.user.findMany({
|
||||||
@@ -327,6 +336,72 @@ export class GroupsService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verschiebt die Standardmarkierung weg von der übergebenen Gruppe, BEVOR
|
||||||
|
* der Aufrufer sie löscht (D-06). Löscht selbst nichts — meldet
|
||||||
|
* ausschließlich per Rückgabewert, ob sie etwas verschoben hat.
|
||||||
|
*
|
||||||
|
* Bewusst NICHT findOwned(): dessen NotFoundException ist für HTTP
|
||||||
|
* gebaut und würde einen Batch-Sync-Lauf (Plan 16-03) abbrechen. Eine
|
||||||
|
* Gruppen-ID aus einem fremden Mandanten oder eine nicht markierte
|
||||||
|
* Gruppe ist stattdessen ein folgenloses No-Op mit Rückgabe false.
|
||||||
|
*
|
||||||
|
* Zielauswahl: zuerst DEFAULT_GROUP_NAME (falls im Mandanten vorhanden
|
||||||
|
* und nicht die zu löschende Gruppe selbst), sonst die älteste andere
|
||||||
|
* Gruppe (orderBy createdAt asc) — dieser Determinismus garantiert, dass
|
||||||
|
* zwei Läufe über denselben Bestand dieselbe Gruppe wählen. Existiert
|
||||||
|
* keine andere Gruppe, gibt die Methode false zurück; der Aufrufer ruft
|
||||||
|
* danach ensureDefaultGroup(tenantId), um den Mandanten neu aufzubauen.
|
||||||
|
*
|
||||||
|
* Dieselbe Zwei-Schritt-Transaktionsform wie update() (isDefault:true):
|
||||||
|
* erst updateMany auf isDefault:false für den ganzen Mandanten, dann
|
||||||
|
* update der Zielgruppe auf isDefault:true. Der partielle Unique-Index
|
||||||
|
* Group_one_default_per_tenant (15-01) bleibt der eigentliche
|
||||||
|
* Durchsetzungspunkt; ein daraus resultierender P2002 wird als "hat sich
|
||||||
|
* schon jemand anderes gekümmert" behandelt und liefert false statt zu
|
||||||
|
* werfen — exakt das Muster aus ensureDefaultGroup().
|
||||||
|
*/
|
||||||
|
async reassignDefaultBeforeDelete(tenantId: string, groupId: string): Promise<boolean> {
|
||||||
|
const group = await this.prisma.group.findFirst({
|
||||||
|
where: { id: groupId, tenantId },
|
||||||
|
});
|
||||||
|
if (!group || !group.isDefault) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const target =
|
||||||
|
(await this.prisma.group.findFirst({
|
||||||
|
where: { tenantId, id: { not: groupId }, name: DEFAULT_GROUP_NAME },
|
||||||
|
})) ??
|
||||||
|
(await this.prisma.group.findFirst({
|
||||||
|
where: { tenantId, id: { not: groupId } },
|
||||||
|
orderBy: { createdAt: 'asc' },
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (!target) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.prisma.$transaction([
|
||||||
|
this.prisma.group.updateMany({
|
||||||
|
where: { tenantId, isDefault: true },
|
||||||
|
data: { isDefault: false },
|
||||||
|
}),
|
||||||
|
this.prisma.group.update({
|
||||||
|
where: { id: target.id },
|
||||||
|
data: { isDefault: true },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
return true;
|
||||||
|
} catch (err: any) {
|
||||||
|
if (err?.code === 'P2002') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Legt eine Mitgliedschaft in der als Standard markierten Gruppe des
|
* Legt eine Mitgliedschaft in der als Standard markierten Gruppe des
|
||||||
* Mandanten an (D-11/D-12/D-13). Existiert keine markierte Standardgruppe,
|
* Mandanten an (D-11/D-12/D-13). Existiert keine markierte Standardgruppe,
|
||||||
|
|||||||
Reference in New Issue
Block a user