501 lines
15 KiB
JavaScript
501 lines
15 KiB
JavaScript
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 '<html></html>'; } },
|
|
...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 '<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: 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 }
|
|
]);
|
|
}); |