Release v2.5.6
This commit is contained in:
@@ -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>/);
|
||||
});
|
||||
@@ -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.');
|
||||
});
|
||||
@@ -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.');
|
||||
});
|
||||
@@ -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.');
|
||||
});
|
||||
@@ -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));
|
||||
});
|
||||
@@ -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));
|
||||
});
|
||||
@@ -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>/);
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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"/);
|
||||
});
|
||||
@@ -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']);
|
||||
});
|
||||
@@ -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\)/);
|
||||
});
|
||||
@@ -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'), '');
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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'));
|
||||
});
|
||||
@@ -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']
|
||||
);
|
||||
});
|
||||
@@ -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>/);
|
||||
});
|
||||
@@ -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>/);
|
||||
});
|
||||
@@ -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'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'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;'));
|
||||
});
|
||||
@@ -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>/);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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 />'));
|
||||
});
|
||||
Reference in New Issue
Block a user