import { describe, expect, it, vi } from 'vitest'; import { ForbiddenException, NotFoundException } from '@nestjs/common'; /** * CalendarService.spec — Zwei-Klienten-Nachweis fuer die Bindung an * forTenant() (260911-cwh, Aufgabe 2). Dieser Bereich hatte VOR diesem * Durchlauf KEINE einzige Testdatei (Befund C) — dieser Fake ist deshalb * die Voraussetzung dafuer, dass irgendeine Aussage dieses Plans * nachpruefbar ist, nicht eine Zugabe. * * Muster wie `dkv.service.spec.ts` (260909-mir): `__makeBoundClient(tenantId)` * wrappt DIESELBEN In-Memory-Zeilen mit einer protokollierenden Schicht fuer * `calendarSource`. Der ungebundene Fake protokolliert NICHT, der gebundene * schon — eine vergessene Bindung wird dadurch sichtbar, ein reiner * Identitaets-Mock (`(p) => p`) wuerde das nicht leisten. Der Wachhund * "genau ein Klient je Aufruf" folgt `dashboard.service.spec.ts` (Zeile * 545): `vi.mocked(forTenant).mock.calls.length` wird nach jedem Aufruf * geprueft. * * Die drei Provider (ICS/CalDAV/Exchange) reden mit echten Servern und * werden NICHT ausgeuebt — nur als `vi.fn()`-Attrappen fuer * `fetchEvents`/`testConnection` eingebunden. */ vi.mock('../prisma/prisma-tenant.extension', () => ({ forTenant: vi.fn((prisma: any, tenantId: string) => prisma.__makeBoundClient(tenantId)), })); import { forTenant } from '../prisma/prisma-tenant.extension'; import { CalendarService } from './calendar.service'; function _applySelect(row: any, select: Record | undefined) { if (!select) return { ...row }; const out: Record = {}; for (const key of Object.keys(select)) { if (select[key]) out[key] = (row as any)[key]; } return out; } function makeDefaultSourceFields() { const now = new Date('2026-01-01T00:00:00.000Z'); return { name: 'Testquelle', type: 'ics', exchangeMode: null, domain: null, url: 'https://example.invalid/cal.ics', username: null, encryptedPassword: null, color: '#3B82F6', isVisible: true, syncIntervalMin: 15, lastSyncAt: null, lastSyncError: null, createdAt: now, updatedAt: now, }; } /** * Handgerollter Prisma-Nachbau mit In-Memory-Zeilen fuer `calendarSource` * (`findMany`, `findUnique`, `create`, `update`, `delete`). Die ungebundene * Form protokolliert NICHT; `__makeBoundClient(tenantId)` liefert eine * ZWEITE, protokollierende Schicht ueber denselben Zeilen. */ function makeFakePrisma() { const sources = new Map(); // key: id const boundCallLog: { tenantId: string; model: string; method: string }[] = []; let autoId = 0; const calendarSource = { findMany: vi.fn( async ({ where, select, orderBy, }: { where?: Record; select?: Record; orderBy?: { createdAt?: string } } = {}) => { let rows = Array.from(sources.values()); if (where) { rows = rows.filter((r) => Object.entries(where).every(([k, v]) => (r as any)[k] === v), ); } if (orderBy?.createdAt === 'asc') { rows = [...rows].sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime()); } return rows.map((r) => _applySelect(r, select)); }, ), findUnique: vi.fn( async ({ where, select }: { where: { id: string }; select?: Record }) => { const row = sources.get(where.id); return row ? _applySelect(row, select) : null; }, ), create: vi.fn( async ({ data, select, }: { data: Record; select?: Record }) => { const id = (data.id as string) ?? `src-${++autoId}`; const now = new Date(); const record = { ...makeDefaultSourceFields(), id, createdAt: now, updatedAt: now, ...data }; sources.set(id, record); return _applySelect(record, select); }, ), update: vi.fn( async ({ where, data, select, }: { where: { id: string }; data: Record; select?: Record }) => { const existing = sources.get(where.id); if (!existing) { // Nachbau des in Aufgabe 1 gemessenen Wettlauf-Ergebnisses // (`calendarsource-generierter-client-gebundenes-update-auf-unsichtbare-zeile-scheitert-laut`, // 260911-cwh): PrismaClientKnownRequestError mit code P2025. const err: any = new Error('An operation failed because it depends on one or more records that were required but not found.'); err.code = 'P2025'; throw err; } const record = { ...existing, ...data, updatedAt: new Date() }; sources.set(where.id, record); return _applySelect(record, select); }, ), delete: vi.fn(async ({ where }: { where: { id: string } }) => { const existing = sources.get(where.id); sources.delete(where.id); return existing; }), }; const fake: any = { calendarSource, __boundCallLog: boundCallLog, __seedSource(row: { id: string; userId: string; tenantId: string } & Partial>) { sources.set(row.id, { ...makeDefaultSourceFields(), ...row }); }, __makeBoundClient(tenantId: string) { const wrapModel = (model: Record, modelName: string, methods: string[]) => { const wrapped: any = {}; for (const method of methods) { wrapped[method] = async (...args: any[]) => { boundCallLog.push({ tenantId, model: modelName, method }); return model[method](...args); }; } return wrapped; }; return { calendarSource: wrapModel(calendarSource, 'calendarSource', [ 'findMany', 'findUnique', 'create', 'update', 'delete', ]), }; }, }; 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); } /** Umkehrbare Attrappen fuer CryptoService — kein echtes Verschluesseln. */ function makeFakeCrypto(overrides: Partial<{ encrypt: any; decrypt: any }> = {}) { return { encrypt: overrides.encrypt ?? vi.fn((plain: string) => `enc(${plain})`), decrypt: overrides.decrypt ?? vi.fn((stored: string) => stored.replace(/^enc\(/, '').replace(/\)$/, '')), }; } /** Attrappen fuer die drei Provider — NIE ausgeuebt, nur `vi.fn()`. */ function makeFakeProviders( overrides: Partial<{ ics: any; caldav: any; exchange: any }> = {}, ) { const makeProvider = () => ({ fetchEvents: vi.fn(async () => []), testConnection: vi.fn(async () => true), }); return { icsProvider: overrides.ics ?? makeProvider(), caldavProvider: overrides.caldav ?? makeProvider(), exchangeProvider: overrides.exchange ?? makeProvider(), }; } function makeCalendarService( prisma: any, overrides: Partial<{ encrypt: any; decrypt: any; icsProvider: any; caldavProvider: any; exchangeProvider: any; }> = {}, ) { const crypto = makeFakeCrypto(overrides); const { icsProvider, caldavProvider, exchangeProvider } = makeFakeProviders({ ics: overrides.icsProvider, caldav: overrides.caldavProvider, exchange: overrides.exchangeProvider, }); const service = new CalendarService( prisma, crypto as any, icsProvider as any, caldavProvider as any, exchangeProvider as any, ); return { service, crypto, icsProvider, caldavProvider, exchangeProvider }; } describe('CalendarService — Bindung an forTenant() (260911-cwh)', () => { // ─── getSources ──────────────────────────────────────────────────────── it('getSources: laeuft gebunden mit der uebergebenen Mandantenkennung im Protokoll; die Antwort traegt hasCredentials und NIE encryptedPassword', async () => { const prisma = makeFakePrisma(); prisma.__seedSource({ id: 'src-a1', userId: 'user-a1', tenantId: 't1', encryptedPassword: 'enc(geheim)' }); const { service } = makeCalendarService(prisma); const result = await service.getSources('user-a1', 't1'); expectBoundCall(prisma, 't1', 'calendarSource', 'findMany'); expect(result).toHaveLength(1); expect(result[0].hasCredentials).toBe(true); expect((result[0] as any).encryptedPassword).toBeUndefined(); // Benutzerdimension (260911-nke): forTenant() bekommt userId als drittes Argument. expect(forTenant).toHaveBeenCalledWith(prisma, 't1', 'user-a1'); }); it('getSources von Nutzer A liefert NICHT die Quellen von Nutzer B desselben Mandanten — der userId-Filter bleibt, die Bindung ergaenzt ihn', async () => { const prisma = makeFakePrisma(); prisma.__seedSource({ id: 'src-a1', userId: 'user-a1', tenantId: 't1' }); prisma.__seedSource({ id: 'src-a2', userId: 'user-a2', tenantId: 't1' }); const { service } = makeCalendarService(prisma); const result = await service.getSources('user-a1', 't1'); expect(result.map((s: any) => s.id)).toEqual(['src-a1']); }); // ─── addSource ───────────────────────────────────────────────────────── it('addSource: gebunden, Mandantenkennung als Pflichtwert geschrieben, Passwort verschluesselt, Antwort ohne encryptedPassword', async () => { const prisma = makeFakePrisma(); const { service, crypto } = makeCalendarService(prisma); const created = await service.addSource('user-a1', 't1', { name: 'Neue Quelle', type: 'ics', url: 'https://example.invalid/neu.ics', password: 'geheim-123', } as any); expectBoundCall(prisma, 't1', 'calendarSource', 'create'); expect(prisma.calendarSource.create).toHaveBeenCalledWith( expect.objectContaining({ data: expect.objectContaining({ tenantId: 't1', userId: 'user-a1' }) }), ); expect(created.hasCredentials).toBe(true); expect((created as any).encryptedPassword).toBeUndefined(); expect(vi.mocked(crypto.encrypt)).toHaveBeenCalledWith('geheim-123'); }); // ─── updateSource ────────────────────────────────────────────────────── it('updateSource: BEIDE Abfragen (Nachschlagen und Aendern) stehen ueber DENSELBEN gebundenen Klienten und dieselbe Mandantenkennung im Protokoll', async () => { const prisma = makeFakePrisma(); prisma.__seedSource({ id: 'src-a1', userId: 'user-a1', tenantId: 't1' }); const { service } = makeCalendarService(prisma); await service.updateSource('src-a1', 'user-a1', 't1', { name: 'Neuer Name' } as any); expectBoundCall(prisma, 't1', 'calendarSource', 'findUnique'); expectBoundCall(prisma, 't1', 'calendarSource', 'update'); }); it('updateSource, Zugangsdaten-Erhaltung — Feld FEHLT: encryptedPassword bleibt unveraendert, kein Lesezugriff laedt ein Passwort (Befund E)', async () => { const prisma = makeFakePrisma(); prisma.__seedSource({ id: 'src-a1', userId: 'user-a1', tenantId: 't1', encryptedPassword: 'enc(alt-passwort)' }); const { service, crypto } = makeCalendarService(prisma); const result = await service.updateSource('src-a1', 'user-a1', 't1', { name: 'Umbenannt' } as any); expect(result.hasCredentials).toBe(true); expect(vi.mocked(crypto.decrypt)).not.toHaveBeenCalled(); expect(vi.mocked(crypto.encrypt)).not.toHaveBeenCalled(); const updateCall = vi.mocked(prisma.calendarSource.update).mock.calls[0][0] as any; expect(updateCall.data).not.toHaveProperty('encryptedPassword'); }); it('updateSource, Zugangsdaten-Erhaltung — Feld LEER: encryptedPassword wird null', async () => { const prisma = makeFakePrisma(); prisma.__seedSource({ id: 'src-a1', userId: 'user-a1', tenantId: 't1', encryptedPassword: 'enc(alt-passwort)' }); const { service } = makeCalendarService(prisma); const result = await service.updateSource('src-a1', 'user-a1', 't1', { password: '' } as any); expect(result.hasCredentials).toBe(false); }); it('updateSource, Zugangsdaten-Erhaltung — Feld GESETZT: encryptedPassword wird neu verschluesselt', async () => { const prisma = makeFakePrisma(); prisma.__seedSource({ id: 'src-a1', userId: 'user-a1', tenantId: 't1', encryptedPassword: 'enc(alt-passwort)' }); const { service, crypto } = makeCalendarService(prisma); const result = await service.updateSource('src-a1', 'user-a1', 't1', { password: 'neu-geheim' } as any); expect(vi.mocked(crypto.encrypt)).toHaveBeenCalledWith('neu-geheim'); expect(result.hasCredentials).toBe(true); }); // ─── Besitzpruefungen (updateSource/deleteSource/testConnection) ────── it('updateSource: eine Quelle eines ANDEREN Benutzers fuehrt weiterhin zu ForbiddenException', async () => { const prisma = makeFakePrisma(); prisma.__seedSource({ id: 'src-b1', userId: 'other-user', tenantId: 't1' }); const { service } = makeCalendarService(prisma); await expect( service.updateSource('src-b1', 'user-a1', 't1', { name: 'X' } as any), ).rejects.toThrow(ForbiddenException); }); it('updateSource: eine UNBEKANNTE Kennung fuehrt weiterhin zu NotFoundException', async () => { const prisma = makeFakePrisma(); const { service } = makeCalendarService(prisma); await expect( service.updateSource('src-unbekannt', 'user-a1', 't1', { name: 'X' } as any), ).rejects.toThrow(NotFoundException); }); it('deleteSource: eine Quelle eines ANDEREN Benutzers fuehrt weiterhin zu ForbiddenException', async () => { const prisma = makeFakePrisma(); prisma.__seedSource({ id: 'src-b1', userId: 'other-user', tenantId: 't1' }); const { service } = makeCalendarService(prisma); await expect(service.deleteSource('src-b1', 'user-a1', 't1')).rejects.toThrow(ForbiddenException); }); it('deleteSource: eine UNBEKANNTE Kennung fuehrt weiterhin zu NotFoundException', async () => { const prisma = makeFakePrisma(); const { service } = makeCalendarService(prisma); await expect(service.deleteSource('src-unbekannt', 'user-a1', 't1')).rejects.toThrow(NotFoundException); }); it('testConnection: eine Quelle eines ANDEREN Benutzers fuehrt weiterhin zu ForbiddenException', async () => { const prisma = makeFakePrisma(); prisma.__seedSource({ id: 'src-b1', userId: 'other-user', tenantId: 't1' }); const { service } = makeCalendarService(prisma); await expect(service.testConnection('src-b1', 'user-a1', 't1')).rejects.toThrow(ForbiddenException); }); it('testConnection: eine UNBEKANNTE Kennung fuehrt weiterhin zu NotFoundException', async () => { const prisma = makeFakePrisma(); const { service } = makeCalendarService(prisma); await expect(service.testConnection('src-unbekannt', 'user-a1', 't1')).rejects.toThrow(NotFoundException); }); // ─── deleteSource, positiver Pfad ────────────────────────────────────── it('deleteSource: beide Abfragen (Nachschlagen und Loeschen) stehen ueber denselben gebundenen Klienten im Protokoll', async () => { const prisma = makeFakePrisma(); prisma.__seedSource({ id: 'src-a1', userId: 'user-a1', tenantId: 't1' }); const { service } = makeCalendarService(prisma); await service.deleteSource('src-a1', 'user-a1', 't1'); expectBoundCall(prisma, 't1', 'calendarSource', 'findUnique'); expectBoundCall(prisma, 't1', 'calendarSource', 'delete'); }); // ─── testConnection, positive Pfade ──────────────────────────────────── it('testConnection, Erfolgspfad: alle Abfragen (Nachschlagen, Rueckschreiben bei Erfolg) stehen ueber denselben gebundenen Klienten; der Provider erhaelt das ENTSCHLUESSELTE Passwort', async () => { const prisma = makeFakePrisma(); prisma.__seedSource({ id: 'src-a1', userId: 'user-a1', tenantId: 't1', encryptedPassword: 'enc(geheim-123)', type: 'ics' }); const { service, icsProvider } = makeCalendarService(prisma); const result = await service.testConnection('src-a1', 'user-a1', 't1'); expectBoundCall(prisma, 't1', 'calendarSource', 'findUnique'); expectBoundCall(prisma, 't1', 'calendarSource', 'update'); expect(result.success).toBe(true); expect(vi.mocked(icsProvider.testConnection)).toHaveBeenCalledWith( expect.objectContaining({ password: 'geheim-123' }), ); }); it('testConnection, Fehlerpfad: alle Abfragen (Nachschlagen, Rueckschreiben im catch) stehen ueber denselben gebundenen Klienten; die Antwort ist generisch (kein Passwort, keine Serverdetails)', async () => { const prisma = makeFakePrisma(); prisma.__seedSource({ id: 'src-a1', userId: 'user-a1', tenantId: 't1', encryptedPassword: 'enc(geheim-123)', type: 'ics' }); const icsProvider = { fetchEvents: vi.fn(async () => []), testConnection: vi.fn(async () => { throw new Error('ECONNREFUSED mail.example.invalid:993 password=geheim-123'); }), }; const { service } = makeCalendarService(prisma, { icsProvider }); const result = await service.testConnection('src-a1', 'user-a1', 't1'); expectBoundCall(prisma, 't1', 'calendarSource', 'findUnique'); expectBoundCall(prisma, 't1', 'calendarSource', 'update'); expect(result.success).toBe(false); expect(result.error).toBe('Connection failed'); expect(result.error).not.toContain('geheim-123'); expect(result.error).not.toContain('mail.example.invalid'); }); // ─── aggregateEvents / fetchAndCacheEvents ──────────────────────────── it('aggregateEvents, Erfolgspfad: das Laden der Quellen UND die Synchronstatus-Rueckschreibung bei Erfolg stehen gebunden unter derselben Mandantenkennung im Protokoll', async () => { const prisma = makeFakePrisma(); prisma.__seedSource({ id: 'src-a1', userId: 'user-a1', tenantId: 't1', isVisible: true, type: 'ics' }); const { service } = makeCalendarService(prisma); await service.aggregateEvents('user-a1', 't1'); expectBoundCall(prisma, 't1', 'calendarSource', 'findMany'); expectBoundCall(prisma, 't1', 'calendarSource', 'update'); }); it('aggregateEvents, Fehlerpfad (Providerfehler): das Laden der Quellen UND die Synchronstatus-Rueckschreibung im catch stehen gebunden unter derselben Mandantenkennung im Protokoll', async () => { const prisma = makeFakePrisma(); prisma.__seedSource({ id: 'src-a1', userId: 'user-a1', tenantId: 't1', isVisible: true, type: 'ics' }); const icsProvider = { fetchEvents: vi.fn(async () => { throw new Error('Provider nicht erreichbar'); }), testConnection: vi.fn(async () => true), }; const { service } = makeCalendarService(prisma, { icsProvider }); const events = await service.aggregateEvents('user-a1', 't1'); expect(events).toEqual([]); expectBoundCall(prisma, 't1', 'calendarSource', 'findMany'); expectBoundCall(prisma, 't1', 'calendarSource', 'update'); const updateCalls = prisma.__boundCallLog.filter( (c: any) => c.tenantId === 't1' && c.model === 'calendarSource' && c.method === 'update', ); expect(updateCalls.length).toBeGreaterThanOrEqual(1); }); it('aggregateEvents ohne Quellen: Rueckgabe ist eine leere Liste, kein Fehler, und es wird KEIN Cache-Eintrag angelegt — die Deutung von Leere als Abwesenheit als heutiges Verhalten festgehalten', async () => { const prisma = makeFakePrisma(); const { service } = makeCalendarService(prisma); const first = await service.aggregateEvents('user-ohne-quellen', 't1'); expect(first).toEqual([]); // Zweiter Aufruf misst erneut ueber die Datenbank — waere ein // Cache-Eintrag angelegt worden, bliebe der Aufrufzaehler von // findMany bei 1 stehen. await service.aggregateEvents('user-ohne-quellen', 't1'); const findManyCalls = prisma.__boundCallLog.filter( (c: any) => c.model === 'calendarSource' && c.method === 'findMany', ); expect(findManyCalls.length).toBe(2); }); // ─── Cache ────────────────────────────────────────────────────────────── it('Cache: ein zweiter Aufruf innerhalb der Lebensdauer erzeugt keinen weiteren Datenbankzugriff', async () => { const prisma = makeFakePrisma(); prisma.__seedSource({ id: 'src-a1', userId: 'user-a1', tenantId: 't1', isVisible: true, type: 'ics' }); const { service } = makeCalendarService(prisma); await service.aggregateEvents('user-a1', 't1', '2026-01-01T00:00:00.000Z', '2026-01-31T00:00:00.000Z'); const findManyCallsAfterFirst = prisma.__boundCallLog.filter( (c: any) => c.model === 'calendarSource' && c.method === 'findMany', ).length; await service.aggregateEvents('user-a1', 't1', '2026-01-01T00:00:00.000Z', '2026-01-31T00:00:00.000Z'); const findManyCallsAfterSecond = prisma.__boundCallLog.filter( (c: any) => c.model === 'calendarSource' && c.method === 'findMany', ).length; expect(findManyCallsAfterSecond).toBe(findManyCallsAfterFirst); }); it('Cache: zwei VERSCHIEDENE Benutzerkennungen teilen sich keinen Eintrag — das Urteil aus Aufgabe 1 zum Cache-Schluessel, festgenagelt', async () => { const prisma = makeFakePrisma(); prisma.__seedSource({ id: 'src-a1', userId: 'user-a1', tenantId: 't1', isVisible: true, type: 'ics' }); prisma.__seedSource({ id: 'src-a2', userId: 'user-a2', tenantId: 't1', isVisible: true, type: 'ics' }); const { service } = makeCalendarService(prisma); await service.aggregateEvents('user-a1', 't1', '2026-01-01T00:00:00.000Z', '2026-01-31T00:00:00.000Z'); const findManyCallsAfterUserA = prisma.__boundCallLog.filter( (c: any) => c.model === 'calendarSource' && c.method === 'findMany', ).length; await service.aggregateEvents('user-a2', 't1', '2026-01-01T00:00:00.000Z', '2026-01-31T00:00:00.000Z'); const findManyCallsAfterUserB = prisma.__boundCallLog.filter( (c: any) => c.model === 'calendarSource' && c.method === 'findMany', ).length; expect(findManyCallsAfterUserB).toBe(findManyCallsAfterUserA + 1); }); // ─── testConnectionFromConfig ─────────────────────────────────────────── it('testConnectionFromConfig: kein Datenbankzugriff, weder gebunden noch ungebunden — der Nachbau bleibt unberuehrt', async () => { const prisma = makeFakePrisma(); const { service } = makeCalendarService(prisma); await service.testConnectionFromConfig({ type: 'ics', url: 'https://example.invalid/cal.ics', } as any); expect(prisma.__boundCallLog).toEqual([]); expect(vi.mocked(prisma.calendarSource.findMany)).not.toHaveBeenCalled(); expect(vi.mocked(prisma.calendarSource.findUnique)).not.toHaveBeenCalled(); expect(vi.mocked(prisma.calendarSource.create)).not.toHaveBeenCalled(); }); // ─── Wachhund ──────────────────────────────────────────────────────── it('keine Methode dieses Bereichs erzeugt mehr als EINEN gebundenen Klienten je Aufruf', async () => { const prisma = makeFakePrisma(); prisma.__seedSource({ id: 'src-a1', userId: 'user-a1', tenantId: 't1', isVisible: true, type: 'ics' }); const { service } = makeCalendarService(prisma); for (const call of [ () => service.getSources('user-a1', 't1'), () => service.addSource('user-a1', 't1', { name: 'X', type: 'ics', url: 'https://example.invalid/x.ics' } as any), () => service.updateSource('src-a1', 'user-a1', 't1', { name: 'Y' } as any), () => service.testConnection('src-a1', 'user-a1', 't1'), () => service.aggregateEvents('user-a1', 't1', '2026-02-01T00:00:00.000Z', '2026-02-02T00:00:00.000Z'), () => service.deleteSource('src-a1', 'user-a1', 't1'), ]) { vi.mocked(forTenant).mockClear(); await call().catch(() => undefined); expect( vi.mocked(forTenant).mock.calls.length, `Aufruf erzeugte ${vi.mocked(forTenant).mock.calls.length} gebundene Klienten, erwartet genau 1`, ).toBe(1); } }); });