feat(260805-fok): beide Mandanten-Entstehungspfade verdrahten + Startup-Reparatur

- TenantService.create ruft nach prisma.tenant.create ensureDefaultGroup
  auf; Fehler werden protokolliert, nicht propagiert (Muster aus
  UserService.create)
- tenant.module.ts importiert GroupsModule (keine Zirkularitaet, wie
  UserModule bereits vormacht)
- AdminSeedService.onApplicationBootstrap besteht jetzt aus zwei
  sequenziellen await-Schritten: seedAdmin() (bisheriger Rumpf, plus
  ensureDefaultGroup nach dem Tenant-Upsert und VOR user.create), dann
  ensureDefaultGroupsForAllTenants() als abschliessende Reparatur ueber
  ALLE Mandanten — laeuft unabhaengig von seedAdmin()s fruehen
  Rueckkehrpfaden (fehlende ENV / Admin existiert bereits) und ist je
  Mandant sowie insgesamt try/catch-gekapselt, blockiert den API-Start nie
- Reparatur sitzt bewusst NICHT als eigener onApplicationBootstrap-Hook
  in GroupsModule (Ordering-Falle aus tender-scheduler.service.ts)
- tenant.service.spec.ts, admin-seed.service.spec.ts (neu): Reihenfolge,
  beide fruehen Rueckkehrpfade, Fehlerisolation je Mandant, Idempotenz
  ueber zwei Bootstrap-Laeufe
This commit is contained in:
2026-08-05 11:30:49 +02:00
parent 9d1254cd78
commit 0d7d8a597e
5 changed files with 318 additions and 3 deletions
@@ -0,0 +1,158 @@
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).
*
* 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.
*
* 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.
*/
describe('AdminSeedService', () => {
let prisma: any;
let configService: any;
let groupsService: any;
let callLog: string[];
let service: AdminSeedService;
const tenant = { id: 't-default', slug: 'default' };
const envValues: Record<string, string> = {
TESSERA_ADMIN_USER: 'admin',
TESSERA_ADMIN_EMAIL: 'admin@example.com',
TESSERA_ADMIN_PASSWORD: 'secret',
};
beforeEach(() => {
vi.clearAllMocks();
callLog = [];
configService = {
get: vi.fn((key: string) => envValues[key]),
};
prisma = {
user: {
findUnique: vi.fn(async () => {
callLog.push('user.findUnique');
return null;
}),
create: vi.fn(async () => {
callLog.push('user.create');
return { id: 'u1' };
}),
},
tenant: {
upsert: vi.fn(async () => {
callLog.push('tenant.upsert');
return tenant;
}),
findMany: vi.fn(async () => {
callLog.push('tenant.findMany');
return [tenant];
}),
},
};
groupsService = {
ensureDefaultGroup: vi.fn(async (tenantId: string) => {
callLog.push(`ensureDefaultGroup:${tenantId}`);
return { id: 'g1' };
}),
};
service = new AdminSeedService(prisma, configService, groupsService);
});
it('ruft auf einer frischen Installation in genau dieser Reihenfolge auf: tenant.upsert, ensureDefaultGroup, dann user.create', async () => {
await service.onApplicationBootstrap();
const seedOrder = callLog.filter(
(c) => c === 'tenant.upsert' || c === 'user.create',
);
expect(seedOrder).toEqual(['tenant.upsert', 'user.create']);
const upsertIdx = callLog.indexOf('tenant.upsert');
const ensureIdx = callLog.indexOf(`ensureDefaultGroup:${tenant.id}`);
const createIdx = callLog.indexOf('user.create');
expect(upsertIdx).toBeLessThan(ensureIdx);
expect(ensureIdx).toBeLessThan(createIdx);
});
it('fehlen die ENV-Variablen, wird kein Benutzer angelegt, die Reparatur ueber alle Mandanten laeuft aber trotzdem', async () => {
configService.get = vi.fn(() => undefined);
await service.onApplicationBootstrap();
expect(prisma.tenant.upsert).not.toHaveBeenCalled();
expect(prisma.user.create).not.toHaveBeenCalled();
expect(prisma.tenant.findMany).toHaveBeenCalledTimes(1);
expect(groupsService.ensureDefaultGroup).toHaveBeenCalledWith(tenant.id);
});
it('existiert der Admin-Benutzer bereits, wird kein Benutzer angelegt, die Reparatur ueber alle Mandanten laeuft aber trotzdem', async () => {
prisma.user.findUnique = vi.fn(async () => ({ id: 'existing-admin' }));
await service.onApplicationBootstrap();
expect(prisma.tenant.upsert).not.toHaveBeenCalled();
expect(prisma.user.create).not.toHaveBeenCalled();
expect(prisma.tenant.findMany).toHaveBeenCalledTimes(1);
expect(groupsService.ensureDefaultGroup).toHaveBeenCalledWith(tenant.id);
});
it('die Reparatur ruft ensureDefaultGroup fuer JEDEN von tenant.findMany gelieferten Mandanten auf, auch fuer nicht-default-Mandanten', async () => {
prisma.user.findUnique = vi.fn(async () => ({ id: 'existing-admin' }));
const otherTenant = { id: 't-other', slug: 'other' };
prisma.tenant.findMany = vi.fn(async () => [tenant, otherTenant]);
await service.onApplicationBootstrap();
expect(groupsService.ensureDefaultGroup).toHaveBeenCalledWith(tenant.id);
expect(groupsService.ensureDefaultGroup).toHaveBeenCalledWith(otherTenant.id);
expect(groupsService.ensureDefaultGroup).toHaveBeenCalledTimes(2);
});
it('wirft ensureDefaultGroup fuer einen Mandanten, werden die uebrigen Mandanten trotzdem abgearbeitet und onApplicationBootstrap wirft nicht', async () => {
prisma.user.findUnique = vi.fn(async () => ({ id: 'existing-admin' }));
const failing = { id: 't-fail', slug: 'fail' };
const ok = { id: 't-ok', slug: 'ok' };
prisma.tenant.findMany = vi.fn(async () => [failing, ok]);
groupsService.ensureDefaultGroup = vi.fn(async (tenantId: string) => {
if (tenantId === failing.id) {
throw new Error('boom');
}
return { id: 'g-ok' };
});
await expect(service.onApplicationBootstrap()).resolves.not.toThrow();
expect(groupsService.ensureDefaultGroup).toHaveBeenCalledWith(failing.id);
expect(groupsService.ensureDefaultGroup).toHaveBeenCalledWith(ok.id);
});
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);
// Erster Lauf: seedAdmin() ruft ensureDefaultGroup einmal vor
// user.create auf, die Reparatur einmal danach fuer denselben Mandanten.
expect(groupsService.ensureDefaultGroup).toHaveBeenCalledTimes(2);
// Zweiter Lauf: Admin existiert jetzt bereits (Neustart-Semantik), also
// ruft nur noch die Reparatur ensureDefaultGroup auf — kein weiterer
// user.create.
prisma.user.findUnique = vi.fn(async () => ({ id: 'u1' }));
await service.onApplicationBootstrap();
expect(prisma.user.create).toHaveBeenCalledTimes(1);
expect(groupsService.ensureDefaultGroup).toHaveBeenCalledTimes(3);
});
});