Files
tessera-ctl/apps/api/src/groups/groups.service.spec.ts
T
schalli 7f08b27eea feat(jts-02): groups.service.ts binden, Transaktionen tragfaehig machen, Absicherung sehend machen
prisma-tenant.extension.ts bekommt withTenantTransaction(prisma, tenantId, fn)
— die interaktive Transaktion auf dem UNgebundenen Client mit set_config als
erster Anweisung direkt auf tx, die in Aufgabe 1 als einzige der drei
gemessenen Formen sowohl die Einzelmessung als auch eine Lastprobe unter
echter Nebenlaeufigkeit bestand (die Array-Form auf dem gebundenen Client
verteilt jede Operation auf eine eigene Teiltransaktion; die interaktive Form
auf dem gebundenen Client brach unter 40 parallelen Aufrufen mit P2028 ab).

rls-access-inventory.spec.ts bekommt eine dritte Erkennung fuer
Modellzugriffe ueber den Rueckgabeparameter einer interaktiven Transaktion
(zwei Formen: direkter Empfaenger.$transaction(async...) und das neue
Hilfsmittel withTenantTransaction(...)) — macht das Paar
(groups.service.ts, tenantModuleActivation) erstmals sichtbar, das bislang
keine Pruefung dieses Projekts je gesehen hat.

groups.service.ts: alle zwoelf Methoden inklusive der drei Transaktionen
(update() isDefault:true, reassignDefaultBeforeDelete(), ensureDefaultGroup())
laufen jetzt ueber den Mandantenkontext. Zaehler und Transaktion in
ensureDefaultGroup() sind gemeinsam gebunden (T-JTS-05). addUserToDefaultGroup()
prueft neu, dass der Zielbenutzer zum Mandanten gehoert (Befund E, T-JTS-02) —
die Regel auf GroupMembership prueft nachweislich nur die Gruppenseite.

groups.service.spec.ts bekommt zwei unterscheidbare Clients ueber demselben
Speicher-Fake (Muster aus 260909-ipc, auf die interaktive Form uebertragen)
und 13 neue Bindungsnachweise; alle 42 Bestandstests bleiben gruen.
Klassifikationsdokument nachgezogen. 737 Tests und die Typpruefung gruen.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AMASaSxv5QMY7RncqZriRR
2026-09-09 15:01:24 +02:00

1085 lines
44 KiB
TypeScript

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<string, any>();
const memberships = new Map<string, any>();
const grants = new Map<string, any>();
const users = new Map<string, any>();
const activations = new Map<string, any>();
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 `<tenantId>.<model>.<method>`
* 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([]);
});
});