import 'reflect-metadata'; import { BadRequestException, NotFoundException } from '@nestjs/common'; import { Role } from '@prisma/client'; import { describe, expect, it, vi } from 'vitest'; import { ROLES_KEY } from '../auth/decorators/roles.decorator'; import { TenantController } from './tenant.controller'; /** * TenantController.spec — legt die Testlage fuer diesen Bereich aus dem * Nichts an (260911-e2s, Aufgabe 3, Befund I: vorher gab es nur * `tenant.service.spec.ts` mit zwei Faellen zu `create`). * * Zwei-Klienten-Nachweis (Muster aus `../dkv/dkv.service.spec.ts`): * `__makeBoundClient(tenantId)` bietet ein `user.count`-Modell, das * zusaetzlich nach der Mandantenkennung filtert und jeden Aufruf in ein * Bindungsprotokoll schreibt. Der UNGEBUNDENE Nachbau (`prisma.tenant.*`) * hat absichtlich KEIN `user`-Modell — ein versehentlich ungebundener * Zaehler scheitert dadurch mit "Cannot read properties of undefined" * (die `dkv`-Form der Falsifizierung), nicht mit einer nur falschen Zahl. */ vi.mock('../prisma/prisma-tenant.extension', () => ({ forTenant: vi.fn((prisma: any, tenantId: string) => prisma.__makeBoundClient(tenantId)), })); interface FakeTenantRow { id: string; name: string; slug: string; isActive: boolean; createdAt: Date; } interface FakeUserRow { id: string; tenantId: string; isActive: boolean; } function makeFakePrisma(tenantRows: FakeTenantRow[], userRows: FakeUserRow[]) { const tenants = new Map(tenantRows.map((t) => [t.id, { ...t }])); const users = [...userRows]; const boundCallLog: { tenantId: string; model: string; method: string; where: any }[] = []; const tenantModel = { findMany: vi.fn(async ({ orderBy }: { orderBy?: { name?: 'asc' | 'desc' } } = {}) => { const rows = [...tenants.values()]; if (orderBy?.name === 'asc') rows.sort((a, b) => a.name.localeCompare(b.name)); return rows; }), findUnique: vi.fn(async ({ where }: { where: { id: string } }) => tenants.get(where.id) ?? null), delete: vi.fn(async ({ where }: { where: { id: string } }) => { const row = tenants.get(where.id) ?? null; tenants.delete(where.id); return row; }), }; const fake: any = { tenant: tenantModel, __boundCallLog: boundCallLog, __makeBoundClient(tenantId: string) { return { user: { count: async ({ where, }: { where: { tenantId: string; isActive?: boolean }; }) => { boundCallLog.push({ tenantId, model: 'user', method: 'count', where }); return users.filter( (u) => u.tenantId === where.tenantId && (where.isActive === undefined || u.isActive === where.isActive), ).length; }, }, }; }, }; return fake; } 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); } function makeFakeTenantService() { return { create: vi.fn(), findById: vi.fn(), update: vi.fn(), } as any; } const TENANT_A: FakeTenantRow = { id: 'TENANT-A', name: 'A GmbH', slug: 'tenant-a', isActive: true, createdAt: new Date('2026-01-01'), }; const TENANT_B: FakeTenantRow = { id: 'TENANT-B', name: 'B GmbH', slug: 'tenant-b', isActive: true, createdAt: new Date('2026-01-02'), }; const TENANT_C: FakeTenantRow = { id: 'TENANT-C', name: 'C GmbH', slug: 'tenant-c', isActive: true, createdAt: new Date('2026-01-03'), }; describe('TenantController.findAll', () => { it('drei Mandanten (A: zwei Benutzer, B: ein Benutzer, C: keiner): liefert drei Eintraege mit userCount 2/1/0, genau drei gebundene Zaehlaufrufe je Mandantenkennung', async () => { const prisma = makeFakePrisma( [TENANT_A, TENANT_B, TENANT_C], [ { id: 'u-a1', tenantId: 'TENANT-A', isActive: true }, { id: 'u-a2', tenantId: 'TENANT-A', isActive: true }, { id: 'u-b1', tenantId: 'TENANT-B', isActive: true }, ], ); const controller = new TenantController(makeFakeTenantService(), prisma as any); const result = await controller.findAll(); expect(result).toEqual([ expect.objectContaining({ id: 'TENANT-A', userCount: 2 }), expect.objectContaining({ id: 'TENANT-B', userCount: 1 }), expect.objectContaining({ id: 'TENANT-C', userCount: 0 }), ]); expect(prisma.tenant.findMany).toHaveBeenCalledTimes(1); expect(prisma.__boundCallLog).toHaveLength(3); expectBoundCall(prisma, 'TENANT-A', 'user', 'count'); expectBoundCall(prisma, 'TENANT-B', 'user', 'count'); expectBoundCall(prisma, 'TENANT-C', 'user', 'count'); for (const call of prisma.__boundCallLog) { expect(call.where.tenantId).toBe(call.tenantId); } }); it('ohne Mandanten: leere Liste, KEIN gebundener Klient erzeugt', async () => { const prisma = makeFakePrisma([], []); const controller = new TenantController(makeFakeTenantService(), prisma as any); const result = await controller.findAll(); expect(result).toEqual([]); expect(prisma.__boundCallLog).toHaveLength(0); }); it('die Antwort traegt genau die Felder id, name, slug, isActive, createdAt, userCount', async () => { const prisma = makeFakePrisma( [TENANT_A], [{ id: 'u-a1', tenantId: 'TENANT-A', isActive: true }], ); const controller = new TenantController(makeFakeTenantService(), prisma as any); const [entry] = await controller.findAll(); expect(Object.keys(entry).sort()).toEqual( ['createdAt', 'id', 'isActive', 'name', 'slug', 'userCount'].sort(), ); }); }); describe('TenantController.findOne', () => { it('unbekannte Kennung: NotFoundException, KEIN gebundener Klient', async () => { const prisma = makeFakePrisma([TENANT_A], []); const controller = new TenantController(makeFakeTenantService(), prisma as any); await expect(controller.findOne('unknown')).rejects.toThrow( new NotFoundException('Tenant not found'), ); expect(prisma.__boundCallLog).toHaveLength(0); }); it('bekannte Kennung: userCount aus dem gebundenen Klienten UNTER DIESER Kennung', async () => { const prisma = makeFakePrisma( [TENANT_A, TENANT_B], [ { id: 'u-a1', tenantId: 'TENANT-A', isActive: true }, { id: 'u-b1', tenantId: 'TENANT-B', isActive: true }, { id: 'u-b2', tenantId: 'TENANT-B', isActive: true }, ], ); const controller = new TenantController(makeFakeTenantService(), prisma as any); const result = await controller.findOne('TENANT-B'); expect(result).toEqual(expect.objectContaining({ id: 'TENANT-B', userCount: 2 })); expectBoundCall(prisma, 'TENANT-B', 'user', 'count'); expect(prisma.__boundCallLog).toHaveLength(1); expect(prisma.__boundCallLog[0].where.tenantId).toBe('TENANT-B'); }); }); describe('TenantController.remove', () => { it('unbekannte Kennung: NotFoundException, kein gebundener Klient, tenant.delete nicht aufgerufen', async () => { const prisma = makeFakePrisma([TENANT_A], []); const controller = new TenantController(makeFakeTenantService(), prisma as any); await expect(controller.remove('unknown')).rejects.toThrow( new NotFoundException('Tenant not found'), ); expect(prisma.__boundCallLog).toHaveLength(0); expect(prisma.tenant.delete).not.toHaveBeenCalled(); }); it('mit aktiven Benutzern: BadRequestException mit der heutigen Meldung, tenant.delete NICHT aufgerufen, der gebundene Zaehlaufruf traegt isActive=true', async () => { const prisma = makeFakePrisma( [TENANT_A], [{ id: 'u-a1', tenantId: 'TENANT-A', isActive: true }], ); const controller = new TenantController(makeFakeTenantService(), prisma as any); await expect(controller.remove('TENANT-A')).rejects.toThrow( new BadRequestException( 'Cannot delete tenant with active users. Deactivate or reassign users first.', ), ); expect(prisma.tenant.delete).not.toHaveBeenCalled(); expectBoundCall(prisma, 'TENANT-A', 'user', 'count'); expect(prisma.__boundCallLog[0].where).toEqual({ tenantId: 'TENANT-A', isActive: true }); }); it('mit ausschliesslich inaktiven Benutzern: der Riegel laesst durch, tenant.delete wird ungebunden mit { where: { id } } aufgerufen, Antwort { message: "Tenant deleted" } (heutiges Verhalten — der Fremdschluessel, der das in der echten Datenbank abfaengt, existiert im Nachbau nicht)', async () => { const prisma = makeFakePrisma( [TENANT_A], [{ id: 'u-a1', tenantId: 'TENANT-A', isActive: false }], ); const controller = new TenantController(makeFakeTenantService(), prisma as any); const result = await controller.remove('TENANT-A'); expect(result).toEqual({ message: 'Tenant deleted' }); expect(prisma.tenant.delete).toHaveBeenCalledWith({ where: { id: 'TENANT-A' } }); }); it('ohne Benutzer: geloescht, Antwort wie oben', async () => { const prisma = makeFakePrisma([TENANT_C], []); const controller = new TenantController(makeFakeTenantService(), prisma as any); const result = await controller.remove('TENANT-C'); expect(result).toEqual({ message: 'Tenant deleted' }); expect(prisma.tenant.delete).toHaveBeenCalledWith({ where: { id: 'TENANT-C' } }); }); }); describe('TenantController.create / update — delegieren an die Dienst-Attrappe', () => { it('create: kein gebundener Klient, kein Aufruf des ungebundenen Nachbaus, delegiert an den Dienst', async () => { const prisma = makeFakePrisma([], []); const tenantService = makeFakeTenantService(); tenantService.create.mockResolvedValue(TENANT_A); const controller = new TenantController(tenantService, prisma as any); const result = await controller.create({ name: 'A GmbH', slug: 'tenant-a' } as any); expect(result).toBe(TENANT_A); expect(tenantService.create).toHaveBeenCalledWith({ name: 'A GmbH', slug: 'tenant-a' }); expect(prisma.tenant.findMany).not.toHaveBeenCalled(); expect(prisma.tenant.findUnique).not.toHaveBeenCalled(); expect(prisma.__boundCallLog).toHaveLength(0); }); it('update: kein gebundener Klient, kein Aufruf des ungebundenen Nachbaus (ausser der Existenzpruefung ueber den Dienst), delegiert an den Dienst', async () => { const prisma = makeFakePrisma([], []); const tenantService = makeFakeTenantService(); tenantService.findById.mockResolvedValue(TENANT_A); tenantService.update.mockResolvedValue({ ...TENANT_A, name: 'Neuer Name' }); const controller = new TenantController(tenantService, prisma as any); const result = await controller.update('TENANT-A', { name: 'Neuer Name' }); expect(result).toEqual(expect.objectContaining({ name: 'Neuer Name' })); expect(tenantService.update).toHaveBeenCalledWith('TENANT-A', { name: 'Neuer Name', isActive: undefined, }); expect(prisma.tenant.findMany).not.toHaveBeenCalled(); expect(prisma.__boundCallLog).toHaveLength(0); }); }); describe('TenantController — Rollen-Metadaten (Befund E, T-E2S-01)', () => { it('klassenweit ist genau [Role.SUPER_ADMIN] gesetzt', () => { const roles = Reflect.getMetadata(ROLES_KEY, TenantController); expect(roles).toEqual([Role.SUPER_ADMIN]); }); it.each(['findAll', 'findOne', 'create', 'update', 'remove'] as const)( 'Handler %s traegt KEINE eigene Rollenmetadaten — eine schwaechere Handler-Rolle wuerde die Klassenrolle via getAllAndOverride ueberschreiben', (handlerName) => { const handlerRoles = Reflect.getMetadata( ROLES_KEY, (TenantController.prototype as any)[handlerName], ); expect(handlerRoles).toBeUndefined(); }, ); }); describe('TenantController — Wachhund: hoechstens ein gebundener Klient je Aufruf und Mandant', () => { it('findOne erzeugt genau EINEN gebundenen Klienten je Aufruf', async () => { const prisma = makeFakePrisma( [TENANT_A], [{ id: 'u-a1', tenantId: 'TENANT-A', isActive: true }], ); const controller = new TenantController(makeFakeTenantService(), prisma as any); await controller.findOne('TENANT-A'); expect(prisma.__boundCallLog).toHaveLength(1); }); it('remove erzeugt genau EINEN gebundenen Klienten je Aufruf', async () => { const prisma = makeFakePrisma([TENANT_A], []); const controller = new TenantController(makeFakeTenantService(), prisma as any); await controller.remove('TENANT-A'); expect(prisma.__boundCallLog).toHaveLength(1); }); it('findAll erzeugt genau so viele gebundene Klienten wie Mandanten vorhanden sind', async () => { const prisma = makeFakePrisma([TENANT_A, TENANT_B, TENANT_C], []); const controller = new TenantController(makeFakeTenantService(), prisma as any); await controller.findAll(); expect(prisma.__boundCallLog).toHaveLength(3); }); });