import assert from 'node:assert/strict'; import fs from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; import { after, before, test } from 'node:test'; import { createApp } from '../server/app.js'; import { createProjectStore } from '../server/projects.js'; let server; let baseUrl; let cookie; let dataDir; const fakeMedia = { duration: 42, hasVideo: true, width: 1080, height: 1920, rotation: 0, fps: 30, hasAudio: true }; before(async () => { dataDir = await fs.mkdtemp(path.join(os.tmpdir(), 'fdc-test-')); const probe = async (file) => { const content = await fs.readFile(file, 'utf8'); if (content.startsWith('kaputt')) throw new Error('Keine Videospur gefunden.'); return fakeMedia; }; const store = createProjectStore({ dataDir, probe }); const app = createApp({ config: { pin: '4711' }, secret: 'geheim', store }); server = app.listen(0); await new Promise((resolve) => server.once('listening', resolve)); baseUrl = `http://127.0.0.1:${server.address().port}`; const res = await fetch(`${baseUrl}/api/login`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ pin: '4711' }), }); cookie = res.headers.get('set-cookie').split(';')[0]; }); after(async () => { server.close(); await fs.rm(dataDir, { recursive: true, force: true }); }); function call(method, url, body, headers = {}) { const isJson = body !== undefined && typeof body === 'object' && !(body instanceof Uint8Array); return fetch(`${baseUrl}/api/${url}`, { method, headers: { cookie, ...(isJson ? { 'Content-Type': 'application/json' } : {}), ...headers }, body: isJson ? JSON.stringify(body) : body, }); } const upload = (id, index, offset, text) => call('PUT', `projects/${id}/clips/${index}?offset=${offset}`, new TextEncoder().encode(text), { 'Content-Type': 'application/octet-stream', }); const clip = (file, size) => ({ file, size, timestamp: '2026-09-21T10:15:30', sender: 'Anna', caption: 'Ordnung', }); async function createProject(clips) { const res = await call('POST', 'projects', { range: { from: '2026-09-21', to: '2026-09-25' }, clips }); assert.equal(res.status, 201); return res.json(); } test('Projekt anlegen und abrufen', async () => { const project = await createProject([clip('a.mp4', 10), clip('b.mov', 20)]); assert.equal(project.clips.length, 2); assert.equal(project.clips[0].status, 'pending'); assert.equal(project.clips[0].received, 0); const res = await call('GET', `projects/${project.id}`); assert.equal((await res.json()).clips[1].file, 'b.mov'); const list = await (await call('GET', 'projects')).json(); assert.ok(list.some((entry) => entry.id === project.id && entry.clipCount === 2)); }); test('Ungültige Clips werden abgelehnt', async () => { for (const clips of [[], [clip('notiz.txt', 10)], [clip('a.mp4', 0)], [{ ...clip('a.mp4', 5), timestamp: 'gestern' }]]) { const res = await call('POST', 'projects', { clips }); assert.equal(res.status, 400); } }); test('Upload in Stücken, Wiederaufnahme und Abschluss mit ffprobe', async () => { const project = await createProject([clip('a.mp4', 10)]); let res = await upload(project.id, 0, 0, 'abcd'); assert.deepEqual(await res.json(), { received: 4, status: 'pending' }); // Falscher Offset: der Server nennt den richtigen Stand res = await upload(project.id, 0, 0, 'abcd'); assert.equal(res.status, 409); assert.equal((await res.json()).received, 4); res = await upload(project.id, 0, 4, 'efghij'); assert.deepEqual(await res.json(), { received: 10, status: 'ready' }); const stored = await (await call('GET', `projects/${project.id}`)).json(); assert.equal(stored.clips[0].status, 'ready'); assert.deepEqual(stored.clips[0].media, fakeMedia); const file = await call('GET', `projects/${project.id}/clips/0/file`); assert.equal(await file.text(), 'abcdefghij'); }); test('Mehr Daten als angekündigt werden abgewiesen, der gültige Anfang bleibt', async () => { const project = await createProject([clip('a.mp4', 5)]); await upload(project.id, 0, 0, 'ab'); const res = await upload(project.id, 0, 2, 'cdefgh'); assert.equal(res.status, 413); const stored = await (await call('GET', `projects/${project.id}`)).json(); assert.equal(stored.clips[0].received, 2); }); test('Datei ohne Videospur wird als Fehler markiert', async () => { const project = await createProject([clip('a.mp4', 6)]); const res = await upload(project.id, 0, 0, 'kaputt'); assert.equal((await res.json()).status, 'error'); const stored = await (await call('GET', `projects/${project.id}`)).json(); assert.equal(stored.clips[0].error, 'Keine Videospur gefunden.'); }); test('Unbekannte Projekte und Clips ergeben 404, auch bei manipulierten IDs', async () => { assert.equal((await call('GET', 'projects/00000000-0000-0000-0000-000000000000')).status, 404); assert.equal((await call('GET', 'projects/..%2F..%2Fetc')).status, 404); const project = await createProject([clip('a.mp4', 5)]); assert.equal((await upload(project.id, 7, 0, 'x')).status, 404); assert.equal((await call('GET', `projects/${project.id}/clips/0/file`)).status, 404); }); test('Projekt löschen entfernt die Dateien', async () => { const project = await createProject([clip('a.mp4', 5)]); await upload(project.id, 0, 0, 'ab'); assert.equal((await call('DELETE', `projects/${project.id}`)).status, 204); assert.equal((await call('GET', `projects/${project.id}`)).status, 404); await assert.rejects(fs.stat(path.join(dataDir, 'projects', project.id))); }); test('Einstellungen speichern und prüfen', async () => { const project = await createProject([clip('a.mp4', 5), clip('b.mp4', 5)]); assert.equal(project.settings.muteClips, true); let res = await call('PATCH', `projects/${project.id}/settings`, { music: { enabled: true, volume: 40 } }); const updated = await res.json(); assert.deepEqual(updated.settings.music, { enabled: true, volume: 40 }); res = await call('PATCH', `projects/${project.id}/settings`, { excluded: [5] }); assert.equal(res.status, 400); }); test('Zusatzdateien anmelden, hochladen, ersetzen und löschen', async () => { const project = await createProject([clip('a.mp4', 5)]); const base = `projects/${project.id}/assets`; // Falscher Dateityp für den Platz assert.equal((await call('POST', `${base}/music`, { file: 'intro.txt', size: 4 })).status, 400); // Unbekannter Platz und Voice-over zu einem Clip, den es nicht gibt assert.equal((await call('POST', `${base}/abspann`, { file: 'a.mp3', size: 4 })).status, 404); assert.equal((await call('POST', `${base}/voice-3`, { file: 'a.webm', size: 4 })).status, 404); let res = await call('POST', `${base}/voice-0`, { file: 'aufnahme.webm', size: 4 }); assert.equal(res.status, 201); assert.equal((await res.json()).assets['voice-0'].status, 'pending'); const put = (slot, offset, text) => call('PUT', `${base}/${slot}?offset=${offset}`, new TextEncoder().encode(text), { 'Content-Type': 'application/octet-stream', }); res = await put('voice-0', 0, 'stim'); assert.equal((await res.json()).status, 'ready'); assert.equal(await (await call('GET', `${base}/voice-0/file`)).text(), 'stim'); // Ersetzen verwirft die alte Datei await call('POST', `${base}/voice-0`, { file: 'neu.m4a', size: 3 }); const replaced = await (await call('GET', `projects/${project.id}`)).json(); assert.equal(replaced.assets['voice-0'].file, 'neu.m4a'); assert.equal(replaced.assets['voice-0'].received, 0); const files = await fs.readdir(path.join(dataDir, 'projects', project.id, 'assets')); assert.deepEqual(files, []); res = await call('DELETE', `${base}/voice-0`); assert.deepEqual((await res.json()).assets, {}); });