test(15-05): add failing test for module-filtered getWidgets

- covers every <behavior> case from 15-05-PLAN.md task 2, including the
  adjacency/empty/ordering/idempotency edge-probe categories and the
  fail-closed unresolved-slug case
- RED confirmed: 4/8 fail against the current 1-arg getWidgets(userId)
This commit is contained in:
2026-08-04 15:31:35 +02:00
parent 954cd6e171
commit d0ff6f0bc0
@@ -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<string, string> = {};
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<typeof makeWidget>[];
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<string>) {
return {
getAccessibleModuleIds: vi.fn(async () => accessibleIds),
};
}
/**
* DashboardService.getWidgets — Modulfilter (D-22, PERM-07). Deckt jeden
* Fall aus 15-05-PLAN.md <behavior> 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([]);
});
});