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 '