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:
@@ -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)', () => {
|
||||
|
||||
Reference in New Issue
Block a user