feat(260805-fok): GroupsService.ensureDefaultGroup(tenantId)
- Neue Methode ensureDefaultGroup: legt fuer einen Mandanten ohne jede Gruppe die Standardgruppe 'Alle Benutzer' (isDefault:true) an, nimmt alle Bestandsbenutzer als MANUAL-Mitglieder auf und erzeugt Grants fuer alle aktiven Module — derselbe Endzustand wie die drei Backfill-INSERTs der Migration 20260804130130 - Waechter prueft ausschliesslich group.count === 0, niemals die fehlende isDefault-Markierung (D-13) - P2002 aus dem partiellen Index Group_one_default_per_tenant wird abgefangen und liefert null statt zu werfen (Race-Sicherheit) - groups.service.spec.ts: Fake erweitert um group.count, tenantModuleActivation, moduleGrant.findMany/createMany, $transaction mit Callback-Form, plus voller ensureDefaultGroup-Testblock
This commit is contained in:
@@ -18,8 +18,10 @@ function makeFakePrisma() {
|
|||||||
const memberships = new Map<string, any>();
|
const memberships = new Map<string, any>();
|
||||||
const grants = new Map<string, any>();
|
const grants = new Map<string, any>();
|
||||||
const users = new Map<string, any>();
|
const users = new Map<string, any>();
|
||||||
|
const activations = new Map<string, any>();
|
||||||
let groupCounter = 0;
|
let groupCounter = 0;
|
||||||
let membershipCounter = 0;
|
let membershipCounter = 0;
|
||||||
|
let grantCounter = 0;
|
||||||
|
|
||||||
function findGroupByTenantAndName(tenantId: string, name: string, excludeId?: string) {
|
function findGroupByTenantAndName(tenantId: string, name: string, excludeId?: string) {
|
||||||
return Array.from(groups.values()).find(
|
return Array.from(groups.values()).find(
|
||||||
@@ -27,6 +29,10 @@ function makeFakePrisma() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function findDefaultGroup(tenantId: string) {
|
||||||
|
return Array.from(groups.values()).find((g) => g.tenantId === tenantId && g.isDefault);
|
||||||
|
}
|
||||||
|
|
||||||
function throwUnique(): never {
|
function throwUnique(): never {
|
||||||
const err: any = new Error('Unique constraint failed');
|
const err: any = new Error('Unique constraint failed');
|
||||||
err.code = 'P2002';
|
err.code = 'P2002';
|
||||||
@@ -39,14 +45,24 @@ function makeFakePrisma() {
|
|||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
const fake: any = {
|
||||||
__seedUser(user: { id: string; tenantId: string }) {
|
__seedUser(user: { id: string; tenantId: string }) {
|
||||||
users.set(user.id, user);
|
users.set(user.id, user);
|
||||||
},
|
},
|
||||||
__seedGrant(grant: { id: string; groupId: string }) {
|
__seedGrant(grant: { id: string; groupId: string }) {
|
||||||
grants.set(grant.id, grant);
|
grants.set(grant.id, grant);
|
||||||
},
|
},
|
||||||
|
__seedActivation(activation: {
|
||||||
|
id: string;
|
||||||
|
tenantId: string;
|
||||||
|
moduleId: string;
|
||||||
|
isActive: boolean;
|
||||||
|
}) {
|
||||||
|
activations.set(activation.id, activation);
|
||||||
|
},
|
||||||
group: {
|
group: {
|
||||||
|
count: async ({ where }: any) =>
|
||||||
|
Array.from(groups.values()).filter((g) => g.tenantId === where.tenantId).length,
|
||||||
findMany: async ({ where, include }: any) => {
|
findMany: async ({ where, include }: any) => {
|
||||||
let rows: any[] = Array.from(groups.values()).filter(
|
let rows: any[] = Array.from(groups.values()).filter(
|
||||||
(g) => g.tenantId === where.tenantId,
|
(g) => g.tenantId === where.tenantId,
|
||||||
@@ -76,6 +92,7 @@ function makeFakePrisma() {
|
|||||||
},
|
},
|
||||||
create: async ({ data }: any) => {
|
create: async ({ data }: any) => {
|
||||||
if (findGroupByTenantAndName(data.tenantId, data.name)) throwUnique();
|
if (findGroupByTenantAndName(data.tenantId, data.name)) throwUnique();
|
||||||
|
if (data.isDefault === true && findDefaultGroup(data.tenantId)) throwUnique();
|
||||||
groupCounter += 1;
|
groupCounter += 1;
|
||||||
const record = {
|
const record = {
|
||||||
id: `g-${groupCounter}`,
|
id: `g-${groupCounter}`,
|
||||||
@@ -158,20 +175,68 @@ function makeFakePrisma() {
|
|||||||
count: async ({ where }: any) =>
|
count: async ({ where }: any) =>
|
||||||
Array.from(memberships.values()).filter((m) => m.groupId === where.groupId).length,
|
Array.from(memberships.values()).filter((m) => m.groupId === where.groupId).length,
|
||||||
},
|
},
|
||||||
|
tenantModuleActivation: {
|
||||||
|
findMany: async ({ where }: any) =>
|
||||||
|
Array.from(activations.values()).filter(
|
||||||
|
(a) =>
|
||||||
|
a.tenantId === where.tenantId &&
|
||||||
|
(where.isActive === undefined || a.isActive === where.isActive),
|
||||||
|
),
|
||||||
|
},
|
||||||
moduleGrant: {
|
moduleGrant: {
|
||||||
count: async ({ where }: any) =>
|
count: async ({ where }: any) =>
|
||||||
Array.from(grants.values()).filter((g) => g.groupId === where.groupId).length,
|
Array.from(grants.values()).filter((g) => g.groupId === where.groupId).length,
|
||||||
|
findMany: async ({ where }: any) =>
|
||||||
|
Array.from(grants.values()).filter(
|
||||||
|
(g) =>
|
||||||
|
(where?.tenantId === undefined || g.tenantId === where.tenantId) &&
|
||||||
|
(where?.groupId === undefined || g.groupId === where.groupId),
|
||||||
|
),
|
||||||
|
createMany: async ({ data, skipDuplicates }: any) => {
|
||||||
|
let count = 0;
|
||||||
|
for (const item of data) {
|
||||||
|
const exists = Array.from(grants.values()).find(
|
||||||
|
(g) =>
|
||||||
|
g.tenantId === item.tenantId &&
|
||||||
|
g.moduleId === item.moduleId &&
|
||||||
|
g.groupId === item.groupId,
|
||||||
|
);
|
||||||
|
if (exists) {
|
||||||
|
if (skipDuplicates) continue;
|
||||||
|
throwUnique();
|
||||||
|
}
|
||||||
|
grantCounter += 1;
|
||||||
|
grants.set(`grant-${grantCounter}`, {
|
||||||
|
id: `grant-${grantCounter}`,
|
||||||
|
createdAt: new Date(),
|
||||||
|
userId: null,
|
||||||
|
...item,
|
||||||
|
});
|
||||||
|
count += 1;
|
||||||
|
}
|
||||||
|
return { count };
|
||||||
|
},
|
||||||
},
|
},
|
||||||
user: {
|
user: {
|
||||||
findMany: async ({ where }: any) => {
|
findMany: async ({ where }: any) => {
|
||||||
|
if (where?.id?.in) {
|
||||||
const ids: string[] = where.id.in;
|
const ids: string[] = where.id.in;
|
||||||
return Array.from(users.values()).filter(
|
return Array.from(users.values()).filter(
|
||||||
(u) => ids.includes(u.id) && u.tenantId === where.tenantId,
|
(u) => ids.includes(u.id) && u.tenantId === where.tenantId,
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
return Array.from(users.values()).filter((u) => u.tenantId === where.tenantId);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
$transaction: async (ops: Promise<any>[]) => Promise.all(ops),
|
$transaction: async (opsOrFn: any) => {
|
||||||
|
if (typeof opsOrFn === 'function') {
|
||||||
|
return opsOrFn(fake);
|
||||||
|
}
|
||||||
|
return Promise.all(opsOrFn);
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
return fake;
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('GroupsService', () => {
|
describe('GroupsService', () => {
|
||||||
@@ -429,4 +494,118 @@ describe('GroupsService', () => {
|
|||||||
|
|
||||||
await expect(service.addUserToDefaultGroup('t1', 'u1')).resolves.not.toThrow();
|
await expect(service.addUserToDefaultGroup('t1', 'u1')).resolves.not.toThrow();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// --- ensureDefaultGroup ----------------------------------------------------
|
||||||
|
|
||||||
|
describe('ensureDefaultGroup()', () => {
|
||||||
|
it('legt bei einem Mandanten ohne jede Gruppe genau eine Gruppe "Alle Benutzer" mit isDefault:true an', async () => {
|
||||||
|
const prisma = makeFakePrisma();
|
||||||
|
const service = new GroupsService(prisma as any);
|
||||||
|
|
||||||
|
const group = await service.ensureDefaultGroup('t-neu');
|
||||||
|
|
||||||
|
expect(group).not.toBeNull();
|
||||||
|
expect(group!.name).toBe('Alle Benutzer');
|
||||||
|
expect(group!.isDefault).toBe(true);
|
||||||
|
const list = await service.listForTenant('t-neu');
|
||||||
|
expect(list).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('nimmt alle Benutzer DIESES Mandanten als MANUAL-Mitglieder auf; ein Benutzer eines fremden Mandanten wird nicht Mitglied', async () => {
|
||||||
|
const prisma = makeFakePrisma();
|
||||||
|
const service = new GroupsService(prisma as any);
|
||||||
|
prisma.__seedUser({ id: 'u1', tenantId: 't1' });
|
||||||
|
prisma.__seedUser({ id: 'u2', tenantId: 't1' });
|
||||||
|
prisma.__seedUser({ id: 'u-fremd', tenantId: 't2' });
|
||||||
|
|
||||||
|
const group = await service.ensureDefaultGroup('t1');
|
||||||
|
|
||||||
|
const members = await service.listMembers('t1', group!.id);
|
||||||
|
expect(members.map((m: any) => m.userId).sort()).toEqual(['u1', 'u2']);
|
||||||
|
expect(members.every((m: any) => m.source === MembershipSource.MANUAL)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('erzeugt genau einen ModuleGrant je aktiver TenantModuleActivation; isActive:false und fremde Mandanten erzeugen keinen Grant', async () => {
|
||||||
|
const prisma = makeFakePrisma();
|
||||||
|
const service = new GroupsService(prisma as any);
|
||||||
|
prisma.__seedActivation({ id: 'a1', tenantId: 't1', moduleId: 'mod-a', isActive: true });
|
||||||
|
prisma.__seedActivation({ id: 'a2', tenantId: 't1', moduleId: 'mod-b', isActive: false });
|
||||||
|
prisma.__seedActivation({ id: 'a3', tenantId: 't2', moduleId: 'mod-c', isActive: true });
|
||||||
|
|
||||||
|
const group = await service.ensureDefaultGroup('t1');
|
||||||
|
|
||||||
|
const grants = await (prisma as any).moduleGrant.findMany({ where: { tenantId: 't1' } });
|
||||||
|
expect(grants).toHaveLength(1);
|
||||||
|
expect(grants[0]).toMatchObject({
|
||||||
|
tenantId: 't1',
|
||||||
|
moduleId: 'mod-a',
|
||||||
|
groupId: group!.id,
|
||||||
|
userId: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Waechter: ein Mandant mit mindestens einer Gruppe ohne isDefault:true wird NICHT angefasst (D-13)', async () => {
|
||||||
|
const prisma = makeFakePrisma();
|
||||||
|
const service = new GroupsService(prisma as any);
|
||||||
|
await service.create('t1', { name: 'Ohne Markierung' });
|
||||||
|
|
||||||
|
const result = await service.ensureDefaultGroup('t1');
|
||||||
|
|
||||||
|
expect(result).toBeNull();
|
||||||
|
expect(await service.listForTenant('t1')).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Waechter: ein Mandant mit bereits markierter Standardgruppe wird nicht angefasst', async () => {
|
||||||
|
const prisma = makeFakePrisma();
|
||||||
|
const service = new GroupsService(prisma as any);
|
||||||
|
const group = await service.create('t1', { name: 'Alle Benutzer' });
|
||||||
|
await service.update('t1', group.id, { isDefault: true });
|
||||||
|
|
||||||
|
const result = await service.ensureDefaultGroup('t1');
|
||||||
|
|
||||||
|
expect(result).toBeNull();
|
||||||
|
expect(await service.listForTenant('t1')).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ist idempotent: zwei aufeinanderfolgende Aufrufe hinterlassen genau eine Gruppe und genau eine Mitgliedschaft pro Benutzer', async () => {
|
||||||
|
const prisma = makeFakePrisma();
|
||||||
|
const service = new GroupsService(prisma as any);
|
||||||
|
prisma.__seedUser({ id: 'u1', tenantId: 't1' });
|
||||||
|
|
||||||
|
const first = await service.ensureDefaultGroup('t1');
|
||||||
|
const second = await service.ensureDefaultGroup('t1');
|
||||||
|
|
||||||
|
expect(first).not.toBeNull();
|
||||||
|
expect(second).toBeNull();
|
||||||
|
const list = await service.listForTenant('t1');
|
||||||
|
expect(list).toHaveLength(1);
|
||||||
|
expect(list[0].memberCount).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('faengt einen P2002 aus group.create ab (verlorenes Rennen gegen den partiellen Index) und liefert null statt zu werfen', async () => {
|
||||||
|
const prisma = makeFakePrisma();
|
||||||
|
const service = new GroupsService(prisma as any);
|
||||||
|
const existing = await service.create('t1', { name: 'Alle Benutzer' });
|
||||||
|
await service.update('t1', existing.id, { isDefault: true });
|
||||||
|
// group.count wird auf 0 gepatcht, um ein verlorenes Rennen nachzustellen —
|
||||||
|
// die schon markierte Gruppe existiert im Fake weiterhin und liefert beim
|
||||||
|
// Anlageversuch denselben P2002 wie der partielle Index in Postgres.
|
||||||
|
(prisma as any).group.count = async () => 0;
|
||||||
|
|
||||||
|
await expect(service.ensureDefaultGroup('t1')).resolves.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('legt fuer einen Mandanten ganz ohne Benutzer und ohne aktive Module trotzdem die leere Standardgruppe an', async () => {
|
||||||
|
const prisma = makeFakePrisma();
|
||||||
|
const service = new GroupsService(prisma as any);
|
||||||
|
|
||||||
|
const group = await service.ensureDefaultGroup('t-leer');
|
||||||
|
|
||||||
|
expect(group).not.toBeNull();
|
||||||
|
const members = await service.listMembers('t-leer', group!.id);
|
||||||
|
expect(members).toEqual([]);
|
||||||
|
const grants = await (prisma as any).moduleGrant.findMany({ where: { tenantId: 't-leer' } });
|
||||||
|
expect(grants).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -250,6 +250,83 @@ export class GroupsService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stellt für einen Mandanten OHNE JEDE Gruppe denselben Endzustand her,
|
||||||
|
* den die drei Backfill-INSERTs der Migration
|
||||||
|
* 20260804130130_add_groups_and_module_grants pro Mandant herstellen:
|
||||||
|
* eine Gruppe 'Alle Benutzer' (isDefault:true), alle Bestandsbenutzer als
|
||||||
|
* MANUAL-Mitglieder und Grants für alle aktiven Module. Aufrufer:
|
||||||
|
* TenantService.create (frischer Mandant) und
|
||||||
|
* AdminSeedService.ensureDefaultGroupsForAllTenants (Startup-Reparatur
|
||||||
|
* für Installationen, die die Migration ohne Mandanten durchlaufen haben).
|
||||||
|
*
|
||||||
|
* Der Wächter prüft AUSSCHLIESSLICH auf group.count === 0 — niemals auf
|
||||||
|
* das Fehlen der isDefault-Markierung. D-13 erlaubt dem Admin
|
||||||
|
* ausdrücklich, die Markierung abzuhängen oder auf eine andere Gruppe
|
||||||
|
* umzuhängen; ein Mandant mit mindestens einer Gruppe hat diese
|
||||||
|
* Entscheidung bereits getroffen und wird hier nie wieder angefasst.
|
||||||
|
* Rückgabe null bedeutet in jedem Fall "nichts zu tun".
|
||||||
|
*
|
||||||
|
* Race-Sicherheit: zwei gleichzeitige Aufrufe (z.B. Startup-Reparatur und
|
||||||
|
* eine parallele Mandanten-Anlage) können beide group.count === 0 lesen.
|
||||||
|
* Der partielle Unique-Index Group_one_default_per_tenant (15-01) bleibt
|
||||||
|
* der eigentliche Durchsetzungspunkt; hier wird nur der resultierende
|
||||||
|
* P2002 des Verlierers abgefangen und in null übersetzt, statt ihn zu
|
||||||
|
* propagieren.
|
||||||
|
*/
|
||||||
|
async ensureDefaultGroup(tenantId: string) {
|
||||||
|
const existingCount = await this.prisma.group.count({ where: { tenantId } });
|
||||||
|
if (existingCount > 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await this.prisma.$transaction(async (tx) => {
|
||||||
|
const group = await tx.group.create({
|
||||||
|
data: { tenantId, name: 'Alle Benutzer', isDefault: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const users = await tx.user.findMany({
|
||||||
|
where: { tenantId },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (users.length > 0) {
|
||||||
|
await tx.groupMembership.createMany({
|
||||||
|
data: users.map((u) => ({
|
||||||
|
groupId: group.id,
|
||||||
|
userId: u.id,
|
||||||
|
source: MembershipSource.MANUAL,
|
||||||
|
})),
|
||||||
|
skipDuplicates: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const activations = await tx.tenantModuleActivation.findMany({
|
||||||
|
where: { tenantId, isActive: true },
|
||||||
|
select: { moduleId: true },
|
||||||
|
});
|
||||||
|
if (activations.length > 0) {
|
||||||
|
await tx.moduleGrant.createMany({
|
||||||
|
data: activations.map((a) => ({
|
||||||
|
tenantId,
|
||||||
|
moduleId: a.moduleId,
|
||||||
|
groupId: group.id,
|
||||||
|
userId: null,
|
||||||
|
})),
|
||||||
|
skipDuplicates: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return group;
|
||||||
|
});
|
||||||
|
} catch (err: any) {
|
||||||
|
if (err?.code === 'P2002') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
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