import { BadRequestException, HttpException, NotFoundException } from '@nestjs/common'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { FavoritesService } from './favorites.service'; import { forTenant } from '../prisma/prisma-tenant.extension'; /** * FavoritesService.spec — NEU (260911-gwh). Der Bereich `favorites` hatte * VOR diesem Lauf KEINE Testdatei fuer den Dienst (Befund J; nur * `icon-discovery.service.spec.ts` existierte). Zwei-Klienten-Nachbau * (Muster `dkv.service.spec.ts`/`auth.service.spec.ts`): `forTenant()` wird * auf `unboundClient.__makeBoundClient(tenantId)` umgeleitet. Der * UNGEBUNDENE Nachbau (der Fake selbst) hat KEINES der Anfrage-Modelle * (`favoriteLink`, `widgetInstance`) — ein versehentlich ungebundener * Modellzugriff scheitert mit "Cannot read properties of undefined" (die * dkv-Form der Falsifizierung, siehe auth.service.spec.ts:280). Der * GEBUNDENE Klient hat ausschliesslich `favoriteLink`/`widgetInstance`. */ vi.mock('../prisma/prisma-tenant.extension', () => ({ forTenant: vi.fn((unboundClient: any, tenantId: string) => unboundClient.__makeBoundClient(tenantId)), })); interface FakeFavoriteRow { id: string; userId: string; tenantId: string; widgetId: string; title: string; url: string; iconUrl: string | null; position: number; createdAt?: Date; updatedAt?: Date; } interface FakeWidgetRow { id: string; userId: string; tenantId: string; } interface BoundCall { tenantId: string; model: 'favoriteLink' | 'widgetInstance'; method: string; } function throwP2025(action: 'update' | 'delete'): never { const err: any = new Error( action === 'update' ? 'An operation failed because it depends on one or more records that were required but not found. No record was found for an update.' : 'An operation failed because it depends on one or more records that were required but not found. No record was found for a delete.', ); err.code = 'P2025'; throw err; } /** * Zwei-Klienten-Nachbau: `favorites`/`widgets` sind das gemeinsame * Gedaechtnis, der gebundene Klient (`__makeBoundClient`) protokolliert * jeden Zugriff im `boundCallLog` — der ungebundene Basisclient (der Fake * selbst) traegt KEIN `favoriteLink`/`widgetInstance` und protokolliert * deshalb strukturell nie. */ function makeFakePrisma(favoriteRows: FakeFavoriteRow[] = [], widgetRows: FakeWidgetRow[] = []) { const favorites = new Map(favoriteRows.map((f) => [f.id, { ...f }])); const widgets = new Map(widgetRows.map((w) => [w.id, { ...w }])); const boundCallLog: BoundCall[] = []; let autoId = favoriteRows.length; function makeScopedFavoriteLink(tenantId: string) { return { findMany: async ({ where, orderBy }: any) => { boundCallLog.push({ tenantId, model: 'favoriteLink', method: 'findMany' }); let rows = Array.from(favorites.values()).filter((f) => f.tenantId === tenantId); if (where?.userId) rows = rows.filter((f) => f.userId === where.userId); if (where?.widgetId) rows = rows.filter((f) => f.widgetId === where.widgetId); if (orderBy) { rows = [...rows].sort((a, b) => { for (const clause of orderBy) { const [key, dir] = Object.entries(clause as Record)[0]; const av = (a as any)[key]; const bv = (b as any)[key]; if (av < bv) return dir === 'asc' ? -1 : 1; if (av > bv) return dir === 'asc' ? 1 : -1; } return 0; }); } return rows; }, findUnique: async ({ where }: any) => { boundCallLog.push({ tenantId, model: 'favoriteLink', method: 'findUnique' }); const row = favorites.get(where.id); if (!row || row.tenantId !== tenantId) return null; return { ...row }; }, create: async ({ data }: any) => { boundCallLog.push({ tenantId, model: 'favoriteLink', method: 'create' }); const id = data.id ?? `fav-${++autoId}`; const now = new Date(); const record = { iconUrl: null, position: 0, createdAt: now, updatedAt: now, ...data, id }; favorites.set(id, record); return record; }, update: async ({ where, data }: any) => { boundCallLog.push({ tenantId, model: 'favoriteLink', method: 'update' }); const row = favorites.get(where.id); if (!row || row.tenantId !== tenantId) throwP2025('update'); const updated = { ...row, ...data, updatedAt: new Date() }; favorites.set(where.id, updated); return updated; }, delete: async ({ where }: any) => { boundCallLog.push({ tenantId, model: 'favoriteLink', method: 'delete' }); const row = favorites.get(where.id); if (!row || row.tenantId !== tenantId) throwP2025('delete'); favorites.delete(where.id); return row; }, }; } function makeScopedWidgetInstance(tenantId: string) { return { findUnique: async ({ where, select }: any) => { boundCallLog.push({ tenantId, model: 'widgetInstance', method: 'findUnique' }); const row = widgets.get(where.id); if (!row || row.tenantId !== tenantId) return null; if (!select) return { ...row }; const picked: any = {}; for (const key of Object.keys(select)) { if (select[key]) picked[key] = (row as any)[key]; } return picked; }, }; } const fake: any = { __favorites: favorites, __widgets: widgets, __boundCallLog: boundCallLog, __makeBoundClient(tenantId: string) { return { favoriteLink: makeScopedFavoriteLink(tenantId), widgetInstance: makeScopedWidgetInstance(tenantId), }; }, }; return fake; } function expectBoundCall( prisma: any, tenantId: string, model: 'favoriteLink' | 'widgetInstance', method: string, ) { const found = prisma.__boundCallLog.some( (c: BoundCall) => 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 makeIconDiscovery( overrides: Partial<{ discoverFavoriteIconUrl: any; fetchIconBytes: any }> = {}, ) { return { discoverFavoriteIconUrl: overrides.discoverFavoriteIconUrl ?? vi.fn(async (url: string) => `https://icons.invalid/${encodeURIComponent(url)}`), fetchIconBytes: overrides.fetchIconBytes ?? vi.fn(async () => ({ contentType: 'image/png', body: Buffer.from('png') })), }; } beforeEach(() => { vi.clearAllMocks(); }); describe('FavoritesService — Bindung an forTenant() (260911-gwh)', () => { it('scheitert an "Cannot read properties of undefined", wenn ein Favoritenzugriff versehentlich ungebunden auf dem Basisclient laeuft (Falsifizierungsform)', () => { const prisma = makeFakePrisma(); expect(prisma.favoriteLink).toBeUndefined(); expect(prisma.widgetInstance).toBeUndefined(); }); describe('list', () => { it('liefert nur die Zeilen von user-a1 fuer widget-a1, sortiert nach position, dann title', async () => { const prisma = makeFakePrisma([ { id: 'f1', userId: 'user-a1', tenantId: 't1', widgetId: 'widget-a1', title: 'B', url: 'https://b.invalid', iconUrl: null, position: 1 }, { id: 'f2', userId: 'user-a1', tenantId: 't1', widgetId: 'widget-a1', title: 'A', url: 'https://a.invalid', iconUrl: null, position: 0 }, { id: 'f3', userId: 'user-a2', tenantId: 't1', widgetId: 'widget-a1', title: 'C', url: 'https://c.invalid', iconUrl: null, position: 0 }, { id: 'f4', userId: 'user-a1', tenantId: 't1', widgetId: 'widget-a2', title: 'D', url: 'https://d.invalid', iconUrl: null, position: 0 }, ]); const service = new FavoritesService(prisma as any, makeIconDiscovery() as any); const result = await service.list('t1', 'user-a1', 'widget-a1'); expect(result.map((r: any) => r.id)).toEqual(['f2', 'f1']); expectBoundCall(prisma, 't1', 'favoriteLink', 'findMany'); }); it('liefert unter einem FREMDEN Mandanten eine leere Liste, kein Fehler (der Wert, aus dem das Widget "Noch keine Favoriten." macht)', async () => { const prisma = makeFakePrisma([ { id: 'f1', userId: 'user-a1', tenantId: 't1', widgetId: 'widget-a1', title: 'B', url: 'https://b.invalid', iconUrl: null, position: 0 }, ]); const service = new FavoritesService(prisma as any, makeIconDiscovery() as any); const result = await service.list('t2', 'user-a1', 'widget-a1'); expect(result).toEqual([]); }); it('wirft BadRequestException ohne widgetId, OHNE einen Klienten zu erzeugen', async () => { const prisma = makeFakePrisma(); const service = new FavoritesService(prisma as any, makeIconDiscovery() as any); await expect(service.list('t1', 'user-a1', '')).rejects.toThrow(BadRequestException); expect(vi.mocked(forTenant).mock.calls.length).toBe(0); }); }); describe('create', () => { it('normalisiert die URL, sucht das Icon mit der NORMALISIERTEN URL, und legt mit tenantId/userId/widgetId/position=0 an, wenn iconUrl fehlt', async () => { const prisma = makeFakePrisma([], [{ id: 'widget-a1', userId: 'user-a1', tenantId: 't1' }]); const iconDiscovery = makeIconDiscovery(); const service = new FavoritesService(prisma as any, iconDiscovery as any); const created = await service.create('t1', 'user-a1', { widgetId: 'widget-a1', title: 'CTL', url: 'ctl.de', } as any); expect(iconDiscovery.discoverFavoriteIconUrl).toHaveBeenCalledWith('https://ctl.de'); expect(created.url).toBe('https://ctl.de'); expect(created.userId).toBe('user-a1'); expect(created.tenantId).toBe('t1'); expect(created.position).toBe(0); expectBoundCall(prisma, 't1', 'favoriteLink', 'create'); }); it('sucht KEIN Icon, wenn iconUrl uebergeben wird — gespeicherter Wert bleibt wie uebergeben', async () => { const prisma = makeFakePrisma([], [{ id: 'widget-a1', userId: 'user-a1', tenantId: 't1' }]); const iconDiscovery = makeIconDiscovery(); const service = new FavoritesService(prisma as any, iconDiscovery as any); const created = await service.create('t1', 'user-a1', { widgetId: 'widget-a1', title: 'CTL', url: 'https://ctl.de', iconUrl: 'https://ctl.de/favicon.ico', } as any); expect(iconDiscovery.discoverFavoriteIconUrl).not.toHaveBeenCalled(); expect(created.iconUrl).toBe('https://ctl.de/favicon.ico'); }); it('T-GWH-05: widgetId gehoert einem ANDEREN Benutzer desselben Mandanten -> NotFoundException "Widget not found", KEIN create, KEINE Icon-Suche', async () => { const prisma = makeFakePrisma([], [{ id: 'widget-a2', userId: 'user-a2', tenantId: 't1' }]); const iconDiscovery = makeIconDiscovery(); const service = new FavoritesService(prisma as any, iconDiscovery as any); await expect( service.create('t1', 'user-a1', { widgetId: 'widget-a2', title: 'X', url: 'https://x.invalid' } as any), ).rejects.toThrow('Widget not found'); expect(iconDiscovery.discoverFavoriteIconUrl).not.toHaveBeenCalled(); expect(prisma.__favorites.size).toBe(0); }); it('T-GWH-05: widgetId gehoert einem Widget unter FREMDEM Mandanten -> dieselbe NotFoundException, nennt weder Halter noch Mandant', async () => { const prisma = makeFakePrisma([], [{ id: 'widget-b1', userId: 'user-b1', tenantId: 't2' }]); const service = new FavoritesService(prisma as any, makeIconDiscovery() as any); await expect( service.create('t1', 'user-a1', { widgetId: 'widget-b1', title: 'X', url: 'https://x.invalid' } as any), ).rejects.toThrow(NotFoundException); await expect( service.create('t1', 'user-a1', { widgetId: 'widget-b1', title: 'X', url: 'https://x.invalid' } as any), ).rejects.toThrow('Widget not found'); }); it('T-GWH-05: unbekannte widgetId -> dieselbe NotFoundException', async () => { const prisma = makeFakePrisma(); const service = new FavoritesService(prisma as any, makeIconDiscovery() as any); await expect( service.create('t1', 'user-a1', { widgetId: 'widget-fehlt', title: 'X', url: 'https://x.invalid' } as any), ).rejects.toThrow('Widget not found'); }); it('Wachhund: genau EIN gebundener Klient je create-Aufruf, Widget-Pruefung UND Schreibzugriff auf DEMSELBEN Klienten', async () => { const prisma = makeFakePrisma([], [{ id: 'widget-a1', userId: 'user-a1', tenantId: 't1' }]); const service = new FavoritesService(prisma as any, makeIconDiscovery() as any); vi.mocked(forTenant).mockClear(); await service.create('t1', 'user-a1', { widgetId: 'widget-a1', title: 'X', url: 'https://x.invalid' } as any); expect(vi.mocked(forTenant).mock.calls.length).toBe(1); expect(prisma.__boundCallLog.filter((c: BoundCall) => c.tenantId === 't1')).toEqual([ { tenantId: 't1', model: 'widgetInstance', method: 'findUnique' }, { tenantId: 't1', model: 'favoriteLink', method: 'create' }, ]); }); }); describe('update', () => { const baseRow: FakeFavoriteRow = { id: 'f1', userId: 'user-a1', tenantId: 't1', widgetId: 'widget-a1', title: 'Alt', url: 'https://alt.invalid', iconUrl: 'https://alt.invalid/icon.png', position: 0, }; it('mergt Titel/URL/Position fuer die eigene Zeile, ueber DEMSELBEN gebundenen Klienten', async () => { const prisma = makeFakePrisma([baseRow]); const service = new FavoritesService(prisma as any, makeIconDiscovery() as any); const updated = await service.update('t1', 'f1', 'user-a1', { title: 'Neu', position: 3 } as any); expect(updated.title).toBe('Neu'); expect(updated.position).toBe(3); expectBoundCall(prisma, 't1', 'favoriteLink', 'findUnique'); expectBoundCall(prisma, 't1', 'favoriteLink', 'update'); }); it('iconUrl explizit null im DTO loest eine Icon-Suche gegen die EFFEKTIVE URL aus', async () => { const prisma = makeFakePrisma([baseRow]); const iconDiscovery = makeIconDiscovery(); const service = new FavoritesService(prisma as any, iconDiscovery as any); await service.update('t1', 'f1', 'user-a1', { iconUrl: null } as any); expect(iconDiscovery.discoverFavoriteIconUrl).toHaveBeenCalledWith(baseRow.url); }); it('iconUrl gesetzt im DTO -> KEINE Icon-Suche', async () => { const prisma = makeFakePrisma([baseRow]); const iconDiscovery = makeIconDiscovery(); const service = new FavoritesService(prisma as any, iconDiscovery as any); const updated = await service.update('t1', 'f1', 'user-a1', { iconUrl: 'https://neu.invalid/icon.png' } as any); expect(iconDiscovery.discoverFavoriteIconUrl).not.toHaveBeenCalled(); expect(updated.iconUrl).toBe('https://neu.invalid/icon.png'); }); it('Zeile eines ANDEREN Benutzers desselben Mandanten -> NotFoundException, KEIN Schreibzugriff', async () => { const prisma = makeFakePrisma([{ ...baseRow, userId: 'user-a2' }]); const service = new FavoritesService(prisma as any, makeIconDiscovery() as any); await expect(service.update('t1', 'f1', 'user-a1', { title: 'X' } as any)).rejects.toThrow( 'FavoriteLink not found', ); expect(prisma.__favorites.get('f1')?.title).toBe('Alt'); }); it('Zeile unter FREMDEM Mandanten -> NotFoundException, KEIN Schreibzugriff — der gebundene findUnique liefert null, bevor irgendetwas geschrieben wird', async () => { const prisma = makeFakePrisma([baseRow]); const service = new FavoritesService(prisma as any, makeIconDiscovery() as any); await expect(service.update('t2', 'f1', 'user-a1', { title: 'X' } as any)).rejects.toThrow( NotFoundException, ); expect(prisma.__favorites.get('f1')?.title).toBe('Alt'); expect( prisma.__boundCallLog.some((c: BoundCall) => c.tenantId === 't2' && c.method === 'update'), ).toBe(false); }); }); describe('remove', () => { const baseRow: FakeFavoriteRow = { id: 'f1', userId: 'user-a1', tenantId: 't1', widgetId: 'widget-a1', title: 'X', url: 'https://x.invalid', iconUrl: null, position: 0, }; it('loescht die eigene Zeile', async () => { const prisma = makeFakePrisma([baseRow]); const service = new FavoritesService(prisma as any, makeIconDiscovery() as any); await service.remove('t1', 'f1', 'user-a1'); expect(prisma.__favorites.has('f1')).toBe(false); }); it('fremder Benutzer -> NotFoundException, Zeile bleibt', async () => { const prisma = makeFakePrisma([{ ...baseRow, userId: 'user-a2' }]); const service = new FavoritesService(prisma as any, makeIconDiscovery() as any); await expect(service.remove('t1', 'f1', 'user-a1')).rejects.toThrow('FavoriteLink not found'); expect(prisma.__favorites.has('f1')).toBe(true); }); it('fremder Mandant -> NotFoundException, Zeile bleibt', async () => { const prisma = makeFakePrisma([baseRow]); const service = new FavoritesService(prisma as any, makeIconDiscovery() as any); await expect(service.remove('t2', 'f1', 'user-a1')).rejects.toThrow(NotFoundException); expect(prisma.__favorites.has('f1')).toBe(true); }); }); describe('getIconBytes', () => { const baseRow: FakeFavoriteRow = { id: 'f1', userId: 'user-a1', tenantId: 't1', widgetId: 'widget-a1', title: 'X', url: 'https://x.invalid', iconUrl: 'https://x.invalid/icon.png', position: 0, }; it('ruft fetchIconBytes GENAU mit der GESPEICHERTEN URL auf und reicht die Rueckgabe durch', async () => { const prisma = makeFakePrisma([baseRow]); const iconDiscovery = makeIconDiscovery(); const service = new FavoritesService(prisma as any, iconDiscovery as any); const result = await service.getIconBytes('t1', 'f1', 'user-a1'); expect(iconDiscovery.fetchIconBytes).toHaveBeenCalledWith(baseRow.iconUrl); expect(result).toEqual({ contentType: 'image/png', body: Buffer.from('png') }); }); it('Zeile ohne iconUrl -> NotFoundException, fetchIconBytes NICHT aufgerufen', async () => { const prisma = makeFakePrisma([{ ...baseRow, iconUrl: null }]); const iconDiscovery = makeIconDiscovery(); const service = new FavoritesService(prisma as any, iconDiscovery as any); await expect(service.getIconBytes('t1', 'f1', 'user-a1')).rejects.toThrow('FavoriteLink not found'); expect(iconDiscovery.fetchIconBytes).not.toHaveBeenCalled(); }); it('fremder Mandant -> NotFoundException', async () => { const prisma = makeFakePrisma([baseRow]); const service = new FavoritesService(prisma as any, makeIconDiscovery() as any); await expect(service.getIconBytes('t2', 'f1', 'user-a1')).rejects.toThrow(NotFoundException); }); it('fetchIconBytes wirft -> HttpException mit Status 502', async () => { const prisma = makeFakePrisma([baseRow]); const iconDiscovery = makeIconDiscovery({ fetchIconBytes: vi.fn(async () => { throw new Error('upstream unreachable'); }), }); const service = new FavoritesService(prisma as any, iconDiscovery as any); await expect(service.getIconBytes('t1', 'f1', 'user-a1')).rejects.toThrow(HttpException); try { await service.getIconBytes('t1', 'f1', 'user-a1'); } catch (err) { expect((err as HttpException).getStatus()).toBe(502); } }); }); describe('Wachhund je Methode', () => { it('genau EIN gebundener Klient je Aufruf von list/update/remove/getIconBytes', async () => { const baseRow: FakeFavoriteRow = { id: 'f1', userId: 'user-a1', tenantId: 't1', widgetId: 'widget-a1', title: 'X', url: 'https://x.invalid', iconUrl: 'https://x.invalid/icon.png', position: 0, }; const prisma = makeFakePrisma([baseRow]); const service = new FavoritesService(prisma as any, makeIconDiscovery() as any); for (const call of [ () => service.list('t1', 'user-a1', 'widget-a1'), () => service.update('t1', 'f1', 'user-a1', { title: 'Y' } as any), () => service.getIconBytes('t1', 'f1', 'user-a1'), () => service.remove('t1', 'f1', 'user-a1'), ]) { 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); } }); }); });