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'; import { createRenderQueue } from '../server/renderQueue.js'; let server; let baseUrl; let cookie; let dataDir; let releaseRender; // Steuerbare Attrappe: meldet halben Fortschritt und wartet auf releaseRender(). async function fakeRender({ output, onProgress, signal }) { onProgress(0.5); await new Promise((resolve, reject) => { releaseRender = resolve; signal.addEventListener('abort', () => reject(new Error('Abgebrochen.'))); }); await fs.writeFile(output, 'VIDEO'); onProgress(1); } 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 queue = createRenderQueue({ store, render: fakeRender }); const app = createApp({ config: { pin: '4711' }, secret: 'geheim', store, queue }); 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, {}); }); async function waitForRender(id, status) { for (let i = 0; i < 100; i += 1) { const project = await (await call('GET', `projects/${id}`)).json(); if (project.render.status === status) return project; await new Promise((resolve) => setTimeout(resolve, 20)); } throw new Error(`Status ${status} nicht erreicht`); } test('Export ohne fertige Clips wird abgelehnt', async () => { const project = await createProject([clip('a.mp4', 5)]); assert.equal(project.render.status, 'idle'); assert.equal((await call('POST', `projects/${project.id}/render`)).status, 400); assert.equal((await call('GET', `projects/${project.id}/output`)).status, 404); }); test('Export läuft, meldet Fortschritt, liefert die Datei und erkennt Änderungen', async () => { const project = await createProject([clip('a.mp4', 5)]); await upload(project.id, 0, 0, 'abcde'); assert.equal((await call('POST', `projects/${project.id}/render`)).status, 202); const running = await waitForRender(project.id, 'running'); assert.equal(running.render.progress, 0.5); assert.equal((await call('POST', `projects/${project.id}/render`)).status, 409); releaseRender(); const done = await waitForRender(project.id, 'done'); assert.equal(done.render.outdated, false); const download = await call('GET', `projects/${project.id}/output?download=1`); assert.equal(await download.text(), 'VIDEO'); assert.match(download.headers.get('content-disposition'), /FuerDieCuts_2026-09-21_bis_2026-09-25\.mp4/); const changed = await (await call('PATCH', `projects/${project.id}/settings`, { speedUp: false })).json(); assert.equal(changed.render.outdated, true); }); test('Laufender Export lässt sich abbrechen', async () => { const project = await createProject([clip('a.mp4', 5)]); await upload(project.id, 0, 0, 'abcde'); await call('POST', `projects/${project.id}/render`); await waitForRender(project.id, 'running'); assert.equal((await call('DELETE', `projects/${project.id}/render`)).status, 202); const cancelled = await waitForRender(project.id, 'cancelled'); assert.equal(cancelled.render.error, null); assert.equal((await call('DELETE', `projects/${project.id}/render`)).status, 404); });