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 '
Loading screen...
'; } }; 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 } ]); });