feat(quick-260923-ad9): Datenmodell, Migration und Reiter-Grundlage - Task 1
Neues Modell Dashboard (D-01/D-02/D-09): position statt Standard-Feld, kein Unique auf (userId, position) - Umsortieren schreibt spaeter alle Positionen einer Transaktion neu. WidgetInstance/DashboardLayout haengen jetzt am Reiter statt am Benutzer (DashboardLayout.dashboardId @unique ersetzt userId @unique). Migration 20260923120000_dashboard_tabs: Zeilenschutz mit Mandant- UND Benutzerdimension (Form 20260911120000/20260921120000), Bestands- uebernahme fuer jeden Benutzer mit Kacheln oder Anordnung VOR den Fremdschluesseln (D-03) - gemessen: 0 Kacheln/Anordnungen ohne Reiter, genau 2 Reiter auf Position 0. dashboard.service.ts: listDashboards() (Transaktionssperre gegen doppelte Erstanlage, T-AD9-07), Riegel assertOwnedDashboard() (fail- closed gegen fremde Reiter, T-AD9-01/02/03) - getLayout/saveLayout/ getWidgets/addWidget laufen jetzt ueber dashboardId statt userId. GET /dashboard/tabs neu; die vier bestehenden Wege reichen die Reiter- Kennung durch. Verhalten fuer den Benutzer unveraendert (ein Reiter, wie bisher) - Task 2 ergaenzt Anlegen/Umbenennen/Loeschen/Umsortieren. dashboard.service.spec.ts: 43 Tests (31 alte unveraendert + 12 neue fuer Reiter-Anlage, -Reihenfolge und den Fremdreiter-Riegel bei allen vier Wegen). Zugriffsklassifikation nachgerechnet: 75 Paare (+1), Bereich dashboard 21->24 gebunden. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -30,12 +30,20 @@ vi.mock('./widget-module-map', () => ({
|
||||
* ungebundenen Klienten (Aufgabe 1, Befund E/H übernommen aus
|
||||
* `module-registry`): die Tabelle trägt heute keinen Zeilenschutz, eine
|
||||
* Bindung wäre heute wirkungslos.
|
||||
*
|
||||
* quick-260923-ad9 (Task 1): `withTenantTransaction` kommt zum Mock hinzu
|
||||
* (Muster favorites.service.spec.ts) — sie reicht den gebundenen Klienten
|
||||
* als `tx` durch und protokolliert den Aufruf. `listDashboards` nutzt sie
|
||||
* fürs Anlegen des ersten Reiters unter einer Transaktionssperre.
|
||||
*/
|
||||
vi.mock('../prisma/prisma-tenant.extension', () => ({
|
||||
forTenant: vi.fn((prisma: any, tenantId: string) => prisma.__makeBoundClient(tenantId)),
|
||||
withTenantTransaction: vi.fn((prisma: any, tenantId: string, fn: any) =>
|
||||
prisma.__withTenantTransaction(tenantId, fn),
|
||||
),
|
||||
}));
|
||||
|
||||
import { ConflictException } from '@nestjs/common';
|
||||
import { ConflictException, NotFoundException } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { forTenant } from '../prisma/prisma-tenant.extension';
|
||||
import { DashboardService } from './dashboard.service';
|
||||
@@ -43,14 +51,42 @@ 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'];
|
||||
* ergaenzt seit Aufgabe 3 (260910-krx). `dashboard` ergaenzt seit
|
||||
* quick-260923-ad9 (Task 1). */
|
||||
const BOUND_MODEL_NAMES = ['dashboard', 'dashboardLayout', 'widgetInstance', 'searchProvider'];
|
||||
|
||||
/** Standard-Reiter-Kennung, die die meisten Tests verwenden — ein Reiter
|
||||
* `dash-1`, der `user-1`/`tenant-1` gehört (Standard-Fixture unten). */
|
||||
const DASH_1 = 'dash-1';
|
||||
|
||||
function makeDashboard(
|
||||
overrides: Partial<{
|
||||
id: string;
|
||||
userId: string;
|
||||
tenantId: string;
|
||||
name: string;
|
||||
position: number;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}> = {},
|
||||
) {
|
||||
return {
|
||||
id: overrides.id ?? DASH_1,
|
||||
userId: overrides.userId ?? 'user-1',
|
||||
tenantId: overrides.tenantId ?? 'tenant-1',
|
||||
name: overrides.name ?? 'Dashboard',
|
||||
position: overrides.position ?? 0,
|
||||
createdAt: overrides.createdAt ?? new Date('2026-01-01'),
|
||||
updatedAt: overrides.updatedAt ?? new Date('2026-01-01'),
|
||||
};
|
||||
}
|
||||
|
||||
function makeWidget(
|
||||
overrides: Partial<{
|
||||
id: string;
|
||||
userId: string;
|
||||
tenantId: string;
|
||||
dashboardId: string;
|
||||
widgetType: string;
|
||||
config: Record<string, unknown>;
|
||||
createdAt: Date;
|
||||
@@ -60,6 +96,7 @@ function makeWidget(
|
||||
id: overrides.id ?? 'w1',
|
||||
userId: overrides.userId ?? 'user-1',
|
||||
tenantId: overrides.tenantId ?? 'tenant-1',
|
||||
dashboardId: overrides.dashboardId ?? DASH_1,
|
||||
widgetType: overrides.widgetType ?? 'clock',
|
||||
config: overrides.config ?? {},
|
||||
createdAt: overrides.createdAt ?? new Date('2026-01-01'),
|
||||
@@ -92,35 +129,67 @@ function makeFakePrisma(
|
||||
opts: {
|
||||
widgets?: ReturnType<typeof makeWidget>[];
|
||||
modules?: { id: string; slug: string }[];
|
||||
layout?: { userId: string; tenantId: string; layouts: unknown } | null;
|
||||
layout?: { dashboardId: string; userId: string; tenantId: string; layouts: unknown } | null;
|
||||
searchProviders?: ReturnType<typeof makeSearchProvider>[];
|
||||
dashboards?: ReturnType<typeof makeDashboard>[];
|
||||
} = {},
|
||||
) {
|
||||
const widgets = opts.widgets ?? [];
|
||||
const modules = opts.modules ?? [];
|
||||
let layoutRow = opts.layout ?? null;
|
||||
const searchProviders = opts.searchProviders ?? [];
|
||||
// Standard-Fixture: GENAU EIN Reiter `dash-1`, der user-1/tenant-1 gehört
|
||||
// — die meisten Tests wollen sich um Reiter-Verwaltung nicht kümmern.
|
||||
// Tests, die eine andere Besitzlage brauchen (fremder Reiter, ADMIN mit
|
||||
// eigenem Reiter, leere Reiterliste), übergeben `dashboards` explizit.
|
||||
const dashboards = opts.dashboards ?? [makeDashboard()];
|
||||
const boundCallLog: { tenantId: string; model: string; method: string }[] = [];
|
||||
|
||||
const fake: any = {
|
||||
dashboard: {
|
||||
findMany: vi.fn(async ({ where }: any) => {
|
||||
return dashboards
|
||||
.filter((d) => d.userId === where.userId)
|
||||
.slice()
|
||||
.sort((a, b) => a.position - b.position);
|
||||
}),
|
||||
findUnique: vi.fn(async ({ where }: any) => {
|
||||
return dashboards.find((d) => d.id === where.id) ?? null;
|
||||
}),
|
||||
count: vi.fn(async ({ where }: any) => {
|
||||
return dashboards.filter(
|
||||
(d) => d.userId === where.userId && d.tenantId === where.tenantId,
|
||||
).length;
|
||||
}),
|
||||
create: vi.fn(async ({ data }: any) => {
|
||||
const created = makeDashboard({ id: `new-dash-${dashboards.length + 1}`, ...data });
|
||||
dashboards.push(created);
|
||||
return created;
|
||||
}),
|
||||
},
|
||||
dashboardLayout: {
|
||||
findUnique: vi.fn(async ({ where }: any) => {
|
||||
if (layoutRow && layoutRow.userId === where.userId) return layoutRow;
|
||||
if (layoutRow && layoutRow.dashboardId === where.dashboardId) return layoutRow;
|
||||
return null;
|
||||
}),
|
||||
upsert: vi.fn(async ({ where, update, create }: any) => {
|
||||
if (layoutRow && layoutRow.userId === where.userId) {
|
||||
if (layoutRow && layoutRow.dashboardId === where.dashboardId) {
|
||||
layoutRow = { ...layoutRow, layouts: update.layouts };
|
||||
return layoutRow;
|
||||
}
|
||||
layoutRow = { userId: create.userId, tenantId: create.tenantId, layouts: create.layouts };
|
||||
layoutRow = {
|
||||
dashboardId: create.dashboardId,
|
||||
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)
|
||||
.filter((w) => w.dashboardId === where.dashboardId)
|
||||
.slice()
|
||||
.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime());
|
||||
}),
|
||||
@@ -188,8 +257,16 @@ function makeFakePrisma(
|
||||
}
|
||||
bound[modelName] = wrapped;
|
||||
}
|
||||
// quick-260923-ad9: `listDashboards` setzt die Transaktionssperre über
|
||||
// ein rohes `$executeRaw` auf `tx` — Attrappe genügt, das Ergebnis
|
||||
// wird nicht ausgewertet.
|
||||
bound.$executeRaw = vi.fn(async () => undefined);
|
||||
return bound;
|
||||
},
|
||||
__withTenantTransaction(tenantId: string, fn: (tx: any) => any) {
|
||||
boundCallLog.push({ tenantId, model: '$transaction', method: 'withTenantTransaction' });
|
||||
return fn(fake.__makeBoundClient(tenantId));
|
||||
},
|
||||
};
|
||||
|
||||
return fake;
|
||||
@@ -245,7 +322,7 @@ describe('DashboardService.getWidgets — Modulfilter (D-22, PERM-07)', () => {
|
||||
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);
|
||||
const result = await service.getWidgets('user-1', 'tenant-1', 'USER' as any, DASH_1);
|
||||
|
||||
expect(result).toEqual(widgets);
|
||||
expect(moduleAccessService.getAccessibleModuleIds).not.toHaveBeenCalled();
|
||||
@@ -261,7 +338,7 @@ describe('DashboardService.getWidgets — Modulfilter (D-22, PERM-07)', () => {
|
||||
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);
|
||||
const result = await service.getWidgets('user-1', 'tenant-1', 'USER' as any, DASH_1);
|
||||
|
||||
expect(result).toEqual([widget]);
|
||||
});
|
||||
@@ -276,7 +353,7 @@ describe('DashboardService.getWidgets — Modulfilter (D-22, PERM-07)', () => {
|
||||
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);
|
||||
const result = await service.getWidgets('user-1', 'tenant-1', 'USER' as any, DASH_1);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
@@ -287,12 +364,13 @@ describe('DashboardService.getWidgets — Modulfilter (D-22, PERM-07)', () => {
|
||||
const prisma = makeFakePrisma({
|
||||
widgets: [widget],
|
||||
modules: [{ id: 'mod-1', slug: 'tender-radar' }],
|
||||
dashboards: [makeDashboard({ userId: 'admin-1' })],
|
||||
});
|
||||
// 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);
|
||||
const result = await service.getWidgets('admin-1', 'tenant-1', 'ADMIN' as any, DASH_1);
|
||||
|
||||
expect(result).toEqual([widget]);
|
||||
expect(moduleAccessService.getAccessibleModuleIds).toHaveBeenCalledWith(
|
||||
@@ -313,7 +391,7 @@ describe('DashboardService.getWidgets — Modulfilter (D-22, PERM-07)', () => {
|
||||
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);
|
||||
const result = await service.getWidgets('user-1', 'tenant-1', 'USER' as any, DASH_1);
|
||||
|
||||
expect(result).toEqual([platformWidget]);
|
||||
});
|
||||
@@ -334,7 +412,7 @@ describe('DashboardService.getWidgets — Modulfilter (D-22, PERM-07)', () => {
|
||||
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);
|
||||
const result = await service.getWidgets('user-1', 'tenant-1', 'USER' as any, DASH_1);
|
||||
|
||||
expect(result.map((w: any) => w.id)).toEqual(['w1', 'w2', 'w3']);
|
||||
});
|
||||
@@ -349,8 +427,8 @@ describe('DashboardService.getWidgets — Modulfilter (D-22, PERM-07)', () => {
|
||||
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);
|
||||
await service.getWidgets('user-1', 'tenant-1', 'USER' as any, DASH_1);
|
||||
await service.getWidgets('user-1', 'tenant-1', 'USER' as any, DASH_1);
|
||||
|
||||
expect(prisma.widgetInstance.delete).not.toHaveBeenCalled();
|
||||
expect(prisma.widgetInstance.findMany).toHaveBeenCalledTimes(2);
|
||||
@@ -363,7 +441,7 @@ describe('DashboardService.getWidgets — Modulfilter (D-22, PERM-07)', () => {
|
||||
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);
|
||||
const result = await service.getWidgets('user-1', 'tenant-1', 'USER' as any, DASH_1);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
@@ -379,15 +457,16 @@ describe('DashboardService — Anordnung und Widgets gebunden an forTenant() (26
|
||||
|
||||
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' }] } },
|
||||
layout: { dashboardId: DASH_1, 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');
|
||||
const result = await service.getLayout('user-1', 'tenant-1', DASH_1);
|
||||
|
||||
expect(result).toEqual({ lg: [{ i: 'w1' }] });
|
||||
expectBoundCall(prisma, 'tenant-1', 'dashboardLayout', 'findUnique');
|
||||
expectBoundCall(prisma, 'tenant-1', 'dashboard', 'findUnique');
|
||||
// Benutzerdimension (260911-nke): forTenant() bekommt userId als drittes Argument.
|
||||
expect(forTenant).toHaveBeenCalledWith(prisma, 'tenant-1', 'user-1');
|
||||
});
|
||||
@@ -397,7 +476,7 @@ describe('DashboardService — Anordnung und Widgets gebunden an forTenant() (26
|
||||
const moduleAccessService = makeFakeModuleAccessService(new Set());
|
||||
const service = new DashboardService(prisma as any, moduleAccessService as any);
|
||||
|
||||
const result = await service.getLayout('user-1', 'tenant-1');
|
||||
const result = await service.getLayout('user-1', 'tenant-1', DASH_1);
|
||||
|
||||
expect(result).toEqual({ lg: [], md: [], sm: [], xs: [], xxs: [] });
|
||||
});
|
||||
@@ -407,17 +486,18 @@ describe('DashboardService — Anordnung und Widgets gebunden an forTenant() (26
|
||||
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);
|
||||
await service.saveLayout('user-1', 'tenant-1', { dashboardId: DASH_1, layouts: { lg: [] } } as any);
|
||||
|
||||
expectBoundCall(prisma, 'tenant-1', 'dashboardLayout', 'upsert');
|
||||
expectBoundCall(prisma, 'tenant-1', 'dashboard', 'findUnique');
|
||||
});
|
||||
|
||||
/**
|
||||
* 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
|
||||
* Gegenrichtung der Bindung (w4, 260910-krx). `DashboardLayout.dashboardId`
|
||||
* (bis quick-260923-ad9: `userId`) ist die eindeutige Spalte. 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`
|
||||
@@ -440,7 +520,7 @@ describe('DashboardService — Anordnung und Widgets gebunden an forTenant() (26
|
||||
const service = new DashboardService(prisma as any, moduleAccessService as any);
|
||||
|
||||
await expect(
|
||||
service.saveLayout('user-1', 'tenant-1', { layouts: { lg: [] } } as any),
|
||||
service.saveLayout('user-1', 'tenant-1', { dashboardId: DASH_1, layouts: { lg: [] } } as any),
|
||||
).rejects.toBeInstanceOf(ConflictException);
|
||||
});
|
||||
|
||||
@@ -457,20 +537,20 @@ describe('DashboardService — Anordnung und Widgets gebunden an forTenant() (26
|
||||
const service = new DashboardService(prisma as any, moduleAccessService as any);
|
||||
|
||||
await expect(
|
||||
service.saveLayout('user-1', 'tenant-1', { layouts: { lg: [] } } as any),
|
||||
service.saveLayout('user-1', 'tenant-1', { dashboardId: DASH_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: {} },
|
||||
layout: { dashboardId: DASH_1, 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);
|
||||
await service.getLayout('user-1', 'tenant-1', DASH_1);
|
||||
await service.saveLayout('user-1', 'tenant-1', { dashboardId: DASH_1, layouts: { lg: [] } } as any);
|
||||
|
||||
expectBoundCall(prisma, 'tenant-1', 'dashboardLayout', 'findUnique');
|
||||
expectBoundCall(prisma, 'tenant-1', 'dashboardLayout', 'upsert');
|
||||
@@ -481,7 +561,7 @@ describe('DashboardService — Anordnung und Widgets gebunden an forTenant() (26
|
||||
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);
|
||||
const result = await service.getWidgets('user-1', 'tenant-1', 'USER' as any, DASH_1);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
expectBoundCall(prisma, 'tenant-1', 'widgetInstance', 'findMany');
|
||||
@@ -493,9 +573,10 @@ describe('DashboardService — Anordnung und Widgets gebunden an forTenant() (26
|
||||
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);
|
||||
await service.addWidget('user-1', 'tenant-1', { widgetType: 'clock', dashboardId: DASH_1 } as any);
|
||||
|
||||
expectBoundCall(prisma, 'tenant-1', 'widgetInstance', 'create');
|
||||
expectBoundCall(prisma, 'tenant-1', 'dashboard', 'findUnique');
|
||||
});
|
||||
|
||||
it('Widget-Konfiguration ändern: BEIDE Abfragen (Besitzprüfung und Änderung) laufen über DENSELBEN gebundenen Klienten und dieselbe Mandantenkennung', async () => {
|
||||
@@ -548,16 +629,16 @@ describe('DashboardService — Anordnung und Widgets gebunden an forTenant() (26
|
||||
const widget = makeWidget({ id: 'w1', userId: 'user-1' });
|
||||
const prisma = makeFakePrisma({
|
||||
widgets: [widget],
|
||||
layout: { userId: 'user-1', tenantId: 'tenant-1', layouts: {} },
|
||||
layout: { dashboardId: DASH_1, 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.getLayout('user-1', 'tenant-1', DASH_1),
|
||||
() => service.saveLayout('user-1', 'tenant-1', { dashboardId: DASH_1, layouts: {} } as any),
|
||||
() => service.getWidgets('user-1', 'tenant-1', 'USER' as any, DASH_1),
|
||||
() => service.addWidget('user-1', 'tenant-1', { widgetType: 'clock', dashboardId: DASH_1 } as any),
|
||||
() => service.updateWidgetConfig('w1', 'user-1', 'tenant-1', { config: {} } as any),
|
||||
() => service.removeWidget('w1', 'user-1', 'tenant-1'),
|
||||
]) {
|
||||
@@ -575,7 +656,7 @@ describe('DashboardService — Anordnung und Widgets gebunden an forTenant() (26
|
||||
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);
|
||||
const result = await service.getWidgets('user-1', 'tenant-1', 'USER' as any, DASH_1);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
@@ -593,9 +674,9 @@ describe('DashboardService — Anordnung und Widgets gebunden an forTenant() (26
|
||||
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);
|
||||
await service.saveLayout('user-1', 'tenant-1', { dashboardId: DASH_1, layouts: stored } as any);
|
||||
|
||||
const result = await service.getLayout('user-1', 'tenant-1');
|
||||
const result = await service.getLayout('user-1', 'tenant-1', DASH_1);
|
||||
|
||||
expect(result).toEqual(stored);
|
||||
expect((result as Record<string, unknown>).__gridVersion).toBe(2);
|
||||
@@ -623,6 +704,178 @@ describe('DashboardService — Anordnung und Widgets gebunden an forTenant() (26
|
||||
});
|
||||
});
|
||||
|
||||
// --- Reiter (quick-260923-ad9, Task 1) --------------------------------------
|
||||
|
||||
describe('DashboardService.listDashboards — Reiter anlegen/lesen (quick-260923-ad9, Task 1)', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(forTenant).mockClear();
|
||||
});
|
||||
|
||||
it('erster Aufruf ohne vorhandenen Reiter legt genau einen mit Position 0 und Namen "Dashboard" an und liefert ihn', async () => {
|
||||
const prisma = makeFakePrisma({ dashboards: [] });
|
||||
const moduleAccessService = makeFakeModuleAccessService(new Set());
|
||||
const service = new DashboardService(prisma as any, moduleAccessService as any);
|
||||
|
||||
const result = await service.listDashboards('user-1', 'tenant-1');
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toMatchObject({ name: 'Dashboard', position: 0, userId: 'user-1' });
|
||||
expect(prisma.dashboard.create).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('zweiter Aufruf legt keinen weiteren Reiter an', async () => {
|
||||
const prisma = makeFakePrisma({ dashboards: [] });
|
||||
const moduleAccessService = makeFakeModuleAccessService(new Set());
|
||||
const service = new DashboardService(prisma as any, moduleAccessService as any);
|
||||
|
||||
await service.listDashboards('user-1', 'tenant-1');
|
||||
const second = await service.listDashboards('user-1', 'tenant-1');
|
||||
|
||||
expect(second).toHaveLength(1);
|
||||
expect(prisma.dashboard.create).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('das Anlegen läuft als EINE withTenantTransaction (Sperre + erneute Zählung), nicht als Einzelbefehl (T-AD9-07)', async () => {
|
||||
const prisma = makeFakePrisma({ dashboards: [] });
|
||||
const moduleAccessService = makeFakeModuleAccessService(new Set());
|
||||
const service = new DashboardService(prisma as any, moduleAccessService as any);
|
||||
|
||||
await service.listDashboards('user-1', 'tenant-1');
|
||||
|
||||
const found = prisma.__boundCallLog.some(
|
||||
(c: any) => c.tenantId === 'tenant-1' && c.model === '$transaction' && c.method === 'withTenantTransaction',
|
||||
);
|
||||
expect(found, 'erwartete withTenantTransaction fehlt im Protokoll').toBe(true);
|
||||
});
|
||||
|
||||
it('die Liste kommt nach Position aufsteigend, unabhängig von der Einfügereihenfolge', async () => {
|
||||
const prisma = makeFakePrisma({
|
||||
dashboards: [
|
||||
makeDashboard({ id: 'd2', position: 1 }),
|
||||
makeDashboard({ id: 'd1', position: 0 }),
|
||||
],
|
||||
});
|
||||
const moduleAccessService = makeFakeModuleAccessService(new Set());
|
||||
const service = new DashboardService(prisma as any, moduleAccessService as any);
|
||||
|
||||
const result = await service.listDashboards('user-1', 'tenant-1');
|
||||
|
||||
expect(result.map((d: any) => d.id)).toEqual(['d1', 'd2']);
|
||||
});
|
||||
|
||||
it('der Lesezugriff läuft über den gebundenen Klienten mit der richtigen Mandantenkennung', async () => {
|
||||
const prisma = makeFakePrisma({ dashboards: [makeDashboard({ id: 'd1' })] });
|
||||
const moduleAccessService = makeFakeModuleAccessService(new Set());
|
||||
const service = new DashboardService(prisma as any, moduleAccessService as any);
|
||||
|
||||
await service.listDashboards('user-1', 'tenant-1');
|
||||
|
||||
expectBoundCall(prisma, 'tenant-1', 'dashboard', 'findMany');
|
||||
});
|
||||
});
|
||||
|
||||
describe('DashboardService — Riegel gegen fremde Reiter (quick-260923-ad9, Task 1, T-AD9-01/02)', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(forTenant).mockClear();
|
||||
});
|
||||
|
||||
it('getLayout: fremde Reiter-Kennung führt zur Nicht-gefunden-Antwort', async () => {
|
||||
const prisma = makeFakePrisma({
|
||||
dashboards: [makeDashboard({ id: DASH_1, userId: 'other-user' })],
|
||||
});
|
||||
const moduleAccessService = makeFakeModuleAccessService(new Set());
|
||||
const service = new DashboardService(prisma as any, moduleAccessService as any);
|
||||
|
||||
await expect(service.getLayout('user-1', 'tenant-1', DASH_1)).rejects.toBeInstanceOf(
|
||||
NotFoundException,
|
||||
);
|
||||
});
|
||||
|
||||
it('saveLayout: fremde Reiter-Kennung führt zur Nicht-gefunden-Antwort, ohne zu schreiben', async () => {
|
||||
const prisma = makeFakePrisma({
|
||||
dashboards: [makeDashboard({ id: DASH_1, userId: 'other-user' })],
|
||||
});
|
||||
const moduleAccessService = makeFakeModuleAccessService(new Set());
|
||||
const service = new DashboardService(prisma as any, moduleAccessService as any);
|
||||
|
||||
await expect(
|
||||
service.saveLayout('user-1', 'tenant-1', { dashboardId: DASH_1, layouts: {} } as any),
|
||||
).rejects.toBeInstanceOf(NotFoundException);
|
||||
expect(prisma.dashboardLayout.upsert).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('getWidgets: fremde Reiter-Kennung führt zur Nicht-gefunden-Antwort', async () => {
|
||||
const prisma = makeFakePrisma({
|
||||
dashboards: [makeDashboard({ id: DASH_1, userId: 'other-user' })],
|
||||
});
|
||||
const moduleAccessService = makeFakeModuleAccessService(new Set());
|
||||
const service = new DashboardService(prisma as any, moduleAccessService as any);
|
||||
|
||||
await expect(
|
||||
service.getWidgets('user-1', 'tenant-1', 'USER' as any, DASH_1),
|
||||
).rejects.toBeInstanceOf(NotFoundException);
|
||||
});
|
||||
|
||||
it('addWidget: fremde Reiter-Kennung führt zur Nicht-gefunden-Antwort, ohne zu schreiben', async () => {
|
||||
const prisma = makeFakePrisma({
|
||||
dashboards: [makeDashboard({ id: DASH_1, userId: 'other-user' })],
|
||||
});
|
||||
const moduleAccessService = makeFakeModuleAccessService(new Set());
|
||||
const service = new DashboardService(prisma as any, moduleAccessService as any);
|
||||
|
||||
await expect(
|
||||
service.addWidget('user-1', 'tenant-1', { widgetType: 'clock', dashboardId: DASH_1 } as any),
|
||||
).rejects.toBeInstanceOf(NotFoundException);
|
||||
expect(prisma.widgetInstance.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('unbekannte Reiter-Kennung (existiert nicht) führt ebenso zur Nicht-gefunden-Antwort — dieselbe Antwort wie "gehört einem Kollegen"', async () => {
|
||||
const prisma = makeFakePrisma({ dashboards: [] });
|
||||
const moduleAccessService = makeFakeModuleAccessService(new Set());
|
||||
const service = new DashboardService(prisma as any, moduleAccessService as any);
|
||||
|
||||
await expect(service.getLayout('user-1', 'tenant-1', 'unknown-dash')).rejects.toThrow(
|
||||
"Dashboard with id 'unknown-dash' not found",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DashboardService — Kacheln und Anordnung sind je Reiter getrennt (quick-260923-ad9, Task 1)', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(forTenant).mockClear();
|
||||
});
|
||||
|
||||
it('getWidgets liest über die Reiter-Kennung, nicht über die Benutzerkennung — zwei Reiter desselben Benutzers teilen keine Kacheln', async () => {
|
||||
const widgetOnDash1 = makeWidget({ id: 'w1', dashboardId: 'dash-1' });
|
||||
const widgetOnDash2 = makeWidget({ id: 'w2', dashboardId: 'dash-2' });
|
||||
const prisma = makeFakePrisma({
|
||||
widgets: [widgetOnDash1, widgetOnDash2],
|
||||
dashboards: [makeDashboard({ id: 'dash-1' }), makeDashboard({ id: 'dash-2' })],
|
||||
});
|
||||
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, 'dash-2');
|
||||
|
||||
expect(result.map((w: any) => w.id)).toEqual(['w2']);
|
||||
});
|
||||
|
||||
it('saveLayout schreibt die Anordnung unter der Reiter-Kennung (create-Zweig)', 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', { dashboardId: DASH_1, layouts: { lg: [] } } as any);
|
||||
|
||||
expect(prisma.dashboardLayout.upsert).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { dashboardId: DASH_1 },
|
||||
create: expect.objectContaining({ dashboardId: DASH_1 }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// --- Bindung an forTenant() (260910-krx, Aufgabe 3: Suchmaschinen) ---------
|
||||
|
||||
describe('DashboardService — Suchmaschinen gebunden an forTenant(), Katalog bewusst ungebunden (260910-krx, Aufgabe 3)', () => {
|
||||
@@ -710,7 +963,7 @@ describe('DashboardService — Suchmaschinen gebunden an forTenant(), Katalog be
|
||||
name: 'Intranet',
|
||||
urlTemplate: 'https://intranet.test/?q={query}',
|
||||
} as any);
|
||||
await service.getWidgets('user-1', 'tenant-1', 'USER' as any);
|
||||
await service.getWidgets('user-1', 'tenant-1', 'USER' as any, DASH_1);
|
||||
|
||||
// Beweist, dass der Katalogzugriff tatsaechlich lief (sonst waere die
|
||||
// Wachhund-Pruefung unten wirkungslos, weil sie nichts protokollieren
|
||||
|
||||
Reference in New Issue
Block a user