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], })); import { DashboardService } from './dashboard.service'; function makeWidget( overrides: Partial<{ id: string; userId: string; widgetType: string; createdAt: Date; }> = {}, ) { return { id: overrides.id ?? 'w1', userId: overrides.userId ?? 'user-1', tenantId: 'tenant-1', widgetType: overrides.widgetType ?? 'clock', config: {}, createdAt: overrides.createdAt ?? new Date('2026-01-01'), }; } function makeFakePrisma( opts: { widgets?: ReturnType[]; modules?: { id: string; slug: string }[]; } = {}, ) { const widgets = opts.widgets ?? []; const modules = opts.modules ?? []; return { 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()); }), delete: vi.fn(), }, module: { findMany: vi.fn(async ({ where }: any) => { const slugs: string[] = where.slug.in; return modules.filter((m) => slugs.includes(m.slug)); }), }, }; } function makeFakeModuleAccessService(accessibleIds: Set) { return { getAccessibleModuleIds: vi.fn(async () => accessibleIds), }; } /** * DashboardService.getWidgets — Modulfilter (D-22, PERM-07). Deckt jeden * Fall aus 15-05-PLAN.md inklusive der Edge-Probe-Kategorien * adjacency/empty/ordering/idempotency ab. */ describe('DashboardService.getWidgets — Modulfilter (D-22, PERM-07)', () => { beforeEach(() => { for (const key of Object.keys(mockMap)) delete mockMap[key]; }); 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([]); }); });