Release 2.22.0: normalize playlist schedules and unify player sync
This commit is contained in:
1 parent
7748129307
commit
b71958cb38
39 files changed
+1174
-314
No files matched your search
@@ -248,3 +248,54 @@ test('client command route uses the bridge device route without a public player
|
||||
payload: { screenSlug: 'lobby', command: 'pause', connectionId: 'connection-1' }
|
||||
}]);
|
||||
});
|
||||
|
||||
test('client command route sends registered local players directly without querying bridge connections', async () => {
|
||||
const { app, handlers } = createAppHarness();
|
||||
const calls = [];
|
||||
registerScreenCommandRoutes(app, {
|
||||
pool: {
|
||||
async query(sql) {
|
||||
if (String(sql).includes('SELECT id, name, slug FROM d_screens WHERE slug = ?')) {
|
||||
return [[{ id: 3, name: 'Test', slug: 'test' }]];
|
||||
}
|
||||
return [[]];
|
||||
}
|
||||
},
|
||||
common: {
|
||||
async fetchPlayerRegistrations() {
|
||||
return [{ identifier: 'player-local', public_base_url: 'http://localhost:8081', internal_base_url: 'http://player:8081' }];
|
||||
}
|
||||
},
|
||||
forwardPlayerCommand() { throw new Error('default player route should not be used'); },
|
||||
forwardPlayerCommandToBaseUrl(baseUrl, slug, payload, connectionId) {
|
||||
calls.push({ baseUrl, slug, payload, connectionId });
|
||||
return { ok: true, sent: 1 };
|
||||
},
|
||||
forwardPlayerCommandToDevice() { throw new Error('bridge device route should not be used'); },
|
||||
getScreenConnections() { throw new Error('bridge connection lookup should not be used'); },
|
||||
isClientNameAvailable() { return true; },
|
||||
withClientNameReservation() {},
|
||||
broadcastDashboardState() {},
|
||||
requirePermission() { return function (_req, _res, next) { next(); }; }
|
||||
});
|
||||
|
||||
const response = {
|
||||
statusCode: 200,
|
||||
body: null,
|
||||
status(code) { this.statusCode = code; return this; },
|
||||
json(payload) { this.body = payload; return this; }
|
||||
};
|
||||
await handlers['/clients/:slug/commands'][1]({
|
||||
params: { slug: 'test' },
|
||||
body: { command: 'pause', connectionId: 'client-local', playerBaseUrl: 'http://localhost:8081' },
|
||||
query: {}
|
||||
}, response, () => {});
|
||||
|
||||
assert.equal(response.statusCode, 200);
|
||||
assert.deepEqual(calls, [{
|
||||
baseUrl: 'http://player:8081',
|
||||
slug: 'test',
|
||||
payload: { command: 'pause', connectionId: 'client-local', playerBaseUrl: 'http://localhost:8081' },
|
||||
connectionId: 'client-local'
|
||||
}]);
|
||||
});
|
||||
+27
-1
@@ -1,7 +1,7 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const { fetchSlidesPage } = require('../src/data/admin');
|
||||
const { fetchAdminData, fetchSlidesPage } = require('../src/data/admin');
|
||||
|
||||
test('slide search does not include slide content_json', async () => {
|
||||
const queries = [];
|
||||
@@ -22,4 +22,30 @@ test('slide search does not include slide content_json', async () => {
|
||||
assert.doesNotMatch(queries[1].sql, /CAST\(s\.content_json AS CHAR\)/);
|
||||
assert.match(queries[0].sql, /CAST\(s\.title AS CHAR\)/);
|
||||
assert.match(queries[0].sql, /CAST\(st\.name AS CHAR\)/);
|
||||
});
|
||||
|
||||
test('admin playlist data attaches normalized slide and group rules by owner ID', async () => {
|
||||
const pool = {
|
||||
async query(sql) {
|
||||
if (sql.includes('FROM c_playlist_slides ps')) {
|
||||
return [[
|
||||
{ id: 12, playlist_id: 3, slide_id: 22, group_id: 5, group_key: 'events', group_name: 'Events', group_pick_count: 1, group_shuffle: 0, group_mode: 'select', group_repeat_count: 1 },
|
||||
{ id: 13, playlist_id: 3, slide_id: 23, group_id: 5, group_key: 'events', group_name: 'Events', group_pick_count: 1, group_shuffle: 0, group_mode: 'select', group_repeat_count: 1 }
|
||||
]];
|
||||
}
|
||||
if (sql.includes('FROM c_playlist_schedule_rules')) {
|
||||
return [[
|
||||
{ id: 70, playlist_slide_id: 12, playlist_group_id: null, start_datetime: '2026-10-01 09:00:00', end_datetime: null, schedule_days_json: null },
|
||||
{ id: 71, playlist_slide_id: null, playlist_group_id: 5, start_datetime: null, end_datetime: '2026-10-31 18:00:00', schedule_days_json: '[3]' }
|
||||
]];
|
||||
}
|
||||
return [[]];
|
||||
}
|
||||
};
|
||||
|
||||
const data = await fetchAdminData(pool);
|
||||
assert.equal(data.playlistSlides[0].scheduleRules[0].id, 70);
|
||||
assert.equal(data.playlistSlides[0].groupScheduleRules[0].id, 71);
|
||||
assert.equal(data.playlistSlides[1].scheduleRules.length, 0);
|
||||
assert.equal(data.playlistSlides[1].groupScheduleRules[0].id, 71);
|
||||
});
|
||||
@@ -4,6 +4,7 @@ const assert = require('node:assert/strict');
|
||||
require('../src/common');
|
||||
|
||||
const { registerInitialFontSyncTask } = require('../src/web/lib/background-tasks/tasks-startup/font-sync');
|
||||
const { registerFontSyncTask } = require('../src/web/lib/background-tasks/tasks-adhoc/font-sync');
|
||||
|
||||
test('initial font sync queues a separate task for each live player', async () => {
|
||||
const queuedTasks = [];
|
||||
@@ -59,4 +60,38 @@ test('initial font sync queues a separate task for each live player', async () =
|
||||
assert.ok(queuedTasks.every(function (task) {
|
||||
return task.key === 'initial-font-sync' && task.title === 'Initial font sync' && task.taskType === 'font-sync';
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
test('font sync task fails when a required player upload is unsuccessful', async () => {
|
||||
let handler = null;
|
||||
registerFontSyncTask({
|
||||
backgroundTaskQueue: {
|
||||
setTaskHandler(taskType, taskHandler) {
|
||||
assert.equal(taskType, 'font-sync');
|
||||
handler = taskHandler;
|
||||
}
|
||||
},
|
||||
uploadSyncService: {
|
||||
async pushUploadFileToPlayer() { return false; },
|
||||
async removeUploadFileFromPlayer() { return false; }
|
||||
}
|
||||
});
|
||||
|
||||
await assert.rejects(handler({
|
||||
payload: {
|
||||
uploadDir: 'media',
|
||||
playerIdentifier: 'player-local',
|
||||
requireSuccess: true,
|
||||
operations: [{ type: 'put', uploadPath: '/media/assets/fonts/test.ttf' }]
|
||||
}
|
||||
}), /Unable to synchronize \/media\/assets\/fonts\/test\.ttf to player player-local\./);
|
||||
|
||||
await assert.rejects(handler({
|
||||
payload: {
|
||||
uploadDir: 'media',
|
||||
playerIdentifier: 'player-local',
|
||||
requireSuccess: true,
|
||||
operations: [{ type: 'delete', uploadPath: '/media/assets/fonts/old.ttf' }]
|
||||
}
|
||||
}), /Unable to remove \/media\/assets\/fonts\/old\.ttf from player player-local\./);
|
||||
});
|
||||
@@ -4,6 +4,7 @@ const assert = require('node:assert/strict');
|
||||
require('../src/common');
|
||||
|
||||
const { registerInitialMediaSyncTask } = require('../src/web/lib/background-tasks/tasks-startup/media-sync');
|
||||
const { registerStartupTasks } = require('../src/web/lib/background-tasks');
|
||||
|
||||
test('initial media sync queues a separate task for each live player', async () => {
|
||||
const queuedTasks = [];
|
||||
@@ -59,4 +60,45 @@ test('initial media sync queues a separate task for each live player', async ()
|
||||
assert.ok(queuedTasks.every(function (task) {
|
||||
return task.key === 'initial-media-sync' && task.title === 'Initial media sync' && task.taskType === 'media-sync';
|
||||
}));
|
||||
});
|
||||
|
||||
test('startup registration queues the shared initial asset sync for a live direct player', async () => {
|
||||
const queuedTasks = [];
|
||||
const liveLastSeenAt = new Date(Date.now() - 10_000).toISOString();
|
||||
|
||||
const startupTasks = registerStartupTasks({
|
||||
pool: {
|
||||
async query() {
|
||||
return [[{
|
||||
identifier: 'player-local',
|
||||
public_base_url: 'http://localhost:8081',
|
||||
internal_base_url: 'http://player:8081',
|
||||
last_seen_at: liveLastSeenAt
|
||||
}]];
|
||||
}
|
||||
},
|
||||
common: {
|
||||
async fetchApiSourcesData() { return { apiSources: [] }; },
|
||||
async fetchRssFeedsData() { return { rssFeeds: [] }; },
|
||||
async fetchWeatherLocationsData() { return { weatherLocations: [] }; }
|
||||
},
|
||||
backgroundTaskQueue: {
|
||||
async enqueueTask(definition) {
|
||||
queuedTasks.push(definition);
|
||||
return definition;
|
||||
}
|
||||
},
|
||||
mediaDir: 'e:/Projects Git/pulse-signage/media',
|
||||
uploadSyncService: {
|
||||
async getPlayerTaskMetadata() { return {}; }
|
||||
}
|
||||
});
|
||||
|
||||
await startupTasks.initialize();
|
||||
|
||||
assert.equal(queuedTasks.length, 1);
|
||||
assert.equal(queuedTasks[0].taskType, 'media-sync');
|
||||
assert.equal(queuedTasks[0].key, 'initial-media-sync');
|
||||
assert.equal(queuedTasks[0].payload.playerIdentifier, 'player-local');
|
||||
assert.equal(queuedTasks[0].payload.mode, 'initial');
|
||||
});
|
||||
@@ -148,6 +148,7 @@ test('player font sync requests are queued as background tasks', async () => {
|
||||
assert.equal(queuedTasks[0].taskType, 'font-sync');
|
||||
assert.equal(queuedTasks[0].payload.mode, 'initial');
|
||||
assert.equal(queuedTasks[0].payload.playerIdentifier, 'player-remote');
|
||||
assert.equal(queuedTasks[0].payload.requireSuccess, true);
|
||||
assert.equal(queuedTasks[0].metadata.playerLabel, 'player-remote');
|
||||
} finally {
|
||||
await new Promise(function (resolve) {
|
||||
|
||||
@@ -55,6 +55,8 @@ test('buildScreenPlaylist assembles slides, templates, and derived values', asyn
|
||||
if (sql.includes('FROM c_playlist_slides ps')) {
|
||||
return [[{
|
||||
id: 101,
|
||||
playlist_slide_id: 301,
|
||||
group_id: 55,
|
||||
title: 'Intro',
|
||||
template_id: 33,
|
||||
content_json: '{"videoRegion":{"type":"video","duration_seconds":12.3456},"backupVideo":{"type":"video","duration_seconds":20.1111},"textRegion":{"type":"text","value":"Hello"},"brandTagline":{"type":"text","value":"Old slide value"},"qrRegion":{"type":"qr-code","value":"https://example.com"}}',
|
||||
@@ -69,7 +71,6 @@ test('buildScreenPlaylist assembles slides, templates, and derived values', asyn
|
||||
group_shuffle: 1,
|
||||
group_mode: 'repeat',
|
||||
group_repeat_count: 2,
|
||||
group_schedule_rules_json: '[{"start_time":"08:00","end_time":"12:00","days":[1,2,3]}]',
|
||||
template_name: 'Template 33',
|
||||
canvas_size_name: 'HD',
|
||||
canvas_size_width: 1920,
|
||||
@@ -78,6 +79,7 @@ test('buildScreenPlaylist assembles slides, templates, and derived values', asyn
|
||||
canvas_height: 1080
|
||||
}, {
|
||||
id: 102,
|
||||
playlist_slide_id: 302,
|
||||
title: 'Live Feed',
|
||||
template_id: 33,
|
||||
content_json: '{"rtmpRegion":{"type":"rtmp","value":"rtmp://example/live"}}',
|
||||
@@ -94,10 +96,12 @@ test('buildScreenPlaylist assembles slides, templates, and derived values', asyn
|
||||
canvas_height: 1080
|
||||
}]];
|
||||
}
|
||||
if (sql.includes('FROM c_playlist_slide_schedule_rules')) {
|
||||
if (sql.includes('FROM c_playlist_schedule_rules')) {
|
||||
assert.deepEqual(params, [22, 22]);
|
||||
return [[
|
||||
{ id: 500, playlist_slide_id: 101, position: 1, start_datetime: null, end_datetime: null, start_time: '08:00', end_time: '12:00', schedule_days_json: '[1,2,3]' },
|
||||
{ id: 501, playlist_slide_id: 101, position: 2, start_datetime: null, end_datetime: null, start_time: '13:00', end_time: '17:00', schedule_days_json: '[4,5]' }
|
||||
{ id: 500, playlist_slide_id: 301, playlist_group_id: null, position: 1, start_datetime: null, end_datetime: null, start_time: '08:00', end_time: '12:00', schedule_days_json: '[1,2,3]' },
|
||||
{ id: 501, playlist_slide_id: 301, playlist_group_id: null, position: 2, start_datetime: null, end_datetime: null, start_time: '13:00', end_time: '17:00', schedule_days_json: '[4,5]' },
|
||||
{ id: 502, playlist_slide_id: null, playlist_group_id: 55, position: 0, start_datetime: '2026-01-01T00:00:00.000Z', end_datetime: null, start_time: null, end_time: null, schedule_days_json: null }
|
||||
]];
|
||||
}
|
||||
if (sql.includes('FROM c_templates st')) {
|
||||
@@ -154,7 +158,9 @@ test('buildScreenPlaylist assembles slides, templates, and derived values', asyn
|
||||
assert.equal(payload.slides[1].groupMode, 'select');
|
||||
assert.equal(payload.slides[1].groupRepeatCount, 1);
|
||||
assert.equal(payload.slides[1].groupShuffle, false);
|
||||
assert.deepEqual(payload.slides[0].groupScheduleRules, [{ start_time: '08:00', end_time: '12:00', days: [1, 2, 3] }]);
|
||||
assert.equal(payload.slides[0].groupScheduleRules.length, 1);
|
||||
assert.equal(payload.slides[0].groupScheduleRules[0].start_datetime, '2026-01-01T00:00:00.000Z');
|
||||
assert.equal(payload.slides[0].groupScheduleRules[0].playlist_group_id, 55);
|
||||
assert.equal(payload.slides[0].template.regions[0].label, 'Text Region');
|
||||
assert.equal(payload.rssFeeds[0].items[0].normalized, true);
|
||||
assert.deepEqual(payload.apiSources[0].responseJson, { ok: true });
|
||||
|
||||
@@ -116,6 +116,20 @@ test('playlist groups rotate in order, fill every pass, and optionally shuffle',
|
||||
assert.equal(sandbox.isSlideActive(gatedSlide, now), true);
|
||||
assert.equal(sandbox.isSlideActive(gatedSlide, new Date(2026, 9, 14, 12, 30)), false);
|
||||
assert.equal(sandbox.isSlideActive(gatedSlide, new Date(2026, 9, 15, 10, 30)), false);
|
||||
|
||||
const databaseRuleSlide = {
|
||||
scheduleRules: [{ start_time: '10:00', end_time: '11:00', schedule_days_json: '[3]' }]
|
||||
};
|
||||
assert.equal(sandbox.isSlideActive(databaseRuleSlide, now), true);
|
||||
assert.equal(sandbox.isSlideActive(databaseRuleSlide, new Date(2026, 9, 15, 10, 30)), false);
|
||||
|
||||
const startBoundSlide = { scheduleRules: [{ start_datetime: '2026-10-14T11:00' }] };
|
||||
assert.equal(sandbox.isSlideActive(startBoundSlide, now), false);
|
||||
assert.equal(sandbox.isSlideActive(startBoundSlide, new Date(2026, 9, 14, 11, 0)), true);
|
||||
|
||||
const endBoundSlide = { scheduleRules: [{ end_datetime: '2026-10-14T10:00' }] };
|
||||
assert.equal(sandbox.isSlideActive(endBoundSlide, now), false);
|
||||
assert.equal(sandbox.isSlideActive(endBoundSlide, new Date(2026, 9, 14, 10, 0)), true);
|
||||
});
|
||||
|
||||
test('repeat groups play all eligible members in order and ignore Pick and shuffle', () => {
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
|
||||
require('../src/common');
|
||||
|
||||
@@ -63,6 +66,10 @@ function createResponse() {
|
||||
headers[name] = value;
|
||||
return this;
|
||||
},
|
||||
type(value) {
|
||||
headers['Content-Type'] = value;
|
||||
return this;
|
||||
},
|
||||
status(code) {
|
||||
this.statusCode = code;
|
||||
return this;
|
||||
@@ -378,6 +385,60 @@ test('playlist api route returns 404, etag, and 304 responses', async () => {
|
||||
assert.equal(notModifiedRes.ended, true);
|
||||
});
|
||||
|
||||
test('remote playlist sync downloads cached API and RSS images from the bridge', async () => {
|
||||
const { app, handlers } = createAppAndHandlers();
|
||||
const mediaDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pulse-player-remote-images-'));
|
||||
const filename = 'a'.repeat(64) + '.png';
|
||||
const imageBytes = Buffer.from('cached image bytes');
|
||||
const playlistData = {
|
||||
screen: { id: 7, slug: 'news' },
|
||||
revision: 'revision-1',
|
||||
apiSources: [{ imageCache: { 'https://api.example/image.png': '/media/player-cache/remote-images/' + filename } }],
|
||||
rssFeeds: []
|
||||
};
|
||||
const originalFetch = global.fetch;
|
||||
const fetchCalls = [];
|
||||
global.fetch = async function (url, init) {
|
||||
fetchCalls.push({ url: String(url), init: init });
|
||||
if (String(url).includes('/api/screens/news/playlist')) {
|
||||
return {
|
||||
status: 200,
|
||||
ok: true,
|
||||
headers: { get(name) { return name.toLowerCase() === 'content-type' ? 'application/json' : null; } },
|
||||
async text() { return JSON.stringify(playlistData); }
|
||||
};
|
||||
}
|
||||
if (String(url).endsWith('/api/media/remote-images/' + filename)) {
|
||||
return { ok: true, async arrayBuffer() { return imageBytes; } };
|
||||
}
|
||||
throw new Error('Unexpected fetch: ' + String(url));
|
||||
};
|
||||
|
||||
try {
|
||||
registerPlayerRoutes(app, {
|
||||
app,
|
||||
pool: null,
|
||||
common: { renderPlayerPage() { return ''; } },
|
||||
...createPlayerRouteOptions({
|
||||
bridgeBaseUrl: 'http://bridge.test',
|
||||
mediaDir
|
||||
})
|
||||
});
|
||||
|
||||
const response = createResponse();
|
||||
await handlers['/api/screens/:slug/playlist']({ params: { slug: 'news' }, headers: {}, query: {} }, response);
|
||||
|
||||
assert.equal(response.statusCode, 200);
|
||||
assert.equal(fetchCalls.length, 2);
|
||||
assert.match(fetchCalls[1].url, /\/api\/media\/remote-images\//);
|
||||
assert.equal(fetchCalls[1].init.method, 'GET');
|
||||
assert.deepEqual(fs.readFileSync(path.join(mediaDir, 'player-cache', 'remote-images', filename)), imageBytes);
|
||||
} finally {
|
||||
global.fetch = originalFetch;
|
||||
fs.rmSync(mediaDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('announcement route returns 503 for transient failures and 304 on matching etag', async () => {
|
||||
const { app, handlers } = createAppAndHandlers();
|
||||
const announcement = {
|
||||
|
||||
@@ -62,10 +62,11 @@ test('player service worker retention supports opaque versions before active cac
|
||||
]);
|
||||
});
|
||||
|
||||
function createFetchWorker({ failure, cachedResponse = null, fetchError = false } = {}) {
|
||||
function createFetchWorker({ failure, cachedResponse = null, cacheMatchResponse = null, fetchError = false, networkStatus = 200 } = {}) {
|
||||
const networkResponse = new Response('fresh network body', {
|
||||
status: 200, headers: { ETag: '"fresh"', 'Content-Type': 'text/plain' }
|
||||
status: networkStatus, headers: { ETag: '"fresh"', 'Content-Type': 'text/plain' }
|
||||
});
|
||||
const namedCacheResponse = cacheMatchResponse === null ? cachedResponse : cacheMatchResponse;
|
||||
const calls = { fetch: 0, writes: [] };
|
||||
const listeners = new Map();
|
||||
const sandbox = {
|
||||
@@ -87,6 +88,7 @@ function createFetchWorker({ failure, cachedResponse = null, fetchError = false
|
||||
async open() {
|
||||
if (failure === 'open') throw new Error('Cache storage unavailable');
|
||||
return {
|
||||
async match() { return namedCacheResponse; },
|
||||
async put(key, response) {
|
||||
if (failure === 'put') throw new Error('QuotaExceededError');
|
||||
calls.writes.push({ key, response });
|
||||
@@ -158,4 +160,39 @@ test('player script assets refresh from network and retain offline cache fallbac
|
||||
respondWith(promise) { offlineResponsePromise = promise; }
|
||||
});
|
||||
assert.equal(await offlineResponsePromise, cachedResponse);
|
||||
});
|
||||
|
||||
test('playlist HTTP errors are not hidden by a stale cached playlist', async () => {
|
||||
const cachedResponse = new Response('stale playlist body', { status: 200 });
|
||||
const worker = createFetchWorker({ cachedResponse, networkStatus: 403 });
|
||||
const request = new Request('http://localhost/api/screens/test/playlist?ts=2');
|
||||
const response = await worker.sandbox.networkFirst(
|
||||
request,
|
||||
'playlists',
|
||||
{ url: 'http://localhost/api/screens/test/playlist' },
|
||||
false
|
||||
);
|
||||
|
||||
assert.equal(response.status, 403);
|
||||
assert.equal(await response.text(), 'fresh network body');
|
||||
});
|
||||
|
||||
test('playlist offline fallback does not read an older cache generation', async () => {
|
||||
const staleResponse = new Response('stale QR playlist', { status: 200 });
|
||||
const currentResponse = new Response('current Video playlist', { status: 200 });
|
||||
const worker = createFetchWorker({
|
||||
cachedResponse: staleResponse,
|
||||
cacheMatchResponse: currentResponse,
|
||||
fetchError: true
|
||||
});
|
||||
const request = new Request('http://localhost/api/screens/test/playlist?ts=3');
|
||||
const response = await worker.sandbox.networkFirst(
|
||||
request,
|
||||
'playlists-current',
|
||||
{ url: 'http://localhost/api/screens/test/playlist' },
|
||||
false
|
||||
);
|
||||
|
||||
assert.equal(response, currentResponse);
|
||||
assert.equal(await response.text(), 'current Video playlist');
|
||||
});
|
||||
@@ -62,6 +62,8 @@ test('playlist schedule summaries show up to two short rules on separate lines a
|
||||
[[{ days: [1] }, { days: [2] }, { days: [3] }, { days: [4] }], '4 rules'],
|
||||
[[{ start_datetime: '2026-10-25T09:00', end_datetime: '2026-10-26T17:00', days: [1] }],
|
||||
'Rule 1: Dates 2026-10-25 09:00 to 2026-10-26 17:00: Mon'],
|
||||
[[{ start_datetime: '2026-10-25T09:00' }], 'Rule 1: From 2026-10-25 09:00'],
|
||||
[[{ end_datetime: '2026-10-26T17:00' }], 'Rule 1: Until 2026-10-26 17:00'],
|
||||
[[{ start_datetime: new Date(2026, 0, 1, 0, 0), end_datetime: new Date(2026, 9, 30, 20, 0) }],
|
||||
'Rule 1: Dates 2026-01-01 00:00 to 2026-10-30 20:00'],
|
||||
[[{ start_datetime: '2026-10-25T09:00', end_datetime: '2026-10-26T17:00',
|
||||
@@ -91,6 +93,36 @@ test('playlist schedule summaries show up to two short rules on separate lines a
|
||||
assert.match(css, /\.playlist-schedule-summary \.chip\s*\{[^}]*display:\s*inline-block;[^}]*white-space:\s*pre-line;[^}]*overflow-wrap:\s*anywhere;/);
|
||||
});
|
||||
|
||||
test('live schedule validator accepts one datetime bound and still rejects reversed ranges', () => {
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const vm = require('node:vm');
|
||||
const source = fs.readFileSync(path.join(__dirname, '../src/web/public/js/playlists/playlist-schedule.js'), 'utf8');
|
||||
const start = source.lastIndexOf(' function validateScheduleRuleCard(card)');
|
||||
const end = source.indexOf(' function updateLiveRuleValidity(card)', start);
|
||||
const fields = {
|
||||
days: [],
|
||||
startDatetime: { value: '2026-10-25T09:00' },
|
||||
endDatetime: { value: '' },
|
||||
startTime: { value: '' },
|
||||
endTime: { value: '' }
|
||||
};
|
||||
const context = {
|
||||
getRuleFields: () => fields,
|
||||
getRuleDayLabels: () => [],
|
||||
getDateRangeDayIndices: () => [0, 1, 2, 3, 4, 5, 6],
|
||||
clearRuleValidity() {},
|
||||
setRuleError() {},
|
||||
setDayInputError() {},
|
||||
hasScheduleRuleOverlap: () => true
|
||||
};
|
||||
vm.runInNewContext(source.slice(start, end) + '\nglobalThis.validateRule = validateScheduleRuleCard;', context);
|
||||
|
||||
assert.equal(context.validateRule({}), true);
|
||||
fields.endDatetime.value = '2026-10-24T09:00';
|
||||
assert.equal(context.validateRule({}), false);
|
||||
});
|
||||
|
||||
test('playlist rows show mute controls for RTMP slides', () => {
|
||||
const model = buildPlaylistFormViewModel(
|
||||
{ id: 22, name: 'Playlist 22' },
|
||||
|
||||
@@ -38,6 +38,8 @@ function createResponse() {
|
||||
test('playlist create and update redirect to the canonical edit path', async () => {
|
||||
const calls = [];
|
||||
let groupInsertParams = null;
|
||||
let slideInsertParams = null;
|
||||
const groupRuleInsertParams = [];
|
||||
let canvasSizeEnabled = 1;
|
||||
const connection = {
|
||||
async beginTransaction() {},
|
||||
@@ -56,14 +58,28 @@ test('playlist create and update redirect to the canonical edit path', async ()
|
||||
calls.push('delete-slides');
|
||||
return [{ affectedRows: 1 }];
|
||||
}
|
||||
if (sql.startsWith('DELETE FROM c_playlist_groups')) {
|
||||
calls.push('delete-groups');
|
||||
return [{ affectedRows: 1 }];
|
||||
}
|
||||
if (sql.startsWith('SELECT sl.id, st.canvas_size_id')) {
|
||||
return [[{ id: 11, canvas_size_id: 7 }]];
|
||||
}
|
||||
if (sql.startsWith('INSERT INTO c_playlist_slides')) {
|
||||
if (sql.startsWith('INSERT INTO c_playlist_groups')) {
|
||||
groupInsertParams = params;
|
||||
calls.push('insert-group');
|
||||
return [{ insertId: 71 }];
|
||||
}
|
||||
if (sql.startsWith('INSERT INTO c_playlist_slides')) {
|
||||
slideInsertParams = params;
|
||||
calls.push('insert-slide');
|
||||
return [{ insertId: 101 }];
|
||||
}
|
||||
if (sql.startsWith('INSERT INTO c_playlist_schedule_rules')) {
|
||||
groupRuleInsertParams.push(params);
|
||||
calls.push('insert-group-rule');
|
||||
return [{ insertId: 81 }];
|
||||
}
|
||||
return [[]];
|
||||
}
|
||||
};
|
||||
@@ -139,26 +155,31 @@ test('playlist create and update redirect to the canonical edit path', async ()
|
||||
group_shuffle: ['1'],
|
||||
group_mode: ['repeat'],
|
||||
group_repeat_count: ['2'],
|
||||
group_schedule_rules_json: [JSON.stringify([{ start_time: '08:00', end_time: '17:00', days: [1, 2, 3] }])]
|
||||
group_schedule_rules_json: [JSON.stringify([
|
||||
{ start_datetime: '2026-10-01T09:00', days: [1, 2, 3] },
|
||||
{ end_datetime: '2026-10-31T18:00' }
|
||||
])]
|
||||
}
|
||||
}, updateRes, () => {});
|
||||
}, updateRes, error => { throw error; });
|
||||
|
||||
assert.equal(createRes.redirectedTo, '/playlists/4/edit');
|
||||
assert.equal(updateRes.redirectedTo, '/playlists/4/edit', updateRes.statusCode + ': ' + String(updateRes.body || ''));
|
||||
assert.deepEqual(groupInsertParams.slice(6, 10), [
|
||||
'news',
|
||||
'News',
|
||||
1,
|
||||
JSON.stringify([{ start_time: '08:00', end_time: '17:00', days: [1, 2, 3] }])
|
||||
assert.deepEqual(groupInsertParams, [4, 'news', 'News', 1, 1, 'repeat', 2, 12, 12]);
|
||||
assert.equal(slideInsertParams[6], 71);
|
||||
assert.deepEqual(groupRuleInsertParams, [
|
||||
[71, 0, '2026-10-01T09:00', null, null, null, '[1,2,3]', 12, 12],
|
||||
[71, 1, null, '2026-10-31T18:00', null, null, null, 12, 12]
|
||||
]);
|
||||
assert.equal(groupInsertParams[10], 1);
|
||||
assert.equal(groupInsertParams[11], 'repeat');
|
||||
assert.equal(groupInsertParams[12], 2);
|
||||
assert.deepEqual(calls, [
|
||||
'delete-slides',
|
||||
'delete-groups',
|
||||
{ kind: 'redirectAfterSave', url: '/playlists/4/edit', path: '/playlists' },
|
||||
'update-playlist',
|
||||
'delete-slides',
|
||||
'delete-groups',
|
||||
'insert-group',
|
||||
'insert-group-rule',
|
||||
'insert-group-rule',
|
||||
'insert-slide',
|
||||
{ kind: 'redirectAfterSave', url: '/playlists/4/edit', path: '/playlists/4' }
|
||||
]);
|
||||
@@ -200,21 +221,22 @@ test('playlist create and update redirect to the canonical edit path', async ()
|
||||
});
|
||||
|
||||
test('playlist audit compares persisted items and schedules without recreated row IDs', async () => {
|
||||
let items = [{ id: 101, slide_id: 11, duration_seconds: 10 }];
|
||||
let items = [{ id: 101, slide_id: 11, group_id: null, duration_seconds: 10 }];
|
||||
let rules = [{ playlist_slide_id: 101, start_time: '08:00:00', end_time: '17:00:00', schedule_days_json: '[1,2]' }];
|
||||
const events = [];
|
||||
const connection = {
|
||||
async commit() {}, async rollback() {}, release() {},
|
||||
async query(sql, params) {
|
||||
if (sql.includes('FROM c_playlist_slides WHERE playlist_id') && sql.startsWith('SELECT id, slide_id')) return [structuredClone(items)];
|
||||
if (sql.includes('FROM c_playlist_slide_schedule_rules r')) return [structuredClone(rules)];
|
||||
if (sql.includes('FROM c_playlist_slides ps') && sql.includes('WHERE ps.playlist_id')) return [structuredClone(items)];
|
||||
if (sql.includes('FROM c_playlist_schedule_rules r')) return [structuredClone(rules)];
|
||||
if (sql.startsWith('SELECT sl.id, st.canvas_size_id')) return [[{ id: 11, canvas_size_id: 7 }]];
|
||||
if (sql.startsWith('DELETE FROM c_playlist_slides')) { items = []; rules = []; }
|
||||
if (sql.startsWith('DELETE FROM c_playlist_groups')) return [{ affectedRows: 0 }];
|
||||
if (sql.startsWith('INSERT INTO c_playlist_slides')) {
|
||||
items = [{ id: 202, slide_id: params[1], duration_seconds: params[3] }];
|
||||
items = [{ id: 202, slide_id: params[1], group_id: params[6], duration_seconds: params[3] }];
|
||||
return [{ insertId: 202 }];
|
||||
}
|
||||
if (sql.startsWith('INSERT INTO c_playlist_slide_schedule_rules')) {
|
||||
if (sql.startsWith('INSERT INTO c_playlist_schedule_rules')) {
|
||||
rules.push({ playlist_slide_id: params[0], start_time: params[4] + ':00', end_time: params[5] + ':00', schedule_days_json: params[6] });
|
||||
}
|
||||
return [[]];
|
||||
|
||||
@@ -93,7 +93,7 @@ test('pending migrations are empty when the schema already matches the app versi
|
||||
|
||||
test('2.20.1 release follows the consolidated 2.20.0 migration', async () => {
|
||||
const pending = await getPendingMigrations(createPool([]), { currentVersion: '2.19.1' });
|
||||
assert.deepEqual(pending.map(migration => migration.version), ['2.20.0', '2.20.1']);
|
||||
assert.deepEqual(pending.filter(migration => migration.version.startsWith('2.20.')).map(migration => migration.version), ['2.20.0', '2.20.1']);
|
||||
});
|
||||
|
||||
test('2.20.1 removes retired security setting rows', async () => {
|
||||
@@ -627,4 +627,120 @@ test('2.19.0 indexed-reference migration backfills slide and template defaults',
|
||||
assert.ok(queries.every(([sql]) => !sql.includes('idx_content_reference_owner')));
|
||||
assert.ok(insertedReferences.some(row => row[0] === 'slide' && row[1] === 2 && row[3] === 'media' && row[4] === '/media/uploads/hero.png'));
|
||||
assert.ok(insertedReferences.some(row => row[0] === 'template' && row[1] === 4 && row[2] === 'headline' && row[3] === 'font-family' && row[4] === 'acme display'));
|
||||
});
|
||||
|
||||
function createPlaylistScheduleMigrationPool(groupRows, sourceRuleCount) {
|
||||
const queries = [];
|
||||
const legacyGroupColumns = new Set([
|
||||
'group_key', 'group_name', 'group_pick_count', 'group_shuffle',
|
||||
'group_mode', 'group_repeat_count', 'group_schedule_rules_json'
|
||||
]);
|
||||
let hasGroupId = false;
|
||||
let hasLegacyScheduleTable = true;
|
||||
let migratedSlideRuleCount = 0;
|
||||
const groupInsertParams = [];
|
||||
const groupRuleInsertParams = [];
|
||||
const memberGroupUpdates = [];
|
||||
|
||||
return {
|
||||
queries,
|
||||
groupInsertParams,
|
||||
groupRuleInsertParams,
|
||||
memberGroupUpdates,
|
||||
async query(sql, params) {
|
||||
const text = String(sql);
|
||||
queries.push([text, params]);
|
||||
if (text.includes('FROM information_schema.COLUMNS')) {
|
||||
const columnName = params[1];
|
||||
const exists = columnName === 'group_id' ? hasGroupId : legacyGroupColumns.has(columnName);
|
||||
return [[{ column_count: exists ? 1 : 0 }]];
|
||||
}
|
||||
if (text.includes('FROM information_schema.TABLES')) {
|
||||
return [[{ table_count: params[0] === 'c_playlist_slide_schedule_rules' && hasLegacyScheduleTable ? 1 : 0 }]];
|
||||
}
|
||||
if (text.includes('FROM information_schema.KEY_COLUMN_USAGE')) {
|
||||
return [[{ fk_count: 0 }]];
|
||||
}
|
||||
if (text.startsWith('ALTER TABLE c_playlist_slides ADD COLUMN group_id')) {
|
||||
hasGroupId = true;
|
||||
}
|
||||
if (text.startsWith('ALTER TABLE c_playlist_slides DROP COLUMN')) {
|
||||
text.split('DROP COLUMN ').slice(1).forEach(part => legacyGroupColumns.delete(part.split(/[ ,]/)[0]));
|
||||
}
|
||||
if (text.includes('SELECT id, playlist_id, group_key, group_name')) {
|
||||
return [structuredClone(groupRows)];
|
||||
}
|
||||
if (text.startsWith('INSERT INTO c_playlist_groups')) {
|
||||
groupInsertParams.push(params);
|
||||
return [{ insertId: 77 }];
|
||||
}
|
||||
if (text.startsWith('INSERT INTO c_playlist_schedule_rules') && text.includes('VALUES')) {
|
||||
groupRuleInsertParams.push(params);
|
||||
}
|
||||
if (text.startsWith('UPDATE c_playlist_slides SET group_id')) {
|
||||
memberGroupUpdates.push(params);
|
||||
}
|
||||
if (text.startsWith('SELECT COUNT(*) AS rule_count FROM c_playlist_slide_schedule_rules')) {
|
||||
return [[{ rule_count: sourceRuleCount }]];
|
||||
}
|
||||
if (text.startsWith('DELETE FROM c_playlist_schedule_rules WHERE playlist_slide_id')) {
|
||||
migratedSlideRuleCount = 0;
|
||||
}
|
||||
if (text.includes('INSERT INTO c_playlist_schedule_rules') && text.includes('SELECT playlist_slide_id')) {
|
||||
migratedSlideRuleCount = sourceRuleCount;
|
||||
}
|
||||
if (text.startsWith('SELECT COUNT(*) AS rule_count FROM c_playlist_schedule_rules WHERE playlist_slide_id')) {
|
||||
return [[{ rule_count: migratedSlideRuleCount }]];
|
||||
}
|
||||
if (text === 'DROP TABLE c_playlist_slide_schedule_rules') {
|
||||
hasLegacyScheduleTable = false;
|
||||
}
|
||||
return [[]];
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function buildLegacyPlaylistGroupRows(groupRulesJson, secondPickCount) {
|
||||
return [
|
||||
{ id: 501, playlist_id: 8, group_key: 'events', group_name: 'Events', group_pick_count: 1, group_shuffle: 1, group_mode: 'select', group_repeat_count: 1, group_schedule_rules_json: groupRulesJson, created_at: '2026-10-01 10:00:00', created_by: 4, modified_at: '2026-10-02 10:00:00', modified_by: 5 },
|
||||
{ id: 502, playlist_id: 8, group_key: 'events', group_name: 'Events', group_pick_count: secondPickCount || 1, group_shuffle: 1, group_mode: 'select', group_repeat_count: 1, group_schedule_rules_json: groupRulesJson, created_at: '2026-10-01 10:00:00', created_by: 4, modified_at: '2026-10-02 10:00:00', modified_by: 5 }
|
||||
];
|
||||
}
|
||||
|
||||
test('2.22.0 playlist schedule migration consolidates shared groups and copies both rule owners', async () => {
|
||||
const pending = await getPendingMigrations(createPool([]), { currentVersion: '2.21.6' });
|
||||
const migration = pending.find(item => item.version === '2.22.0');
|
||||
assert.ok(migration);
|
||||
|
||||
const groupRules = JSON.stringify([{ start_datetime: '2026-10-01T10:00', days: [2] }]);
|
||||
const pool = createPlaylistScheduleMigrationPool(buildLegacyPlaylistGroupRows(groupRules), 2);
|
||||
await migration.run(pool);
|
||||
|
||||
assert.equal(pool.groupInsertParams.length, 1);
|
||||
assert.deepEqual(pool.groupInsertParams[0].slice(0, 7), [8, 'events', 'Events', 1, 1, 'select', 1]);
|
||||
assert.equal(pool.memberGroupUpdates.length, 2);
|
||||
assert.deepEqual(pool.memberGroupUpdates, [[77, 501], [77, 502]]);
|
||||
assert.deepEqual(pool.groupRuleInsertParams, [[77, 0, '2026-10-01T10:00', null, null, null, '[2]', 5, 5]]);
|
||||
assert.ok(pool.queries.some(([sql]) => sql.includes('INSERT INTO c_playlist_schedule_rules') && sql.includes('SELECT playlist_slide_id')));
|
||||
assert.ok(pool.queries.some(([sql]) => sql === 'DROP TABLE c_playlist_slide_schedule_rules'));
|
||||
const legacyGroupColumnDrops = pool.queries.filter(([sql]) => sql.startsWith('ALTER TABLE c_playlist_slides DROP COLUMN'));
|
||||
assert.equal(legacyGroupColumnDrops.length, 1);
|
||||
assert.match(legacyGroupColumnDrops[0][0], /DROP COLUMN group_key, DROP COLUMN group_name, DROP COLUMN group_pick_count, DROP COLUMN group_shuffle, DROP COLUMN group_mode, DROP COLUMN group_repeat_count, DROP COLUMN group_schedule_rules_json/);
|
||||
|
||||
const schema = require('node:fs').readFileSync(require.resolve('../src/db/index.js'), 'utf8');
|
||||
assert.match(schema, /CREATE TABLE IF NOT EXISTS c_playlist_groups/);
|
||||
assert.match(schema, /CREATE TABLE IF NOT EXISTS c_playlist_schedule_rules/);
|
||||
assert.doesNotMatch(schema, /group_schedule_rules_json/);
|
||||
});
|
||||
|
||||
test('2.22.0 playlist schedule migration aborts before dropping inconsistent legacy group data', async () => {
|
||||
const pending = await getPendingMigrations(createPool([]), { currentVersion: '2.21.6' });
|
||||
const migration = pending.find(item => item.version === '2.22.0');
|
||||
assert.ok(migration);
|
||||
|
||||
const pool = createPlaylistScheduleMigrationPool(buildLegacyPlaylistGroupRows('[]', 2), 0);
|
||||
await assert.rejects(migration.run(pool), /inconsistent settings across members/);
|
||||
assert.equal(pool.groupInsertParams.length, 0);
|
||||
assert.equal(pool.queries.some(([sql]) => sql === 'DROP TABLE c_playlist_slide_schedule_rules'), false);
|
||||
assert.equal(pool.queries.some(([sql]) => sql.startsWith('ALTER TABLE c_playlist_slides DROP COLUMN')), false);
|
||||
});
|
||||
@@ -227,7 +227,7 @@ test('2.19.1 migration rebuilds only managed font references for all content in
|
||||
]);
|
||||
|
||||
const pending = await getPendingMigrations(pool, { currentVersion: '2.19.0' });
|
||||
assert.deepEqual(pending.map(migration => migration.version), ['2.19.1', '2.20.0', '2.20.1']);
|
||||
assert.deepEqual(pending.map(migration => migration.version), ['2.19.1', '2.20.0', '2.20.1', '2.22.0']);
|
||||
await pending[0].run(pool);
|
||||
|
||||
const deletions = pool.queries.filter(([sql]) => sql.startsWith('DELETE'));
|
||||
@@ -243,7 +243,7 @@ test('2.19.1 migration rebuilds only managed font references for all content in
|
||||
assert.ok(pool.queries.some(([sql, params]) => sql.startsWith('SELECT id, template_id, region_key') && params[0] === 900));
|
||||
assert.ok(!pool.queries.some(([sql]) => /^(UPDATE|ALTER|TRUNCATE)/.test(sql)));
|
||||
assert.deepEqual(pool.queries.slice(-1), [['COMMIT']]);
|
||||
assert.deepEqual((await getPendingMigrations(pool, { currentVersion: '2.19.1' })).map(migration => migration.version), ['2.20.0', '2.20.1']);
|
||||
assert.deepEqual((await getPendingMigrations(pool, { currentVersion: '2.19.1' })).map(migration => migration.version), ['2.20.0', '2.20.1', '2.22.0']);
|
||||
});
|
||||
|
||||
test('2.19.1 font repair rolls back without releasing the caller-owned connection on failure', async () => {
|
||||
|
||||
@@ -3,7 +3,37 @@ const assert = require('node:assert/strict');
|
||||
|
||||
require('../src/common');
|
||||
|
||||
const { createWebBootstrap } = require('../src/web/bootstrap');
|
||||
const { createWebBootstrap, mergePlayerSnapshotConnections } = require('../src/web/bootstrap');
|
||||
const { createWebConfig } = require('../src/web/lib/config');
|
||||
|
||||
test('dashboard combines player and bridge snapshots without duplicating clients', () => {
|
||||
const localClient = { id: 'connection-1', clientId: 'client-1', deviceId: 'device-1', playerPublicBaseUrl: 'http://player.local' };
|
||||
const bridgeClient = { id: 'connection-2', clientId: 'client-2', deviceId: 'device-2', playerPublicBaseUrl: 'https://player.example' };
|
||||
const connections = mergePlayerSnapshotConnections([
|
||||
[localClient, bridgeClient],
|
||||
[localClient]
|
||||
]);
|
||||
|
||||
assert.deepEqual(connections, [localClient, bridgeClient]);
|
||||
});
|
||||
|
||||
test('web config uses the player directly when no bridge URL is configured', () => {
|
||||
const previousBridgeUrl = process.env.BRIDGE_INTERNAL_URL;
|
||||
delete process.env.BRIDGE_INTERNAL_URL;
|
||||
try {
|
||||
assert.equal(createWebConfig().bridgeInternalUrl, 'http://player-bridge:8090');
|
||||
process.env.BRIDGE_INTERNAL_URL = '';
|
||||
const config = createWebConfig();
|
||||
assert.equal(config.bridgeInternalUrl, '');
|
||||
assert.equal(config.playerInternalUrl, 'http://player:8081');
|
||||
} finally {
|
||||
if (previousBridgeUrl === undefined) {
|
||||
delete process.env.BRIDGE_INTERNAL_URL;
|
||||
} else {
|
||||
process.env.BRIDGE_INTERNAL_URL = previousBridgeUrl;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('dashboard websocket broadcasts keep the last successful snapshot when refreshes fail', async () => {
|
||||
let adminDataCalls = 0;
|
||||
|
||||
Reference in new issue
Block a user