82472ee665
Beim Rundgang aufgefallen: nach dem Umzug zeigt eine Zeile auf eine Datei, die es auf diesem Server nicht gibt — lokal, weil der Umzug am Host lief und der Container ein eigenes Volume hat. Derselbe Zustand entsteht im Betrieb, wenn jemand einen `pg_dump` von vor dem Umzug zurueckspielt: die Bytes stecken noch in der Spalte `data`, das getrennt gesicherte Volume `user-files` ist aber leer. `getBytes` schreibt die Datei in diesem Fall aus `data` neu und liefert sie aus, statt 404 zu melden. Fehlt beides, bleibt es bei 404. Nach dem Entfernen der Spalte (eigenes Todo) faellt der Zweig ersatzlos weg. api-Tests 1186 -> 1188, type-check und lint unveraendert gruen. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
573 lines
26 KiB
TypeScript
573 lines
26 KiB
TypeScript
import * as fs from 'node:fs';
|
|
import * as os from 'node:os';
|
|
import * as path from 'node:path';
|
|
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
/**
|
|
* Bindung an forTenant()/forSystem() — dasselbe Muster wie
|
|
* dashboard.service.spec.ts (260910-krx): der gebundene Klient ist ein
|
|
* ZWEITES, von `prisma` unterscheidbares Objekt ueber DEMSELBEN Speicher,
|
|
* das protokolliert, welche Aufrufe ueber ihn liefen. Ein vergessener
|
|
* Bindungsaufruf faellt damit auf (`prisma.dashboardImage` waere dann ohne
|
|
* Protokoll-Eintrag). Seit quick-260922-hk4 gibt es einen zweiten
|
|
* Klienten-Typ: der Systemkontext des Bootstrap-Umzugs (`forSystem()`,
|
|
* liest ueber ALLE Mandanten, Muster dkv.service.ts) — das Protokoll
|
|
* unterscheidet beide ueber `via`.
|
|
*/
|
|
vi.mock('../prisma/prisma-tenant.extension', () => ({
|
|
forTenant: vi.fn((prisma: FakePrisma, tenantId: string, userId?: string) =>
|
|
prisma.__makeBoundClient(tenantId, userId),
|
|
),
|
|
forSystem: vi.fn((prisma: FakePrisma) => prisma.__makeSystemClient()),
|
|
}));
|
|
|
|
import { BadRequestException, InternalServerErrorException, NotFoundException } from '@nestjs/common';
|
|
import type { AuthUser, UploadedFileLike } from '../auth/types/auth-user';
|
|
import { forSystem, forTenant } from '../prisma/prisma-tenant.extension';
|
|
import { DashboardImagesService } from './dashboard-images.service';
|
|
|
|
/**
|
|
* dashboard-images.service.spec — quick-260921-pi9 (Bilderrahmen),
|
|
* erweitert in quick-260922-hk4 (Bilder auf der Festplatte statt in der
|
|
* Datenbank).
|
|
*
|
|
* Faelle an der Grenze Dienst -> Datenbank: Liste ohne `data`, Upload ohne
|
|
* Datei, Magic Bytes schlagen den behaupteten MIME-Typ in BEIDE Richtungen
|
|
* (T-PI9-01), Zaehler 30 (T-PI9-03), fremder Benutzer UND fremder Mandant
|
|
* -> 404 (T-PI9-04, nie 403), eigenes Bild liefert Bytes, Loeschen
|
|
* eigen/fremd, und der Nachweis, dass jede Methode
|
|
* `forTenant(prisma, tenantId, userId)` mit dem Benutzer als drittem
|
|
* Argument aufruft.
|
|
*
|
|
* Dazu die Grenze Dienst -> Dateibereich (hk4, Tests 13-22): KEIN
|
|
* `fs`-Mock, sondern ein echtes Verzeichnis unter `os.tmpdir()` (Muster
|
|
* desktop.service.spec.ts) ueber den Testschalter
|
|
* `DASHBOARD_IMAGES_DIR` — der Dienst schreibt und liest wirklich.
|
|
* Geprueft werden Ablageort und Dateiname (IMMER die UUID der Zeile plus
|
|
* die Endung aus dem ERKANNTEN Typ, NIE `originalName`, T-HK4-01), das
|
|
* Zuruecknehmen der Zeile bei fehlgeschlagenem Schreiben (T-HK4-04), 404
|
|
* bei fehlender Datei, das Mitloeschen der Datei und der automatische
|
|
* Umzug beim Start (T-HK4-03).
|
|
*/
|
|
|
|
interface ImageRow {
|
|
id: string;
|
|
userId: string;
|
|
tenantId: string;
|
|
originalName: string;
|
|
mimeType: string;
|
|
size: number;
|
|
data: Uint8Array | null;
|
|
storagePath: string | null;
|
|
createdAt: Date;
|
|
}
|
|
|
|
interface BoundCall {
|
|
via: 'tenant' | 'system';
|
|
tenantId: string;
|
|
userId: string | undefined;
|
|
model: string;
|
|
method: string;
|
|
}
|
|
|
|
type ModelMethods = Record<string, (...args: unknown[]) => Promise<unknown>>;
|
|
|
|
interface FakePrisma {
|
|
dashboardImage: ModelMethods;
|
|
__rows: ImageRow[];
|
|
__boundCallLog: BoundCall[];
|
|
__makeBoundClient(tenantId: string, userId?: string): { dashboardImage: ModelMethods };
|
|
__makeSystemClient(): { dashboardImage: ModelMethods };
|
|
}
|
|
|
|
const PNG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 13]);
|
|
const JPEG = Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0, 0x10, 0x4a, 0x46]);
|
|
const TEXT = Buffer.from('nur Text, kein Bild');
|
|
|
|
/** Ablageort der Testdateien — echtes Verzeichnis, kein fs-Mock. */
|
|
let imagesDir: string;
|
|
const ORIGINAL_DIR_ENV = process.env.DASHBOARD_IMAGES_DIR;
|
|
|
|
beforeAll(() => {
|
|
imagesDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tessera-dashboard-images-'));
|
|
process.env.DASHBOARD_IMAGES_DIR = imagesDir;
|
|
});
|
|
|
|
afterAll(() => {
|
|
fs.rmSync(imagesDir, { recursive: true, force: true });
|
|
if (ORIGINAL_DIR_ENV === undefined) {
|
|
delete process.env.DASHBOARD_IMAGES_DIR;
|
|
} else {
|
|
process.env.DASHBOARD_IMAGES_DIR = ORIGINAL_DIR_ENV;
|
|
}
|
|
});
|
|
|
|
function makeRow(overrides: Partial<ImageRow> = {}): ImageRow {
|
|
return {
|
|
id: overrides.id ?? 'img-1',
|
|
userId: overrides.userId ?? 'user-1',
|
|
tenantId: overrides.tenantId ?? 'tenant-1',
|
|
originalName: overrides.originalName ?? 'foto.png',
|
|
mimeType: overrides.mimeType ?? 'image/png',
|
|
size: overrides.size ?? PNG.length,
|
|
data: overrides.data === undefined ? null : overrides.data,
|
|
storagePath: overrides.storagePath === undefined ? null : overrides.storagePath,
|
|
createdAt: overrides.createdAt ?? new Date('2026-01-01'),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Legt eine Zeile MIT passender Datei auf der Platte an — der Normalfall
|
|
* nach dem Upload (die Tests 8-12 pruefen Besitz und Bindung, nicht die
|
|
* Ablage).
|
|
*/
|
|
function makeStoredRow(overrides: Partial<ImageRow> = {}, bytes: Buffer = PNG): ImageRow {
|
|
const row = makeRow(overrides);
|
|
const relative = `user-files/dashboard-images/${row.userId}/${row.id}.png`;
|
|
const absolute = path.join(imagesDir, row.userId, `${row.id}.png`);
|
|
fs.mkdirSync(path.dirname(absolute), { recursive: true });
|
|
fs.writeFileSync(absolute, bytes);
|
|
return { ...row, storagePath: overrides.storagePath === undefined ? relative : overrides.storagePath };
|
|
}
|
|
|
|
function storedFile(userId: string, id: string, ext = 'png'): string {
|
|
return path.join(imagesDir, userId, `${id}.${ext}`);
|
|
}
|
|
|
|
function pick(row: ImageRow, select: Record<string, boolean> | undefined) {
|
|
if (!select) return row;
|
|
const out: Record<string, unknown> = {};
|
|
for (const key of Object.keys(select)) {
|
|
if (select[key]) out[key] = row[key as keyof ImageRow];
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function makeFakePrisma(rows: ImageRow[] = []): FakePrisma {
|
|
const boundCallLog: BoundCall[] = [];
|
|
const dashboardImage: ModelMethods = {
|
|
findMany: vi.fn(async (raw: unknown) => {
|
|
const args = raw as {
|
|
where: { tenantId?: string; userId?: string; storagePath?: string | null };
|
|
select?: Record<string, boolean>;
|
|
};
|
|
const where = args.where ?? {};
|
|
return rows
|
|
.filter((r) => {
|
|
if (where.tenantId !== undefined && r.tenantId !== where.tenantId) return false;
|
|
if (where.userId !== undefined && r.userId !== where.userId) return false;
|
|
if ('storagePath' in where && where.storagePath === null && r.storagePath !== null) {
|
|
return false;
|
|
}
|
|
return true;
|
|
})
|
|
.slice()
|
|
.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime())
|
|
.map((r) => pick(r, args.select));
|
|
}),
|
|
count: vi.fn(async (raw: unknown) => {
|
|
const args = raw as { where: { tenantId: string; userId: string } };
|
|
return rows.filter((r) => r.tenantId === args.where.tenantId && r.userId === args.where.userId).length;
|
|
}),
|
|
create: vi.fn(async (raw: unknown) => {
|
|
const args = raw as { data: Partial<ImageRow>; select?: Record<string, boolean> };
|
|
const created = makeRow({ id: `new-${rows.length + 1}`, ...args.data, createdAt: new Date('2026-02-02') });
|
|
rows.push(created);
|
|
return pick(created, args.select);
|
|
}),
|
|
update: vi.fn(async (raw: unknown) => {
|
|
const args = raw as { where: { id: string }; data: Partial<ImageRow> };
|
|
const row = rows.find((r) => r.id === args.where.id);
|
|
if (!row) throw new Error(`update: Zeile '${args.where.id}' gibt es nicht`);
|
|
Object.assign(row, args.data);
|
|
return row;
|
|
}),
|
|
findUnique: vi.fn(async (raw: unknown) => {
|
|
const args = raw as { where: { id: string } };
|
|
return rows.find((r) => r.id === args.where.id) ?? null;
|
|
}),
|
|
delete: vi.fn(async (raw: unknown) => {
|
|
const args = raw as { where: { id: string } };
|
|
const idx = rows.findIndex((r) => r.id === args.where.id);
|
|
if (idx === -1) return null;
|
|
const [removed] = rows.splice(idx, 1);
|
|
return removed;
|
|
}),
|
|
};
|
|
|
|
function wrap(via: 'tenant' | 'system', tenantId: string, userId?: string) {
|
|
const wrapped: ModelMethods = {};
|
|
for (const method of Object.keys(dashboardImage)) {
|
|
wrapped[method] = async (...args: unknown[]) => {
|
|
boundCallLog.push({ via, tenantId, userId, model: 'dashboardImage', method });
|
|
return dashboardImage[method](...args);
|
|
};
|
|
}
|
|
return { dashboardImage: wrapped };
|
|
}
|
|
|
|
const fake: FakePrisma = {
|
|
dashboardImage,
|
|
__rows: rows,
|
|
__boundCallLog: boundCallLog,
|
|
__makeBoundClient(tenantId: string, userId?: string) {
|
|
return wrap('tenant', tenantId, userId);
|
|
},
|
|
__makeSystemClient() {
|
|
return wrap('system', '', undefined);
|
|
},
|
|
};
|
|
return fake;
|
|
}
|
|
|
|
function makeService(prisma: FakePrisma) {
|
|
// Der Dienst verlangt einen PrismaService; die Attrappe deckt genau das
|
|
// Modell ab, das der Dienst anfasst (nur ueber den gebundenen Klienten).
|
|
// `as never` statt einer Doppelzusicherung ueber unknown (Muster
|
|
// tender-mail.service.spec.ts; der Zusicherungs-Zaehler bleibt bei 27).
|
|
return new DashboardImagesService(prisma as never);
|
|
}
|
|
|
|
const user: AuthUser = {
|
|
id: 'user-1',
|
|
username: 'anna',
|
|
role: 'USER',
|
|
tenantId: 'tenant-1',
|
|
mustChangePassword: false,
|
|
};
|
|
|
|
function file(buffer: Buffer, mimetype: string, originalname = 'foto.png'): UploadedFileLike {
|
|
return { buffer, mimetype, originalname, size: buffer.length };
|
|
}
|
|
|
|
beforeEach(() => {
|
|
vi.mocked(forTenant).mockClear();
|
|
vi.mocked(forSystem).mockClear();
|
|
});
|
|
|
|
describe('DashboardImagesService (quick-260921-pi9)', () => {
|
|
it('Test 1: list liefert nur eigene Zeilen, nur Metadaten (nie data), aelteste zuerst', async () => {
|
|
const prisma = makeFakePrisma([
|
|
makeRow({ id: 'b', createdAt: new Date('2026-03-01') }),
|
|
makeRow({ id: 'a', createdAt: new Date('2026-01-01') }),
|
|
makeRow({ id: 'fremd', userId: 'user-2' }),
|
|
]);
|
|
const result = await makeService(prisma).list('user-1', 'tenant-1');
|
|
expect(result.map((r) => r.id)).toEqual(['a', 'b']);
|
|
for (const r of result) {
|
|
expect(Object.keys(r).sort()).toEqual(['createdAt', 'id', 'mimeType', 'originalName', 'size']);
|
|
}
|
|
const call = vi.mocked(prisma.dashboardImage.findMany).mock.calls[0][0] as { select: Record<string, boolean> };
|
|
expect(call.select.data).toBeUndefined();
|
|
expect(call.select.storagePath).toBeUndefined();
|
|
});
|
|
|
|
it('Test 2: upload ohne Datei -> BadRequestException mit deutscher Meldung', async () => {
|
|
const prisma = makeFakePrisma();
|
|
await expect(makeService(prisma).upload(user, undefined)).rejects.toThrow(
|
|
new BadRequestException('Bitte wählen Sie eine Bilddatei aus.'),
|
|
);
|
|
expect(prisma.dashboardImage.create).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('Test 3: PNG-Bytes mit behauptetem text/plain gelingen und speichern image/png (Magic Bytes, nicht mimetype)', async () => {
|
|
const prisma = makeFakePrisma();
|
|
const result = await makeService(prisma).upload(user, file(PNG, 'text/plain', 'irgendwas.txt'));
|
|
expect(result.mimeType).toBe('image/png');
|
|
expect(result.originalName).toBe('irgendwas.txt');
|
|
expect(result.size).toBe(PNG.length);
|
|
expect(Object.keys(result).sort()).toEqual(['createdAt', 'id', 'mimeType', 'originalName', 'size']);
|
|
expect(prisma.__rows[0].userId).toBe('user-1');
|
|
expect(prisma.__rows[0].tenantId).toBe('tenant-1');
|
|
});
|
|
|
|
it('Test 4: Textdatei mit behauptetem image/png scheitert mit deutscher Meldung, nichts wird angelegt', async () => {
|
|
const prisma = makeFakePrisma();
|
|
await expect(makeService(prisma).upload(user, file(TEXT, 'image/png', 'bild.png'))).rejects.toThrow(
|
|
new BadRequestException('Nur Bilder im Format PNG, JPEG, GIF oder WebP sind erlaubt.'),
|
|
);
|
|
expect(prisma.dashboardImage.create).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('Test 5: Zaehler — 30 eigene Bilder blocken das 31., 29 lassen das 30. durch', async () => {
|
|
const thirty = Array.from({ length: 30 }, (_, i) => makeRow({ id: `i${i}` }));
|
|
const full = makeFakePrisma(thirty);
|
|
await expect(makeService(full).upload(user, file(PNG, 'image/png'))).rejects.toThrow(
|
|
new BadRequestException(
|
|
'Sie haben die Höchstzahl von 30 Bildern erreicht. Bitte löschen Sie zuerst ein Bild.',
|
|
),
|
|
);
|
|
expect(full.dashboardImage.create).not.toHaveBeenCalled();
|
|
|
|
const almost = makeFakePrisma(thirty.slice(0, 29));
|
|
await expect(makeService(almost).upload(user, file(PNG, 'image/png'))).resolves.toMatchObject({
|
|
mimeType: 'image/png',
|
|
});
|
|
});
|
|
|
|
it('Test 6: Zaehler zaehlt nur den eigenen Benutzer im eigenen Mandanten (fremde Zeilen zaehlen nicht)', async () => {
|
|
const foreign = Array.from({ length: 30 }, (_, i) => makeRow({ id: `f${i}`, userId: 'user-2' }));
|
|
const prisma = makeFakePrisma(foreign);
|
|
await expect(makeService(prisma).upload(user, file(PNG, 'image/png'))).resolves.toMatchObject({
|
|
mimeType: 'image/png',
|
|
});
|
|
const countArgs = vi.mocked(prisma.dashboardImage.count).mock.calls[0][0] as { where: unknown };
|
|
expect(countArgs.where).toEqual({ tenantId: 'tenant-1', userId: 'user-1' });
|
|
});
|
|
|
|
it('Test 7: originalName wird auf 255 Zeichen gekuerzt', async () => {
|
|
const prisma = makeFakePrisma();
|
|
const result = await makeService(prisma).upload(user, file(PNG, 'image/png', 'x'.repeat(400)));
|
|
expect(result.originalName).toHaveLength(255);
|
|
});
|
|
|
|
it('Test 8: getBytes — fremder Benutzer (gleicher Mandant) -> NotFoundException, nie Forbidden', async () => {
|
|
const prisma = makeFakePrisma([makeStoredRow({ id: 'img-1', userId: 'user-2' })]);
|
|
await expect(makeService(prisma).getBytes('img-1', 'user-1', 'tenant-1')).rejects.toThrow(NotFoundException);
|
|
});
|
|
|
|
it('Test 9: getBytes — fremder Mandant (gleicher Benutzer) -> NotFoundException; unbekannte Kennung ebenso', async () => {
|
|
const prisma = makeFakePrisma([makeStoredRow({ id: 'img-1', tenantId: 'tenant-2' })]);
|
|
const service = makeService(prisma);
|
|
await expect(service.getBytes('img-1', 'user-1', 'tenant-1')).rejects.toThrow(NotFoundException);
|
|
await expect(service.getBytes('gibt-es-nicht', 'user-1', 'tenant-1')).rejects.toThrow(NotFoundException);
|
|
});
|
|
|
|
it('Test 10: getBytes — eigenes Bild liefert mimeType und die gespeicherten Bytes', async () => {
|
|
const prisma = makeFakePrisma([makeStoredRow({ id: 'img-1' })]);
|
|
const result = await makeService(prisma).getBytes('img-1', 'user-1', 'tenant-1');
|
|
expect(result.mimeType).toBe('image/png');
|
|
expect(Buffer.from(result.data).equals(PNG)).toBe(true);
|
|
});
|
|
|
|
it('Test 10b: getBytes — Datei fehlt, aber die alte Spalte `data` traegt die Bytes noch: wiederherstellen statt 404', async () => {
|
|
// Fall aus dem Browser-Rundgang 22.09.2026: ein `pg_dump` von vor dem Umzug
|
|
// traegt die Bytes noch, das Volume `user-files` wird getrennt gesichert —
|
|
// wer nur den Abzug zurueckspielt, haette sonst Zeilen ohne Datei.
|
|
const row = makeRow({
|
|
id: 'img-alt',
|
|
data: PNG,
|
|
storagePath: 'user-files/dashboard-images/user-1/img-alt.png',
|
|
});
|
|
const prisma = makeFakePrisma([row]);
|
|
expect(fs.existsSync(storedFile('user-1', 'img-alt'))).toBe(false);
|
|
|
|
const result = await makeService(prisma).getBytes('img-alt', 'user-1', 'tenant-1');
|
|
|
|
expect(Buffer.from(result.data).equals(PNG)).toBe(true);
|
|
expect(fs.existsSync(storedFile('user-1', 'img-alt'))).toBe(true);
|
|
expect(fs.readFileSync(storedFile('user-1', 'img-alt')).equals(PNG)).toBe(true);
|
|
});
|
|
|
|
it('Test 10c: getBytes — Datei fehlt UND `data` ist leer -> 404', async () => {
|
|
const row = makeRow({
|
|
id: 'img-weg',
|
|
data: null,
|
|
storagePath: 'user-files/dashboard-images/user-1/img-weg.png',
|
|
});
|
|
const prisma = makeFakePrisma([row]);
|
|
await expect(makeService(prisma).getBytes('img-weg', 'user-1', 'tenant-1')).rejects.toThrow(
|
|
NotFoundException,
|
|
);
|
|
});
|
|
|
|
it('Test 11: remove — eigenes Bild wird geloescht und { id } geliefert; fremdes (Benutzer ODER Mandant) -> 404 ohne Loeschung', async () => {
|
|
const prisma = makeFakePrisma([
|
|
makeStoredRow({ id: 'eigen' }),
|
|
makeStoredRow({ id: 'fremd-user', userId: 'user-2' }),
|
|
makeStoredRow({ id: 'fremd-tenant', tenantId: 'tenant-2' }),
|
|
]);
|
|
const service = makeService(prisma);
|
|
await expect(service.remove('eigen', 'user-1', 'tenant-1')).resolves.toEqual({ id: 'eigen' });
|
|
expect(prisma.__rows.map((r) => r.id)).toEqual(['fremd-user', 'fremd-tenant']);
|
|
await expect(service.remove('fremd-user', 'user-1', 'tenant-1')).rejects.toThrow(NotFoundException);
|
|
await expect(service.remove('fremd-tenant', 'user-1', 'tenant-1')).rejects.toThrow(NotFoundException);
|
|
expect(prisma.__rows).toHaveLength(2);
|
|
});
|
|
|
|
it('Test 12: jede Methode bindet mit (prisma, tenantId, userId) und laeuft NUR ueber den gebundenen Klienten', async () => {
|
|
const prisma = makeFakePrisma([]);
|
|
const service = makeService(prisma);
|
|
await service.list('user-1', 'tenant-1');
|
|
const created = await service.upload(user, file(PNG, 'image/png'));
|
|
await service.getBytes(created.id, 'user-1', 'tenant-1');
|
|
await service.remove(created.id, 'user-1', 'tenant-1');
|
|
|
|
expect(vi.mocked(forTenant)).toHaveBeenCalledTimes(4);
|
|
for (const call of vi.mocked(forTenant).mock.calls) {
|
|
expect(call[0]).toBe(prisma);
|
|
expect(call[1]).toBe('tenant-1');
|
|
expect(call[2]).toBe('user-1');
|
|
}
|
|
// Jeder Modellaufruf steht im Protokoll des gebundenen Klienten; der
|
|
// Upload schreibt den Ablageort in einem zweiten Schritt nach, weil die
|
|
// UUID der Zeile erst nach `create` feststeht (hk4).
|
|
const methods = prisma.__boundCallLog.map((c) => c.method);
|
|
expect(methods).toEqual(['findMany', 'count', 'create', 'update', 'findUnique', 'findUnique', 'delete']);
|
|
for (const c of prisma.__boundCallLog) {
|
|
expect(c.via).toBe('tenant');
|
|
expect(c.tenantId).toBe('tenant-1');
|
|
expect(c.userId).toBe('user-1');
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('DashboardImagesService — Ablage im Dateibereich (quick-260922-hk4)', () => {
|
|
it('Test 13: upload schreibt die Datei unter <dir>/<userId>/<id>.png und speichert den relativen Pfad in der Zeile', async () => {
|
|
const prisma = makeFakePrisma();
|
|
const result = await makeService(prisma).upload(user, file(PNG, 'image/png'));
|
|
|
|
const onDisk = storedFile('user-1', result.id);
|
|
expect(fs.existsSync(onDisk)).toBe(true);
|
|
expect(fs.readFileSync(onDisk).equals(PNG)).toBe(true);
|
|
expect(prisma.__rows[0].storagePath).toBe(`user-files/dashboard-images/user-1/${result.id}.png`);
|
|
// Die Bytes gehen NICHT mehr in die Zeile (das ist der ganze Zweck).
|
|
expect(prisma.__rows[0].data).toBeNull();
|
|
const createArgs = vi.mocked(prisma.dashboardImage.create).mock.calls[0][0] as { data: Record<string, unknown> };
|
|
expect(createArgs.data.data).toBeUndefined();
|
|
});
|
|
|
|
it('Test 14: der Dateiname ist IMMER die UUID plus die Endung des ERKANNTEN Typs — originalName kommt nie im Pfad vor (T-HK4-01)', async () => {
|
|
const prisma = makeFakePrisma();
|
|
const service = makeService(prisma);
|
|
const boeserName = '../../../etc/passwd.png';
|
|
const result = await service.upload(user, file(JPEG, 'image/png', boeserName));
|
|
|
|
// Erkannt wurde JPEG (Magic Bytes), also .jpg — nicht .png aus dem Namen.
|
|
expect(result.mimeType).toBe('image/jpeg');
|
|
const stored = prisma.__rows[0].storagePath ?? '';
|
|
expect(stored).toBe(`user-files/dashboard-images/user-1/${result.id}.jpg`);
|
|
expect(stored).not.toContain('passwd');
|
|
expect(stored).not.toContain('..');
|
|
expect(fs.existsSync(storedFile('user-1', result.id, 'jpg'))).toBe(true);
|
|
// Der Anzeigename bleibt in der Zeile erhalten, nur eben als Text.
|
|
expect(result.originalName).toBe(boeserName);
|
|
});
|
|
|
|
it('Test 15: scheitert das Schreiben, wird die eben angelegte Zeile wieder geloescht und 500 geworfen (T-HK4-04)', async () => {
|
|
const blocker = path.join(imagesDir, 'blockade');
|
|
fs.writeFileSync(blocker, 'ich bin eine Datei, kein Verzeichnis');
|
|
const vorher = process.env.DASHBOARD_IMAGES_DIR;
|
|
process.env.DASHBOARD_IMAGES_DIR = path.join(blocker, 'unmoeglich');
|
|
try {
|
|
const prisma = makeFakePrisma();
|
|
await expect(makeService(prisma).upload(user, file(PNG, 'image/png'))).rejects.toThrow(
|
|
InternalServerErrorException,
|
|
);
|
|
expect(prisma.dashboardImage.create).toHaveBeenCalledTimes(1);
|
|
expect(prisma.dashboardImage.delete).toHaveBeenCalledTimes(1);
|
|
expect(prisma.__rows).toHaveLength(0);
|
|
} finally {
|
|
process.env.DASHBOARD_IMAGES_DIR = vorher;
|
|
}
|
|
});
|
|
|
|
it('Test 16: getBytes liest den Dateiinhalt (nicht die Zeile) — auch wenn in der Zeile noch alte Bytes stehen', async () => {
|
|
const prisma = makeFakePrisma([makeStoredRow({ id: 'img-1', data: Uint8Array.from(TEXT) }, PNG)]);
|
|
const result = await makeService(prisma).getBytes('img-1', 'user-1', 'tenant-1');
|
|
expect(Buffer.from(result.data).equals(PNG)).toBe(true);
|
|
});
|
|
|
|
it('Test 17: Zeile vorhanden, Datei fehlt -> NotFoundException (die Kachel zeigt „Bild nicht verfügbar")', async () => {
|
|
// Eigene Kennung: das Verzeichnis ist ueber alle Tests dieser Datei
|
|
// dasselbe, eine von Test 8/10 angelegte `img-1.png` waere sonst da.
|
|
const prisma = makeFakePrisma([
|
|
makeRow({ id: 'datei-fehlt', storagePath: 'user-files/dashboard-images/user-1/datei-fehlt.png' }),
|
|
]);
|
|
await expect(makeService(prisma).getBytes('datei-fehlt', 'user-1', 'tenant-1')).rejects.toThrow(
|
|
NotFoundException,
|
|
);
|
|
});
|
|
|
|
it('Test 18: Zeile ohne storagePath (noch nicht umgezogen) -> NotFoundException statt Absturz', async () => {
|
|
const prisma = makeFakePrisma([makeRow({ id: 'img-1', data: Uint8Array.from(PNG) })]);
|
|
await expect(makeService(prisma).getBytes('img-1', 'user-1', 'tenant-1')).rejects.toThrow(NotFoundException);
|
|
});
|
|
|
|
it('Test 19: remove loescht Zeile UND Datei', async () => {
|
|
const prisma = makeFakePrisma([makeStoredRow({ id: 'weg' })]);
|
|
const onDisk = storedFile('user-1', 'weg');
|
|
expect(fs.existsSync(onDisk)).toBe(true);
|
|
|
|
await expect(makeService(prisma).remove('weg', 'user-1', 'tenant-1')).resolves.toEqual({ id: 'weg' });
|
|
expect(prisma.__rows).toHaveLength(0);
|
|
expect(fs.existsSync(onDisk)).toBe(false);
|
|
});
|
|
|
|
it('Test 20: fehlt die Datei beim Loeschen, gelingt das Loeschen trotzdem (eine Dateileiche ist harmloser als eine haengende Loeschung)', async () => {
|
|
const prisma = makeFakePrisma([
|
|
makeRow({ id: 'nur-zeile', storagePath: 'user-files/dashboard-images/user-1/nur-zeile.png' }),
|
|
]);
|
|
await expect(makeService(prisma).remove('nur-zeile', 'user-1', 'tenant-1')).resolves.toEqual({
|
|
id: 'nur-zeile',
|
|
});
|
|
expect(prisma.__rows).toHaveLength(0);
|
|
});
|
|
});
|
|
|
|
describe('DashboardImagesService — Umzug beim Start (quick-260922-hk4, T-HK4-03)', () => {
|
|
it('Test 21: onApplicationBootstrap schreibt die Bytes alter Zeilen auf die Platte und setzt storagePath — systemgebunden lesen, je Zeile mandantengebunden schreiben', async () => {
|
|
const alt = makeRow({ id: 'alt-1', data: Uint8Array.from(PNG) });
|
|
const fremderMandant = makeRow({
|
|
id: 'alt-2',
|
|
userId: 'user-9',
|
|
tenantId: 'tenant-2',
|
|
mimeType: 'image/jpeg',
|
|
data: Uint8Array.from(JPEG),
|
|
});
|
|
const schonUmgezogen = makeStoredRow({ id: 'neu-1' });
|
|
const prisma = makeFakePrisma([alt, fremderMandant, schonUmgezogen]);
|
|
|
|
await makeService(prisma).onApplicationBootstrap();
|
|
|
|
expect(fs.readFileSync(storedFile('user-1', 'alt-1')).equals(PNG)).toBe(true);
|
|
expect(fs.readFileSync(storedFile('user-9', 'alt-2', 'jpg')).equals(JPEG)).toBe(true);
|
|
expect(prisma.__rows[0].storagePath).toBe('user-files/dashboard-images/user-1/alt-1.png');
|
|
expect(prisma.__rows[1].storagePath).toBe('user-files/dashboard-images/user-9/alt-2.jpg');
|
|
|
|
// Gelesen wird EINMAL ueber den Systemkontext, geschrieben je Zeile
|
|
// ueber einen Klienten, der auf Mandant UND Benutzer DIESER Zeile
|
|
// gebunden ist.
|
|
expect(vi.mocked(forSystem)).toHaveBeenCalledTimes(1);
|
|
expect(vi.mocked(forSystem).mock.calls[0][0]).toBe(prisma);
|
|
const leseAufrufe = prisma.__boundCallLog.filter((c) => c.via === 'system');
|
|
expect(leseAufrufe.map((c) => c.method)).toEqual(['findMany']);
|
|
const findManyArgs = vi.mocked(prisma.dashboardImage.findMany).mock.calls[0][0] as {
|
|
where: Record<string, unknown>;
|
|
};
|
|
expect(findManyArgs.where.storagePath).toBeNull();
|
|
|
|
expect(vi.mocked(forTenant)).toHaveBeenCalledTimes(2);
|
|
expect(vi.mocked(forTenant).mock.calls[0].slice(1)).toEqual(['tenant-1', 'user-1']);
|
|
expect(vi.mocked(forTenant).mock.calls[1].slice(1)).toEqual(['tenant-2', 'user-9']);
|
|
const schreibAufrufe = prisma.__boundCallLog.filter((c) => c.via === 'tenant');
|
|
expect(schreibAufrufe.map((c) => c.method)).toEqual(['update', 'update']);
|
|
|
|
// Die bereits umgezogene Zeile wird nicht angefasst.
|
|
expect(prisma.__rows[2].storagePath).toBe('user-files/dashboard-images/user-1/neu-1.png');
|
|
});
|
|
|
|
it('Test 22: ohne offene Zeilen bleibt der Start still — kein Schreibzugriff, keine Bindung je Mandant', async () => {
|
|
const prisma = makeFakePrisma([makeStoredRow({ id: 'neu-2' })]);
|
|
await makeService(prisma).onApplicationBootstrap();
|
|
|
|
expect(vi.mocked(forSystem)).toHaveBeenCalledTimes(1);
|
|
expect(vi.mocked(forTenant)).not.toHaveBeenCalled();
|
|
expect(prisma.dashboardImage.update).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('Test 23: der Umzug ist wiederholbar — ein zweiter Lauf findet nichts mehr und ueberschreibt nichts', async () => {
|
|
const prisma = makeFakePrisma([makeRow({ id: 'alt-3', data: Uint8Array.from(PNG) })]);
|
|
const service = makeService(prisma);
|
|
await service.onApplicationBootstrap();
|
|
const ersterStand = fs.statSync(storedFile('user-1', 'alt-3')).mtimeMs;
|
|
|
|
vi.mocked(forTenant).mockClear();
|
|
await service.onApplicationBootstrap();
|
|
|
|
expect(vi.mocked(forTenant)).not.toHaveBeenCalled();
|
|
expect(fs.statSync(storedFile('user-1', 'alt-3')).mtimeMs).toBe(ersterStand);
|
|
expect(prisma.__rows[0].storagePath).toBe('user-files/dashboard-images/user-1/alt-3.png');
|
|
});
|
|
});
|