WIP: Phase 3B playback engine handoff
This commit is contained in:
@@ -0,0 +1,249 @@
|
||||
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");
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -4,6 +4,7 @@ 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,
|
||||
@@ -19,13 +20,22 @@ function createHarness() {
|
||||
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")}`,
|
||||
onChanged: (change) => changes.push(change),
|
||||
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;
|
||||
@@ -71,7 +81,15 @@ function createHarness() {
|
||||
.get();
|
||||
}
|
||||
|
||||
return { changes, database, playback, seedAsset, service, setNow };
|
||||
return {
|
||||
changes,
|
||||
database,
|
||||
playback,
|
||||
playbackService,
|
||||
seedAsset,
|
||||
service,
|
||||
setNow,
|
||||
};
|
||||
}
|
||||
|
||||
function expectError(action, code, statusCode) {
|
||||
@@ -479,7 +497,7 @@ test("activation queues at a live boundary, is idempotent, and mode off is only
|
||||
durationOverrideMs: 4000,
|
||||
});
|
||||
|
||||
const initial = harness.service.activate(oldPlaylist.id);
|
||||
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);
|
||||
@@ -489,7 +507,7 @@ test("activation queues at a live boundary, is idempotent, and mode off is only
|
||||
.prepare("UPDATE playback_state SET mode = 'off' WHERE output_id = 'global'")
|
||||
.run();
|
||||
harness.setNow(BASE_TIME + 1000);
|
||||
const pending = harness.service.activate(newPlaylist.id);
|
||||
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);
|
||||
@@ -498,18 +516,18 @@ test("activation queues at a live boundary, is idempotent, and mode off is only
|
||||
const pendingRevision = pending.playback.revision;
|
||||
const requestedAt = pending.playback.pendingPlaylistRequestedAt;
|
||||
|
||||
const repeated = harness.service.activate(newPlaylist.id);
|
||||
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.service.activate(oldPlaylist.id);
|
||||
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.service.activate(newPlaylist.id);
|
||||
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);
|
||||
@@ -534,7 +552,7 @@ test("paused normal playback queues and can replace a pending activation", (t) =
|
||||
harness.service.addEntry(secondTarget.id, {
|
||||
assetId: harness.seedAsset({ id: "second-target", name: "Second target" }),
|
||||
});
|
||||
harness.service.activate(current.id);
|
||||
harness.playbackService.activatePlaylist(current.id);
|
||||
harness.database
|
||||
.prepare(
|
||||
`UPDATE playback_state
|
||||
@@ -545,14 +563,14 @@ test("paused normal playback queues and can replace a pending activation", (t) =
|
||||
.run();
|
||||
|
||||
harness.setNow(BASE_TIME + 1000);
|
||||
const first = harness.service.activate(firstTarget.id);
|
||||
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.service.activate(secondTarget.id);
|
||||
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);
|
||||
@@ -579,7 +597,7 @@ test("static and paused activation prepare the first playable normal target with
|
||||
durationOverrideMs: 9000,
|
||||
});
|
||||
assert.equal(skipped.position, 0);
|
||||
harness.service.activate(oldPlaylist.id);
|
||||
harness.playbackService.activatePlaylist(oldPlaylist.id);
|
||||
harness.database
|
||||
.prepare(
|
||||
`UPDATE playback_state
|
||||
@@ -590,7 +608,7 @@ test("static and paused activation prepare the first playable normal target with
|
||||
)
|
||||
.run(staticAssetId);
|
||||
|
||||
const result = harness.service.activate(target.id);
|
||||
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);
|
||||
@@ -611,7 +629,7 @@ test("empty activation is immediate and current-entry deletion coordinates playb
|
||||
assetId: harness.seedAsset({ id: `asset-${id}`, name: id }),
|
||||
}),
|
||||
);
|
||||
harness.service.activate(populated.id);
|
||||
harness.playbackService.activatePlaylist(populated.id);
|
||||
const futurePlaybackTimestamp = BASE_TIME + 100_000;
|
||||
harness.database
|
||||
.prepare(
|
||||
@@ -624,10 +642,10 @@ test("empty activation is immediate and current-entry deletion coordinates playb
|
||||
|
||||
harness.service.removeEntry(populated.id, entries[0].id);
|
||||
const afterDelete = harness.playback();
|
||||
assert.equal(afterDelete.current_entry_id, null);
|
||||
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, 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");
|
||||
@@ -646,7 +664,7 @@ test("empty activation is immediate and current-entry deletion coordinates playb
|
||||
WHERE output_id = 'global'`,
|
||||
)
|
||||
.run();
|
||||
const result = harness.service.activate(empty.id);
|
||||
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);
|
||||
@@ -665,7 +683,7 @@ test("inactive playlist edits do not mutate authoritative playback state", (t) =
|
||||
const draftEntry = harness.service.addEntry(inactive.id, {
|
||||
assetId: harness.seedAsset({ id: "draft", name: "Draft asset" }),
|
||||
});
|
||||
harness.service.activate(active.id);
|
||||
harness.playbackService.activatePlaylist(active.id);
|
||||
const before = harness.playback();
|
||||
|
||||
harness.service.update(inactive.id, { name: "Draft edited" });
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const net = require("node:net");
|
||||
const { loadConfig } = require("../src/config");
|
||||
const { createRuntime } = require("../src/runtime");
|
||||
|
||||
function testConfig() {
|
||||
function testConfig(overrides = {}) {
|
||||
return loadConfig({
|
||||
NODE_ENV: "test",
|
||||
HOST: "127.0.0.1",
|
||||
@@ -20,6 +21,7 @@ function testConfig() {
|
||||
MQTT_URL: "mqtt://broker:1883",
|
||||
MQTT_PASSWORD: "mqtt-password-that-must-not-leak",
|
||||
HA_WEBHOOK_URL: "http://home-assistant:8123/api/webhook/private-id",
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -171,3 +173,28 @@ test("runtime startup and shutdown are idempotent", async () => {
|
||||
assert.equal(runtime.lifecycle.ready, false);
|
||||
assert.equal(runtime.database.open, false);
|
||||
});
|
||||
|
||||
test("a listen failure stops playback and closes startup resources", async (t) => {
|
||||
const occupied = net.createServer();
|
||||
occupied.listen(0, "127.0.0.1");
|
||||
await new Promise((resolve, reject) => {
|
||||
occupied.once("listening", resolve);
|
||||
occupied.once("error", reject);
|
||||
});
|
||||
t.after(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
occupied.close(resolve);
|
||||
}),
|
||||
);
|
||||
|
||||
const runtime = createRuntime({
|
||||
config: testConfig({ PORT: String(occupied.address().port) }),
|
||||
logger: silentLogger,
|
||||
storage: testStorage(),
|
||||
});
|
||||
|
||||
await assert.rejects(runtime.start(), (error) => error.code === "EADDRINUSE");
|
||||
assert.equal(runtime.lifecycle.ready, false);
|
||||
assert.equal(runtime.database.open, false);
|
||||
});
|
||||
|
||||
@@ -97,6 +97,7 @@ async function connectPollingSocket(baseUrl) {
|
||||
);
|
||||
|
||||
return {
|
||||
initialEvents: socketEvents(connectedPayload),
|
||||
async poll() {
|
||||
const response = await fetch(endpoint(sid), {
|
||||
signal: AbortSignal.timeout(3_000),
|
||||
@@ -115,6 +116,65 @@ async function connectPollingSocket(baseUrl) {
|
||||
};
|
||||
}
|
||||
|
||||
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(),
|
||||
@@ -144,10 +204,12 @@ test("Socket.IO publishes playlists:changed only after successful semantic mutat
|
||||
});
|
||||
assert.equal(created.response.status, 201);
|
||||
let events = socketEvents(await socket.poll());
|
||||
assert.equal(events.length, 1);
|
||||
assert.equal(events[0][0], "playlists:changed");
|
||||
assert.equal(events[0][1].reason, "created");
|
||||
assert.equal(events[0][1].playlistId, created.body.id);
|
||||
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",
|
||||
@@ -163,7 +225,10 @@ test("Socket.IO publishes playlists:changed only after successful semantic mutat
|
||||
body: { defaultDurationMs: 8_000 },
|
||||
});
|
||||
events = socketEvents(await socket.poll());
|
||||
assert.deepEqual(events, [
|
||||
playlistEvents = events.filter(
|
||||
([eventName]) => eventName === "playlists:changed",
|
||||
);
|
||||
assert.deepEqual(playlistEvents, [
|
||||
[
|
||||
"playlists:changed",
|
||||
{
|
||||
@@ -180,8 +245,11 @@ test("Socket.IO publishes playlists:changed only after successful semantic mutat
|
||||
});
|
||||
assert.equal(second.response.status, 201);
|
||||
events = socketEvents(await socket.poll());
|
||||
assert.equal(events.length, 1);
|
||||
assert.equal(events[0][1].reason, "created");
|
||||
playlistEvents = events.filter(
|
||||
([eventName]) => eventName === "playlists:changed",
|
||||
);
|
||||
assert.equal(playlistEvents.length, 1);
|
||||
assert.equal(playlistEvents[0][1].reason, "created");
|
||||
|
||||
const activated = await jsonRequest(
|
||||
baseUrl,
|
||||
@@ -191,14 +259,20 @@ test("Socket.IO publishes playlists:changed only after successful semantic mutat
|
||||
assert.equal(activated.response.status, 200);
|
||||
assert.equal(activated.body.activation, "active");
|
||||
events = socketEvents(await socket.poll());
|
||||
assert.equal(events.length, 1);
|
||||
assert.equal(events[0][0], "playlists:changed");
|
||||
assert.equal(events[0][1].reason, "activated");
|
||||
assert.equal(events[0][1].playlistId, created.body.id);
|
||||
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(
|
||||
events[0][1].playbackRevision,
|
||||
playlistEvents[0][1].playbackRevision,
|
||||
activated.body.playback.revision,
|
||||
);
|
||||
assert.equal(
|
||||
events.filter(([eventName]) => eventName === "playback:state").length,
|
||||
1,
|
||||
);
|
||||
|
||||
const activeDelete = await jsonRequest(
|
||||
baseUrl,
|
||||
@@ -208,3 +282,102 @@ test("Socket.IO publishes playlists:changed only after successful semantic mutat
|
||||
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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user