import { ConflictException } from '@nestjs/common'; import { describe, expect, it, vi } from 'vitest'; import { TenderNotificationPrefService } from './tender-notification-pref.service'; import { forTenant } from '../prisma/prisma-tenant.extension'; /** * TenderNotificationPrefService.spec — RED-first (TDD) proof for NOTIFY-01 * (D-01/D-03) and the V4/IDOR access-control invariant (T-12-14): * * - getForUser() without an existing row returns a default * { digestInterval: 'daily' } (D-01) — no error, no implicit autowrite. * - setForUser() upserts on the @@unique userId (D-03); a second call with * a different value updates the SAME row rather than creating a new one. * - setForUser() translates a P2002 (unique-constraint violation on a * stale-tenant upsert, T-LAA-07/Befund F) into a German ConflictException * instead of a raw error. * * Bindung an forTenant() (260909-laa, Befund C/H) — Muster aus * `groups.service.spec.ts` (260909-jts): `__makeBoundClient()` wraps the * SAME in-memory Map with a per-call logging layer, so a forgotten * `forTenant()` call is visible as a missing log entry, not just a passing * test either way. */ vi.mock('../prisma/prisma-tenant.extension', () => ({ forTenant: vi.fn((prisma: any, tenantId: string) => prisma.__makeBoundClient(tenantId)), })); function makeFakePrisma() { const rows = new Map(); const boundCallLog: { tenantId: string; model: string; method: string }[] = []; function throwUniqueViolation(): never { const err: any = new Error('Unique constraint failed on the fields: (`userId`)'); err.code = 'P2002'; throw err; } const tenderNotificationPref = { findUnique: async ({ where }: any) => rows.get(where.userId) ?? null, upsert: async ({ where, create, update }: any) => { const existing = rows.get(where.userId); const record = existing ? { ...existing, ...update, updatedAt: new Date() } : { id: `pref-${rows.size + 1}`, ...create, createdAt: new Date(), updatedAt: new Date() }; rows.set(where.userId, record); return record; }, __throwUniqueViolationOnNextUpsert: false, }; const fake: any = { tenderNotificationPref, __boundCallLog: boundCallLog, __makeBoundClient(tenantId: string) { const wrapped: any = {}; for (const method of ['findUnique', 'upsert']) { wrapped[method] = async (...args: any[]) => { boundCallLog.push({ tenantId, model: 'tenderNotificationPref', method }); return (tenderNotificationPref as any)[method](...args); }; } return { tenderNotificationPref: wrapped }; }, __throwUniqueViolation: throwUniqueViolation, }; return fake; } function expectBoundCall(prisma: any, tenantId: string, method: string) { const found = prisma.__boundCallLog.some( (c: any) => c.tenantId === tenantId && c.model === 'tenderNotificationPref' && c.method === method, ); expect( found, `erwarteter gebundener Aufruf tenderNotificationPref.${method}(tenant=${tenantId}) fehlt im Protokoll: ${JSON.stringify(prisma.__boundCallLog)}`, ).toBe(true); } describe('TenderNotificationPrefService', () => { it('getForUser() returns a default digestInterval="daily" when no row exists (D-01)', async () => { const prisma = makeFakePrisma(); const service = new TenderNotificationPrefService(prisma as any); const result = await service.getForUser('u1', 'tenant1'); expect(result.digestInterval).toBe('daily'); }); it('setForUser() upserts on userId, creating a row scoped to (userId, tenantId) (D-03)', async () => { const prisma = makeFakePrisma(); const service = new TenderNotificationPrefService(prisma as any); const result = await service.setForUser('u1', 'tenant1', 'weekly'); expect(result.userId).toBe('u1'); expect(result.tenantId).toBe('tenant1'); expect(result.digestInterval).toBe('weekly'); }); it('setForUser() called a second time updates the SAME row (@@unique userId), not a new one', async () => { const prisma = makeFakePrisma(); const service = new TenderNotificationPrefService(prisma as any); await service.setForUser('u1', 'tenant1', 'weekly'); const second = await service.setForUser('u1', 'tenant1', 'off'); expect(second.digestInterval).toBe('off'); expect(await service.getForUser('u1', 'tenant1')).toMatchObject({ digestInterval: 'off' }); }); it('getForUser() is scoped strictly by userId — a foreign userId never sees another user\'s pref (V4 / IDOR)', async () => { const prisma = makeFakePrisma(); const service = new TenderNotificationPrefService(prisma as any); await service.setForUser('u1', 'tenant1', 'weekly'); const foreign = await service.getForUser('u2', 'tenant1'); expect(foreign.digestInterval).toBe('daily'); // default, not u1's 'weekly' }); // --- Fehlerbehandlung (260909-laa, Befund F / T-LAA-07) ------------------- it('setForUser() translates a P2002 unique-constraint violation into a German ConflictException, never a raw error', async () => { const prisma = makeFakePrisma(); prisma.tenderNotificationPref.upsert = vi.fn(async () => { prisma.__throwUniqueViolation(); }); const service = new TenderNotificationPrefService(prisma as any); await expect(service.setForUser('u1', 'tenant1', 'weekly')).rejects.toBeInstanceOf( ConflictException, ); }); // --- Bindung an forTenant() (260909-laa, Aufgabe 2) ----------------------- describe('Bindung an forTenant() (260909-laa)', () => { it('getForUser() bindet tenderNotificationPref.findUnique an den uebergebenen Mandanten', async () => { const prisma = makeFakePrisma(); const service = new TenderNotificationPrefService(prisma as any); await service.getForUser('u1', 't1'); expectBoundCall(prisma, 't1', 'findUnique'); // Benutzerdimension (260911-nke): forTenant() bekommt userId als drittes Argument. expect(forTenant).toHaveBeenCalledWith(prisma, 't1', 'u1'); }); it('setForUser() bindet tenderNotificationPref.upsert an den uebergebenen Mandanten', async () => { const prisma = makeFakePrisma(); const service = new TenderNotificationPrefService(prisma as any); await service.setForUser('u1', 't1', 'weekly'); expectBoundCall(prisma, 't1', 'upsert'); }); }); });