From 8f2c13a35ffc73d00ca0d369c09582143f225891 Mon Sep 17 00:00:00 2001 From: Schalli Date: Fri, 11 Sep 2026 13:50:46 +0200 Subject: [PATCH] =?UTF-8?q?test(260911-gwh):=20RED=20=E2=80=94=20neue=20Te?= =?UTF-8?q?stdateien=20fuer=20favorites/settings=20mit=20dem=20Zwei-Klient?= =?UTF-8?q?en-Nachbau?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - favorites.service.spec.ts (NEU, 23 Faelle): ungebundener Nachbau hat KEIN favoriteLink/widgetInstance-Modell; erwartet die Zielsignatur list/create/update/remove/getIconBytes(tenantId, ...) und den Widget-Besitzriegel in create -- scheitert erwartungsgemaess an "Cannot read properties of undefined" gegen den heutigen Dienst (22/23 rot) - settings.service.spec.ts (NEU, 20 Faelle): ungebundener Nachbau bietet fuer smtpConfig NUR findFirst, gebundener Klient NUR findUnique/upsert; erwartet loadAnySmtpConfigForStartupTransport() (Startpfad-Umbenennung) und den Null-Klienten-Nachweis fuer den Startpfad -- 18/20 rot Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01AMASaSxv5QMY7RncqZriRR --- .../src/favorites/favorites.service.spec.ts | 506 ++++++++++++++++ .../api/src/settings/settings.service.spec.ts | 566 ++++++++++++++++++ 2 files changed, 1072 insertions(+) create mode 100644 apps/api/src/favorites/favorites.service.spec.ts create mode 100644 apps/api/src/settings/settings.service.spec.ts diff --git a/apps/api/src/favorites/favorites.service.spec.ts b/apps/api/src/favorites/favorites.service.spec.ts new file mode 100644 index 0000000..86b37ef --- /dev/null +++ b/apps/api/src/favorites/favorites.service.spec.ts @@ -0,0 +1,506 @@ +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); + } + }); + }); +}); diff --git a/apps/api/src/settings/settings.service.spec.ts b/apps/api/src/settings/settings.service.spec.ts new file mode 100644 index 0000000..7d15090 --- /dev/null +++ b/apps/api/src/settings/settings.service.spec.ts @@ -0,0 +1,566 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import * as nodemailer from 'nodemailer'; +import { SettingsService } from './settings.service'; +import { forTenant } from '../prisma/prisma-tenant.extension'; + +/** + * SettingsService.spec — NEU (260911-gwh). Der Bereich `settings` hatte VOR + * diesem Lauf KEINE Testdatei (Befund J). Zwei-Klienten-Nachbau, aber mit + * einer GRENZE als Bauform (anders als `favorites`): der UNGEBUNDENE Nachbau + * bietet fuer `smtpConfig` AUSSCHLIESSLICH `findFirst` (der Startpfad) — + * KEIN `findUnique`, KEIN `upsert`; der GEBUNDENE Klient bietet + * AUSSCHLIESSLICH `findUnique`/`upsert` — KEIN `findFirst`. Ein gebundener + * Startpfad scheitert damit ebenso hart wie ein ungebundener Anfrageweg + * ("X is not a function" statt eines stillen Fallbacks). + * + * `nodemailer` wird per `vi.mock` ersetzt — kein echter Transport (lokal + * gibt es keinen `mailhog`). + */ +vi.mock('../prisma/prisma-tenant.extension', () => ({ + forTenant: vi.fn((unboundClient: any, tenantId: string) => unboundClient.__makeBoundClient(tenantId)), +})); + +let mockVerify = vi.fn(async () => true); +let mockSendMail = vi.fn(async () => ({})); +vi.mock('nodemailer', () => ({ + createTransport: vi.fn(() => ({ + verify: (...args: unknown[]) => (mockVerify as any)(...args), + sendMail: (...args: unknown[]) => (mockSendMail as any)(...args), + })), +})); + +interface FakeSmtpRow { + id: string; + tenantId: string; + host: string; + port: number; + encryption: string; + username: string | null; + encryptedPassword: string | null; + fromAddress: string; + createdAt?: Date; + updatedAt?: Date; +} + +interface BoundCall { + tenantId: string; + model: 'smtpConfig'; + method: string; +} + +function applySelect(row: Record, select?: Record) { + if (!select) return { ...row }; + const out: Record = {}; + for (const key of Object.keys(select)) { + if (select[key]) out[key] = row[key]; + } + return out; +} + +function makeFakePrisma(rows: FakeSmtpRow[] = []) { + const configs = new Map(rows.map((r) => [r.tenantId, { ...r }])); + const boundCallLog: BoundCall[] = []; + let autoId = rows.length; + + const unboundSmtpConfig = { + findFirst: vi.fn(async () => { + const first = configs.values().next().value; + return first ? { ...first } : null; + }), + }; + + function makeScopedSmtpConfig(tenantId: string) { + return { + findUnique: async ({ where, select }: any) => { + boundCallLog.push({ tenantId, model: 'smtpConfig', method: 'findUnique' }); + const row = configs.get(where.tenantId); + if (!row || row.tenantId !== tenantId) return null; + return applySelect(row, select); + }, + upsert: async ({ where, create, update, select }: any) => { + boundCallLog.push({ tenantId, model: 'smtpConfig', method: 'upsert' }); + if (where.tenantId !== tenantId) return null; + const existing = configs.get(tenantId); + const record = existing + ? { ...existing, ...update } + : { id: `smtp-${++autoId}`, createdAt: new Date(), ...create }; + record.updatedAt = new Date(); + configs.set(tenantId, record); + return applySelect(record, select); + }, + }; + } + + const fake: any = { + smtpConfig: unboundSmtpConfig, + __configs: configs, + __boundCallLog: boundCallLog, + __makeBoundClient(tenantId: string) { + return { smtpConfig: makeScopedSmtpConfig(tenantId) }; + }, + }; + return fake; +} + +function expectBoundCall(prisma: any, tenantId: string, method: string) { + const found = prisma.__boundCallLog.some( + (c: BoundCall) => c.tenantId === tenantId && c.model === 'smtpConfig' && c.method === method, + ); + expect( + found, + `erwarteter gebundener Aufruf smtpConfig.${method}(tenant=${tenantId}) fehlt im Protokoll: ${JSON.stringify(prisma.__boundCallLog)}`, + ).toBe(true); +} + +function makeFakeCrypto() { + return { + encrypt: vi.fn((s: string) => `enc(${s})`), + decrypt: vi.fn((s: string) => s.slice(4, -1)), + }; +} + +beforeEach(() => { + vi.clearAllMocks(); + mockVerify = vi.fn(async () => true); + mockSendMail = vi.fn(async () => ({})); +}); + +describe('SettingsService — Bindung an forTenant() (260911-gwh)', () => { + it('scheitert an "X is not a function", wenn getSmtpConfig versehentlich ungebunden auf dem Basisclient laeuft (Falsifizierungsform)', () => { + const prisma = makeFakePrisma(); + expect((prisma.smtpConfig as any).findUnique).toBeUndefined(); + expect((prisma.smtpConfig as any).upsert).toBeUndefined(); + }); + + describe('getSmtpConfig', () => { + it('liefert die Zeile mit encryptedPassword (fuer hasPassword), ohne Felder ausserhalb des select', async () => { + const prisma = makeFakePrisma([ + { + id: 'smtp-a', + tenantId: 't1', + host: 'smtp-a.example.invalid', + port: 587, + encryption: 'starttls', + username: 'user-a', + encryptedPassword: 'enc(geheim)', + fromAddress: 'a@example.invalid', + }, + ]); + const crypto = makeFakeCrypto(); + const service = new SettingsService(prisma as any, crypto as any); + + const result = await service.getSmtpConfig('t1'); + + expect(result?.encryptedPassword).toBe('enc(geheim)'); + expect(result?.host).toBe('smtp-a.example.invalid'); + expectBoundCall(prisma, 't1', 'findUnique'); + }); + + it('fremder Mandant: null', async () => { + const prisma = makeFakePrisma([ + { + id: 'smtp-a', + tenantId: 't1', + host: 'smtp-a.example.invalid', + port: 587, + encryption: 'starttls', + username: null, + encryptedPassword: null, + fromAddress: 'a@example.invalid', + }, + ]); + const service = new SettingsService(prisma as any, makeFakeCrypto() as any); + + const result = await service.getSmtpConfig('t2'); + + expect(result).toBeNull(); + }); + }); + + describe('saveSmtpConfig', () => { + it('mit Kennwort: encrypt wird GENAU mit dem Klartext aufgerufen, upsert traegt encryptedPassword, Rueckgabe OHNE encryptedPassword', async () => { + const prisma = makeFakePrisma(); + const crypto = makeFakeCrypto(); + const service = new SettingsService(prisma as any, crypto as any); + + const result = await service.saveSmtpConfig('t1', { + host: 'smtp-a.example.invalid', + port: 587, + encryption: 'starttls', + username: 'user-a', + password: 'klartext-geheim', + fromAddress: 'a@example.invalid', + } as any); + + expect(crypto.encrypt).toHaveBeenCalledWith('klartext-geheim'); + expect((result as any).encryptedPassword).toBeUndefined(); + const stored = prisma.__configs.get('t1'); + expect(stored.encryptedPassword).toBe('enc(klartext-geheim)'); + expectBoundCall(prisma, 't1', 'upsert'); + const logged = JSON.stringify(prisma.__boundCallLog); + expect(logged).not.toContain('klartext-geheim'); + }); + + it('ohne Kennwort (leer oder fehlend): encrypt NICHT aufgerufen, update traegt KEINEN Schluessel encryptedPassword, username fehlend -> null', async () => { + const prisma = makeFakePrisma([ + { + id: 'smtp-a', + tenantId: 't1', + host: 'alt.example.invalid', + port: 587, + encryption: 'starttls', + username: 'alt-user', + encryptedPassword: 'enc(alt-geheim)', + fromAddress: 'a@example.invalid', + }, + ]); + const crypto = makeFakeCrypto(); + const service = new SettingsService(prisma as any, crypto as any); + + await service.saveSmtpConfig('t1', { + host: 'neu.example.invalid', + port: 587, + encryption: 'starttls', + password: '', + fromAddress: 'a@example.invalid', + } as any); + + expect(crypto.encrypt).not.toHaveBeenCalled(); + const stored = prisma.__configs.get('t1'); + expect(stored.encryptedPassword).toBe('enc(alt-geheim)'); // bestehendes bleibt + expect(stored.username).toBeNull(); + }); + + it('unter t2, wenn nur t1 eine Zeile hat: legt fuer t2 an, t1 bleibt unveraendert (Semantik nach der Bindung)', async () => { + const prisma = makeFakePrisma([ + { + id: 'smtp-a', + tenantId: 't1', + host: 'a.example.invalid', + port: 587, + encryption: 'starttls', + username: null, + encryptedPassword: null, + fromAddress: 'a@example.invalid', + }, + ]); + const service = new SettingsService(prisma as any, makeFakeCrypto() as any); + + await service.saveSmtpConfig('t2', { + host: 'b.example.invalid', + port: 587, + encryption: 'starttls', + fromAddress: 'b@example.invalid', + } as any); + + expect(prisma.__configs.get('t1').host).toBe('a.example.invalid'); + expect(prisma.__configs.get('t2').host).toBe('b.example.invalid'); + }); + }); + + describe('getDecryptedSmtpConfig', () => { + it('entschluesselt encryptedPassword, liefert die Form die tender-mail.service.ts erwartet', async () => { + const prisma = makeFakePrisma([ + { + id: 'smtp-a', + tenantId: 't1', + host: 'smtp-a.example.invalid', + port: 587, + encryption: 'starttls', + username: 'user-a', + encryptedPassword: 'enc(geheim)', + fromAddress: 'a@example.invalid', + }, + ]); + const crypto = makeFakeCrypto(); + const service = new SettingsService(prisma as any, crypto as any); + + const result = await service.getDecryptedSmtpConfig('t1'); + + expect(crypto.decrypt).toHaveBeenCalledWith('enc(geheim)'); + expect(result).toEqual({ + host: 'smtp-a.example.invalid', + port: 587, + encryption: 'starttls', + username: 'user-a', + fromAddress: 'a@example.invalid', + decryptedPassword: 'geheim', + }); + }); + + it('ohne encryptedPassword: decryptedPassword null, decrypt NICHT aufgerufen', async () => { + const prisma = makeFakePrisma([ + { + id: 'smtp-a', + tenantId: 't1', + host: 'smtp-a.example.invalid', + port: 587, + encryption: 'starttls', + username: null, + encryptedPassword: null, + fromAddress: 'a@example.invalid', + }, + ]); + const crypto = makeFakeCrypto(); + const service = new SettingsService(prisma as any, crypto as any); + + const result = await service.getDecryptedSmtpConfig('t1'); + + expect(result?.decryptedPassword).toBeNull(); + expect(crypto.decrypt).not.toHaveBeenCalled(); + }); + + it('T-GWH-01: fremder Mandant -> null, decrypt NICHT aufgerufen', async () => { + const prisma = makeFakePrisma([ + { + id: 'smtp-a', + tenantId: 't1', + host: 'smtp-a.example.invalid', + port: 587, + encryption: 'starttls', + username: 'user-a', + encryptedPassword: 'enc(geheim)', + fromAddress: 'a@example.invalid', + }, + ]); + const crypto = makeFakeCrypto(); + const service = new SettingsService(prisma as any, crypto as any); + + const result = await service.getDecryptedSmtpConfig('t2'); + + expect(result).toBeNull(); + expect(crypto.decrypt).not.toHaveBeenCalled(); + }); + }); + + describe('testSmtpConfig', () => { + const storedRow: FakeSmtpRow = { + id: 'smtp-a', + tenantId: 't1', + host: 'smtp-a.example.invalid', + port: 587, + encryption: 'starttls', + username: 'user-a', + encryptedPassword: 'enc(geheim)', + fromAddress: 'a@example.invalid', + }; + + it('ohne Kennwort/Benutzername im DTO: greift auf die gespeicherten Werte zurueck, ruft verify auf, { success: true }', async () => { + const prisma = makeFakePrisma([storedRow]); + const service = new SettingsService(prisma as any, makeFakeCrypto() as any); + + const result = await service.testSmtpConfig('t1', { + host: 'smtp-a.example.invalid', + port: 587, + encryption: 'starttls', + fromAddress: 'a@example.invalid', + } as any); + + expect(result).toEqual({ success: true }); + expect(mockVerify).toHaveBeenCalled(); + expect(mockSendMail).not.toHaveBeenCalled(); + expect(nodemailer.createTransport).toHaveBeenCalledWith( + expect.objectContaining({ auth: { user: 'user-a', pass: 'geheim' } }), + ); + }); + + it('mit testTo: sendMail statt verify, from/to aus dto', async () => { + const prisma = makeFakePrisma([storedRow]); + const service = new SettingsService(prisma as any, makeFakeCrypto() as any); + + await service.testSmtpConfig('t1', { + host: 'smtp-a.example.invalid', + port: 587, + encryption: 'starttls', + fromAddress: 'a@example.invalid', + testTo: 'ziel@example.invalid', + } as any); + + expect(mockSendMail).toHaveBeenCalledWith( + expect.objectContaining({ from: 'a@example.invalid', to: 'ziel@example.invalid' }), + ); + expect(mockVerify).not.toHaveBeenCalled(); + }); + + it('verify wirft -> { success: false }, kein Wurf nach aussen', async () => { + mockVerify = vi.fn(async () => { + throw new Error('ECONNREFUSED'); + }); + const prisma = makeFakePrisma([storedRow]); + const service = new SettingsService(prisma as any, makeFakeCrypto() as any); + + const result = await service.testSmtpConfig('t1', { + host: 'smtp-a.example.invalid', + port: 587, + encryption: 'starttls', + fromAddress: 'a@example.invalid', + } as any); + + expect(result.success).toBe(false); + }); + + it('fremder Mandant ohne Kennwort im DTO: auth ohne Benutzer, kein Fehler', async () => { + const prisma = makeFakePrisma([storedRow]); + const service = new SettingsService(prisma as any, makeFakeCrypto() as any); + + await service.testSmtpConfig('t2', { + host: 'x.example.invalid', + port: 587, + encryption: 'starttls', + fromAddress: 'x@example.invalid', + } as any); + + expect(nodemailer.createTransport).toHaveBeenCalledWith( + expect.objectContaining({ auth: undefined }), + ); + }); + }); + + describe('loadAnySmtpConfigForStartupTransport (Startpfad, bewusst ungebunden)', () => { + it('laeuft ueber den UNGEBUNDENEN Nachbau (findFirst), liefert secure/requireTLS/entschluesseltes Kennwort', async () => { + const prisma = makeFakePrisma([ + { + id: 'smtp-a', + tenantId: 't1', + host: 'smtp-a.example.invalid', + port: 465, + encryption: 'ssl-tls', + username: 'user-a', + encryptedPassword: 'enc(geheim)', + fromAddress: 'a@example.invalid', + }, + ]); + const crypto = makeFakeCrypto(); + const service = new SettingsService(prisma as any, crypto as any); + + const result = await service.loadAnySmtpConfigForStartupTransport(); + + expect(result).toEqual({ + host: 'smtp-a.example.invalid', + port: 465, + secure: true, + requireTLS: false, + username: 'user-a', + password: 'geheim', + fromAddress: 'a@example.invalid', + }); + }); + + it('requireTLS bei starttls', async () => { + const prisma = makeFakePrisma([ + { + id: 'smtp-a', + tenantId: 't1', + host: 'smtp-a.example.invalid', + port: 587, + encryption: 'starttls', + username: null, + encryptedPassword: null, + fromAddress: 'a@example.invalid', + }, + ]); + const service = new SettingsService(prisma as any, makeFakeCrypto() as any); + + const result = await service.loadAnySmtpConfigForStartupTransport(); + + expect(result?.secure).toBe(false); + expect(result?.requireTLS).toBe(true); + }); + + it('leerer Nachbau -> null', async () => { + const prisma = makeFakePrisma([]); + const service = new SettingsService(prisma as any, makeFakeCrypto() as any); + + const result = await service.loadAnySmtpConfigForStartupTransport(); + + expect(result).toBeNull(); + }); + + it('Null-Klienten-Nachweis: der Startpfad erzeugt KEINEN gebundenen Klienten (gemessen, nicht behauptet)', async () => { + const prisma = makeFakePrisma([ + { + id: 'smtp-a', + tenantId: 't1', + host: 'smtp-a.example.invalid', + port: 587, + encryption: 'starttls', + username: null, + encryptedPassword: null, + fromAddress: 'a@example.invalid', + }, + ]); + const service = new SettingsService(prisma as any, makeFakeCrypto() as any); + + vi.mocked(forTenant).mockClear(); + await service.loadAnySmtpConfigForStartupTransport(); + + expect(vi.mocked(forTenant).mock.calls.length).toBe(0); + }); + }); + + describe('Wachhund je Anfrageweg', () => { + const storedRow: FakeSmtpRow = { + id: 'smtp-a', + tenantId: 't1', + host: 'smtp-a.example.invalid', + port: 587, + encryption: 'starttls', + username: 'user-a', + encryptedPassword: 'enc(geheim)', + fromAddress: 'a@example.invalid', + }; + + it('genau EIN gebundener Klient je Aufruf von getSmtpConfig/saveSmtpConfig/getDecryptedSmtpConfig', async () => { + const prisma = makeFakePrisma([storedRow]); + const service = new SettingsService(prisma as any, makeFakeCrypto() as any); + + for (const call of [ + () => service.getSmtpConfig('t1'), + () => + service.saveSmtpConfig('t1', { + host: 'x.invalid', + port: 587, + encryption: 'starttls', + fromAddress: 'x@invalid.de', + } as any), + () => service.getDecryptedSmtpConfig('t1'), + ]) { + vi.mocked(forTenant).mockClear(); + await call(); + expect(vi.mocked(forTenant).mock.calls.length).toBe(1); + } + }); + + it('testSmtpConfig erzeugt genau einen gebundenen Klienten, wenn es auf gespeicherte Zugangsdaten zurueckgreift', async () => { + const prisma = makeFakePrisma([storedRow]); + const service = new SettingsService(prisma as any, makeFakeCrypto() as any); + + vi.mocked(forTenant).mockClear(); + await service.testSmtpConfig('t1', { + host: 'smtp-a.example.invalid', + port: 587, + encryption: 'starttls', + fromAddress: 'a@example.invalid', + } as any); + + expect(vi.mocked(forTenant).mock.calls.length).toBe(1); + }); + + it('testSmtpConfig erzeugt KEINEN gebundenen Klienten, wenn Kennwort und Benutzername im DTO stehen', async () => { + const prisma = makeFakePrisma([storedRow]); + const service = new SettingsService(prisma as any, makeFakeCrypto() as any); + + vi.mocked(forTenant).mockClear(); + await service.testSmtpConfig('t1', { + host: 'smtp-a.example.invalid', + port: 587, + encryption: 'starttls', + username: 'anderer-user', + password: 'anderes-kennwort', + fromAddress: 'a@example.invalid', + } as any); + + expect(vi.mocked(forTenant).mock.calls.length).toBe(0); + }); + }); +});