Implement Phase 3A playlist management
This commit is contained in:
@@ -0,0 +1,210 @@
|
||||
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 {
|
||||
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 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());
|
||||
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);
|
||||
|
||||
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());
|
||||
assert.deepEqual(events, [
|
||||
[
|
||||
"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());
|
||||
assert.equal(events.length, 1);
|
||||
assert.equal(events[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());
|
||||
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);
|
||||
assert.equal(
|
||||
events[0][1].playbackRevision,
|
||||
activated.body.playback.revision,
|
||||
);
|
||||
|
||||
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");
|
||||
});
|
||||
Reference in New Issue
Block a user