Add template animation editor
This commit is contained in:
@@ -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.'
|
||||
);
|
||||
});
|
||||
@@ -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]);
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -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>');
|
||||
});
|
||||
@@ -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 }
|
||||
]);
|
||||
});
|
||||
@@ -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));
|
||||
}
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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.'
|
||||
);
|
||||
});
|
||||
@@ -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' }
|
||||
]);
|
||||
});
|
||||
Reference in New Issue
Block a user