Implement Phase 3A playlist management
This commit is contained in:
@@ -79,6 +79,48 @@ display and thumbnail routes stream private S3 objects through the API with a
|
||||
one-year immutable cache policy. Original downloads use private, no-store
|
||||
caching.
|
||||
|
||||
## Playlists
|
||||
|
||||
Playlist and entry metadata is authoritative in SQLite. The API surface is:
|
||||
|
||||
- `GET /api/playlists`
|
||||
- `POST /api/playlists`
|
||||
- `GET /api/playlists/:playlistId`
|
||||
- `PATCH /api/playlists/:playlistId`
|
||||
- `DELETE /api/playlists/:playlistId`
|
||||
- `POST /api/playlists/:playlistId/entries`
|
||||
- `PATCH /api/playlists/:playlistId/entries/:entryId`
|
||||
- `DELETE /api/playlists/:playlistId/entries/:entryId`
|
||||
- `PUT /api/playlists/:playlistId/entries/order`
|
||||
- `POST /api/playlists/:playlistId/activate`
|
||||
|
||||
Entry positions are zero-based and contiguous. Reordering requires the complete
|
||||
ordered list as `{ "entryIds": [...] }`. An asset can occur only once in a
|
||||
playlist, but the same asset can be used independently by any number of other
|
||||
playlists. Only ready, unexpired assets can be newly added.
|
||||
|
||||
Entry duration overrides may be null (inherit the global setting) or 1,000 to
|
||||
3,600,000 milliseconds. Presentation overrides are nullable; darkening is an
|
||||
inherit/off/on boolean and blur always comes from the global setting. Schedule
|
||||
instants use epoch milliseconds or ISO strings with an explicit `Z` or numeric
|
||||
UTC offset, and intervals are evaluated as `[start, end)`.
|
||||
|
||||
Playlist detail responses include effective duration and presentation,
|
||||
server-evaluated eligibility, immutable display/thumbnail URLs, and the
|
||||
settings revision and evaluation time used. Playlist revisions increment once
|
||||
per semantic content change. Exact no-ops and reads do not increment them.
|
||||
|
||||
Activation is explicit. A request is held in the existing pending-activation
|
||||
state while a playable normal slide is mid-duration; otherwise the target
|
||||
playlist is prepared immediately at its first currently playable entry. The
|
||||
output-off flag remains only a visual gate, and static hold is preserved while
|
||||
the hidden normal playlist changes. Phase 3A does not include the advancing
|
||||
playback timer that will consume pending activation at a slide boundary.
|
||||
|
||||
Socket.IO publishes `playlists:changed` as a refetch invalidation after
|
||||
committed playlist changes and after settings or referenced-asset edits that
|
||||
affect resolved playlist data. Socket.IO is not a mutation API.
|
||||
|
||||
## Storage and health
|
||||
|
||||
The API uses a small path-style S3 adapter configured by `S3_ENDPOINT`,
|
||||
|
||||
+43
-2
@@ -6,24 +6,55 @@ const {
|
||||
const { createAssetRouter } = require("./assets/routes");
|
||||
const { createMediaRouter } = require("./assets/media-routes");
|
||||
const { createAssetService } = require("./assets/service");
|
||||
const {
|
||||
createPlaylistRouter,
|
||||
createPlaylistService,
|
||||
} = require("./playlists");
|
||||
const { createSettingsRouter } = require("./settings/routes");
|
||||
const { createSettingsService } = require("./settings/service");
|
||||
|
||||
function safeNotifier(notifier, logger) {
|
||||
const warn = (error) => {
|
||||
try {
|
||||
logger?.warn?.("Playlist invalidation notification failed", error);
|
||||
} catch {
|
||||
// A best-effort notification or warning must not undo a database commit.
|
||||
}
|
||||
};
|
||||
return (event) => {
|
||||
try {
|
||||
const result = notifier(event);
|
||||
result?.catch?.(warn);
|
||||
} catch (error) {
|
||||
warn(error);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function createApp({
|
||||
config,
|
||||
database,
|
||||
imageProcessor,
|
||||
lifecycle,
|
||||
logger = console,
|
||||
migrations,
|
||||
now = Date.now,
|
||||
onPlaylistsChanged = () => {},
|
||||
storage,
|
||||
}) {
|
||||
const app = express();
|
||||
const notifyPlaylistsChanged = safeNotifier(onPlaylistsChanged, logger);
|
||||
const settingsService = createSettingsService(database);
|
||||
const assetService = createAssetService({
|
||||
database,
|
||||
imageProcessor,
|
||||
storage,
|
||||
});
|
||||
const playlistService = createPlaylistService({
|
||||
database,
|
||||
now,
|
||||
onChanged: notifyPlaylistsChanged,
|
||||
});
|
||||
|
||||
app.disable("x-powered-by");
|
||||
app.set("trust proxy", config.trustProxy);
|
||||
@@ -51,11 +82,21 @@ function createApp({
|
||||
"/api/status",
|
||||
createDetailedHealthRouter(detailedHealthRouterOptions),
|
||||
);
|
||||
app.use("/api/settings", createSettingsRouter(settingsService));
|
||||
app.use(
|
||||
"/api/settings",
|
||||
createSettingsRouter(settingsService, {
|
||||
onPlaylistsChanged: notifyPlaylistsChanged,
|
||||
}),
|
||||
);
|
||||
app.use(
|
||||
"/api/assets",
|
||||
createAssetRouter({ assetService, processingConfig: config.processing }),
|
||||
createAssetRouter({
|
||||
assetService,
|
||||
onPlaylistsChanged: notifyPlaylistsChanged,
|
||||
processingConfig: config.processing,
|
||||
}),
|
||||
);
|
||||
app.use("/api/playlists", createPlaylistRouter(playlistService));
|
||||
app.use("/media", createMediaRouter(assetService));
|
||||
|
||||
app.use((request, response) => {
|
||||
|
||||
@@ -6,7 +6,11 @@ const {
|
||||
} = require("./multipart");
|
||||
const { sendOriginal } = require("./streaming");
|
||||
|
||||
function createAssetRouter({ assetService, processingConfig }) {
|
||||
function createAssetRouter({
|
||||
assetService,
|
||||
onPlaylistsChanged = () => {},
|
||||
processingConfig,
|
||||
}) {
|
||||
const router = express.Router();
|
||||
const receiveUpload = createMultipartUpload(processingConfig);
|
||||
|
||||
@@ -80,7 +84,23 @@ function createAssetRouter({ assetService, processingConfig }) {
|
||||
|
||||
router.patch("/:assetId", (request, response, next) => {
|
||||
try {
|
||||
response.json(assetService.update(request.params.assetId, request.body));
|
||||
const previous = assetService.get(request.params.assetId);
|
||||
const playlistIds = [
|
||||
...new Set(
|
||||
assetService
|
||||
.references(request.params.assetId)
|
||||
.playlists.map((reference) => reference.playlistId),
|
||||
),
|
||||
];
|
||||
const updated = assetService.update(request.params.assetId, request.body);
|
||||
if (updated.updatedAt !== previous.updatedAt && playlistIds.length > 0) {
|
||||
onPlaylistsChanged({
|
||||
reason: "asset_updated",
|
||||
playlistIds,
|
||||
assetId: updated.id,
|
||||
});
|
||||
}
|
||||
response.json(updated);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
const ELIGIBILITY_REASONS = Object.freeze({
|
||||
DISABLED: "disabled",
|
||||
SCHEDULED_FUTURE: "scheduled_future",
|
||||
SCHEDULED_ENDED: "scheduled_ended",
|
||||
ASSET_NOT_READY: "asset_not_ready",
|
||||
ASSET_EXPIRED: "asset_expired",
|
||||
});
|
||||
|
||||
function read(object, snakeCaseName, camelCaseName) {
|
||||
if (object && Object.hasOwn(object, snakeCaseName)) {
|
||||
return object[snakeCaseName];
|
||||
}
|
||||
return object?.[camelCaseName];
|
||||
}
|
||||
|
||||
function validateNow(nowMs) {
|
||||
if (!Number.isSafeInteger(nowMs) || nowMs < 0) {
|
||||
throw new TypeError("nowMs must be a non-negative, safe epoch-millisecond integer");
|
||||
}
|
||||
}
|
||||
|
||||
function evaluateEntryEligibility(entry, asset, nowMs) {
|
||||
validateNow(nowMs);
|
||||
|
||||
const enabled = read(entry, "enabled", "enabled");
|
||||
if (enabled !== true && enabled !== 1) {
|
||||
return { playable: false, reason: ELIGIBILITY_REASONS.DISABLED };
|
||||
}
|
||||
|
||||
const scheduleStartAt = read(entry, "schedule_start_at", "scheduleStartAt");
|
||||
if (scheduleStartAt != null && nowMs < scheduleStartAt) {
|
||||
return { playable: false, reason: ELIGIBILITY_REASONS.SCHEDULED_FUTURE };
|
||||
}
|
||||
|
||||
const scheduleEndAt = read(entry, "schedule_end_at", "scheduleEndAt");
|
||||
if (scheduleEndAt != null && nowMs >= scheduleEndAt) {
|
||||
return { playable: false, reason: ELIGIBILITY_REASONS.SCHEDULED_ENDED };
|
||||
}
|
||||
|
||||
if (read(asset, "status", "status") !== "ready") {
|
||||
return { playable: false, reason: ELIGIBILITY_REASONS.ASSET_NOT_READY };
|
||||
}
|
||||
|
||||
const expirationAt = read(asset, "expiration_at", "expirationAt");
|
||||
if (expirationAt != null && nowMs >= expirationAt) {
|
||||
return { playable: false, reason: ELIGIBILITY_REASONS.ASSET_EXPIRED };
|
||||
}
|
||||
|
||||
return { playable: true, reason: null };
|
||||
}
|
||||
|
||||
function resolveEntrySettings(entry, appSettings) {
|
||||
const durationOverrideMs = read(
|
||||
entry,
|
||||
"duration_override_ms",
|
||||
"durationOverrideMs",
|
||||
);
|
||||
const backgroundModeOverride = read(
|
||||
entry,
|
||||
"background_mode_override",
|
||||
"backgroundModeOverride",
|
||||
);
|
||||
const backgroundColorOverride = read(
|
||||
entry,
|
||||
"background_color_override",
|
||||
"backgroundColorOverride",
|
||||
);
|
||||
const backgroundDarkenedOverride = read(
|
||||
entry,
|
||||
"background_darkened_override",
|
||||
"backgroundDarkenedOverride",
|
||||
);
|
||||
|
||||
return {
|
||||
durationMs:
|
||||
durationOverrideMs ??
|
||||
read(appSettings, "default_duration_ms", "defaultDurationMs"),
|
||||
presentation: {
|
||||
backgroundMode:
|
||||
backgroundModeOverride ??
|
||||
read(appSettings, "default_background_mode", "defaultBackgroundMode"),
|
||||
backgroundColor:
|
||||
backgroundColorOverride ??
|
||||
read(
|
||||
appSettings,
|
||||
"default_solid_background_color",
|
||||
"defaultSolidBackgroundColor",
|
||||
),
|
||||
backgroundDarkened: Boolean(
|
||||
backgroundDarkenedOverride ??
|
||||
read(
|
||||
appSettings,
|
||||
"default_background_darkened",
|
||||
"defaultBackgroundDarkened",
|
||||
),
|
||||
),
|
||||
blurPx: read(appSettings, "default_blur_px", "defaultBlurPx"),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
ELIGIBILITY_REASONS,
|
||||
evaluateEntryEligibility,
|
||||
resolveEntrySettings,
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
module.exports = require("./eligibility");
|
||||
@@ -0,0 +1,31 @@
|
||||
class PlaylistError extends Error {
|
||||
constructor(code, message, { statusCode = 400, details, cause } = {}) {
|
||||
super(message, cause ? { cause } : undefined);
|
||||
this.name = "PlaylistError";
|
||||
this.code = code;
|
||||
this.statusCode = statusCode;
|
||||
if (details !== undefined) {
|
||||
this.details = details;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function playlistNotFound(playlistId) {
|
||||
return new PlaylistError("PLAYLIST_NOT_FOUND", "Playlist not found", {
|
||||
statusCode: 404,
|
||||
details: { playlistId },
|
||||
});
|
||||
}
|
||||
|
||||
function playlistEntryNotFound(playlistId, entryId) {
|
||||
return new PlaylistError("PLAYLIST_ENTRY_NOT_FOUND", "Playlist entry not found", {
|
||||
statusCode: 404,
|
||||
details: { playlistId, entryId },
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
PlaylistError,
|
||||
playlistEntryNotFound,
|
||||
playlistNotFound,
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
const { createPlaylistRouter } = require("./routes");
|
||||
const { createPlaylistService } = require("./service");
|
||||
|
||||
module.exports = {
|
||||
createPlaylistRouter,
|
||||
createPlaylistService,
|
||||
};
|
||||
@@ -0,0 +1,136 @@
|
||||
const express = require("express");
|
||||
const { PlaylistError } = require("./errors");
|
||||
|
||||
function orderedEntryIds(body) {
|
||||
if (body === null || typeof body !== "object" || Array.isArray(body)) {
|
||||
throw new PlaylistError(
|
||||
"INVALID_PLAYLIST_ORDER",
|
||||
"Playlist order must be an object containing entryIds",
|
||||
);
|
||||
}
|
||||
const fields = Object.keys(body);
|
||||
if (fields.length !== 1 || fields[0] !== "entryIds") {
|
||||
throw new PlaylistError(
|
||||
"INVALID_PLAYLIST_ORDER",
|
||||
"Playlist order must contain only entryIds",
|
||||
{
|
||||
details: {
|
||||
fields,
|
||||
requiredFields: ["entryIds"],
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
return body.entryIds;
|
||||
}
|
||||
|
||||
function createPlaylistRouter(playlistService) {
|
||||
const router = express.Router();
|
||||
|
||||
router.get("/", (request, response, next) => {
|
||||
try {
|
||||
response.json({ playlists: playlistService.list() });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/", (request, response, next) => {
|
||||
try {
|
||||
response.status(201).json(playlistService.create(request.body));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.get("/:playlistId", (request, response, next) => {
|
||||
try {
|
||||
response.json(playlistService.get(request.params.playlistId));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.patch("/:playlistId", (request, response, next) => {
|
||||
try {
|
||||
response.json(
|
||||
playlistService.update(request.params.playlistId, request.body),
|
||||
);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.delete("/:playlistId", (request, response, next) => {
|
||||
try {
|
||||
response.json(playlistService.remove(request.params.playlistId));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/:playlistId/entries", (request, response, next) => {
|
||||
try {
|
||||
response
|
||||
.status(201)
|
||||
.json(playlistService.addEntry(request.params.playlistId, request.body));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.patch("/:playlistId/entries/:entryId", (request, response, next) => {
|
||||
try {
|
||||
response.json(
|
||||
playlistService.updateEntry(
|
||||
request.params.playlistId,
|
||||
request.params.entryId,
|
||||
request.body,
|
||||
),
|
||||
);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.delete("/:playlistId/entries/:entryId", (request, response, next) => {
|
||||
try {
|
||||
response.json(
|
||||
playlistService.removeEntry(
|
||||
request.params.playlistId,
|
||||
request.params.entryId,
|
||||
),
|
||||
);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.put("/:playlistId/entries/order", (request, response, next) => {
|
||||
try {
|
||||
response.json(
|
||||
playlistService.reorderEntries(
|
||||
request.params.playlistId,
|
||||
orderedEntryIds(request.body),
|
||||
),
|
||||
);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/:playlistId/activate", (request, response, next) => {
|
||||
try {
|
||||
response.json(playlistService.activate(request.params.playlistId));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createPlaylistRouter,
|
||||
orderedEntryIds,
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
@@ -53,13 +53,18 @@ function createRuntime({
|
||||
throw error;
|
||||
}
|
||||
let app;
|
||||
let io = null;
|
||||
try {
|
||||
app = createApp({
|
||||
config,
|
||||
database,
|
||||
imageProcessor,
|
||||
lifecycle,
|
||||
logger,
|
||||
migrations,
|
||||
onPlaylistsChanged(event) {
|
||||
io?.emit("playlists:changed", event);
|
||||
},
|
||||
storage,
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -68,7 +73,7 @@ function createRuntime({
|
||||
throw error;
|
||||
}
|
||||
const httpServer = http.createServer(app);
|
||||
const io = new SocketServer(httpServer, {
|
||||
io = new SocketServer(httpServer, {
|
||||
serveClient: false,
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
const express = require("express");
|
||||
|
||||
function createSettingsRouter(settingsService) {
|
||||
function createSettingsRouter(
|
||||
settingsService,
|
||||
{ onPlaylistsChanged = () => {} } = {},
|
||||
) {
|
||||
const router = express.Router();
|
||||
|
||||
router.get("/", (request, response) => {
|
||||
@@ -9,7 +12,16 @@ function createSettingsRouter(settingsService) {
|
||||
|
||||
router.patch("/", (request, response, next) => {
|
||||
try {
|
||||
response.json(settingsService.update(request.body));
|
||||
const previous = settingsService.get();
|
||||
const updated = settingsService.update(request.body);
|
||||
if (updated.revision !== previous.revision) {
|
||||
onPlaylistsChanged({
|
||||
reason: "settings_updated",
|
||||
playlistIds: null,
|
||||
settingsRevision: updated.revision,
|
||||
});
|
||||
}
|
||||
response.json(updated);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const {
|
||||
ELIGIBILITY_REASONS,
|
||||
evaluateEntryEligibility,
|
||||
resolveEntrySettings,
|
||||
} = require("../src/lifecycle");
|
||||
|
||||
const NOW = Date.parse("2035-05-06T07:08:09.000Z");
|
||||
|
||||
function readyAsset(overrides = {}) {
|
||||
return { status: "ready", expiration_at: null, ...overrides };
|
||||
}
|
||||
|
||||
function enabledEntry(overrides = {}) {
|
||||
return {
|
||||
enabled: 1,
|
||||
schedule_start_at: null,
|
||||
schedule_end_at: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test("an enabled, ready, permanent, unscheduled entry is playable", () => {
|
||||
assert.deepEqual(
|
||||
evaluateEntryEligibility(enabledEntry(), readyAsset(), NOW),
|
||||
{ playable: true, reason: null },
|
||||
);
|
||||
});
|
||||
|
||||
test("disabled entries are not playable", () => {
|
||||
assert.deepEqual(
|
||||
evaluateEntryEligibility(enabledEntry({ enabled: 0 }), readyAsset(), NOW),
|
||||
{ playable: false, reason: ELIGIBILITY_REASONS.DISABLED },
|
||||
);
|
||||
});
|
||||
|
||||
test("schedule start is inclusive", () => {
|
||||
const entry = enabledEntry({ schedule_start_at: NOW });
|
||||
|
||||
assert.deepEqual(evaluateEntryEligibility(entry, readyAsset(), NOW - 1), {
|
||||
playable: false,
|
||||
reason: ELIGIBILITY_REASONS.SCHEDULED_FUTURE,
|
||||
});
|
||||
assert.deepEqual(evaluateEntryEligibility(entry, readyAsset(), NOW), {
|
||||
playable: true,
|
||||
reason: null,
|
||||
});
|
||||
});
|
||||
|
||||
test("schedule end is exclusive", () => {
|
||||
const entry = enabledEntry({ schedule_end_at: NOW });
|
||||
|
||||
assert.deepEqual(evaluateEntryEligibility(entry, readyAsset(), NOW - 1), {
|
||||
playable: true,
|
||||
reason: null,
|
||||
});
|
||||
assert.deepEqual(evaluateEntryEligibility(entry, readyAsset(), NOW), {
|
||||
playable: false,
|
||||
reason: ELIGIBILITY_REASONS.SCHEDULED_ENDED,
|
||||
});
|
||||
});
|
||||
|
||||
test("start-only, end-only, and bounded schedules use UTC epoch milliseconds", () => {
|
||||
assert.equal(
|
||||
evaluateEntryEligibility(
|
||||
enabledEntry({ schedule_start_at: NOW - 1 }),
|
||||
readyAsset(),
|
||||
NOW,
|
||||
).playable,
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
evaluateEntryEligibility(
|
||||
enabledEntry({ schedule_end_at: NOW + 1 }),
|
||||
readyAsset(),
|
||||
NOW,
|
||||
).playable,
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
evaluateEntryEligibility(
|
||||
enabledEntry({ schedule_start_at: NOW, schedule_end_at: NOW + 1 }),
|
||||
readyAsset(),
|
||||
NOW,
|
||||
).playable,
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("only ready assets are playable", () => {
|
||||
for (const status of ["processing", "failed", "deleting"]) {
|
||||
assert.deepEqual(
|
||||
evaluateEntryEligibility(enabledEntry(), readyAsset({ status }), NOW),
|
||||
{ playable: false, reason: ELIGIBILITY_REASONS.ASSET_NOT_READY },
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("permanent assets stay playable and expiration is exclusive", () => {
|
||||
assert.equal(
|
||||
evaluateEntryEligibility(enabledEntry(), readyAsset(), NOW).playable,
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
evaluateEntryEligibility(
|
||||
enabledEntry(),
|
||||
readyAsset({ expiration_at: NOW + 1 }),
|
||||
NOW,
|
||||
).playable,
|
||||
true,
|
||||
);
|
||||
assert.deepEqual(
|
||||
evaluateEntryEligibility(
|
||||
enabledEntry(),
|
||||
readyAsset({ expiration_at: NOW }),
|
||||
NOW,
|
||||
),
|
||||
{ playable: false, reason: ELIGIBILITY_REASONS.ASSET_EXPIRED },
|
||||
);
|
||||
});
|
||||
|
||||
test("not-playable reasons follow the documented precedence", () => {
|
||||
const asset = readyAsset({ status: "failed", expiration_at: NOW });
|
||||
|
||||
assert.equal(
|
||||
evaluateEntryEligibility(
|
||||
enabledEntry({ enabled: 0, schedule_start_at: NOW + 1 }),
|
||||
asset,
|
||||
NOW,
|
||||
).reason,
|
||||
ELIGIBILITY_REASONS.DISABLED,
|
||||
);
|
||||
assert.equal(
|
||||
evaluateEntryEligibility(
|
||||
enabledEntry({ schedule_start_at: NOW + 1, schedule_end_at: NOW }),
|
||||
asset,
|
||||
NOW,
|
||||
).reason,
|
||||
ELIGIBILITY_REASONS.SCHEDULED_FUTURE,
|
||||
);
|
||||
assert.equal(
|
||||
evaluateEntryEligibility(enabledEntry({ schedule_end_at: NOW }), asset, NOW)
|
||||
.reason,
|
||||
ELIGIBILITY_REASONS.SCHEDULED_ENDED,
|
||||
);
|
||||
assert.equal(
|
||||
evaluateEntryEligibility(enabledEntry(), asset, NOW).reason,
|
||||
ELIGIBILITY_REASONS.ASSET_NOT_READY,
|
||||
);
|
||||
});
|
||||
|
||||
test("mapped camel-case domain values are supported", () => {
|
||||
assert.deepEqual(
|
||||
evaluateEntryEligibility(
|
||||
{ enabled: true, scheduleStartAt: NOW, scheduleEndAt: NOW + 1 },
|
||||
{ status: "ready", expirationAt: NOW + 1 },
|
||||
NOW,
|
||||
),
|
||||
{ playable: true, reason: null },
|
||||
);
|
||||
});
|
||||
|
||||
test("eligibility rejects invalid evaluation clocks", () => {
|
||||
for (const invalid of [-1, 1.5, Number.MAX_SAFE_INTEGER + 1, "2035-01-01"]) {
|
||||
assert.throws(
|
||||
() => evaluateEntryEligibility(enabledEntry(), readyAsset(), invalid),
|
||||
/nowMs must be a non-negative, safe epoch-millisecond integer/,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("resolved settings inherit global duration and presentation", () => {
|
||||
const settings = {
|
||||
default_duration_ms: 7_000,
|
||||
default_background_mode: "blurred",
|
||||
default_solid_background_color: "#123456",
|
||||
default_background_darkened: 1,
|
||||
default_blur_px: 20,
|
||||
};
|
||||
|
||||
assert.deepEqual(resolveEntrySettings({}, settings), {
|
||||
durationMs: 7_000,
|
||||
presentation: {
|
||||
backgroundMode: "blurred",
|
||||
backgroundColor: "#123456",
|
||||
backgroundDarkened: true,
|
||||
blurPx: 20,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("entry overrides are independent and blur always remains global", () => {
|
||||
const settings = {
|
||||
defaultDurationMs: 7_000,
|
||||
defaultBackgroundMode: "blurred",
|
||||
defaultSolidBackgroundColor: "#123456",
|
||||
defaultBackgroundDarkened: true,
|
||||
defaultBlurPx: 20,
|
||||
};
|
||||
const entry = {
|
||||
durationOverrideMs: 9_000,
|
||||
backgroundModeOverride: "solid",
|
||||
backgroundColorOverride: "#abcdef",
|
||||
backgroundDarkenedOverride: false,
|
||||
blurPx: 999,
|
||||
};
|
||||
|
||||
assert.deepEqual(resolveEntrySettings(entry, settings), {
|
||||
durationMs: 9_000,
|
||||
presentation: {
|
||||
backgroundMode: "solid",
|
||||
backgroundColor: "#abcdef",
|
||||
backgroundDarkened: false,
|
||||
blurPx: 20,
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,675 @@
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const crypto = require("node:crypto");
|
||||
const { openDatabase } = require("../src/database");
|
||||
const { runMigrations } = require("../src/database/migrate");
|
||||
const { createAssetService } = require("../src/assets/service");
|
||||
const {
|
||||
MAX_DURATION_MS,
|
||||
MIN_DURATION_MS,
|
||||
createPlaylistService,
|
||||
} = require("../src/playlists/service");
|
||||
|
||||
const BASE_TIME = 2_000_000_000_000;
|
||||
|
||||
function createHarness() {
|
||||
const database = openDatabase({ path: ":memory:" });
|
||||
runMigrations(database);
|
||||
let clock = BASE_TIME;
|
||||
let idCounter = 0;
|
||||
let assetCounter = 0;
|
||||
const changes = [];
|
||||
const service = createPlaylistService({
|
||||
database,
|
||||
now: () => clock,
|
||||
createId: () =>
|
||||
`00000000-0000-4000-8000-${String(++idCounter).padStart(12, "0")}`,
|
||||
onChanged: (change) => changes.push(change),
|
||||
});
|
||||
|
||||
function setNow(value) {
|
||||
clock = value;
|
||||
}
|
||||
|
||||
function seedAsset(options = {}) {
|
||||
const sequence = ++assetCounter;
|
||||
const id = options.id || `asset-${sequence}`;
|
||||
const name = options.name || id;
|
||||
const status = options.status || "ready";
|
||||
const expirationAt = options.expirationAt ?? null;
|
||||
const hash = crypto
|
||||
.createHash("sha256")
|
||||
.update(`${sequence}:${id}`)
|
||||
.digest("hex");
|
||||
database
|
||||
.prepare(
|
||||
`INSERT INTO assets (
|
||||
id, source_sha256, display_name, original_filename, status,
|
||||
expiration_at, display_sha256, display_width, display_height,
|
||||
thumbnail_sha256, thumbnail_width, thumbnail_height,
|
||||
created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, 1920, 1080, ?, 480, 270, ?, ?)`,
|
||||
)
|
||||
.run(
|
||||
id,
|
||||
hash,
|
||||
name,
|
||||
`${name}.png`,
|
||||
status,
|
||||
expirationAt,
|
||||
hash,
|
||||
hash,
|
||||
clock,
|
||||
clock,
|
||||
);
|
||||
return id;
|
||||
}
|
||||
|
||||
function playback() {
|
||||
return database
|
||||
.prepare("SELECT * FROM playback_state WHERE output_id = 'global'")
|
||||
.get();
|
||||
}
|
||||
|
||||
return { changes, database, playback, seedAsset, service, setNow };
|
||||
}
|
||||
|
||||
function expectError(action, code, statusCode) {
|
||||
assert.throws(action, (error) => {
|
||||
assert.equal(error.code, code);
|
||||
if (statusCode !== undefined) assert.equal(error.statusCode, statusCode);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
test("playlist CRUD uses UUIDs, case-insensitive names, revisions, and post-commit events", (t) => {
|
||||
const harness = createHarness();
|
||||
t.after(() => harness.database.close());
|
||||
|
||||
const sunday = harness.service.create({ name: " Sunday Service " });
|
||||
const midweek = harness.service.create({ name: "Midweek" });
|
||||
assert.match(sunday.id, /^[0-9a-f-]{36}$/);
|
||||
assert.equal(sunday.name, "Sunday Service");
|
||||
assert.equal(sunday.revision, 1);
|
||||
assert.deepEqual(
|
||||
harness.service.list().map((playlist) => playlist.name),
|
||||
["Midweek", "Sunday Service"],
|
||||
);
|
||||
|
||||
const revisionBeforeRead = harness.service.list()[1].revision;
|
||||
const changesBeforeRead = harness.changes.length;
|
||||
assert.equal(harness.service.get(sunday.id).entries.length, 0);
|
||||
assert.equal(harness.service.list()[1].revision, revisionBeforeRead);
|
||||
assert.equal(harness.changes.length, changesBeforeRead);
|
||||
|
||||
const renamed = harness.service.update(sunday.id, { name: "Sunday Worship" });
|
||||
assert.equal(renamed.revision, 2);
|
||||
assert.equal(harness.service.update(sunday.id, { name: "Sunday Worship" }).revision, 2);
|
||||
expectError(
|
||||
() => harness.service.create({ name: "sUnDaY wOrShIp" }),
|
||||
"PLAYLIST_NAME_CONFLICT",
|
||||
409,
|
||||
);
|
||||
expectError(
|
||||
() => harness.service.update(midweek.id, { name: "SUNDAY WORSHIP" }),
|
||||
"PLAYLIST_NAME_CONFLICT",
|
||||
409,
|
||||
);
|
||||
assert.equal(harness.service.get(midweek.id).revision, 1);
|
||||
|
||||
assert.deepEqual(harness.service.remove(midweek.id), {
|
||||
id: midweek.id,
|
||||
deleted: true,
|
||||
});
|
||||
expectError(
|
||||
() => harness.service.get(midweek.id),
|
||||
"PLAYLIST_NOT_FOUND",
|
||||
404,
|
||||
);
|
||||
assert.deepEqual(
|
||||
harness.changes.map((change) => change.reason),
|
||||
["created", "created", "renamed", "deleted"],
|
||||
);
|
||||
});
|
||||
|
||||
test("active and pending playlists are conservatively protected from deletion", (t) => {
|
||||
const harness = createHarness();
|
||||
t.after(() => harness.database.close());
|
||||
const active = harness.service.create({ name: "Active" });
|
||||
const pending = harness.service.create({ name: "Pending" });
|
||||
|
||||
harness.database
|
||||
.prepare(
|
||||
`UPDATE playback_state
|
||||
SET active_playlist_id = ?, pending_playlist_id = ?,
|
||||
pending_playlist_requested_at = ?, updated_at = ?
|
||||
WHERE output_id = 'global'`,
|
||||
)
|
||||
.run(active.id, pending.id, BASE_TIME, BASE_TIME);
|
||||
|
||||
expectError(() => harness.service.remove(active.id), "PLAYLIST_ACTIVE", 409);
|
||||
expectError(
|
||||
() => harness.service.remove(pending.id),
|
||||
"PLAYLIST_PENDING_ACTIVATION",
|
||||
409,
|
||||
);
|
||||
assert.equal(harness.service.list().length, 2);
|
||||
});
|
||||
|
||||
test("entry adds require a ready, unexpired asset and enforce one occurrence per playlist", (t) => {
|
||||
const harness = createHarness();
|
||||
t.after(() => harness.database.close());
|
||||
const first = harness.service.create({ name: "First" });
|
||||
const second = harness.service.create({ name: "Second" });
|
||||
const ready = harness.seedAsset({ id: "ready", name: "Ready" });
|
||||
for (const status of ["processing", "failed", "deleting"]) {
|
||||
harness.seedAsset({ id: status, name: status, status });
|
||||
}
|
||||
harness.seedAsset({
|
||||
id: "expired",
|
||||
name: "Expired",
|
||||
expirationAt: BASE_TIME,
|
||||
});
|
||||
|
||||
const firstEntry = harness.service.addEntry(first.id, { assetId: ready });
|
||||
const secondEntry = harness.service.addEntry(second.id, { assetId: ready });
|
||||
assert.equal(firstEntry.position, 0);
|
||||
assert.equal(secondEntry.position, 0);
|
||||
assert.notEqual(firstEntry.id, secondEntry.id);
|
||||
expectError(
|
||||
() => harness.service.addEntry(first.id, { assetId: ready }),
|
||||
"PLAYLIST_ENTRY_EXISTS",
|
||||
409,
|
||||
);
|
||||
for (const status of ["processing", "failed", "deleting"]) {
|
||||
expectError(
|
||||
() => harness.service.addEntry(first.id, { assetId: status }),
|
||||
"ASSET_NOT_READY",
|
||||
409,
|
||||
);
|
||||
}
|
||||
expectError(
|
||||
() => harness.service.addEntry(first.id, { assetId: "missing" }),
|
||||
"ASSET_NOT_FOUND",
|
||||
404,
|
||||
);
|
||||
expectError(
|
||||
() => harness.service.addEntry(first.id, { assetId: "expired" }),
|
||||
"ASSET_EXPIRED",
|
||||
409,
|
||||
);
|
||||
assert.equal(harness.service.get(first.id).revision, 2);
|
||||
});
|
||||
|
||||
test("entry enablement, duration, and presentation overrides are playlist-local", (t) => {
|
||||
const harness = createHarness();
|
||||
t.after(() => harness.database.close());
|
||||
const a = harness.service.create({ name: "A" });
|
||||
const b = harness.service.create({ name: "B" });
|
||||
const assetId = harness.seedAsset({ id: "shared", name: "Shared" });
|
||||
const entryA = harness.service.addEntry(a.id, { assetId });
|
||||
const entryB = harness.service.addEntry(b.id, { assetId });
|
||||
|
||||
assert.equal(entryA.durationOverrideMs, null);
|
||||
assert.equal(entryA.effectiveDurationMs, 7000);
|
||||
assert.deepEqual(entryA.effectivePresentation, {
|
||||
backgroundMode: "blurred",
|
||||
backgroundColor: "#000000",
|
||||
backgroundDarkened: false,
|
||||
blurPx: 20,
|
||||
});
|
||||
|
||||
const patched = harness.service.updateEntry(a.id, entryA.id, {
|
||||
enabled: false,
|
||||
durationOverrideMs: 4000,
|
||||
backgroundModeOverride: "solid",
|
||||
backgroundColorOverride: "#a1b2c3",
|
||||
backgroundDarkenedOverride: true,
|
||||
});
|
||||
assert.equal(patched.enabled, false);
|
||||
assert.equal(patched.effectiveDurationMs, 4000);
|
||||
assert.deepEqual(patched.effectivePresentation, {
|
||||
backgroundMode: "solid",
|
||||
backgroundColor: "#A1B2C3",
|
||||
backgroundDarkened: true,
|
||||
blurPx: 20,
|
||||
});
|
||||
assert.equal(harness.service.get(b.id).entries[0].enabled, true);
|
||||
assert.equal(harness.service.get(b.id).entries[0].effectiveDurationMs, 7000);
|
||||
assert.equal(
|
||||
harness.database.prepare("SELECT status FROM assets WHERE id = ?").get(assetId)
|
||||
.status,
|
||||
"ready",
|
||||
);
|
||||
|
||||
const inheritedAgain = harness.service.updateEntry(a.id, entryA.id, {
|
||||
durationOverrideMs: null,
|
||||
backgroundModeOverride: null,
|
||||
backgroundColorOverride: null,
|
||||
backgroundDarkenedOverride: null,
|
||||
});
|
||||
assert.equal(inheritedAgain.effectiveDurationMs, 7000);
|
||||
assert.equal(inheritedAgain.backgroundDarkenedOverride, null);
|
||||
|
||||
for (const invalid of [MIN_DURATION_MS - 1, MAX_DURATION_MS + 1, 1.5]) {
|
||||
expectError(
|
||||
() =>
|
||||
harness.service.updateEntry(a.id, entryA.id, {
|
||||
durationOverrideMs: invalid,
|
||||
}),
|
||||
"INVALID_PLAYLIST_ENTRY",
|
||||
400,
|
||||
);
|
||||
}
|
||||
for (const patch of [
|
||||
{ backgroundModeOverride: "gradient" },
|
||||
{ backgroundColorOverride: "red" },
|
||||
{ backgroundDarkenedOverride: 1 },
|
||||
]) {
|
||||
expectError(
|
||||
() => harness.service.updateEntry(a.id, entryA.id, patch),
|
||||
"INVALID_PLAYLIST_ENTRY",
|
||||
400,
|
||||
);
|
||||
}
|
||||
assert.equal(harness.service.get(a.id).revision, 4);
|
||||
assert.equal(harness.service.get(b.id).revision, 2);
|
||||
assert.equal(entryB.enabled, true);
|
||||
});
|
||||
|
||||
test("resolved entries centralize schedule and expiration eligibility at exact boundaries", (t) => {
|
||||
const harness = createHarness();
|
||||
t.after(() => harness.database.close());
|
||||
const playlist = harness.service.create({ name: "Scheduled" });
|
||||
const permanent = harness.seedAsset({ id: "permanent", name: "Permanent" });
|
||||
const futureExpiry = harness.seedAsset({
|
||||
id: "future-expiry",
|
||||
name: "Future expiry",
|
||||
expirationAt: BASE_TIME + 20_000,
|
||||
});
|
||||
const noSchedule = harness.service.addEntry(playlist.id, { assetId: permanent });
|
||||
const bounded = harness.service.addEntry(playlist.id, {
|
||||
assetId: futureExpiry,
|
||||
scheduleStartAt: BASE_TIME + 10_000,
|
||||
scheduleEndAt: BASE_TIME + 30_000,
|
||||
});
|
||||
|
||||
let detail = harness.service.get(playlist.id);
|
||||
assert.equal(detail.evaluatedAt, BASE_TIME);
|
||||
assert.equal(detail.settingsRevision, 1);
|
||||
assert.deepEqual(
|
||||
detail.entries.map((entry) => [entry.id, entry.playable, entry.notPlayableReason]),
|
||||
[
|
||||
[noSchedule.id, true, null],
|
||||
[bounded.id, false, "scheduled_future"],
|
||||
],
|
||||
);
|
||||
|
||||
harness.setNow(BASE_TIME + 10_000);
|
||||
assert.equal(harness.service.get(playlist.id).entries[1].playable, true);
|
||||
harness.setNow(BASE_TIME + 20_000);
|
||||
assert.equal(
|
||||
harness.service.get(playlist.id).entries[1].notPlayableReason,
|
||||
"asset_expired",
|
||||
);
|
||||
harness.setNow(BASE_TIME + 30_000);
|
||||
assert.equal(
|
||||
harness.service.get(playlist.id).entries[1].notPlayableReason,
|
||||
"scheduled_ended",
|
||||
);
|
||||
|
||||
harness.setNow(BASE_TIME);
|
||||
const endOnlyAsset = harness.seedAsset({ id: "end-only", name: "End only" });
|
||||
const endOnly = harness.service.addEntry(playlist.id, {
|
||||
assetId: endOnlyAsset,
|
||||
scheduleEndAt: BASE_TIME + 1,
|
||||
});
|
||||
assert.equal(endOnly.playable, true);
|
||||
harness.setNow(BASE_TIME + 1);
|
||||
assert.equal(
|
||||
harness.service.get(playlist.id).entries.find((entry) => entry.id === endOnly.id)
|
||||
.notPlayableReason,
|
||||
"scheduled_ended",
|
||||
);
|
||||
|
||||
expectError(
|
||||
() =>
|
||||
harness.service.updateEntry(playlist.id, noSchedule.id, {
|
||||
scheduleStartAt: BASE_TIME + 2,
|
||||
scheduleEndAt: BASE_TIME + 1,
|
||||
}),
|
||||
"INVALID_PLAYLIST_ENTRY_SCHEDULE",
|
||||
400,
|
||||
);
|
||||
expectError(
|
||||
() =>
|
||||
harness.service.updateEntry(playlist.id, noSchedule.id, {
|
||||
scheduleStartAt: "2033-05-18T03:33:20",
|
||||
}),
|
||||
"INVALID_PLAYLIST_ENTRY",
|
||||
400,
|
||||
);
|
||||
const iso = harness.service.updateEntry(playlist.id, noSchedule.id, {
|
||||
scheduleStartAt: "2033-05-18T03:33:20Z",
|
||||
});
|
||||
assert.equal(iso.scheduleStartAt, 2_000_000_000_000);
|
||||
});
|
||||
|
||||
test("full-list reorder validates membership and persists contiguous zero-based positions", (t) => {
|
||||
const harness = createHarness();
|
||||
t.after(() => harness.database.close());
|
||||
const playlist = harness.service.create({ name: "Ordered" });
|
||||
const other = harness.service.create({ name: "Other" });
|
||||
const entries = ["one", "two", "three"].map((id) =>
|
||||
harness.service.addEntry(playlist.id, {
|
||||
assetId: harness.seedAsset({ id, name: id }),
|
||||
}),
|
||||
);
|
||||
const foreign = harness.service.addEntry(other.id, {
|
||||
assetId: harness.seedAsset({ id: "foreign", name: "foreign" }),
|
||||
});
|
||||
const revision = harness.service.get(playlist.id).revision;
|
||||
|
||||
const reordered = harness.service.reorderEntries(
|
||||
playlist.id,
|
||||
[entries[2].id, entries[0].id, entries[1].id],
|
||||
);
|
||||
assert.deepEqual(
|
||||
reordered.entries.map((entry) => [entry.id, entry.position]),
|
||||
[
|
||||
[entries[2].id, 0],
|
||||
[entries[0].id, 1],
|
||||
[entries[1].id, 2],
|
||||
],
|
||||
);
|
||||
assert.equal(reordered.revision, revision + 1);
|
||||
|
||||
const failedRevision = reordered.revision;
|
||||
expectError(
|
||||
() =>
|
||||
harness.service.reorderEntries(playlist.id, [
|
||||
entries[2].id,
|
||||
entries[2].id,
|
||||
entries[1].id,
|
||||
]),
|
||||
"PLAYLIST_ORDER_DUPLICATE_IDS",
|
||||
);
|
||||
expectError(
|
||||
() => harness.service.reorderEntries(playlist.id, [entries[2].id]),
|
||||
"PLAYLIST_ORDER_INCOMPLETE",
|
||||
);
|
||||
expectError(
|
||||
() =>
|
||||
harness.service.reorderEntries(playlist.id, [
|
||||
entries[2].id,
|
||||
entries[0].id,
|
||||
foreign.id,
|
||||
]),
|
||||
"PLAYLIST_ORDER_FOREIGN_ENTRY",
|
||||
);
|
||||
expectError(
|
||||
() =>
|
||||
harness.service.reorderEntries(playlist.id, [
|
||||
entries[2].id,
|
||||
entries[0].id,
|
||||
"missing-entry",
|
||||
]),
|
||||
"PLAYLIST_ORDER_UNKNOWN_ENTRY",
|
||||
);
|
||||
assert.equal(harness.service.get(playlist.id).revision, failedRevision);
|
||||
|
||||
harness.service.removeEntry(playlist.id, entries[0].id);
|
||||
assert.deepEqual(
|
||||
harness.service.get(playlist.id).entries.map((entry) => entry.position),
|
||||
[0, 1],
|
||||
);
|
||||
});
|
||||
|
||||
test("no-op and failed mutations do not increment revisions or emit invalidations", (t) => {
|
||||
const harness = createHarness();
|
||||
t.after(() => harness.database.close());
|
||||
const playlist = harness.service.create({ name: "Stable" });
|
||||
const entry = harness.service.addEntry(playlist.id, {
|
||||
assetId: harness.seedAsset({ id: "stable-asset", name: "Stable asset" }),
|
||||
});
|
||||
const baseline = harness.service.get(playlist.id).revision;
|
||||
const eventCount = harness.changes.length;
|
||||
|
||||
harness.service.update(playlist.id, { name: "Stable" });
|
||||
harness.service.updateEntry(playlist.id, entry.id, { enabled: true });
|
||||
harness.service.reorderEntries(playlist.id, [entry.id]);
|
||||
expectError(
|
||||
() => harness.service.updateEntry(playlist.id, "missing", { enabled: false }),
|
||||
"PLAYLIST_ENTRY_NOT_FOUND",
|
||||
404,
|
||||
);
|
||||
assert.equal(harness.service.get(playlist.id).revision, baseline);
|
||||
assert.equal(harness.changes.length, eventCount);
|
||||
});
|
||||
|
||||
test("entry references protect assets and final removal restores delete eligibility", (t) => {
|
||||
const harness = createHarness();
|
||||
t.after(() => harness.database.close());
|
||||
const playlist = harness.service.create({ name: "References" });
|
||||
const assetId = harness.seedAsset({ id: "referenced", name: "Referenced" });
|
||||
const entry = harness.service.addEntry(playlist.id, { assetId, enabled: false });
|
||||
const assetService = createAssetService({
|
||||
database: harness.database,
|
||||
storage: {},
|
||||
imageProcessor: {},
|
||||
});
|
||||
|
||||
const referenced = assetService.references(assetId);
|
||||
assert.equal(referenced.canDelete, false);
|
||||
assert.equal(referenced.playlists[0].playlistEntryId, entry.id);
|
||||
harness.service.removeEntry(playlist.id, entry.id);
|
||||
assert.equal(assetService.references(assetId).canDelete, true);
|
||||
});
|
||||
|
||||
test("activation queues at a live boundary, is idempotent, and mode off is only a gate", (t) => {
|
||||
const harness = createHarness();
|
||||
t.after(() => harness.database.close());
|
||||
const oldPlaylist = harness.service.create({ name: "Old" });
|
||||
const newPlaylist = harness.service.create({ name: "New" });
|
||||
const oldEntry = harness.service.addEntry(oldPlaylist.id, {
|
||||
assetId: harness.seedAsset({ id: "old-asset", name: "Old asset" }),
|
||||
});
|
||||
const newEntry = harness.service.addEntry(newPlaylist.id, {
|
||||
assetId: harness.seedAsset({ id: "new-asset", name: "New asset" }),
|
||||
durationOverrideMs: 4000,
|
||||
});
|
||||
|
||||
const initial = harness.service.activate(oldPlaylist.id);
|
||||
assert.equal(initial.activation, "active");
|
||||
assert.equal(initial.playback.currentEntryId, oldEntry.id);
|
||||
assert.equal(initial.playback.slideStartedAt, BASE_TIME);
|
||||
assert.equal(initial.playback.nextSlideAt, BASE_TIME + 7000);
|
||||
|
||||
harness.database
|
||||
.prepare("UPDATE playback_state SET mode = 'off' WHERE output_id = 'global'")
|
||||
.run();
|
||||
harness.setNow(BASE_TIME + 1000);
|
||||
const pending = harness.service.activate(newPlaylist.id);
|
||||
assert.equal(pending.activation, "pending");
|
||||
assert.equal(pending.playback.mode, "off");
|
||||
assert.equal(pending.playback.activePlaylistId, oldPlaylist.id);
|
||||
assert.equal(pending.playback.pendingPlaylistId, newPlaylist.id);
|
||||
assert.equal(pending.playback.currentEntryId, oldEntry.id);
|
||||
const pendingRevision = pending.playback.revision;
|
||||
const requestedAt = pending.playback.pendingPlaylistRequestedAt;
|
||||
|
||||
const repeated = harness.service.activate(newPlaylist.id);
|
||||
assert.equal(repeated.activation, "pending");
|
||||
assert.equal(repeated.playback.revision, pendingRevision);
|
||||
assert.equal(repeated.playback.pendingPlaylistRequestedAt, requestedAt);
|
||||
|
||||
const cancel = harness.service.activate(oldPlaylist.id);
|
||||
assert.equal(cancel.activation, "already_active");
|
||||
assert.equal(cancel.playback.pendingPlaylistId, null);
|
||||
assert.equal(cancel.playback.currentEntryId, oldEntry.id);
|
||||
|
||||
harness.setNow(BASE_TIME + 7000);
|
||||
const immediate = harness.service.activate(newPlaylist.id);
|
||||
assert.equal(immediate.activation, "active");
|
||||
assert.equal(immediate.playback.activePlaylistId, newPlaylist.id);
|
||||
assert.equal(immediate.playback.pendingPlaylistId, null);
|
||||
assert.equal(immediate.playback.currentEntryId, newEntry.id);
|
||||
assert.equal(immediate.playback.slideStartedAt, BASE_TIME + 7000);
|
||||
assert.equal(immediate.playback.nextSlideAt, BASE_TIME + 11_000);
|
||||
assert.equal(immediate.playback.mode, "off");
|
||||
});
|
||||
|
||||
test("paused normal playback queues and can replace a pending activation", (t) => {
|
||||
const harness = createHarness();
|
||||
t.after(() => harness.database.close());
|
||||
const current = harness.service.create({ name: "Paused current" });
|
||||
const firstTarget = harness.service.create({ name: "First target" });
|
||||
const secondTarget = harness.service.create({ name: "Second target" });
|
||||
harness.service.addEntry(current.id, {
|
||||
assetId: harness.seedAsset({ id: "paused-current", name: "Paused current" }),
|
||||
});
|
||||
harness.service.addEntry(firstTarget.id, {
|
||||
assetId: harness.seedAsset({ id: "first-target", name: "First target" }),
|
||||
});
|
||||
harness.service.addEntry(secondTarget.id, {
|
||||
assetId: harness.seedAsset({ id: "second-target", name: "Second target" }),
|
||||
});
|
||||
harness.service.activate(current.id);
|
||||
harness.database
|
||||
.prepare(
|
||||
`UPDATE playback_state
|
||||
SET rotation_paused = 1, paused_remaining_ms = 5000,
|
||||
slide_started_at = NULL, next_slide_at = NULL
|
||||
WHERE output_id = 'global'`,
|
||||
)
|
||||
.run();
|
||||
|
||||
harness.setNow(BASE_TIME + 1000);
|
||||
const first = harness.service.activate(firstTarget.id);
|
||||
assert.equal(first.activation, "pending");
|
||||
assert.equal(first.playback.activePlaylistId, current.id);
|
||||
assert.equal(first.playback.pendingPlaylistId, firstTarget.id);
|
||||
assert.equal(first.playback.pausedRemainingMs, 5000);
|
||||
|
||||
harness.setNow(BASE_TIME + 2000);
|
||||
const replacement = harness.service.activate(secondTarget.id);
|
||||
assert.equal(replacement.activation, "pending");
|
||||
assert.equal(replacement.playback.activePlaylistId, current.id);
|
||||
assert.equal(replacement.playback.pendingPlaylistId, secondTarget.id);
|
||||
assert.equal(replacement.playback.pendingPlaylistRequestedAt, BASE_TIME + 2000);
|
||||
assert.equal(replacement.playback.revision, first.playback.revision + 1);
|
||||
assert.equal(replacement.playback.pausedRemainingMs, 5000);
|
||||
});
|
||||
|
||||
test("static and paused activation prepare the first playable normal target without breaking hold", (t) => {
|
||||
const harness = createHarness();
|
||||
t.after(() => harness.database.close());
|
||||
const oldPlaylist = harness.service.create({ name: "Old static" });
|
||||
const target = harness.service.create({ name: "Target static" });
|
||||
const staticAssetId = harness.seedAsset({ id: "static", name: "Static" });
|
||||
harness.service.addEntry(oldPlaylist.id, {
|
||||
assetId: harness.seedAsset({ id: "old-normal", name: "Old normal" }),
|
||||
});
|
||||
const skipped = harness.service.addEntry(target.id, {
|
||||
assetId: harness.seedAsset({ id: "disabled", name: "Disabled" }),
|
||||
enabled: false,
|
||||
});
|
||||
const firstPlayable = harness.service.addEntry(target.id, {
|
||||
assetId: harness.seedAsset({ id: "playable", name: "Playable" }),
|
||||
durationOverrideMs: 9000,
|
||||
});
|
||||
assert.equal(skipped.position, 0);
|
||||
harness.service.activate(oldPlaylist.id);
|
||||
harness.database
|
||||
.prepare(
|
||||
`UPDATE playback_state
|
||||
SET static_asset_id = ?, rotation_paused = 1,
|
||||
paused_remaining_ms = 1234, slide_started_at = NULL,
|
||||
next_slide_at = NULL
|
||||
WHERE output_id = 'global'`,
|
||||
)
|
||||
.run(staticAssetId);
|
||||
|
||||
const result = harness.service.activate(target.id);
|
||||
assert.equal(result.activation, "active");
|
||||
assert.equal(result.playback.activePlaylistId, target.id);
|
||||
assert.equal(result.playback.staticAssetId, staticAssetId);
|
||||
assert.equal(result.playback.rotationPaused, true);
|
||||
assert.equal(result.playback.currentEntryId, firstPlayable.id);
|
||||
assert.equal(result.playback.pausedRemainingMs, 9000);
|
||||
assert.equal(result.playback.slideStartedAt, null);
|
||||
assert.equal(result.playback.nextSlideAt, null);
|
||||
});
|
||||
|
||||
test("empty activation is immediate and current-entry deletion coordinates playback revision safely", (t) => {
|
||||
const harness = createHarness();
|
||||
t.after(() => harness.database.close());
|
||||
const populated = harness.service.create({ name: "Populated" });
|
||||
const empty = harness.service.create({ name: "Empty" });
|
||||
const entries = ["first", "second"].map((id) =>
|
||||
harness.service.addEntry(populated.id, {
|
||||
assetId: harness.seedAsset({ id: `asset-${id}`, name: id }),
|
||||
}),
|
||||
);
|
||||
harness.service.activate(populated.id);
|
||||
const futurePlaybackTimestamp = BASE_TIME + 100_000;
|
||||
harness.database
|
||||
.prepare(
|
||||
`UPDATE playback_state
|
||||
SET updated_at = ?, mode = 'off', static_asset_id = ?, rotation_paused = 1
|
||||
WHERE output_id = 'global'`,
|
||||
)
|
||||
.run(futurePlaybackTimestamp, "asset-second");
|
||||
const beforeDelete = harness.playback();
|
||||
|
||||
harness.service.removeEntry(populated.id, entries[0].id);
|
||||
const afterDelete = harness.playback();
|
||||
assert.equal(afterDelete.current_entry_id, null);
|
||||
assert.equal(afterDelete.slide_started_at, null);
|
||||
assert.equal(afterDelete.next_slide_at, null);
|
||||
assert.equal(afterDelete.paused_remaining_ms, null);
|
||||
assert.equal(afterDelete.active_playlist_id, populated.id);
|
||||
assert.equal(afterDelete.mode, "off");
|
||||
assert.equal(afterDelete.static_asset_id, "asset-second");
|
||||
assert.equal(afterDelete.rotation_paused, 1);
|
||||
assert.equal(afterDelete.revision, beforeDelete.revision + 1);
|
||||
assert.equal(afterDelete.updated_at, futurePlaybackTimestamp + 1);
|
||||
assert.deepEqual(
|
||||
harness.service.get(populated.id).entries.map((entry) => entry.position),
|
||||
[0],
|
||||
);
|
||||
|
||||
harness.database
|
||||
.prepare(
|
||||
`UPDATE playback_state
|
||||
SET static_asset_id = NULL, rotation_paused = 0
|
||||
WHERE output_id = 'global'`,
|
||||
)
|
||||
.run();
|
||||
const result = harness.service.activate(empty.id);
|
||||
assert.equal(result.activation, "active");
|
||||
assert.equal(result.playback.activePlaylistId, empty.id);
|
||||
assert.equal(result.playback.currentEntryId, null);
|
||||
assert.equal(result.playback.slideStartedAt, null);
|
||||
assert.equal(result.playback.nextSlideAt, null);
|
||||
});
|
||||
|
||||
test("inactive playlist edits do not mutate authoritative playback state", (t) => {
|
||||
const harness = createHarness();
|
||||
t.after(() => harness.database.close());
|
||||
const active = harness.service.create({ name: "Live" });
|
||||
const inactive = harness.service.create({ name: "Draft" });
|
||||
harness.service.addEntry(active.id, {
|
||||
assetId: harness.seedAsset({ id: "live", name: "Live asset" }),
|
||||
});
|
||||
const draftEntry = harness.service.addEntry(inactive.id, {
|
||||
assetId: harness.seedAsset({ id: "draft", name: "Draft asset" }),
|
||||
});
|
||||
harness.service.activate(active.id);
|
||||
const before = harness.playback();
|
||||
|
||||
harness.service.update(inactive.id, { name: "Draft edited" });
|
||||
harness.service.updateEntry(inactive.id, draftEntry.id, { enabled: false });
|
||||
harness.service.reorderEntries(inactive.id, [draftEntry.id]);
|
||||
assert.deepEqual(harness.playback(), before);
|
||||
});
|
||||
@@ -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