Implement Phase 3A playlist management

This commit is contained in:
Media
2026-08-16 20:34:36 -05:00
parent 722c6cf77d
commit e2ecdc97e2
15 changed files with 2955 additions and 7 deletions
+389
View File
@@ -0,0 +1,389 @@
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,
);
});