Files
tessera-ctl/apps/api/src/favorites/favorites.service.spec.ts
T
schalli b15c74632b fix(favorites): Symbol-Adresse auch speichern, wenn nur der Browser sie laden kann
- API: ausdrueckliche iconUrl wird nur auf Form (http/https, <= 2048) geprueft
  und auch gespeichert, wenn der Server sie nicht abrufen kann; keine 422 mehr
- Erkennung: Seite mit Fehlerstatus, aber HTML mit <link rel=icon>, liefert
  diesen Verweis (docuvita); og:image einer Fehlerseite zaehlt nicht
- Kachel: Proxy -> iconUrl direkt im Browser (no-referrer, nur http/https) ->
  Origin-Favicon -> Buchstabe
- Meldung iconUrlUnreachable (de/en) entfernt, Hinweis zum Vorrang angepasst

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 15:34:58 +02:00

1027 lines
44 KiB
TypeScript

import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import {
BadRequestException,
HttpException,
NotFoundException,
PayloadTooLargeException,
} from '@nestjs/common';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { FavoritesService } from './favorites.service';
import { forTenant, withTenantTransaction } from '../prisma/prisma-tenant.extension';
/**
* FavoritesService.spec — NEU (260911-gwh). Der Bereich `favorites` hatte
* VOR diesem Lauf KEINE Testdatei fuer den Dienst (Befund J; nur
* `icon-discovery.service.spec.ts` existierte). Zwei-Klienten-Nachbau
* (Muster `dkv.service.spec.ts`/`auth.service.spec.ts`): `forTenant()` wird
* auf `unboundClient.__makeBoundClient(tenantId)` umgeleitet. Der
* UNGEBUNDENE Nachbau (der Fake selbst) hat KEINES der Anfrage-Modelle
* (`favoriteLink`, `widgetInstance`) — ein versehentlich ungebundener
* Modellzugriff scheitert mit "Cannot read properties of undefined" (die
* dkv-Form der Falsifizierung, siehe auth.service.spec.ts:280). Der
* GEBUNDENE Klient hat ausschliesslich `favoriteLink`/`widgetInstance`.
*
* 260917-jdd: `withTenantTransaction` kommt zum Mock hinzu (Muster
* groups.service.spec.ts Z. 30-35/296-299) — `prisma.__withTenantTransaction`
* reicht den gebundenen Klienten als `tx` durch und protokolliert den
* Aufruf. Der Fake bekommt zusaetzlich `updateMany` auf `favoriteLink` fuer
* `reorder()`.
*/
vi.mock('../prisma/prisma-tenant.extension', () => ({
forTenant: vi.fn((unboundClient: any, tenantId: string) => unboundClient.__makeBoundClient(tenantId)),
withTenantTransaction: vi.fn((unboundClient: any, tenantId: string, fn: (tx: any) => any) =>
unboundClient.__withTenantTransaction(tenantId, fn),
),
}));
interface FakeFavoriteRow {
id: string;
userId: string;
tenantId: string;
widgetId: string;
title: string;
url: string;
iconUrl: string | null;
position: number;
createdAt?: Date;
updatedAt?: Date;
/** 260923-lrr — Bestandszeilen im Fake bekommen die Vorgabe null/0. */
uploadedIconMime?: string | null;
iconVersion?: number;
}
interface FakeWidgetRow {
id: string;
userId: string;
tenantId: string;
}
interface BoundCall {
tenantId: string;
model: 'favoriteLink' | 'widgetInstance' | '$transaction';
method: string;
}
function throwP2025(action: 'update' | 'delete'): never {
const err: any = new Error(
action === 'update'
? 'An operation failed because it depends on one or more records that were required but not found. No record was found for an update.'
: 'An operation failed because it depends on one or more records that were required but not found. No record was found for a delete.',
);
err.code = 'P2025';
throw err;
}
/**
* Zwei-Klienten-Nachbau: `favorites`/`widgets` sind das gemeinsame
* Gedaechtnis, der gebundene Klient (`__makeBoundClient`) protokolliert
* jeden Zugriff im `boundCallLog` — der ungebundene Basisclient (der Fake
* selbst) traegt KEIN `favoriteLink`/`widgetInstance` und protokolliert
* deshalb strukturell nie.
*/
function makeFakePrisma(favoriteRows: FakeFavoriteRow[] = [], widgetRows: FakeWidgetRow[] = []) {
const favorites = new Map(
favoriteRows.map((f) => [f.id, { uploadedIconMime: null, iconVersion: 0, ...f }]),
);
const widgets = new Map(widgetRows.map((w) => [w.id, { ...w }]));
const boundCallLog: BoundCall[] = [];
let autoId = favoriteRows.length;
function makeScopedFavoriteLink(tenantId: string) {
return {
findMany: async ({ where, orderBy }: any) => {
boundCallLog.push({ tenantId, model: 'favoriteLink', method: 'findMany' });
let rows = Array.from(favorites.values()).filter((f) => f.tenantId === tenantId);
if (where?.userId) rows = rows.filter((f) => f.userId === where.userId);
if (where?.widgetId) rows = rows.filter((f) => f.widgetId === where.widgetId);
if (orderBy) {
rows = [...rows].sort((a, b) => {
for (const clause of orderBy) {
const [key, dir] = Object.entries(clause as Record<string, string>)[0];
const av = (a as any)[key];
const bv = (b as any)[key];
if (av < bv) return dir === 'asc' ? -1 : 1;
if (av > bv) return dir === 'asc' ? 1 : -1;
}
return 0;
});
}
return rows;
},
findUnique: async ({ where }: any) => {
boundCallLog.push({ tenantId, model: 'favoriteLink', method: 'findUnique' });
const row = favorites.get(where.id);
if (!row || row.tenantId !== tenantId) return null;
return { ...row };
},
create: async ({ data }: any) => {
boundCallLog.push({ tenantId, model: 'favoriteLink', method: 'create' });
const id = data.id ?? `fav-${++autoId}`;
const now = new Date();
const record = {
iconUrl: null,
position: 0,
uploadedIconMime: null,
iconVersion: 0,
createdAt: now,
updatedAt: now,
...data,
id,
};
favorites.set(id, record);
return record;
},
update: async ({ where, data }: any) => {
boundCallLog.push({ tenantId, model: 'favoriteLink', method: 'update' });
const row = favorites.get(where.id);
if (!row || row.tenantId !== tenantId) throwP2025('update');
const updated: any = { ...row, ...data, updatedAt: new Date() };
// 260923-lrr: `iconVersion: { increment: n }` — Prisma's atomic
// increment form, angewendet auf den bisherigen Zaehlerstand.
if (data.iconVersion && typeof data.iconVersion === 'object' && 'increment' in data.iconVersion) {
updated.iconVersion = (row.iconVersion ?? 0) + data.iconVersion.increment;
}
favorites.set(where.id, updated);
return updated;
},
delete: async ({ where }: any) => {
boundCallLog.push({ tenantId, model: 'favoriteLink', method: 'delete' });
const row = favorites.get(where.id);
if (!row || row.tenantId !== tenantId) throwP2025('delete');
favorites.delete(where.id);
return row;
},
// 260917-jdd: reorder() — filtert nach tenantId sowie, falls in
// `where` vorhanden, id/userId/widgetId; wendet `data` auf jede
// Treffer-Zeile an; liefert { count }.
updateMany: async ({ where, data }: any) => {
boundCallLog.push({ tenantId, model: 'favoriteLink', method: 'updateMany' });
let rows = Array.from(favorites.values()).filter((f) => f.tenantId === tenantId);
if (where?.id) rows = rows.filter((f) => f.id === where.id);
if (where?.userId) rows = rows.filter((f) => f.userId === where.userId);
if (where?.widgetId) rows = rows.filter((f) => f.widgetId === where.widgetId);
for (const row of rows) {
favorites.set(row.id, { ...row, ...data, updatedAt: new Date() });
}
return { count: rows.length };
},
};
}
function makeScopedWidgetInstance(tenantId: string) {
return {
findUnique: async ({ where, select }: any) => {
boundCallLog.push({ tenantId, model: 'widgetInstance', method: 'findUnique' });
const row = widgets.get(where.id);
if (!row || row.tenantId !== tenantId) return null;
if (!select) return { ...row };
const picked: any = {};
for (const key of Object.keys(select)) {
if (select[key]) picked[key] = (row as any)[key];
}
return picked;
},
};
}
const fake: any = {
__favorites: favorites,
__widgets: widgets,
__boundCallLog: boundCallLog,
__makeBoundClient(tenantId: string) {
return {
favoriteLink: makeScopedFavoriteLink(tenantId),
widgetInstance: makeScopedWidgetInstance(tenantId),
};
},
__withTenantTransaction(tenantId: string, fn: (tx: any) => any) {
boundCallLog.push({ tenantId, model: '$transaction', method: 'withTenantTransaction' });
return fn(fake.__makeBoundClient(tenantId));
},
};
return fake;
}
function expectBoundCall(
prisma: any,
tenantId: string,
model: 'favoriteLink' | 'widgetInstance',
method: string,
) {
const found = prisma.__boundCallLog.some(
(c: BoundCall) => 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);
}
function makeIconDiscovery(
overrides: Partial<{ discoverFavoriteIconUrl: any; fetchIconBytes: any }> = {},
) {
return {
discoverFavoriteIconUrl:
overrides.discoverFavoriteIconUrl ??
vi.fn(async (url: string) => `https://icons.invalid/${encodeURIComponent(url)}`),
fetchIconBytes:
overrides.fetchIconBytes ??
vi.fn(async () => ({ contentType: 'image/png', body: Buffer.from('png') })),
};
}
beforeEach(() => {
vi.clearAllMocks();
});
describe('FavoritesService — Bindung an forTenant() (260911-gwh)', () => {
it('scheitert an "Cannot read properties of undefined", wenn ein Favoritenzugriff versehentlich ungebunden auf dem Basisclient laeuft (Falsifizierungsform)', () => {
const prisma = makeFakePrisma();
expect(prisma.favoriteLink).toBeUndefined();
expect(prisma.widgetInstance).toBeUndefined();
});
describe('list', () => {
it('liefert nur die Zeilen von user-a1 fuer widget-a1, sortiert nach position, dann title', async () => {
const prisma = makeFakePrisma([
{ id: 'f1', userId: 'user-a1', tenantId: 't1', widgetId: 'widget-a1', title: 'B', url: 'https://b.invalid', iconUrl: null, position: 1 },
{ id: 'f2', userId: 'user-a1', tenantId: 't1', widgetId: 'widget-a1', title: 'A', url: 'https://a.invalid', iconUrl: null, position: 0 },
{ id: 'f3', userId: 'user-a2', tenantId: 't1', widgetId: 'widget-a1', title: 'C', url: 'https://c.invalid', iconUrl: null, position: 0 },
{ id: 'f4', userId: 'user-a1', tenantId: 't1', widgetId: 'widget-a2', title: 'D', url: 'https://d.invalid', iconUrl: null, position: 0 },
]);
const service = new FavoritesService(prisma as any, makeIconDiscovery() as any);
const result = await service.list('t1', 'user-a1', 'widget-a1');
expect(result.map((r: any) => r.id)).toEqual(['f2', 'f1']);
expectBoundCall(prisma, 't1', 'favoriteLink', 'findMany');
// Benutzerdimension (260911-nke): forTenant() bekommt userId als drittes Argument.
expect(forTenant).toHaveBeenCalledWith(prisma, 't1', 'user-a1');
});
it('liefert unter einem FREMDEN Mandanten eine leere Liste, kein Fehler (der Wert, aus dem das Widget "Noch keine Favoriten." macht)', async () => {
const prisma = makeFakePrisma([
{ id: 'f1', userId: 'user-a1', tenantId: 't1', widgetId: 'widget-a1', title: 'B', url: 'https://b.invalid', iconUrl: null, position: 0 },
]);
const service = new FavoritesService(prisma as any, makeIconDiscovery() as any);
const result = await service.list('t2', 'user-a1', 'widget-a1');
expect(result).toEqual([]);
});
it('wirft BadRequestException ohne widgetId, OHNE einen Klienten zu erzeugen', async () => {
const prisma = makeFakePrisma();
const service = new FavoritesService(prisma as any, makeIconDiscovery() as any);
await expect(service.list('t1', 'user-a1', '')).rejects.toThrow(BadRequestException);
expect(vi.mocked(forTenant).mock.calls.length).toBe(0);
});
});
describe('create', () => {
it('normalisiert die URL, sucht das Icon mit der NORMALISIERTEN URL, und legt mit tenantId/userId/widgetId/position=0 an, wenn iconUrl fehlt', async () => {
const prisma = makeFakePrisma([], [{ id: 'widget-a1', userId: 'user-a1', tenantId: 't1' }]);
const iconDiscovery = makeIconDiscovery();
const service = new FavoritesService(prisma as any, iconDiscovery as any);
const created = await service.create('t1', 'user-a1', {
widgetId: 'widget-a1',
title: 'CTL',
url: 'ctl.de',
} as any);
expect(iconDiscovery.discoverFavoriteIconUrl).toHaveBeenCalledWith('https://ctl.de');
expect(created.url).toBe('https://ctl.de');
expect(created.userId).toBe('user-a1');
expect(created.tenantId).toBe('t1');
expect(created.position).toBe(0);
expectBoundCall(prisma, 't1', 'favoriteLink', 'create');
});
it('sucht KEIN Icon, wenn iconUrl uebergeben wird — gespeicherter Wert bleibt wie uebergeben', async () => {
const prisma = makeFakePrisma([], [{ id: 'widget-a1', userId: 'user-a1', tenantId: 't1' }]);
const iconDiscovery = makeIconDiscovery();
const service = new FavoritesService(prisma as any, iconDiscovery as any);
const created = await service.create('t1', 'user-a1', {
widgetId: 'widget-a1',
title: 'CTL',
url: 'https://ctl.de',
iconUrl: 'https://ctl.de/favicon.ico',
} as any);
expect(iconDiscovery.discoverFavoriteIconUrl).not.toHaveBeenCalled();
expect(created.iconUrl).toBe('https://ctl.de/favicon.ico');
});
it('T-GWH-05: widgetId gehoert einem ANDEREN Benutzer desselben Mandanten -> NotFoundException "Widget not found", KEIN create, KEINE Icon-Suche', async () => {
const prisma = makeFakePrisma([], [{ id: 'widget-a2', userId: 'user-a2', tenantId: 't1' }]);
const iconDiscovery = makeIconDiscovery();
const service = new FavoritesService(prisma as any, iconDiscovery as any);
await expect(
service.create('t1', 'user-a1', { widgetId: 'widget-a2', title: 'X', url: 'https://x.invalid' } as any),
).rejects.toThrow('Widget not found');
expect(iconDiscovery.discoverFavoriteIconUrl).not.toHaveBeenCalled();
expect(prisma.__favorites.size).toBe(0);
});
it('T-GWH-05: widgetId gehoert einem Widget unter FREMDEM Mandanten -> dieselbe NotFoundException, nennt weder Halter noch Mandant', async () => {
const prisma = makeFakePrisma([], [{ id: 'widget-b1', userId: 'user-b1', tenantId: 't2' }]);
const service = new FavoritesService(prisma as any, makeIconDiscovery() as any);
await expect(
service.create('t1', 'user-a1', { widgetId: 'widget-b1', title: 'X', url: 'https://x.invalid' } as any),
).rejects.toThrow(NotFoundException);
await expect(
service.create('t1', 'user-a1', { widgetId: 'widget-b1', title: 'X', url: 'https://x.invalid' } as any),
).rejects.toThrow('Widget not found');
});
it('T-GWH-05: unbekannte widgetId -> dieselbe NotFoundException', async () => {
const prisma = makeFakePrisma();
const service = new FavoritesService(prisma as any, makeIconDiscovery() as any);
await expect(
service.create('t1', 'user-a1', { widgetId: 'widget-fehlt', title: 'X', url: 'https://x.invalid' } as any),
).rejects.toThrow('Widget not found');
});
it('Wachhund: genau EIN gebundener Klient je create-Aufruf, Widget-Pruefung UND Schreibzugriff auf DEMSELBEN Klienten', async () => {
const prisma = makeFakePrisma([], [{ id: 'widget-a1', userId: 'user-a1', tenantId: 't1' }]);
const service = new FavoritesService(prisma as any, makeIconDiscovery() as any);
vi.mocked(forTenant).mockClear();
await service.create('t1', 'user-a1', { widgetId: 'widget-a1', title: 'X', url: 'https://x.invalid' } as any);
expect(vi.mocked(forTenant).mock.calls.length).toBe(1);
expect(prisma.__boundCallLog.filter((c: BoundCall) => c.tenantId === 't1')).toEqual([
{ tenantId: 't1', model: 'widgetInstance', method: 'findUnique' },
{ tenantId: 't1', model: 'favoriteLink', method: 'create' },
]);
});
});
describe('update', () => {
const baseRow: FakeFavoriteRow = {
id: 'f1',
userId: 'user-a1',
tenantId: 't1',
widgetId: 'widget-a1',
title: 'Alt',
url: 'https://alt.invalid',
iconUrl: 'https://alt.invalid/icon.png',
position: 0,
};
it('mergt Titel/URL/Position fuer die eigene Zeile, ueber DEMSELBEN gebundenen Klienten', async () => {
const prisma = makeFakePrisma([baseRow]);
const service = new FavoritesService(prisma as any, makeIconDiscovery() as any);
const updated = await service.update('t1', 'f1', 'user-a1', { title: 'Neu', position: 3 } as any);
expect(updated.title).toBe('Neu');
expect(updated.position).toBe(3);
expectBoundCall(prisma, 't1', 'favoriteLink', 'findUnique');
expectBoundCall(prisma, 't1', 'favoriteLink', 'update');
});
it('iconUrl explizit null im DTO loest eine Icon-Suche gegen die EFFEKTIVE URL aus', async () => {
const prisma = makeFakePrisma([baseRow]);
const iconDiscovery = makeIconDiscovery();
const service = new FavoritesService(prisma as any, iconDiscovery as any);
await service.update('t1', 'f1', 'user-a1', { iconUrl: null } as any);
expect(iconDiscovery.discoverFavoriteIconUrl).toHaveBeenCalledWith(baseRow.url);
});
it('iconUrl gesetzt im DTO -> KEINE Icon-Suche', async () => {
const prisma = makeFakePrisma([baseRow]);
const iconDiscovery = makeIconDiscovery();
const service = new FavoritesService(prisma as any, iconDiscovery as any);
const updated = await service.update('t1', 'f1', 'user-a1', { iconUrl: 'https://neu.invalid/icon.png' } as any);
expect(iconDiscovery.discoverFavoriteIconUrl).not.toHaveBeenCalled();
expect(updated.iconUrl).toBe('https://neu.invalid/icon.png');
});
it('Zeile eines ANDEREN Benutzers desselben Mandanten -> NotFoundException, KEIN Schreibzugriff', async () => {
const prisma = makeFakePrisma([{ ...baseRow, userId: 'user-a2' }]);
const service = new FavoritesService(prisma as any, makeIconDiscovery() as any);
await expect(service.update('t1', 'f1', 'user-a1', { title: 'X' } as any)).rejects.toThrow(
'FavoriteLink not found',
);
expect(prisma.__favorites.get('f1')?.title).toBe('Alt');
});
it('Zeile unter FREMDEM Mandanten -> NotFoundException, KEIN Schreibzugriff — der gebundene findUnique liefert null, bevor irgendetwas geschrieben wird', async () => {
const prisma = makeFakePrisma([baseRow]);
const service = new FavoritesService(prisma as any, makeIconDiscovery() as any);
await expect(service.update('t2', 'f1', 'user-a1', { title: 'X' } as any)).rejects.toThrow(
NotFoundException,
);
expect(prisma.__favorites.get('f1')?.title).toBe('Alt');
expect(
prisma.__boundCallLog.some((c: BoundCall) => c.tenantId === 't2' && c.method === 'update'),
).toBe(false);
});
});
describe('remove', () => {
const baseRow: FakeFavoriteRow = {
id: 'f1',
userId: 'user-a1',
tenantId: 't1',
widgetId: 'widget-a1',
title: 'X',
url: 'https://x.invalid',
iconUrl: null,
position: 0,
};
it('loescht die eigene Zeile', async () => {
const prisma = makeFakePrisma([baseRow]);
const service = new FavoritesService(prisma as any, makeIconDiscovery() as any);
await service.remove('t1', 'f1', 'user-a1');
expect(prisma.__favorites.has('f1')).toBe(false);
});
it('fremder Benutzer -> NotFoundException, Zeile bleibt', async () => {
const prisma = makeFakePrisma([{ ...baseRow, userId: 'user-a2' }]);
const service = new FavoritesService(prisma as any, makeIconDiscovery() as any);
await expect(service.remove('t1', 'f1', 'user-a1')).rejects.toThrow('FavoriteLink not found');
expect(prisma.__favorites.has('f1')).toBe(true);
});
it('fremder Mandant -> NotFoundException, Zeile bleibt', async () => {
const prisma = makeFakePrisma([baseRow]);
const service = new FavoritesService(prisma as any, makeIconDiscovery() as any);
await expect(service.remove('t2', 'f1', 'user-a1')).rejects.toThrow(NotFoundException);
expect(prisma.__favorites.has('f1')).toBe(true);
});
});
describe('getIconBytes', () => {
const baseRow: FakeFavoriteRow = {
id: 'f1',
userId: 'user-a1',
tenantId: 't1',
widgetId: 'widget-a1',
title: 'X',
url: 'https://x.invalid',
iconUrl: 'https://x.invalid/icon.png',
position: 0,
};
it('ruft fetchIconBytes GENAU mit der GESPEICHERTEN URL auf und reicht die Rueckgabe durch', async () => {
const prisma = makeFakePrisma([baseRow]);
const iconDiscovery = makeIconDiscovery();
const service = new FavoritesService(prisma as any, iconDiscovery as any);
const result = await service.getIconBytes('t1', 'f1', 'user-a1');
expect(iconDiscovery.fetchIconBytes).toHaveBeenCalledWith(baseRow.iconUrl);
expect(result).toEqual({ contentType: 'image/png', body: Buffer.from('png') });
});
it('Zeile ohne iconUrl -> NotFoundException, fetchIconBytes NICHT aufgerufen', async () => {
const prisma = makeFakePrisma([{ ...baseRow, iconUrl: null }]);
const iconDiscovery = makeIconDiscovery();
const service = new FavoritesService(prisma as any, iconDiscovery as any);
await expect(service.getIconBytes('t1', 'f1', 'user-a1')).rejects.toThrow('FavoriteLink not found');
expect(iconDiscovery.fetchIconBytes).not.toHaveBeenCalled();
});
it('fremder Mandant -> NotFoundException', async () => {
const prisma = makeFakePrisma([baseRow]);
const service = new FavoritesService(prisma as any, makeIconDiscovery() as any);
await expect(service.getIconBytes('t2', 'f1', 'user-a1')).rejects.toThrow(NotFoundException);
});
it('fetchIconBytes wirft -> HttpException mit Status 502', async () => {
const prisma = makeFakePrisma([baseRow]);
const iconDiscovery = makeIconDiscovery({
fetchIconBytes: vi.fn(async () => {
throw new Error('upstream unreachable');
}),
});
const service = new FavoritesService(prisma as any, iconDiscovery as any);
await expect(service.getIconBytes('t1', 'f1', 'user-a1')).rejects.toThrow(HttpException);
try {
await service.getIconBytes('t1', 'f1', 'user-a1');
} catch (err) {
expect((err as HttpException).getStatus()).toBe(502);
}
});
});
describe('Wachhund je Methode', () => {
it('genau EIN gebundener Klient je Aufruf von list/update/remove/getIconBytes', async () => {
const baseRow: FakeFavoriteRow = {
id: 'f1',
userId: 'user-a1',
tenantId: 't1',
widgetId: 'widget-a1',
title: 'X',
url: 'https://x.invalid',
iconUrl: 'https://x.invalid/icon.png',
position: 0,
};
const prisma = makeFakePrisma([baseRow]);
const service = new FavoritesService(prisma as any, makeIconDiscovery() as any);
for (const call of [
() => service.list('t1', 'user-a1', 'widget-a1'),
() => service.update('t1', 'f1', 'user-a1', { title: 'Y' } as any),
() => service.getIconBytes('t1', 'f1', 'user-a1'),
() => service.remove('t1', 'f1', 'user-a1'),
]) {
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);
}
});
});
describe('reorder (260917-jdd)', () => {
const makeAltbestand = () =>
makeFakePrisma([
{ id: 'f1', userId: 'user-a1', tenantId: 't1', widgetId: 'widget-a1', title: 'A', url: 'https://a.invalid', iconUrl: null, position: 0 },
{ id: 'f2', userId: 'user-a1', tenantId: 't1', widgetId: 'widget-a1', title: 'B', url: 'https://b.invalid', iconUrl: null, position: 0 },
{ id: 'f3', userId: 'user-a1', tenantId: 't1', widgetId: 'widget-a1', title: 'C', url: 'https://c.invalid', iconUrl: null, position: 0 },
{ id: 'f9', userId: 'user-a2', tenantId: 't1', widgetId: 'widget-a1', title: 'D', url: 'https://d.invalid', iconUrl: null, position: 0 },
]);
it('setzt position 0/1/2 in der uebergebenen Reihenfolge und liefert die Liste so sortiert', async () => {
const prisma = makeAltbestand();
const service = new FavoritesService(prisma as any, makeIconDiscovery() as any);
const result = await service.reorder('t1', 'user-a1', {
widgetId: 'widget-a1',
ids: ['f3', 'f1', 'f2'],
} as any);
expect(result.map((r: any) => r.id)).toEqual(['f3', 'f1', 'f2']);
expect(prisma.__favorites.get('f3').position).toBe(0);
expect(prisma.__favorites.get('f1').position).toBe(1);
expect(prisma.__favorites.get('f2').position).toBe(2);
expect(prisma.__favorites.get('f9').position).toBe(0);
expect(vi.mocked(withTenantTransaction)).toHaveBeenCalledWith(prisma, 't1', expect.any(Function));
expectBoundCall(prisma, 't1', 'favoriteLink', 'updateMany');
});
it('fremde id (user-a2) -> BadRequestException, KEINE Position geaendert', async () => {
const prisma = makeAltbestand();
const service = new FavoritesService(prisma as any, makeIconDiscovery() as any);
await expect(
service.reorder('t1', 'user-a1', { widgetId: 'widget-a1', ids: ['f3', 'f1', 'f9'] } as any),
).rejects.toThrow(BadRequestException);
expect(prisma.__favorites.get('f1').position).toBe(0);
expect(prisma.__favorites.get('f2').position).toBe(0);
expect(prisma.__favorites.get('f3').position).toBe(0);
});
it('unbekannte id -> BadRequestException', async () => {
const prisma = makeAltbestand();
const service = new FavoritesService(prisma as any, makeIconDiscovery() as any);
await expect(
service.reorder('t1', 'user-a1', { widgetId: 'widget-a1', ids: ['f3', 'f1', 'f-fehlt'] } as any),
).rejects.toThrow(BadRequestException);
});
it('Teilmenge (2 von 3) -> BadRequestException', async () => {
const prisma = makeAltbestand();
const service = new FavoritesService(prisma as any, makeIconDiscovery() as any);
await expect(
service.reorder('t1', 'user-a1', { widgetId: 'widget-a1', ids: ['f1', 'f2'] } as any),
).rejects.toThrow(BadRequestException);
});
it('doppelte ids -> BadRequestException OHNE withTenantTransaction-Aufruf', async () => {
const prisma = makeAltbestand();
const service = new FavoritesService(prisma as any, makeIconDiscovery() as any);
vi.mocked(withTenantTransaction).mockClear();
await expect(
service.reorder('t1', 'user-a1', { widgetId: 'widget-a1', ids: ['f1', 'f1', 'f2'] } as any),
).rejects.toThrow(BadRequestException);
expect(vi.mocked(withTenantTransaction).mock.calls.length).toBe(0);
});
it('fremder Mandant (t2 auf t1-Zeilen) -> BadRequestException, Positionen unveraendert', async () => {
const prisma = makeAltbestand();
const service = new FavoritesService(prisma as any, makeIconDiscovery() as any);
await expect(
service.reorder('t2', 'user-a1', { widgetId: 'widget-a1', ids: ['f1', 'f2', 'f3'] } as any),
).rejects.toThrow(BadRequestException);
expect(prisma.__favorites.get('f1').position).toBe(0);
expect(prisma.__favorites.get('f2').position).toBe(0);
expect(prisma.__favorites.get('f3').position).toBe(0);
});
it('Wachhund: 0 forTenant-Aufrufe, genau 1 withTenantTransaction-Aufruf fuer den Happy Path', async () => {
const prisma = makeAltbestand();
const service = new FavoritesService(prisma as any, makeIconDiscovery() as any);
vi.mocked(forTenant).mockClear();
vi.mocked(withTenantTransaction).mockClear();
await service.reorder('t1', 'user-a1', { widgetId: 'widget-a1', ids: ['f3', 'f1', 'f2'] } as any);
expect(vi.mocked(forTenant).mock.calls.length).toBe(0);
expect(vi.mocked(withTenantTransaction).mock.calls.length).toBe(1);
});
});
// --- 260923-lrr: eigenes Symbol, Vorrang, Versionszaehler, Abrufprobe ---
describe('uploadIcon/removeUploadedIcon/getIconBytes — eigenes Symbol (260923-lrr)', () => {
let iconsDir: string;
const ORIGINAL_DIR_ENV = process.env.FAVORITE_ICONS_DIR;
const PNG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 13]);
const SVG = Buffer.from('<svg xmlns="http://www.w3.org/2000/svg"></svg>');
const TEXT = Buffer.from('nur Text, kein Bild');
beforeEach(() => {
iconsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tessera-favorite-icons-svc-'));
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;
}
});
const baseRow: FakeFavoriteRow = {
id: 'f1',
userId: 'user-a1',
tenantId: 't1',
widgetId: 'widget-a1',
title: 'X',
url: 'https://x.invalid',
iconUrl: 'https://x.invalid/icon.png',
position: 0,
uploadedIconMime: null,
iconVersion: 0,
};
function fileFor(userId: string, id: string, ext: string): string {
return path.join(iconsDir, userId, `${id}.${ext}`);
}
describe('uploadIcon', () => {
it('PNG: Datei liegt unter <dir>/<userId>/<id>.png mit genau den Bytes, Zeile hat uploadedIconMime image/png und iconVersion +1', async () => {
const prisma = makeFakePrisma([baseRow]);
const service = new FavoritesService(prisma as any, makeIconDiscovery() as any);
const file = { buffer: PNG, originalname: 'x.png', mimetype: 'image/png', size: PNG.length };
const updated = await service.uploadIcon('t1', 'f1', 'user-a1', file);
expect(updated.uploadedIconMime).toBe('image/png');
expect(updated.iconVersion).toBe(1);
const written = fs.readFileSync(fileFor('user-a1', 'f1', 'png'));
expect(written.equals(PNG)).toBe(true);
});
it('ohne Datei -> BadRequestException', async () => {
const prisma = makeFakePrisma([baseRow]);
const service = new FavoritesService(prisma as any, makeIconDiscovery() as any);
await expect(service.uploadIcon('t1', 'f1', 'user-a1', undefined)).rejects.toThrow(
BadRequestException,
);
});
it('Klartext-Puffer -> BadRequestException, keine Datei, Zeile unveraendert', async () => {
const prisma = makeFakePrisma([baseRow]);
const service = new FavoritesService(prisma as any, makeIconDiscovery() as any);
const file = { buffer: TEXT, originalname: 'x.txt', mimetype: 'text/plain', size: TEXT.length };
await expect(service.uploadIcon('t1', 'f1', 'user-a1', file)).rejects.toThrow(
BadRequestException,
);
expect(fs.existsSync(path.join(iconsDir, 'user-a1'))).toBe(false);
expect(prisma.__favorites.get('f1').uploadedIconMime).toBeNull();
});
it('Puffer groesser 512 KB -> PayloadTooLargeException (zweites Netz)', async () => {
const prisma = makeFakePrisma([baseRow]);
const service = new FavoritesService(prisma as any, makeIconDiscovery() as any);
const big = Buffer.concat([PNG, Buffer.alloc(513 * 1024)]);
const file = { buffer: big, originalname: 'x.png', mimetype: 'image/png', size: big.length };
await expect(service.uploadIcon('t1', 'f1', 'user-a1', file)).rejects.toThrow(
PayloadTooLargeException,
);
});
it('fremder Benutzer, fremder Mandant, unbekannte Kennung -> NotFoundException, keine Datei geschrieben', async () => {
const file = { buffer: PNG, originalname: 'x.png', mimetype: 'image/png', size: PNG.length };
const prismaForeignUser = makeFakePrisma([{ ...baseRow, userId: 'user-a2' }]);
const serviceForeignUser = new FavoritesService(
prismaForeignUser as any,
makeIconDiscovery() as any,
);
await expect(
serviceForeignUser.uploadIcon('t1', 'f1', 'user-a1', file),
).rejects.toThrow(NotFoundException);
const prismaForeignTenant = makeFakePrisma([baseRow]);
const serviceForeignTenant = new FavoritesService(
prismaForeignTenant as any,
makeIconDiscovery() as any,
);
await expect(
serviceForeignTenant.uploadIcon('t2', 'f1', 'user-a1', file),
).rejects.toThrow(NotFoundException);
const prismaUnknown = makeFakePrisma([]);
const serviceUnknown = new FavoritesService(prismaUnknown as any, makeIconDiscovery() as any);
await expect(
serviceUnknown.uploadIcon('t1', 'fehlt', 'user-a1', file),
).rejects.toThrow(NotFoundException);
expect(fs.existsSync(path.join(iconsDir, 'user-a1'))).toBe(false);
expect(fs.existsSync(path.join(iconsDir, 'user-a2'))).toBe(false);
});
it('erneuter Upload mit anderem Typ (erst PNG, dann SVG): .png entfernt, .svg vorhanden, iconVersion insgesamt +2', async () => {
const prisma = makeFakePrisma([baseRow]);
const service = new FavoritesService(prisma as any, makeIconDiscovery() as any);
const pngFile = { buffer: PNG, originalname: 'x.png', mimetype: 'image/png', size: PNG.length };
const svgFile = { buffer: SVG, originalname: 'x.svg', mimetype: 'image/svg+xml', size: SVG.length };
await service.uploadIcon('t1', 'f1', 'user-a1', pngFile);
const updated = await service.uploadIcon('t1', 'f1', 'user-a1', svgFile);
expect(fs.existsSync(fileFor('user-a1', 'f1', 'png'))).toBe(false);
expect(fs.existsSync(fileFor('user-a1', 'f1', 'svg'))).toBe(true);
expect(updated.uploadedIconMime).toBe('image/svg+xml');
expect(updated.iconVersion).toBe(2);
});
});
describe('getIconBytes — Vorrang des hochgeladenen Symbols', () => {
it('hochgeladenes Symbol: liefert Dateibytes und gespeicherten Typ, fetchIconBytes wird NICHT aufgerufen', async () => {
const prisma = makeFakePrisma([baseRow]);
const iconDiscovery = makeIconDiscovery();
const service = new FavoritesService(prisma as any, iconDiscovery as any);
const file = { buffer: PNG, originalname: 'x.png', mimetype: 'image/png', size: PNG.length };
await service.uploadIcon('t1', 'f1', 'user-a1', file);
const result = await service.getIconBytes('t1', 'f1', 'user-a1');
expect(result.contentType).toBe('image/png');
expect((result.body as Buffer).equals(PNG)).toBe(true);
expect(iconDiscovery.fetchIconBytes).not.toHaveBeenCalled();
});
it('Typ gesetzt, aber Datei fehlt, iconUrl vorhanden -> faellt auf fetchIconBytes(iconUrl) zurueck', async () => {
const prisma = makeFakePrisma([{ ...baseRow, uploadedIconMime: 'image/png' }]);
const iconDiscovery = makeIconDiscovery();
const service = new FavoritesService(prisma as any, iconDiscovery as any);
const result = await service.getIconBytes('t1', 'f1', 'user-a1');
expect(iconDiscovery.fetchIconBytes).toHaveBeenCalledWith(baseRow.iconUrl);
expect(result).toEqual({ contentType: 'image/png', body: Buffer.from('png') });
});
it('Typ gesetzt, Datei fehlt, KEINE iconUrl -> NotFoundException', async () => {
const prisma = makeFakePrisma([
{ ...baseRow, iconUrl: null, uploadedIconMime: 'image/png' },
]);
const service = new FavoritesService(prisma as any, makeIconDiscovery() as any);
await expect(service.getIconBytes('t1', 'f1', 'user-a1')).rejects.toThrow(
'FavoriteLink not found',
);
});
});
describe('removeUploadedIcon', () => {
it('Datei weg, uploadedIconMime null, iconVersion +1', async () => {
const prisma = makeFakePrisma([baseRow]);
const service = new FavoritesService(prisma as any, makeIconDiscovery() as any);
const file = { buffer: PNG, originalname: 'x.png', mimetype: 'image/png', size: PNG.length };
await service.uploadIcon('t1', 'f1', 'user-a1', file);
const updated = await service.removeUploadedIcon('t1', 'f1', 'user-a1');
expect(updated.uploadedIconMime).toBeNull();
expect(updated.iconVersion).toBe(2);
expect(fs.existsSync(fileFor('user-a1', 'f1', 'png'))).toBe(false);
});
it('ohne vorhandenen Upload -> Zeile unveraendert, keine Erhoehung', async () => {
const prisma = makeFakePrisma([baseRow]);
const service = new FavoritesService(prisma as any, makeIconDiscovery() as any);
const updated = await service.removeUploadedIcon('t1', 'f1', 'user-a1');
expect(updated.iconVersion).toBe(0);
expect(updated.uploadedIconMime).toBeNull();
});
it('fremder Benutzer -> NotFoundException', async () => {
const prisma = makeFakePrisma([{ ...baseRow, userId: 'user-a2' }]);
const service = new FavoritesService(prisma as any, makeIconDiscovery() as any);
await expect(service.removeUploadedIcon('t1', 'f1', 'user-a1')).rejects.toThrow(
NotFoundException,
);
});
});
describe('update — ausdrueckliche Logo-Adresse verdraengt ein hochgeladenes Symbol (260929-lh3)', () => {
const file = { buffer: PNG, originalname: 'x.png', mimetype: 'image/png', size: PNG.length };
it('neue, abweichende iconUrl bei vorhandenem Upload: Upload-Typ null, Datei weg, iconVersion erneut +1 — die neue Adresse wird angezeigt', async () => {
const prisma = makeFakePrisma([baseRow]);
const service = new FavoritesService(prisma as any, makeIconDiscovery() as any);
await service.uploadIcon('t1', 'f1', 'user-a1', file);
expect(fs.existsSync(fileFor('user-a1', 'f1', 'png'))).toBe(true);
const updated = await service.update('t1', 'f1', 'user-a1', {
iconUrl: 'https://neu.invalid/logo.png',
} as any);
expect(updated.iconUrl).toBe('https://neu.invalid/logo.png');
expect(updated.uploadedIconMime).toBeNull();
expect(updated.iconVersion).toBe(2);
expect(fs.existsSync(fileFor('user-a1', 'f1', 'png'))).toBe(false);
});
it('UNVERAENDERTE iconUrl bei vorhandenem Upload (das Formular schickt sie bei jedem Speichern mit): Upload bleibt', async () => {
const prisma = makeFakePrisma([baseRow]);
const service = new FavoritesService(prisma as any, makeIconDiscovery() as any);
await service.uploadIcon('t1', 'f1', 'user-a1', file);
const updated = await service.update('t1', 'f1', 'user-a1', {
iconUrl: baseRow.iconUrl,
} as any);
expect(updated.uploadedIconMime).toBe('image/png');
expect(updated.iconVersion).toBe(1);
expect(fs.existsSync(fileFor('user-a1', 'f1', 'png'))).toBe(true);
});
});
describe('remove() mit hochgeladenem Symbol', () => {
it('Zeile und Datei weg', async () => {
const prisma = makeFakePrisma([baseRow]);
const service = new FavoritesService(prisma as any, makeIconDiscovery() as any);
const file = { buffer: PNG, originalname: 'x.png', mimetype: 'image/png', size: PNG.length };
await service.uploadIcon('t1', 'f1', 'user-a1', file);
await service.remove('t1', 'f1', 'user-a1');
expect(prisma.__favorites.has('f1')).toBe(false);
expect(fs.existsSync(fileFor('user-a1', 'f1', 'png'))).toBe(false);
});
it('Fehler beim Datei-Entfernen wird geschluckt — das Loeschen der Zeile gelingt trotzdem', async () => {
const prisma = makeFakePrisma([baseRow]);
const service = new FavoritesService(prisma as any, makeIconDiscovery() as any);
const file = { buffer: PNG, originalname: 'x.png', mimetype: 'image/png', size: PNG.length };
await service.uploadIcon('t1', 'f1', 'user-a1', file);
// Datei vorab entfernen, damit fs.unlink() im Dienst scheitert.
fs.unlinkSync(fileFor('user-a1', 'f1', 'png'));
await expect(service.remove('t1', 'f1', 'user-a1')).resolves.toBeUndefined();
expect(prisma.__favorites.has('f1')).toBe(false);
});
});
});
describe('create/update — ausdrueckliche iconUrl: nur Formpruefung, kein Abruf (260929-lh3)', () => {
const widgets = [{ id: 'widget-a1', userId: 'user-a1', tenantId: 't1' }];
const failingFetch = () =>
vi.fn(async () => {
throw new Error('server bekommt 404/HTML');
});
it('create mit einer Adresse, die der SERVER nicht abrufen kann: wird gespeichert, KEIN Abruf, KEINE Erkennung', async () => {
const prisma = makeFakePrisma([], widgets);
const iconDiscovery = makeIconDiscovery({ fetchIconBytes: failingFetch() });
const service = new FavoritesService(prisma as any, iconDiscovery as any);
const created = await service.create('t1', 'user-a1', {
widgetId: 'widget-a1',
title: 'Docuvita',
url: 'https://docuvita.ctl.local/server/services/web/',
iconUrl: 'https://docuvita.ctl.local/webclient/docuvita/resources/brandimage/favicon.ico',
} as any);
expect(created.iconUrl).toBe(
'https://docuvita.ctl.local/webclient/docuvita/resources/brandimage/favicon.ico',
);
expect(iconDiscovery.fetchIconBytes).not.toHaveBeenCalled();
expect(iconDiscovery.discoverFavoriteIconUrl).not.toHaveBeenCalled();
expect(prisma.__favorites.size).toBe(1);
});
it.each([
['kein http/https', 'ftp://x.invalid/icon.png'],
['javascript-Schema', 'javascript:alert(1)'],
['keine Adresse', 'kein url'],
['laenger als 2048 Zeichen', `https://x.invalid/${'a'.repeat(2050)}`],
])('create mit ungueltiger iconUrl (%s) -> BadRequestException, nichts geschrieben', async (_label, iconUrl) => {
const prisma = makeFakePrisma([], widgets);
const service = new FavoritesService(prisma as any, makeIconDiscovery() as any);
await expect(
service.create('t1', 'user-a1', {
widgetId: 'widget-a1',
title: 'X',
url: 'https://x.invalid',
iconUrl,
} as any),
).rejects.toThrow(BadRequestException);
expect(prisma.__favorites.size).toBe(0);
});
const baseRow: FakeFavoriteRow = {
id: 'f1',
userId: 'user-a1',
tenantId: 't1',
widgetId: 'widget-a1',
title: 'Alt',
url: 'https://alt.invalid',
iconUrl: 'https://alt.invalid/icon.png',
position: 0,
uploadedIconMime: null,
iconVersion: 0,
};
it('update mit neuer iconUrl, die der Server nicht abrufen kann: gespeichert, iconVersion +1, KEIN Abruf', async () => {
const prisma = makeFakePrisma([baseRow]);
const iconDiscovery = makeIconDiscovery({ fetchIconBytes: failingFetch() });
const service = new FavoritesService(prisma as any, iconDiscovery as any);
const updated = await service.update('t1', 'f1', 'user-a1', {
iconUrl: 'https://neu.invalid/icon.png',
} as any);
expect(updated.iconUrl).toBe('https://neu.invalid/icon.png');
expect(updated.iconVersion).toBe(1);
expect(iconDiscovery.fetchIconBytes).not.toHaveBeenCalled();
});
it('update mit ungueltiger neuer iconUrl -> BadRequestException, Zeile unveraendert', async () => {
const prisma = makeFakePrisma([baseRow]);
const service = new FavoritesService(prisma as any, makeIconDiscovery() as any);
await expect(
service.update('t1', 'f1', 'user-a1', { iconUrl: 'file:///etc/passwd' } as any),
).rejects.toThrow(BadRequestException);
expect(prisma.__favorites.get('f1').iconUrl).toBe(baseRow.iconUrl);
});
it('update mit UNVERAENDERTER iconUrl: keine Pruefung, keine Erhoehung', async () => {
const prisma = makeFakePrisma([baseRow]);
const iconDiscovery = makeIconDiscovery();
const service = new FavoritesService(prisma as any, iconDiscovery as any);
const updated = await service.update('t1', 'f1', 'user-a1', { iconUrl: baseRow.iconUrl } as any);
expect(iconDiscovery.fetchIconBytes).not.toHaveBeenCalled();
expect(updated.iconVersion).toBe(0);
});
it('update nur Titel: keine Erhoehung', async () => {
const prisma = makeFakePrisma([baseRow]);
const service = new FavoritesService(prisma as any, makeIconDiscovery() as any);
const updated = await service.update('t1', 'f1', 'user-a1', { title: 'Neu' } as any);
expect(updated.iconVersion).toBe(0);
});
});
});