Release v2.5.6

This commit is contained in:
2026-08-05 19:38:16 +01:00
parent a8ef35b287
commit 9f831f04bc
161 changed files with 8487 additions and 1295 deletions
+72
View File
@@ -0,0 +1,72 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const path = require('node:path');
const Handlebars = require('handlebars');
const { registerHandlebars } = require('../src/web/handlebars');
function compileActionButtons(hash) {
const hb = Handlebars.create();
registerHandlebars(hb, {
viewsRoot: path.join(__dirname, '..', 'src', 'web', 'views'),
hasPermission: () => true,
hasAnyPermission: () => true
});
const template = hb.compile('{{{saveActionButtons ' + Object.entries(hash).map(function ([key, value]) {
return key + '="' + String(value).replace(/"/g, '"') + '"';
}).join(' ') + '}}}');
return String(template({}));
}
test('save action buttons render save close new cancel and delete controls', () => {
const html = compileActionButtons({
formId: 'example-form',
saveUrl: '/items/1',
cancelUrl: '/items',
deleteUrl: '/items/1/delete',
deleteConfirmMessage: 'Delete this item?',
deleteTitle: 'Delete is disabled.',
showSaveAndClose: true,
showSaveAndNew: true,
showDelete: true
});
assert.match(html, /<button type="submit" class="btn btn-success" form="example-form" name="save_action" value="save">Save<\/button>/);
assert.match(html, /name="save_action" value="close">Save and Close<\/button>/);
assert.match(html, /name="save_action" value="new">Save and New<\/button>/);
assert.match(html, /<a class="btn btn-warning" href="\/items" data-confirm-unsaved="You have unsaved changes\. Leave this page\?">Cancel<\/a>/);
assert.match(html, /<button type="button" class="btn btn-danger" data-delete-action-url="\/items\/1\/delete" data-confirm-message="Delete this item\?">Delete<\/button>/);
});
test('save action buttons can hide the secondary dropdown and delete control', () => {
const html = compileActionButtons({
formId: 'example-form',
saveUrl: '/items/1',
cancelUrl: '/items',
showSaveAndClose: false,
showSaveAndNew: false,
showDelete: false
});
assert.match(html, /<button type="submit" class="btn btn-success" form="example-form" name="save_action" value="save">Save<\/button>/);
assert.doesNotMatch(html, /Save and Close/);
assert.doesNotMatch(html, /Save and New/);
assert.match(html, /<a class="btn btn-warning" href="\/items" data-confirm-unsaved="You have unsaved changes\. Leave this page\?">Cancel<\/a>/);
assert.doesNotMatch(html, /Delete<\/button>/);
});
test('save action buttons keep the new action wired for create pages', () => {
const html = compileActionButtons({
formId: 'example-form',
saveUrl: '/users',
cancelUrl: '/users',
showSaveAndClose: true,
showSaveAndNew: true,
showDelete: true
});
assert.match(html, /name="save_action" value="new">Save and New<\/button>/);
assert.match(html, /name="save_action" value="close">Save and Close<\/button>/);
});
+70
View File
@@ -0,0 +1,70 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const registerAccountRoutes = require('../src/web/routes/admin/account');
const { validatePasswordStrength } = require('../src/auth');
test('account password update redirects back to the account page', async () => {
const handlers = {};
const app = {
post(path, ...routeHandlers) {
handlers[path] = routeHandlers;
},
get() {}
};
const deps = {
pool: {
async query(sql) {
if (sql.includes('SELECT id, name, username, password_hash, password_salt, password_iterations FROM a_users WHERE id = ? LIMIT 1')) {
return [[{ id: 5, password_hash: 'hash', password_salt: 'salt', password_iterations: 1 }]];
}
if (sql.includes('UPDATE a_users SET password_hash = ?, password_salt = ?, password_iterations = ?, modified_by = ? WHERE id = ?')) {
return [{ affectedRows: 1 }];
}
if (sql.includes('DELETE FROM a_sessions WHERE user_id = ?')) {
return [{ affectedRows: 1 }];
}
return [[]];
}
},
common: {},
pages: {},
formatDashboardDate: () => '',
getAuditUserId: () => 9,
verifyPassword: () => true,
validatePasswordStrength: validatePasswordStrength,
hashPassword: () => ({ hash: 'new-hash', salt: 'new-salt', iterations: 2 }),
createUserSession: async () => 'session-token',
setSessionCookie: () => {}
};
registerAccountRoutes(app, deps);
const routeHandlers = handlers['/account/password'];
assert.equal(Array.isArray(routeHandlers), true);
const req = {
body: {
current_password: 'current-password',
new_password: 'NewPassword!1',
confirm_password: 'NewPassword!1'
},
currentUser: { id: 5 }
};
const res = {
redirect(url) {
this.redirectedTo = url;
},
status() {
return this;
},
send() {
return this;
}
};
await routeHandlers[0](req, res, () => {});
assert.equal(res.redirectedTo, '/account?message=Password%20updated.');
});
+11
View File
@@ -0,0 +1,11 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const adminPageScript = fs.readFileSync(require.resolve('../src/web/public/js/admin/admin-page.js'), 'utf8');
test('async save errors keep validation failures as warning toasts', () => {
assert.ok(adminPageScript.includes('function isWarningSaveError(error)'));
assert.ok(adminPageScript.includes('error.status = response.status;'));
assert.ok(adminPageScript.includes('var variant = isWarningSaveError(error) ? \'warning\' : \'danger\';'));
});
@@ -0,0 +1,193 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const registerUsersRoutes = require('../src/web/routes/admin/users');
test('user create route allows users without roles', async () => {
const handlers = {};
const app = {
get() {},
post(path, ...routeHandlers) {
handlers[path] = routeHandlers;
}
};
let insertedArgs = null;
let syncedRoles = null;
const deps = {
pool: {
async query(sql) {
if (sql.includes('SELECT id FROM a_users WHERE username = ? LIMIT 1')) {
return [[]];
}
return [[]];
},
async getConnection() {
return {
async beginTransaction() {},
async query(sql, args) {
if (sql.includes('INSERT INTO a_users')) {
insertedArgs = args;
return [{ insertId: 11 }];
}
return [[]];
},
async commit() {},
async rollback() {},
release() {}
};
}
},
common: {
validateMaxLength(value) {
return String(value || '').trim();
},
fetchDuplicateName: async () => null
},
pages: {
renderUsersAddPage() {
return '';
}
},
formatDashboardDate: () => '',
getAuditUserId: () => 1,
hashPassword: () => ({ hash: 'hash', salt: 'salt', iterations: 1 }),
validatePasswordStrength: () => '',
readArrayField: () => [],
rbacData: {
fetchRoles: async () => ([]),
fetchUsersWithRolesPage: async () => ({ users: [], totalItems: 0, currentPage: 1 }),
syncUserRoles: async (_connection, userId, roleIds) => {
syncedRoles = { userId, roleIds };
}
},
requirePermission() {
return function (_req, _res, next) {
next();
};
}
};
registerUsersRoutes(app, deps);
const routeHandlers = handlers['/users'];
assert.equal(Array.isArray(routeHandlers), true);
const req = {
body: {
name: 'No Roles User',
username: 'noroles',
password: 'ValidPass!1',
confirm_password: 'ValidPass!1'
},
currentUser: { id: 1 }
};
const res = {
redirect(url) {
this.redirectedTo = url;
},
status() {
return this;
},
send() {
return this;
}
};
await routeHandlers[1](req, res, () => {});
assert.ok(insertedArgs);
assert.deepEqual(syncedRoles, { userId: 11, roleIds: [] });
assert.equal(res.redirectedTo, '/users/11/edit?message=User%20created.');
});
test('user create save and new goes to the blank create page', async () => {
const handlers = {};
const app = {
get() {},
post(path, ...routeHandlers) {
handlers[path] = routeHandlers;
}
};
const deps = {
pool: {
async query(sql) {
if (sql.includes('SELECT id FROM a_users WHERE username = ? LIMIT 1')) {
return [[]];
}
return [[]];
},
async getConnection() {
return {
async beginTransaction() {},
async query(sql) {
if (sql.includes('INSERT INTO a_users')) {
return [{ insertId: 15 }];
}
return [[]];
},
async commit() {},
async rollback() {},
release() {}
};
}
},
common: {
validateMaxLength(value) {
return String(value || '').trim();
},
fetchDuplicateName: async () => null
},
pages: {
renderUsersAddPage() {
return '';
}
},
formatDashboardDate: () => '',
getAuditUserId: () => 1,
hashPassword: () => ({ hash: 'hash', salt: 'salt', iterations: 1 }),
validatePasswordStrength: () => '',
readArrayField: () => [],
rbacData: {
fetchRoles: async () => ([]),
fetchUsersWithRolesPage: async () => ({ users: [], totalItems: 0, currentPage: 1 }),
syncUserRoles: async () => {}
},
requirePermission() {
return function (_req, _res, next) {
next();
};
}
};
registerUsersRoutes(app, deps);
const routeHandlers = handlers['/users'];
const req = {
body: {
name: 'No Roles User',
username: 'noroles',
password: 'ValidPass!1',
confirm_password: 'ValidPass!1',
save_action: 'new'
},
currentUser: { id: 1 }
};
const res = {
redirect(url) {
this.redirectedTo = url;
},
status() {
return this;
},
send() {
return this;
}
};
await routeHandlers[1](req, res, () => {});
assert.equal(res.redirectedTo, '/users/new?message=User%20created.');
});
@@ -0,0 +1,95 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const registerUsersRoutes = require('../src/web/routes/admin/users');
test('user edit save and new goes to the blank create page', async () => {
const handlers = {};
const app = {
get() {},
post(path, ...routeHandlers) {
handlers[path] = routeHandlers;
}
};
const deps = {
pool: {
async query(sql) {
if (sql.includes('SELECT id FROM a_users WHERE id = ? LIMIT 1')) {
return [[{ id: 7 }]];
}
if (sql.includes('SELECT id FROM a_users WHERE username = ? AND id <> ? LIMIT 1')) {
return [[]];
}
return [[]];
},
async getConnection() {
return {
async beginTransaction() {},
async query(sql) {
if (sql.includes('UPDATE a_users SET name = ?, username = ?, modified_by = ? WHERE id = ?')) {
return [{ affectedRows: 1 }];
}
return [[]];
},
async commit() {},
async rollback() {},
release() {}
};
}
},
common: {
validateMaxLength(value) {
return String(value || '').trim();
},
fetchDuplicateName: async () => null
},
pages: {},
formatDashboardDate: () => '',
getAuditUserId: () => 1,
hashPassword: () => ({ hash: 'hash', salt: 'salt', iterations: 1 }),
validatePasswordStrength: () => '',
readArrayField: () => [],
rbacData: {
fetchRoles: async () => ([]),
fetchUsersWithRolesPage: async () => ({ users: [], totalItems: 0, currentPage: 1 }),
fetchUserWithRoles: async () => ({ id: 7, name: 'User', username: 'user', roleIds: [], inUse: false }),
syncUserRoles: async () => {}
},
requirePermission() {
return function (_req, _res, next) {
next();
};
}
};
registerUsersRoutes(app, deps);
const routeHandlers = handlers['/users/:id/username'];
assert.equal(Array.isArray(routeHandlers), true);
const req = {
params: { id: '7' },
body: {
name: 'User',
username: 'user',
save_action: 'new'
},
currentUser: { id: 1 }
};
const res = {
redirect(url) {
this.redirectedTo = url;
},
status() {
return this;
},
send() {
return this;
}
};
await routeHandlers[1](req, res, () => {});
assert.equal(res.redirectedTo, '/users/new?message=User%20updated.');
});
+78
View File
@@ -0,0 +1,78 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const registerUsersRoutes = require('../src/web/routes/admin/users');
const { validatePasswordStrength } = require('../src/auth');
test('user password reset redirects back to the selected user edit page', async () => {
const handlers = {};
const app = {
post(path, ...routeHandlers) {
handlers[path] = routeHandlers;
},
get() {}
};
const deps = {
pool: {
async query(sql) {
if (sql.includes('SELECT id FROM a_users WHERE id = ? LIMIT 1')) {
return [[{ id: 7 }]];
}
if (sql.includes('UPDATE a_users SET password_hash = ?, password_salt = ?, password_iterations = ?, modified_by = ? WHERE id = ?')) {
return [{ affectedRows: 1 }];
}
if (sql.includes('DELETE FROM a_sessions WHERE user_id = ?')) {
return [{ affectedRows: 1 }];
}
return [[]];
}
},
common: {
fetchDuplicateName: async () => null
},
pages: {},
formatDashboardDate: () => '',
getAuditUserId: () => 3,
hashPassword: () => ({ hash: 'hash', salt: 'salt', iterations: 1 }),
validatePasswordStrength: validatePasswordStrength,
readArrayField: () => [],
rbacData: {
fetchRoles: async () => [],
fetchUsersWithRolesPage: async () => ({ users: [], totalItems: 0, currentPage: 1 }),
fetchUserWithRoles: async () => ({ id: 7, name: 'User', username: 'user', roleIds: [], inUse: false }),
syncUserRoles: async () => {}
},
requirePermission() {
return function (_req, _res, next) {
next();
};
}
};
registerUsersRoutes(app, deps);
const routeHandlers = handlers['/users/:id/password'];
assert.equal(Array.isArray(routeHandlers), true);
const req = {
params: { id: '7' },
body: { password: 'NewPassword!1', confirm_password: 'NewPassword!1' },
currentUser: { id: 1 }
};
const res = {
redirect(url) {
this.redirectedTo = url;
},
status() {
return this;
},
send() {
return this;
}
};
await routeHandlers[1](req, res, () => {});
assert.equal(res.redirectedTo, '/users/7/edit?message=Password%20updated.');
});
+111
View File
@@ -0,0 +1,111 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const Module = require('module');
const path = require('path');
const originalResolveFilename = Module._resolveFilename;
Module._resolveFilename = function (request, parent, isMain, options) {
if (request === '#src/data/announcement-icons') {
return path.join(__dirname, '..', 'src', 'data', 'announcement-icons.js');
}
if (request === '#root/package.json') {
return path.join(__dirname, '..', 'package.json');
}
return originalResolveFilename.call(this, request, parent, isMain, options);
};
const { buildAnnouncementFormViewModel } = require('../src/web/routes/signage/announcements/form-view-model');
const renderAnnouncementsPage = require('../src/web/routes/signage/announcements/list');
const renderAnnouncementEditPage = require('../src/web/routes/signage/announcements/edit');
test('announcement add page shows a disabled delete button placeholder', () => {
const model = buildAnnouncementFormViewModel({ id: null, canDelete: false }, '', { permissionKeys: ['announcements.delete'] }, { announcementTypes: [], announcementColors: [], announcementIcons: [], announcementScreens: [] }, false);
assert.equal(model.showSaveSecondaryActions, true);
assert.equal(model.showDeleteAction, true);
assert.equal(model.deleteDisabled, true);
assert.match(model.deleteTitle, /available after the announcement has been created/i);
});
test('announcement edit page enables delete when the announcement is inactive', () => {
const model = buildAnnouncementFormViewModel({ id: 12, canDelete: true, expires_at: '2026-08-04T00:00:00.000Z' }, '', { permissionKeys: ['announcements.delete'] }, { announcementTypes: [], announcementColors: [], announcementIcons: [], announcementScreens: [] }, true);
assert.equal(model.showDeleteAction, true);
assert.equal(model.deleteDisabled, false);
assert.equal(model.deleteConfirmMessage, 'Delete this announcement?');
assert.equal(model.deleteTitle, '');
});
test('announcement edit page renders delete confirmation on the delete button', () => {
const html = renderAnnouncementEditPage({
id: 12,
message: 'Test message',
short_label: 'Test announcement',
announcement_type: 'top-banner',
color_key: 'primary',
icon_key: 'info-circle',
duration_seconds: null,
expires_at: '2026-08-03T00:00:00.000Z',
canDelete: true,
screen_ids: [1]
}, {
announcementTypes: [],
announcementColors: [],
announcementIcons: [],
announcementScreens: []
}, '', { permissionKeys: ['announcements.delete'] }, {
announcementTypes: [],
announcementColors: [],
announcementIcons: [],
announcementScreens: []
});
assert.match(html, /data-delete-action-url="\/announcements\/12\/delete"/);
assert.match(html, /data-confirm-message="Delete this announcement\?"/);
});
test('announcement list disables delete for active announcements', () => {
const html = renderAnnouncementsPage({
announcements: [
{
id: 1,
short_label: 'Active announcement',
message: 'Now live',
icon_key: 'info-circle',
color_key: 'primary',
announcement_type: 'top-banner',
duration_seconds: null,
expires_at: null,
screen_target_count: 1,
total_screen_count: 1,
screen_targets_label: 'Main Screen'
}
],
pagination: { totalItems: 1, currentPage: 1 }
}, '', { permissionKeys: ['announcements.delete'] });
assert.match(html, /action="\/announcements\/1\/delete"[\s\S]*button class="btn btn-sm btn-outline-danger" type="submit" disabled title="This announcement is currently active and cannot be deleted\.">Delete<\/button>/);
});
test('announcement list enables delete for inactive announcements', () => {
const html = renderAnnouncementsPage({
announcements: [
{
id: 2,
short_label: 'Expired announcement',
message: 'Done',
icon_key: 'info-circle',
color_key: 'primary',
announcement_type: 'top-banner',
duration_seconds: null,
expires_at: '2026-08-03T00:00:00.000Z',
screen_target_count: 1,
total_screen_count: 1,
screen_targets_label: 'Main Screen'
}
],
pagination: { totalItems: 1, currentPage: 1 }
}, '', { permissionKeys: ['announcements.delete'] });
assert.match(html, /action="\/announcements\/2\/delete"[\s\S]*button class="btn btn-sm btn-danger" type="submit"\s*>Delete<\/button>/);
});
@@ -0,0 +1,40 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const { fetchAnnouncementsPage } = require('../src/data/announcements');
test('announcement search pagination inserts the search clause before group by', async () => {
const queries = [];
const pool = {
async query(sql, params) {
queries.push({ sql, params });
if (sql.startsWith('SELECT COUNT(*) AS count FROM (')) {
return [[{ count: 1 }]];
}
return [[{
id: 1,
message: 'Hello',
short_label: 'Test',
announcement_type: 'top-banner',
color_key: 'info',
icon_key: 'info-circle',
duration_seconds: null,
expires_at: null,
created_at: null,
modified_at: null,
created_by: null,
modified_by: null,
screen_target_count: 0,
total_screen_count: 0,
screen_targets_label: ''
}]];
}
};
await fetchAnnouncementsPage(pool, 1, 25, 'hello', 'description', 'asc');
assert.match(queries[0].sql, /WHERE \(/i);
assert.match(queries[0].sql, /GROUP BY a\.id\) AS filtered_rows$/);
assert.match(queries[1].sql, /WHERE \(/i);
assert.ok(!/GROUP BY a\.id\s+WHERE/i.test(queries[1].sql));
});
+111
View File
@@ -0,0 +1,111 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const Module = require('module');
const originalResolveFilename = Module._resolveFilename;
Module._resolveFilename = function (request, parent, isMain, options) {
if (request === '#src/data/announcement-icons') {
return path.join(__dirname, '..', 'src', 'data', 'announcement-icons.js');
}
if (request === '#root/package.json') {
return path.join(__dirname, '..', 'package.json');
}
return originalResolveFilename.call(this, request, parent, isMain, options);
};
const { buildDuplicateAnnouncementName, buildDuplicateAnnouncement } = require('../src/web/routes/signage/announcements/duplicate');
const { buildAnnouncementFormViewModel } = require('../src/web/routes/signage/announcements/form-view-model');
const renderAnnouncementsPage = require('../src/web/routes/signage/announcements/list');
test('announcement duplicate helper copies editable fields and resets runtime state', () => {
const source = {
id: 7,
message: 'Maintenance starts soon.',
short_label: 'Maintenance',
announcement_type: 'top-banner',
color_key: 'warning',
icon_key: 'info-circle',
duration_seconds: 120,
expires_at: '2026-08-04T10:00:00.000Z',
screen_ids: [2, 5]
};
const duplicate = buildDuplicateAnnouncement(source, buildDuplicateAnnouncementName(source.short_label));
assert.equal(duplicate.id, null);
assert.equal(duplicate.short_label, 'Copy of Maintenance');
assert.equal(duplicate.expires_at, null);
assert.equal(duplicate.message, 'Maintenance starts soon.');
assert.equal(duplicate.announcement_type, 'top-banner');
assert.equal(duplicate.color_key, 'warning');
assert.equal(duplicate.icon_key, 'info-circle');
assert.equal(duplicate.duration_seconds, 120);
assert.deepEqual(duplicate.screen_ids, [2, 5]);
});
test('announcement duplicate form model keeps info toast variant and secondary actions', () => {
const model = buildAnnouncementFormViewModel({ id: null, short_label: 'Maintenance' }, 'Review the copied values and save when ready.', { permissionKeys: ['announcements.create'] }, { messageVariant: 'info', announcementTypes: [], announcementColors: [], announcementIcons: [], announcementScreens: [] }, false);
assert.equal(model.messageVariant, 'info');
assert.equal(model.showSaveSecondaryActions, true);
});
test('announcement form model disables play when there are no screen targets', () => {
const model = buildAnnouncementFormViewModel({ id: 7, short_label: 'Maintenance', screen_ids: [], expires_at: '2026-08-03T00:00:00.000Z' }, '', { permissionKeys: ['announcements.update'] }, { announcementTypes: [], announcementColors: [], announcementIcons: [], announcementScreens: [] }, true);
assert.equal(model.actionClassName, 'btn-outline-info');
assert.equal(model.actionDisabled, true);
assert.match(model.actionDisabledTitle, /Select at least one screen group/i);
});
test('announcement list renders a disabled info play button without targets', () => {
const html = renderAnnouncementsPage({
announcements: [
{
id: 7,
short_label: 'Maintenance',
message: 'Maintenance starts soon.',
icon_key: 'info-circle',
color_key: 'warning',
announcement_type: 'top-banner',
duration_seconds: null,
expires_at: '2026-08-03T00:00:00.000Z',
screen_target_count: 0,
total_screen_count: 2,
screen_targets_label: ''
}
],
pagination: { totalItems: 1, currentPage: 1 }
}, '', { permissionKeys: ['announcements.update'] });
assert.match(html, /class="btn btn-sm btn-outline-info" type="submit" disabled title="Select at least one screen group to play this announcement\."/);
});
test('announcement list template includes duplicate action', () => {
const template = fs.readFileSync(path.join(__dirname, '..', 'src', 'web', 'views', 'signage', 'announcements', 'list.hbs'), 'utf8');
assert.match(template, /\/announcements\/{\{id\}\}\/duplicate/);
assert.match(template, />Dupe<\/a>/);
});
test('announcement form template uses an icon-backed type picker', () => {
const template = fs.readFileSync(path.join(__dirname, '..', 'src', 'web', 'views', 'signage', 'announcements', 'form.hbs'), 'utf8');
assert.match(template, /data-announcement-type-picker-toggle/);
assert.match(template, /data-announcement-type-option/);
assert.match(template, /data-type-icon="\{\{icon\}\}"/);
});
test('announcement form template uses a checkbox card grid for screen groups', () => {
const template = fs.readFileSync(path.join(__dirname, '..', 'src', 'web', 'views', 'signage', 'announcements', 'form.hbs'), 'utf8');
assert.match(template, /announcement-screen-picker/);
assert.match(template, /data-announcement-screen-select-all/);
assert.match(template, /data-announcement-screen-select-none/);
assert.match(template, /name="screen_ids\[\]"/);
assert.match(template, /announcement-screen-chip/);
assert.match(template, /announcement-screen-chip__check/);
assert.ok(!/announcement-screen-chip__icon/.test(template));
});
+62
View File
@@ -0,0 +1,62 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { buildDuplicateApiSourceName, buildDuplicateApiSource } = require('../src/web/routes/data-sources/api-sources/duplicate');
const { buildApiSourceFormViewModel } = require('../src/web/routes/data-sources/api-sources/form-view-model');
test('api source duplicate helper copies editable fields and resets runtime state', () => {
const source = {
id: 9,
name: 'Source A',
api_url: 'https://example.com/api',
auth_method: 'bearer',
auth_username: 'user',
auth_password: 'pass',
auth_bearer_token: 'token',
auth_header_name: 'X-API-Key',
auth_header_value: 'secret',
items_path: 'items',
update_interval_value: 15,
update_interval_unit: 'minutes',
last_pulled_at: '2026-08-01T12:00:00.000Z',
last_pull_error: 'Bad response',
last_response_status: 500,
last_response_content_type: 'application/json',
last_response_json: '{"ok":false}'
};
const duplicate = buildDuplicateApiSource(source, buildDuplicateApiSourceName(source.name));
assert.equal(duplicate.id, null);
assert.equal(duplicate.name, 'Copy of Source A');
assert.equal(duplicate.api_url, 'https://example.com/api');
assert.equal(duplicate.apiUrl, 'https://example.com/api');
assert.equal(duplicate.authMethod, 'bearer');
assert.equal(duplicate.authUsername, 'user');
assert.equal(duplicate.authPassword, 'pass');
assert.equal(duplicate.authBearerToken, 'token');
assert.equal(duplicate.authHeaderName, 'X-API-Key');
assert.equal(duplicate.authHeaderValue, 'secret');
assert.equal(duplicate.itemsPath, 'items');
assert.equal(duplicate.lastPulledAt, null);
assert.equal(duplicate.lastPullError, '');
assert.equal(duplicate.lastResponseStatus, null);
assert.equal(duplicate.lastResponseContentType, '');
assert.equal(duplicate.lastResponseJson, '');
});
test('api source duplicate form model keeps info toast variant', () => {
const model = buildApiSourceFormViewModel({ id: null, name: 'Source A' }, 'Review the copied values and save when ready.', { permissionKeys: ['api-sources.create'] }, { messageVariant: 'info', showSaveSecondaryActions: true }, false);
assert.equal(model.messageVariant, 'info');
assert.equal(model.showSaveSecondaryActions, true);
});
test('api source list template includes duplicate action', () => {
const template = fs.readFileSync(path.join(__dirname, '..', 'src', 'web', 'views', 'data-sources', 'api-sources', 'list.hbs'), 'utf8');
assert.match(template, /\/data-sources\/api-sources\/{\{id\}\}\/duplicate/);
assert.match(template, />Dupe<\/a>/);
});
+128
View File
@@ -0,0 +1,128 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const registerAuthRoutes = require('../src/web/routes/auth');
const renderLoginPage = require('../src/web/routes/auth/login');
const { createSessionService, normalizeReturnToPath } = require('../src/web/lib/auth/session');
test('login page preserves the return target in a hidden field', () => {
const html = renderLoginPage('', '/slides/6/edit');
assert.match(html, /name="returnTo"/);
assert.match(html, /value="\/slides\/6\/edit"/);
});
test('requireAuth preserves the original request path when redirecting to login', () => {
const sessionService = createSessionService({
sessionCookieName: 'pulse_session',
sessionMaxAgeMs: 60 * 60 * 1000,
hashSessionToken: (value) => value,
createSessionToken: () => 'token'
});
const req = {
originalUrl: '/slides/6/edit?tab=content'
};
const res = {
headers: {},
getHeader(name) {
return this.headers[name];
},
setHeader(name, value) {
this.headers[name] = value;
},
redirect(url) {
this.redirectedTo = url;
}
};
sessionService.requireAuth(req, res, () => {});
assert.equal(res.redirectedTo, '/login?returnTo=%2Fslides%2F6%2Fedit%3Ftab%3Dcontent');
});
test('normalizeReturnToPath keeps same-origin absolute urls and strips the origin', () => {
assert.equal(
normalizeReturnToPath('https://signage.example.com/slides/6/edit?tab=content', 'https://signage.example.com'),
'/slides/6/edit?tab=content'
);
assert.equal(
normalizeReturnToPath('https://evil.example.com/slides/6/edit', 'https://signage.example.com'),
''
);
});
test('successful login returns to the requested link instead of the dashboard', async () => {
const handlers = {};
const app = {
get(path, ...routeHandlers) {
handlers['GET ' + path] = routeHandlers;
},
post(path, ...routeHandlers) {
handlers['POST ' + path] = routeHandlers;
}
};
registerAuthRoutes(app, {
pool: {
async query(sql) {
if (sql.includes('FROM a_users WHERE username = ? LIMIT 1')) {
return [[{ id: 7, name: 'Demo User', username: 'demo', password_hash: 'hash', password_salt: 'salt', password_iterations: 1 }]];
}
return [[]];
}
},
pages: {
renderLoginPage(message, returnTo) {
return { message, returnTo };
}
},
createUserSession: async () => 'session-token',
setSessionCookie(res, token) {
res.sessionToken = token;
},
clearSessionCookie() {},
parseCookies() {
return {};
},
consumeAuthMessageCookie() {
return '';
},
setAuthMessageCookie() {},
hashSessionToken(value) {
return value;
},
verifyPassword() {
return true;
},
sessionCookieName: 'pulse_session'
});
const req = {
body: {
username: 'demo',
password: 'secret',
returnTo: '/slides/6/edit?tab=content'
},
query: {
returnTo: '/slides/6/edit?tab=content'
}
};
const res = {
redirect(url) {
this.redirectedTo = url;
},
status() {
return this;
},
send(value) {
this.sentValue = value;
return this;
}
};
await handlers['POST /login'][0](req, res, () => {});
assert.equal(res.sessionToken, 'session-token');
assert.equal(res.redirectedTo, '/slides/6/edit?tab=content');
});
+72
View File
@@ -0,0 +1,72 @@
const test = require('node:test');
const assert = require('node:assert/strict');
require('../src/common');
const {
renderBackgroundTasksPage,
renderBackgroundTasksScheduledPage
} = require('../src/web/routes/settings/background-tasks-page');
test('background task pages opt into 24-hour timestamps, confirm clearing tasks, and search by dates', () => {
const baseQueueData = {
tasks: [
{
title: 'Example task',
key: 'secret-key',
category: 'Refresh',
status: 'completed',
createdAt: '2026-08-04T10:15:00.000Z',
startedAt: '2026-08-04T10:16:00.000Z',
finishedAt: '2026-08-04T10:17:00.000Z'
},
{
title: 'Older task',
key: 'older-key',
category: 'Refresh',
status: 'completed',
createdAt: '2026-08-01T10:15:00.000Z',
startedAt: '2026-08-01T10:16:00.000Z',
finishedAt: '2026-08-01T10:17:00.000Z'
}
],
summary: { counts: {}, total: 1, activeCount: 0 }
};
const queueHtml = renderBackgroundTasksPage(baseQueueData, '', {
permissions: ['background-tasks.allow']
});
const queueKeySearchHtml = renderBackgroundTasksPage(Object.assign({}, baseQueueData, { search: 'secret-key' }), '', {
permissions: ['background-tasks.allow']
});
const queueDateSearchHtml = renderBackgroundTasksPage(Object.assign({}, baseQueueData, { search: '2026-08-04' }), '', {
permissions: ['background-tasks.allow']
});
const scheduledHtml = renderBackgroundTasksScheduledPage(
{
recurringTasks: [
{
title: 'Recurring refresh',
key: 'refresh',
intervalMs: 60000,
nextRunAt: '2026-08-04T10:15:00.000Z',
lastRunAt: '2026-08-04T09:15:00.000Z'
}
],
summary: { counts: {}, total: 1, activeCount: 0, scheduledCount: 1 }
},
'',
{
permissions: ['scheduled-tasks.allow']
}
);
assert.match(queueHtml, /data-confirm-message="Clear finished background tasks\?"/);
assert.match(queueHtml, /data-local-datetime-format="24h"/);
assert.doesNotMatch(queueKeySearchHtml, /Example task/);
assert.match(queueDateSearchHtml, /Example task/);
assert.match(scheduledHtml, /data-local-datetime-format="24h"/);
});
+294
View File
@@ -0,0 +1,294 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
require('../src/common');
const { buildCanvasSizePayload, MAX_CANVAS_SIZE_DIMENSION } = require('../src/data');
const { buildCanvasSizeFormViewModel } = require('../src/web/routes/signage/canvas-sizes/form-view-model');
const { buildDuplicateCanvasSizeName, buildDuplicateCanvasSize } = require('../src/web/routes/signage/canvas-sizes/duplicate');
const registerContentRoutes = require('../src/web/routes/admin/content');
const adminPageScript = fs.readFileSync(require.resolve('../src/web/public/js/admin/admin-page.js'), 'utf8');
const canvasSizeFormTemplate = fs.readFileSync(require.resolve('../src/web/views/signage/canvas-sizes/form.hbs'), 'utf8');
const canvasSizeListTemplate = fs.readFileSync(require.resolve('../src/web/views/signage/canvas-sizes/list.hbs'), 'utf8');
test('canvas size payload rejects dimensions above the shared maximum', () => {
assert.throws(() => {
buildCanvasSizePayload({ body: { name: 'Oversized', width: MAX_CANVAS_SIZE_DIMENSION + 1, height: 1080 } }, null);
}, {
message: 'Canvas size dimensions must be 16384 or less.'
});
});
test('canvas size edit form locks dimensions while the size is in use', () => {
const viewModel = buildCanvasSizeFormViewModel({
id: 7,
name: 'Full HD',
width: 1920,
height: 1080,
inUse: true
}, '', null, true);
assert.equal(viewModel.dimensionsLocked, true);
assert.equal(viewModel.deleteDisabled, true);
assert.equal(viewModel.maxDimension, MAX_CANVAS_SIZE_DIMENSION);
assert.ok(adminPageScript.includes('function initLimitedInputValues()'));
assert.ok(adminPageScript.includes('function initLimitedTextInputValues()'));
assert.ok(canvasSizeFormTemplate.includes('id="canvas-size-name" name="name" type="text" maxlength="255" data-limit-text-length'));
assert.ok(canvasSizeFormTemplate.includes('data-limit-input-value'));
});
test('buildDuplicateCanvasSize copies canvas size fields for the create page', () => {
const duplicate = buildDuplicateCanvasSize({
id: 7,
name: 'Full HD',
width: 1920,
height: 1080
}, buildDuplicateCanvasSizeName('Full HD'));
assert.equal(duplicate.id, null);
assert.equal(duplicate.name, 'Copy of Full HD');
assert.equal(duplicate.width, 1920);
assert.equal(duplicate.height, 1080);
});
test('canvas size list template includes duplicate action', () => {
assert.match(canvasSizeListTemplate, /\/canvas-sizes\/\{\{id\}\}\/duplicate/);
assert.match(canvasSizeListTemplate, />Dupe<\/a>/);
});
test('canvas size update returns a warning when an in-use canvas changes dimensions', async () => {
const handlers = {};
const app = {
get(path, ...routeHandlers) {
handlers[path] = routeHandlers;
},
post(path, ...routeHandlers) {
handlers[path] = routeHandlers;
}
};
const queries = [];
const pool = {
async query(sql) {
queries.push(sql);
if (sql.includes('FROM c_canvas_sizes WHERE id = ?')) {
return [[{ id: 7, name: 'Full HD', width: 1920, height: 1080 }]];
}
if (sql.includes('SELECT COUNT(*) AS ref_count FROM c_templates WHERE canvas_size_id = ?')) {
return [[{ ref_count: 1 }]];
}
throw new Error('Unexpected query: ' + sql);
}
};
const deps = {
pool,
common: {
fetchCanvasSizesPage: async () => ({}),
getSearchQuery: () => '',
getSortQuery: () => '',
getSortDirectionQuery: () => 'asc',
fetchCanvasSizeById: async (connection, id) => {
const [rows] = await connection.query('SELECT id, name, width, height, created_at, modified_at, created_by, modified_by FROM c_canvas_sizes WHERE id = ?', [id]);
return rows[0] || null;
},
buildCanvasSizePayload: buildCanvasSizePayload,
fetchDuplicateName: async () => null
},
pages: {
renderCanvasSizesPage() { return ''; },
renderCanvasSizeEditPage() { return ''; },
renderCanvasSizeAddPage() { return ''; }
},
upload: {
any() {
return function (_req, _res, next) {
next();
};
},
single() {
return function (_req, _res, next) {
next();
};
}
},
setAuthMessageCookie() {},
fetchScreensBySlideId: async () => [],
fetchScreensByTemplateId: async () => [],
collectUploadReferencesFromSlide: () => [],
collectUploadReferencesFromTemplate: () => [],
collectUploadReferencesFromPayload: () => [],
removeUnusedUploadFiles: async () => {},
syncPlaylistUploadsOnChange: async () => {},
getAuditUserId: () => 1,
redirectAfterSave: () => {},
notifyPlayerScreens: async () => 0,
broadcastDashboardState: async () => {},
backgroundTaskQueue: { enqueueTask: async () => null },
getSlideDeleteBlockMessage: async () => '',
getTemplateDeleteBlockMessage: async () => '',
getCanvasSizeDeleteBlockMessage: async () => '',
requirePermission() {
return function (_req, _res, next) {
next();
};
},
hasAnyPermission: () => false,
uploadDir: 'e:\\Projects Git\\pulse-signage\\media\\uploads'
};
registerContentRoutes(app, deps);
const routeHandlers = handlers['/canvas-sizes/:id'];
assert.ok(Array.isArray(routeHandlers));
const req = {
params: { id: '7' },
body: { name: 'Full HD', width: 2560, height: 1440 }
};
const res = {
statusCode: 0,
body: '',
redirectedTo: '',
redirect(url) {
this.redirectedTo = url;
},
status(code) {
this.statusCode = code;
return this;
},
send(body) {
this.body = body;
return this;
}
};
await routeHandlers[1](req, res, () => {});
assert.equal(res.statusCode, 400);
assert.equal(res.body, 'This canvas size is still used by one or more templates, so its dimensions cannot be changed.');
assert.equal(res.redirectedTo, '');
assert.equal(queries.length, 2);
});
test('slide upload cleanup route removes unused uploads', async () => {
const handlers = {};
const app = {
get(path, ...routeHandlers) {
handlers[path] = routeHandlers;
},
post(path, ...routeHandlers) {
handlers[path] = routeHandlers;
}
};
let cleanupCall = null;
const deps = {
pool: {
async query() {
return [[]];
}
},
common: {
fetchTemplatesData: async () => ({}),
fetchRssFeedsData: async () => ({ rssFeeds: [] }),
fetchApiSourcesData: async () => ({ apiSources: [] }),
fetchTimetablesData: async () => ({ timetableGroups: [] }),
parseJsonSafe: () => null,
fetchRssFeedItemsByFeedId: async () => [],
normalizeRssFeedItem: (item) => item,
fetchSlidesPage: async () => ({}),
fetchSlideById: async () => null,
fetchTemplatesPage: async () => ({}),
fetchTemplateById: async () => null,
fetchCanvasSizesPage: async () => ({}),
fetchCanvasSizeById: async () => null,
getSearchQuery: () => '',
getSortQuery: () => '',
getSortDirectionQuery: () => 'asc',
fetchDuplicateName: async () => null,
buildCanvasSizePayload
},
pages: {
renderCanvasSizesPage() { return ''; },
renderCanvasSizeEditPage() { return ''; },
renderCanvasSizeAddPage() { return ''; },
renderSlideAddPage() { return ''; },
renderSlideEditPage() { return ''; },
renderTemplatesPage() { return ''; },
renderTemplateAddPage() { return ''; },
renderTemplateEditPage() { return ''; }
},
upload: {
any() {
return function (_req, _res, next) {
next();
};
},
single() {
return function (_req, _res, next) {
next();
};
}
},
setAuthMessageCookie() {},
fetchScreensBySlideId: async () => [],
fetchScreensByTemplateId: async () => [],
collectUploadReferencesFromSlide: () => [],
collectUploadReferencesFromTemplate: () => [],
collectUploadReferencesFromPayload: () => [],
removeUnusedUploadFiles: async (pool, uploadDir, uploadPaths) => {
cleanupCall = { pool, uploadDir, uploadPaths };
},
syncPlaylistUploadsOnChange: async () => {},
getAuditUserId: () => 1,
redirectAfterSave: () => {},
notifyPlayerScreens: async () => 0,
broadcastDashboardState: async () => {},
backgroundTaskQueue: { enqueueTask: async () => null },
getSlideDeleteBlockMessage: async () => '',
getTemplateDeleteBlockMessage: async () => '',
getCanvasSizeDeleteBlockMessage: async () => '',
requirePermission() {
return function (_req, _res, next) {
next();
};
},
hasAnyPermission: () => true,
uploadDir: 'e:\\Projects Git\\pulse-signage\\media\\uploads'
};
registerContentRoutes(app, deps);
const routeHandlers = handlers['/slides/uploads/cleanup'];
assert.ok(Array.isArray(routeHandlers));
const req = {
body: { uploadPaths: ['/media/uploads/test-file.png'] },
currentUser: { id: 1, permissions: ['slides.create'] }
};
const res = {
statusCode: 0,
body: '',
sendStatus(code) {
this.statusCode = code;
return this;
},
status(code) {
this.statusCode = code;
return this;
},
json(body) {
this.body = body;
return this;
}
};
await routeHandlers[1](req, res, () => {});
assert.equal(res.statusCode, 204);
assert.equal(cleanupCall.uploadDir, 'e:\\Projects Git\\pulse-signage\\media\\uploads');
assert.deepEqual(cleanupCall.uploadPaths, ['/media/uploads/test-file.png']);
});
+25
View File
@@ -0,0 +1,25 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const { fetchSlidesPage } = require('../src/data/admin');
test('slide search does not include slide content_json', async () => {
const queries = [];
const pool = {
async query(sql, params) {
queries.push({ sql, params });
if (sql.startsWith('SELECT COUNT(*)')) {
return [[{ count: 0 }]];
}
return [[{ id: 1 }]];
}
};
await fetchSlidesPage(pool, 1, 25, 'hello world', 'title', 'asc');
assert.equal(queries.length, 2);
assert.doesNotMatch(queries[0].sql, /CAST\(s\.content_json AS CHAR\)/);
assert.doesNotMatch(queries[1].sql, /CAST\(s\.content_json AS CHAR\)/);
assert.match(queries[0].sql, /CAST\(s\.title AS CHAR\)/);
assert.match(queries[0].sql, /CAST\(st\.name AS CHAR\)/);
});
+11
View File
@@ -0,0 +1,11 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const { validatePasswordStrength } = require('../src/auth');
test('password strength requires a longer mixed-character password', () => {
assert.equal(validatePasswordStrength('weakpass'), 'Password must be at least 10 characters and include 3 of: uppercase, lowercase, number, and symbol.');
assert.equal(validatePasswordStrength('1234567890'), 'Password must be at least 10 characters and include 3 of: uppercase, lowercase, number, and symbol.');
assert.equal(validatePasswordStrength('Password12'), '');
assert.equal(validatePasswordStrength('NewPassword!1'), '');
});
+108
View File
@@ -0,0 +1,108 @@
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const test = require('node:test');
const assert = require('node:assert/strict');
function loadAnnouncementTemplateHelpers() {
const scriptPath = path.join(__dirname, '..', 'src', 'player', 'public', 'js', 'player-announcement-templates.js');
const script = fs.readFileSync(scriptPath, 'utf8');
const sandbox = {
window: {},
module: { exports: {} },
exports: {},
console
};
sandbox.window = sandbox;
vm.runInNewContext(script, sandbox, { filename: scriptPath });
return sandbox;
}
function createTextNode(width) {
return {
width: width,
attributes: {},
cloneNode() {
return createTextNode(width);
},
setAttribute(name, value) {
this.attributes[name] = String(value);
},
getBoundingClientRect() {
return { width: this.width };
}
};
}
test('announcement marquee helper repeats enough content and measures a real cycle width', () => {
const sandbox = loadAnnouncementTemplateHelpers();
assert.equal(typeof sandbox.initPlayerAnnouncementMarquee, 'function');
const textNodes = [createTextNode(100), createTextNode(100)];
const track = {
dataset: {},
style: {
values: {},
setProperty(name, value) {
this.values[name] = String(value);
}
},
attributes: { 'data-announcement-marquee-rate': '50' },
parentElement: null,
appended: [],
getAttribute(name) {
return this.attributes[name];
},
querySelectorAll() {
return textNodes.slice();
},
appendChild(node) {
this.appended.push(node);
textNodes.push(node);
return node;
},
getBoundingClientRect() {
const gap = 20;
const width = textNodes.length * 100 + Math.max(0, textNodes.length - 1) * gap;
return { width: width };
}
};
const messageContainer = {
getBoundingClientRect() {
return { width: 420 };
}
};
sandbox.window.getComputedStyle = function () {
return { columnGap: '20px', gap: '20px' };
};
sandbox.initPlayerAnnouncementMarquee(track, { container: messageContainer });
assert.equal(track.dataset.marqueeMeasured, 'true');
assert.equal(track.style.values['--announcement-scroll-distance'], '120.00px');
assert.equal(track.style.values['--announcement-scroll-duration'], '8.00s');
assert.equal(track.style.animationDuration, '8.00s');
assert.ok(track.appended.length >= 3);
});
test('announcement markup strips line breaks from the message', () => {
const sandbox = loadAnnouncementTemplateHelpers();
sandbox.playerAnnouncementTemplates = {
topBanner: '<div class="message">{{MESSAGE}}</div>'
};
const markup = sandbox.renderPlayerAnnouncementMarkup({
announcement_type: 'top-banner',
message: 'First line\nSecond line\r\nThird line',
short_label: 'Breaking News',
color_key: 'primary',
icon_key: 'info-circle'
});
assert.match(markup, /First line Second line Third line/);
assert.doesNotMatch(markup, /<br\s*\/?\s*>/i);
});
+3 -1
View File
@@ -56,7 +56,7 @@ test('buildScreenPlaylist assembles slides, templates, and derived values', asyn
id: 101,
title: 'Intro',
template_id: 33,
content_json: '{"videoRegion":{"type":"video","duration_seconds":12.3456},"textRegion":{"type":"text","value":"Hello"}}',
content_json: '{"videoRegion":{"type":"video","duration_seconds":12.3456},"textRegion":{"type":"text","value":"Hello"},"qrRegion":{"type":"qr-code","value":"https://example.com"}}',
modified_at: '2026-08-03T00:00:01.000Z',
position: 1,
duration_seconds: 9,
@@ -111,6 +111,8 @@ test('buildScreenPlaylist assembles slides, templates, and derived values', asyn
assert.equal(payload.slides[0].disable_audio, true);
assert.equal(payload.slides[0].content.videoRegion.disable_audio, true);
assert.equal(payload.slides[0].content.videoRegion.cache_bust, '2026-08-03T00:00:01.000Z');
assert.equal(typeof payload.slides[0].content.qrRegion.qr_preview, 'string');
assert.match(payload.slides[0].content.qrRegion.qr_preview, /^data:image\/svg\+xml/);
assert.equal(payload.slides[0].scheduleRules.length, 2);
assert.equal(payload.slides[0].template.regions[0].label, 'Text Region');
assert.equal(payload.rssFeeds[0].items[0].normalized, true);
+56
View File
@@ -0,0 +1,56 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
require('../src/common');
const { buildDuplicatePlaylistName, buildDuplicatePlaylist } = require('../src/web/routes/signage/playlists/duplicate');
const renderPlaylistFormPage = require('../src/web/routes/signage/playlists/add');
test('buildDuplicatePlaylist copies playlist fields for the create page', () => {
const duplicate = buildDuplicatePlaylist({
id: 12,
name: 'Lunch Loop',
fade_between_slides: 1,
skip_unavailable_rtmp: 0,
canvas_id: 7
}, buildDuplicatePlaylistName('Lunch Loop'));
assert.equal(duplicate.id, 12);
assert.equal(duplicate.name, 'Copy of Lunch Loop');
assert.equal(duplicate.fade_between_slides, true);
assert.equal(duplicate.skip_unavailable_rtmp, false);
assert.equal(duplicate.canvas_id, 7);
});
test('playlist duplicate form page reuses the copied playlist id for rows', async () => {
const html = await renderPlaylistFormPage(
{ id: 12, name: 'Copy of Lunch Loop', fade_between_slides: true, skip_unavailable_rtmp: false, canvas_id: 7 },
'Review the copied values and save when ready.',
{ permissionKeys: ['playlists.create'] },
{
playlists: [],
canvasSizes: [{ id: 7, name: 'HD', width: 1280, height: 720 }],
slides: [
{ id: 3, title: 'Welcome', template_id: 8, content_json: '{}', thumbnail_path: '', canvas_size_id: 7, canvas_width: 1280, canvas_height: 720 }
],
playlistSlides: [
{ id: 21, playlist_id: 12, position: 0, duration_seconds: 10, use_video_duration: 0, disable_audio: 1, slide_id: 3, title: 'Welcome', content_json: '{}', thumbnail_path: '', canvas_size_id: 7, canvas_width: 1280, canvas_height: 720, scheduleRules: [] }
]
},
null
);
assert.match(html, /Copy of Lunch Loop/);
assert.match(html, /data-playlist-id="12"/);
assert.match(html, /Welcome/);
assert.match(html, /Save and Close/);
});
test('playlist list template includes duplicate action', () => {
const template = fs.readFileSync(path.join(__dirname, '..', 'src', 'web', 'views', 'signage', 'playlists', 'list.hbs'), 'utf8');
assert.match(template, /\/playlists\/\{\{id\}\}\/duplicate/);
assert.match(template, />Dupe<\/a>/);
});
+62 -4
View File
@@ -1,14 +1,28 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const { createQrCodeSvg, buildQrCodeContent } = require('../src/data/qr-code');
const { createQrCodeSvg, createStyledQrCodeSvg, createStyledQrCodeDataUrl, buildQrCodeContent } = require('../src/data/qr-code');
const { buildSlidePayload } = require('../src/data/slides');
test('createQrCodeSvg renders a qr svg for non-empty values', async () => {
const svg = await createQrCodeSvg('https://example.com');
assert.equal(typeof svg, 'string');
assert.match(svg, /^<svg[\s>]/);
assert.match(svg, /<svg[\s>]/);
});
test('createStyledQrCodeSvg renders a styled qr svg for non-empty values', async () => {
const svg = await createStyledQrCodeSvg('https://example.com');
assert.equal(typeof svg, 'string');
assert.match(svg, /<svg[\s>]/);
});
test('createStyledQrCodeDataUrl renders a styled qr data url for non-empty values', async () => {
const dataUrl = await createStyledQrCodeDataUrl('https://example.com');
assert.equal(typeof dataUrl, 'string');
assert.match(dataUrl, /^data:image\/svg\+xml/);
});
test('buildQrCodeContent stores qr svg content for qr-code regions', async () => {
@@ -17,7 +31,9 @@ test('buildQrCodeContent stores qr svg content for qr-code regions', async () =>
assert.equal(content.type, 'qr-code');
assert.equal(content.value, 'https://example.com');
assert.equal(typeof content.qr_svg, 'string');
assert.match(content.qr_svg, /^<svg[\s>]/);
assert.match(content.qr_svg, /<svg[\s>]/);
assert.equal(typeof content.qr_preview, 'string');
assert.match(content.qr_preview, /^data:image\/svg\+xml/);
});
test('buildSlidePayload stores qr-code slide content from the submitted url', async () => {
@@ -31,6 +47,10 @@ test('buildSlidePayload stores qr-code slide content from the submitted url', as
return [[{ id: 22, template_id: 7, region_key: 'qrRegion', region_type: 'qr-code', label: 'QR' }]];
}
if (sql.includes('FROM i_rss_feed_items')) {
return [[{ count: 2 }]];
}
return [[]];
}
};
@@ -48,5 +68,43 @@ test('buildSlidePayload stores qr-code slide content from the submitted url', as
assert.equal(content.qrRegion.type, 'qr-code');
assert.equal(content.qrRegion.value, 'https://example.com');
assert.equal(typeof content.qrRegion.qr_svg, 'string');
assert.match(content.qrRegion.qr_svg, /^<svg[\s>]/);
assert.match(content.qrRegion.qr_svg, /<svg[\s>]/);
assert.equal(typeof content.qrRegion.qr_preview, 'string');
assert.match(content.qrRegion.qr_preview, /^data:image\/svg\+xml/);
});
test('buildSlidePayload clamps rss item_number to the available item count', async () => {
const pool = {
async query(sql) {
if (sql.includes('FROM c_templates st')) {
return [[{ id: 8, name: 'Template 8', canvas_size_id: 1, canvas_size_width: 1920, canvas_size_height: 1080 }]];
}
if (sql.includes('FROM c_template_regions')) {
return [[{ id: 23, template_id: 8, region_key: 'rssRegion', region_type: 'rss', label: 'RSS' }]];
}
if (sql.includes('FROM i_rss_feed_items')) {
return [[{ count: 2 }]];
}
return [[]];
}
};
const payload = await buildSlidePayload(pool, {
body: {
title: 'RSS Slide',
template_id: '8',
region_rss_feed_id_23: '11',
region_rss_item_number_23: '5',
region_text_23: '<p>Hello</p>'
},
files: []
}, null);
const content = JSON.parse(payload.contentJson);
assert.equal(content.rssRegion.type, 'rss');
assert.equal(content.rssRegion.feed_id, 11);
assert.equal(content.rssRegion.item_number, 2);
});
+62
View File
@@ -0,0 +1,62 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const rbacPermissionsScript = fs.readFileSync(require.resolve('../src/web/public/js/rbac-permissions.js'), 'utf8');
const rbacFormTemplate = fs.readFileSync(require.resolve('../src/web/views/settings/rbac/form.hbs'), 'utf8');
const rbacFormViewModel = fs.readFileSync(require.resolve('../src/web/routes/settings/rbac/form-view-model.js'), 'utf8');
const rbacSource = fs.readFileSync(require.resolve('../src/rbac.js'), 'utf8');
test('rbac form exposes bulk permission controls', () => {
assert.match(rbacFormTemplate, /accordion accordion-flush/);
assert.match(rbacFormTemplate, /card card-outline card-secondary overflow-hidden/);
assert.match(rbacFormTemplate, /accordion-item overflow-hidden" data-permission-section/);
assert.match(rbacFormTemplate, /accordion-body p-0/);
assert.match(rbacFormTemplate, /table-layout: fixed; width: 100%;/);
assert.match(rbacFormTemplate, /<col style="width: 5\.5rem;" \/>/);
assert.match(rbacFormTemplate, /class="text-center"/);
assert.match(rbacFormTemplate, /data-permission-row-toggle/);
assert.ok(rbacFormTemplate.includes('text-reset'));
assert.ok(rbacFormTemplate.includes('color: inherit;'));
assert.ok(rbacFormTemplate.includes('text-decoration-style: dotted'));
assert.ok(rbacFormTemplate.includes('aria-label="Role actions"'));
assert.ok(!rbacFormTemplate.includes('aria-label="Permission actions"'));
assert.ok(!rbacFormTemplate.includes('showSaveAndClose=false'));
assert.ok(!rbacFormTemplate.includes('showSaveAndNew=false'));
assert.ok(!rbacFormTemplate.includes('Select none'));
assert.ok(!rbacFormTemplate.includes('permission categories'));
assert.ok(!rbacFormTemplate.includes('btn-link'));
assert.ok(!rbacFormTemplate.includes('rounded-bottom-3 overflow-hidden'));
assert.ok(!rbacFormTemplate.includes('table-responsive rounded-bottom-3 overflow-hidden'));
assert.ok(rbacFormViewModel.includes("label: 'Read'"));
assert.ok(rbacFormViewModel.includes("label: 'Create'"));
assert.ok(rbacFormViewModel.includes("label: 'Update'"));
assert.ok(rbacFormViewModel.includes("label: 'Delete'"));
assert.ok(rbacFormViewModel.includes("label: 'Allow'"));
assert.ok(rbacFormViewModel.includes('left.resourceOrder'));
assert.ok(rbacFormViewModel.includes('section.actions = actionDefinitions.map'));
assert.ok(rbacFormViewModel.includes('data-async-save-new-url="/rbac/new"'));
assert.ok(rbacFormViewModel.includes('data-async-save-close-url="/rbac" data-async-save-new-url="/rbac/new"'));
assert.ok(rbacSource.includes("sectionName: 'Main navigation'"));
assert.ok(rbacSource.includes("permissions: ["));
assert.ok(rbacSource.includes("resourceOrder: resource.order"));
});
test('rbac permissions script supports bulk permission selection', () => {
assert.ok(rbacPermissionsScript.includes('function setRowCheckboxes(row, checked)'));
assert.ok(rbacPermissionsScript.includes('function toggleRowCheckboxes(row)'));
assert.ok(rbacPermissionsScript.includes('data-permission-row-toggle'));
assert.ok(rbacPermissionsScript.includes("toggleRowCheckboxes(findPermissionRow(targetId));"));
assert.ok(!rbacPermissionsScript.includes('data-permission-select-none'));
assert.ok(!rbacPermissionsScript.includes('data-permission-section-select-none'));
});
test('rbac duplicate route exists', () => {
const rbacRoutes = fs.readFileSync(require.resolve('../src/web/routes/admin/rbac.js'), 'utf8');
const duplicateHelpers = fs.readFileSync(require.resolve('../src/web/routes/settings/rbac/duplicate.js'), 'utf8');
assert.match(rbacRoutes, /\/rbac\/:id\/duplicate/);
assert.ok(rbacRoutes.includes("requirePermission('rbac.create')"));
assert.ok(duplicateHelpers.includes('buildDuplicateRoleName'));
assert.ok(duplicateHelpers.includes('buildDuplicateRole'));
});
+22
View File
@@ -0,0 +1,22 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const vm = require('node:vm');
test('region type registry lists add-region buttons by label', () => {
const script = fs.readFileSync(require.resolve('../src/web/public/js/regions/region-types.js'), 'utf8');
const sandbox = { window: {} };
vm.runInNewContext(script, sandbox, { filename: 'region-types.js' });
const registry = sandbox.window.pulseRegionTypes;
registry.register('timetable', { label: 'Timetable' });
registry.register('text', { label: 'Text' });
registry.register('time-date', { label: 'Time/date' });
registry.register('api', { label: 'API' });
assert.deepEqual(
Array.from(registry.list(), (entry) => entry.type),
['api', 'text', 'time-date', 'timetable']
);
});
+47
View File
@@ -0,0 +1,47 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { buildDuplicateRssFeedName, buildDuplicateRssFeed } = require('../src/web/routes/data-sources/rss-feeds/duplicate');
const { buildRssFeedFormViewModel } = require('../src/web/routes/data-sources/rss-feeds/form-view-model');
test('rss feed duplicate helper copies editable fields and resets runtime state', () => {
const source = {
id: 11,
name: 'Feed A',
feed_url: 'https://example.com/feed.xml',
update_interval_value: 30,
update_interval_unit: 'minutes',
item_limit: 5,
last_pulled_at: '2026-08-01T12:00:00.000Z',
last_pull_error: 'Bad response'
};
const duplicate = buildDuplicateRssFeed(source, buildDuplicateRssFeedName(source.name));
assert.equal(duplicate.id, null);
assert.equal(duplicate.name, 'Copy of Feed A');
assert.equal(duplicate.feed_url, 'https://example.com/feed.xml');
assert.equal(duplicate.feedUrl, 'https://example.com/feed.xml');
assert.equal(duplicate.updateIntervalValue, 30);
assert.equal(duplicate.updateIntervalUnit, 'minutes');
assert.equal(duplicate.itemLimit, 5);
assert.equal(duplicate.lastPulledAt, null);
assert.equal(duplicate.pullError, '');
assert.deepEqual(duplicate.pulledItems, []);
});
test('rss feed duplicate form model keeps info toast variant and secondary actions', () => {
const model = buildRssFeedFormViewModel({ id: null, name: 'Feed A' }, 'Review the copied values and save when ready.', { permissionKeys: ['rss-feeds.create'] }, { messageVariant: 'info' }, false);
assert.equal(model.messageVariant, 'info');
assert.equal(model.showSaveSecondaryActions, true);
});
test('rss feed list template includes duplicate action', () => {
const template = fs.readFileSync(path.join(__dirname, '..', 'src', 'web', 'views', 'data-sources', 'rss-feeds', 'list.hbs'), 'utf8');
assert.match(template, /\/data-sources\/rss-feeds\/{\{id\}\}\/duplicate/);
assert.match(template, />Dupe<\/a>/);
});
+43
View File
@@ -0,0 +1,43 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
require('../src/common');
const { buildDuplicateSlideName, buildDuplicateSlide } = require('../src/web/routes/signage/slides/duplicate');
const renderSlideAddPage = require('../src/web/routes/signage/slides/add');
test('buildDuplicateSlide copies slide fields for the create page', () => {
const duplicate = buildDuplicateSlide({
id: 11,
title: 'Morning Loop',
template_id: 7,
content: { hero: { type: 'text', value: 'Hello' } }
}, buildDuplicateSlideName('Morning Loop'));
assert.equal(duplicate.id, null);
assert.equal(duplicate.title, 'Copy of Morning Loop');
assert.equal(duplicate.template_id, 7);
assert.deepEqual(duplicate.content, { hero: { type: 'text', value: 'Hello' } });
});
test('slide duplicate add page renders the copied slide title', async () => {
const html = await renderSlideAddPage(
{ id: null, title: 'Copy of Morning Loop', template_id: 7, content: { hero: { type: 'text', value: 'Hello' } } },
{ templates: [], templateRegions: [], rssFeeds: [], apiSources: [], timetableGroups: [], fontLibrary: null },
'Review the copied values and save when ready.',
{ permissionKeys: ['slides.create'] }
);
assert.match(html, /Copy of Morning Loop/);
assert.match(html, /Create slide/);
assert.match(html, /Select a template to preview the slide\./);
});
test('slide list template includes duplicate action', () => {
const template = fs.readFileSync(path.join(__dirname, '..', 'src', 'web', 'views', 'signage', 'slides', 'list.hbs'), 'utf8');
assert.match(template, /\/slides\/\{\{id\}\}\/duplicate/);
assert.match(template, />Dupe<\/a>/);
});
+123
View File
@@ -1,10 +1,16 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
require('../src/common');
const {
buildTemplatePayload,
extractTemplateRegions
} = require('../src/data/templates');
const renderTemplateAddPage = require('../src/web/routes/signage/templates/add');
const renderTemplateEditPage = require('../src/web/routes/signage/templates/edit');
const { buildDuplicateTemplateName, buildDuplicateTemplate } = require('../src/web/routes/signage/templates/duplicate');
test('extractTemplateRegions normalizes JSON regions and filters invalid rows', () => {
const regions = extractTemplateRegions({
@@ -106,4 +112,121 @@ test('buildTemplatePayload resolves canvas size and rejects duplicate region nam
}, null),
(error) => error && error.statusCode === 400 && error.message === 'Region names must be unique on this template.'
);
});
test('buildDuplicateTemplate copies template fields and strips row identity', () => {
const duplicate = buildDuplicateTemplate({
id: 9,
name: 'Main Template',
canvas_size_id: 4,
canvas_size_width: 1280,
canvas_size_height: 720,
background_color: '#112233',
background_image_path: '/media/uploads/bg.png',
regions: [{ id: 22, template_id: 9, region_key: 'Hero', label: 'Hero', region_type: 'text' }]
}, buildDuplicateTemplateName('Main Template'));
assert.equal(duplicate.id, null);
assert.equal(duplicate.name, 'Copy of Main Template');
assert.equal(duplicate.canvas_size_id, 4);
assert.equal(duplicate.background_color, '#112233');
assert.equal(duplicate.background_image_path, '/media/uploads/bg.png');
assert.deepEqual(duplicate.region_usage, []);
assert.equal(duplicate.regions.length, 1);
assert.equal(duplicate.regions[0].id, undefined);
assert.equal(duplicate.regions[0].template_id, undefined);
assert.equal(duplicate.regions[0].region_key, 'Hero');
});
test('template create page renders the background remove control', () => {
const html = renderTemplateAddPage(null, '', [
{ id: 1, name: 'HD', width: 1280, height: 720 }
], { permissionKeys: ['templates.create'] });
assert.match(html, /id="remove-background-image"/);
assert.match(html, /name="remove_background_image"/);
assert.match(html, /placeholder="type_1"/);
assert.match(html, /Save and Close/);
assert.match(html, /Save and New/);
assert.match(html, /data-confirm-unsaved="You&#x27;ve made changes\. Are you sure you want to leave this page\?"/);
});
test('template edit page confirms deletion', () => {
const html = renderTemplateEditPage({
id: 9,
name: 'Main Template',
canvas_size_id: 1,
canvas_size_width: 1280,
canvas_size_height: 720,
background_image_path: '',
background_color: '#111111',
inUse: false,
regions: [],
region_usage: []
}, {
canvasSizes: [{ id: 1, name: 'HD', width: 1280, height: 720 }]
}, '', { permissionKeys: ['templates.delete'] });
assert.match(html, /data-delete-action-url="\/templates\/9\/delete"/);
assert.match(html, /data-confirm-message="Delete this template\?"/);
assert.match(html, /data-confirm-unsaved="You&#x27;ve made changes\. Are you sure you want to leave this page\?"/);
assert.doesNotMatch(html, /id="delete-template-form"/);
});
test('template list template includes duplicate action', () => {
const template = fs.readFileSync(require.resolve('../src/web/views/signage/templates/list.hbs'), 'utf8');
assert.match(template, /\/templates\/\{\{id\}\}\/duplicate/);
assert.match(template, />Dupe<\/a>/);
});
test('template animation controls mark the form dirty', () => {
const script = fs.readFileSync(require.resolve('../src/web/public/js/templates/template-designer.js'), 'utf8');
assert.match(script, /animationPresetInputs[\s\S]*markTemplateFormDirty\(\);/);
assert.ok(script.includes("animationAdvancedModal.addEventListener('input'"));
assert.ok(script.includes('clampAnimationModalField(event.target);'));
assert.ok(script.includes("animationAdvancedModal.addEventListener('change'"));
assert.ok(script.includes("animationModalApplyButton.addEventListener('click', function () {"));
assert.ok(script.includes("saveAnimationModal();"));
assert.ok(script.includes("animationModalResetButton.addEventListener('click', function () {"));
assert.ok(script.includes("writeAnimationConfig(activeAnimationCard, normalizeAnimationConfig('{}'));"));
assert.ok(script.includes("markTemplateFormDirty();"));
});
test('template animation durations use percent in the advanced modal', () => {
const modal = fs.readFileSync(require.resolve('../src/web/views/signage/templates/animation-advanced-modal.hbs'), 'utf8');
const script = fs.readFileSync(require.resolve('../src/web/public/js/templates/template-designer.js'), 'utf8');
const utilsScript = fs.readFileSync(require.resolve('../src/web/public/js/templates/template-designer-utils.js'), 'utf8');
assert.match(modal, /Duration %/);
assert.match(modal, /data-animation-modal-field="duration_ms"/);
assert.match(modal, /max="200"/);
assert.match(modal, /data-animation-modal-field="iterations"/);
assert.match(modal, /max="999"/);
assert.ok(script.includes('function getAnimationDurationPercent(durationMs)'));
assert.ok(script.includes('function getAnimationDurationMs(durationPercent)'));
assert.ok(script.includes('durationInput.value = String(getAnimationDurationPercent(step.duration_ms));'));
assert.ok(script.includes("nextStep.duration_ms = getAnimationDurationMs(durationInput ? durationInput.value : '');"));
assert.ok(script.includes('MAX_ANIMATION_REPEAT_COUNT = 999'));
assert.match(script, /Math\.min\(MAX_ANIMATION_REPEAT_COUNT, Math\.max\(1, readNumberField\(iterationsInput \? iterationsInput\.value : ''\) \|\| 1\)\)/);
assert.match(utilsScript, /MAX_ANIMATION_REPEAT_COUNT = 999/);
assert.match(utilsScript, /clamp\(Math\.round\(Number\(value\.iterations\)\), 1, MAX_ANIMATION_REPEAT_COUNT\)/);
});
test('template designer generates default region names per type', () => {
const script = fs.readFileSync(require.resolve('../src/web/public/js/templates/template-designer.js'), 'utf8');
assert.ok(script.includes('function getNextDefaultRegionName(regionType)'));
assert.ok(script.includes('var name = getNextDefaultRegionName(type);'));
assert.ok(script.includes("var name = getNextDefaultRegionName('text');"));
assert.ok(script.includes('Math.max(count, suffix)'));
});
test('template designer warns when invalid region names block save', () => {
const script = fs.readFileSync(require.resolve('../src/web/public/js/templates/template-designer.js'), 'utf8');
assert.ok(script.includes("templateForm.addEventListener('invalid'"));
assert.ok(script.includes('function notifyRegionNameValidationError(message)'));
assert.ok(script.includes('regionNameValidationToastShown = false;'));
});
+62
View File
@@ -0,0 +1,62 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const {
buildDuplicateTimetableGroupName,
buildDuplicateTimetableGroup,
buildDuplicateTimetableEntry
} = require('../src/web/routes/data-sources/timetables/duplicate');
const { buildTimetableGroupFormViewModel } = require('../src/web/routes/data-sources/timetables/form-view-model');
test('timetable duplicate helper copies group and entry fields', () => {
const group = {
id: 5,
name: 'Morning Shift',
short_description: 'Weekday events'
};
const entry = {
id: 12,
schedule_group_id: 5,
title: 'Opening',
short_description: 'Doors open',
start_datetime: '2026-08-04T08:00:00.000Z',
end_datetime: '2026-08-04T09:00:00.000Z'
};
const duplicateGroup = buildDuplicateTimetableGroup(group, buildDuplicateTimetableGroupName(group.name));
const duplicateEntry = buildDuplicateTimetableEntry(entry);
assert.equal(duplicateGroup.id, null);
assert.equal(duplicateGroup.name, 'Copy of Morning Shift');
assert.equal(duplicateGroup.short_description, 'Weekday events');
assert.equal(duplicateGroup.shortDescription, 'Weekday events');
assert.equal(duplicateEntry.id, null);
assert.equal(duplicateEntry.schedule_group_id, null);
assert.equal(duplicateEntry.title, 'Opening');
assert.equal(duplicateEntry.short_description, 'Doors open');
assert.equal(duplicateEntry.start_datetime, '2026-08-04T08:00:00.000Z');
assert.equal(duplicateEntry.end_datetime, '2026-08-04T09:00:00.000Z');
});
test('timetable duplicate form model keeps info toast variant and secondary actions', () => {
const model = buildTimetableGroupFormViewModel(
{ id: null, name: 'Morning Shift', shortDescription: 'Weekday events' },
[],
'Review the copied values and save when ready.',
{ permissionKeys: ['timetables.create'] },
false,
{ messageVariant: 'info' }
);
assert.equal(model.messageVariant, 'info');
assert.equal(model.showSaveSecondaryActions, true);
});
test('timetable list template includes duplicate action', () => {
const template = fs.readFileSync(path.join(__dirname, '..', 'src', 'web', 'views', 'data-sources', 'timetables', 'list.hbs'), 'utf8');
assert.match(template, /\/data-sources\/timetables\/{\{id\}\}\/duplicate/);
assert.match(template, />Dupe<\/a>/);
});
+143
View File
@@ -0,0 +1,143 @@
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const test = require('node:test');
const assert = require('node:assert/strict');
test('pending toast is shown after a refresh and cleared from storage', () => {
const toastNodes = [];
const bodyNode = { textContent: '' };
const titleNode = { textContent: '' };
const timeNode = { textContent: '' };
const iconNode = { className: '' };
const closeNode = { className: '' };
const headerNode = { className: '', querySelector(selector) {
if (selector === '.toast-header i') {
return iconNode;
}
if (selector === '.toast-header .me-auto') {
return titleNode;
}
if (selector === '.toast-time') {
return timeNode;
}
if (selector === '.toast-header .btn-close') {
return closeNode;
}
return null;
} };
function createToastNode() {
const attributes = {};
return {
className: '',
id: '',
attributes: attributes,
classList: {
add() {},
remove() {}
},
setAttribute(name, value) {
attributes[name] = String(value);
},
getAttribute(name) {
return attributes[name];
},
querySelector(selector) {
if (selector === '.toast-header') {
return headerNode;
}
if (selector === '.toast-body') {
return bodyNode;
}
if (selector === '.toast-header i') {
return iconNode;
}
if (selector === '.toast-header .me-auto') {
return titleNode;
}
if (selector === '.toast-time') {
return timeNode;
}
if (selector === '.toast-header .btn-close') {
return closeNode;
}
return null;
},
addEventListener() {},
innerHTML: ''
};
}
const container = {
appendChild(node) {
toastNodes.push(node);
return node;
}
};
const storage = {
value: JSON.stringify({ message: 'Password updated.', variant: 'success' }),
getItem(key) {
return key === 'pulse:pending-toast' ? this.value : null;
},
setItem() {},
removeItem(key) {
if (key === 'pulse:pending-toast') {
this.value = null;
}
}
};
const sandbox = {
document: {
getElementById(id) {
if (id === 'app-toast') {
return null;
}
if (id === 'app-toast-container') {
return container;
}
return null;
},
createElement(tagName) {
assert.equal(tagName, 'div');
return createToastNode();
}
},
sessionStorage: storage,
bootstrap: {
Toast: {
getOrCreateInstance(node) {
return {
show() {
node.shown = true;
},
hide() {}
};
}
}
},
history: { replaceState() {} },
location: { href: 'http://localhost/account' },
URL,
Date,
console,
setTimeout,
clearTimeout,
module: { exports: {} },
exports: {}
};
sandbox.window = sandbox;
const scriptPath = path.join(__dirname, '..', 'src', 'web', 'public', 'js', 'toast.js');
const script = fs.readFileSync(scriptPath, 'utf8');
vm.runInNewContext(script, sandbox, { filename: scriptPath });
assert.equal(storage.value, null);
assert.equal(toastNodes.length, 1);
assert.equal(bodyNode.textContent, 'Password updated.');
assert.equal(toastNodes[0].attributes['data-toast-variant'], 'success');
assert.equal(toastNodes[0].shown, true);
assert.match(titleNode.textContent, /Success/i);
});
+74
View File
@@ -0,0 +1,74 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const registerUsersRoutes = require('../src/web/routes/admin/users');
test('user duplicate route pre-fills the add form', async () => {
const handlers = {};
const app = {
get(path, ...routeHandlers) {
handlers[path] = routeHandlers;
},
post() {}
};
const deps = {
pool: {
async query() {
return [[]];
}
},
common: {},
pages: {
renderUsersAddPage(message, currentUser, roles, formValues, messageVariant) {
return JSON.stringify({ message, currentUser, roles, formValues, messageVariant });
}
},
formatDashboardDate: () => '',
getAuditUserId: () => 1,
hashPassword: () => ({ hash: '', salt: '', iterations: 1 }),
validatePasswordStrength: () => '',
readArrayField: () => [],
rbacData: {
fetchRoles: async () => ([{ id: 2, name: 'Editors' }]),
fetchUsersWithRolesPage: async () => ({ users: [], totalItems: 0, currentPage: 1 }),
fetchUserWithRoles: async () => ({ id: 7, name: 'Alice', username: 'alice', roleIds: [2] }),
syncUserRoles: async () => {}
},
requirePermission() {
return function (_req, _res, next) {
next();
};
}
};
registerUsersRoutes(app, deps);
const routeHandlers = handlers['/users/:id/duplicate'];
assert.equal(Array.isArray(routeHandlers), true);
const req = {
params: { id: '7' },
currentUser: { id: 1 }
};
const res = {
send(value) {
this.body = value;
},
redirect(url) {
this.redirectedTo = url;
},
status(code) {
this.statusCode = code;
return this;
}
};
await routeHandlers[2](req, res, () => {});
const payload = JSON.parse(res.body);
assert.equal(payload.formValues.name, 'Copy of Alice');
assert.equal(Object.prototype.hasOwnProperty.call(payload.formValues, 'username'), false);
assert.equal(payload.roles.length, 1);
assert.equal(payload.roles[0].isSelected, true);
});
+17
View File
@@ -1,7 +1,9 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const { buildUsersEditViewModel, buildUsersAddViewModel } = require('../src/web/routes/settings/users/form-view-model');
const usersFormTemplate = fs.readFileSync(require.resolve('../src/web/views/settings/users/form.hbs'), 'utf8');
test('user edit view model includes delete confirmation text', () => {
const model = buildUsersEditViewModel({ id: 12, inUse: false, roleIds: [] }, 'Saved.', { id: 1 }, []);
@@ -9,6 +11,8 @@ test('user edit view model includes delete confirmation text', () => {
assert.equal(model.footerDeleteUrl, '/users/12/delete');
assert.equal(model.footerDeleteConfirmMessage, 'Delete this user?');
assert.equal(model.footerDeleteDisabled, false);
assert.equal(model.showSaveSecondaryActions, true);
assert.equal(model.formAttrs, 'data-async-save data-async-save-close-url="/users" data-async-save-new-url="/users/new"');
});
test('user add view model keeps delete disabled without a confirm message', () => {
@@ -17,4 +21,17 @@ test('user add view model keeps delete disabled without a confirm message', () =
assert.equal(model.footerDeleteDisabled, true);
assert.equal(model.footerDeleteUrl, '');
assert.equal(model.footerDeleteConfirmMessage, undefined);
assert.equal(model.formAttrs, 'data-async-save data-async-save-close-url="/users" data-async-save-new-redirect="response-url" data-async-save-new-url="/users/new"');
});
test('user add view model uses success messaging for redirected saves', () => {
const model = buildUsersAddViewModel('User created.', { id: 1 }, [], { name: 'New User', username: 'newuser' });
assert.equal(model.messageVariant, 'success');
});
test('user form keeps username editable in the admin section', () => {
assert.ok(usersFormTemplate.includes('name="username"'));
assert.ok(!usersFormTemplate.includes('{{#if isEdit}}disabled{{else}}required{{/if}}'));
assert.ok(usersFormTemplate.includes('required />'));
});