const test = require("node:test"); const assert = require("node:assert/strict"); const { createApp } = require("../src/app"); const { loadConfig } = require("../src/config"); const { openDatabase } = require("../src/database"); const { runMigrations } = require("../src/database/migrate"); function testConfig() { return loadConfig({ NODE_ENV: "test", HOST: "127.0.0.1", PORT: "0", DATABASE_PATH: ":memory:", APP_TIMEZONE: "America/Chicago", S3_ENDPOINT: "http://seaweedfs:8333", S3_BUCKET: "announcements-test", }); } function createPlaybackStub({ domainError = null } = {}) { const calls = []; let revision = 10; const snapshot = (action = "get") => ({ protocolVersion: 1, outputId: "global", playbackRevision: revision, action, }); const action = (name) => () => { calls.push([name]); revision += 1; return snapshot(name); }; return { calls, domainChanged(change) { calls.push(["domainChanged", change]); if (domainError) throw domainError; return snapshot("domainChanged"); }, getSnapshot() { calls.push(["getSnapshot"]); return snapshot(); }, pause: action("pause"), reconcile(reason) { calls.push(["reconcile", reason]); return snapshot("reconcile"); }, resume: action("resume"), next: action("next"), previous: action("previous"), reload: action("reload"), enterStatic(assetId) { calls.push(["enterStatic", assetId]); revision += 1; return snapshot("static"); }, setMode(mode) { calls.push(["setMode", mode]); revision += 1; return snapshot("mode"); }, activatePlaylist(playlistId) { calls.push(["activatePlaylist", playlistId]); revision += 1; const current = snapshot("activate"); return { activation: "active", playlistId, playback: { revision: current.playbackRevision }, snapshot: current, }; }, }; } async function createHarness(t, { domainError = null } = {}) { const database = openDatabase({ path: ":memory:" }); const migrations = runMigrations(database); const playbackService = createPlaybackStub({ domainError }); const invalidations = []; const app = createApp({ config: testConfig(), database, imageProcessor: {}, lifecycle: { ready: true, stopping: false }, logger: { info() {}, warn() {}, error() {} }, migrations, onPlaylistsChanged: (event) => invalidations.push(event), playbackService, storage: { health() { return { status: "healthy", checkedAt: null, lastSuccessAt: null }; }, }, }); const server = app.listen(0, "127.0.0.1"); await new Promise((resolve, reject) => { server.once("listening", resolve); server.once("error", reject); }); t.after(async () => { await new Promise((resolve) => server.close(resolve)); database.close(); }); return { baseUrl: `http://127.0.0.1:${server.address().port}`, database, invalidations, playbackService, }; } async function requestJson(baseUrl, path, { body, ...init } = {}) { const response = await fetch(`${baseUrl}${path}`, { ...init, ...(body === undefined ? {} : { headers: { "content-type": "application/json", ...init.headers }, body: JSON.stringify(body), }), }); return { response, body: await response.json() }; } test("playback REST routes delegate every command to the one playback service", async (t) => { const harness = await createHarness(t); const read = await requestJson(harness.baseUrl, "/api/playback"); assert.equal(read.response.status, 200); assert.equal(read.body.playbackRevision, 10); const commands = [ ["POST", "/api/playback/pause", undefined, "pause"], ["POST", "/api/playback/resume", undefined, "resume"], ["POST", "/api/playback/next", undefined, "next"], ["POST", "/api/playback/previous", undefined, "previous"], ["POST", "/api/playback/reload", undefined, "reload"], ["POST", "/api/playback/static", { assetId: "asset-static" }, "static"], ["PUT", "/api/playback/mode", { mode: "off" }, "mode"], ]; for (const [method, path, body, action] of commands) { const result = await requestJson(harness.baseUrl, path, { method, body }); assert.equal(result.response.status, 200); assert.equal(result.body.action, action); } assert.deepEqual(harness.playbackService.calls, [ ["getSnapshot"], ["pause"], ["resume"], ["next"], ["previous"], ["reload"], ["enterStatic", "asset-static"], ["setMode", "off"], ]); }); test("playlist activation delegates directly to the playback authority", async (t) => { const harness = await createHarness(t); const result = await requestJson( harness.baseUrl, "/api/playlists/playlist-a/activate", { method: "POST" }, ); assert.equal(result.response.status, 200); assert.equal(result.body.activation, "active"); assert.equal(result.body.playlistId, "playlist-a"); assert.deepEqual(harness.playbackService.calls, [ ["activatePlaylist", "playlist-a"], ]); assert.deepEqual(harness.invalidations, []); }); test("semantic domain edits reconcile playback independently of list invalidation", async (t) => { const harness = await createHarness(t); const timestamp = Date.now(); harness.database .prepare( `INSERT INTO assets ( id, source_sha256, display_name, original_filename, status, created_at, updated_at ) VALUES (?, ?, ?, ?, 'ready', ?, ?)`, ) .run( "unreferenced-asset", "a".repeat(64), "Unreferenced", "unreferenced.png", timestamp, timestamp, ); const settings = await requestJson(harness.baseUrl, "/api/settings", { method: "PATCH", body: { defaultBlurPx: 21 }, }); assert.equal(settings.response.status, 200); const asset = await requestJson( harness.baseUrl, "/api/assets/unreferenced-asset", { method: "PATCH", body: { displayName: "Renamed unreferenced" } }, ); assert.equal(asset.response.status, 200); assert.deepEqual(harness.playbackService.calls, [ ["reconcile", "before_domain_change"], [ "domainChanged", { reason: "settings_updated", playlistIds: null, settingsRevision: 2 }, ], ["reconcile", "before_domain_change"], [ "domainChanged", { reason: "asset_updated", playlistIds: [], assetId: "unreferenced-asset", }, ], ]); assert.deepEqual(harness.invalidations, [ { reason: "settings_updated", playlistIds: null, settingsRevision: 2 }, ]); }); test("a playback reconciliation failure is surfaced after the domain commit", async (t) => { const harness = await createHarness(t, { domainError: new Error("reconciliation failed"), }); const result = await requestJson(harness.baseUrl, "/api/playlists", { method: "POST", body: { name: "Committed before reconciliation" }, }); assert.equal(result.response.status, 500); assert.equal(result.body.error.code, "INTERNAL_ERROR"); assert.equal( harness.database.prepare("SELECT COUNT(*) AS count FROM playlists").get() .count, 1, ); assert.equal(harness.invalidations.length, 0); assert.equal(harness.playbackService.calls[0][0], "domainChanged"); });