Release 2.22.1: preserve playlist row identities
Publish Docker Image / build-and-push-existing-registry (./build/Dockerfile, web, pulse-signage-web) (push) Successful in 1m18s
Publish Docker Image / build-and-push-existing-registry (./build/Dockerfile.player, player, pulse-signage-player) (push) Successful in 34s

This commit is contained in:
lzstealth committed 2026-10-27 23:46:47 +00:00
1 parent c40bc6844a
commit 9b87ad4721
10 files changed
+337 -75

No files matched your search

+123 -22
View File
@@ -54,14 +54,9 @@ test('playlist create and update redirect to the canonical edit path', async ()
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 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 }]];
}
@@ -171,12 +166,8 @@ test('playlist create and update redirect to the canonical edit path', async ()
[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',
@@ -220,24 +211,134 @@ test('playlist create and update redirect to the canonical edit path', async ()
}
});
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 }];
let rules = [{ playlist_slide_id: 101, start_time: '08:00:00', end_time: '17:00:00', schedule_days_json: '[1,2]' }];
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 commit() {}, async rollback() {}, release() {},
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('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('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('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] });
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 [[]];
}