import { BadRequestException, ConflictException, NotFoundException } from '@nestjs/common'; import { MembershipSource } from '@prisma/client'; import { describe, expect, it, vi } from 'vitest'; import { DEFAULT_GROUP_NAME, GroupsService } from './groups.service'; /** * GroupsService.spec — TDD-RED-first Beweis für PERM-01 (D-13/D-17/D-19) * und die tenantId-Ownership-Invariante (T-15-02/T-15-12). * * Hand-rolled In-Memory-Fake im Stil von tender-saved-search.service.spec.ts: * keine Live-DB, simuliert P2002 (Unique-Verletzung) und P2025 * (Record-not-found bei einem zweiten remove()-Aufruf) exakt wie ein * echter Postgres-Client via Prisma-Fehlercodes. * * Bindung an forTenant()/withTenantTransaction() (260909-jts, Befund C): * anders als bei ldap-config.service.spec.ts (einfacher Identitaets-Mock * `forTenant: vi.fn((p) => p)`) braucht diese Datei einen Mock, der den * gebundenen Client als ZWEITES, von `prisma` UNTERSCHEIDBARES Objekt ueber * DEMSELBEN Speicher liefert — sonst waeren ein Aufruf ueber den * ungebundenen Fake und ein Aufruf ueber den (mit reiner Identitaet) * "gebundenen" Client nicht auseinanderzuhalten, und ein vergessener * Bindungsaufruf faellt in keinem Test auf. `makeFakePrisma()` bekommt dafuer * `__makeBoundClient(tenantId)` (liefert je Modell einen protokollierenden * Wrapper um dieselben Maps) und `__withTenantTransaction(tenantId, fn)` * (reicht denselben gebundenen Client als Transaktionsparameter durch, * Muster aus 260909-ipc uebertragen auf die interaktive Form dieses * Bereichs). `forTenant`/`withTenantTransaction` selbst werden gemockt, * damit der Fake nicht durch die echte `$extends`-Implementierung muss. */ vi.mock('../prisma/prisma-tenant.extension', () => ({ forTenant: vi.fn((prisma: any, tenantId: string) => prisma.__makeBoundClient(tenantId)), withTenantTransaction: vi.fn((prisma: any, tenantId: string, fn: (tx: any) => any) => prisma.__withTenantTransaction(tenantId, fn), ), })); function makeFakePrisma() { const groups = new Map(); const memberships = new Map(); const grants = new Map(); const users = new Map(); const activations = new Map(); let groupCounter = 0; let membershipCounter = 0; let grantCounter = 0; const boundCallLog: { tenantId: string; model: string; method: string }[] = []; function findGroupByTenantAndName(tenantId: string, name: string, excludeId?: string) { return Array.from(groups.values()).find( (g) => g.tenantId === tenantId && g.name === name && g.id !== excludeId, ); } function findDefaultGroup(tenantId: string) { return Array.from(groups.values()).find((g) => g.tenantId === tenantId && g.isDefault); } function throwUnique(): never { const err: any = new Error('Unique constraint failed'); err.code = 'P2002'; throw err; } function throwNotFound(): never { const err: any = new Error('Record to update/delete not found'); err.code = 'P2025'; throw err; } const fake: any = { __seedUser(user: { id: string; tenantId: string }) { users.set(user.id, user); }, __seedGrant(grant: { id: string; groupId: string }) { grants.set(grant.id, grant); }, __seedActivation(activation: { id: string; tenantId: string; moduleId: string; isActive: boolean; }) { activations.set(activation.id, activation); }, group: { count: async ({ where }: any) => Array.from(groups.values()).filter((g) => g.tenantId === where.tenantId).length, findMany: async ({ where, include }: any) => { let rows: any[] = Array.from(groups.values()).filter( (g) => g.tenantId === where.tenantId, ); rows = [...rows].sort((a, b) => a.name.localeCompare(b.name)); if (include?._count) { rows = rows.map((g) => ({ ...g, _count: { memberships: Array.from(memberships.values()).filter( (m) => m.groupId === g.id, ).length, }, })); } return rows; }, 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(); if (data.isDefault === true && findDefaultGroup(data.tenantId)) throwUnique(); groupCounter += 1; const record = { id: `g-${groupCounter}`, ldapDn: null, internalName: null, ldapObjectGuid: null, isDefault: false, createdAt: new Date(), updatedAt: new Date(), ...data, }; groups.set(record.id, record); return record; }, update: async ({ where, data }: any) => { const existing = groups.get(where.id); if (!existing) throwNotFound(); if (data.name && findGroupByTenantAndName(existing.tenantId, data.name, existing.id)) { throwUnique(); } const record = { ...existing, ...data, updatedAt: new Date() }; groups.set(where.id, record); return record; }, updateMany: async ({ where, data }: any) => { let count = 0; for (const g of groups.values()) { if ( g.tenantId === where.tenantId && (where.isDefault === undefined || g.isDefault === where.isDefault) ) { Object.assign(g, data); count += 1; } } return { count }; }, delete: async ({ where }: any) => { const existing = groups.get(where.id); if (!existing) throwNotFound(); groups.delete(where.id); return existing; }, }, groupMembership: { findMany: async ({ where }: any) => Array.from(memberships.values()).filter((m) => m.groupId === where.groupId), createMany: async ({ data, skipDuplicates }: any) => { let count = 0; for (const item of data) { const exists = Array.from(memberships.values()).find( (m) => m.groupId === item.groupId && m.userId === item.userId, ); if (exists) { if (skipDuplicates) continue; throwUnique(); } membershipCounter += 1; memberships.set(`m-${membershipCounter}`, { id: `m-${membershipCounter}`, createdAt: new Date(), ...item, }); count += 1; } return { count }; }, deleteMany: async ({ where }: any) => { let count = 0; for (const [key, m] of memberships.entries()) { if ( m.groupId === where.groupId && m.userId === where.userId && (where.source === undefined || m.source === where.source) ) { memberships.delete(key); count += 1; } } return { count }; }, count: async ({ where }: any) => 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: { count: async ({ where }: any) => 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: { findMany: async ({ where }: any) => { if (where?.id?.in) { const ids: string[] = where.id.in; return Array.from(users.values()).filter( (u) => ids.includes(u.id) && u.tenantId === where.tenantId, ); } return Array.from(users.values()).filter((u) => u.tenantId === where.tenantId); }, findFirst: async ({ where }: any) => { return ( Array.from(users.values()).find( (u) => u.id === where.id && u.tenantId === where.tenantId, ) ?? null ); }, }, $transaction: async (opsOrFn: any) => { if (typeof opsOrFn === 'function') { return opsOrFn(fake); } return Promise.all(opsOrFn); }, // --- Bindungsnachweis (260909-jts, Befund C) --------------------------- __boundCallLog: boundCallLog, __makeBoundClient(tenantId: string) { const bound: any = { __isBoundClient: true, __tenantId: tenantId }; for (const modelName of BOUND_MODEL_NAMES) { const model = fake[modelName]; const wrapped: any = {}; for (const method of Object.keys(model)) { wrapped[method] = async (...args: any[]) => { boundCallLog.push({ tenantId, model: modelName, method }); return model[method](...args); }; } bound[modelName] = wrapped; } return bound; }, __withTenantTransaction(tenantId: string, fn: (tx: any) => any) { boundCallLog.push({ tenantId, model: '$transaction', method: 'withTenantTransaction' }); return fn(fake.__makeBoundClient(tenantId)); }, }; return fake; } /** Modelle, die `__makeBoundClient()` je Aufruf mit einem eigenen, das * Herkunfts-Tenant protokollierenden Wrapper versieht. */ const BOUND_MODEL_NAMES = ['group', 'groupMembership', 'moduleGrant', 'tenantModuleActivation', 'user']; /** * Bindungsnachweis: mindestens ein Aufruf von `..` * lief ueber den gebundenen Client (nicht ueber den rohen, ungebundenen * Fake). Ein vergessener `forTenant()`/`withTenantTransaction()`-Aufruf * hinterlaesst hier KEINEN Eintrag und laesst den Test fehlschlagen. */ function expectBoundCall(prisma: any, tenantId: string, model: string, method: string) { const found = prisma.__boundCallLog.some( (c: any) => c.tenantId === tenantId && c.model === model && c.method === method, ); expect( found, `erwarteter gebundener Aufruf ${model}.${method}(tenant=${tenantId}) fehlt im Protokoll: ${JSON.stringify(prisma.__boundCallLog)}`, ).toBe(true); } describe('GroupsService', () => { // --- listForTenant ------------------------------------------------------ it('listForTenant() liefert die Gruppen nach name aufsteigend, mit memberCount', async () => { const prisma = makeFakePrisma(); const service = new GroupsService(prisma as any); await service.create('t1', { name: 'Zebra' }); const alpha = await service.create('t1', { name: 'Alpha' }); prisma.__seedUser({ id: 'u1', tenantId: 't1' }); await service.addMembers('t1', alpha.id, ['u1']); const result = await service.listForTenant('t1'); expect(result.map((g) => g.name)).toEqual(['Alpha', 'Zebra']); expect(result[0].memberCount).toBe(1); expect(result[1].memberCount).toBe(0); }); it('listForTenant() eines Mandanten ohne Gruppen liefert ein leeres Array', async () => { const prisma = makeFakePrisma(); const service = new GroupsService(prisma as any); expect(await service.listForTenant('t-leer')).toEqual([]); }); // --- create -------------------------------------------------------------- it('create() trimmt führenden und abschließenden Leerraum', async () => { const prisma = makeFakePrisma(); const service = new GroupsService(prisma as any); const result = await service.create('t1', { name: ' Vertrieb ' }); expect(result.name).toBe('Vertrieb'); }); it('create() mit leerem oder nur aus Leerzeichen bestehendem Namen wirft BadRequestException', async () => { const prisma = makeFakePrisma(); const service = new GroupsService(prisma as any); await expect(service.create('t1', { name: '' })).rejects.toBeInstanceOf( BadRequestException, ); await expect(service.create('t1', { name: ' ' })).rejects.toBeInstanceOf( BadRequestException, ); }); it('create() mit einem im Mandanten bereits vergebenen Namen wirft ConflictException (P2002)', async () => { const prisma = makeFakePrisma(); const service = new GroupsService(prisma as any); await service.create('t1', { name: 'Vertrieb' }); await expect(service.create('t1', { name: 'Vertrieb' })).rejects.toBeInstanceOf( ConflictException, ); }); it('create() mit demselben Namen in einem anderen Mandanten ist erfolgreich', async () => { const prisma = makeFakePrisma(); const service = new GroupsService(prisma as any); await service.create('t1', { name: 'Vertrieb' }); const result = await service.create('t2', { name: 'Vertrieb' }); expect(result.tenantId).toBe('t2'); }); it('encoding/PERM-01: Gruppennamen werden nicht normalisiert oder kleingeschrieben — "Vertrieb" und "vertrieb" sind zwei Gruppen', async () => { const prisma = makeFakePrisma(); const service = new GroupsService(prisma as any); await service.create('t1', { name: 'Vertrieb' }); const lower = await service.create('t1', { name: 'vertrieb' }); expect(lower.name).toBe('vertrieb'); }); // --- update ---------------------------------------------------------------- it('update() benennt um; eine ID eines fremden Mandanten wirft NotFoundException', async () => { const prisma = makeFakePrisma(); const service = new GroupsService(prisma as any); const group = await service.create('t1', { name: 'Alt' }); const updated = await service.update('t1', group.id, { name: 'Neu' }); expect(updated.name).toBe('Neu'); await expect( service.update('t-fremd', group.id, { name: 'Uebernahme' }), ).rejects.toBeInstanceOf(NotFoundException); }); it('update() mit isDefault:true setzt die Markierung und entfernt sie von jeder anderen Gruppe desselben Mandanten', 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', a.id, { isDefault: true }); const updatedB = await service.update('t1', b.id, { isDefault: true }); expect(updatedB.isDefault).toBe(true); const list = await service.listForTenant('t1'); const groupA = list.find((g) => g.id === a.id)!; expect(groupA.isDefault).toBe(false); }); it('update() mit isDefault:false schaltet die Markierung ab, ohne sie irgendwo anders zu setzen', async () => { const prisma = makeFakePrisma(); const service = new GroupsService(prisma as any); const a = await service.create('t1', { name: 'A' }); await service.update('t1', a.id, { isDefault: true }); const result = await service.update('t1', a.id, { isDefault: false }); expect(result.isDefault).toBe(false); }); // D-07: update() bietet seit diesem Plan absichtlich keinen ldapDn-Parameter // mehr — eine lokal angelegte Gruppe kann nicht mehr nachträglich an eine // AD-Gruppe gebunden werden, und eine bestehende Bindung kann nicht mehr // über diesen Weg gelöst werden. Der frühere Test dafür entfällt ersatzlos. // --- update() — Namenssperre und interner Name (D-03/D-04) --------------- describe('GroupsService.update — Namenssperre und interner Name (D-03/D-04)', () => { it('lehnt name fuer eine importierte Gruppe (gesetzter ldapObjectGuid) mit BadRequestException ab', async () => { const prisma = makeFakePrisma(); const service = new GroupsService(prisma as any); const group = await service.create('t1', { name: 'Vertrieb' }); await (prisma as any).group.update({ where: { id: group.id }, data: { ldapObjectGuid: 'abc123' }, }); await expect( service.update('t1', group.id, { name: 'Umbenannt' }), ).rejects.toBeInstanceOf(BadRequestException); const list = await service.listForTenant('t1'); expect(list.find((g) => g.id === group.id)!.name).toBe('Vertrieb'); }); it('erlaubt name fuer eine Gruppe ohne gesetzten ldapObjectGuid — unveraendertes Verhalten', async () => { const prisma = makeFakePrisma(); const service = new GroupsService(prisma as any); const group = await service.create('t1', { name: 'Alt' }); const result = await service.update('t1', group.id, { name: 'Neu' }); expect(result.name).toBe('Neu'); }); it('lehnt name fuer eine Alt-Bindung (ldapDn gesetzt, ldapObjectGuid noch null) mit BadRequestException ab (WR-01, 16-REVIEW.md)', async () => { // Eine Gruppe aus der alten Plan-15-06-Bindung: ldapDn ist gesetzt, // aber der neue Rekonziliations-Schritt (syncBoundGroupsForTenant, // Legacy-Backfill) hat sie noch nicht durchlaufen — ldapObjectGuid // ist deshalb noch null. Ohne die ldapDn-Bedingung wuerde dieser // direkte PATCH-Aufruf durchgehen, obwohl GroupFormModal.tsx dieselbe // Gruppe bereits als importiert sperrt (isImported = ldapDn != null). const prisma = makeFakePrisma(); const service = new GroupsService(prisma as any); const group = await service.create('t1', { name: 'Vertrieb' }); await (prisma as any).group.update({ where: { id: group.id }, data: { ldapDn: 'cn=Vertrieb,dc=example,dc=com', ldapObjectGuid: null }, }); await expect( service.update('t1', group.id, { name: 'Umbenannt' }), ).rejects.toBeInstanceOf(BadRequestException); const list = await service.listForTenant('t1'); expect(list.find((g) => g.id === group.id)!.name).toBe('Vertrieb'); }); it('setzt internalName auf einer importierten Gruppe, name bleibt unangetastet', async () => { const prisma = makeFakePrisma(); const service = new GroupsService(prisma as any); const group = await service.create('t1', { name: 'AD-Name' }); await (prisma as any).group.update({ where: { id: group.id }, data: { ldapObjectGuid: 'abc123' }, }); const result = await service.update('t1', group.id, { internalName: 'Vertrieb' }); expect(result.internalName).toBe('Vertrieb'); expect(result.name).toBe('AD-Name'); }); it('normalisiert internalName aus reinen Leerzeichen auf null statt auf einen leeren Anzeigenamen', 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, { internalName: 'Vertrieb' }); const result = await service.update('t1', group.id, { internalName: ' ' }); expect(result.internalName).toBeNull(); }); it('setzt internalName:null direkt auf null', 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, { internalName: 'Vertrieb' }); const result = await service.update('t1', group.id, { internalName: null }); expect(result.internalName).toBeNull(); }); it('ein zweites PATCH mit identischem internalName aendert nichts und wirft nicht', 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, { internalName: 'Vertrieb' }); const result = await service.update('t1', group.id, { internalName: 'Vertrieb' }); expect(result.internalName).toBe('Vertrieb'); }); it('erlaubt internalName auf einer lokalen Gruppe (keine Sperre auf diesem Feld)', async () => { const prisma = makeFakePrisma(); const service = new GroupsService(prisma as any); const group = await service.create('t1', { name: 'Lokal' }); const result = await service.update('t1', group.id, { internalName: 'Anzeigename' }); expect(result.internalName).toBe('Anzeigename'); }); it('speichert und liefert Unicode-Namen unveraendert, ueber den getrimmten Inhalt entschieden statt ueber Truthiness', async () => { const prisma = makeFakePrisma(); const service = new GroupsService(prisma as any); const group = await service.create('t1', { name: 'A' }); const result = await service.update('t1', group.id, { internalName: ' Vértriéb 部門 ' }); expect(result.internalName).toBe('Vértriéb 部門'); }); it('listForTenant() liefert internalName zusaetzlich zu name', async () => { const prisma = makeFakePrisma(); const service = new GroupsService(prisma as any); const group = await service.create('t1', { name: 'AD-Name' }); await service.update('t1', group.id, { internalName: 'Vertrieb' }); const list = await service.listForTenant('t1'); expect(list.find((g) => g.id === group.id)).toMatchObject({ name: 'AD-Name', internalName: 'Vertrieb', }); }); }); // --- getImpact --------------------------------------------------------- it('getImpact() liefert { memberCount, grantCount }; eine ID eines fremden Mandanten wirft NotFoundException', async () => { const prisma = makeFakePrisma(); const service = new GroupsService(prisma as any); const group = await service.create('t1', { name: 'A' }); prisma.__seedUser({ id: 'u1', tenantId: 't1' }); await service.addMembers('t1', group.id, ['u1']); prisma.__seedGrant({ id: 'grant-1', groupId: group.id }); const impact = await service.getImpact('t1', group.id); expect(impact).toEqual({ memberCount: 1, grantCount: 1 }); await expect(service.getImpact('t-fremd', group.id)).rejects.toBeInstanceOf( NotFoundException, ); }); // --- remove -------------------------------------------------------------- it('remove() löscht die Gruppe; ein zweiter Aufruf auf dieselbe ID wirft NotFoundException statt eines unbehandelten Prisma-Fehlers', async () => { const prisma = makeFakePrisma(); const service = new GroupsService(prisma as any); const group = await service.create('t1', { name: 'A' }); await service.remove('t1', group.id); await expect(service.remove('t1', group.id)).rejects.toBeInstanceOf(NotFoundException); }); // --- addMembers / removeMember ------------------------------------------- it('addMembers() legt fehlende Mitgliedschaften mit source:MANUAL an und ist für bereits vorhandene folgenlos', async () => { const prisma = makeFakePrisma(); const service = new GroupsService(prisma as any); const group = await service.create('t1', { name: 'A' }); prisma.__seedUser({ id: 'u1', tenantId: 't1' }); const first = await service.addMembers('t1', group.id, ['u1']); expect(first.added).toBe(1); const second = await service.addMembers('t1', group.id, ['u1']); expect(second.added).toBe(0); const members = await service.listMembers('t1', group.id); expect(members).toHaveLength(1); expect(members[0].source).toBe(MembershipSource.MANUAL); }); it('addMembers() mit einer userId eines fremden Mandanten überspringt diese ID', async () => { const prisma = makeFakePrisma(); const service = new GroupsService(prisma as any); const group = await service.create('t1', { name: 'A' }); prisma.__seedUser({ id: 'u-fremd', tenantId: 't2' }); const result = await service.addMembers('t1', group.id, ['u-fremd']); expect(result.added).toBe(0); expect(await service.listMembers('t1', group.id)).toEqual([]); }); it('removeMember() entfernt ausschließlich Mitgliedschaften mit source:MANUAL und lässt LDAP-Mitgliedschaften unberührt', async () => { const prisma = makeFakePrisma(); const service = new GroupsService(prisma as any); const group = await service.create('t1', { name: 'A' }); prisma.__seedUser({ id: 'u-manual', tenantId: 't1' }); prisma.__seedUser({ id: 'u-ldap', tenantId: 't1' }); await service.addMembers('t1', group.id, ['u-manual']); // LDAP-Mitgliedschaft direkt im Fake seeden (kein Service-Pfad dafür in diesem Plan) await (prisma as any).groupMembership.createMany({ data: [{ groupId: group.id, userId: 'u-ldap', source: MembershipSource.LDAP }], skipDuplicates: true, }); await service.removeMember('t1', group.id, 'u-manual'); await service.removeMember('t1', group.id, 'u-ldap'); const remaining = await service.listMembers('t1', group.id); expect(remaining).toHaveLength(1); expect(remaining[0].userId).toBe('u-ldap'); }); it('removeMember() für ein nicht vorhandenes Mitglied ist folgenlos und wirft nicht', async () => { const prisma = makeFakePrisma(); const service = new GroupsService(prisma as any); const group = await service.create('t1', { name: 'A' }); await expect(service.removeMember('t1', group.id, 'kein-mitglied')).resolves.not.toThrow(); }); // --- addUserToDefaultGroup ------------------------------------------------- it('addUserToDefaultGroup() legt eine Mitgliedschaft in der Gruppe mit isDefault:true an', 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 }); // Befund E/T-JTS-02 (260909-jts): die Methode prueft seither, dass der // Zielbenutzer zum Mandanten gehoert — ohne einen seed hier waere // dieser Test wieder die Luecke, die er einst unbemerkt durchliess. prisma.__seedUser({ id: 'u1', tenantId: 't1' }); await service.addUserToDefaultGroup('t1', 'u1'); const members = await service.listMembers('t1', group.id); expect(members.map((m: any) => m.userId)).toEqual(['u1']); }); it('addUserToDefaultGroup() bei einem Mandanten ohne markierte Standardgruppe tut nichts und wirft nicht', async () => { const prisma = makeFakePrisma(); const service = new GroupsService(prisma as any); await service.create('t1', { name: 'Keine Standardgruppe' }); 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([]); }); }); // --- 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); }); }); }); // --- Bindung an forTenant()/withTenantTransaction() (260909-jts, Aufgabe 2) --- describe('GroupsService — Bindung an forTenant()/withTenantTransaction() (260909-jts)', () => { it('listForTenant() bindet group.findMany an den uebergebenen Mandanten', async () => { const prisma = makeFakePrisma(); const service = new GroupsService(prisma as any); await service.listForTenant('t1'); expectBoundCall(prisma, 't1', 'group', 'findMany'); }); it('create() bindet group.create an den uebergebenen Mandanten', async () => { const prisma = makeFakePrisma(); const service = new GroupsService(prisma as any); await service.create('t1', { name: 'A' }); expectBoundCall(prisma, 't1', 'group', 'create'); }); it('update() (Name/internalName, kein isDefault) bindet findOwned und group.update an den uebergebenen Mandanten', 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, { internalName: 'X' }); expectBoundCall(prisma, 't1', 'group', 'findFirst'); expectBoundCall(prisma, 't1', 'group', 'update'); }); it('update() mit isDefault:true laeuft als EINE withTenantTransaction — beide Teilschritte (updateMany, update) landen am gebundenen Client', 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 }); expectBoundCall(prisma, 't1', '$transaction', 'withTenantTransaction'); expectBoundCall(prisma, 't1', 'group', 'updateMany'); expectBoundCall(prisma, 't1', 'group', 'update'); }); it('getImpact() bindet findOwned, groupMembership.count und moduleGrant.count an den uebergebenen Mandanten', async () => { const prisma = makeFakePrisma(); const service = new GroupsService(prisma as any); const group = await service.create('t1', { name: 'A' }); await service.getImpact('t1', group.id); expectBoundCall(prisma, 't1', 'group', 'findFirst'); expectBoundCall(prisma, 't1', 'groupMembership', 'count'); expectBoundCall(prisma, 't1', 'moduleGrant', 'count'); }); it('remove() bindet findOwned und group.delete an den uebergebenen Mandanten', async () => { const prisma = makeFakePrisma(); const service = new GroupsService(prisma as any); const group = await service.create('t1', { name: 'A' }); await service.remove('t1', group.id); expectBoundCall(prisma, 't1', 'group', 'findFirst'); expectBoundCall(prisma, 't1', 'group', 'delete'); }); it('listMembers() bindet findOwned und groupMembership.findMany an den uebergebenen Mandanten', async () => { const prisma = makeFakePrisma(); const service = new GroupsService(prisma as any); const group = await service.create('t1', { name: 'A' }); await service.listMembers('t1', group.id); expectBoundCall(prisma, 't1', 'group', 'findFirst'); expectBoundCall(prisma, 't1', 'groupMembership', 'findMany'); }); it('addMembers() bindet findOwned, user.findMany und groupMembership.createMany an den uebergebenen Mandanten', async () => { const prisma = makeFakePrisma(); const service = new GroupsService(prisma as any); const group = await service.create('t1', { name: 'A' }); prisma.__seedUser({ id: 'u1', tenantId: 't1' }); await service.addMembers('t1', group.id, ['u1']); expectBoundCall(prisma, 't1', 'group', 'findFirst'); expectBoundCall(prisma, 't1', 'user', 'findMany'); expectBoundCall(prisma, 't1', 'groupMembership', 'createMany'); }); it('removeMember() bindet findOwned und groupMembership.deleteMany an den uebergebenen Mandanten', async () => { const prisma = makeFakePrisma(); const service = new GroupsService(prisma as any); const group = await service.create('t1', { name: 'A' }); await service.removeMember('t1', group.id, 'u1'); expectBoundCall(prisma, 't1', 'group', 'findFirst'); expectBoundCall(prisma, 't1', 'groupMembership', 'deleteMany'); }); it('ensureDefaultGroup() bindet den Zaehler UND alle vier Schritte der Transaktion an denselben Mandanten (T-JTS-05)', async () => { const prisma = makeFakePrisma(); const service = new GroupsService(prisma as any); prisma.__seedUser({ id: 'u1', tenantId: 't1' }); prisma.__seedActivation({ id: 'a1', tenantId: 't1', moduleId: 'mod-a', isActive: true }); await service.ensureDefaultGroup('t1'); expectBoundCall(prisma, 't1', 'group', 'count'); expectBoundCall(prisma, 't1', '$transaction', 'withTenantTransaction'); expectBoundCall(prisma, 't1', 'group', 'create'); expectBoundCall(prisma, 't1', 'user', 'findMany'); expectBoundCall(prisma, 't1', 'groupMembership', 'createMany'); expectBoundCall(prisma, 't1', 'tenantModuleActivation', 'findMany'); expectBoundCall(prisma, 't1', 'moduleGrant', 'createMany'); }); it('reassignDefaultBeforeDelete() bindet die drei Lesezugriffe UND die Transaktion an denselben Mandanten', 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 }); await service.reassignDefaultBeforeDelete('t1', toDelete.id); expectBoundCall(prisma, 't1', 'group', 'findFirst'); expectBoundCall(prisma, 't1', '$transaction', 'withTenantTransaction'); expectBoundCall(prisma, 't1', 'group', 'updateMany'); expectBoundCall(prisma, 't1', 'group', 'update'); }); it('addUserToDefaultGroup() bindet group.findFirst und groupMembership.createMany an den uebergebenen Mandanten', 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 }); prisma.__seedUser({ id: 'u1', tenantId: 't1' }); await service.addUserToDefaultGroup('t1', 'u1'); expectBoundCall(prisma, 't1', 'group', 'findFirst'); expectBoundCall(prisma, 't1', 'groupMembership', 'createMany'); }); it('addUserToDefaultGroup() mit einem Zielbenutzer eines fremden Mandanten legt KEINE Mitgliedschaft an und wirft nicht (Befund E, T-JTS-02)', 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 }); prisma.__seedUser({ id: 'u-fremd', tenantId: 't2' }); await expect(service.addUserToDefaultGroup('t1', 'u-fremd')).resolves.not.toThrow(); const members = await service.listMembers('t1', group.id); expect(members).toEqual([]); }); });