390 lines
11 KiB
JavaScript
390 lines
11 KiB
JavaScript
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,
|
|
);
|
|
});
|