Add template animation editor

This commit is contained in:
2026-08-03 17:27:42 +01:00
parent e95928f31c
commit ed8d51580e
35 changed files with 3173 additions and 44 deletions
+91
View File
@@ -0,0 +1,91 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const crypto = require('node:crypto');
const {
collectLiveConnections,
isClientNameAvailable,
normalizeClientName,
normalizeDeviceId,
withClientNameReservation
} = require('../src/data/client-name-check');
test('normalize helpers trim names and sanitize device ids', () => {
assert.equal(normalizeClientName(' Screen A '), 'Screen A');
assert.equal(normalizeClientName(''), '');
assert.equal(normalizeDeviceId(' device-01 /abc!? '), 'device-01abc');
assert.equal(normalizeDeviceId('x'.repeat(200)).length, 128);
});
test('collectLiveConnections returns an array safely', () => {
assert.deepEqual(collectLiveConnections(null), []);
assert.deepEqual(collectLiveConnections([{ clientName: 'A' }]), [{ clientName: 'A' }]);
});
test('isClientNameAvailable rejects matching db rows and live connections', async () => {
const pool = {
async query(sql, params) {
assert.match(sql, /FROM d_onboarding_devices/);
assert.deepEqual(params, ['Screen A', 'device-01']);
return [[{ device_id: 'device-99' }]];
}
};
assert.equal(await isClientNameAvailable(pool, ' Screen A ', 'device-01', []), false);
assert.equal(await isClientNameAvailable(null, 'Screen A', 'device-01', [{ clientName: 'screen a', clientId: 'device-99' }]), false);
assert.equal(await isClientNameAvailable(null, 'Screen A', 'device-01', [{ clientName: 'screen a', clientId: 'device-01' }]), true);
assert.equal(await isClientNameAvailable(null, ' ', 'device-01', []), false);
});
test('withClientNameReservation acquires and releases locks around the handler', async () => {
const calls = [];
const lockName = `ps_client_name_${crypto.createHash('sha1').update('screen a').digest('hex')}`;
const connection = {
async query(sql, params) {
calls.push({ sql, params });
if (sql.includes('GET_LOCK')) {
return [[{ lock_result: 1 }]];
}
return [[{ released: 1 }]];
},
release() {
calls.push({ sql: 'RELEASE_CONNECTION' });
}
};
const pool = {
async getConnection() {
return connection;
}
};
const result = await withClientNameReservation(pool, ' Screen A ', async () => 'ok');
assert.equal(result, 'ok');
assert.deepEqual(calls, [
{ sql: 'SELECT GET_LOCK(?, 5) AS lock_result', params: [lockName] },
{ sql: 'SELECT RELEASE_LOCK(?)', params: [lockName] },
{ sql: 'RELEASE_CONNECTION' }
]);
});
test('withClientNameReservation rejects busy names', async () => {
const connection = {
async query(sql) {
if (sql.includes('GET_LOCK')) {
return [[{ lock_result: 0 }]];
}
throw new Error('unexpected query');
},
release() {}
};
const pool = {
async getConnection() {
return connection;
}
};
await assert.rejects(
() => withClientNameReservation(pool, 'Screen A', async () => 'ok'),
(error) => error && error.statusCode === 409 && error.message === 'Client name is busy. Please try again.'
);
});
+105
View File
@@ -0,0 +1,105 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const {
buildSearchFilter,
buildSortOrderClause,
fetchPagedRows,
findTopLevelOrderByIndex,
normalizePageNumber,
normalizeSortDirection,
parseJsonSafe,
readFormArray
} = require('../src/data/utils');
test('parseJsonSafe returns null for invalid JSON and preserves objects', () => {
assert.equal(parseJsonSafe(''), null);
assert.equal(parseJsonSafe('not-json'), null);
assert.deepEqual(parseJsonSafe('{"value":true}'), { value: true });
const value = { nested: ['a'] };
assert.equal(parseJsonSafe(value), value);
});
test('readFormArray always returns an array shape', () => {
assert.deepEqual(readFormArray({}, 'roles'), []);
assert.deepEqual(readFormArray({ roles: 'admin' }, 'roles'), ['admin']);
assert.deepEqual(readFormArray({ roles: ['admin', 'editor'] }, 'roles'), ['admin', 'editor']);
});
test('normalize helpers clamp pagination and sort direction', () => {
assert.equal(normalizePageNumber('0'), 1);
assert.equal(normalizePageNumber('3.9'), 3);
assert.equal(normalizePageNumber('abc'), 1);
assert.equal(normalizeSortDirection('DESC'), 'desc');
assert.equal(normalizeSortDirection('anything else'), 'asc');
});
test('buildSortOrderClause applies multiple columns and ignores unknown keys', () => {
assert.deepEqual(buildSortOrderClause({ name: 'title' }, 'name', 'desc'), {
clause: ' ORDER BY title DESC',
sortKey: 'name',
sortDirection: 'desc'
});
assert.deepEqual(buildSortOrderClause({ name: ['title', 'id'] }, 'name', 'asc'), {
clause: ' ORDER BY title ASC, id ASC',
sortKey: 'name',
sortDirection: 'asc'
});
assert.deepEqual(buildSortOrderClause({ name: 'title' }, 'missing', 'desc'), {
clause: '',
sortKey: 'missing',
sortDirection: 'desc'
});
});
test('buildSearchFilter generates escaped LIKE clauses', () => {
assert.deepEqual(buildSearchFilter(['name', 'description'], '100%_ready'), {
clause: " WHERE (LOWER(COALESCE(CAST(name AS CHAR), '')) LIKE ? ESCAPE '\\\\' OR LOWER(COALESCE(CAST(description AS CHAR), '')) LIKE ? ESCAPE '\\\\')",
params: ['%100\\%\\_ready%', '%100\\%\\_ready%']
});
assert.deepEqual(buildSearchFilter([], 'anything'), { clause: '', params: [] });
});
test('findTopLevelOrderByIndex skips nested subqueries', () => {
const sql = 'SELECT * FROM (SELECT * FROM items ORDER BY created_at DESC) AS nested ORDER BY id ASC';
assert.equal(findTopLevelOrderByIndex(sql), sql.lastIndexOf('ORDER BY id ASC'));
});
test('fetchPagedRows combines search, ordering, and pagination parameters', async () => {
const queries = [];
const pool = {
async query(sql, params) {
queries.push({ sql, params });
if (sql.startsWith('SELECT COUNT(*)')) {
return [[{ count: 3 }]];
}
return [[{ id: 1 }]];
}
};
const result = await fetchPagedRows(pool, {
selectSql: 'SELECT id, name FROM items WHERE active = 1 ORDER BY name ASC',
countSql: 'SELECT COUNT(*) AS count FROM items WHERE active = 1',
params: ['active'],
searchColumns: ['name'],
searchTerm: 'alpha',
sortColumns: { name: 'name' },
sortKey: 'name',
sortDirection: 'desc',
pageSize: 2,
page: 2
});
assert.deepEqual(result, {
rows: [{ id: 1 }],
totalItems: 3,
totalPages: 2,
currentPage: 2,
pageSize: 2
});
assert.equal(queries.length, 2);
assert.equal(queries[0].sql, "SELECT COUNT(*) AS count FROM (SELECT id, name FROM items WHERE active = 1 AND (LOWER(COALESCE(CAST(name AS CHAR), '')) LIKE ? ESCAPE '\\\\')) AS filtered_rows");
assert.deepEqual(queries[0].params, ['active', '%alpha%']);
assert.equal(queries[1].sql, "SELECT id, name FROM items WHERE active = 1 AND (LOWER(COALESCE(CAST(name AS CHAR), '')) LIKE ? ESCAPE '\\\\') ORDER BY name DESC LIMIT ? OFFSET ?");
assert.deepEqual(queries[1].params, ['active', '%alpha%', 2, 2]);
});
+148
View File
@@ -0,0 +1,148 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const { createPlayerPlaylistService } = require('../src/player/playlist');
function createFsStub() {
const original = {
readFile: fs.promises.readFile,
writeFile: fs.promises.writeFile,
mkdir: fs.promises.mkdir
};
const calls = [];
fs.promises.readFile = async function (filePath) {
calls.push({ method: 'readFile', filePath });
if (filePath.endsWith('fallback.json')) {
return JSON.stringify({ fromSnapshot: true, screen: { slug: 'test2' } });
}
const error = new Error('missing');
error.code = 'ENOENT';
throw error;
};
fs.promises.writeFile = async function (filePath, content) {
calls.push({ method: 'writeFile', filePath, content });
};
fs.promises.mkdir = async function (dirPath, options) {
calls.push({ method: 'mkdir', dirPath, options });
};
return {
calls,
restore() {
fs.promises.readFile = original.readFile;
fs.promises.writeFile = original.writeFile;
fs.promises.mkdir = original.mkdir;
}
};
}
test('buildScreenPlaylist assembles slides, templates, and derived values', async () => {
const pool = {
async query(sql, params) {
if (sql.includes('FROM d_screens')) {
assert.deepEqual(params, ['test2']);
return [[{ id: 7, name: 'Screen 7', slug: 'test2', playlist_id: 22, modified_at: '2026-08-03T00:00:00.000Z' }]];
}
if (sql.includes('FROM c_playlists')) {
assert.deepEqual(params, [22]);
return [[{ id: 22, name: 'Playlist 22', fade_between_slides: 1, skip_unavailable_rtmp: 0, modified_at: '2026-08-02T00:00:00.000Z' }]];
}
if (sql.includes('FROM c_playlist_slides ps')) {
return [[{
id: 101,
title: 'Intro',
template_id: 33,
content_json: '{"videoRegion":{"type":"video","duration_seconds":12.3456},"textRegion":{"type":"text","value":"Hello"}}',
modified_at: '2026-08-03T00:00:01.000Z',
position: 1,
duration_seconds: 9,
use_video_duration: 1,
disable_audio: null,
template_name: 'Template 33',
canvas_size_name: 'HD',
canvas_size_width: 1920,
canvas_size_height: 1080,
canvas_width: 1920,
canvas_height: 1080
}]];
}
if (sql.includes('FROM c_playlist_slide_schedule_rules')) {
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]' }
]];
}
if (sql.includes('FROM c_templates st')) {
assert.deepEqual(params, [[33]]);
return [[{ id: 33, name: 'Template 33', canvas_size_id: 4, canvas_size_width: 1920, canvas_size_height: 1080, background_image_path: '/media/bg.png', background_color: '#111111', modified_at: '2026-08-03T00:00:02.000Z' }]];
}
if (sql.includes('FROM c_template_regions')) {
assert.deepEqual(params, [[33]]);
return [[{ id: 900, template_id: 33, region_key: 'textRegion', region_type: 'text', label: 'Text Region', font_family: 'Arial', x: 10, y: 20, width: 300, height: 200, z_index: 1, modified_at: '2026-08-03T00:00:03.000Z' }]];
}
throw new Error(`unexpected query: ${sql}`);
}
};
const common = {
parseJsonSafe(value) {
return JSON.parse(value);
},
fetchRssFeedsData: async () => ({ rssFeeds: [{ id: 1, title: 'Feed 1' }] }),
fetchRssFeedItemsByFeedId: async () => ([{ title: 'Item 1', link: 'https://example.com' }]),
normalizeRssFeedItem(item) {
return Object.assign({}, item, { normalized: true });
},
fetchApiSourcesData: async () => ({ apiSources: [{ id: 2, name: 'Source 2', last_response_json: '{"ok":true}' }] }),
fetchTimetablesData: async () => ({ timetableGroups: [{ id: 3, name: 'Group 3' }] })
};
const fsStub = createFsStub();
try {
const service = createPlayerPlaylistService({ pool, common, snapshotDir: 'C:\\tmp\\snapshots' });
const payload = await service.buildScreenPlaylist('test2');
assert.equal(payload.screen.slug, 'test2');
assert.equal(payload.playlist.name, 'Playlist 22');
assert.equal(payload.slides[0].duration_seconds, 12.346);
assert.equal(payload.slides[0].disable_audio, true);
assert.equal(payload.slides[0].content.videoRegion.disable_audio, true);
assert.equal(payload.slides[0].content.videoRegion.cache_bust, '2026-08-03T00:00:01.000Z');
assert.equal(payload.slides[0].scheduleRules.length, 2);
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 });
assert.deepEqual(payload.timetableGroups, [{ id: 3, name: 'Group 3' }]);
assert.match(payload.revision, /^[a-f0-9]{40}$/);
assert.ok(fsStub.calls.some((call) => call.method === 'writeFile'));
} finally {
fsStub.restore();
}
});
test('buildScreenPlaylist falls back to a snapshot when queries fail', async () => {
const pool = {
async query() {
throw new Error('database unavailable');
}
};
const common = {
parseJsonSafe() {
throw new Error('not expected');
}
};
const fsStub = createFsStub();
try {
const service = createPlayerPlaylistService({ pool, common, snapshotDir: 'C:\\tmp\\snapshots' });
const payload = await service.buildScreenPlaylist('fallback');
assert.deepEqual(payload, { fromSnapshot: true, screen: { slug: 'test2' } });
assert.ok(fsStub.calls.some((call) => call.method === 'readFile'));
} finally {
fsStub.restore();
}
});
+81
View File
@@ -0,0 +1,81 @@
const test = require('node:test');
const assert = require('node:assert/strict');
require('../src/common');
const {
mediaKind,
normalizeSlide,
renderEditorJsContent,
sanitizeRichText
} = require('../src/player/render-helpers');
test('mediaKind classifies player media by extension', () => {
assert.equal(mediaKind('poster.PNG'), 'image');
assert.equal(mediaKind('intro.mp4'), 'video');
assert.equal(mediaKind('manual.pdf'), 'pdf');
assert.equal(mediaKind('notes.txt'), 'file');
});
test('sanitizeRichText strips unsafe content but preserves allowed markup', () => {
const html = '<div class="wrap"><a href="https://example.com" target="_blank">Link</a><script>alert(1)</script><span style="color:red">Text</span><img src="x" onerror="alert(1)"></div>';
assert.equal(
sanitizeRichText(html),
'<div class="wrap"><a href="https://example.com" target="_blank" rel="noreferrer noopener">Link</a><span style="color:red">Text</span></div>'
);
});
test('normalizeSlide normalizes nested content without mutating the source', () => {
const slide = {
id: 12,
content: {
hero: {
value: '{"headline":"Hello"}',
font_family: ' Open Sans! ',
font_size: '42',
font_color: 'not-a-color',
type: 'text'
},
footer: 'plain text'
}
};
const normalized = normalizeSlide(slide);
assert.notEqual(normalized.content, slide.content);
assert.deepEqual(normalized.content.hero, {
value: { headline: 'Hello' },
font_family: ' Open Sans! ',
font_size: '42',
font_color: 'not-a-color',
type: 'text'
});
assert.deepEqual(normalized.content.footer, {
type: 'text',
value: 'plain text'
});
assert.deepEqual(slide.content.hero, {
value: '{"headline":"Hello"}',
font_family: ' Open Sans! ',
font_size: '42',
font_color: 'not-a-color',
type: 'text'
});
});
test('renderEditorJsContent sanitizes editor blocks and wraps legacy text', () => {
const editorJson = {
blocks: [
{ type: 'header', data: { level: 2, text: '<strong>Title</strong><script>bad()</script>' } },
{ type: 'paragraph', data: { text: '<a href="javascript:alert(1)">bad</a><em>ok</em>' } },
{ type: 'list', data: { style: 'ordered', items: ['One', { text: '<span>Two</span>' }] } }
]
};
assert.equal(
renderEditorJsContent(editorJson),
'<h2><strong>Title</strong></h2><p><a>bad</a><em>ok</em></p><ol style="list-style-type:decimal;padding-left:1.4em;"><li>One</li><li><span>Two</span></li></ol>'
);
assert.equal(renderEditorJsContent('plain text'), '<p>plain text</p>');
});
+396
View File
@@ -0,0 +1,396 @@
const test = require('node:test');
const assert = require('node:assert/strict');
require('../src/common');
const { registerPlayerRoutes } = require('../src/player/routes');
function createAppAndHandlers() {
const handlers = {};
const app = {
use() {},
get(path, ...routeHandlers) {
handlers[path] = function (req, res) {
return new Promise((resolve, reject) => {
const runHandler = (index) => {
const handler = routeHandlers[index];
if (!handler) {
resolve();
return;
}
let nextCalled = false;
const next = function () {
nextCalled = true;
return runHandler(index + 1);
};
let result;
try {
result = handler(req, res, next);
} catch (error) {
reject(error);
return;
}
Promise.resolve(result).then(function (value) {
if (!nextCalled) {
resolve(value);
}
}, reject);
};
runHandler(0);
});
};
},
post() {},
put() {},
delete() {}
};
return { app, handlers };
}
function createResponse() {
const headers = {};
return {
headers,
statusCode: 200,
body: null,
ended: false,
set(name, value) {
headers[name] = value;
return this;
},
status(code) {
this.statusCode = code;
return this;
},
json(value) {
this.body = value;
return this;
},
send(value) {
this.body = value;
return this;
},
end() {
this.ended = true;
return this;
}
};
}
function createPlayerRouteOptions(overrides) {
return Object.assign({
mediaDir: 'e:\\Projects Git\\pulse-signage\\media',
assetDir: 'e:\\Projects Git\\pulse-signage\\src\\player\\public',
playerRuntime: { broadcastAnnouncementRefresh() {}, snapshotConnections() { return []; } },
playerPlaylistService: { async buildScreenPlaylist() { return { screen: { slug: 'test2' } }; } },
rtmpStreamService: {
async getSessionStatus() { return { ready: false, live: false, session: {} }; },
async getManifestFilePath() { return null; },
async getSegmentFilePath() { return null; }
},
playerIdentifier: ''
}, overrides);
}
test('screen route falls back to offline rendering when playlist build fails', async () => {
const { app, handlers } = createAppAndHandlers();
const renderCalls = [];
const originalConsoleError = console.error;
console.error = () => {};
const pool = {
async query() {
return [[{ id: 1 }]];
},
async getConnection() {
return {
async beginTransaction() {},
async query() {},
async commit() {},
async rollback() {},
release() {}
};
}
};
const common = {
renderPlayerPage(slug, data) {
renderCalls.push({ slug, data });
return data ? 'online' : 'offline';
}
};
registerPlayerRoutes(app, {
app,
pool,
common,
...createPlayerRouteOptions({
playerPlaylistService: {
async buildScreenPlaylist() {
const error = new Error('db unavailable');
error.code = 'ECONNREFUSED';
throw error;
}
}
})
});
const handler = handlers['/screen/:slug'];
assert.equal(typeof handler, 'function');
const res = createResponse();
try {
await handler({ params: { slug: 'test2' } }, res);
await new Promise((resolve) => setImmediate(resolve));
assert.equal(res.headers['X-Player-Offline'], '1');
assert.equal(res.body, 'offline');
assert.deepEqual(renderCalls, [
{ slug: 'test2', data: null }
]);
} finally {
console.error = originalConsoleError;
}
});
test('screen route renders the shell when playlist data is missing', async () => {
const { app, handlers } = createAppAndHandlers();
const renderCalls = [];
const pool = {
async query() {
return [[{ id: 1 }]];
},
async getConnection() {
return {
async beginTransaction() {},
async query() {},
async commit() {},
async rollback() {},
release() {}
};
}
};
const common = {
renderPlayerPage(slug, data) {
renderCalls.push({ slug, data });
return '<html><body><div id="app"><div class="empty">Loading screen...</div></div></body></html>';
}
};
registerPlayerRoutes(app, {
app,
pool,
common,
...createPlayerRouteOptions({
playerPlaylistService: {
async buildScreenPlaylist() {
return {
screen: { id: 7, slug: 'test2' },
playlist: null,
slides: [],
rssFeeds: [],
apiSources: [],
timetableGroups: [],
revision: 'abc123'
};
}
}
})
});
const handler = handlers['/screen/:slug'];
const res = createResponse();
await handler({ params: { slug: 'test2' } }, res);
assert.equal(res.statusCode, 200);
assert.match(String(res.body), /Loading screen/);
assert.deepEqual(renderCalls, [
{
slug: 'test2',
data: {
screen: { id: 7, slug: 'test2' },
playlist: null,
slides: [],
rssFeeds: [],
apiSources: [],
timetableGroups: [],
revision: 'abc123'
}
}
]);
});
test('playlist api route returns 404, etag, and 304 responses', async () => {
const { app, handlers } = createAppAndHandlers();
const playlistData = {
screen: { id: 7, slug: 'test2' },
playlist: { id: 22 },
slides: [],
rssFeeds: [],
apiSources: [],
timetableGroups: [],
revision: 'rev-123'
};
registerPlayerRoutes(app, {
app,
pool: { async query() { return [[{ id: 1 }]]; } },
common: { renderPlayerPage() { return ''; } },
...createPlayerRouteOptions({
playerPlaylistService: {
async buildScreenPlaylist(slug) {
if (slug === 'missing') {
return { screen: null, playlist: null, slides: [], rssFeeds: [], apiSources: [], timetableGroups: [], revision: 'none' };
}
return playlistData;
}
}
})
});
const handler = handlers['/api/screens/:slug/playlist'];
assert.equal(typeof handler, 'function');
const missingRes = createResponse();
await handler({ params: { slug: 'missing' }, headers: {} }, missingRes);
assert.equal(missingRes.statusCode, 404);
assert.deepEqual(missingRes.body, { error: 'Screen not found' });
const okRes = createResponse();
await handler({ params: { slug: 'test2' }, headers: {} }, okRes);
assert.equal(okRes.statusCode, 200);
assert.equal(okRes.headers.ETag, '"rev-123"');
assert.deepEqual(okRes.body, playlistData);
const notModifiedRes = createResponse();
await handler({ params: { slug: 'test2' }, headers: { 'if-none-match': '"rev-123"' } }, notModifiedRes);
assert.equal(notModifiedRes.statusCode, 304);
assert.equal(notModifiedRes.ended, true);
});
test('announcement route returns 503 for transient failures and 304 on matching etag', async () => {
const { app, handlers } = createAppAndHandlers();
const announcement = {
id: 9,
modified_at: '2026-08-03T00:00:00.000Z',
expires_at: '2026-08-04T00:00:00.000Z',
enabled: true
};
registerPlayerRoutes(app, {
app,
pool: { async query() { return [[{ id: 1 }]]; } },
common: {
async fetchActiveAnnouncement() {
const error = new Error('db unavailable');
error.code = 'ECONNREFUSED';
throw error;
},
renderPlayerPage() { return ''; }
},
...createPlayerRouteOptions({
rtmpStreamService: {
async getSessionStatus() { return { ready: false, live: false, session: {} }; },
async getManifestFilePath() { return null; },
async getSegmentFilePath() { return null; }
}
})
});
const handler = handlers['/api/screens/:slug/announcement'];
const unavailableRes = createResponse();
await handler({ params: { slug: 'test2' }, headers: {} }, unavailableRes);
assert.equal(unavailableRes.statusCode, 503);
assert.deepEqual(unavailableRes.body, { error: 'Announcement state unavailable.' });
registerPlayerRoutes(app, {
app,
pool: { async query() { return [[{ id: 1 }]]; } },
common: {
async fetchActiveAnnouncement() {
return announcement;
},
renderPlayerPage() { return ''; }
},
...createPlayerRouteOptions({
rtmpStreamService: {
async getSessionStatus() { return { ready: false, live: false, session: {} }; },
async getManifestFilePath() { return null; },
async getSegmentFilePath() { return null; }
}
})
});
const okHandler = handlers['/api/screens/:slug/announcement'];
const okRes = createResponse();
await okHandler({ params: { slug: 'test2' }, headers: {} }, okRes);
assert.equal(okRes.statusCode, 200);
assert.equal(okRes.headers.ETag, '"9:2026-08-03T00:00:00.000Z:2026-08-04T00:00:00.000Z:1"');
assert.deepEqual(okRes.body, { announcement, revision: '9:2026-08-03T00:00:00.000Z:2026-08-04T00:00:00.000Z:1' });
const notModifiedRes = createResponse();
await okHandler({ params: { slug: 'test2' }, headers: { 'if-none-match': '"9:2026-08-03T00:00:00.000Z:2026-08-04T00:00:00.000Z:1"' } }, notModifiedRes);
assert.equal(notModifiedRes.statusCode, 304);
assert.equal(notModifiedRes.ended, true);
});
test('rtmp session route returns not-ready and ready payloads', async () => {
const { app, handlers } = createAppAndHandlers();
const sessionCalls = [];
registerPlayerRoutes(app, {
app,
pool: { async query() { return [[{ id: 1 }]]; } },
common: { renderPlayerPage() { return ''; } },
...createPlayerRouteOptions({
rtmpStreamService: {
async getSessionStatus(source, useMutedOutput) {
sessionCalls.push({ source, useMutedOutput });
if (source === 'ready') {
return {
ready: true,
live: true,
session: { key: 'abc', playlistUrl: 'http://example.com/live.m3u8', disableAudio: useMutedOutput }
};
}
return {
ready: false,
live: false,
timedOut: true,
stderr: 'starting',
session: {}
};
},
async getManifestFilePath() { return null; },
async getSegmentFilePath() { return null; }
}
})
});
const handler = handlers['/api/rtmp/session'];
const unavailableRes = createResponse();
await handler({ headers: {}, query: { source: 'cold', disableAudio: 'true' } }, unavailableRes);
assert.equal(unavailableRes.statusCode, 503);
assert.deepEqual(unavailableRes.body, { ready: false, live: false, timedOut: true, stderr: 'starting' });
const readyRes = createResponse();
await handler({ headers: {}, query: { source: 'ready', disableAudio: 'yes' } }, readyRes);
assert.equal(readyRes.statusCode, 200);
assert.deepEqual(readyRes.body, {
key: 'abc',
playlistUrl: 'http://example.com/live.m3u8',
disableAudio: true,
ready: true,
live: true
});
assert.deepEqual(sessionCalls, [
{ source: 'cold', useMutedOutput: true },
{ source: 'ready', useMutedOutput: true }
]);
});
+156
View File
@@ -0,0 +1,156 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const http = require('http');
const WebSocket = require('ws');
require('../src/common');
const { createPageAuthToken } = require('../src/request-auth');
const { createPlayerRuntime } = require('../src/player/runtime');
const originalSecret = process.env.PULSE_SIGNAGE_SHARED_SECRET;
test.after(() => {
if (originalSecret === undefined) {
delete process.env.PULSE_SIGNAGE_SHARED_SECRET;
} else {
process.env.PULSE_SIGNAGE_SHARED_SECRET = originalSecret;
}
});
function waitFor(predicate, timeoutMs = 1000) {
const start = Date.now();
return new Promise((resolve, reject) => {
const tick = () => {
const value = predicate();
if (value) {
return resolve(value);
}
if (Date.now() - start > timeoutMs) {
return reject(new Error('Timed out waiting for runtime state.'));
}
setTimeout(tick, 25);
};
tick();
});
}
test('player runtime snapshots websocket state and checks live names', async () => {
process.env.PULSE_SIGNAGE_SHARED_SECRET = 'runtime-secret';
const runtime = createPlayerRuntime({ pool: null });
const server = http.createServer();
runtime.installWebsocket(server);
await new Promise((resolve) => server.listen(0, resolve));
const { port } = server.address();
const token = createPageAuthToken({ scope: 'player', slug: 'test2' });
const client = new WebSocket(`ws://127.0.0.1:${port}/ws/screens/test2?auth=${encodeURIComponent(token)}`);
try {
await new Promise((resolve, reject) => {
client.once('open', resolve);
client.once('error', reject);
});
client.send(JSON.stringify({
type: 'state',
clientId: 'client-1',
clientName: ' Lobby Player ',
deviceId: 'device 123!?',
userAgent: 'Mozilla/5.0 (unit test)',
viewport: { width: 1920, height: 1080 },
page: 'http://localhost:8081/screen/test2',
paused: true,
blackout: false,
currentSlide: { id: 9, title: 'Intro', kind: 'slide', playlistSignature: 'sig' }
}));
await waitFor(() => {
const snapshot = runtime.snapshotConnections('test2')[0];
return snapshot && snapshot.clientName === 'Lobby Player' ? snapshot : null;
});
const snapshot = runtime.snapshotConnections('test2')[0];
assert.equal(snapshot.clientName, 'Lobby Player');
assert.equal(snapshot.deviceId, 'device123');
assert.match(snapshot.label, /Lobby Player/);
assert.equal(snapshot.paused, true);
assert.equal(snapshot.currentSlideId, 9);
assert.equal(snapshot.currentSlideTitle, 'Intro');
assert.equal(await runtime.isClientNameAvailableOnScreen(null, 'Lobby Player', 'other-device'), false);
assert.equal(await runtime.isClientNameAvailableOnScreen(null, 'Lobby Player', 'device123'), true);
assert.equal(await runtime.isClientNameAvailableOnScreen(null, 'Other Name', 'device123'), true);
} finally {
client.close();
await new Promise((resolve) => server.close(resolve));
}
});
test('player runtime sends targeted and broadcast commands to live sockets', async () => {
process.env.PULSE_SIGNAGE_SHARED_SECRET = 'runtime-secret';
const runtime = createPlayerRuntime({ pool: null });
const server = http.createServer();
runtime.installWebsocket(server);
await new Promise((resolve) => server.listen(0, resolve));
const { port } = server.address();
const token = createPageAuthToken({ scope: 'player', slug: 'test2' });
const clientA = new WebSocket(`ws://127.0.0.1:${port}/ws/screens/test2?auth=${encodeURIComponent(token)}`);
const clientB = new WebSocket(`ws://127.0.0.1:${port}/ws/screens/test2?auth=${encodeURIComponent(token)}`);
function openClient(client) {
return new Promise((resolve, reject) => {
client.once('open', resolve);
client.once('error', reject);
});
}
function waitForMessage(client) {
return new Promise((resolve) => {
client.once('message', (raw) => {
resolve(JSON.parse(String(raw)));
});
});
}
try {
await Promise.all([openClient(clientA), openClient(clientB)]);
clientA.send(JSON.stringify({ type: 'state', clientId: 'client-a', clientName: 'Alpha', deviceId: 'device-a', page: 'http://localhost:8081/screen/test2' }));
clientB.send(JSON.stringify({ type: 'state', clientId: 'client-b', clientName: 'Beta', deviceId: 'device-b', page: 'http://localhost:8081/screen/test2' }));
await waitFor(() => {
const connections = runtime.snapshotConnections('test2');
return connections.length === 2 && connections.every((connection) => connection.clientName);
});
const [firstConnection, secondConnection] = runtime.snapshotConnections('test2');
const targetedMessagePromise = waitForMessage(clientA);
const targetedCount = await runtime.sendCommandToConnection('test2', firstConnection.id, { action: 'pause' });
assert.equal(targetedCount, 1);
const targetedMessage = await targetedMessagePromise;
assert.equal(targetedMessage.type, 'command');
assert.equal(targetedMessage.action, 'pause');
assert.equal(targetedMessage.targetConnectionId, firstConnection.id);
const broadcastPromises = [waitForMessage(clientA), waitForMessage(clientB)];
const broadcastCount = await runtime.broadcastCommand('test2', 'resume');
assert.equal(broadcastCount, 2);
const [broadcastA, broadcastB] = await Promise.all(broadcastPromises);
assert.equal(broadcastA.type, 'command');
assert.equal(broadcastA.command, 'resume');
assert.equal(broadcastB.type, 'command');
assert.equal(broadcastB.command, 'resume');
assert.equal(broadcastA.targetConnectionId, undefined);
assert.equal(broadcastB.targetConnectionId, undefined);
assert.equal(await runtime.sendCommandToConnection('test2', 'missing', 'pause'), 0);
assert.equal(secondConnection.clientName, 'Beta');
} finally {
clientA.close();
clientB.close();
await new Promise((resolve) => server.close(resolve));
}
});
+103
View File
@@ -0,0 +1,103 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const {
createPageAuthBundle,
createPageAuthToken,
createPageFetchAuthScript,
createRequestAuthHeaders,
verifyPageAuthToken,
verifyRequestAuth,
PAGE_TOKEN_HEADER,
REQUEST_SIGNATURE_HEADER,
REQUEST_TIMESTAMP_HEADER
} = require('../src/request-auth');
const originalSecret = process.env.PULSE_SIGNAGE_SHARED_SECRET;
function setSecret(value) {
process.env.PULSE_SIGNAGE_SHARED_SECRET = value;
}
test.after(() => {
if (originalSecret === undefined) {
delete process.env.PULSE_SIGNAGE_SHARED_SECRET;
} else {
process.env.PULSE_SIGNAGE_SHARED_SECRET = originalSecret;
}
});
test('request auth headers verify empty parser bodies as null payloads', () => {
setSecret('test-secret');
const originalNow = Date.now;
Date.now = () => 1700000000000;
try {
const headers = createRequestAuthHeaders({ method: 'POST', pathname: '/api/media/config', body: null, timestamp: 1700000000000 });
const req = {
method: 'POST',
path: '/api/media/config',
body: {},
headers: {
[REQUEST_TIMESTAMP_HEADER]: headers[REQUEST_TIMESTAMP_HEADER],
[REQUEST_SIGNATURE_HEADER]: headers[REQUEST_SIGNATURE_HEADER]
}
};
assert.equal(verifyRequestAuth(req), true);
} finally {
Date.now = originalNow;
}
});
test('page auth bundles round-trip and expire as expected', () => {
setSecret('test-secret');
const originalNow = Date.now;
Date.now = () => 1700000000000;
try {
const bundle = createPageAuthBundle({ scope: 'player', slug: 'test2' });
assert.equal(typeof bundle.token, 'string');
assert.equal(bundle.issuedAt, 1700000000000);
assert.equal(bundle.expiresAt, 1700043200000);
assert.deepEqual(verifyPageAuthToken(bundle.token), {
scope: 'player',
slug: 'test2',
issuedAt: 1700000000000,
expiresAt: 1700043200000
});
assert.equal(createPageAuthToken({ scope: 'player' }).split('.').length, 2);
Date.now = () => 1700043200001;
assert.equal(verifyPageAuthToken(bundle.token), null);
} finally {
Date.now = originalNow;
}
});
test('request auth rejects mismatched signatures', () => {
setSecret('test-secret');
const headers = createRequestAuthHeaders({ method: 'PUT', pathname: '/api/media/config', body: { hello: 'world' }, timestamp: 1700000000000 });
const req = {
method: 'PUT',
path: '/api/media/config',
body: { hello: 'changed' },
headers: {
[REQUEST_TIMESTAMP_HEADER]: headers[REQUEST_TIMESTAMP_HEADER],
[REQUEST_SIGNATURE_HEADER]: headers[REQUEST_SIGNATURE_HEADER]
}
};
assert.equal(verifyRequestAuth(req), false);
assert.equal(PAGE_TOKEN_HEADER, 'x-pulse-page-auth');
});
test('page fetch auth script injects renew and header logic', () => {
const script = createPageFetchAuthScript({ token: 'abc123', expiresAt: 1700000000000 });
assert.match(script, /window\.__pulsePageAuthToken = pageAuthToken/);
assert.match(script, /"x-pulse-page-auth"/);
assert.match(script, /\/api\/auth\/page/);
assert.match(script, /abc123/);
});
+109
View File
@@ -0,0 +1,109 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const {
buildTemplatePayload,
extractTemplateRegions
} = require('../src/data/templates');
test('extractTemplateRegions normalizes JSON regions and filters invalid rows', () => {
const regions = extractTemplateRegions({
regions_json: JSON.stringify([
{
region_name: 'Hero',
region_type: 'TEXT',
lock_ratio: '16 : 9',
animation_json: '{"intro":{"preset":"fadeIn"},"out":{"preset":"bounceOut"}}',
x: '10',
y: '20',
width: '320',
height: '180',
z_index: '2'
},
{
region_name: ' ',
region_type: 'image'
}
])
});
assert.deepEqual(regions, [{
region_key: 'Hero',
region_type: 'TEXT',
label: 'Hero',
lock_ratio: '16:9',
animation_json: {
intro: { preset: 'fadeIn' },
outro: { preset: 'bounceOut' },
loop: { preset: 'none' }
},
x: 10,
y: 20,
width: 320,
height: 180,
z_index: 2
}]);
});
test('buildTemplatePayload resolves canvas size and rejects duplicate region names', async () => {
const pool = {
async query(sql, params) {
if (sql.includes('FROM c_canvas_sizes WHERE id = ?')) {
assert.deepEqual(params, [4]);
return [[{ id: 4, name: 'HD', width: 1280, height: 720 }]];
}
throw new Error(`unexpected query: ${sql}`);
}
};
const payload = await buildTemplatePayload(pool, {
body: {
name: ' Main Template ',
canvas_size_id: '4',
background_color: 'not-a-color',
regions_json: JSON.stringify([
{ region_name: 'Header', region_type: 'text', lock_ratio: '4:3' }
])
},
files: []
}, null);
assert.deepEqual(payload, {
name: 'Main Template',
canvasSizeId: 4,
canvasSizeWidth: 1280,
canvasSizeHeight: 720,
backgroundImagePath: null,
backgroundColor: '#111111',
regions: [{
region_key: 'Header',
region_type: 'text',
label: 'Header',
lock_ratio: '4:3',
animation_json: {
intro: { preset: 'none' },
outro: { preset: 'none' },
loop: { preset: 'none' }
},
x: 0,
y: 0,
width: 100,
height: 100,
z_index: 0
}]
});
await assert.rejects(
() => buildTemplatePayload(pool, {
body: {
name: 'Dupes',
regions_json: JSON.stringify([
{ region_name: 'One' },
{ region_name: 'one' }
])
},
files: []
}, null),
(error) => error && error.statusCode === 400 && error.message === 'Region names must be unique on this template.'
);
});
+96
View File
@@ -0,0 +1,96 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const registerManageRoutes = require('../src/web/routes/admin/manage');
test('screen update redirects and forwards redirect when the slug changes', async () => {
const handlers = {};
const app = {
post(path, ...routeHandlers) {
handlers[path] = routeHandlers;
},
get() {}
};
const pool = {
async query(sql) {
if (sql.includes('SELECT slug FROM d_screens ORDER BY slug ASC')) {
return [[{ slug: 'alpha' }, { slug: 'beta' }]];
}
if (sql.includes('SELECT id, name, slug, playlist_id')) {
return [[{ id: 42, name: 'Old Screen', slug: 'alpha', playlist_id: null }]];
}
if (sql.includes('UPDATE d_screens SET name = ?, slug = ?, playlist_id = ?, player_id = ?, modified_by = ? WHERE id = ?')) {
return [{ affectedRows: 1 }];
}
return [[]];
}
};
const calls = [];
const pages = { renderScreenFormPage() {}, renderScreenEditPage() {} };
const deps = {
pool,
common: {
slugify(value) { return String(value || '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, ''); },
uniqueScreenSlug: async () => 'beta',
fetchDuplicateName: async () => null,
fetchScreenById: async () => ({ id: 42, name: 'Old Screen', slug: 'alpha', playlist_id: null }),
fetchScreenPlayerRecord: async () => ({ public_base_url: 'http://player.local' })
},
pages,
getAuditUserId() { return 7; },
redirectAfterSave(req, res, url) {
calls.push({ kind: 'redirectAfterSave', url });
res.redirectedTo = url;
},
notifyPlayerScreens: async (slugs, payload) => {
calls.push({ kind: 'notifyPlayerScreens', slugs, payload });
return 1;
},
broadcastDashboardState: async () => {
calls.push({ kind: 'broadcastDashboardState' });
},
getScreenDeleteBlockMessage: async () => '',
getScreenConnections: async () => [],
forwardPlayerCommand: async (slug, payload) => {
calls.push({ kind: 'forwardPlayerCommand', slug, payload });
return { ok: true };
},
playerPublicBaseUrl: 'http://player.example',
requirePermission() {
return function (_req, _res, next) {
next();
};
}
};
registerManageRoutes(app, deps);
const routeHandlers = handlers['/screens/:id'];
assert.equal(Array.isArray(routeHandlers), true);
const req = {
params: { id: '42' },
body: { name: 'Updated Screen', slug: 'beta' }
};
const res = {
redirect(url) {
this.redirectedTo = url;
},
status() {
return this;
},
send() {
return this;
}
};
await routeHandlers[1](req, res, () => {});
assert.equal(res.redirectedTo, '/screens?edit=42');
assert.deepEqual(calls, [
{ kind: 'forwardPlayerCommand', slug: 'alpha', payload: { command: 'redirect', url: 'http://player.local/screen/beta' } },
{ kind: 'redirectAfterSave', url: '/screens?edit=42' }
]);
});