Files
pulse-signage/test/playlists-routes.test.js
T
lzstealth b71958cb38
Publish Docker Image / build-and-push-existing-registry (./build/Dockerfile, web, pulse-signage-web) (push) Successful in 1m21s
Publish Docker Image / build-and-push-existing-registry (./build/Dockerfile.player, player, pulse-signage-player) (push) Successful in 32s
Release 2.22.0: normalize playlist schedules and unify player sync
2026-10-27 22:54:38 +00:00

284 lines
10 KiB
JavaScript

const test = require('node:test');
const assert = require('node:assert/strict');
require('../src/common');
const registerPlaylistRoutes = require('../src/web/routes/admin/playlists');
function createAppAndHandlers() {
const handlers = {};
const app = {
post(path, ...routeHandlers) {
handlers[path] = routeHandlers;
},
get() {}
};
return { app, handlers };
}
function createResponse() {
return {
redirectedTo: '',
statusCode: 200,
redirect(url) {
this.redirectedTo = url;
},
status(code) {
this.statusCode = code;
return this;
},
send(body) {
this.body = body;
return this;
}
};
}
test('playlist create and update redirect to the canonical edit path', async () => {
const calls = [];
let groupInsertParams = null;
let slideInsertParams = null;
const groupRuleInsertParams = [];
let canvasSizeEnabled = 1;
const connection = {
async beginTransaction() {},
async commit() {},
async rollback() {},
release() {},
async query(sql, params) {
if (sql.startsWith('INSERT INTO c_playlists')) {
return [{ insertId: 4 }];
}
if (sql.startsWith('UPDATE c_playlists SET')) {
calls.push('update-playlist');
return [{ affectedRows: 1 }];
}
if (sql.startsWith('DELETE FROM c_playlist_slides')) {
calls.push('delete-slides');
return [{ affectedRows: 1 }];
}
if (sql.startsWith('DELETE FROM c_playlist_groups')) {
calls.push('delete-groups');
return [{ affectedRows: 1 }];
}
if (sql.startsWith('SELECT sl.id, st.canvas_size_id')) {
return [[{ id: 11, canvas_size_id: 7 }]];
}
if (sql.startsWith('INSERT INTO c_playlist_groups')) {
groupInsertParams = params;
calls.push('insert-group');
return [{ insertId: 71 }];
}
if (sql.startsWith('INSERT INTO c_playlist_slides')) {
slideInsertParams = params;
calls.push('insert-slide');
return [{ insertId: 101 }];
}
if (sql.startsWith('INSERT INTO c_playlist_schedule_rules')) {
groupRuleInsertParams.push(params);
calls.push('insert-group-rule');
return [{ insertId: 81 }];
}
return [[]];
}
};
const { app, handlers } = createAppAndHandlers();
registerPlaylistRoutes(app, {
pool: {
async getConnection() {
return connection;
}
},
common: {
async fetchDuplicateName() { return null; },
async fetchCanvasSizeById() { return { id: 7, enabled: canvasSizeEnabled }; },
async fetchPlaylistById() { return { id: 4, canvas_id: 7 }; }
},
pages: {},
fetchOrderedPlaylistSlides: async () => [],
fetchScreensByPlaylistId: async () => [],
fetchPlaylistCanvasId: async () => null,
readArrayField(body, keys) {
for (const key of keys) {
if (Object.prototype.hasOwnProperty.call(body, key)) {
return Array.isArray(body[key]) ? body[key] : [body[key]];
}
}
return [];
},
getAuditUserId: () => 12,
redirectAfterSave(req, res, url) {
calls.push({ kind: 'redirectAfterSave', url, path: req.path });
res.redirect(url);
},
notifyPlayerScreens: async () => {},
broadcastDashboardState: async () => {},
getPlaylistDeleteBlockMessage: async () => '',
requirePermission() {
return function (_req, _res, next) {
next();
};
}
});
const createHandlers = handlers['/playlists'];
const updateHandlers = handlers['/playlists/:id'];
assert.equal(Array.isArray(createHandlers), true);
assert.equal(Array.isArray(updateHandlers), true);
const createRes = createResponse();
await createHandlers[1]({
path: '/playlists',
body: {
name: 'Lunch Loop',
canvas_size_id: '7',
slide_id: []
}
}, createRes, () => {});
const updateRes = createResponse();
await updateHandlers[1]({
path: '/playlists/4',
params: { id: '4' },
body: {
name: 'Lunch Loop',
slide_id: ['11'],
row_key: ['row-11'],
duration_seconds: ['10'],
use_video_duration: ['0'],
disable_audio: ['1'],
group_key: ['news'],
group_name: ['News'],
group_pick_count: ['1'],
group_shuffle: ['1'],
group_mode: ['repeat'],
group_repeat_count: ['2'],
group_schedule_rules_json: [JSON.stringify([
{ start_datetime: '2026-10-01T09:00', days: [1, 2, 3] },
{ end_datetime: '2026-10-31T18:00' }
])]
}
}, updateRes, error => { throw error; });
assert.equal(createRes.redirectedTo, '/playlists/4/edit');
assert.equal(updateRes.redirectedTo, '/playlists/4/edit', updateRes.statusCode + ': ' + String(updateRes.body || ''));
assert.deepEqual(groupInsertParams, [4, 'news', 'News', 1, 1, 'repeat', 2, 12, 12]);
assert.equal(slideInsertParams[6], 71);
assert.deepEqual(groupRuleInsertParams, [
[71, 0, '2026-10-01T09:00', null, null, null, '[1,2,3]', 12, 12],
[71, 1, null, '2026-10-31T18:00', null, null, null, 12, 12]
]);
assert.deepEqual(calls, [
'delete-slides',
'delete-groups',
{ kind: 'redirectAfterSave', url: '/playlists/4/edit', path: '/playlists' },
'update-playlist',
'delete-slides',
'delete-groups',
'insert-group',
'insert-group-rule',
'insert-group-rule',
'insert-slide',
{ kind: 'redirectAfterSave', url: '/playlists/4/edit', path: '/playlists/4' }
]);
canvasSizeEnabled = 0;
const disabledCreateRes = createResponse();
await createHandlers[1]({
path: '/playlists',
body: { name: 'Disabled size playlist', canvas_size_id: '7', slide_id: [] }
}, disabledCreateRes, (error) => { throw error; });
assert.equal(disabledCreateRes.statusCode, 400);
assert.equal(disabledCreateRes.body, 'Canvas size is disabled.');
const retainDisabledRes = createResponse();
await updateHandlers[1]({
path: '/playlists/4',
params: { id: '4' },
body: { name: 'Lunch Loop', canvas_size_id: '7', slide_id: [] }
}, retainDisabledRes, (error) => { throw error; });
assert.equal(retainDisabledRes.redirectedTo, '/playlists/4/edit');
for (const pickCount of ['0', '2']) {
const invalidRes = createResponse();
await updateHandlers[1]({
path: '/playlists/4',
params: { id: '4' },
body: { name: 'Lunch Loop', slide_id: ['11'], group_key: ['news'], group_name: ['News'], group_pick_count: [pickCount] }
}, invalidRes, () => {});
assert.equal(invalidRes.statusCode, 400);
}
for (const settings of [{ group_mode: ['unknown'] }, { group_repeat_count: ['0'] }, { group_repeat_count: ['101'] }, { group_repeat_count: ['1.5'] }]) {
const invalidRes = createResponse();
await updateHandlers[1]({
path: '/playlists/4', params: { id: '4' },
body: Object.assign({ name: 'Lunch Loop', slide_id: ['11'], group_key: ['news'], group_name: ['News'], group_pick_count: ['1'] }, settings)
}, invalidRes, () => {});
assert.equal(invalidRes.statusCode, 400);
}
});
test('playlist audit compares persisted items and schedules without recreated row IDs', async () => {
let items = [{ id: 101, slide_id: 11, group_id: null, duration_seconds: 10 }];
let rules = [{ playlist_slide_id: 101, start_time: '08:00:00', end_time: '17:00:00', schedule_days_json: '[1,2]' }];
const events = [];
const connection = {
async commit() {}, async rollback() {}, release() {},
async query(sql, params) {
if (sql.includes('FROM c_playlist_slides ps') && sql.includes('WHERE ps.playlist_id')) return [structuredClone(items)];
if (sql.includes('FROM c_playlist_schedule_rules r')) return [structuredClone(rules)];
if (sql.startsWith('SELECT sl.id, st.canvas_size_id')) return [[{ id: 11, canvas_size_id: 7 }]];
if (sql.startsWith('DELETE FROM c_playlist_slides')) { items = []; rules = []; }
if (sql.startsWith('DELETE FROM c_playlist_groups')) return [{ affectedRows: 0 }];
if (sql.startsWith('INSERT INTO c_playlist_slides')) {
items = [{ id: 202, slide_id: params[1], group_id: params[6], duration_seconds: params[3] }];
return [{ insertId: 202 }];
}
if (sql.startsWith('INSERT INTO c_playlist_schedule_rules')) {
rules.push({ playlist_slide_id: params[0], start_time: params[4] + ':00', end_time: params[5] + ':00', schedule_days_json: params[6] });
}
return [[]];
}
};
const { app, handlers } = createAppAndHandlers();
registerPlaylistRoutes(app, {
pool: { async getConnection() { return connection; } },
common: {
async fetchDuplicateName() { return null; },
async fetchPlaylistById() { return { id: 4, name: 'Loop', canvas_id: 7, fade_between_slides: 0, skip_unavailable_rtmp: 0 }; }
},
readArrayField(body, keys) {
for (const key of keys) {
if (Object.hasOwn(body, key)) return Array.isArray(body[key]) ? body[key] : [body[key]];
}
return [];
},
fetchScreensByPlaylistId: async () => [], getAuditUserId: () => 12,
recordRequestAuditEvent: async (_pool, _req, event) => { events.push(event); },
redirectAfterSave() {}, notifyPlayerScreens: async () => {}, broadcastDashboardState: async () => {},
requirePermission: () => () => {}
});
const body = {
name: 'Loop', slide_id: ['11'], row_key: ['row-11'], duration_seconds: ['10'],
schedule_rule_row_key: ['row-11'], schedule_rule_start_time: ['08:00'], schedule_rule_end_time: ['17:00'], schedule_rule_days_csv: ['1,2']
};
const save = async () => {
const response = createResponse();
await handlers['/playlists/:id'].at(-1)({ params: { id: '4' }, body }, response, error => { throw error; });
assert.equal(response.statusCode, 200);
return events.at(-1).details.changes;
};
assert.deepEqual(await save(), {});
body.duration_seconds = ['20'];
assert.deepEqual(await save(), { 'items[0].duration_seconds': { from: 10, to: 20 } });
body.schedule_rule_start_time = ['09:00'];
assert.deepEqual(await save(), { 'items[0].scheduleRules[0].start_time': { from: '08:00:00', to: '09:00:00' } });
body.slide_id = [];
body.row_key = [];
body.duration_seconds = [];
const removed = await save();
assert.deepEqual(removed['items[0].slide_id'], { from: 11, to: null });
assert.deepEqual(removed['items[0].scheduleRules[0].start_time'], { from: '09:00:00', to: null });
});