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 { 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 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.'); });