250 lines
7.2 KiB
JavaScript
250 lines
7.2 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");
|
|
|
|
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");
|
|
});
|