Files
pulse-signage/test/canvas-sizes.test.js
T
lzstealth f0601f823d
Publish Docker Image / build-and-push-existing-registry (./build/Dockerfile, web, pulse-signage-web) (push) Successful in 1m35s
Publish Docker Image / build-and-push-existing-registry (./build/Dockerfile.player, player, pulse-signage-player) (push) Successful in 59s
Add canvas size disable controls
2026-10-15 02:09:34 +01:00

468 lines
15 KiB
JavaScript

const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
require('../src/common');
const { buildCanvasSizePayload, MAX_CANVAS_SIZE_DIMENSION } = require('../src/data');
const { fetchCanvasSizesData } = require('../src/data/canvas-sizes');
const { buildCanvasSizeFormViewModel } = require('../src/web/routes/signage/canvas-sizes/form-view-model');
const registerContentRoutes = require('../src/web/routes/admin/content');
const renderCanvasSizesPage = require('../src/web/routes/signage/canvas-sizes/list');
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('canvas size list replaces duplicate action with status and toggle controls', () => {
assert.doesNotMatch(canvasSizeListTemplate, /\/canvas-sizes\/\{\{id\}\}\/duplicate/);
assert.match(canvasSizeListTemplate, /data-label="Status"/);
assert.match(canvasSizeListTemplate, /\/canvas-sizes\/\{\{id\}\}\/toggle/);
assert.match(canvasSizeListTemplate, />\{\{#if enabled\}\}Disable\{\{else\}\}Enable\{\{\/if\}\}<\/button>/);
});
test('canvas size list renderer preserves enabled state for status labels', () => {
const html = renderCanvasSizesPage({
canvasSizes: [
{ id: 1, name: 'Enabled size', width: 1920, height: 1080, enabled: 1, template_count: 0 },
{ id: 2, name: 'Disabled size', width: 1280, height: 720, enabled: 0, template_count: 0 }
]
}, '', null);
assert.match(html, /data-label="Status"><span class="badge text-bg-success">Enabled<\/span>/);
assert.match(html, /data-label="Status"><span class="badge text-bg-secondary">Disabled<\/span>/);
});
test('canvas-size selectors exclude disabled sizes unless retaining the current assignment', async () => {
const queries = [];
const pool = {
async query(sql, params) {
queries.push([sql, params]);
return [[]];
}
};
await fetchCanvasSizesData(pool);
await fetchCanvasSizesData(pool, 9);
assert.match(queries[0][0], /WHERE enabled = 1/);
assert.match(queries[1][0], /WHERE enabled = 1 OR id = \?/);
assert.deepEqual(queries[1][1], [9]);
});
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 toggleUpdates = [];
const auditEvents = [];
let enabled = 1;
const pool = {
async query(sql, params) {
queries.push([sql, params]);
if (sql.includes('UPDATE c_canvas_sizes SET enabled')) {
enabled = params[0];
toggleUpdates.push(params);
return [{ affectedRows: 1 }];
}
if (sql.includes('FROM c_canvas_sizes WHERE id = ?')) {
return [[{ id: 7, name: 'Full HD', width: 1920, height: 1080, enabled: enabled }]];
}
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, enabled, 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,
recordRequestAuditEvent: async (_connection, _req, event) => auditEvents.push(event),
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);
const toggleHandler = handlers['/canvas-sizes/:id/toggle'][1];
const toggleReq = { params: { id: '7' } };
const toggleRes = { redirectedTo: '', redirect(url) { this.redirectedTo = url; } };
await toggleHandler(toggleReq, toggleRes, (error) => { throw error; });
assert.equal(toggleRes.redirectedTo, '/canvas-sizes?message=Canvas%20size%20disabled.');
await toggleHandler(toggleReq, toggleRes, (error) => { throw error; });
assert.deepEqual(toggleUpdates.map((params) => params[0]), [0, 1]);
assert.deepEqual(auditEvents.map((event) => event.eventType), ['canvas-size.disabled', 'canvas-size.enabled']);
});
test('slide upload cleanup route removes pending media assets', async () => {
const uploadRoot = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'pulse-slide-upload-cleanup-'));
const uploadDir = path.join(uploadRoot, 'media', 'uploads');
await fs.promises.mkdir(uploadDir, { recursive: true });
await fs.promises.writeFile(path.join(uploadDir, 'test-file.png'), Buffer.from('temporary upload'));
const handlers = {};
const app = {
get(path, ...routeHandlers) {
handlers[path] = routeHandlers;
},
post(path, ...routeHandlers) {
handlers[path] = routeHandlers;
}
};
const deps = {
pool: {
async query(sql) {
if (String(sql).includes('is_published = 0')) {
return [[{ id: 1 }]];
}
if (String(sql).includes('DELETE FROM c_media_assets')) {
return [{ affectedRows: 1 }];
}
return [[{ ref_count: 0 }]];
}
},
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 () => {},
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
};
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;
}
};
try {
await routeHandlers[1](req, res, () => {});
assert.equal(res.statusCode, 204);
assert.equal(fs.existsSync(path.join(uploadDir, 'test-file.png')), false);
assert.equal(res.statusCode, 204);
} finally {
await fs.promises.rm(uploadRoot, { recursive: true, force: true });
}
});
test('wysiwyg image uploads are capped below the dedicated image region limit', async () => {
const handlers = {};
const app = {
get(path, ...routeHandlers) {
handlers[path] = routeHandlers;
},
post(path, ...routeHandlers) {
handlers[path] = routeHandlers;
}
};
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 () => {},
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'];
assert.ok(Array.isArray(routeHandlers));
const req = {
file: {
filename: 'wysiwyg-large.png',
originalname: 'wysiwyg-large.png',
mimetype: 'image/png',
size: 11 * 1024 * 1024
},
get(headerName) {
return headerName === 'X-Upload-Context' ? 'wysiwyg' : '';
},
currentUser: { id: 1, permissions: ['slides.create'] }
};
const res = {
statusCode: 0,
body: '',
json(body) {
this.body = body;
return this;
},
status(code) {
this.statusCode = code;
return this;
}
};
let nextError = null;
await routeHandlers[2](req, res, (error) => {
nextError = error || null;
});
assert.ok(nextError);
assert.equal(nextError.statusCode, 400);
assert.equal(nextError.message, 'Image must be 2 MB or smaller. Larger images should use the dedicated Image region.');
});