import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; import { BadRequestException, HttpException, NotFoundException, PayloadTooLargeException, } from '@nestjs/common'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { FavoritesService } from './favorites.service'; import { forTenant, withTenantTransaction } 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`. * * 260917-jdd: `withTenantTransaction` kommt zum Mock hinzu (Muster * groups.service.spec.ts Z. 30-35/296-299) — `prisma.__withTenantTransaction` * reicht den gebundenen Klienten als `tx` durch und protokolliert den * Aufruf. Der Fake bekommt zusaetzlich `updateMany` auf `favoriteLink` fuer * `reorder()`. */ vi.mock('../prisma/prisma-tenant.extension', () => ({ forTenant: vi.fn((unboundClient: any, tenantId: string) => unboundClient.__makeBoundClient(tenantId)), withTenantTransaction: vi.fn((unboundClient: any, tenantId: string, fn: (tx: any) => any) => unboundClient.__withTenantTransaction(tenantId, fn), ), })); interface FakeFavoriteRow { id: string; userId: string; tenantId: string; widgetId: string; title: string; url: string; iconUrl: string | null; position: number; createdAt?: Date; updatedAt?: Date; /** 260923-lrr — Bestandszeilen im Fake bekommen die Vorgabe null/0. */ uploadedIconMime?: string | null; iconVersion?: number; } interface FakeWidgetRow { id: string; userId: string; tenantId: string; } interface BoundCall { tenantId: string; model: 'favoriteLink' | 'widgetInstance' | '$transaction'; 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, { uploadedIconMime: null, iconVersion: 0, ...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, uploadedIconMime: null, iconVersion: 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: any = { ...row, ...data, updatedAt: new Date() }; // 260923-lrr: `iconVersion: { increment: n }` — Prisma's atomic // increment form, angewendet auf den bisherigen Zaehlerstand. if (data.iconVersion && typeof data.iconVersion === 'object' && 'increment' in data.iconVersion) { updated.iconVersion = (row.iconVersion ?? 0) + data.iconVersion.increment; } 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; }, // 260917-jdd: reorder() — filtert nach tenantId sowie, falls in // `where` vorhanden, id/userId/widgetId; wendet `data` auf jede // Treffer-Zeile an; liefert { count }. updateMany: async ({ where, data }: any) => { boundCallLog.push({ tenantId, model: 'favoriteLink', method: 'updateMany' }); let rows = Array.from(favorites.values()).filter((f) => f.tenantId === tenantId); if (where?.id) rows = rows.filter((f) => f.id === where.id); if (where?.userId) rows = rows.filter((f) => f.userId === where.userId); if (where?.widgetId) rows = rows.filter((f) => f.widgetId === where.widgetId); for (const row of rows) { favorites.set(row.id, { ...row, ...data, updatedAt: new Date() }); } return { count: rows.length }; }, }; } 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), }; }, __withTenantTransaction(tenantId: string, fn: (tx: any) => any) { boundCallLog.push({ tenantId, model: '$transaction', method: 'withTenantTransaction' }); return fn(fake.__makeBoundClient(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'); // Benutzerdimension (260911-nke): forTenant() bekommt userId als drittes Argument. expect(forTenant).toHaveBeenCalledWith(prisma, 't1', 'user-a1'); }); 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); } }); }); describe('reorder (260917-jdd)', () => { const makeAltbestand = () => makeFakePrisma([ { id: 'f1', userId: 'user-a1', tenantId: 't1', widgetId: 'widget-a1', title: 'A', url: 'https://a.invalid', iconUrl: null, position: 0 }, { id: 'f2', userId: 'user-a1', tenantId: 't1', widgetId: 'widget-a1', title: 'B', url: 'https://b.invalid', iconUrl: null, position: 0 }, { id: 'f3', userId: 'user-a1', tenantId: 't1', widgetId: 'widget-a1', title: 'C', url: 'https://c.invalid', iconUrl: null, position: 0 }, { id: 'f9', userId: 'user-a2', tenantId: 't1', widgetId: 'widget-a1', title: 'D', url: 'https://d.invalid', iconUrl: null, position: 0 }, ]); it('setzt position 0/1/2 in der uebergebenen Reihenfolge und liefert die Liste so sortiert', async () => { const prisma = makeAltbestand(); const service = new FavoritesService(prisma as any, makeIconDiscovery() as any); const result = await service.reorder('t1', 'user-a1', { widgetId: 'widget-a1', ids: ['f3', 'f1', 'f2'], } as any); expect(result.map((r: any) => r.id)).toEqual(['f3', 'f1', 'f2']); expect(prisma.__favorites.get('f3').position).toBe(0); expect(prisma.__favorites.get('f1').position).toBe(1); expect(prisma.__favorites.get('f2').position).toBe(2); expect(prisma.__favorites.get('f9').position).toBe(0); expect(vi.mocked(withTenantTransaction)).toHaveBeenCalledWith(prisma, 't1', expect.any(Function)); expectBoundCall(prisma, 't1', 'favoriteLink', 'updateMany'); }); it('fremde id (user-a2) -> BadRequestException, KEINE Position geaendert', async () => { const prisma = makeAltbestand(); const service = new FavoritesService(prisma as any, makeIconDiscovery() as any); await expect( service.reorder('t1', 'user-a1', { widgetId: 'widget-a1', ids: ['f3', 'f1', 'f9'] } as any), ).rejects.toThrow(BadRequestException); expect(prisma.__favorites.get('f1').position).toBe(0); expect(prisma.__favorites.get('f2').position).toBe(0); expect(prisma.__favorites.get('f3').position).toBe(0); }); it('unbekannte id -> BadRequestException', async () => { const prisma = makeAltbestand(); const service = new FavoritesService(prisma as any, makeIconDiscovery() as any); await expect( service.reorder('t1', 'user-a1', { widgetId: 'widget-a1', ids: ['f3', 'f1', 'f-fehlt'] } as any), ).rejects.toThrow(BadRequestException); }); it('Teilmenge (2 von 3) -> BadRequestException', async () => { const prisma = makeAltbestand(); const service = new FavoritesService(prisma as any, makeIconDiscovery() as any); await expect( service.reorder('t1', 'user-a1', { widgetId: 'widget-a1', ids: ['f1', 'f2'] } as any), ).rejects.toThrow(BadRequestException); }); it('doppelte ids -> BadRequestException OHNE withTenantTransaction-Aufruf', async () => { const prisma = makeAltbestand(); const service = new FavoritesService(prisma as any, makeIconDiscovery() as any); vi.mocked(withTenantTransaction).mockClear(); await expect( service.reorder('t1', 'user-a1', { widgetId: 'widget-a1', ids: ['f1', 'f1', 'f2'] } as any), ).rejects.toThrow(BadRequestException); expect(vi.mocked(withTenantTransaction).mock.calls.length).toBe(0); }); it('fremder Mandant (t2 auf t1-Zeilen) -> BadRequestException, Positionen unveraendert', async () => { const prisma = makeAltbestand(); const service = new FavoritesService(prisma as any, makeIconDiscovery() as any); await expect( service.reorder('t2', 'user-a1', { widgetId: 'widget-a1', ids: ['f1', 'f2', 'f3'] } as any), ).rejects.toThrow(BadRequestException); expect(prisma.__favorites.get('f1').position).toBe(0); expect(prisma.__favorites.get('f2').position).toBe(0); expect(prisma.__favorites.get('f3').position).toBe(0); }); it('Wachhund: 0 forTenant-Aufrufe, genau 1 withTenantTransaction-Aufruf fuer den Happy Path', async () => { const prisma = makeAltbestand(); const service = new FavoritesService(prisma as any, makeIconDiscovery() as any); vi.mocked(forTenant).mockClear(); vi.mocked(withTenantTransaction).mockClear(); await service.reorder('t1', 'user-a1', { widgetId: 'widget-a1', ids: ['f3', 'f1', 'f2'] } as any); expect(vi.mocked(forTenant).mock.calls.length).toBe(0); expect(vi.mocked(withTenantTransaction).mock.calls.length).toBe(1); }); }); // --- 260923-lrr: eigenes Symbol, Vorrang, Versionszaehler, Abrufprobe --- describe('uploadIcon/removeUploadedIcon/getIconBytes — eigenes Symbol (260923-lrr)', () => { let iconsDir: string; const ORIGINAL_DIR_ENV = process.env.FAVORITE_ICONS_DIR; const PNG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 13]); const SVG = Buffer.from(''); const TEXT = Buffer.from('nur Text, kein Bild'); beforeEach(() => { iconsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tessera-favorite-icons-svc-')); process.env.FAVORITE_ICONS_DIR = iconsDir; }); afterEach(() => { fs.rmSync(iconsDir, { recursive: true, force: true }); if (ORIGINAL_DIR_ENV === undefined) { delete process.env.FAVORITE_ICONS_DIR; } else { process.env.FAVORITE_ICONS_DIR = ORIGINAL_DIR_ENV; } }); 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, uploadedIconMime: null, iconVersion: 0, }; function fileFor(userId: string, id: string, ext: string): string { return path.join(iconsDir, userId, `${id}.${ext}`); } describe('uploadIcon', () => { it('PNG: Datei liegt unter //.png mit genau den Bytes, Zeile hat uploadedIconMime image/png und iconVersion +1', async () => { const prisma = makeFakePrisma([baseRow]); const service = new FavoritesService(prisma as any, makeIconDiscovery() as any); const file = { buffer: PNG, originalname: 'x.png', mimetype: 'image/png', size: PNG.length }; const updated = await service.uploadIcon('t1', 'f1', 'user-a1', file); expect(updated.uploadedIconMime).toBe('image/png'); expect(updated.iconVersion).toBe(1); const written = fs.readFileSync(fileFor('user-a1', 'f1', 'png')); expect(written.equals(PNG)).toBe(true); }); it('ohne Datei -> BadRequestException', async () => { const prisma = makeFakePrisma([baseRow]); const service = new FavoritesService(prisma as any, makeIconDiscovery() as any); await expect(service.uploadIcon('t1', 'f1', 'user-a1', undefined)).rejects.toThrow( BadRequestException, ); }); it('Klartext-Puffer -> BadRequestException, keine Datei, Zeile unveraendert', async () => { const prisma = makeFakePrisma([baseRow]); const service = new FavoritesService(prisma as any, makeIconDiscovery() as any); const file = { buffer: TEXT, originalname: 'x.txt', mimetype: 'text/plain', size: TEXT.length }; await expect(service.uploadIcon('t1', 'f1', 'user-a1', file)).rejects.toThrow( BadRequestException, ); expect(fs.existsSync(path.join(iconsDir, 'user-a1'))).toBe(false); expect(prisma.__favorites.get('f1').uploadedIconMime).toBeNull(); }); it('Puffer groesser 512 KB -> PayloadTooLargeException (zweites Netz)', async () => { const prisma = makeFakePrisma([baseRow]); const service = new FavoritesService(prisma as any, makeIconDiscovery() as any); const big = Buffer.concat([PNG, Buffer.alloc(513 * 1024)]); const file = { buffer: big, originalname: 'x.png', mimetype: 'image/png', size: big.length }; await expect(service.uploadIcon('t1', 'f1', 'user-a1', file)).rejects.toThrow( PayloadTooLargeException, ); }); it('fremder Benutzer, fremder Mandant, unbekannte Kennung -> NotFoundException, keine Datei geschrieben', async () => { const file = { buffer: PNG, originalname: 'x.png', mimetype: 'image/png', size: PNG.length }; const prismaForeignUser = makeFakePrisma([{ ...baseRow, userId: 'user-a2' }]); const serviceForeignUser = new FavoritesService( prismaForeignUser as any, makeIconDiscovery() as any, ); await expect( serviceForeignUser.uploadIcon('t1', 'f1', 'user-a1', file), ).rejects.toThrow(NotFoundException); const prismaForeignTenant = makeFakePrisma([baseRow]); const serviceForeignTenant = new FavoritesService( prismaForeignTenant as any, makeIconDiscovery() as any, ); await expect( serviceForeignTenant.uploadIcon('t2', 'f1', 'user-a1', file), ).rejects.toThrow(NotFoundException); const prismaUnknown = makeFakePrisma([]); const serviceUnknown = new FavoritesService(prismaUnknown as any, makeIconDiscovery() as any); await expect( serviceUnknown.uploadIcon('t1', 'fehlt', 'user-a1', file), ).rejects.toThrow(NotFoundException); expect(fs.existsSync(path.join(iconsDir, 'user-a1'))).toBe(false); expect(fs.existsSync(path.join(iconsDir, 'user-a2'))).toBe(false); }); it('erneuter Upload mit anderem Typ (erst PNG, dann SVG): .png entfernt, .svg vorhanden, iconVersion insgesamt +2', async () => { const prisma = makeFakePrisma([baseRow]); const service = new FavoritesService(prisma as any, makeIconDiscovery() as any); const pngFile = { buffer: PNG, originalname: 'x.png', mimetype: 'image/png', size: PNG.length }; const svgFile = { buffer: SVG, originalname: 'x.svg', mimetype: 'image/svg+xml', size: SVG.length }; await service.uploadIcon('t1', 'f1', 'user-a1', pngFile); const updated = await service.uploadIcon('t1', 'f1', 'user-a1', svgFile); expect(fs.existsSync(fileFor('user-a1', 'f1', 'png'))).toBe(false); expect(fs.existsSync(fileFor('user-a1', 'f1', 'svg'))).toBe(true); expect(updated.uploadedIconMime).toBe('image/svg+xml'); expect(updated.iconVersion).toBe(2); }); }); describe('getIconBytes — Vorrang des hochgeladenen Symbols', () => { it('hochgeladenes Symbol: liefert Dateibytes und gespeicherten Typ, fetchIconBytes wird NICHT aufgerufen', async () => { const prisma = makeFakePrisma([baseRow]); const iconDiscovery = makeIconDiscovery(); const service = new FavoritesService(prisma as any, iconDiscovery as any); const file = { buffer: PNG, originalname: 'x.png', mimetype: 'image/png', size: PNG.length }; await service.uploadIcon('t1', 'f1', 'user-a1', file); const result = await service.getIconBytes('t1', 'f1', 'user-a1'); expect(result.contentType).toBe('image/png'); expect((result.body as Buffer).equals(PNG)).toBe(true); expect(iconDiscovery.fetchIconBytes).not.toHaveBeenCalled(); }); it('Typ gesetzt, aber Datei fehlt, iconUrl vorhanden -> faellt auf fetchIconBytes(iconUrl) zurueck', async () => { const prisma = makeFakePrisma([{ ...baseRow, uploadedIconMime: 'image/png' }]); 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('Typ gesetzt, Datei fehlt, KEINE iconUrl -> NotFoundException', async () => { const prisma = makeFakePrisma([ { ...baseRow, iconUrl: null, uploadedIconMime: 'image/png' }, ]); const service = new FavoritesService(prisma as any, makeIconDiscovery() as any); await expect(service.getIconBytes('t1', 'f1', 'user-a1')).rejects.toThrow( 'FavoriteLink not found', ); }); }); describe('removeUploadedIcon', () => { it('Datei weg, uploadedIconMime null, iconVersion +1', async () => { const prisma = makeFakePrisma([baseRow]); const service = new FavoritesService(prisma as any, makeIconDiscovery() as any); const file = { buffer: PNG, originalname: 'x.png', mimetype: 'image/png', size: PNG.length }; await service.uploadIcon('t1', 'f1', 'user-a1', file); const updated = await service.removeUploadedIcon('t1', 'f1', 'user-a1'); expect(updated.uploadedIconMime).toBeNull(); expect(updated.iconVersion).toBe(2); expect(fs.existsSync(fileFor('user-a1', 'f1', 'png'))).toBe(false); }); it('ohne vorhandenen Upload -> Zeile unveraendert, keine Erhoehung', async () => { const prisma = makeFakePrisma([baseRow]); const service = new FavoritesService(prisma as any, makeIconDiscovery() as any); const updated = await service.removeUploadedIcon('t1', 'f1', 'user-a1'); expect(updated.iconVersion).toBe(0); expect(updated.uploadedIconMime).toBeNull(); }); it('fremder Benutzer -> NotFoundException', async () => { const prisma = makeFakePrisma([{ ...baseRow, userId: 'user-a2' }]); const service = new FavoritesService(prisma as any, makeIconDiscovery() as any); await expect(service.removeUploadedIcon('t1', 'f1', 'user-a1')).rejects.toThrow( NotFoundException, ); }); }); describe('update — ausdrueckliche Logo-Adresse verdraengt ein hochgeladenes Symbol (260929-lh3)', () => { const file = { buffer: PNG, originalname: 'x.png', mimetype: 'image/png', size: PNG.length }; it('neue, abweichende iconUrl bei vorhandenem Upload: Upload-Typ null, Datei weg, iconVersion erneut +1 — die neue Adresse wird angezeigt', async () => { const prisma = makeFakePrisma([baseRow]); const service = new FavoritesService(prisma as any, makeIconDiscovery() as any); await service.uploadIcon('t1', 'f1', 'user-a1', file); expect(fs.existsSync(fileFor('user-a1', 'f1', 'png'))).toBe(true); const updated = await service.update('t1', 'f1', 'user-a1', { iconUrl: 'https://neu.invalid/logo.png', } as any); expect(updated.iconUrl).toBe('https://neu.invalid/logo.png'); expect(updated.uploadedIconMime).toBeNull(); expect(updated.iconVersion).toBe(2); expect(fs.existsSync(fileFor('user-a1', 'f1', 'png'))).toBe(false); }); it('UNVERAENDERTE iconUrl bei vorhandenem Upload (das Formular schickt sie bei jedem Speichern mit): Upload bleibt', async () => { const prisma = makeFakePrisma([baseRow]); const service = new FavoritesService(prisma as any, makeIconDiscovery() as any); await service.uploadIcon('t1', 'f1', 'user-a1', file); const updated = await service.update('t1', 'f1', 'user-a1', { iconUrl: baseRow.iconUrl, } as any); expect(updated.uploadedIconMime).toBe('image/png'); expect(updated.iconVersion).toBe(1); expect(fs.existsSync(fileFor('user-a1', 'f1', 'png'))).toBe(true); }); }); describe('remove() mit hochgeladenem Symbol', () => { it('Zeile und Datei weg', async () => { const prisma = makeFakePrisma([baseRow]); const service = new FavoritesService(prisma as any, makeIconDiscovery() as any); const file = { buffer: PNG, originalname: 'x.png', mimetype: 'image/png', size: PNG.length }; await service.uploadIcon('t1', 'f1', 'user-a1', file); await service.remove('t1', 'f1', 'user-a1'); expect(prisma.__favorites.has('f1')).toBe(false); expect(fs.existsSync(fileFor('user-a1', 'f1', 'png'))).toBe(false); }); it('Fehler beim Datei-Entfernen wird geschluckt — das Loeschen der Zeile gelingt trotzdem', async () => { const prisma = makeFakePrisma([baseRow]); const service = new FavoritesService(prisma as any, makeIconDiscovery() as any); const file = { buffer: PNG, originalname: 'x.png', mimetype: 'image/png', size: PNG.length }; await service.uploadIcon('t1', 'f1', 'user-a1', file); // Datei vorab entfernen, damit fs.unlink() im Dienst scheitert. fs.unlinkSync(fileFor('user-a1', 'f1', 'png')); await expect(service.remove('t1', 'f1', 'user-a1')).resolves.toBeUndefined(); expect(prisma.__favorites.has('f1')).toBe(false); }); }); }); describe('create/update — ausdrueckliche iconUrl: nur Formpruefung, kein Abruf (260929-lh3)', () => { const widgets = [{ id: 'widget-a1', userId: 'user-a1', tenantId: 't1' }]; const failingFetch = () => vi.fn(async () => { throw new Error('server bekommt 404/HTML'); }); it('create mit einer Adresse, die der SERVER nicht abrufen kann: wird gespeichert, KEIN Abruf, KEINE Erkennung', async () => { const prisma = makeFakePrisma([], widgets); const iconDiscovery = makeIconDiscovery({ fetchIconBytes: failingFetch() }); const service = new FavoritesService(prisma as any, iconDiscovery as any); const created = await service.create('t1', 'user-a1', { widgetId: 'widget-a1', title: 'Docuvita', url: 'https://docuvita.ctl.local/server/services/web/', iconUrl: 'https://docuvita.ctl.local/webclient/docuvita/resources/brandimage/favicon.ico', } as any); expect(created.iconUrl).toBe( 'https://docuvita.ctl.local/webclient/docuvita/resources/brandimage/favicon.ico', ); expect(iconDiscovery.fetchIconBytes).not.toHaveBeenCalled(); expect(iconDiscovery.discoverFavoriteIconUrl).not.toHaveBeenCalled(); expect(prisma.__favorites.size).toBe(1); }); it.each([ ['kein http/https', 'ftp://x.invalid/icon.png'], ['javascript-Schema', 'javascript:alert(1)'], ['keine Adresse', 'kein url'], ['laenger als 2048 Zeichen', `https://x.invalid/${'a'.repeat(2050)}`], ])('create mit ungueltiger iconUrl (%s) -> BadRequestException, nichts geschrieben', async (_label, iconUrl) => { const prisma = makeFakePrisma([], widgets); const service = new FavoritesService(prisma as any, makeIconDiscovery() as any); await expect( service.create('t1', 'user-a1', { widgetId: 'widget-a1', title: 'X', url: 'https://x.invalid', iconUrl, } as any), ).rejects.toThrow(BadRequestException); expect(prisma.__favorites.size).toBe(0); }); 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, uploadedIconMime: null, iconVersion: 0, }; it('update mit neuer iconUrl, die der Server nicht abrufen kann: gespeichert, iconVersion +1, KEIN Abruf', async () => { const prisma = makeFakePrisma([baseRow]); const iconDiscovery = makeIconDiscovery({ fetchIconBytes: failingFetch() }); 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(updated.iconUrl).toBe('https://neu.invalid/icon.png'); expect(updated.iconVersion).toBe(1); expect(iconDiscovery.fetchIconBytes).not.toHaveBeenCalled(); }); it('update mit ungueltiger neuer iconUrl -> BadRequestException, Zeile unveraendert', async () => { const prisma = makeFakePrisma([baseRow]); const service = new FavoritesService(prisma as any, makeIconDiscovery() as any); await expect( service.update('t1', 'f1', 'user-a1', { iconUrl: 'file:///etc/passwd' } as any), ).rejects.toThrow(BadRequestException); expect(prisma.__favorites.get('f1').iconUrl).toBe(baseRow.iconUrl); }); it('update mit UNVERAENDERTER iconUrl: keine Pruefung, keine Erhoehung', 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: baseRow.iconUrl } as any); expect(iconDiscovery.fetchIconBytes).not.toHaveBeenCalled(); expect(updated.iconVersion).toBe(0); }); it('update nur Titel: keine Erhoehung', 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' } as any); expect(updated.iconVersion).toBe(0); }); }); });