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('local player serves a neutral shell for direct screen URLs', async () => { const { app, handlers } = createAppAndHandlers(); registerPlayerRoutes(app, { app, pool: { async query(sql) { if (String(sql).includes('FROM d_onboarding_devices d')) { return [[{ slug: 'target-screen' }]]; } return [[]]; } }, common: { renderPlayerPage() { return 'online'; } }, ...createPlayerRouteOptions({ playerDeviceId: 'player-local' }) }); const movedResponse = createResponse(); await handlers['/screen/:slug']({ params: { slug: 'target-screen' }, query: { clientId: 'tab-local' }, headers: {} }, movedResponse); assert.equal(movedResponse.statusCode, 200); assert.equal(movedResponse.body, 'online'); const oldResponse = createResponse(); await handlers['/screen/:slug']({ params: { slug: 'old-screen' }, query: { clientId: 'tab-local' }, headers: {} }, oldResponse); assert.equal(oldResponse.statusCode, 200); assert.equal(oldResponse.body, 'online'); }); test('remote player serves a neutral shell for direct screen URLs', async () => { const { app, handlers } = createAppAndHandlers(); const originalFetch = global.fetch; global.fetch = async function (url) { if (String(url).includes('/api/onboarding/status')) { return { status: 200, headers: { get() { return 'application/json'; } }, async json() { return { screenId: 27, screenSlug: 'target-screen' }; } }; } return { status: 200, headers: { get() { return 'application/json'; } }, async json() { return { screen: { slug: 'target-screen' } }; } }; }; try { registerPlayerRoutes(app, { app, pool: null, common: { renderPlayerPage() { return 'online'; } }, ...createPlayerRouteOptions({ bridgeBaseUrl: 'http://bridge.test', playerDeviceId: 'player-remote' }) }); const movedResponse = createResponse(); await handlers['/screen/:slug']({ params: { slug: 'target-screen' }, query: { clientId: 'tab-remote' }, headers: {} }, movedResponse); await new Promise((resolve) => setImmediate(resolve)); assert.equal(movedResponse.statusCode, 200); assert.equal(movedResponse.body, 'online'); const oldResponse = createResponse(); await handlers['/screen/:slug']({ params: { slug: 'old-screen' }, query: { clientId: 'tab-remote' }, headers: {} }, oldResponse); await new Promise((resolve) => setImmediate(resolve)); assert.equal(oldResponse.statusCode, 200); assert.equal(oldResponse.body, 'online'); } finally { global.fetch = originalFetch; } }); test('screen route reports the request origin for the player public base url', async () => { const { app, handlers } = createAppAndHandlers(); let reportedBaseUrl = null; registerPlayerRoutes(app, { app, pool: { async query() { return [[{ id: 1 }]]; } }, common: { renderPlayerPage() { return ''; } }, ...createPlayerRouteOptions({ playerPlaylistService: { async buildScreenPlaylist() { return { screen: { id: 7, slug: 'test2' }, playlist: null, slides: [], rssFeeds: [], apiSources: [], timetableGroups: [], revision: 'abc123' }; } }, onPlayerPublicBaseUrl(value) { reportedBaseUrl = value; } }) }); const handler = handlers['/screen/:slug']; const res = createResponse(); await handler({ params: { slug: 'test2' }, headers: { host: '127.0.0.1:8080' }, socket: { encrypted: false } }, res); assert.equal(reportedBaseUrl, 'http://127.0.0.1:8080'); }); 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: null }]); }); 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 } ]); });