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