import { ConflictException } from '@nestjs/common'; import { describe, expect, it, vi } from 'vitest'; import { TenderTriageService } from './tender-triage.service'; import { forTenant } from '../prisma/prisma-tenant.extension'; /** * TenderTriageService.spec — RED-first (TDD) proof for UI-03/04 (D-09/D-10/ * D-11) and the V4/IDOR access-control invariant (T-11-10): * * - setTriage() is idempotent: repeated calls with the same params upsert * onto exactly one row (@@unique([userId,tenderId]) target). * - listForUser()/favoriteIds() are scoped strictly by userId — a foreign * userId must see nothing, even for the same tenderId (IDOR). * - Cascade: once a tender's triage rows are removed (simulating the DB's * `onDelete: Cascade` FK — real enforcement is verified separately * against the applied migration on the live dev DB), the service's read * path must not leak orphaned rows (Pitfall 6). * * Bindung an forTenant() (260909-laa, Befund C/H): `__makeBoundClient()` * wraps the SAME in-memory Map with a per-model, per-call logging layer — * a pure identity mock (`(p) => p`, the ldap-era mistake) would leave a * forgotten `forTenant()` call invisible to every test. Muster aus * `groups.service.spec.ts` (260909-jts). */ vi.mock('../prisma/prisma-tenant.extension', () => ({ forTenant: vi.fn((prisma: any, tenantId: string) => prisma.__makeBoundClient(tenantId)), })); function makeFakePrisma() { const rows = new Map(); const key = (userId: string, tenderId: string) => `${userId}:${tenderId}`; const boundCallLog: { tenantId: string; model: string; method: string }[] = []; const tenderTriage = { upsert: vi.fn(async ({ where, update, create }: any) => { const k = key(where.userId_tenderId.userId, where.userId_tenderId.tenderId); const existing = rows.get(k); const record = existing ? { ...existing, ...update, updatedAt: new Date() } : { id: `tt-${rows.size + 1}`, ...create, createdAt: new Date(), updatedAt: new Date() }; rows.set(k, record); return record; }), findMany: vi.fn(async ({ where }: any) => { return Array.from(rows.values()).filter((r) => { if (where.userId !== undefined && r.userId !== where.userId) return false; if (where.isFavorite !== undefined && r.isFavorite !== where.isFavorite) return false; if (where.tenderId?.in && !where.tenderId.in.includes(r.tenderId)) return false; return true; }); }), // Simulates the schema-level `onDelete: Cascade` FK constraint // (Pitfall 6). Real enforcement is verified separately by applying // the 20260721160000_add_tender_triage migration and checking // `SELECT * FROM "TenderTriage"` after a live delete — this fake // proves the service's read path (listForUser/favoriteIds) is // consistent with that cascade once rows are gone. _simulateTenderCascadeDelete: (tenderId: string) => { for (const [k, r] of rows) { if (r.tenderId === tenderId) rows.delete(k); } }, }; const fake: any = { tenderTriage, __boundCallLog: boundCallLog, __makeBoundClient(tenantId: string) { const wrapped: any = {}; for (const method of ['upsert', 'findMany']) { wrapped[method] = async (...args: any[]) => { boundCallLog.push({ tenantId, model: 'tenderTriage', method }); return (tenderTriage as any)[method](...args); }; } return { tenderTriage: wrapped }; }, }; return fake; } function expectBoundCall(prisma: any, tenantId: string, method: string) { const found = prisma.__boundCallLog.some( (c: any) => c.tenantId === tenantId && c.model === 'tenderTriage' && c.method === method, ); expect( found, `erwarteter gebundener Aufruf tenderTriage.${method}(tenant=${tenantId}) fehlt im Protokoll: ${JSON.stringify(prisma.__boundCallLog)}`, ).toBe(true); } describe('TenderTriageService', () => { it('setTriage is idempotent: calling twice with identical params results in exactly one row', async () => { const prisma = makeFakePrisma(); const service = new TenderTriageService(prisma as any); await service.setTriage('u1', 'tenant1', 't1', { isRead: true }); await service.setTriage('u1', 'tenant1', 't1', { isRead: true }); const rows = await service.listForUser('u1', 'tenant1', ['t1']); expect(rows).toHaveLength(1); expect(rows[0].isRead).toBe(true); }); it('setTriage upserts isFavorite=true and stamps favoritedAt', async () => { const prisma = makeFakePrisma(); const service = new TenderTriageService(prisma as any); const result = await service.setTriage('u1', 'tenant1', 't1', { isFavorite: true }); expect(result.isFavorite).toBe(true); expect(result.favoritedAt).toBeInstanceOf(Date); }); it('setTriage toggling isRead back to false clears readAt', async () => { const prisma = makeFakePrisma(); const service = new TenderTriageService(prisma as any); await service.setTriage('u1', 'tenant1', 't1', { isRead: true }); const result = await service.setTriage('u1', 'tenant1', 't1', { isRead: false }); expect(result.isRead).toBe(false); expect(result.readAt).toBeNull(); }); it('a partial update (isRead only) does not clobber a previously-set isFavorite', async () => { const prisma = makeFakePrisma(); const service = new TenderTriageService(prisma as any); await service.setTriage('u1', 'tenant1', 't1', { isFavorite: true }); const result = await service.setTriage('u1', 'tenant1', 't1', { isRead: true }); expect(result.isRead).toBe(true); expect(result.isFavorite).toBe(true); }); it('listForUser scopes strictly by userId — a foreign user sees nothing for the same tenderId (V4 / IDOR)', async () => { const prisma = makeFakePrisma(); const service = new TenderTriageService(prisma as any); await service.setTriage('u1', 'tenant1', 't1', { isRead: true, isFavorite: true }); const foreignRows = await service.listForUser('u2', 'tenant1', ['t1']); expect(foreignRows).toHaveLength(0); }); it('listForUser with an empty tenderIds array short-circuits to an empty result without querying prisma', async () => { const prisma = makeFakePrisma(); const service = new TenderTriageService(prisma as any); const rows = await service.listForUser('u1', 'tenant1', []); expect(rows).toEqual([]); expect(prisma.tenderTriage.findMany).not.toHaveBeenCalled(); }); it('favoriteIds returns only tenderIds favorited by that exact user, never another user\'s (V4 / IDOR)', async () => { const prisma = makeFakePrisma(); const service = new TenderTriageService(prisma as any); await service.setTriage('u1', 'tenant1', 't1', { isFavorite: true }); await service.setTriage('u1', 'tenant1', 't2', { isFavorite: false }); await service.setTriage('u2', 'tenant1', 't3', { isFavorite: true }); expect(await service.favoriteIds('u1', 'tenant1')).toEqual(['t1']); expect(await service.favoriteIds('u2', 'tenant1')).toEqual(['t3']); }); it('cascade: after a tender\'s triage rows are removed (DB onDelete: Cascade), it no longer appears for any user', async () => { const prisma = makeFakePrisma(); const service = new TenderTriageService(prisma as any); await service.setTriage('u1', 'tenant1', 't1', { isFavorite: true }); prisma.tenderTriage._simulateTenderCascadeDelete('t1'); expect(await service.listForUser('u1', 'tenant1', ['t1'])).toHaveLength(0); expect(await service.favoriteIds('u1', 'tenant1')).toEqual([]); }); // --- Bindung an forTenant() (260909-laa, Aufgabe 2) ----------------------- describe('Bindung an forTenant() (260909-laa)', () => { it('setTriage() bindet tenderTriage.upsert an den uebergebenen Mandanten', async () => { const prisma = makeFakePrisma(); const service = new TenderTriageService(prisma as any); await service.setTriage('u1', 't1', 'tender-x', { isRead: true }); expectBoundCall(prisma, 't1', 'upsert'); // Benutzerdimension (260911-nke): forTenant() bekommt userId als drittes Argument. expect(forTenant).toHaveBeenCalledWith(prisma, 't1', 'u1'); }); it('listForUser() bindet tenderTriage.findMany an den uebergebenen Mandanten', async () => { const prisma = makeFakePrisma(); const service = new TenderTriageService(prisma as any); await service.listForUser('u1', 't1', ['tender-x']); expectBoundCall(prisma, 't1', 'findMany'); }); it('favoriteIds() bindet tenderTriage.findMany an den uebergebenen Mandanten', async () => { const prisma = makeFakePrisma(); const service = new TenderTriageService(prisma as any); await service.favoriteIds('u1', 't1'); expectBoundCall(prisma, 't1', 'findMany'); }); }); /** * Gegenrichtung der Bindung (Befund F, 260909-laa). Der Eindeutigkeits- * schluessel `@@unique([userId, tenderId])` traegt keine Mandanten- * dimension. Ist die vorhandene Zeile unter dem gebundenen Kontext * unsichtbar, findet das `upsert` sie nicht, versucht anzulegen und * laeuft in die Eindeutigkeitsverletzung — aus stillem Ueberschreiben * wird ein harter Fehler. * * Diese Pruefung fehlte in der ersten Lieferung von 260909-laa: die * Zusammenfassung behauptete die Uebersetzung fuer ALLE DREI * mandantenlosen Eindeutigkeitsschluessel, gebaut war sie nur fuer zwei. * Vom Verifizierer gefunden, hier nachgereicht. */ describe('P2002 auf mandantenlosem Eindeutigkeitsschluessel (Befund F)', () => { it('setTriage() uebersetzt die Eindeutigkeitsverletzung in eine ConflictException statt in einen 500', async () => { const prisma = makeFakePrisma(); const verletzung: any = new Error( 'Unique constraint failed on the fields: (`userId`,`tenderId`)', ); verletzung.code = 'P2002'; prisma.tenderTriage.upsert = vi.fn(async () => { throw verletzung; }); const service = new TenderTriageService(prisma as any); await expect( service.setTriage('u1', 't1', 'tender-x', { isFavorite: true }), ).rejects.toBeInstanceOf(ConflictException); }); it('setTriage() reicht jeden anderen Datenbankfehler unveraendert durch', async () => { const prisma = makeFakePrisma(); const anderer: any = new Error('Verbindung verloren'); anderer.code = 'P1001'; prisma.tenderTriage.upsert = vi.fn(async () => { throw anderer; }); const service = new TenderTriageService(prisma as any); await expect( service.setTriage('u1', 't1', 'tender-x', { isFavorite: true }), ).rejects.toBe(anderer); }); }); });