feat(quick-260910-das): user-Dienst binden, Linie ziehen, Startsperre entschaerfen

- user.service.ts: findById/update/deactivate/delete bekommen einen
  Pflicht-Mandanten und laufen ueber forTenant(); create/update
  uebersetzen die plattformweite Eindeutigkeitsverletzung (P2002) in eine
  deutsche Konfliktmeldung ohne Halter/Mandant zu nennen; zwei neue
  Methoden (findAllForPlatformAdmin, findByIdForPlatformAdmin) bilden die
  Plattform-Administratorsicht als Schleife ueber alle Mandanten mit je
  einem gebundenen Lesezugriff nach; findByUsername bleibt bewusst
  ungebunden, Kopfkommentar richtiggestellt (Anmeldeweg laeuft seit
  Etappe 1 ueber SECURITY-DEFINER-Funktionen, kein Aufrufer mehr)
- admin-seed.service.ts: Erstanlage-Pruefung bleibt ungebunden (mit
  Begruendung), Erstanlage des Administrators bindet an den unmittelbar
  zuvor angelegten Mandanten (Befund J-Korrektur); P2002 bei der Anlage
  wird wie "Administrator existiert bereits" behandelt statt den Start
  abzubrechen -- jeder andere Fehler bricht weiterhin ab
- user.controller.ts: die vier Aufrufstellen der geaenderten Signaturen
  auf currentUser.tenantId umgestellt (Signatur-Minimalanpassung; die
  Rollenlogik inkl. Plattform-Administratorsicht folgt in Aufgabe 3)
- Zwei-Klienten-Nachweis in beiden Testdateien (Muster
  groups.service.spec.ts), Falsifizierungsnachweis fuer beide Bereiche
  durchgefuehrt und zurueckgenommen (siehe SUMMARY)
- docs/mandantentrennung-zugriffsklassifikation.md: Zwischenstand fuer
  (user.service.ts, user) und (admin-seed.service.ts, user) auf gemischt
  korrigiert, neue Zeile (user.service.ts, tenant) ergaenzt -- volle
  Klassenkorrektur mit Begruendung sowie die vier handgepflegten
  Uebersichtstabellen folgen in Aufgabe 3
- .planning/WINDOWS.md: offener Eintrag fuer die plattformweite
  Eindeutigkeit von username/email (Produktentscheidung fuer Etappe 3)
