import { beforeEach, describe, expect, it, vi } from 'vitest'; /** * WIDGET_MODULE_MAP wird je Testfall über eine gemeinsame, gemockte * Objektreferenz (`mockMap`) gesteuert: `getModuleSlugForWidgetType` * liest `mockMap` erst zur Aufrufzeit, deshalb genügt eine Mutation vor * jedem Test statt eines dynamischen Imports mit `vi.resetModules()` je * Testfall. `vi.mock` selbst wird von Vitest an den Dateianfang gehoben — * `mockMap` muss deshalb über `vi.hoisted` deklariert werden, sonst * schlägt der Zugriff im Factory-Callback mit einem * Initialisierungsfehler fehl. */ const mockMap = vi.hoisted(() => ({}) as Record); vi.mock('./widget-module-map', () => ({ WIDGET_MODULE_MAP: mockMap, getModuleSlugForWidgetType: (widgetType: string) => mockMap[widgetType], })); /** * Bindung an forTenant() (260910-krx, Aufgabe 2). Dasselbe Muster wie * `module-access.service.spec.ts` (260910-exd): der gebundene Klient ist * ein ZWEITES, von `prisma` unterscheidbares Objekt über DEMSELBEN * Speicher, das protokolliert, welche Aufrufe über ihn liefen (Modellname, * Methodenname, Mandantenkennung). Ein reiner Identitäts-Mock * (`forTenant: vi.fn((p) => p)`) könnte einen vergessenen Bindungsaufruf * nicht von einem ungebundenen Aufruf unterscheiden. * * `module` wird NICHT gewrappt — der Katalogzugriff läuft bewusst über den * ungebundenen Klienten (Aufgabe 1, Befund E/H übernommen aus * `module-registry`): die Tabelle trägt heute keinen Zeilenschutz, eine * Bindung wäre heute wirkungslos. */ vi.mock('../prisma/prisma-tenant.extension', () => ({ forTenant: vi.fn((prisma: any, tenantId: string) => prisma.__makeBoundClient(tenantId)), })); import { ConflictException } from '@nestjs/common'; import { Prisma } from '@prisma/client'; import { forTenant } from '../prisma/prisma-tenant.extension'; import { DashboardService } from './dashboard.service'; /** Modelle, die `__makeBoundClient()` je Aufruf mit einem eigenen, das * Herkunfts-Tenant protokollierenden Wrapper versieht. `module` ist bewusst * NICHT enthalten — der Katalogzugriff bleibt ungebunden. `searchProvider` * ergaenzt seit Aufgabe 3 (260910-krx). */ const BOUND_MODEL_NAMES = ['dashboardLayout', 'widgetInstance', 'searchProvider']; function makeWidget( overrides: Partial<{ id: string; userId: string; tenantId: string; widgetType: string; config: Record; createdAt: Date; }> = {}, ) { return { id: overrides.id ?? 'w1', userId: overrides.userId ?? 'user-1', tenantId: overrides.tenantId ?? 'tenant-1', widgetType: overrides.widgetType ?? 'clock', config: overrides.config ?? {}, createdAt: overrides.createdAt ?? new Date('2026-01-01'), }; } function makeSearchProvider( overrides: Partial<{ id: string; userId: string | null; tenantId: string | null; name: string; urlTemplate: string; isDefault: boolean; createdAt: Date; }> = {}, ) { return { id: overrides.id ?? 'sp1', userId: overrides.userId ?? 'user-1', tenantId: overrides.tenantId ?? 'tenant-1', name: overrides.name ?? 'Eigene Suche', urlTemplate: overrides.urlTemplate ?? 'https://example.test/?q={query}', isDefault: overrides.isDefault ?? false, createdAt: overrides.createdAt ?? new Date('2026-01-01'), }; } function makeFakePrisma( opts: { widgets?: ReturnType[]; modules?: { id: string; slug: string }[]; layout?: { userId: string; tenantId: string; layouts: unknown } | null; searchProviders?: ReturnType[]; } = {}, ) { const widgets = opts.widgets ?? []; const modules = opts.modules ?? []; let layoutRow = opts.layout ?? null; const searchProviders = opts.searchProviders ?? []; const boundCallLog: { tenantId: string; model: string; method: string }[] = []; const fake: any = { dashboardLayout: { findUnique: vi.fn(async ({ where }: any) => { if (layoutRow && layoutRow.userId === where.userId) return layoutRow; return null; }), upsert: vi.fn(async ({ where, update, create }: any) => { if (layoutRow && layoutRow.userId === where.userId) { layoutRow = { ...layoutRow, layouts: update.layouts }; return layoutRow; } layoutRow = { userId: create.userId, tenantId: create.tenantId, layouts: create.layouts }; return layoutRow; }), }, widgetInstance: { findMany: vi.fn(async ({ where }: any) => { return widgets .filter((w) => w.userId === where.userId) .slice() .sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime()); }), create: vi.fn(async ({ data }: any) => { const created = makeWidget({ id: `new-${widgets.length + 1}`, ...data }); widgets.push(created); return created; }), findUnique: vi.fn(async ({ where }: any) => { return widgets.find((w) => w.id === where.id) ?? null; }), update: vi.fn(async ({ where, data }: any) => { const widget = widgets.find((w) => w.id === where.id); if (!widget) return null; Object.assign(widget, data); return widget; }), delete: vi.fn(async ({ where }: any) => { const idx = widgets.findIndex((w) => w.id === where.id); if (idx === -1) return null; const [removed] = widgets.splice(idx, 1); return removed; }), }, module: { findMany: vi.fn(async ({ where }: any) => { const slugs: string[] = where.slug.in; return modules.filter((m) => slugs.includes(m.slug)); }), }, searchProvider: { findMany: vi.fn(async ({ where }: any) => { return searchProviders .filter((p) => p.userId === where.userId) .slice() .sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime()); }), create: vi.fn(async ({ data }: any) => { const created = makeSearchProvider({ id: `new-sp-${searchProviders.length + 1}`, ...data }); searchProviders.push(created); return created; }), findUnique: vi.fn(async ({ where }: any) => { return searchProviders.find((p) => p.id === where.id) ?? null; }), delete: vi.fn(async ({ where }: any) => { const idx = searchProviders.findIndex((p) => p.id === where.id); if (idx === -1) return null; const [removed] = searchProviders.splice(idx, 1); return removed; }), }, // --- Bindungsnachweis (260910-krx, Muster aus 260910-exd) -------------- __boundCallLog: boundCallLog, __makeBoundClient(tenantId: string) { const bound: any = { __isBoundClient: true, __tenantId: tenantId }; for (const modelName of BOUND_MODEL_NAMES) { const model = fake[modelName]; const wrapped: any = {}; for (const method of Object.keys(model)) { wrapped[method] = async (...args: any[]) => { boundCallLog.push({ tenantId, model: modelName, method }); return model[method](...args); }; } bound[modelName] = wrapped; } return bound; }, }; return fake; } function makeFakeModuleAccessService(accessibleIds: Set) { return { getAccessibleModuleIds: vi.fn(async () => accessibleIds), }; } /** * Bindungsnachweis: mindestens ein Aufruf von `..` * lief über den gebundenen Client (nicht über den rohen, ungebundenen * Fake). Ein vergessener `forTenant()`-Aufruf hinterlässt hier KEINEN * Eintrag und lässt den Test fehlschlagen. */ function expectBoundCall(prisma: any, tenantId: string, model: string, method: string) { const found = prisma.__boundCallLog.some( (c: any) => c.tenantId === tenantId && c.model === model && c.method === method, ); expect( found, `erwarteter gebundener Aufruf ${model}.${method}(tenant=${tenantId}) fehlt im Protokoll: ${JSON.stringify(prisma.__boundCallLog)}`, ).toBe(true); } /** * Wachhund-Gegenprobe: ein Modell darf im Bindungsprotokoll gar nicht * vorkommen — das ist der Testfall, der jemanden erwischt, der den bewusst * ungebundenen Katalogzugriff später versehentlich bindet. */ function expectNeverBound(prisma: any, model: string) { const found = prisma.__boundCallLog.some((c: any) => c.model === model); expect( found, `Modell "${model}" darf nie im Bindungsprotokoll auftauchen (Katalog bleibt ungebunden): ${JSON.stringify(prisma.__boundCallLog)}`, ).toBe(false); } describe('DashboardService.getWidgets — Modulfilter (D-22, PERM-07)', () => { beforeEach(() => { for (const key of Object.keys(mockMap)) delete mockMap[key]; vi.mocked(forTenant).mockClear(); }); it('empty/PERM-07: liefert bei leerer Zuordnungstabelle exakt die Prisma-Menge, ohne Zugriffs-Lookup', async () => { const widgets = [ makeWidget({ id: 'w1', widgetType: 'clock' }), makeWidget({ id: 'w2', widgetType: 'search' }), ]; const prisma = makeFakePrisma({ widgets }); const moduleAccessService = makeFakeModuleAccessService(new Set()); const service = new DashboardService(prisma as any, moduleAccessService as any); const result = await service.getWidgets('user-1', 'tenant-1', 'USER' as any); expect(result).toEqual(widgets); expect(moduleAccessService.getAccessibleModuleIds).not.toHaveBeenCalled(); }); it('bleibt sichtbar, wenn der Widget-Typ in der Tabelle steht und das Modul zugänglich ist', async () => { mockMap['tender-radar'] = 'tender-radar'; const widget = makeWidget({ id: 'w1', widgetType: 'tender-radar' }); const prisma = makeFakePrisma({ widgets: [widget], modules: [{ id: 'mod-1', slug: 'tender-radar' }], }); const moduleAccessService = makeFakeModuleAccessService(new Set(['mod-1'])); const service = new DashboardService(prisma as any, moduleAccessService as any); const result = await service.getWidgets('user-1', 'tenant-1', 'USER' as any); expect(result).toEqual([widget]); }); it('fehlt, wenn der Widget-Typ in der Tabelle steht und das Modul nicht zugänglich ist', async () => { mockMap['tender-radar'] = 'tender-radar'; const widget = makeWidget({ id: 'w1', widgetType: 'tender-radar' }); const prisma = makeFakePrisma({ widgets: [widget], modules: [{ id: 'mod-1', slug: 'tender-radar' }], }); const moduleAccessService = makeFakeModuleAccessService(new Set()); const service = new DashboardService(prisma as any, moduleAccessService as any); const result = await service.getWidgets('user-1', 'tenant-1', 'USER' as any); expect(result).toEqual([]); }); it('D-03: ein ADMIN sieht ein modulgebundenes Widget eines aktiven Moduls auch ohne eigenen Grant', async () => { mockMap['tender-radar'] = 'tender-radar'; const widget = makeWidget({ id: 'w1', userId: 'admin-1', widgetType: 'tender-radar' }); const prisma = makeFakePrisma({ widgets: [widget], modules: [{ id: 'mod-1', slug: 'tender-radar' }], }); // Simuliert den D-03-Kurzschluss der Zugriffsauflösung aus 15-01: ADMIN erhält alle aktiven Module. const moduleAccessService = makeFakeModuleAccessService(new Set(['mod-1'])); const service = new DashboardService(prisma as any, moduleAccessService as any); const result = await service.getWidgets('admin-1', 'tenant-1', 'ADMIN' as any); expect(result).toEqual([widget]); expect(moduleAccessService.getAccessibleModuleIds).toHaveBeenCalledWith( 'tenant-1', 'admin-1', 'ADMIN', ); }); it('adjacency/PERM-07: ein Widget-Typ, der nicht in der Tabelle steht, bleibt unabhängig von jeder Zugriffsentscheidung erhalten', async () => { mockMap['tender-radar'] = 'tender-radar'; const boundWidget = makeWidget({ id: 'w1', widgetType: 'tender-radar' }); const platformWidget = makeWidget({ id: 'w2', widgetType: 'clock' }); const prisma = makeFakePrisma({ widgets: [boundWidget, platformWidget], modules: [{ id: 'mod-1', slug: 'tender-radar' }], }); const moduleAccessService = makeFakeModuleAccessService(new Set()); // kein Zugriff auf tender-radar const service = new DashboardService(prisma as any, moduleAccessService as any); const result = await service.getWidgets('user-1', 'tenant-1', 'USER' as any); expect(result).toEqual([platformWidget]); }); it('ordering/PERM-07: die Sortierung nach createdAt aufsteigend bleibt nach dem Filtern erhalten', async () => { mockMap['tender-radar'] = 'tender-radar'; const w1 = makeWidget({ id: 'w1', widgetType: 'clock', createdAt: new Date('2026-01-01') }); const w2 = makeWidget({ id: 'w2', widgetType: 'tender-radar', createdAt: new Date('2026-01-02'), }); const w3 = makeWidget({ id: 'w3', widgetType: 'search', createdAt: new Date('2026-01-03') }); const prisma = makeFakePrisma({ widgets: [w1, w2, w3], modules: [{ id: 'mod-1', slug: 'tender-radar' }], }); const moduleAccessService = makeFakeModuleAccessService(new Set(['mod-1'])); const service = new DashboardService(prisma as any, moduleAccessService as any); const result = await service.getWidgets('user-1', 'tenant-1', 'USER' as any); expect(result.map((w: any) => w.id)).toEqual(['w1', 'w2', 'w3']); }); it('idempotency/PERM-07: getWidgets löscht keine WidgetInstance-Zeile — nur findMany wird aufgerufen', async () => { mockMap['tender-radar'] = 'tender-radar'; const widget = makeWidget({ id: 'w1', widgetType: 'tender-radar' }); const prisma = makeFakePrisma({ widgets: [widget], modules: [{ id: 'mod-1', slug: 'tender-radar' }], }); const moduleAccessService = makeFakeModuleAccessService(new Set()); const service = new DashboardService(prisma as any, moduleAccessService as any); await service.getWidgets('user-1', 'tenant-1', 'USER' as any); await service.getWidgets('user-1', 'tenant-1', 'USER' as any); expect(prisma.widgetInstance.delete).not.toHaveBeenCalled(); expect(prisma.widgetInstance.findMany).toHaveBeenCalledTimes(2); }); it('Fail-Closed: existiert zu einem eingetragenen Modul-Slug kein Module-Datensatz, wird das Widget herausgefiltert', async () => { mockMap['ghost-module'] = 'ghost-module'; const widget = makeWidget({ id: 'w1', widgetType: 'ghost-module' }); const prisma = makeFakePrisma({ widgets: [widget], modules: [] }); const moduleAccessService = makeFakeModuleAccessService(new Set(['irgendeine-id'])); const service = new DashboardService(prisma as any, moduleAccessService as any); const result = await service.getWidgets('user-1', 'tenant-1', 'USER' as any); expect(result).toEqual([]); }); }); // --- Bindung an forTenant() (260910-krx, Aufgabe 2) ------------------------- describe('DashboardService — Anordnung und Widgets gebunden an forTenant() (260910-krx, Aufgabe 2)', () => { beforeEach(() => { for (const key of Object.keys(mockMap)) delete mockMap[key]; vi.mocked(forTenant).mockClear(); }); it('getLayout: der Lesezugriff läuft über den gebundenen Klienten, mit der übergebenen Mandantenkennung im Protokoll', async () => { const prisma = makeFakePrisma({ layout: { userId: 'user-1', tenantId: 'tenant-1', layouts: { lg: [{ i: 'w1' }] } }, }); const moduleAccessService = makeFakeModuleAccessService(new Set()); const service = new DashboardService(prisma as any, moduleAccessService as any); const result = await service.getLayout('user-1', 'tenant-1'); expect(result).toEqual({ lg: [{ i: 'w1' }] }); expectBoundCall(prisma, 'tenant-1', 'dashboardLayout', 'findUnique'); // Benutzerdimension (260911-nke): forTenant() bekommt userId als drittes Argument. expect(forTenant).toHaveBeenCalledWith(prisma, 'tenant-1', 'user-1'); }); it('getLayout: kein Widget/keine Anordnung vorhanden liefert die Vorgabeanordnung, keinen Fehler — heutiges Verhalten, damit eine spätere Änderung sichtbar wird', async () => { const prisma = makeFakePrisma({ layout: null }); const moduleAccessService = makeFakeModuleAccessService(new Set()); const service = new DashboardService(prisma as any, moduleAccessService as any); const result = await service.getLayout('user-1', 'tenant-1'); expect(result).toEqual({ lg: [], md: [], sm: [], xs: [], xxs: [] }); }); it('saveLayout: der Schreibzugriff läuft über den gebundenen Klienten mit derselben Mandantenkennung', async () => { const prisma = makeFakePrisma({}); const moduleAccessService = makeFakeModuleAccessService(new Set()); const service = new DashboardService(prisma as any, moduleAccessService as any); await service.saveLayout('user-1', 'tenant-1', { layouts: { lg: [] } } as any); expectBoundCall(prisma, 'tenant-1', 'dashboardLayout', 'upsert'); }); /** * Gegenrichtung der Bindung (w4, 260910-krx). `DashboardLayout.userId` ist * plattformweit eindeutig, ohne Mandantenanteil. Ist die vorhandene Zeile * unter dem gebundenen Kontext unsichtbar, laeuft das `upsert` in einen * Konflikt — und der aeussert sich hier NICHT als der bekannte P2002-Fehler * (`PrismaClientKnownRequestError`), sondern als * `PrismaClientUnknownRequestError`, weil die Zeilenschutz-Regel den * Schreibzugriff mit SQLSTATE 42501 abweist, bevor die Eindeutigkeit * ueberhaupt geprueft wird. Gemessen in `rls-scratch-check.mjs` * (`dashboardlayout-gebundenes-upsert-auf-unsichtbare-zeile-wirft-unknown`). * * Dieser Test fehlte in der ersten Lieferung — die Zusammenfassung berief * sich auf eine nicht committete Ad-hoc-Messung. Vom Verifizierer gefunden. */ describe('saveLayout: Konflikt auf unsichtbare Zeile (w4)', () => { it('uebersetzt PrismaClientUnknownRequestError in eine ConflictException statt in einen 500', async () => { const prisma = makeFakePrisma({}); const moduleAccessService = makeFakeModuleAccessService(new Set()); const unknown = new Prisma.PrismaClientUnknownRequestError( 'Error occurred during query execution: ConnectorError(... new row violates row-level security policy ...)', { clientVersion: 'test' }, ); prisma.dashboardLayout.upsert = vi.fn(async () => { throw unknown; }); const service = new DashboardService(prisma as any, moduleAccessService as any); await expect( service.saveLayout('user-1', 'tenant-1', { layouts: { lg: [] } } as any), ).rejects.toBeInstanceOf(ConflictException); }); it('reicht einen bekannten Prisma-Fehler (z.B. P2002) unveraendert durch — der ist hier NICHT der gemessene Fall', async () => { const prisma = makeFakePrisma({}); const moduleAccessService = makeFakeModuleAccessService(new Set()); const known = new Prisma.PrismaClientKnownRequestError('Unique constraint failed', { code: 'P2002', clientVersion: 'test', }); prisma.dashboardLayout.upsert = vi.fn(async () => { throw known; }); const service = new DashboardService(prisma as any, moduleAccessService as any); await expect( service.saveLayout('user-1', 'tenant-1', { layouts: { lg: [] } } as any), ).rejects.toBe(known); }); }); it('Anordnung lesen und speichern sind GEMEINSAM gebunden: beide laufen über denselben gebundenen Klienten und dieselbe Mandantenkennung', async () => { const prisma = makeFakePrisma({ layout: { userId: 'user-1', tenantId: 'tenant-1', layouts: {} }, }); const moduleAccessService = makeFakeModuleAccessService(new Set()); const service = new DashboardService(prisma as any, moduleAccessService as any); await service.getLayout('user-1', 'tenant-1'); await service.saveLayout('user-1', 'tenant-1', { layouts: { lg: [] } } as any); expectBoundCall(prisma, 'tenant-1', 'dashboardLayout', 'findUnique'); expectBoundCall(prisma, 'tenant-1', 'dashboardLayout', 'upsert'); }); it('Widgets lesen: der Widget-Lesezugriff läuft gebunden, der Katalogzugriff NICHT', async () => { const prisma = makeFakePrisma({ widgets: [] }); const moduleAccessService = makeFakeModuleAccessService(new Set()); const service = new DashboardService(prisma as any, moduleAccessService as any); const result = await service.getWidgets('user-1', 'tenant-1', 'USER' as any); expect(result).toEqual([]); expectBoundCall(prisma, 'tenant-1', 'widgetInstance', 'findMany'); expectNeverBound(prisma, 'module'); }); it('Widget anlegen: gebunden, mit der übergebenen Mandantenkennung', async () => { const prisma = makeFakePrisma({}); const moduleAccessService = makeFakeModuleAccessService(new Set()); const service = new DashboardService(prisma as any, moduleAccessService as any); await service.addWidget('user-1', 'tenant-1', { widgetType: 'clock' } as any); expectBoundCall(prisma, 'tenant-1', 'widgetInstance', 'create'); }); it('Widget-Konfiguration ändern: BEIDE Abfragen (Besitzprüfung und Änderung) laufen über DENSELBEN gebundenen Klienten und dieselbe Mandantenkennung', async () => { const widget = makeWidget({ id: 'w1', userId: 'user-1' }); const prisma = makeFakePrisma({ widgets: [widget] }); const moduleAccessService = makeFakeModuleAccessService(new Set()); const service = new DashboardService(prisma as any, moduleAccessService as any); await service.updateWidgetConfig('w1', 'user-1', 'tenant-1', { config: { foo: 'bar' } } as any); expectBoundCall(prisma, 'tenant-1', 'widgetInstance', 'findUnique'); expectBoundCall(prisma, 'tenant-1', 'widgetInstance', 'update'); }); it('Widget entfernen: ebenso, beide Abfragen über denselben Klienten', async () => { const widget = makeWidget({ id: 'w1', userId: 'user-1' }); const prisma = makeFakePrisma({ widgets: [widget] }); const moduleAccessService = makeFakeModuleAccessService(new Set()); const service = new DashboardService(prisma as any, moduleAccessService as any); await service.removeWidget('w1', 'user-1', 'tenant-1'); expectBoundCall(prisma, 'tenant-1', 'widgetInstance', 'findUnique'); expectBoundCall(prisma, 'tenant-1', 'widgetInstance', 'delete'); }); it('Besitzprüfung bleibt wirksam beim Ändern: ein Widget eines anderen Benutzers führt weiterhin zu NotFoundException — die Bindung ERGÄNZT die Prüfung über die Benutzerkennung, sie ersetzt sie nicht', async () => { const widget = makeWidget({ id: 'w1', userId: 'other-user' }); const prisma = makeFakePrisma({ widgets: [widget] }); const moduleAccessService = makeFakeModuleAccessService(new Set()); const service = new DashboardService(prisma as any, moduleAccessService as any); await expect( service.updateWidgetConfig('w1', 'user-1', 'tenant-1', { config: {} } as any), ).rejects.toThrow("Widget with id 'w1' not found"); }); it('Besitzprüfung bleibt wirksam beim Entfernen: ein Widget eines anderen Benutzers führt weiterhin zu NotFoundException', async () => { const widget = makeWidget({ id: 'w1', userId: 'other-user' }); const prisma = makeFakePrisma({ widgets: [widget] }); const moduleAccessService = makeFakeModuleAccessService(new Set()); const service = new DashboardService(prisma as any, moduleAccessService as any); await expect(service.removeWidget('w1', 'user-1', 'tenant-1')).rejects.toThrow( "Widget with id 'w1' not found", ); }); it('keine Methode dieses Bereichs erzeugt mehr als EINEN gebundenen Klienten je Aufruf', async () => { const widget = makeWidget({ id: 'w1', userId: 'user-1' }); const prisma = makeFakePrisma({ widgets: [widget], layout: { userId: 'user-1', tenantId: 'tenant-1', layouts: {} }, }); const moduleAccessService = makeFakeModuleAccessService(new Set()); const service = new DashboardService(prisma as any, moduleAccessService as any); for (const call of [ () => service.getLayout('user-1', 'tenant-1'), () => service.saveLayout('user-1', 'tenant-1', { layouts: {} } as any), () => service.getWidgets('user-1', 'tenant-1', 'USER' as any), () => service.addWidget('user-1', 'tenant-1', { widgetType: 'clock' } as any), () => service.updateWidgetConfig('w1', 'user-1', 'tenant-1', { config: {} } as any), () => service.removeWidget('w1', 'user-1', 'tenant-1'), ]) { 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); } }); it('Kein Widget vorhanden: der Rückgabewert ist eine leere Liste, kein Fehler — Deutung von Leere als Abwesenheit, heutiges Verhalten', async () => { const prisma = makeFakePrisma({ widgets: [] }); const moduleAccessService = makeFakeModuleAccessService(new Set()); const service = new DashboardService(prisma as any, moduleAccessService as any); const result = await service.getWidgets('user-1', 'tenant-1', 'USER' as any); expect(result).toEqual([]); }); // --- Durchreichung fuer das feinere Dashboard-Raster (quick-260916-bwo) --- // Die API kennt weder die Raster-Einheiten noch den Marker; sie reicht das // `layouts`-JSON (`@IsObject()`) unveraendert durch. Diese beiden Tests sind // nach heutigem Code bereits gruen — sie PINNEN die Durchreichung, damit eine // spaetere Bereinigung des JSON die Frontend-Umrechnung nicht unbemerkt // bricht (fehlt der Marker beim Laden, verdoppelt das Frontend erneut). it('quick-260916-bwo Test A: der Marker __gridVersion ueberlebt saveLayout -> getLayout unveraendert', async () => { const prisma = makeFakePrisma({}); const moduleAccessService = makeFakeModuleAccessService(new Set()); const service = new DashboardService(prisma as any, moduleAccessService as any); const stored = { lg: [{ i: 'w1', x: 0, y: 0, w: 4, h: 4 }], __gridVersion: 2 }; await service.saveLayout('user-1', 'tenant-1', { layouts: stored } as any); const result = await service.getLayout('user-1', 'tenant-1'); expect(result).toEqual(stored); expect((result as Record).__gridVersion).toBe(2); }); it('quick-260916-bwo Test B: timeFontSizePt wird in die Widget-Konfiguration gemischt, null ueberschreibt, timezone bleibt', async () => { // Die API prueft Config-Felder nicht (`@IsObject()`); die Grenzen 8..200 // liegen im Frontend (`clock-font-size.ts`), ein Fremdwert faellt dort auf // "automatisch" zurueck (T-BWO-01). const widget = makeWidget({ id: 'w1', userId: 'user-1', config: { timezone: 'Europe/Berlin' } }); const prisma = makeFakePrisma({ widgets: [widget] }); const moduleAccessService = makeFakeModuleAccessService(new Set()); const service = new DashboardService(prisma as any, moduleAccessService as any); const first = await service.updateWidgetConfig('w1', 'user-1', 'tenant-1', { config: { timeFontSizePt: 36 }, } as any); expect(first.config).toEqual({ timezone: 'Europe/Berlin', timeFontSizePt: 36 }); const second = await service.updateWidgetConfig('w1', 'user-1', 'tenant-1', { config: { timeFontSizePt: null }, } as any); expect((second.config as Record).timeFontSizePt).toBeNull(); expect((second.config as Record).timezone).toBe('Europe/Berlin'); }); }); // --- Bindung an forTenant() (260910-krx, Aufgabe 3: Suchmaschinen) --------- describe('DashboardService — Suchmaschinen gebunden an forTenant(), Katalog bewusst ungebunden (260910-krx, Aufgabe 3)', () => { beforeEach(() => { for (const key of Object.keys(mockMap)) delete mockMap[key]; vi.mocked(forTenant).mockClear(); }); it('Suchmaschinen lesen: der Lesezugriff läuft gebunden, die drei Vorgabe-Suchmaschinen aus der Konstante werden UNVERÄNDERT vorangestellt', async () => { const own = makeSearchProvider({ id: 'sp1', userId: 'user-1', tenantId: 'tenant-1' }); const prisma = makeFakePrisma({ searchProviders: [own] }); const moduleAccessService = makeFakeModuleAccessService(new Set()); const service = new DashboardService(prisma as any, moduleAccessService as any); const result = await service.getSearchProviders('user-1', 'tenant-1'); expect(result.map((p: any) => p.id)).toEqual(['google', 'bing', 'ddg', 'sp1']); expectBoundCall(prisma, 'tenant-1', 'searchProvider', 'findMany'); }); it('Suchmaschine anlegen: gebunden, mit der übergebenen Mandantenkennung, die Mandantenkennung bleibt Pflichtangabe — wird rot, sobald ein Schreibweg ohne Mandantenkennung eingeführt wird', async () => { const prisma = makeFakePrisma({}); const moduleAccessService = makeFakeModuleAccessService(new Set()); const service = new DashboardService(prisma as any, moduleAccessService as any); const created = await service.addSearchProvider('user-1', 'tenant-1', { name: 'Intranet', urlTemplate: 'https://intranet.test/?q={query}', } as any); expect(created.tenantId).toBe('tenant-1'); expectBoundCall(prisma, 'tenant-1', 'searchProvider', 'create'); expect(prisma.searchProvider.create).toHaveBeenCalledWith( expect.objectContaining({ data: expect.objectContaining({ tenantId: 'tenant-1' }) }), ); }); it('Suchmaschine entfernen: BEIDE Abfragen über DENSELBEN gebundenen Klienten', async () => { const own = makeSearchProvider({ id: 'sp1', userId: 'user-1', tenantId: 'tenant-1' }); const prisma = makeFakePrisma({ searchProviders: [own] }); const moduleAccessService = makeFakeModuleAccessService(new Set()); const service = new DashboardService(prisma as any, moduleAccessService as any); await service.removeSearchProvider('sp1', 'user-1', 'tenant-1'); expectBoundCall(prisma, 'tenant-1', 'searchProvider', 'findUnique'); expectBoundCall(prisma, 'tenant-1', 'searchProvider', 'delete'); }); it('Besitzprüfung bleibt wirksam: die Suchmaschine eines anderen Benutzers führt weiterhin zu NotFoundException', async () => { const foreign = makeSearchProvider({ id: 'sp1', userId: 'other-user' }); const prisma = makeFakePrisma({ searchProviders: [foreign] }); const moduleAccessService = makeFakeModuleAccessService(new Set()); const service = new DashboardService(prisma as any, moduleAccessService as any); await expect(service.removeSearchProvider('sp1', 'user-1', 'tenant-1')).rejects.toThrow( "Search provider with id 'sp1' not found", ); }); it('eine der drei Vorgabe-Suchmaschinen lässt sich weiterhin nicht entfernen (userId null fällt in den Nicht-gefunden-Zweig)', async () => { const prisma = makeFakePrisma({}); const moduleAccessService = makeFakeModuleAccessService(new Set()); const service = new DashboardService(prisma as any, moduleAccessService as any); await expect(service.removeSearchProvider('google', 'user-1', 'tenant-1')).rejects.toThrow( "Search provider with id 'google' not found", ); }); it('Wachhund: der Modulkatalog taucht im Bindungsprotokoll nie auf, auch nicht nach der Suchmaschinen-Bindung — und der Katalogpfad wird tatsächlich durchlaufen, nicht nur theoretisch geprüft', async () => { mockMap['tender-radar'] = 'tender-radar'; const boundWidget = makeWidget({ id: 'w1', widgetType: 'tender-radar' }); const own = makeSearchProvider({ id: 'sp1', userId: 'user-1', tenantId: 'tenant-1' }); const prisma = makeFakePrisma({ searchProviders: [own], widgets: [boundWidget], modules: [{ id: 'mod-1', slug: 'tender-radar' }], }); const moduleAccessService = makeFakeModuleAccessService(new Set(['mod-1'])); const service = new DashboardService(prisma as any, moduleAccessService as any); await service.getSearchProviders('user-1', 'tenant-1'); await service.addSearchProvider('user-1', 'tenant-1', { name: 'Intranet', urlTemplate: 'https://intranet.test/?q={query}', } as any); await service.getWidgets('user-1', 'tenant-1', 'USER' as any); // Beweist, dass der Katalogzugriff tatsaechlich lief (sonst waere die // Wachhund-Pruefung unten wirkungslos, weil sie nichts protokollieren // koennte). expect(prisma.module.findMany).toHaveBeenCalled(); expectNeverBound(prisma, 'module'); }); it('keine Methode dieses Bereichs erzeugt mehr als EINEN gebundenen Klienten je Aufruf — auch fuer die drei Suchmaschinen-Methoden', async () => { const own = makeSearchProvider({ id: 'sp1', userId: 'user-1', tenantId: 'tenant-1' }); const prisma = makeFakePrisma({ searchProviders: [own] }); const moduleAccessService = makeFakeModuleAccessService(new Set()); const service = new DashboardService(prisma as any, moduleAccessService as any); for (const call of [ () => service.getSearchProviders('user-1', 'tenant-1'), () => service.addSearchProvider('user-1', 'tenant-1', { name: 'x', urlTemplate: 'https://x.test/?q={query}', } as any), () => service.removeSearchProvider('sp1', 'user-1', 'tenant-1'), ]) { 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); } }); });