Files
Announcements/server/test/socket-invalidation.test.js
T

384 lines
11 KiB
JavaScript

const test = require("node:test");
const assert = require("node:assert/strict");
const { loadConfig } = require("../src/config");
const { createRuntime } = require("../src/runtime");
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",
SHUTDOWN_TIMEOUT_MS: "1000",
S3_ENDPOINT: "http://seaweedfs:8333",
S3_BUCKET: "announcements-test",
});
}
function testStorage() {
return {
async start() {},
stop() {},
health() {
return {
status: "healthy",
checkedAt: "2032-04-05T12:00:00.000Z",
lastSuccessAt: "2032-04-05T12:00:00.000Z",
};
},
};
}
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() };
}
function splitEnginePackets(payload) {
return payload.split("\x1e").filter(Boolean);
}
function socketEvents(payload) {
return splitEnginePackets(payload)
.filter((packet) => packet.startsWith("42"))
.map((packet) => JSON.parse(packet.slice(2)));
}
async function connectPollingSocket(baseUrl) {
let requestNumber = 0;
const endpoint = (sid) => {
const parameters = new URLSearchParams({
EIO: "4",
transport: "polling",
t: String(requestNumber++),
});
if (sid) parameters.set("sid", sid);
return `${baseUrl}/socket.io/?${parameters}`;
};
const openingResponse = await fetch(endpoint());
assert.equal(openingResponse.status, 200);
const openingPackets = splitEnginePackets(await openingResponse.text());
const openPacket = openingPackets.find((packet) => packet.startsWith("0"));
assert.ok(openPacket, "Engine.IO must return an open packet");
const { sid } = JSON.parse(openPacket.slice(1));
const connectResponse = await fetch(endpoint(sid), {
method: "POST",
headers: { "content-type": "text/plain;charset=UTF-8" },
body: "40",
});
assert.equal(connectResponse.status, 200);
await connectResponse.text();
const connectedResponse = await fetch(endpoint(sid));
assert.equal(connectedResponse.status, 200);
const connectedPayload = await connectedResponse.text();
assert.ok(
splitEnginePackets(connectedPayload).some((packet) =>
packet.startsWith("40"),
),
"Socket.IO must acknowledge the namespace connection",
);
return {
initialEvents: socketEvents(connectedPayload),
async poll() {
const response = await fetch(endpoint(sid), {
signal: AbortSignal.timeout(3_000),
});
assert.equal(response.status, 200);
return response.text();
},
async close() {
const response = await fetch(endpoint(sid), {
method: "POST",
headers: { "content-type": "text/plain;charset=UTF-8" },
body: "41",
});
await response.text();
},
};
}
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(),
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(() => {}));
await jsonRequest(baseUrl, "/api/playlists");
const invalid = await jsonRequest(baseUrl, "/api/playlists", {
method: "POST",
body: {},
});
assert.equal(invalid.response.status, 400);
await jsonRequest(baseUrl, "/api/settings", {
method: "PATCH",
body: { defaultDurationMs: 7_000 },
});
const created = await jsonRequest(baseUrl, "/api/playlists", {
method: "POST",
body: { name: "Sunday" },
});
assert.equal(created.response.status, 201);
let events = socketEvents(await socket.poll());
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",
body: { name: "SUNDAY" },
});
assert.equal(duplicate.response.status, 409);
await jsonRequest(baseUrl, `/api/playlists/${created.body.id}`, {
method: "PATCH",
body: { name: "Sunday" },
});
await jsonRequest(baseUrl, "/api/settings", {
method: "PATCH",
body: { defaultDurationMs: 8_000 },
});
events = socketEvents(await socket.poll());
playlistEvents = events.filter(
([eventName]) => eventName === "playlists:changed",
);
assert.deepEqual(playlistEvents, [
[
"playlists:changed",
{
reason: "settings_updated",
playlistIds: null,
settingsRevision: 2,
},
],
]);
const second = await jsonRequest(baseUrl, "/api/playlists", {
method: "POST",
body: { name: "Midweek" },
});
assert.equal(second.response.status, 201);
events = socketEvents(await socket.poll());
playlistEvents = events.filter(
([eventName]) => eventName === "playlists:changed",
);
assert.equal(playlistEvents.length, 1);
assert.equal(playlistEvents[0][1].reason, "created");
const activated = await jsonRequest(
baseUrl,
`/api/playlists/${created.body.id}/activate`,
{ method: "POST" },
);
assert.equal(activated.response.status, 200);
assert.equal(activated.body.activation, "active");
events = socketEvents(await socket.poll());
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(
playlistEvents[0][1].playbackRevision,
activated.body.playback.revision,
);
assert.equal(
events.filter(([eventName]) => eventName === "playback:state").length,
1,
);
const activeDelete = await jsonRequest(
baseUrl,
`/api/playlists/${created.body.id}`,
{ method: "DELETE" },
);
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);
});