- 802 Tests gruen (13 neue in user.service.spec.ts, 4 neue in
  admin-seed.service.spec.ts), Typpruefung sauber, Wegwerf-Werkzeug
  meldet weiterhin alle 53 Pruefungen bestanden

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AMASaSxv5QMY7RncqZriRR
This commit is contained in:
2026-09-10 10:27:14 +02:00
parent b848ba6baa
commit 888f66003c
7 changed files with 714 additions and 118 deletions
+90 -20
View File
@@ -2,26 +2,33 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
import { AdminSeedService } from './admin-seed.service';
/**
* AdminSeedService — Reihenfolge der Mandanten-/Admin-Anlage und
* Startup-Reparatur (quick-260805-fok).
* AdminSeedService — Reihenfolge der Mandanten-/Admin-Anlage, Startup-
* Reparatur (quick-260805-fok) UND Bindungsnachweis/Startsperren-
* Entschärfung (260910-das, Aufgabe 2, Befund C/I/J).
*
* Deckt ab: seedAdmin() ruft ensureDefaultGroup NACH tenant.upsert und VOR
* user.create auf; beide frühen Rückkehrpfade (fehlende ENV, Admin existiert
* bereits) überspringen den Benutzer, lassen aber die Reparatur über ALLE
* Mandanten laufen; die Reparatur ist fehlerisoliert je Mandant; ein zweiter
* Bootstrap-Lauf löst keinen zusätzlichen user.create aus.
* Diese Datei hatte bisher KEINE Attrappe für das Bindungshilfsmittel — die
* Erstanlage des Administrators lief nach der Umstellung über
* `forTenant(this.prisma, tenant.id)`, ohne dass ein Test das bemerken
* konnte. Zwei-Klienten-Nachweis nach dem Muster aus
* `groups.service.spec.ts`: `forTenant(prisma, tenantId)` delegiert an
* `prisma.__makeBoundClient(tenantId)`, ein protokollierender Wrapper.
*
* Die Reihenfolgeprüfung läuft über ein gemeinsames Aufruf-Log-Array, in das
* jeder Mock beim Aufruf seinen Namen schiebt (nicht über bloße
* Aufrufzähler) — Mock-invocationCallOrder ist über drei unabhängige vi.fn's
* (tenant.upsert / ensureDefaultGroup / user.create) weniger lesbar als ein
* geteiltes Log.
* Die Reihenfolgeprüfung läuft weiterhin über ein gemeinsames
* Aufruf-Log-Array (`callLog`), in das jeder Mock beim Aufruf seinen Namen
* schiebt — die Ordering-Assertions der ursprünglichen Datei bleiben
* inhaltlich erhalten.
*/
vi.mock('../prisma/prisma-tenant.extension', () => ({
forTenant: vi.fn((prisma: any, tenantId: string) => prisma.__makeBoundClient(tenantId)),
}));
describe('AdminSeedService', () => {
let prisma: any;
let configService: any;
let groupsService: any;
let callLog: string[];
let boundCallLog: { tenantId: string; model: string; method: string }[];
let userCreateImpl: (args: any) => Promise<any>;
let service: AdminSeedService;
const tenant = { id: 't-default', slug: 'default' };
@@ -34,6 +41,11 @@ describe('AdminSeedService', () => {
beforeEach(() => {
vi.clearAllMocks();
callLog = [];
boundCallLog = [];
userCreateImpl = async () => {
callLog.push('user.create');
return { id: 'u1' };
};
configService = {
get: vi.fn((key: string) => envValues[key]),
@@ -45,10 +57,6 @@ describe('AdminSeedService', () => {
callLog.push('user.findUnique');
return null;
}),
create: vi.fn(async () => {
callLog.push('user.create');
return { id: 'u1' };
}),
},
tenant: {
upsert: vi.fn(async () => {
@@ -60,6 +68,21 @@ describe('AdminSeedService', () => {
return [tenant];
}),
},
__setUserCreateImpl(fn: (args: any) => Promise<any>) {
userCreateImpl = fn;
},
__makeBoundClient(tenantId: string) {
return {
__isBoundClient: true,
__tenantId: tenantId,
user: {
create: async (args: any) => {
boundCallLog.push({ tenantId, model: 'user', method: 'create' });
return userCreateImpl(args);
},
},
};
},
};
groupsService = {
@@ -93,7 +116,7 @@ describe('AdminSeedService', () => {
await service.onApplicationBootstrap();
expect(prisma.tenant.upsert).not.toHaveBeenCalled();
expect(prisma.user.create).not.toHaveBeenCalled();
expect(callLog).not.toContain('user.create');
expect(prisma.tenant.findMany).toHaveBeenCalledTimes(1);
expect(groupsService.ensureDefaultGroup).toHaveBeenCalledWith(tenant.id);
});
@@ -104,7 +127,7 @@ describe('AdminSeedService', () => {
await service.onApplicationBootstrap();
expect(prisma.tenant.upsert).not.toHaveBeenCalled();
expect(prisma.user.create).not.toHaveBeenCalled();
expect(callLog).not.toContain('user.create');
expect(prisma.tenant.findMany).toHaveBeenCalledTimes(1);
expect(groupsService.ensureDefaultGroup).toHaveBeenCalledWith(tenant.id);
});
@@ -141,7 +164,7 @@ describe('AdminSeedService', () => {
it('ein zweiter onApplicationBootstrap-Lauf ruft ensureDefaultGroup erneut auf, loest aber keinen zusaetzlichen user.create aus', async () => {
await service.onApplicationBootstrap();
expect(prisma.user.create).toHaveBeenCalledTimes(1);
expect(callLog.filter((c) => c === 'user.create')).toHaveLength(1);
// Erster Lauf: seedAdmin() ruft ensureDefaultGroup einmal vor
// user.create auf, die Reparatur einmal danach fuer denselben Mandanten.
expect(groupsService.ensureDefaultGroup).toHaveBeenCalledTimes(2);
@@ -152,7 +175,54 @@ describe('AdminSeedService', () => {
prisma.user.findUnique = vi.fn(async () => ({ id: 'u1' }));
await service.onApplicationBootstrap();
expect(prisma.user.create).toHaveBeenCalledTimes(1);
expect(callLog.filter((c) => c === 'user.create')).toHaveLength(1);
expect(groupsService.ensureDefaultGroup).toHaveBeenCalledTimes(3);
});
it('Test 9: die Erstanlage-Pruefung steht NICHT im Bindungsprotokoll, die Erstanlage des Administrators dagegen steht dort mit der Kennung des unmittelbar zuvor angelegten Mandanten', async () => {
await service.onApplicationBootstrap();
expect(
boundCallLog.some((c) => c.model === 'user' && c.method === 'findUnique'),
).toBe(false);
expect(boundCallLog).toContainEqual({
tenantId: tenant.id,
model: 'user',
method: 'create',
});
});
it('Test 10: liefert die Erstanlage-Pruefung nichts, waehrend das Anlegen an der plattformweiten Eindeutigkeit scheitert, entsteht KEIN Startabbruch — der Dienst behandelt das wie "Administrator existiert bereits", protokolliert und laeuft weiter', async () => {
prisma.__setUserCreateImpl(async () => {
const err: any = new Error('Unique constraint failed on the fields: (`username`)');
err.code = 'P2002';
throw err;
});
await expect(service.onApplicationBootstrap()).resolves.not.toThrow();
// Die Reparatur laeuft trotz der abgefangenen Kollision weiter:
expect(groupsService.ensureDefaultGroup).toHaveBeenCalledWith(tenant.id);
});
it('Test 11: jeder ANDERE Fehler beim Anlegen bricht den Start weiterhin ab — die Absicht des Dateikopfs bleibt erhalten', async () => {
prisma.__setUserCreateImpl(async () => {
throw new Error('connection refused');
});
await expect(service.onApplicationBootstrap()).rejects.toThrow('connection refused');
});
it('Test 12: die beiden Zugriffe auf die Mandantentabelle stehen NICHT im Bindungsprotokoll, und die Reparaturschleife ruft die Standardgruppen-Sicherung weiterhin je Mandant mit dessen Kennung auf', async () => {
const otherTenant = { id: 't-other', slug: 'other' };
prisma.tenant.findMany = vi.fn(async () => {
callLog.push('tenant.findMany');
return [tenant, otherTenant];
});
await service.onApplicationBootstrap();
expect(boundCallLog.some((c) => c.model === 'tenant')).toBe(false);
expect(groupsService.ensureDefaultGroup).toHaveBeenCalledWith(tenant.id);
expect(groupsService.ensureDefaultGroup).toHaveBeenCalledWith(otherTenant.id);
});
});