385 lines
16 KiB
JavaScript
385 lines
16 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('SELECT ps.id, ps.slide_id, ps.group_id')) return [[]];
|
|
if (sql.startsWith('SELECT id, group_key FROM c_playlist_groups')) return [[]];
|
|
if (sql.startsWith('SELECT r.id, r.playlist_slide_id')) return [[]];
|
|
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, [
|
|
{ kind: 'redirectAfterSave', url: '/playlists/4/edit', path: '/playlists' },
|
|
'update-playlist',
|
|
'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 save updates retained slide, group, and schedule rows in place', async () => {
|
|
const writes = [];
|
|
let items = [
|
|
{ id: 101, slide_id: 11, group_id: 201, duration_seconds: 10, position: 0, use_video_duration: 0, disable_audio: 1 },
|
|
{ id: 102, slide_id: 12, group_id: null, duration_seconds: 15, position: 1, use_video_duration: 0, disable_audio: 1 }
|
|
];
|
|
let groups = [{ id: 201, group_key: 'news' }];
|
|
let rules = [
|
|
{ id: 301, playlist_slide_id: null, playlist_group_id: 201, position: 0, start_datetime: null, end_datetime: null, start_time: '08:00:00', end_time: '17:00:00', schedule_days_json: '[1,2]' },
|
|
{ id: 302, playlist_slide_id: 101, playlist_group_id: null, position: 0, start_datetime: null, end_datetime: null, start_time: '09:00:00', end_time: '18:00:00', schedule_days_json: null },
|
|
{ id: 303, playlist_slide_id: 102, playlist_group_id: null, position: 0, start_datetime: null, end_datetime: null, start_time: '10:00:00', end_time: '19:00:00', schedule_days_json: null }
|
|
];
|
|
const connection = {
|
|
async beginTransaction() {}, async commit() {}, async rollback() {}, release() {},
|
|
async query(sql, params) {
|
|
if (sql.startsWith('SELECT ps.id, ps.slide_id, ps.group_id FROM c_playlist_slides')) return [structuredClone(items)];
|
|
if (sql.startsWith('SELECT id, group_key FROM c_playlist_groups')) return [structuredClone(groups)];
|
|
if (sql.startsWith('SELECT r.id, r.playlist_slide_id')) return [structuredClone(rules)];
|
|
if (sql.startsWith('SELECT sl.id, st.canvas_size_id')) return [[{ id: 11, canvas_size_id: 7 }, { id: 13, canvas_size_id: 7 }]];
|
|
if (sql.startsWith('UPDATE c_playlists SET')) return [{ affectedRows: 1 }];
|
|
if (sql.startsWith('UPDATE c_playlist_groups SET')) {
|
|
writes.push({ kind: 'update-group', id: params[6], name: params[0] });
|
|
return [{ affectedRows: 1 }];
|
|
}
|
|
if (sql.startsWith('UPDATE c_playlist_slides SET')) {
|
|
writes.push({ kind: 'update-slide', id: params[7], duration: params[2] });
|
|
items = items.map(item => item.id === params[7] ? Object.assign({}, item, {
|
|
slide_id: params[0], position: params[1], duration_seconds: params[2],
|
|
use_video_duration: params[3], disable_audio: params[4], group_id: params[5]
|
|
}) : item);
|
|
return [{ affectedRows: 1 }];
|
|
}
|
|
if (sql.startsWith('UPDATE c_playlist_schedule_rules SET')) {
|
|
writes.push({ kind: 'update-rule', id: params[7] });
|
|
rules = rules.map(rule => rule.id === params[7] ? Object.assign({}, rule, {
|
|
position: params[0], start_datetime: params[1], end_datetime: params[2],
|
|
start_time: params[3], end_time: params[4], schedule_days_json: params[5]
|
|
}) : rule);
|
|
return [{ affectedRows: 1 }];
|
|
}
|
|
if (sql.startsWith('INSERT INTO c_playlist_slides')) {
|
|
writes.push({ kind: 'insert-slide', slideId: params[1] });
|
|
items.push({ id: 103, slide_id: params[1], group_id: params[6], duration_seconds: params[3], position: params[2] });
|
|
return [{ insertId: 103 }];
|
|
}
|
|
if (sql.startsWith('DELETE FROM c_playlist_slides')) {
|
|
writes.push({ kind: 'delete-slide', id: params[0] });
|
|
items = items.filter(item => item.id !== params[0]);
|
|
rules = rules.filter(rule => rule.playlist_slide_id !== params[0]);
|
|
}
|
|
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 }; }
|
|
},
|
|
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,
|
|
redirectAfterSave(_req, res, url) { res.redirect(url); }, notifyPlayerScreens: async () => {}, broadcastDashboardState: async () => {},
|
|
requirePermission: () => () => {}
|
|
});
|
|
|
|
const response = createResponse();
|
|
await handlers['/playlists/:id'].at(-1)({
|
|
params: { id: '4' },
|
|
body: {
|
|
name: 'Loop',
|
|
slide_id: ['11', '13'], row_key: ['101', 'new-slide'],
|
|
duration_seconds: ['20', '12'], use_video_duration: ['0', '0'], disable_audio: ['1', '1'],
|
|
group_key: ['news', ''], group_name: ['News updated', ''], group_pick_count: ['1', ''], group_shuffle: ['0', ''],
|
|
group_mode: ['repeat', ''], group_repeat_count: ['3', ''],
|
|
group_schedule_rules_json: [JSON.stringify([{ start_time: '08:30', end_time: '17:30', days: [1, 2] }]), ''],
|
|
schedule_rule_row_key: ['101'], schedule_rule_start_time: ['09:30'],
|
|
schedule_rule_end_time: ['18:30'], schedule_rule_days_csv: ['']
|
|
}
|
|
}, response, error => { throw error; });
|
|
|
|
assert.equal(response.redirectedTo, '/playlists/4/edit', response.statusCode + ': ' + String(response.body || ''));
|
|
assert.deepEqual(writes, [
|
|
{ kind: 'update-group', id: 201, name: 'News updated' },
|
|
{ kind: 'update-rule', id: 301 },
|
|
{ kind: 'update-slide', id: 101, duration: 20 },
|
|
{ kind: 'update-rule', id: 302 },
|
|
{ kind: 'insert-slide', slideId: 13 },
|
|
{ kind: 'delete-slide', id: 102 }
|
|
]);
|
|
assert.deepEqual(items.map(item => item.id), [101, 103]);
|
|
assert.deepEqual(rules.map(rule => rule.id), [301, 302]);
|
|
});
|
|
|
|
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, position: 0, use_video_duration: 0, disable_audio: 1 }];
|
|
let rules = [{ id: 301, playlist_slide_id: 101, playlist_group_id: null, position: 0, start_datetime: null, end_datetime: null, start_time: '08:00:00', end_time: '17:00:00', schedule_days_json: '[1,2]' }];
|
|
const events = [];
|
|
const connection = {
|
|
async beginTransaction() {}, async commit() {}, async rollback() {}, release() {},
|
|
async query(sql, params) {
|
|
if (sql.startsWith('SELECT ps.id, ps.slide_id, ps.group_id FROM c_playlist_slides')) return [structuredClone(items)];
|
|
if (sql.includes('FROM c_playlist_slides ps') && sql.includes('WHERE ps.playlist_id')) return [structuredClone(items)];
|
|
if (sql.startsWith('SELECT id, group_key FROM c_playlist_groups')) return [[]];
|
|
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('UPDATE c_playlist_slides SET')) {
|
|
items = items.map(item => item.id === params[7] ? Object.assign({}, item, {
|
|
slide_id: params[0], position: params[1], duration_seconds: params[2],
|
|
use_video_duration: params[3], disable_audio: params[4], group_id: params[5]
|
|
}) : item);
|
|
return [{ affectedRows: 1 }];
|
|
}
|
|
if (sql.startsWith('UPDATE c_playlist_schedule_rules SET')) {
|
|
rules = rules.map(rule => rule.id === params[7] ? Object.assign({}, rule, {
|
|
position: params[0], start_datetime: params[1], end_datetime: params[2],
|
|
start_time: params[3] + ':00', end_time: params[4] + ':00', schedule_days_json: params[5]
|
|
}) : rule);
|
|
return [{ affectedRows: 1 }];
|
|
}
|
|
if (sql.startsWith('DELETE FROM c_playlist_slides')) {
|
|
items = items.filter(item => item.id !== params[0]);
|
|
rules = rules.filter(rule => rule.playlist_slide_id !== params[0]);
|
|
}
|
|
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 });
|
|
}); |