const test = require("node:test"); const assert = require("node:assert/strict"); const { loadConfig } = require("../src/config"); const { createRuntime } = require("../src/runtime"); const silentLogger = { info() {}, warn() {}, error() {}, }; function testConfig() { return loadConfig({ NODE_ENV: "test", HOST: "127.0.0.1", PORT: "0", DATABASE_PATH: ":memory:", APP_TIMEZONE: "America/Chicago", SHUTDOWN_TIMEOUT_MS: "1000", S3_ENDPOINT: "http://seaweedfs:8333", S3_BUCKET: "announcements-test", }); } function testStorage() { return { async start() {}, stop() {}, health() { return { status: "healthy", checkedAt: "2032-04-05T12:00:00.000Z", lastSuccessAt: "2032-04-05T12:00:00.000Z", }; }, }; } async function jsonRequest(baseUrl, route, { body, ...init } = {}) { const response = await fetch(`${baseUrl}${route}`, { ...init, ...(body === undefined ? {} : { headers: { "content-type": "application/json", ...init.headers }, body: JSON.stringify(body), }), }); return { response, body: await response.json() }; } function splitEnginePackets(payload) { return payload.split("\x1e").filter(Boolean); } function socketEvents(payload) { return splitEnginePackets(payload) .filter((packet) => packet.startsWith("42")) .map((packet) => JSON.parse(packet.slice(2))); } async function connectPollingSocket(baseUrl) { let requestNumber = 0; const endpoint = (sid) => { const parameters = new URLSearchParams({ EIO: "4", transport: "polling", t: String(requestNumber++), }); if (sid) parameters.set("sid", sid); return `${baseUrl}/socket.io/?${parameters}`; }; const openingResponse = await fetch(endpoint()); assert.equal(openingResponse.status, 200); const openingPackets = splitEnginePackets(await openingResponse.text()); const openPacket = openingPackets.find((packet) => packet.startsWith("0")); assert.ok(openPacket, "Engine.IO must return an open packet"); const { sid } = JSON.parse(openPacket.slice(1)); const connectResponse = await fetch(endpoint(sid), { method: "POST", headers: { "content-type": "text/plain;charset=UTF-8" }, body: "40", }); assert.equal(connectResponse.status, 200); await connectResponse.text(); const connectedResponse = await fetch(endpoint(sid)); assert.equal(connectedResponse.status, 200); const connectedPayload = await connectedResponse.text(); assert.ok( splitEnginePackets(connectedPayload).some((packet) => packet.startsWith("40"), ), "Socket.IO must acknowledge the namespace connection", ); return { initialEvents: socketEvents(connectedPayload), async poll() { const response = await fetch(endpoint(sid), { signal: AbortSignal.timeout(3_000), }); assert.equal(response.status, 200); return response.text(); }, async close() { const response = await fetch(endpoint(sid), { method: "POST", headers: { "content-type": "text/plain;charset=UTF-8" }, body: "41", }); await response.text(); }, }; } test("Socket.IO sends an authoritative playback snapshot on connect and after mutations", async (t) => { const runtime = createRuntime({ config: testConfig(), logger: silentLogger, storage: testStorage(), }); await runtime.start(); t.after(() => runtime.stop("test cleanup")); const baseUrl = `http://127.0.0.1:${runtime.address.port}`; const socket = await connectPollingSocket(baseUrl); t.after(() => socket.close().catch(() => {})); let initial = socket.initialEvents.find( ([eventName]) => eventName === "playback:state", ); if (!initial) { initial = socketEvents(await socket.poll()).find( ([eventName]) => eventName === "playback:state", ); } assert.ok(initial, "a full playback snapshot must be sent on connection"); assert.equal(initial[1].protocolVersion, 1); assert.equal(initial[1].outputId, "global"); const initialRevision = initial[1].playbackRevision; const read = await jsonRequest(baseUrl, "/api/playback"); assert.equal(read.response.status, 200); assert.equal(read.body.playbackRevision, initialRevision); const reread = await jsonRequest(baseUrl, "/api/playback"); assert.equal(reread.body.playbackRevision, initialRevision); const changed = await jsonRequest(baseUrl, "/api/playback/mode", { method: "PUT", body: { mode: "off" }, }); assert.equal(changed.response.status, 200); assert.equal(changed.body.mode, "off"); assert.equal(changed.body.playbackRevision, initialRevision + 1); const events = socketEvents(await socket.poll()); const playbackEvents = events.filter( ([eventName]) => eventName === "playback:state", ); assert.equal(playbackEvents.length, 1); assert.deepEqual(playbackEvents[0][1], changed.body); const unchanged = await jsonRequest(baseUrl, "/api/playback/mode", { method: "PUT", body: { mode: "off" }, }); assert.equal(unchanged.body.playbackRevision, changed.body.playbackRevision); assert.equal( runtime.database .prepare("SELECT revision FROM playback_state WHERE output_id = 'global'") .get().revision, changed.body.playbackRevision, ); }); test("Socket.IO publishes playlists:changed only after successful semantic mutations", async (t) => { const runtime = createRuntime({ config: testConfig(), logger: silentLogger, storage: testStorage(), }); await runtime.start(); t.after(() => runtime.stop("test cleanup")); const baseUrl = `http://127.0.0.1:${runtime.address.port}`; const socket = await connectPollingSocket(baseUrl); t.after(() => socket.close().catch(() => {})); await jsonRequest(baseUrl, "/api/playlists"); const invalid = await jsonRequest(baseUrl, "/api/playlists", { method: "POST", body: {}, }); assert.equal(invalid.response.status, 400); await jsonRequest(baseUrl, "/api/settings", { method: "PATCH", body: { defaultDurationMs: 7_000 }, }); const created = await jsonRequest(baseUrl, "/api/playlists", { method: "POST", body: { name: "Sunday" }, }); assert.equal(created.response.status, 201); let events = socketEvents(await socket.poll()); let playlistEvents = events.filter( ([eventName]) => eventName === "playlists:changed", ); assert.equal(playlistEvents.length, 1); assert.equal(playlistEvents[0][1].reason, "created"); assert.equal(playlistEvents[0][1].playlistId, created.body.id); const duplicate = await jsonRequest(baseUrl, "/api/playlists", { method: "POST", body: { name: "SUNDAY" }, }); assert.equal(duplicate.response.status, 409); await jsonRequest(baseUrl, `/api/playlists/${created.body.id}`, { method: "PATCH", body: { name: "Sunday" }, }); await jsonRequest(baseUrl, "/api/settings", { method: "PATCH", body: { defaultDurationMs: 8_000 }, }); events = socketEvents(await socket.poll()); playlistEvents = events.filter( ([eventName]) => eventName === "playlists:changed", ); assert.deepEqual(playlistEvents, [ [ "playlists:changed", { reason: "settings_updated", playlistIds: null, settingsRevision: 2, }, ], ]); const second = await jsonRequest(baseUrl, "/api/playlists", { method: "POST", body: { name: "Midweek" }, }); assert.equal(second.response.status, 201); events = socketEvents(await socket.poll()); playlistEvents = events.filter( ([eventName]) => eventName === "playlists:changed", ); assert.equal(playlistEvents.length, 1); assert.equal(playlistEvents[0][1].reason, "created"); const activated = await jsonRequest( baseUrl, `/api/playlists/${created.body.id}/activate`, { method: "POST" }, ); assert.equal(activated.response.status, 200); assert.equal(activated.body.activation, "active"); events = socketEvents(await socket.poll()); playlistEvents = events.filter( ([eventName]) => eventName === "playlists:changed", ); assert.equal(playlistEvents.length, 1); assert.equal(playlistEvents[0][1].reason, "activated"); assert.equal(playlistEvents[0][1].playlistId, created.body.id); assert.equal( playlistEvents[0][1].playbackRevision, activated.body.playback.revision, ); assert.equal( events.filter(([eventName]) => eventName === "playback:state").length, 1, ); const activeDelete = await jsonRequest( baseUrl, `/api/playlists/${created.body.id}`, { method: "DELETE" }, ); assert.equal(activeDelete.response.status, 409); assert.equal(activeDelete.body.error.code, "PLAYLIST_ACTIVE"); }); test("activation invalidations come from authoritative pending-state transitions", async (t) => { const runtime = createRuntime({ config: testConfig(), logger: silentLogger, storage: testStorage(), }); await runtime.start(); t.after(() => runtime.stop("test cleanup")); const timestamp = Date.now(); const insertAsset = runtime.database.prepare(` INSERT INTO assets ( id, source_sha256, display_name, original_filename, status, display_sha256, display_content_type, display_byte_size, display_width, display_height, created_at, updated_at ) VALUES (?, ?, ?, ?, 'ready', ?, 'image/webp', 1, 1920, 1080, ?, ?) `); const insertPlaylist = runtime.database.prepare(` INSERT INTO playlists (id, name, created_at, updated_at) VALUES (?, ?, ?, ?) `); const insertEntry = runtime.database.prepare(` INSERT INTO playlist_entries ( id, playlist_id, asset_id, position, created_at, updated_at ) VALUES (?, ?, ?, 0, ?, ?) `); insertAsset.run( "asset-old", "a".repeat(64), "Old asset", "old.png", "c".repeat(64), timestamp, timestamp, ); insertAsset.run( "asset-new", "b".repeat(64), "New asset", "new.png", "d".repeat(64), timestamp, timestamp, ); insertPlaylist.run("playlist-old", "Old", timestamp, timestamp); insertPlaylist.run("playlist-new", "New", timestamp, timestamp); insertEntry.run( "entry-old", "playlist-old", "asset-old", timestamp, timestamp, ); insertEntry.run( "entry-new", "playlist-new", "asset-new", timestamp, timestamp, ); const baseUrl = `http://127.0.0.1:${runtime.address.port}`; const socket = await connectPollingSocket(baseUrl); t.after(() => socket.close().catch(() => {})); const activateOld = await jsonRequest( baseUrl, "/api/playlists/playlist-old/activate", { method: "POST" }, ); assert.equal(activateOld.body.activation, "active"); await socket.poll(); const requestNew = await jsonRequest( baseUrl, "/api/playlists/playlist-new/activate", { method: "POST" }, ); assert.equal(requestNew.body.activation, "pending"); let invalidations = socketEvents(await socket.poll()).filter( ([eventName]) => eventName === "playlists:changed", ); assert.equal(invalidations.length, 1); assert.equal(invalidations[0][1].reason, "activation_requested"); assert.equal(invalidations[0][1].pendingPlaylistId, "playlist-new"); const cancel = await jsonRequest( baseUrl, "/api/playlists/playlist-old/activate", { method: "POST" }, ); assert.equal(cancel.body.activation, "already_active"); invalidations = socketEvents(await socket.poll()).filter( ([eventName]) => eventName === "playlists:changed", ); assert.equal(invalidations.length, 1); assert.equal(invalidations[0][1].reason, "activation_canceled"); assert.equal(invalidations[0][1].pendingPlaylistId, null); });