Exporte laufen nacheinander. Der Fortschritt steht im Speicher; ein nach einem Neustart verwaister Export wird als unterbrochen gemeldet. Nach Änderungen an Einstellungen oder Dateien gilt ein fertiges Video als veraltet. Der Download trägt den Zeitraum im Dateinamen. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
252 lines
10 KiB
JavaScript
252 lines
10 KiB
JavaScript
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);
|
|
});
|