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"); const NOW = Date.parse("2032-04-05T12:00:00.000Z"); 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", S3_ENDPOINT: "http://seaweedfs:8333", S3_BUCKET: "announcements-test", }); } function testStorage() { return { health() { return { status: "healthy", checkedAt: new Date(NOW).toISOString(), lastSuccessAt: new Date(NOW).toISOString(), }; }, }; } async function createHarness(t, { onPlaylistsChanged = () => {} } = {}) { const config = testConfig(); const database = openDatabase({ path: ":memory:" }); const migrations = runMigrations(database); const app = createApp({ config, database, imageProcessor: {}, lifecycle: { ready: true, stopping: false }, logger: silentLogger, migrations, now: () => NOW, onPlaylistsChanged, storage: testStorage(), }); 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, }; } function insertAsset(database, { id, name, status = "ready", expirationAt = null }) { const hashCharacter = id.slice(-1).toLowerCase().replace(/[^0-9a-f]/g, "a"); database .prepare( `INSERT INTO assets ( id, source_sha256, display_name, original_filename, status, expiration_at, created_at, updated_at ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, ) .run( id, hashCharacter.repeat(64), name, `${id}.png`, status, expirationAt, NOW, NOW, ); } 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() }; } test("playlist REST routes expose resolved entries, complete ordering, and structured errors", async (t) => { const changes = []; const harness = await createHarness(t, { onPlaylistsChanged: (event) => changes.push(event), }); insertAsset(harness.database, { id: "asset-a", name: "Welcome" }); insertAsset(harness.database, { id: "asset-b", name: "Events" }); insertAsset(harness.database, { id: "asset-f", name: "Failed upload", status: "failed", }); const created = await jsonRequest(harness.baseUrl, "/api/playlists", { method: "POST", body: { name: "Sunday" }, }); assert.equal(created.response.status, 201); assert.match(created.body.id, /^[0-9a-f-]{36}$/); assert.equal(created.body.revision, 1); const playlistId = created.body.id; const listed = await jsonRequest(harness.baseUrl, "/api/playlists"); assert.equal(listed.response.status, 200); assert.deepEqual(listed.body.playlists.map((playlist) => playlist.id), [ playlistId, ]); assert.equal(changes.length, 1, "GET must not publish invalidation events"); const first = await jsonRequest( harness.baseUrl, `/api/playlists/${playlistId}/entries`, { method: "POST", body: { assetId: "asset-a", durationOverrideMs: 9_000, backgroundModeOverride: "solid", backgroundColorOverride: "#123abc", backgroundDarkenedOverride: true, }, }, ); assert.equal(first.response.status, 201); assert.equal(first.body.position, 0); assert.equal(first.body.effectiveDurationMs, 9_000); assert.equal(first.body.backgroundColorOverride, "#123ABC"); assert.deepEqual(first.body.eligibility, { playable: true, reason: null }); const second = await jsonRequest( harness.baseUrl, `/api/playlists/${playlistId}/entries`, { method: "POST", body: { assetId: "asset-b" } }, ); assert.equal(second.response.status, 201); assert.equal(second.body.position, 1); assert.equal(second.body.effectiveDurationMs, 7_000); const ordered = await jsonRequest( harness.baseUrl, `/api/playlists/${playlistId}/entries/order`, { method: "PUT", body: { entryIds: [second.body.id, first.body.id] }, }, ); assert.equal(ordered.response.status, 200); assert.deepEqual( ordered.body.entries.map((entry) => [entry.id, entry.position]), [ [second.body.id, 0], [first.body.id, 1], ], ); const incomplete = await jsonRequest( harness.baseUrl, `/api/playlists/${playlistId}/entries/order`, { method: "PUT", body: { entryIds: [first.body.id] } }, ); assert.equal(incomplete.response.status, 400); assert.equal(incomplete.body.error.code, "PLAYLIST_ORDER_INCOMPLETE"); const unexpectedOrderField = await jsonRequest( harness.baseUrl, `/api/playlists/${playlistId}/entries/order`, { method: "PUT", body: { entryIds: [second.body.id, first.body.id], appendMissing: false, }, }, ); assert.equal(unexpectedOrderField.response.status, 400); assert.equal( unexpectedOrderField.body.error.code, "INVALID_PLAYLIST_ORDER", ); const missingOrderField = await jsonRequest( harness.baseUrl, `/api/playlists/${playlistId}/entries/order`, { method: "PUT", body: {} }, ); assert.equal(missingOrderField.response.status, 400); assert.equal(missingOrderField.body.error.code, "INVALID_PLAYLIST_ORDER"); const revisionAfterOrder = ordered.body.revision; const duplicate = await jsonRequest( harness.baseUrl, `/api/playlists/${playlistId}/entries`, { method: "POST", body: { assetId: "asset-a" } }, ); assert.equal(duplicate.response.status, 409); assert.equal(duplicate.body.error.code, "PLAYLIST_ENTRY_EXISTS"); const failed = await jsonRequest( harness.baseUrl, `/api/playlists/${playlistId}/entries`, { method: "POST", body: { assetId: "asset-f" } }, ); assert.equal(failed.response.status, 409); assert.equal(failed.body.error.code, "ASSET_NOT_READY"); const missing = await jsonRequest( harness.baseUrl, "/api/playlists/not-a-playlist", ); assert.equal(missing.response.status, 404); assert.equal(missing.body.error.code, "PLAYLIST_NOT_FOUND"); const detail = await jsonRequest( harness.baseUrl, `/api/playlists/${playlistId}`, ); assert.equal(detail.body.revision, revisionAfterOrder); assert.equal(detail.body.entries.length, 2); const patchedEntry = await jsonRequest( harness.baseUrl, `/api/playlists/${playlistId}/entries/${first.body.id}`, { method: "PATCH", body: { enabled: false, durationOverrideMs: null }, }, ); assert.equal(patchedEntry.response.status, 200); assert.equal(patchedEntry.body.enabled, false); assert.equal(patchedEntry.body.effectiveDurationMs, 7_000); assert.equal(patchedEntry.body.notPlayableReason, "disabled"); const removedEntry = await jsonRequest( harness.baseUrl, `/api/playlists/${playlistId}/entries/${second.body.id}`, { method: "DELETE" }, ); assert.equal(removedEntry.response.status, 200); assert.deepEqual(removedEntry.body, { id: second.body.id, deleted: true }); const compacted = await jsonRequest( harness.baseUrl, `/api/playlists/${playlistId}`, ); assert.deepEqual( compacted.body.entries.map((entry) => entry.position), [0], ); const temporary = await jsonRequest(harness.baseUrl, "/api/playlists", { method: "POST", body: { name: "Temporary" }, }); const deletedPlaylist = await jsonRequest( harness.baseUrl, `/api/playlists/${temporary.body.id}`, { method: "DELETE" }, ); assert.equal(deletedPlaylist.response.status, 200); assert.deepEqual(deletedPlaylist.body, { id: temporary.body.id, deleted: true, }); }); test("playlist data invalidation fires once for semantic changes and not for reads, failures, or no-ops", async (t) => { const changes = []; const harness = await createHarness(t, { onPlaylistsChanged: (event) => changes.push(event), }); insertAsset(harness.database, { id: "asset-a", name: "Welcome" }); insertAsset(harness.database, { id: "asset-b", name: "Unused" }); const created = await jsonRequest(harness.baseUrl, "/api/playlists", { method: "POST", body: { name: "Sunday" }, }); const playlistId = created.body.id; assert.equal(changes.length, 1); assert.equal(changes[0].reason, "created"); await jsonRequest(harness.baseUrl, `/api/playlists/${playlistId}`); await jsonRequest(harness.baseUrl, "/api/playlists", { method: "POST", body: { name: "sUNDAY" }, }); await jsonRequest(harness.baseUrl, `/api/playlists/${playlistId}`, { method: "PATCH", body: { name: "Sunday" }, }); assert.equal(changes.length, 1); await jsonRequest(harness.baseUrl, `/api/playlists/${playlistId}`, { method: "PATCH", body: { name: "Weekend" }, }); assert.equal(changes.length, 2); assert.equal(changes[1].reason, "renamed"); await jsonRequest(harness.baseUrl, "/api/settings", { method: "PATCH", body: { defaultDurationMs: 7_000 }, }); assert.equal(changes.length, 2); await jsonRequest(harness.baseUrl, "/api/settings", { method: "PATCH", body: { defaultDurationMs: 8_000 }, }); assert.equal(changes.length, 3); assert.deepEqual(changes[2], { reason: "settings_updated", playlistIds: null, settingsRevision: 2, }); await jsonRequest(harness.baseUrl, "/api/assets/asset-b", { method: "PATCH", body: { displayName: "Still unused" }, }); assert.equal(changes.length, 3); await jsonRequest( harness.baseUrl, `/api/playlists/${playlistId}/entries`, { method: "POST", body: { assetId: "asset-a" } }, ); assert.equal(changes.length, 4); await jsonRequest(harness.baseUrl, "/api/assets/asset-a", { method: "PATCH", body: { expirationAt: NOW + 60_000 }, }); assert.deepEqual(changes.at(-1), { reason: "asset_updated", playlistIds: [playlistId], assetId: "asset-a", }); const countAfterAssetUpdate = changes.length; await jsonRequest(harness.baseUrl, "/api/assets/asset-a", { method: "PATCH", body: { expirationAt: NOW + 60_000 }, }); assert.equal(changes.length, countAfterAssetUpdate); }); test("a failing invalidation listener cannot turn a committed mutation into HTTP 500", async (t) => { const harness = await createHarness(t, { onPlaylistsChanged() { throw new Error("listener unavailable"); }, }); const settings = await jsonRequest(harness.baseUrl, "/api/settings", { method: "PATCH", body: { defaultDurationMs: 8_000 }, }); assert.equal(settings.response.status, 200); assert.equal(settings.body.revision, 2); const playlist = await jsonRequest(harness.baseUrl, "/api/playlists", { method: "POST", body: { name: "Sunday" }, }); assert.equal(playlist.response.status, 201); assert.equal( harness.database.prepare("SELECT COUNT(*) AS count FROM playlists").get() .count, 1, ); });