feat(260923-lrr): API — Favoriten-Symbol hochladen, Vorrang, Versionszaehler, Abrufprobe

- 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>
This commit is contained in:
2026-09-23 16:04:29 +02:00
parent bf4384ad73
commit 7704372c3c
11 changed files with 1454 additions and 21 deletions
@@ -1,4 +1,7 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
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
@@ -45,6 +48,7 @@ vi.mock('../prisma/prisma-tenant.extension', () => ({
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';
@@ -52,8 +56,16 @@ import { DashboardService } from './dashboard.service';
* 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). */
const BOUND_MODEL_NAMES = ['dashboard', 'dashboardLayout', 'widgetInstance', 'searchProvider'];
* 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). */
@@ -125,6 +137,27 @@ function makeSearchProvider(
};
}
/** 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>[];
@@ -132,12 +165,14 @@ function makeFakePrisma(
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
@@ -256,6 +291,24 @@ function makeFakePrisma(
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;
@@ -667,6 +720,69 @@ describe('DashboardService — Anordnung und Widgets gebunden an forTenant() (26
);
});
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({
@@ -1234,6 +1350,63 @@ describe('DashboardService.deleteDashboard — Reiter löschen (quick-260923-ad9
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)', () => {