Files
tessera-ctl/apps/api/src/dashboard/dashboard.service.spec.ts
T
schalli 3f5afb0f54 feat(quick-260916-bwo): Dashboard-Raster verdoppelt (24 Spalten, 20 px, 8 px Abstand), Konstanten x2, einmalige Umrechnung gespeicherter Anordnungen mit Marker __gridVersion
- dashboard-grid.tsx: COLS 24/20/12/8/2, rowHeight 20, margin 8 (containerPadding folgt), Rueckfallwerte 4
- widget-registry.tsx: alle 32 Werte in WIDGET_CONSTRAINTS verdoppelt
- grid-layout-migration.ts (neu): migrateGridLayouts/withGridVersion, Marker nur im JSON, Idempotenz (T-BWO-02)
- dashboard-store.ts: Umrechnung beim Laden, Sofort-Speichern mit Marker, withGridVersion bei jedem saveLayout
- Tests: Migration 7 (neu), Store 6 (neu), Registry +1 (Tabelle), Grid +2 (Props ueber Mock), API-Spec +2 (Durchreichung __gridVersion, timeFontSizePt)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018N9CD3ebPKm1b32bPpBknY
2026-09-16 09:09:00 +02:00

746 lines
33 KiB
TypeScript

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<string, string>);
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<string, unknown>;
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<typeof makeWidget>[];
modules?: { id: string; slug: string }[];
layout?: { userId: string; tenantId: string; layouts: unknown } | null;
searchProviders?: ReturnType<typeof makeSearchProvider>[];
} = {},
) {
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<string>) {
return {
getAccessibleModuleIds: vi.fn(async () => accessibleIds),
};
}
/**
* Bindungsnachweis: mindestens ein Aufruf von `<tenantId>.<model>.<method>`
* 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<string, unknown>).__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<string, unknown>).timeFontSizePt).toBeNull();
expect((second.config as Record<string, unknown>).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);
}
});
});