7f08b27eea
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
1085 lines
44 KiB
TypeScript
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([]);
|
|
});
|
|
});
|