7704372c3c
- FavoriteLink: neue Spalten uploadedIconMime/iconVersion (Migration 20260923160000) - favorite-icon-files.ts: Erkennung PNG/JPEG/GIF/WebP/ICO/SVG, Pfadbildung ohne Byte aus der Anfrage im Pfad (T-LRR-01), best-effort Dateientfernung - FavoritesService: uploadIcon/removeUploadedIcon, Vorrang der hochgeladenen Datei in getIconBytes, Abrufprobe fuer eine neue iconUrl (422 statt stiller Speicherung), iconVersion-Erhoehung bei jeder Aenderung der Symbolquelle - FavoritesController: POST/DELETE /favorites/:id/icon, Cache-Control private - T-LRR-07 (Restrisiko aus dem Plan-Threat-Model geschlossen, ueber den Plan hinaus): DashboardService.removeWidget/deleteDashboard raeumen jetzt die Symboldateien der per Datenbank-Kaskade mitgeloeschten Favoriten auf (best effort, nie blockierend) Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1483 lines
64 KiB
TypeScript
1483 lines
64 KiB
TypeScript
import * as fs from 'node:fs';
|
|
import * as os from 'node:os';
|
|
import * as path from 'node:path';
|
|
import { afterEach, 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.
|
|
*
|
|
* 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 { BadRequestException, ConflictException, NotFoundException } from '@nestjs/common';
|
|
import { Prisma } from '@prisma/client';
|
|
import { favoriteIconAbsolutePath } from '../favorites/favorite-icon-files';
|
|
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). `dashboard` ergaenzt seit
|
|
* quick-260923-ad9 (Task 1). `favoriteLink` ergaenzt seit quick-260923-lrr
|
|
* (T-LRR-07: `removeWidget`/`deleteDashboard` lesen vor der Kaskade die
|
|
* betroffenen Favoriten mit hochgeladenem Symbol). */
|
|
const BOUND_MODEL_NAMES = [
|
|
'dashboard',
|
|
'dashboardLayout',
|
|
'widgetInstance',
|
|
'searchProvider',
|
|
'favoriteLink',
|
|
];
|
|
|
|
/** 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;
|
|
}> = {},
|
|
) {
|
|
return {
|
|
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'),
|
|
};
|
|
}
|
|
|
|
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'),
|
|
};
|
|
}
|
|
|
|
/** Minimale FavoriteLink-Fixture fuer T-LRR-07 (quick-260923-lrr) — nur die
|
|
* Felder, die `cleanUpFavoriteIconFiles` und die vorbereitenden `findMany`-
|
|
* Aufrufe in `removeWidget`/`deleteDashboard` lesen. */
|
|
function makeFavorite(
|
|
overrides: Partial<{
|
|
id: string;
|
|
userId: string;
|
|
tenantId: string;
|
|
widgetId: string;
|
|
uploadedIconMime: string | null;
|
|
}> = {},
|
|
) {
|
|
return {
|
|
id: overrides.id ?? 'fav-1',
|
|
userId: overrides.userId ?? 'user-1',
|
|
tenantId: overrides.tenantId ?? 'tenant-1',
|
|
widgetId: overrides.widgetId ?? 'w1',
|
|
uploadedIconMime: overrides.uploadedIconMime === undefined ? null : overrides.uploadedIconMime,
|
|
};
|
|
}
|
|
|
|
function makeFakePrisma(
|
|
opts: {
|
|
widgets?: ReturnType<typeof makeWidget>[];
|
|
modules?: { id: string; slug: string }[];
|
|
layout?: { dashboardId: string; userId: string; tenantId: string; layouts: unknown } | null;
|
|
searchProviders?: ReturnType<typeof makeSearchProvider>[];
|
|
dashboards?: ReturnType<typeof makeDashboard>[];
|
|
favorites?: ReturnType<typeof makeFavorite>[];
|
|
} = {},
|
|
) {
|
|
const widgets = opts.widgets ?? [];
|
|
const modules = opts.modules ?? [];
|
|
let layoutRow = opts.layout ?? null;
|
|
const searchProviders = opts.searchProviders ?? [];
|
|
const favorites = opts.favorites ?? [];
|
|
// 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;
|
|
}),
|
|
update: vi.fn(async ({ where, data }: any) => {
|
|
const dashboard = dashboards.find((d) => d.id === where.id);
|
|
if (!dashboard) return null;
|
|
Object.assign(dashboard, data);
|
|
return dashboard;
|
|
}),
|
|
updateMany: vi.fn(async ({ where, data }: any) => {
|
|
const matches = dashboards.filter(
|
|
(d) => d.id === where.id && d.userId === where.userId,
|
|
);
|
|
for (const d of matches) Object.assign(d, data);
|
|
return { count: matches.length };
|
|
}),
|
|
deleteMany: vi.fn(async ({ where }: any) => {
|
|
const before = dashboards.length;
|
|
for (let i = dashboards.length - 1; i >= 0; i--) {
|
|
if (dashboards[i].id === where.id && dashboards[i].userId === where.userId) {
|
|
dashboards.splice(i, 1);
|
|
}
|
|
}
|
|
return { count: before - dashboards.length };
|
|
}),
|
|
},
|
|
dashboardLayout: {
|
|
findUnique: vi.fn(async ({ where }: any) => {
|
|
if (layoutRow && layoutRow.dashboardId === where.dashboardId) return layoutRow;
|
|
return null;
|
|
}),
|
|
upsert: vi.fn(async ({ where, update, create }: any) => {
|
|
if (layoutRow && layoutRow.dashboardId === where.dashboardId) {
|
|
layoutRow = { ...layoutRow, layouts: update.layouts };
|
|
return layoutRow;
|
|
}
|
|
layoutRow = {
|
|
dashboardId: create.dashboardId,
|
|
userId: create.userId,
|
|
tenantId: create.tenantId,
|
|
layouts: create.layouts,
|
|
};
|
|
return layoutRow;
|
|
}),
|
|
deleteMany: vi.fn(async ({ where }: any) => {
|
|
if (
|
|
layoutRow &&
|
|
layoutRow.dashboardId === where.dashboardId &&
|
|
layoutRow.userId === where.userId
|
|
) {
|
|
layoutRow = null;
|
|
return { count: 1 };
|
|
}
|
|
return { count: 0 };
|
|
}),
|
|
},
|
|
widgetInstance: {
|
|
findMany: vi.fn(async ({ where }: any) => {
|
|
return widgets
|
|
.filter((w) => w.dashboardId === where.dashboardId)
|
|
.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;
|
|
}),
|
|
deleteMany: vi.fn(async ({ where }: any) => {
|
|
const before = widgets.length;
|
|
for (let i = widgets.length - 1; i >= 0; i--) {
|
|
if (widgets[i].dashboardId === where.dashboardId && widgets[i].userId === where.userId) {
|
|
widgets.splice(i, 1);
|
|
}
|
|
}
|
|
return { count: before - widgets.length };
|
|
}),
|
|
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;
|
|
}),
|
|
},
|
|
// T-LRR-07 (quick-260923-lrr): nur `findMany` — `removeWidget`/
|
|
// `deleteDashboard` LESEN die betroffenen Favoriten vor der Kaskade,
|
|
// sie schreiben nie auf `favoriteLink` (das Loeschen selbst passiert
|
|
// ueber die Datenbank-Kaskade auf `widgetInstance`, nicht hier).
|
|
favoriteLink: {
|
|
findMany: vi.fn(async ({ where }: any) => {
|
|
let rows = favorites.filter((f) => f.userId === where.userId);
|
|
if (where.widgetId?.in) {
|
|
rows = rows.filter((f) => where.widgetId.in.includes(f.widgetId));
|
|
} else if (where.widgetId) {
|
|
rows = rows.filter((f) => f.widgetId === where.widgetId);
|
|
}
|
|
if (where.uploadedIconMime?.not === null) {
|
|
rows = rows.filter((f) => f.uploadedIconMime !== null);
|
|
}
|
|
return rows;
|
|
}),
|
|
},
|
|
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;
|
|
}
|
|
// 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;
|
|
}
|
|
|
|
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, DASH_1);
|
|
|
|
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, DASH_1);
|
|
|
|
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, DASH_1);
|
|
|
|
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' }],
|
|
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, DASH_1);
|
|
|
|
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, DASH_1);
|
|
|
|
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, DASH_1);
|
|
|
|
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, 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);
|
|
});
|
|
|
|
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, DASH_1);
|
|
|
|
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: { 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', 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');
|
|
});
|
|
|
|
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', DASH_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', { 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.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`
|
|
* (`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', { dashboardId: DASH_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', { 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: { 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', 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');
|
|
});
|
|
|
|
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, DASH_1);
|
|
|
|
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', 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 () => {
|
|
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",
|
|
);
|
|
});
|
|
|
|
describe('removeWidget — T-LRR-07 (quick-260923-lrr): Datei-Leichen nach Kaskadenloeschung', () => {
|
|
let iconsDir: string;
|
|
const ORIGINAL_DIR_ENV = process.env.FAVORITE_ICONS_DIR;
|
|
|
|
beforeEach(() => {
|
|
iconsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tessera-favorite-icons-dashboard-'));
|
|
process.env.FAVORITE_ICONS_DIR = iconsDir;
|
|
});
|
|
|
|
afterEach(() => {
|
|
fs.rmSync(iconsDir, { recursive: true, force: true });
|
|
if (ORIGINAL_DIR_ENV === undefined) {
|
|
delete process.env.FAVORITE_ICONS_DIR;
|
|
} else {
|
|
process.env.FAVORITE_ICONS_DIR = ORIGINAL_DIR_ENV;
|
|
}
|
|
});
|
|
|
|
it('entfernt die Symboldatei eines Favoriten, dessen Zeile die Datenbank-Kaskade mitloescht', async () => {
|
|
const widget = makeWidget({ id: 'w1', userId: 'user-1' });
|
|
const favorite = makeFavorite({
|
|
id: 'fav-1',
|
|
userId: 'user-1',
|
|
widgetId: 'w1',
|
|
uploadedIconMime: 'image/png',
|
|
});
|
|
const prisma = makeFakePrisma({ widgets: [widget], favorites: [favorite] });
|
|
const service = new DashboardService(prisma as any, makeFakeModuleAccessService(new Set()) as any);
|
|
|
|
const absolute = favoriteIconAbsolutePath('user-1', 'fav-1', 'image/png') as string;
|
|
fs.mkdirSync(path.dirname(absolute), { recursive: true });
|
|
fs.writeFileSync(absolute, Buffer.from([1, 2, 3]));
|
|
|
|
await service.removeWidget('w1', 'user-1', 'tenant-1');
|
|
|
|
expect(fs.existsSync(absolute)).toBe(false);
|
|
});
|
|
|
|
it('fehlende Datei wird geschluckt — removeWidget scheitert nicht daran', async () => {
|
|
const widget = makeWidget({ id: 'w1', userId: 'user-1' });
|
|
const favorite = makeFavorite({
|
|
id: 'fav-1',
|
|
userId: 'user-1',
|
|
widgetId: 'w1',
|
|
uploadedIconMime: 'image/png',
|
|
});
|
|
const prisma = makeFakePrisma({ widgets: [widget], favorites: [favorite] });
|
|
const service = new DashboardService(prisma as any, makeFakeModuleAccessService(new Set()) as any);
|
|
|
|
await expect(service.removeWidget('w1', 'user-1', 'tenant-1')).resolves.toBeTruthy();
|
|
});
|
|
|
|
it('Favoriten OHNE hochgeladenes Symbol loesen keinen Dateizugriff aus', async () => {
|
|
const widget = makeWidget({ id: 'w1', userId: 'user-1' });
|
|
const favorite = makeFavorite({ id: 'fav-1', userId: 'user-1', widgetId: 'w1', uploadedIconMime: null });
|
|
const prisma = makeFakePrisma({ widgets: [widget], favorites: [favorite] });
|
|
const service = new DashboardService(prisma as any, makeFakeModuleAccessService(new Set()) as any);
|
|
|
|
await expect(service.removeWidget('w1', 'user-1', 'tenant-1')).resolves.toBeTruthy();
|
|
expect(fs.existsSync(path.join(iconsDir, 'user-1'))).toBe(false);
|
|
});
|
|
});
|
|
|
|
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: { 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', 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'),
|
|
]) {
|
|
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, DASH_1);
|
|
|
|
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', { dashboardId: DASH_1, layouts: stored } as any);
|
|
|
|
const result = await service.getLayout('user-1', 'tenant-1', DASH_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');
|
|
});
|
|
});
|
|
|
|
// --- 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)', () => {
|
|
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, DASH_1);
|
|
|
|
// 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);
|
|
}
|
|
});
|
|
});
|
|
|
|
// --- Reiter anlegen/umbenennen/löschen/umsortieren (quick-260923-ad9, Task 2) ---
|
|
|
|
describe('DashboardService.createDashboard — Reiter anlegen (quick-260923-ad9, Task 2, D-08)', () => {
|
|
beforeEach(() => {
|
|
vi.mocked(forTenant).mockClear();
|
|
});
|
|
|
|
it('erzeugt "Dashboard 2", wenn nur "Dashboard" existiert', async () => {
|
|
const prisma = makeFakePrisma({ dashboards: [makeDashboard({ id: 'd1', name: 'Dashboard', position: 0 })] });
|
|
const service = new DashboardService(prisma as any, makeFakeModuleAccessService(new Set()) as any);
|
|
|
|
const result = await service.createDashboard('user-1', 'tenant-1');
|
|
|
|
expect(result.name).toBe('Dashboard 2');
|
|
});
|
|
|
|
it('erzeugt "Dashboard 3", wenn "Dashboard" und "Dashboard 2" existieren', async () => {
|
|
const prisma = makeFakePrisma({
|
|
dashboards: [
|
|
makeDashboard({ id: 'd1', name: 'Dashboard', position: 0 }),
|
|
makeDashboard({ id: 'd2', name: 'Dashboard 2', position: 1 }),
|
|
],
|
|
});
|
|
const service = new DashboardService(prisma as any, makeFakeModuleAccessService(new Set()) as any);
|
|
|
|
const result = await service.createDashboard('user-1', 'tenant-1');
|
|
|
|
expect(result.name).toBe('Dashboard 3');
|
|
});
|
|
|
|
it('füllt eine Lücke: "Dashboard" und "Dashboard 3" existieren -> "Dashboard 2"', async () => {
|
|
const prisma = makeFakePrisma({
|
|
dashboards: [
|
|
makeDashboard({ id: 'd1', name: 'Dashboard', position: 0 }),
|
|
makeDashboard({ id: 'd3', name: 'Dashboard 3', position: 1 }),
|
|
],
|
|
});
|
|
const service = new DashboardService(prisma as any, makeFakeModuleAccessService(new Set()) as any);
|
|
|
|
const result = await service.createDashboard('user-1', 'tenant-1');
|
|
|
|
expect(result.name).toBe('Dashboard 2');
|
|
});
|
|
|
|
it('hängt ans Ende — Position ist die höchste vorhandene plus eins', async () => {
|
|
const prisma = makeFakePrisma({
|
|
dashboards: [
|
|
makeDashboard({ id: 'd1', name: 'Dashboard', position: 0 }),
|
|
makeDashboard({ id: 'd2', name: 'Dashboard 2', position: 5 }),
|
|
],
|
|
});
|
|
const service = new DashboardService(prisma as any, makeFakeModuleAccessService(new Set()) as any);
|
|
|
|
const result = await service.createDashboard('user-1', 'tenant-1');
|
|
|
|
expect(result.position).toBe(6);
|
|
});
|
|
|
|
it('liefert den neuen Reiter mit leerer Kachelliste', async () => {
|
|
const prisma = makeFakePrisma({ dashboards: [makeDashboard({ id: 'd1' })] });
|
|
const service = new DashboardService(prisma as any, makeFakeModuleAccessService(new Set()) as any);
|
|
|
|
const created = await service.createDashboard('user-1', 'tenant-1');
|
|
const widgets = await service.getWidgets('user-1', 'tenant-1', 'USER' as any, created.id);
|
|
|
|
expect(widgets).toEqual([]);
|
|
});
|
|
|
|
it('wird ab 20 vorhandenen Reitern abgewiesen, ohne eine Zeile zu schreiben', async () => {
|
|
const dashboards = Array.from({ length: 20 }, (_, i) =>
|
|
makeDashboard({ id: `d${i}`, name: i === 0 ? 'Dashboard' : `Dashboard ${i + 1}`, position: i }),
|
|
);
|
|
const prisma = makeFakePrisma({ dashboards });
|
|
const service = new DashboardService(prisma as any, makeFakeModuleAccessService(new Set()) as any);
|
|
|
|
await expect(service.createDashboard('user-1', 'tenant-1')).rejects.toThrow();
|
|
expect(prisma.dashboard.create).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('DashboardService.renameDashboard — Reiter umbenennen (quick-260923-ad9, Task 2)', () => {
|
|
beforeEach(() => {
|
|
vi.mocked(forTenant).mockClear();
|
|
});
|
|
|
|
it('benennt den eigenen Reiter um', async () => {
|
|
const prisma = makeFakePrisma({ dashboards: [makeDashboard({ id: DASH_1 })] });
|
|
const service = new DashboardService(prisma as any, makeFakeModuleAccessService(new Set()) as any);
|
|
|
|
const result = await service.renameDashboard(DASH_1, 'user-1', 'tenant-1', { name: 'Finanzen' } as any);
|
|
|
|
expect(result.name).toBe('Finanzen');
|
|
});
|
|
|
|
it('ein fremder Reiter führt zur Nicht-gefunden-Antwort', async () => {
|
|
const prisma = makeFakePrisma({
|
|
dashboards: [makeDashboard({ id: DASH_1, userId: 'other-user' })],
|
|
});
|
|
const service = new DashboardService(prisma as any, makeFakeModuleAccessService(new Set()) as any);
|
|
|
|
await expect(
|
|
service.renameDashboard(DASH_1, 'user-1', 'tenant-1', { name: 'x' } as any),
|
|
).rejects.toBeInstanceOf(NotFoundException);
|
|
});
|
|
});
|
|
|
|
describe('RenameDashboardDto — Beschneiden und Längenprüfung (quick-260923-ad9, Task 2)', () => {
|
|
it('beschneidet führende/nachgestellte Leerräume vor der Längenprüfung', async () => {
|
|
const { plainToInstance } = await import('class-transformer');
|
|
const { validate } = await import('class-validator');
|
|
const { RenameDashboardDto } = await import('./dto/rename-dashboard.dto');
|
|
|
|
const dto = plainToInstance(RenameDashboardDto, { name: ' Finanzen ' });
|
|
expect(dto.name).toBe('Finanzen');
|
|
expect(await validate(dto)).toEqual([]);
|
|
});
|
|
|
|
it('ein leerer Name (nach dem Beschneiden) wird abgewiesen', async () => {
|
|
const { plainToInstance } = await import('class-transformer');
|
|
const { validate } = await import('class-validator');
|
|
const { RenameDashboardDto } = await import('./dto/rename-dashboard.dto');
|
|
|
|
const dto = plainToInstance(RenameDashboardDto, { name: ' ' });
|
|
const errors = await validate(dto);
|
|
|
|
expect(errors.length).toBeGreaterThan(0);
|
|
expect(errors[0].property).toBe('name');
|
|
});
|
|
|
|
it('ein Name über 40 Zeichen wird abgewiesen', async () => {
|
|
const { plainToInstance } = await import('class-transformer');
|
|
const { validate } = await import('class-validator');
|
|
const { RenameDashboardDto } = await import('./dto/rename-dashboard.dto');
|
|
|
|
const dto = plainToInstance(RenameDashboardDto, { name: 'x'.repeat(41) });
|
|
const errors = await validate(dto);
|
|
|
|
expect(errors.length).toBeGreaterThan(0);
|
|
expect(errors[0].property).toBe('name');
|
|
});
|
|
});
|
|
|
|
describe('DashboardService.deleteDashboard — Reiter löschen (quick-260923-ad9, Task 2, D-10)', () => {
|
|
beforeEach(() => {
|
|
vi.mocked(forTenant).mockClear();
|
|
});
|
|
|
|
it('entfernt Reiter, seine Kacheln und seine Anordnung in EINER Transaktion und schreibt die verbleibenden Positionen lückenlos neu', async () => {
|
|
const prisma = makeFakePrisma({
|
|
dashboards: [
|
|
makeDashboard({ id: 'd1', position: 0 }),
|
|
makeDashboard({ id: 'd2', position: 1 }),
|
|
makeDashboard({ id: 'd3', position: 2 }),
|
|
],
|
|
widgets: [makeWidget({ id: 'w1', dashboardId: 'd2' })],
|
|
layout: { dashboardId: 'd2', userId: 'user-1', tenantId: 'tenant-1', layouts: {} },
|
|
});
|
|
const service = new DashboardService(prisma as any, makeFakeModuleAccessService(new Set()) as any);
|
|
|
|
await service.deleteDashboard('d2', 'user-1', 'tenant-1');
|
|
|
|
const remaining = await service.listDashboards('user-1', 'tenant-1');
|
|
expect(remaining.map((d: any) => d.id)).toEqual(['d1', 'd3']);
|
|
expect(remaining.map((d: any) => d.position)).toEqual([0, 1]);
|
|
expect(prisma.widgetInstance.deleteMany).toHaveBeenCalledWith(
|
|
expect.objectContaining({ where: { dashboardId: 'd2', userId: 'user-1' } }),
|
|
);
|
|
expect(prisma.dashboardLayout.deleteMany).toHaveBeenCalledWith(
|
|
expect.objectContaining({ where: { dashboardId: 'd2', userId: 'user-1' } }),
|
|
);
|
|
});
|
|
|
|
it('der letzte verbleibende Reiter kann nicht gelöscht werden — nichts wird geschrieben', async () => {
|
|
const prisma = makeFakePrisma({ dashboards: [makeDashboard({ id: 'd1' })] });
|
|
const service = new DashboardService(prisma as any, makeFakeModuleAccessService(new Set()) as any);
|
|
|
|
await expect(service.deleteDashboard('d1', 'user-1', 'tenant-1')).rejects.toBeInstanceOf(
|
|
ConflictException,
|
|
);
|
|
expect(prisma.dashboard.deleteMany).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('ein fremder Reiter führt zur Nicht-gefunden-Antwort', async () => {
|
|
const prisma = makeFakePrisma({
|
|
dashboards: [
|
|
makeDashboard({ id: 'd1', userId: 'other-user' }),
|
|
makeDashboard({ id: 'd2', userId: 'other-user', position: 1 }),
|
|
],
|
|
});
|
|
const service = new DashboardService(prisma as any, makeFakeModuleAccessService(new Set()) as any);
|
|
|
|
await expect(service.deleteDashboard('d1', 'user-1', 'tenant-1')).rejects.toBeInstanceOf(
|
|
NotFoundException,
|
|
);
|
|
});
|
|
|
|
describe('T-LRR-07 (quick-260923-lrr): Datei-Leichen nach Kaskadenloeschung eines ganzen Reiters', () => {
|
|
let iconsDir: string;
|
|
const ORIGINAL_DIR_ENV = process.env.FAVORITE_ICONS_DIR;
|
|
|
|
beforeEach(() => {
|
|
iconsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tessera-favorite-icons-dashboard-tab-'));
|
|
process.env.FAVORITE_ICONS_DIR = iconsDir;
|
|
});
|
|
|
|
afterEach(() => {
|
|
fs.rmSync(iconsDir, { recursive: true, force: true });
|
|
if (ORIGINAL_DIR_ENV === undefined) {
|
|
delete process.env.FAVORITE_ICONS_DIR;
|
|
} else {
|
|
process.env.FAVORITE_ICONS_DIR = ORIGINAL_DIR_ENV;
|
|
}
|
|
});
|
|
|
|
it('entfernt die Symboldateien ALLER Favoriten der Widgets dieses Reiters', async () => {
|
|
const prisma = makeFakePrisma({
|
|
dashboards: [makeDashboard({ id: 'd1', position: 0 }), makeDashboard({ id: 'd2', position: 1 })],
|
|
widgets: [
|
|
makeWidget({ id: 'w1', dashboardId: 'd1' }),
|
|
makeWidget({ id: 'w2', dashboardId: 'd1' }),
|
|
],
|
|
favorites: [
|
|
makeFavorite({ id: 'fav-1', widgetId: 'w1', uploadedIconMime: 'image/png' }),
|
|
makeFavorite({ id: 'fav-2', widgetId: 'w2', uploadedIconMime: 'image/svg+xml' }),
|
|
makeFavorite({ id: 'fav-3', widgetId: 'w2', uploadedIconMime: null }),
|
|
],
|
|
});
|
|
const service = new DashboardService(prisma as any, makeFakeModuleAccessService(new Set()) as any);
|
|
|
|
const abs1 = favoriteIconAbsolutePath('user-1', 'fav-1', 'image/png') as string;
|
|
const abs2 = favoriteIconAbsolutePath('user-1', 'fav-2', 'image/svg+xml') as string;
|
|
for (const absolute of [abs1, abs2]) {
|
|
fs.mkdirSync(path.dirname(absolute), { recursive: true });
|
|
fs.writeFileSync(absolute, Buffer.from([1]));
|
|
}
|
|
|
|
await service.deleteDashboard('d1', 'user-1', 'tenant-1');
|
|
|
|
expect(fs.existsSync(abs1)).toBe(false);
|
|
expect(fs.existsSync(abs2)).toBe(false);
|
|
});
|
|
|
|
it('ein Reiter ohne Widgets loest keinen Dateizugriff aus', async () => {
|
|
const prisma = makeFakePrisma({
|
|
dashboards: [makeDashboard({ id: 'd1', position: 0 }), makeDashboard({ id: 'd2', position: 1 })],
|
|
});
|
|
const service = new DashboardService(prisma as any, makeFakeModuleAccessService(new Set()) as any);
|
|
|
|
await expect(service.deleteDashboard('d1', 'user-1', 'tenant-1')).resolves.toEqual({ id: 'd1' });
|
|
expect(fs.existsSync(path.join(iconsDir, 'user-1'))).toBe(false);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('DashboardService.reorderDashboards — Reiter umsortieren (quick-260923-ad9, Task 2, T-AD9-04)', () => {
|
|
beforeEach(() => {
|
|
vi.mocked(forTenant).mockClear();
|
|
});
|
|
|
|
it('schreibt die Positionen 0…n-1 in der gesendeten Reihenfolge', async () => {
|
|
const prisma = makeFakePrisma({
|
|
dashboards: [
|
|
makeDashboard({ id: 'd1', position: 0 }),
|
|
makeDashboard({ id: 'd2', position: 1 }),
|
|
makeDashboard({ id: 'd3', position: 2 }),
|
|
],
|
|
});
|
|
const service = new DashboardService(prisma as any, makeFakeModuleAccessService(new Set()) as any);
|
|
|
|
const result = await service.reorderDashboards('user-1', 'tenant-1', {
|
|
ids: ['d3', 'd1', 'd2'],
|
|
} as any);
|
|
|
|
expect(result.map((d: any) => d.id)).toEqual(['d3', 'd1', 'd2']);
|
|
expect(result.map((d: any) => d.position)).toEqual([0, 1, 2]);
|
|
});
|
|
|
|
it('eine unvollständige Liste wird abgewiesen, ohne eine einzige Position zu ändern', async () => {
|
|
const prisma = makeFakePrisma({
|
|
dashboards: [
|
|
makeDashboard({ id: 'd1', position: 0 }),
|
|
makeDashboard({ id: 'd2', position: 1 }),
|
|
],
|
|
});
|
|
const service = new DashboardService(prisma as any, makeFakeModuleAccessService(new Set()) as any);
|
|
|
|
await expect(
|
|
service.reorderDashboards('user-1', 'tenant-1', { ids: ['d1'] } as any),
|
|
).rejects.toBeInstanceOf(BadRequestException);
|
|
const unchanged = await service.listDashboards('user-1', 'tenant-1');
|
|
expect(unchanged.map((d: any) => d.position)).toEqual([0, 1]);
|
|
});
|
|
|
|
it('eine unbekannte Kennung wird abgewiesen, ohne eine einzige Position zu ändern', async () => {
|
|
const prisma = makeFakePrisma({
|
|
dashboards: [
|
|
makeDashboard({ id: 'd1', position: 0 }),
|
|
makeDashboard({ id: 'd2', position: 1 }),
|
|
],
|
|
});
|
|
const service = new DashboardService(prisma as any, makeFakeModuleAccessService(new Set()) as any);
|
|
|
|
await expect(
|
|
service.reorderDashboards('user-1', 'tenant-1', { ids: ['d1', 'unknown'] } as any),
|
|
).rejects.toBeInstanceOf(BadRequestException);
|
|
const unchanged = await service.listDashboards('user-1', 'tenant-1');
|
|
expect(unchanged.map((d: any) => d.position)).toEqual([0, 1]);
|
|
});
|
|
|
|
it('die Kennung eines fremden Reiters wird abgewiesen, ohne eine einzige Position zu ändern', async () => {
|
|
const prisma = makeFakePrisma({
|
|
dashboards: [
|
|
makeDashboard({ id: 'd1', position: 0 }),
|
|
makeDashboard({ id: 'foreign', userId: 'other-user', position: 0 }),
|
|
],
|
|
});
|
|
const service = new DashboardService(prisma as any, makeFakeModuleAccessService(new Set()) as any);
|
|
|
|
await expect(
|
|
service.reorderDashboards('user-1', 'tenant-1', { ids: ['d1', 'foreign'] } as any),
|
|
).rejects.toBeInstanceOf(BadRequestException);
|
|
const unchanged = await service.listDashboards('user-1', 'tenant-1');
|
|
expect(unchanged.map((d: any) => d.position)).toEqual([0]);
|
|
});
|
|
});
|