diff --git a/apps/api/src/dashboard/dashboard.service.spec.ts b/apps/api/src/dashboard/dashboard.service.spec.ts new file mode 100644 index 0000000..322054d --- /dev/null +++ b/apps/api/src/dashboard/dashboard.service.spec.ts @@ -0,0 +1,212 @@ +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 wird von Vitest ohnehin an den Dateianfang gehoben). + */ +const mockMap: 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', 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([]); + }); +});