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