const test = require("node:test"); const assert = require("node:assert/strict"); const crypto = require("node:crypto"); const { openDatabase } = require("../src/database"); const { runMigrations } = require("../src/database/migrate"); const { createAssetService } = require("../src/assets/service"); const { createPlaybackService } = require("../src/playback"); const { MAX_DURATION_MS, MIN_DURATION_MS, createPlaylistService, } = require("../src/playlists/service"); const BASE_TIME = 2_000_000_000_000; function createHarness() { const database = openDatabase({ path: ":memory:" }); runMigrations(database); let clock = BASE_TIME; let idCounter = 0; let assetCounter = 0; const changes = []; let playbackService; const service = createPlaylistService({ database, now: () => clock, createId: () => `00000000-0000-4000-8000-${String(++idCounter).padStart(12, "0")}`, onBeforeChange: () => { playbackService?.reconcile("before_domain_change"); }, onChanged: (change) => { changes.push(change); playbackService?.domainChanged(change); }, }); playbackService = createPlaybackService({ database, now: () => clock }); playbackService.initialize(); function setNow(value) { clock = value; } function seedAsset(options = {}) { const sequence = ++assetCounter; const id = options.id || `asset-${sequence}`; const name = options.name || id; const status = options.status || "ready"; const expirationAt = options.expirationAt ?? null; const hash = crypto .createHash("sha256") .update(`${sequence}:${id}`) .digest("hex"); database .prepare( `INSERT INTO assets ( id, source_sha256, display_name, original_filename, status, expiration_at, display_sha256, display_width, display_height, thumbnail_sha256, thumbnail_width, thumbnail_height, created_at, updated_at ) VALUES (?, ?, ?, ?, ?, ?, ?, 1920, 1080, ?, 480, 270, ?, ?)`, ) .run( id, hash, name, `${name}.png`, status, expirationAt, hash, hash, clock, clock, ); return id; } function playback() { return database .prepare("SELECT * FROM playback_state WHERE output_id = 'global'") .get(); } return { changes, database, playback, playbackService, seedAsset, service, setNow, }; } function expectError(action, code, statusCode) { assert.throws(action, (error) => { assert.equal(error.code, code); if (statusCode !== undefined) assert.equal(error.statusCode, statusCode); return true; }); } test("playlist CRUD uses UUIDs, case-insensitive names, revisions, and post-commit events", (t) => { const harness = createHarness(); t.after(() => harness.database.close()); const sunday = harness.service.create({ name: " Sunday Service " }); const midweek = harness.service.create({ name: "Midweek" }); assert.match(sunday.id, /^[0-9a-f-]{36}$/); assert.equal(sunday.name, "Sunday Service"); assert.equal(sunday.revision, 1); assert.deepEqual( harness.service.list().map((playlist) => playlist.name), ["Midweek", "Sunday Service"], ); const revisionBeforeRead = harness.service.list()[1].revision; const changesBeforeRead = harness.changes.length; assert.equal(harness.service.get(sunday.id).entries.length, 0); assert.equal(harness.service.list()[1].revision, revisionBeforeRead); assert.equal(harness.changes.length, changesBeforeRead); const renamed = harness.service.update(sunday.id, { name: "Sunday Worship" }); assert.equal(renamed.revision, 2); assert.equal(harness.service.update(sunday.id, { name: "Sunday Worship" }).revision, 2); expectError( () => harness.service.create({ name: "sUnDaY wOrShIp" }), "PLAYLIST_NAME_CONFLICT", 409, ); expectError( () => harness.service.update(midweek.id, { name: "SUNDAY WORSHIP" }), "PLAYLIST_NAME_CONFLICT", 409, ); assert.equal(harness.service.get(midweek.id).revision, 1); assert.deepEqual(harness.service.remove(midweek.id), { id: midweek.id, deleted: true, }); expectError( () => harness.service.get(midweek.id), "PLAYLIST_NOT_FOUND", 404, ); assert.deepEqual( harness.changes.map((change) => change.reason), ["created", "created", "renamed", "deleted"], ); }); test("active and pending playlists are conservatively protected from deletion", (t) => { const harness = createHarness(); t.after(() => harness.database.close()); const active = harness.service.create({ name: "Active" }); const pending = harness.service.create({ name: "Pending" }); harness.service.addEntry(active.id, { assetId: harness.seedAsset({ id: "held", name: "Held" }), }); harness.service.addEntry(pending.id, { assetId: harness.seedAsset({ id: "queued", name: "Queued" }), }); // A mid-duration normal slide is what keeps an activation genuinely pending. // With no playable normal target the authority promotes the request straight // away, so an activation cannot be left queued behind an empty playlist. harness.playbackService.activatePlaylist(active.id); assert.equal( harness.playbackService.activatePlaylist(pending.id).activation, "pending", ); expectError(() => harness.service.remove(active.id), "PLAYLIST_ACTIVE", 409); expectError( () => harness.service.remove(pending.id), "PLAYLIST_PENDING_ACTIVATION", 409, ); assert.equal(harness.playback().active_playlist_id, active.id); assert.equal(harness.playback().pending_playlist_id, pending.id); assert.equal(harness.service.list().length, 2); }); test("entry adds require a ready, unexpired asset and enforce one occurrence per playlist", (t) => { const harness = createHarness(); t.after(() => harness.database.close()); const first = harness.service.create({ name: "First" }); const second = harness.service.create({ name: "Second" }); const ready = harness.seedAsset({ id: "ready", name: "Ready" }); for (const status of ["processing", "failed", "deleting"]) { harness.seedAsset({ id: status, name: status, status }); } harness.seedAsset({ id: "expired", name: "Expired", expirationAt: BASE_TIME, }); const firstEntry = harness.service.addEntry(first.id, { assetId: ready }); const secondEntry = harness.service.addEntry(second.id, { assetId: ready }); assert.equal(firstEntry.position, 0); assert.equal(secondEntry.position, 0); assert.notEqual(firstEntry.id, secondEntry.id); expectError( () => harness.service.addEntry(first.id, { assetId: ready }), "PLAYLIST_ENTRY_EXISTS", 409, ); for (const status of ["processing", "failed", "deleting"]) { expectError( () => harness.service.addEntry(first.id, { assetId: status }), "ASSET_NOT_READY", 409, ); } expectError( () => harness.service.addEntry(first.id, { assetId: "missing" }), "ASSET_NOT_FOUND", 404, ); expectError( () => harness.service.addEntry(first.id, { assetId: "expired" }), "ASSET_EXPIRED", 409, ); assert.equal(harness.service.get(first.id).revision, 2); }); test("entry enablement, duration, and presentation overrides are playlist-local", (t) => { const harness = createHarness(); t.after(() => harness.database.close()); const a = harness.service.create({ name: "A" }); const b = harness.service.create({ name: "B" }); const assetId = harness.seedAsset({ id: "shared", name: "Shared" }); const entryA = harness.service.addEntry(a.id, { assetId }); const entryB = harness.service.addEntry(b.id, { assetId }); assert.equal(entryA.durationOverrideMs, null); assert.equal(entryA.effectiveDurationMs, 7000); assert.deepEqual(entryA.effectivePresentation, { backgroundMode: "blurred", backgroundColor: "#000000", backgroundDarkened: false, blurPx: 20, }); const patched = harness.service.updateEntry(a.id, entryA.id, { enabled: false, durationOverrideMs: 4000, backgroundModeOverride: "solid", backgroundColorOverride: "#a1b2c3", backgroundDarkenedOverride: true, }); assert.equal(patched.enabled, false); assert.equal(patched.effectiveDurationMs, 4000); assert.deepEqual(patched.effectivePresentation, { backgroundMode: "solid", backgroundColor: "#A1B2C3", backgroundDarkened: true, blurPx: 20, }); assert.equal(harness.service.get(b.id).entries[0].enabled, true); assert.equal(harness.service.get(b.id).entries[0].effectiveDurationMs, 7000); assert.equal( harness.database.prepare("SELECT status FROM assets WHERE id = ?").get(assetId) .status, "ready", ); const inheritedAgain = harness.service.updateEntry(a.id, entryA.id, { durationOverrideMs: null, backgroundModeOverride: null, backgroundColorOverride: null, backgroundDarkenedOverride: null, }); assert.equal(inheritedAgain.effectiveDurationMs, 7000); assert.equal(inheritedAgain.backgroundDarkenedOverride, null); for (const invalid of [MIN_DURATION_MS - 1, MAX_DURATION_MS + 1, 1.5]) { expectError( () => harness.service.updateEntry(a.id, entryA.id, { durationOverrideMs: invalid, }), "INVALID_PLAYLIST_ENTRY", 400, ); } for (const patch of [ { backgroundModeOverride: "gradient" }, { backgroundColorOverride: "red" }, { backgroundDarkenedOverride: 1 }, ]) { expectError( () => harness.service.updateEntry(a.id, entryA.id, patch), "INVALID_PLAYLIST_ENTRY", 400, ); } assert.equal(harness.service.get(a.id).revision, 4); assert.equal(harness.service.get(b.id).revision, 2); assert.equal(entryB.enabled, true); }); test("resolved entries centralize schedule and expiration eligibility at exact boundaries", (t) => { const harness = createHarness(); t.after(() => harness.database.close()); const playlist = harness.service.create({ name: "Scheduled" }); const permanent = harness.seedAsset({ id: "permanent", name: "Permanent" }); const futureExpiry = harness.seedAsset({ id: "future-expiry", name: "Future expiry", expirationAt: BASE_TIME + 20_000, }); const noSchedule = harness.service.addEntry(playlist.id, { assetId: permanent }); const bounded = harness.service.addEntry(playlist.id, { assetId: futureExpiry, scheduleStartAt: BASE_TIME + 10_000, scheduleEndAt: BASE_TIME + 30_000, }); let detail = harness.service.get(playlist.id); assert.equal(detail.evaluatedAt, BASE_TIME); assert.equal(detail.settingsRevision, 1); assert.deepEqual( detail.entries.map((entry) => [entry.id, entry.playable, entry.notPlayableReason]), [ [noSchedule.id, true, null], [bounded.id, false, "scheduled_future"], ], ); harness.setNow(BASE_TIME + 10_000); assert.equal(harness.service.get(playlist.id).entries[1].playable, true); harness.setNow(BASE_TIME + 20_000); assert.equal( harness.service.get(playlist.id).entries[1].notPlayableReason, "asset_expired", ); harness.setNow(BASE_TIME + 30_000); assert.equal( harness.service.get(playlist.id).entries[1].notPlayableReason, "scheduled_ended", ); harness.setNow(BASE_TIME); const endOnlyAsset = harness.seedAsset({ id: "end-only", name: "End only" }); const endOnly = harness.service.addEntry(playlist.id, { assetId: endOnlyAsset, scheduleEndAt: BASE_TIME + 1, }); assert.equal(endOnly.playable, true); harness.setNow(BASE_TIME + 1); assert.equal( harness.service.get(playlist.id).entries.find((entry) => entry.id === endOnly.id) .notPlayableReason, "scheduled_ended", ); expectError( () => harness.service.updateEntry(playlist.id, noSchedule.id, { scheduleStartAt: BASE_TIME + 2, scheduleEndAt: BASE_TIME + 1, }), "INVALID_PLAYLIST_ENTRY_SCHEDULE", 400, ); expectError( () => harness.service.updateEntry(playlist.id, noSchedule.id, { scheduleStartAt: "2033-05-18T03:33:20", }), "INVALID_PLAYLIST_ENTRY", 400, ); const iso = harness.service.updateEntry(playlist.id, noSchedule.id, { scheduleStartAt: "2033-05-18T03:33:20Z", }); assert.equal(iso.scheduleStartAt, 2_000_000_000_000); }); test("full-list reorder validates membership and persists contiguous zero-based positions", (t) => { const harness = createHarness(); t.after(() => harness.database.close()); const playlist = harness.service.create({ name: "Ordered" }); const other = harness.service.create({ name: "Other" }); const entries = ["one", "two", "three"].map((id) => harness.service.addEntry(playlist.id, { assetId: harness.seedAsset({ id, name: id }), }), ); const foreign = harness.service.addEntry(other.id, { assetId: harness.seedAsset({ id: "foreign", name: "foreign" }), }); const revision = harness.service.get(playlist.id).revision; const reordered = harness.service.reorderEntries( playlist.id, [entries[2].id, entries[0].id, entries[1].id], ); assert.deepEqual( reordered.entries.map((entry) => [entry.id, entry.position]), [ [entries[2].id, 0], [entries[0].id, 1], [entries[1].id, 2], ], ); assert.equal(reordered.revision, revision + 1); const failedRevision = reordered.revision; expectError( () => harness.service.reorderEntries(playlist.id, [ entries[2].id, entries[2].id, entries[1].id, ]), "PLAYLIST_ORDER_DUPLICATE_IDS", ); expectError( () => harness.service.reorderEntries(playlist.id, [entries[2].id]), "PLAYLIST_ORDER_INCOMPLETE", ); expectError( () => harness.service.reorderEntries(playlist.id, [ entries[2].id, entries[0].id, foreign.id, ]), "PLAYLIST_ORDER_FOREIGN_ENTRY", ); expectError( () => harness.service.reorderEntries(playlist.id, [ entries[2].id, entries[0].id, "missing-entry", ]), "PLAYLIST_ORDER_UNKNOWN_ENTRY", ); assert.equal(harness.service.get(playlist.id).revision, failedRevision); harness.service.removeEntry(playlist.id, entries[0].id); assert.deepEqual( harness.service.get(playlist.id).entries.map((entry) => entry.position), [0, 1], ); }); test("no-op and failed mutations do not increment revisions or emit invalidations", (t) => { const harness = createHarness(); t.after(() => harness.database.close()); const playlist = harness.service.create({ name: "Stable" }); const entry = harness.service.addEntry(playlist.id, { assetId: harness.seedAsset({ id: "stable-asset", name: "Stable asset" }), }); const baseline = harness.service.get(playlist.id).revision; const eventCount = harness.changes.length; harness.service.update(playlist.id, { name: "Stable" }); harness.service.updateEntry(playlist.id, entry.id, { enabled: true }); harness.service.reorderEntries(playlist.id, [entry.id]); expectError( () => harness.service.updateEntry(playlist.id, "missing", { enabled: false }), "PLAYLIST_ENTRY_NOT_FOUND", 404, ); assert.equal(harness.service.get(playlist.id).revision, baseline); assert.equal(harness.changes.length, eventCount); }); test("entry references protect assets and final removal restores delete eligibility", (t) => { const harness = createHarness(); t.after(() => harness.database.close()); const playlist = harness.service.create({ name: "References" }); const assetId = harness.seedAsset({ id: "referenced", name: "Referenced" }); const entry = harness.service.addEntry(playlist.id, { assetId, enabled: false }); const assetService = createAssetService({ database: harness.database, storage: {}, imageProcessor: {}, }); const referenced = assetService.references(assetId); assert.equal(referenced.canDelete, false); assert.equal(referenced.playlists[0].playlistEntryId, entry.id); harness.service.removeEntry(playlist.id, entry.id); assert.equal(assetService.references(assetId).canDelete, true); }); test("activation queues at a live boundary, is idempotent, and mode off is only a gate", (t) => { const harness = createHarness(); t.after(() => harness.database.close()); const oldPlaylist = harness.service.create({ name: "Old" }); const newPlaylist = harness.service.create({ name: "New" }); const oldEntry = harness.service.addEntry(oldPlaylist.id, { assetId: harness.seedAsset({ id: "old-asset", name: "Old asset" }), }); const newEntry = harness.service.addEntry(newPlaylist.id, { assetId: harness.seedAsset({ id: "new-asset", name: "New asset" }), durationOverrideMs: 4000, }); const initial = harness.playbackService.activatePlaylist(oldPlaylist.id); assert.equal(initial.activation, "active"); assert.equal(initial.playback.currentEntryId, oldEntry.id); assert.equal(initial.playback.slideStartedAt, BASE_TIME); assert.equal(initial.playback.nextSlideAt, BASE_TIME + 7000); harness.database .prepare("UPDATE playback_state SET mode = 'off' WHERE output_id = 'global'") .run(); harness.setNow(BASE_TIME + 1000); const pending = harness.playbackService.activatePlaylist(newPlaylist.id); assert.equal(pending.activation, "pending"); assert.equal(pending.playback.mode, "off"); assert.equal(pending.playback.activePlaylistId, oldPlaylist.id); assert.equal(pending.playback.pendingPlaylistId, newPlaylist.id); assert.equal(pending.playback.currentEntryId, oldEntry.id); const pendingRevision = pending.playback.revision; const requestedAt = pending.playback.pendingPlaylistRequestedAt; const repeated = harness.playbackService.activatePlaylist(newPlaylist.id); assert.equal(repeated.activation, "pending"); assert.equal(repeated.playback.revision, pendingRevision); assert.equal(repeated.playback.pendingPlaylistRequestedAt, requestedAt); const cancel = harness.playbackService.activatePlaylist(oldPlaylist.id); assert.equal(cancel.activation, "already_active"); assert.equal(cancel.playback.pendingPlaylistId, null); assert.equal(cancel.playback.currentEntryId, oldEntry.id); harness.setNow(BASE_TIME + 7000); const immediate = harness.playbackService.activatePlaylist(newPlaylist.id); assert.equal(immediate.activation, "active"); assert.equal(immediate.playback.activePlaylistId, newPlaylist.id); assert.equal(immediate.playback.pendingPlaylistId, null); assert.equal(immediate.playback.currentEntryId, newEntry.id); assert.equal(immediate.playback.slideStartedAt, BASE_TIME + 7000); assert.equal(immediate.playback.nextSlideAt, BASE_TIME + 11_000); assert.equal(immediate.playback.mode, "off"); }); test("paused normal playback queues and can replace a pending activation", (t) => { const harness = createHarness(); t.after(() => harness.database.close()); const current = harness.service.create({ name: "Paused current" }); const firstTarget = harness.service.create({ name: "First target" }); const secondTarget = harness.service.create({ name: "Second target" }); harness.service.addEntry(current.id, { assetId: harness.seedAsset({ id: "paused-current", name: "Paused current" }), }); harness.service.addEntry(firstTarget.id, { assetId: harness.seedAsset({ id: "first-target", name: "First target" }), }); harness.service.addEntry(secondTarget.id, { assetId: harness.seedAsset({ id: "second-target", name: "Second target" }), }); harness.playbackService.activatePlaylist(current.id); harness.database .prepare( `UPDATE playback_state SET rotation_paused = 1, paused_remaining_ms = 5000, slide_started_at = NULL, next_slide_at = NULL WHERE output_id = 'global'`, ) .run(); harness.setNow(BASE_TIME + 1000); const first = harness.playbackService.activatePlaylist(firstTarget.id); assert.equal(first.activation, "pending"); assert.equal(first.playback.activePlaylistId, current.id); assert.equal(first.playback.pendingPlaylistId, firstTarget.id); assert.equal(first.playback.pausedRemainingMs, 5000); harness.setNow(BASE_TIME + 2000); const replacement = harness.playbackService.activatePlaylist(secondTarget.id); assert.equal(replacement.activation, "pending"); assert.equal(replacement.playback.activePlaylistId, current.id); assert.equal(replacement.playback.pendingPlaylistId, secondTarget.id); assert.equal(replacement.playback.pendingPlaylistRequestedAt, BASE_TIME + 2000); assert.equal(replacement.playback.revision, first.playback.revision + 1); assert.equal(replacement.playback.pausedRemainingMs, 5000); }); test("static and paused activation prepare the first playable normal target without breaking hold", (t) => { const harness = createHarness(); t.after(() => harness.database.close()); const oldPlaylist = harness.service.create({ name: "Old static" }); const target = harness.service.create({ name: "Target static" }); const staticAssetId = harness.seedAsset({ id: "static", name: "Static" }); harness.service.addEntry(oldPlaylist.id, { assetId: harness.seedAsset({ id: "old-normal", name: "Old normal" }), }); const skipped = harness.service.addEntry(target.id, { assetId: harness.seedAsset({ id: "disabled", name: "Disabled" }), enabled: false, }); const firstPlayable = harness.service.addEntry(target.id, { assetId: harness.seedAsset({ id: "playable", name: "Playable" }), durationOverrideMs: 9000, }); assert.equal(skipped.position, 0); harness.playbackService.activatePlaylist(oldPlaylist.id); harness.database .prepare( `UPDATE playback_state SET static_asset_id = ?, rotation_paused = 1, paused_remaining_ms = 1234, slide_started_at = NULL, next_slide_at = NULL WHERE output_id = 'global'`, ) .run(staticAssetId); const result = harness.playbackService.activatePlaylist(target.id); assert.equal(result.activation, "active"); assert.equal(result.playback.activePlaylistId, target.id); assert.equal(result.playback.staticAssetId, staticAssetId); assert.equal(result.playback.rotationPaused, true); assert.equal(result.playback.currentEntryId, firstPlayable.id); assert.equal(result.playback.pausedRemainingMs, 9000); assert.equal(result.playback.slideStartedAt, null); assert.equal(result.playback.nextSlideAt, null); }); test("empty activation is immediate and current-entry deletion coordinates playback revision safely", (t) => { const harness = createHarness(); t.after(() => harness.database.close()); const populated = harness.service.create({ name: "Populated" }); const empty = harness.service.create({ name: "Empty" }); const entries = ["first", "second"].map((id) => harness.service.addEntry(populated.id, { assetId: harness.seedAsset({ id: `asset-${id}`, name: id }), }), ); harness.playbackService.activatePlaylist(populated.id); const futurePlaybackTimestamp = BASE_TIME + 100_000; // Static and pause suspend the normal timeline. Persist the same shape the // playback authority itself writes, so the pre-change reconciliation has no // malformed timeline to repair and only the deletion moves the revision. harness.database .prepare( `UPDATE playback_state SET updated_at = ?, mode = 'off', static_asset_id = ?, rotation_paused = 1, paused_remaining_ms = 7000, slide_started_at = NULL, next_slide_at = NULL WHERE output_id = 'global'`, ) .run(futurePlaybackTimestamp, "asset-second"); const beforeDelete = harness.playback(); harness.service.removeEntry(populated.id, entries[0].id); const afterDelete = harness.playback(); assert.equal(afterDelete.current_entry_id, entries[1].id); assert.equal(afterDelete.slide_started_at, null); assert.equal(afterDelete.next_slide_at, null); assert.equal(afterDelete.paused_remaining_ms, 7000); assert.equal(afterDelete.active_playlist_id, populated.id); assert.equal(afterDelete.mode, "off"); assert.equal(afterDelete.static_asset_id, "asset-second"); assert.equal(afterDelete.rotation_paused, 1); assert.equal(afterDelete.revision, beforeDelete.revision + 1); assert.equal(afterDelete.updated_at, futurePlaybackTimestamp + 1); assert.deepEqual( harness.service.get(populated.id).entries.map((entry) => entry.position), [0], ); harness.database .prepare( `UPDATE playback_state SET static_asset_id = NULL, rotation_paused = 0 WHERE output_id = 'global'`, ) .run(); const result = harness.playbackService.activatePlaylist(empty.id); assert.equal(result.activation, "active"); assert.equal(result.playback.activePlaylistId, empty.id); assert.equal(result.playback.currentEntryId, null); assert.equal(result.playback.slideStartedAt, null); assert.equal(result.playback.nextSlideAt, null); }); test("inactive playlist edits do not mutate authoritative playback state", (t) => { const harness = createHarness(); t.after(() => harness.database.close()); const active = harness.service.create({ name: "Live" }); const inactive = harness.service.create({ name: "Draft" }); harness.service.addEntry(active.id, { assetId: harness.seedAsset({ id: "live", name: "Live asset" }), }); const draftEntry = harness.service.addEntry(inactive.id, { assetId: harness.seedAsset({ id: "draft", name: "Draft asset" }), }); harness.playbackService.activatePlaylist(active.id); const before = harness.playback(); harness.service.update(inactive.id, { name: "Draft edited" }); harness.service.updateEntry(inactive.id, draftEntry.id, { enabled: false }); harness.service.reorderEntries(inactive.id, [draftEntry.id]); assert.deepEqual(harness.playback(), before); });