const test = require('node:test'); const assert = require('node:assert/strict'); const fs = require('fs'); const os = require('os'); const path = require('path'); const crypto = require('crypto'); const vm = require('node:vm'); const sharp = require('sharp'); require('../src/common'); const mediaLibrary = require('../src/web/lib/media/library'); const registerMediaLibraryRoutes = require('../src/web/routes/settings/media-library'); const { buildSlidePayload } = require('../src/data/slides'); const { outputSize, isFullImage } = require('../src/web/public/js/shared/image-crop'); const { prepareImageCopies } = require('../src/web/lib/media/image-crops'); const { removeUnusedImageCopies, removePendingMediaAssets } = require('../src/web/lib/media/library'); const { normalizeMediaPath, createImageVariants, createImageThumbnail, removeImageVariants, ensureImageThumbnail, optimizeUploadedImage, registerMediaAssets, syncMediaAssetsFromDirectory, countMediaAssetReferences } = require('../src/web/lib/media/library'); test('media library normalizes only media URLs', () => { assert.equal(normalizeMediaPath('/media/uploads/photo.png'), '/media/uploads/photo.png'); assert.equal(normalizeMediaPath(' /media/video.mp4 '), '/media/video.mp4'); assert.equal(normalizeMediaPath('/assets/photo.png'), null); }); test('unused generated copies and their variants are deleted without deleting originals or referenced copies', async () => { const uploadDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'pulse-copy-cleanup-')); const generated = 'image-crop-' + 'a'.repeat(64) + '.png'; const referenced = 'image-playback-' + 'b'.repeat(64) + '.png'; const files = [generated, generated.replace('.png', '.variant-2048.png'), referenced, 'original.png', 'original-crop-name.png', 'draft.png']; for (const filename of files) await fs.promises.writeFile(path.join(uploadDir, filename), Buffer.from('fixture')); const deleted = []; const pool = { async query(sql, params) { if (sql.startsWith('SELECT id, original_name')) return [[{ id: 1, is_published: params[0].endsWith('/draft.png') ? 0 : 1, original_name: /\/(?:draft|original-crop-name)\.png$/.test(params[0]) ? 'slide-image-crop.png' : path.basename(params[0]) }]]; if (sql.includes('COUNT(DISTINCT owner_type, owner_id)')) return [[{ owner_count: params.includes('/media/uploads/' + referenced) ? 1 : 0 }]]; if (sql.startsWith('DELETE')) deleted.push(params[0]); return [[{ ref_count: 0 }]]; } }; try { await removeUnusedImageCopies(pool, uploadDir, files.map(filename => '/media/uploads/' + filename)); assert.deepEqual((await fs.promises.readdir(uploadDir)).sort(), ['original.png', 'original-crop-name.png', referenced].sort()); assert.ok(deleted.includes('/media/uploads/' + generated)); assert.ok(deleted.includes('/media/uploads/draft.png')); assert.ok(!deleted.includes('/media/uploads/original.png')); } finally { await fs.promises.rm(uploadDir, { recursive: true, force: true }); } }); test('pending crop cleanup removes physical copies and variants but protects published and referenced files', async () => { const directory = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'pulse-pending-crop-')); const uploadDir = path.join(directory, 'uploads'); await fs.promises.mkdir(uploadDir); const files = ['draft.png', 'draft.variant-2048.png', 'referenced.png', 'published.png', 'original.png']; for (const filename of files) await fs.promises.writeFile(path.join(uploadDir, filename), Buffer.from('fixture')); const pool = { async query(sql, params) { if (sql.startsWith('SELECT id FROM c_media_assets')) return [params[0].endsWith('/published.png') ? [] : [{ id: 1 }]]; if (sql.includes('COUNT(DISTINCT owner_type, owner_id)')) return [[{ owner_count: params.includes('/media/uploads/referenced.png') ? 1 : 0 }]]; return [[{ ref_count: 0 }]]; } }; try { await removePendingMediaAssets(pool, uploadDir, ['/media/uploads/draft.png', '/media/uploads/referenced.png', '/media/uploads/published.png']); assert.deepEqual((await fs.promises.readdir(uploadDir)).sort(), ['original.png', 'published.png', 'referenced.png']); } finally { await fs.promises.rm(directory, { recursive: true, force: true }); } }); test('crop playback sizes fit the region and canvas without upscaling or changing aspect ratio', () => { assert.deepEqual(outputSize({ width: 6000, height: 4000 }, { width: 600, height: 300 }, { width: 1920, height: 1080 }), { width: 450, height: 300 }); assert.deepEqual(outputSize({ width: 6000, height: 4000 }, { width: 5000, height: 3000 }, { width: 1920, height: 1080 }), { width: 1620, height: 1080 }); assert.deepEqual(outputSize({ width: 120, height: 80 }, { width: 600, height: 300 }, { width: 1920, height: 1080 }), { width: 120, height: 80 }); assert.deepEqual(outputSize({ width: 4000, height: 6000 }, { width: 1080, height: 1920 }, { width: 1080, height: 1920 }), { width: 1080, height: 1620 }); }); test('untouched full-image selections are not treated as crops', () => { const image = { naturalWidth: 600, naturalHeight: 400 }; const full = { x: 0, y: 0, width: 600, height: 400, rotate: 0, scaleX: 1, scaleY: 1 }; assert.equal(isFullImage(full, image), true); assert.equal(isFullImage({ ...full, x: 0.000001 }, image), true); for (const change of [{ width: 599 }, { x: 1 }, { rotate: 90 }, { scaleX: -1 }, { scaleY: -1 }]) assert.equal(isFullImage({ ...full, ...change }, image), false); }); test('cancelling an initial crop clears the selection and cleans its upload, but cancelling a re-crop keeps it', () => { const source = fs.readFileSync(path.join(__dirname, '../src/web/public/js/slides/slide-image-cropper.js'), 'utf8'); const start = source.indexOf('function discardInitialSelection()'); const end = source.indexOf('\n function loadCropper()', start); const cleaned = []; const current = { value: '/media/uploads/new.png', dataset: { cropSelectionPending: '1' }, dispatchEvent() {} }; const preview = { innerHTML: '' }; const active = { initialSelection: true, applied: false, previousValue: current.value, current, original: current.value, originalInput: { value: current.value }, cropInput: { value: '' }, fileInput: { value: '', dataset: { uploadedPath: current.value, uploadedNeedsCleanup: '1' } }, card: { querySelector() { return preview; } } }; const discard = vm.runInNewContext('(' + source.slice(start, end).trim() + ')', { active, Event, cleanupCopies(paths) { cleaned.push(...paths); } }); active.initialSelection = false; discard(); assert.equal(current.value, '/media/uploads/new.png'); assert.equal(cleaned.length, 0); active.initialSelection = true; active.applied = true; discard(); assert.equal(cleaned.length, 0); active.applied = false; discard(); assert.deepEqual(cleaned, ['/media/uploads/new.png']); assert.equal(current.value, ''); assert.equal(active.originalInput.value, ''); assert.equal(active.fileInput.dataset.uploadedNeedsCleanup, undefined); assert.match(preview.innerHTML, /No image/); }); test('playback crops regenerate from the original for larger layouts and preserve source bytes', async () => { const uploadDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'pulse-crop-size-')); const original = await sharp({ create: { width: 1200, height: 800, channels: 3, background: '#fa4030' } }).png().toBuffer(); await fs.promises.writeFile(path.join(uploadDir, 'original.png'), original); const template = { canvas_size_width: 1920, canvas_size_height: 1080, regions: [{ region_key: 'photo', region_type: 'image', x: 0, y: 0, width: 300, height: 200 }] }; const content = { photo: { type: 'image', value: '/media/uploads/old.png', original_value: '/media/uploads/original.png', crop: { x: 0, y: 0, width: 1200, height: 800, rotate: 0, scaleX: 1, scaleY: 1 } } }; try { let files = await prepareImageCopies(content, template, uploadDir); let metadata = await sharp(files[0].path).metadata(); assert.equal(metadata.width, 300); assert.equal(metadata.height, 200); const firstPath = content.photo.value; await prepareImageCopies(content, template, uploadDir); assert.equal(content.photo.value, firstPath); await fs.promises.unlink(files[0].path); content.photo.layout = { x: 0, y: 0, width: 900, height: 600 }; files = await prepareImageCopies(content, template, uploadDir); metadata = await sharp(files[0].path).metadata(); assert.equal(metadata.width, 900); assert.equal(metadata.height, 600); assert.notEqual(content.photo.value, firstPath); content.photo.layout = { x: 0, y: 0, width: 1920, height: 1080 }; files = await prepareImageCopies(content, template, uploadDir); metadata = await sharp(files[0].path).metadata(); assert.equal(metadata.width, 1200); assert.equal(metadata.height, 800); assert.deepEqual(await fs.promises.readFile(path.join(uploadDir, 'original.png')), original); content.photo.original_value = '/media/uploads/../original.png'; await assert.rejects(prepareImageCopies(content, template, uploadDir), /local upload/); } finally { await fs.promises.rm(uploadDir, { recursive: true, force: true }); } }); test('uncropped 8K images get bounded full-frame playback copies and regenerate from the original', async () => { const uploadDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'pulse-full-image-size-')); try { const original = await sharp({ create: { width: 7680, height: 4320, channels: 3, background: '#236944' } }).jpeg().toBuffer(); await fs.promises.writeFile(path.join(uploadDir, 'original.jpg'), original); const image = { type: 'image', value: '/media/uploads/original.jpg', crop: null }; const template = { canvas_size_width: 1920, canvas_size_height: 1080, regions: [{ region_key: 'photo', region_type: 'image', width: 960, height: 540 }] }; let files = await prepareImageCopies({ photo: image }, template, uploadDir); let metadata = await sharp(files[0].path).metadata(); assert.equal(metadata.width, 960); assert.equal(metadata.height, 540); assert.equal(image.original_value, '/media/uploads/original.jpg'); assert.equal(image.crop, null); const firstPath = image.value; await prepareImageCopies({ photo: image }, template, uploadDir); assert.equal(image.value, firstPath); await fs.promises.unlink(files[0].path); image.layout = { x: 0, y: 0, width: 1920, height: 1080 }; files = await prepareImageCopies({ photo: image }, template, uploadDir); metadata = await sharp(files[0].path).metadata(); assert.equal(metadata.width, 1920); assert.equal(metadata.height, 1080); assert.notEqual(image.value, firstPath); assert.deepEqual(await fs.promises.readFile(path.join(uploadDir, 'original.jpg')), original); } finally { await fs.promises.rm(uploadDir, { recursive: true, force: true }); } }); test('pre-crop-change images remain usable across edit, save, preview and player rendering', async () => { const uploadDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'pulse-legacy-image-')); const region = { id: 22, region_key: 'photo', region_type: 'image', width: 800, height: 600, label: 'Legacy image', baseStyle: '' }; const template = { id: 7, canvas_size_width: 1920, canvas_size_height: 1080, regions: [region] }; const pool = { async query(sql) { if (sql.includes('FROM c_templates st')) return [[template]]; if (sql.includes('FROM c_template_regions')) return [[region]]; return [[]]; } }; const modules = {}; const browserWindow = { pulseRegionTypes: { register(type, definition) { modules.editor = definition; }, renderEditorCardShell(context) { return context.headerActions + context.bodyHtml; } }, pulsePlayerRegionTypes: { register(type, definition) { modules.player = definition; } } }; const context = vm.createContext({ window: browserWindow, escapeHtml: String, selectPlayerMediaSource: value => value }); vm.runInContext(fs.readFileSync(path.join(__dirname, '../src/web/public/js/regions/type/image.js'), 'utf8'), context); vm.runInContext(fs.readFileSync(path.join(__dirname, '../src/player/regions/image.js'), 'utf8'), context); try { for (const [filename, width, height] of [['old-cropped.png', 600, 400], ['old-large.png', 2400, 800]]) { const original = await sharp({ create: { width, height, channels: 3, background: '#237744' } }).png().toBuffer(); await fs.promises.writeFile(path.join(uploadDir, filename), original); const legacy = { type: 'image', value: '/media/uploads/' + filename }; const editorContext = modules.editor.buildEditorCardContext({ region, current: legacy.value }, { photo: legacy }); const card = modules.editor.renderEditorCard(editorContext); assert.ok(card.includes('name="region_image_original_22" value="' + legacy.value + '"')); const preview = modules.editor.renderPreview(region, modules.editor.buildPreviewRenderContext(region, null, { photo: legacy })); assert.ok(preview.includes(legacy.value)); assert.ok(modules.player.renderRegion(region, legacy).includes(legacy.value)); const payload = await buildSlidePayload(pool, { body: { title: 'Legacy saved again', template_id: '7' }, files: [] }, { template_id: 7, content_json: JSON.stringify({ photo: legacy }) }); const saved = JSON.parse(payload.contentJson); assert.equal(saved.photo.value, legacy.value); assert.equal(saved.photo.original_value, legacy.value); assert.equal(saved.photo.crop, null); const copies = await prepareImageCopies(saved, template, uploadDir); if (width === 600) { assert.equal(copies.length, 0); assert.equal(saved.photo.value, legacy.value); } else { assert.equal(copies.length, 1); const metadata = await sharp(copies[0].path).metadata(); assert.ok(metadata.width <= region.width); assert.ok(metadata.height <= region.height); assert.equal(metadata.width / metadata.height, width / height); assert.equal(saved.photo.original_value, legacy.value); } assert.ok(modules.player.renderRegion(region, saved.photo).includes(saved.photo.value)); assert.deepEqual(await fs.promises.readFile(path.join(uploadDir, filename)), original); assert.equal(legacy.original_value, undefined); assert.equal(legacy.crop, undefined); } } finally { await fs.promises.rm(uploadDir, { recursive: true, force: true }); } }); test('uncropped playback preserves animation and never enlarges small images', async () => { const uploadDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'pulse-full-image-animation-')); try { const red = await sharp({ create: { width: 400, height: 200, channels: 4, background: '#ff0000' } }).raw().toBuffer(); const green = await sharp({ create: { width: 400, height: 200, channels: 4, background: '#00ff00' } }).raw().toBuffer(); const template = { canvas_size_width: 1920, canvas_size_height: 1080, regions: [{ region_key: 'photo', region_type: 'image', width: 100, height: 100 }] }; for (const format of ['gif', 'webp']) { const original = await sharp(Buffer.concat([red, green]), { raw: { width: 400, height: 400, channels: 4, pageHeight: 200 } })[format]({ delay: [120, 240], loop: 0 }).toBuffer(); const filename = 'original.' + format; await fs.promises.writeFile(path.join(uploadDir, filename), original); const image = { value: '/media/uploads/' + filename, crop: null }; const files = await prepareImageCopies({ photo: image }, template, uploadDir); const metadata = await sharp(await fs.promises.readFile(files[0].path), { animated: true }).metadata(); assert.equal(metadata.width, 100); assert.equal(metadata.pageHeight, 50); assert.equal(metadata.pages, 2); assert.deepEqual(metadata.delay, [120, 240]); assert.equal(metadata.loop, 0); assert.equal(image.crop, null); assert.deepEqual(await fs.promises.readFile(path.join(uploadDir, filename)), original); } await sharp({ create: { width: 40, height: 20, channels: 4, background: '#00ff0080' } }).png().toFile(path.join(uploadDir, 'small.png')); const before = (await fs.promises.readdir(uploadDir)).sort(); const small = { value: '/media/uploads/previous-copy.png', original_value: '/media/uploads/small.png', crop: null }; const files = await prepareImageCopies({ photo: small }, template, uploadDir); assert.equal(files.length, 0); assert.equal(small.value, '/media/uploads/small.png'); assert.equal(small.crop, null); assert.deepEqual((await fs.promises.readdir(uploadDir)).sort(), before); const metadata = await sharp(path.join(uploadDir, 'small.png')).metadata(); assert.equal(metadata.width, 40); assert.equal(metadata.height, 20); assert.equal(metadata.hasAlpha, true); } finally { await fs.promises.rm(uploadDir, { recursive: true, force: true }); } }); test('regenerated crop pixels preserve cropper rotation and flips', async () => { const uploadDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'pulse-crop-transform-')); const red = [255, 0, 0, 255]; const green = [0, 255, 0, 255]; const pixels = Buffer.from([...red, ...red, ...green, ...green, ...red, ...red, ...green, ...green]); try { await sharp(pixels, { raw: { width: 4, height: 2, channels: 4 } }).png().toFile(path.join(uploadDir, 'original.png')); const content = { photo: { value: '/media/uploads/old.png', original_value: '/media/uploads/original.png', crop: { x: 0, y: 0, width: 2, height: 4, rotate: 90, scaleX: -1, scaleY: 1 } } }; const template = { canvas_size_width: 1920, canvas_size_height: 1080, regions: [{ region_key: 'photo', region_type: 'image', width: 2, height: 4 }] }; const files = await prepareImageCopies(content, template, uploadDir); const output = await sharp(files[0].path).raw().toBuffer(); assert.deepEqual([...output.subarray(0, 4)], green); assert.deepEqual([...output.subarray(output.length - 4)], red); content.photo.crop = { x: -2, y: 0, width: 6, height: 2 }; template.regions[0].width = 6; template.regions[0].height = 2; const padded = await prepareImageCopies(content, template, uploadDir); const padding = await sharp(padded[0].path).raw().toBuffer(); assert.equal(padding[3], 0); assert.deepEqual([...padding.subarray(8, 12)], red); } finally { await fs.promises.rm(uploadDir, { recursive: true, force: true }); } }); test('image crop preview fills its responsive column without right-aligned thumbnail padding', () => { const stylesheet = fs.readFileSync(path.join(__dirname, '../src/web/public/css/theme-custom.css'), 'utf8'); const box = stylesheet.match(/^\.slide-image-region-preview-tools > \.slide-image-region-preview-box\s*\{([^}]+)\}/m); assert.ok(box); assert.match(box[1], /min-width:\s*0;/); assert.match(box[1], /justify-items:\s*stretch;/); assert.match(stylesheet, /\.slide-image-region-preview-tools \.slide-image-region-preview-shell,\s*\.slide-image-region-preview-tools \.slide-image-region-preview\s*\{\s*max-width:\s*none;/); }); test('editor offers the cropper and player uses the crop copy while retaining original metadata', () => { const modules = {}; const browserWindow = { pulseRegionTypes: { register(type, definition) { modules.editor = definition; }, renderEditorCardShell(context) { return '
' + context.headerActions + '
' + context.bodyHtml + '
'; } }, pulsePlayerRegionTypes: { register(type, definition) { modules.player = definition; } } }; const context = vm.createContext({ window: browserWindow, escapeHtml: String, selectPlayerMediaSource: value => value }); vm.runInContext(fs.readFileSync(path.join(__dirname, '../src/web/public/js/regions/type/image.js'), 'utf8'), context); vm.runInContext(fs.readFileSync(path.join(__dirname, '../src/player/regions/image.js'), 'utf8'), context); const photo = { value: '/media/uploads/crop.png', original_value: '/media/uploads/original.svg', crop: { x: 20, y: 30, width: 400, height: 200, rotate: 90, scaleX: -1, scaleY: 1 } }; const region = { id: 22, region_key: 'photo', baseStyle: '', label: 'Photo' }; const editorContext = modules.editor.buildEditorCardContext({ region, current: photo.value }, { photo }); assert.deepEqual(editorContext.imageContent, photo); const card = modules.editor.renderEditorCard(editorContext); assert.match(card, /data-edit-image-crop="22"/); const header = card.slice(0, card.indexOf('class="card-body"')); assert.doesNotMatch(header, /data-edit-image-crop/); assert.match(card, /class="slide-image-region-preview-tools">')); for (const ratio of ['Free', '1:1', '4:3', '16:9', 'Region']) assert.ok(html.includes('data-crop-ratio="' + ratio + '"')); assert.match(html, /class="btn btn-outline-danger btn-sm" data-crop-action="reset"/); assert.match(html, /slide-image-cropper-frame is-loading" aria-busy="true"/); assert.match(html, /slide-image-cropper-loading-overlay" aria-hidden="false"/); assert.match(html, /spinner-border text-primary slide-image-cropper-loading-spinner" role="status"/); assert.doesNotMatch(html, /zoom-in|zoom-out|data-crop-original/); }); test('cropper fits the transformed original before restoring a saved crop box', () => { const source = fs.readFileSync(path.join(__dirname, '../src/web/public/js/slides/slide-image-cropper.js'), 'utf8'); const start = source.indexOf('function () {', source.indexOf('ready:')); const end = source.indexOf('\n });', start); const crop = { x: 20, y: 30, width: 400, height: 200, rotate: 90, scaleX: -1, scaleY: 1 }; const calls = []; const ready = vm.runInNewContext('(' + source.slice(start, end) + ')', { active: { crop, original: '/media/uploads/original.jpg' }, cropper: { rotateTo(value) { calls.push(['rotate', value]); }, scale(horizontal, vertical) { calls.push(['scale', horizontal, vertical]); }, setCropBoxData(value) { calls.push(['box', value.width, value.height]); }, getContainerData() { return { width: 900, height: 600 }; }, getCanvasData() { return { naturalWidth: 600, naturalHeight: 1200 }; }, zoomTo(value) { calls.push(['zoom', value]); }, setData(value) { calls.push(['data', value]); } }, setLoading(value) { calls.push(['loading', value]); }, setBusy() {}, status() {} }); ready(); assert.deepEqual(calls, [['rotate', 90], ['scale', -1, 1], ['box', 1, 1], ['zoom', 0.5], ['data', crop], ['loading', false]]); }); test('crop loading toggles the frame overlay and accessible busy state', () => { const source = fs.readFileSync(path.join(__dirname, '../src/web/public/js/slides/slide-image-cropper.js'), 'utf8'); const start = source.indexOf('function setLoading('); const end = source.indexOf('\n function createModal()', start); const attributes = {}; const overlay = { setAttribute(name, value) { attributes['overlay.' + name] = value; } }; const frame = { classList: { toggle(name, value) { attributes[name] = value; } }, setAttribute(name, value) { attributes[name] = value; }, querySelector() { return overlay; } }; const loading = vm.runInNewContext('(' + source.slice(start, end).trim() + ')', { modal: { querySelector() { return frame; } } }); loading(true); assert.deepEqual(attributes, { 'is-loading': true, 'aria-busy': 'true', 'overlay.aria-hidden': 'false' }); loading(false); assert.deepEqual(attributes, { 'is-loading': false, 'aria-busy': 'false', 'overlay.aria-hidden': 'true' }); assert.ok(source.indexOf('setLoading(true);', source.indexOf('active = {')) < source.indexOf(".src = active.original")); assert.match(source, /addEventListener\('error', function \(\) \{\s+setLoading\(false\);/); }); test('library upload selection and drop accept valid batches and skip invalid files', () => { const source = fs.readFileSync(path.join(__dirname, '../src/web/public/js/settings/media-library.js'), 'utf8'); const handlers = {}; let submissions = 0; const input = { accept: 'image/png, image/gif, image/svg+xml', dataset: { imageMaxBytes: '1000' }, files: [], value: '', addEventListener(name, handler) { handlers[name] = handler; } }; const zone = { classList: { add() {}, remove() {} }, addEventListener(name, handler) { handlers[name] = handler; } }; const context = vm.createContext({ uploadInput: input, uploadZone: zone, uploadName: {}, uploadForm: { requestSubmit() { submissions += 1; } }, window: { PulseUpload: { showMessage() {}, formatUploadLimit: String } }, DataTransfer: class { constructor() { this.files = []; this.items = { add: file => this.files.push(file) }; } } }); vm.runInContext(source.slice(source.indexOf('function getUploadFileError('), source.indexOf('if (uploadForm) {')), context); const first = { name: 'first.png', type: 'image/png', size: 42 }; const second = { name: 'second.gif', type: 'image/gif', size: 42 }; const tooLarge = { name: 'large.png', type: 'image/png', size: 1001 }; const unsupported = { name: 'document.txt', type: 'text/plain', size: 1 }; input.files = [first, second, tooLarge]; handlers.change(); assert.deepEqual(Array.from(input.files), [first, second]); assert.equal(submissions, 1); handlers.drop({ preventDefault() {}, dataTransfer: { files: [first, second] } }); assert.deepEqual(Array.from(input.files), [first, second]); assert.equal(submissions, 2); input.files = [unsupported]; handlers.change(); assert.equal(submissions, 2); const slideForm = fs.readFileSync(path.join(__dirname, '../src/web/views/signage/slides/form.hbs'), 'utf8'); assert.doesNotMatch(slideForm, /slide-image-cropper|cropper\.min/); }); test('slide images retain original references and re-editable crops across saves', async () => { const pool = { async query(sql) { if (sql.includes('FROM c_templates st')) return [[{ id: 7, canvas_size_width: 1920, canvas_size_height: 1080 }]]; if (sql.includes('FROM c_template_regions')) return [[{ id: 22, region_key: 'photo', region_type: 'image' }]]; return [[]]; } }; const crop = { x: 20, y: 30, width: 400, height: 200, rotate: 90, scaleX: -1, scaleY: 1 }; const body = { title: 'Photo', template_id: '7', existing_region_image_22: '/media/uploads/crop.png', region_image_original_22: '/media/uploads/original.jpg', region_image_crop_22: JSON.stringify(crop) }; const saved = await buildSlidePayload(pool, { body, files: [] }, null); const photo = JSON.parse(saved.contentJson).photo; assert.equal(photo.value, '/media/uploads/crop.png'); assert.equal(photo.original_value, '/media/uploads/original.jpg'); assert.deepEqual(photo.crop, crop); const reopened = await buildSlidePayload(pool, { body: { title: 'Renamed', template_id: '7' }, files: [] }, { template_id: 7, content_json: saved.contentJson }); assert.deepEqual(JSON.parse(reopened.contentJson).photo, photo); const references = require('../src/data/content-references').collectContentReferences('slide', 1, { photo }); assert.deepEqual(references.filter(reference => reference.reference_type === 'media').map(reference => reference.reference_value).sort(), ['/media/uploads/crop.png', '/media/uploads/original.jpg']); const reset = await buildSlidePayload(pool, { body: { ...body, existing_region_image_22: photo.original_value, region_image_crop_22: '' }, files: [] }, { content_json: saved.contentJson }); assert.equal(JSON.parse(reset.contentJson).photo.value, photo.original_value); assert.equal(JSON.parse(reset.contentJson).photo.crop, null); const replaced = await buildSlidePayload(pool, { body: { title: 'Photo', template_id: '7', existing_region_image_22: '/media/uploads/new.jpg' }, files: [] }, { content_json: saved.contentJson }); assert.equal(JSON.parse(replaced.contentJson).photo.original_value, '/media/uploads/new.jpg'); assert.equal(JSON.parse(replaced.contentJson).photo.crop, null); const cleared = await buildSlidePayload(pool, { body: { ...body, existing_region_image_22: '' }, files: [] }, { content_json: saved.contentJson }); assert.equal(JSON.parse(cleared.contentJson).photo.original_value, ''); assert.equal(JSON.parse(cleared.contentJson).photo.crop, null); }); test('media library sorts Date-valued creation timestamps chronologically', async () => { const originalSyncMediaAssets = mediaLibrary.syncMediaAssetsFromDirectory; const originalCountReferences = mediaLibrary.countMediaAssetReferences; const originalEnsureImageThumbnail = mediaLibrary.ensureImageThumbnail; const originalEnsureVideoThumbnail = mediaLibrary.ensureVideoThumbnail; const assets = [ { id: 1, original_name: 'October 14', media_type: 'image', media_path: '/media/uploads/14.png', file_size: 1, created_at: new Date('2026-10-14T12:00:00Z') }, { id: 2, original_name: 'October 15', media_type: 'image', media_path: '/media/uploads/15.png', file_size: 1, created_at: new Date('2026-10-15T12:00:00Z') }, { id: 3, original_name: 'October 17', media_type: 'image', media_path: '/media/uploads/17.png', file_size: 1, created_at: new Date('2026-10-17T12:00:00Z') }, { id: 4, original_name: 'October 13', media_type: 'video', media_path: '/media/uploads/13.mp4', file_size: 1, created_at: new Date('2026-10-13T12:00:00Z') } ]; const handlers = {}; mediaLibrary.syncMediaAssetsFromDirectory = async function () { return 0; }; mediaLibrary.countMediaAssetReferences = async function () { return 0; }; mediaLibrary.ensureImageThumbnail = async function (mediaPath) { return mediaPath.replace(/\.png$/, '.thumb.webp'); }; mediaLibrary.ensureVideoThumbnail = async function (mediaPath) { return mediaPath.replace(/\.mp4$/, '.poster.webp'); }; try { registerMediaLibraryRoutes({ get(route, ...middleware) { handlers[route] = middleware.at(-1); }, post() {} }, { pool: { async query() { return [assets]; } }, pages: {}, upload: { single() { return function () {}; } }, uploadDir: os.tmpdir(), requirePermission() { return function () {}; } }); const response = { json(data) { this.data = data; } }; const next = function (error) { if (error) throw error; }; const request = { currentUser: { permissions: [] }, query: { offset: '0', limit: '24', sort: 'date-desc' } }; await handlers['/media-library/assets'](request, response, next); assert.deepEqual(response.data.assets.map(asset => asset.original_name), ['October 17', 'October 15', 'October 14', 'October 13']); assert.equal(response.data.assets[0].thumbnail_path, '/media/uploads/17.thumb.webp'); assert.equal(response.data.assets[0].media_path, '/media/uploads/17.png'); assert.equal(response.data.assets[3].thumbnail_path, '/media/uploads/13.poster.webp'); assert.equal(response.data.assets[3].media_path, '/media/uploads/13.mp4'); request.query.sort = 'date-asc'; await handlers['/media-library/assets'](request, response, next); assert.deepEqual(response.data.assets.map(asset => asset.original_name), ['October 13', 'October 14', 'October 15', 'October 17']); } finally { mediaLibrary.syncMediaAssetsFromDirectory = originalSyncMediaAssets; mediaLibrary.countMediaAssetReferences = originalCountReferences; mediaLibrary.ensureImageThumbnail = originalEnsureImageThumbnail; mediaLibrary.ensureVideoThumbnail = originalEnsureVideoThumbnail; } }); test('media library optimizes large raster uploads in place', async () => { const uploadDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'pulse-media-optimize-')); const filePath = path.join(uploadDir, 'large.jpg'); const sourceBuffer = await sharp(crypto.randomBytes(5000 * 100 * 3), { raw: { width: 5000, height: 100, channels: 3 } }).jpeg({ quality: 100 }).toBuffer(); await fs.promises.writeFile(filePath, sourceBuffer); try { const file = { path: filePath, size: sourceBuffer.length }; await optimizeUploadedImage(file, { maxDimension: 8192 }); const optimizedStats = await fs.promises.stat(filePath); assert.equal(file.path, filePath); assert.equal(file.size, optimizedStats.size); assert.ok(optimizedStats.size < sourceBuffer.length); const metadata = await sharp(filePath).metadata(); assert.equal(metadata.format, 'jpeg'); assert.equal(metadata.width, 5000); } finally { await fs.promises.rm(uploadDir, { recursive: true, force: true }); } }); test('media library creates only smaller image variants and preserves the original', async () => { const uploadDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'pulse-media-variants-')); const filePath = path.join(uploadDir, 'large.jpg'); const sourceBuffer = await sharp(crypto.randomBytes(5000 * 100 * 3), { raw: { width: 5000, height: 100, channels: 3 } }).jpeg({ quality: 100 }).toBuffer(); await fs.promises.writeFile(filePath, sourceBuffer); try { const variants = await createImageVariants({ path: filePath }); assert.deepEqual(variants.map(function (variant) { return variant.width; }), [2048, 4096]); assert.deepEqual(await fs.promises.readFile(filePath), sourceBuffer); for (const variant of variants) { const variantPath = path.join(uploadDir, path.basename(variant.path)); assert.ok((await fs.promises.stat(variantPath)).size < sourceBuffer.length); } await removeImageVariants(filePath); assert.equal((await fs.promises.readdir(uploadDir)).filter(function (name) { return name.includes('.variant-'); }).length, 0); } finally { await fs.promises.rm(uploadDir, { recursive: true, force: true }); } }); test('media library creates compact WebP thumbnails and removes them with image derivatives', async () => { const uploadDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'pulse-media-thumbnails-')); const filePath = path.join(uploadDir, 'large.png'); const sourceBuffer = await sharp(crypto.randomBytes(1600 * 900 * 3), { raw: { width: 1600, height: 900, channels: 3 } }).png({ compressionLevel: 0 }).toBuffer(); await fs.promises.writeFile(filePath, sourceBuffer); try { const thumbnailUrl = await createImageThumbnail({ path: filePath }); const thumbnailPath = path.join(uploadDir, 'large.thumb.webp'); const thumbnailBuffer = await fs.promises.readFile(thumbnailPath); const metadata = await sharp(thumbnailBuffer).metadata(); assert.equal(thumbnailUrl, '/media/uploads/large.thumb.webp'); assert.equal(metadata.format, 'webp'); assert.ok(metadata.width <= 480 && metadata.height <= 480); assert.ok((await fs.promises.stat(thumbnailPath)).size < sourceBuffer.length); assert.deepEqual(await fs.promises.readFile(filePath), sourceBuffer); await removeImageVariants(filePath); await assert.rejects(fs.promises.access(thumbnailPath), { code: 'ENOENT' }); } finally { await fs.promises.rm(uploadDir, { recursive: true, force: true }); } }); test('media library creates thumbnails for older assets when a gallery page requests them', async () => { const mediaRoot = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'pulse-media-backfill-')); const uploadDir = path.join(mediaRoot, 'uploads'); await fs.promises.mkdir(uploadDir); const filePath = path.join(uploadDir, 'legacy.jpg'); await sharp({ create: { width: 900, height: 600, channels: 3, background: '#16835d' } }).jpeg().toFile(filePath); try { assert.equal(await ensureImageThumbnail('/media/uploads/legacy.jpg', uploadDir), '/media/uploads/legacy.thumb.webp'); assert.equal(await ensureImageThumbnail('/media/uploads/missing.jpg', uploadDir), null); assert.equal(await ensureImageThumbnail('/media/../outside.jpg', uploadDir), null); assert.ok((await fs.promises.stat(path.join(uploadDir, 'legacy.thumb.webp'))).size > 0); } finally { await fs.promises.rm(mediaRoot, { recursive: true, force: true }); } }); test('media library registers uploaded files with their metadata', async () => { const queries = []; const pool = { async query(sql, params) { queries.push({ sql, params }); return [{ affectedRows: 1 }]; } }; const registered = await registerMediaAssets(pool, [{ filename: 'upload.png', originalname: 'Photo.png', mimetype: 'image/png', size: 42 }], file => '/media/uploads/' + file.filename, () => 'image', 7); assert.deepEqual(registered, ['/media/uploads/upload.png']); assert.match(queries[0].sql, /INSERT INTO c_media_assets/); assert.deepEqual(queries[0].params, [ '/media/uploads/upload.png', 'Photo.png', 'image', 'image/png', 42, 1, 7, 7 ]); }); test('media library backfills existing image and video uploads', async () => { const uploadDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'pulse-media-library-')); await fs.promises.writeFile(path.join(uploadDir, 'legacy.png'), Buffer.from('image')); await fs.promises.writeFile(path.join(uploadDir, 'legacy.thumb.webp'), Buffer.from('thumbnail')); await fs.promises.writeFile(path.join(uploadDir, 'image-playback-' + 'a'.repeat(64) + '.png'), Buffer.from('generated')); await fs.promises.writeFile(path.join(uploadDir, 'legacy.mp4'), Buffer.from('video')); await fs.promises.writeFile(path.join(uploadDir, 'ignore.txt'), Buffer.from('other')); const queries = []; const pool = { async query(sql, params) { queries.push({ sql, params }); return [{ affectedRows: 1 }]; } }; try { assert.equal(await syncMediaAssetsFromDirectory(pool, uploadDir), 2); assert.deepEqual(queries.map(query => query.params[0]).sort(), [ '/media/uploads/legacy.mp4', '/media/uploads/legacy.png' ]); assert.match(queries[0].sql, /original_name = original_name/); assert.match(queries[0].sql, /is_published = is_published/); } finally { await fs.promises.rm(uploadDir, { recursive: true, force: true }); } }); test('media library omits generated image copies from published asset results', async () => { const generated = 'image-crop-' + 'b'.repeat(64) + '.png'; const pool = { async query() { return [[ { id: 1, media_path: '/media/uploads/source.png' }, { id: 2, media_path: '/media/uploads/' + generated } ]]; } }; assert.deepEqual((await mediaLibrary.fetchMediaAssets(pool)).map(asset => asset.id), [1]); }); test('media library counts slide, template background, and region default references', async () => { const queries = []; const pool = { async query(sql, params) { queries.push({ sql, params }); if (sql.includes('FROM c_content_references')) { return [[{ owner_count: 3 }]]; } if (sql.includes('thumbnail_path')) { return [[{ ref_count: 2 }]]; } if (sql.includes('FROM c_templates')) return [[{ ref_count: 1 }]]; return [[]]; } }; assert.equal(await countMediaAssetReferences(pool, '/media/uploads/shared.png'), 6); assert.equal(queries.length, 3); assert.deepEqual(queries[0].params, ['media', '/media/uploads/shared.png']); assert.equal(queries[1].params[0], '/media/uploads/shared.png'); assert.equal(queries[2].params[0], '/media/uploads/shared.png'); assert.match(queries[0].sql, /c_content_references/); assert.match(queries[0].sql, /idx_content_reference_lookup|reference_type/); assert.match(queries[1].sql, /background_image_path/); assert.match(queries[2].sql, /thumbnail_path/); }); test('full-size slide previews share the small thumbnail reference', async () => { const pool = { async query(sql, params) { if (sql.includes('thumbnail_path')) { assert.deepEqual(params, ['/media/thumbnails/slides/slide-7.png']); return [[{ ref_count: 1 }]]; } return [[{ owner_count: 0, ref_count: 0 }]]; } }; assert.equal(await countMediaAssetReferences(pool, '/media/thumbnails/slides/slide-7.full.png'), 1); }); test('media library upload and delete routes audit successful asset changes', async () => { const originalCreateVariants = mediaLibrary.createImageVariants; const originalCreateThumbnail = mediaLibrary.createImageThumbnail; const originalRegisterAsset = mediaLibrary.registerMediaAsset; const originalCountReferences = mediaLibrary.countMediaAssetReferences; const originalRemoveVariants = mediaLibrary.removeImageVariants; const root = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'pulse-media-audit-')); const uploadDir = path.join(root, 'media', 'uploads'); const filePath = path.join(uploadDir, 'photo.png'); await fs.promises.mkdir(uploadDir, { recursive: true }); await fs.promises.writeFile(filePath, 'image'); const routeHandlers = {}; const events = []; const app = { get(route, ...handlers) { routeHandlers['GET ' + route] = handlers; }, post(route, ...handlers) { routeHandlers['POST ' + route] = handlers; } }; const pool = { async query(sql) { if (sql.includes('FROM o_app_settings')) return [[]]; if (sql.includes('FROM c_media_assets')) { return [[{ id: 9, media_path: '/media/uploads/photo.png', original_name: 'Photo.png', media_type: 'image', file_size: 5 }]]; } return [[]]; } }; mediaLibrary.createImageVariants = async function () {}; mediaLibrary.createImageThumbnail = async function () {}; mediaLibrary.registerMediaAsset = async function () {}; mediaLibrary.countMediaAssetReferences = async function () { return 0; }; mediaLibrary.removeImageVariants = async function () {}; try { registerMediaLibraryRoutes(app, { pool: pool, pages: {}, upload: { single() { return function (_req, _res, next) { next(); }; } }, uploadDir: uploadDir, getAuditUserId(req) { return req.currentUser.id; }, recordRequestAuditEvent(_pool, _req, event) { events.push(event); }, requirePermission() { return function (_req, _res, next) { next(); }; } }); const response = { redirect(value) { this.location = value; return this; } }; const next = function (error) { if (error) throw error; }; const request = { currentUser: { id: 4 }, body: {}, file: { path: filePath, filename: 'photo.png', originalname: 'Photo.png', mimetype: 'image/png', size: 5 } }; await routeHandlers['POST /media-library'].at(-1)(request, response, next); await routeHandlers['POST /media-library/:id/delete'].at(-1)( { currentUser: { id: 4 }, params: { id: '9' } }, response, next ); assert.deepEqual(events.map(function (event) { return event.eventType; }), ['media.uploaded', 'media.deleted']); assert.equal(events[0].targetLabel, 'Photo.png'); assert.equal(events[1].targetId, 9); assert.ok(events.every(function (event) { return event.category === 'media-assets' && event.actorUserId === 4; })); await fs.promises.writeFile(filePath, 'image'); const jsonResponse = { statusCode: 200, status(value) { this.statusCode = value; return this; }, json(value) { this.data = value; return this; }, redirect() { throw new Error('JSON upload must not redirect.'); } }; await routeHandlers['POST /media-library'].at(-1)({ ...request, headers: { accept: 'application/json' } }, jsonResponse, next); assert.deepEqual(jsonResponse.data, { success: true, path: '/media/uploads/photo.png' }); assert.equal(jsonResponse.statusCode, 200); await routeHandlers['POST /media-library'].at(-1)({ headers: { accept: 'application/json' } }, jsonResponse, next); assert.equal(jsonResponse.statusCode, 400); assert.match(jsonResponse.data.error, /Choose an image or video/); await routeHandlers['POST /media-library'].at(-1)({ ...request, headers: { accept: 'application/json' }, file: { ...request.file, mimetype: 'text/plain' } }, jsonResponse, next); assert.equal(jsonResponse.statusCode, 400); assert.match(jsonResponse.data.error, /disabled/); } finally { mediaLibrary.createImageVariants = originalCreateVariants; mediaLibrary.createImageThumbnail = originalCreateThumbnail; mediaLibrary.registerMediaAsset = originalRegisterAsset; mediaLibrary.countMediaAssetReferences = originalCountReferences; mediaLibrary.removeImageVariants = originalRemoveVariants; await fs.promises.rm(root, { recursive: true, force: true }); } }); test('media library uploads a batch sequentially and reports independent failures', async () => { const source = fs.readFileSync(path.join(__dirname, '../src/web/public/js/settings/media-library.js'), 'utf8'); const handlers = {}; const requests = []; const messages = []; const attributes = {}; const progress = { hidden: true }; const label = { textContent: '' }; const bar = { setAttribute(name, value) { attributes[name] = value; }, removeAttribute(name) { delete attributes[name]; } }; const fill = { style: {}, classList: { toggle() {} }, textContent: '' }; const input = { disabled: false, files: [{ name: 'first.png' }, { name: 'second.png' }, { name: 'third.png' }], value: 'selected' }; const closeButton = { disabled: false }; let refreshed = 0; let hidden = 0; const zone = { classList: { toggle(name, value) { attributes[name] = value; } }, setAttribute(name, value) { attributes[name] = value; }, querySelector(selector) { return selector.endsWith('progress]') ? progress : selector.endsWith('text]') ? label : selector.endsWith('bar]') ? bar : fill; } }; const context = vm.createContext({ uploadForm: { action: '/media-library', addEventListener(name, handler) { handlers[name] = handler; } }, uploadInput: input, uploadZone: zone, uploadModal: { querySelectorAll() { return [closeButton]; }, addEventListener(name, handler) { handlers[name] = handler; } }, window: { PulseUpload: { showMessage(message) { messages.push(message); } }, showToast() {} }, hideModal() { hidden += 1; }, async refreshGallery() { refreshed += 1; }, FormData: class { constructor() { assert.equal(input.disabled, true); this.files = []; } append(name, file) { this.files.push({ name, file }); } }, XMLHttpRequest: class { constructor() { this.upload = {}; requests.push(this); } open(method, url) { this.method = method; this.url = url; } setRequestHeader() {} send(data) { this.data = data; } } }); const start = source.indexOf('function setUploadProgress('); const end = source.indexOf('function getGalleryFilters()', start); vm.runInContext('let uploadInProgress = false;\n' + source.slice(start, end), context); const submit = () => handlers.submit({ preventDefault() {} }); submit(); assert.equal(progress.hidden, false); assert.equal(input.disabled, true); assert.equal(closeButton.disabled, true); assert.equal(attributes['aria-busy'], 'true'); assert.equal(requests.length, 1); assert.equal(requests[0].data.files[0].file.name, 'first.png'); requests[0].upload.onprogress({ lengthComputable: true, loaded: 250, total: 1000 }); assert.equal(fill.style.width, '25%'); assert.equal(label.textContent, 'Uploading 1 of 3: first.png (25%)'); assert.equal(attributes['aria-valuenow'], '25'); requests[0].upload.onload(); assert.equal(label.textContent, 'Processing 1 of 3: first.png'); assert.equal(fill.style.width, '100%'); assert.equal(attributes['aria-valuenow'], undefined); submit(); assert.equal(requests.length, 1); let prevented = false; handlers['hide.bs.modal']({ preventDefault() { prevented = true; } }); assert.equal(prevented, true); requests[0].status = 400; requests[0].responseText = JSON.stringify({ error: 'Upload rejected.' }); await requests[0].onload(); await new Promise(resolve => setImmediate(resolve)); assert.equal(requests.length, 2); assert.equal(requests[1].data.files[0].file.name, 'second.png'); requests[1].status = 200; requests[1].responseText = JSON.stringify({ success: true }); await requests[1].onload(); await new Promise(resolve => setImmediate(resolve)); assert.equal(requests.length, 3); assert.equal(requests[2].data.files[0].file.name, 'third.png'); requests[2].ontimeout(); await new Promise(resolve => setImmediate(resolve)); assert.deepEqual(messages, ['1 uploaded. first.png: Upload rejected. third.png: Upload timed out.']); assert.equal(input.disabled, false); assert.equal(closeButton.disabled, false); assert.equal(progress.hidden, true); assert.equal(hidden, 1); assert.equal(refreshed, 1); assert.equal(input.value, ''); assert.equal(progress.hidden, true); assert.equal(attributes['aria-busy'], 'false'); assert.deepEqual(requests.map(request => request.method), ['POST', 'POST', 'POST']); assert.ok(requests.every(request => request.url === '/media-library')); assert.equal(requests[1].withCredentials, true); const view = fs.readFileSync(path.join(__dirname, '../src/web/views/settings/media-library/list.hbs'), 'utf8'); assert.match(view, /data-media-library-upload[^>]*multiple/); assert.match(view, /data-media-library-upload-progress/); assert.match(view, /role="progressbar" aria-label="Media upload progress"/); assert.match(view, /class="progress rounded-pill slide-image-region-upload-zone-progress-bar"/); assert.match(view, /class="progress-bar bg-primary rounded-pill progress-bar-striped progress-bar-animated"/); }); test('media library delete redirects preserve filters for individual and bulk outcomes', async () => { const originalCountReferences = mediaLibrary.countMediaAssetReferences; const originalRemoveVariants = mediaLibrary.removeImageVariants; const handlers = {}; let outcome = 'deleted'; const query = { search: 'Photo & Video', type: 'image', usage: 'unused', sort: 'name-desc' }; mediaLibrary.countMediaAssetReferences = async function () { return outcome === 'in-use' ? 1 : 0; }; mediaLibrary.removeImageVariants = async function () {}; try { registerMediaLibraryRoutes({ get() {}, post(route, ...middleware) { handlers[route] = middleware.at(-1); } }, { pool: { async query(sql) { if (sql.startsWith('SELECT')) { return [outcome === 'missing' ? [] : [{ id: 9, media_path: '/media/uploads/missing-test.png' }]]; } return [[]]; } }, pages: {}, upload: { single() { return function () {}; } }, uploadDir: path.join(os.tmpdir(), 'pulse-media-filter-test', 'uploads'), requirePermission() { return function () {}; } }); for (const route of ['/media-library/:id/delete', '/media-library/delete']) { for (outcome of ['deleted', 'in-use', 'missing', 'empty']) { let location; await handlers[route]({ query, params: { id: '9' }, body: { 'assetIds[]': outcome === 'empty' ? [] : ['9'] } }, { redirect(value) { location = value; } }, function (error) { throw error; }); const redirect = new URL(location, 'http://localhost'); assert.equal(redirect.pathname, '/media-library'); for (const [key, value] of Object.entries(query)) { assert.equal(redirect.searchParams.get(key), value, route + ': ' + outcome + ': ' + key); } assert.ok(redirect.searchParams.get('message')); } } } finally { mediaLibrary.countMediaAssetReferences = originalCountReferences; mediaLibrary.removeImageVariants = originalRemoveVariants; } }); test('media gallery restores controls and submits current filters with both delete actions', () => { const listeners = {}; const controls = {}; for (const [name, values] of Object.entries({ search: [''], 'type-filter': ['all', 'image', 'video'], 'usage-filter': ['all', 'used', 'unused'], sort: ['date-desc', 'name-desc', 'size-asc'] })) { controls['[data-media-gallery-' + name + ']'] = { value: values[0], options: values.map(value => ({ value })), addEventListener() {} }; } const gallery = { dataset: {}, querySelector(selector) { return controls[selector] || null; }, querySelectorAll() { return []; }, addEventListener(name, callback) { listeners[name] = callback; } }; let submittedForm; const context = vm.createContext({ URLSearchParams, window: { location: { search: '?search=Photo+%26+Video&type=image&usage=unused&sort=name-desc' }, innerWidth: 1200, confirm() { return true; } }, document: { getElementById() { return null; }, querySelector(selector) { return selector === '.media-gallery' ? gallery : null; }, createElement() { return { appendChild() {}, submit() { submittedForm = this; } }; }, body: { appendChild() {} } } }); vm.runInContext(fs.readFileSync(path.join(__dirname, '../src/web/public/js/settings/media-library.js'), 'utf8'), context); assert.equal(controls['[data-media-gallery-search]'].value, 'Photo & Video'); assert.equal(controls['[data-media-gallery-type-filter]'].value, 'image'); assert.equal(controls['[data-media-gallery-usage-filter]'].value, 'unused'); assert.equal(controls['[data-media-gallery-sort]'].value, 'name-desc'); controls['[data-media-gallery-search]'].value = 'Updated & current'; controls['[data-media-gallery-sort]'].value = 'size-asc'; const form = { getAttribute() { return '/media-library/9/delete'; } }; listeners.submit({ target: form }); vm.runInContext("selectedAssetIds.add('9'); submitBulkDelete();", context); for (const target of [form, submittedForm]) { const url = new URL(target.action, 'http://localhost'); assert.equal(url.searchParams.get('search'), 'Updated & current'); assert.equal(url.searchParams.get('type'), 'image'); assert.equal(url.searchParams.get('usage'), 'unused'); assert.equal(url.searchParams.get('sort'), 'size-asc'); assert.equal(url.searchParams.has('offset'), false); } }); test('empty media gallery retains filters and its refreshable results container', () => { const renderMediaLibraryPage = require('../src/web/routes/settings/media-library/list'); const html = renderMediaLibraryPage({ assets: [], assetTotal: 0 }, '', { permissions: ['media-library.read', 'media-library.delete'] }); assert.match(html, /data-media-gallery-search/); assert.match(html, /data-media-gallery-type-filter/); assert.match(html, /data-media-gallery-usage-filter/); assert.match(html, /data-media-gallery-sort/); assert.match(html, /class="media-gallery-scroll"/); assert.match(html, /data-media-gallery-loading[^>]*role="status" aria-live="polite"/); assert.doesNotMatch(html, /data-media-gallery-load-more>/); assert.match(html, /data-media-gallery-no-results>/); }); test('media gallery automatically loads the next page when its load-more sentinel approaches the scroll viewport', async () => { const controls = {}; const mediaScroll = {}; const loadMoreWrap = { hidden: false }; const loading = { hidden: true }; let observedTarget; let observerCallback; let observerOptions; let requestedUrl = ''; const gallery = { dataset: { mediaGalleryOffset: '24', mediaGalleryHasMore: 'true' }, querySelector(selector) { if (selector === '.media-gallery-scroll') return mediaScroll; return controls[selector] || null; }, querySelectorAll() { return []; }, addEventListener() {} }; controls['[data-media-gallery-load-more-wrap]'] = loadMoreWrap; controls['[data-media-gallery-loading]'] = loading; class TestIntersectionObserver { constructor(callback, options) { observerCallback = callback; observerOptions = options; } observe(target) { observedTarget = target; } } const context = vm.createContext({ URLSearchParams, window: { location: { search: '' }, innerWidth: 1200, IntersectionObserver: TestIntersectionObserver }, document: { getElementById() { return null; }, querySelector(selector) { return selector === '.media-gallery' ? gallery : null; } }, fetch: async function (url) { requestedUrl = url; return { ok: true, async json() { return { assets: [], total: 24, nextOffset: 24, hasMore: false }; } }; } }); vm.runInContext(fs.readFileSync(path.join(__dirname, '../src/web/public/js/settings/media-library.js'), 'utf8'), context); assert.equal(observedTarget, loadMoreWrap); assert.equal(observerOptions.root, mediaScroll); assert.equal(observerOptions.rootMargin, '0px'); observerCallback([{ isIntersecting: true }]); assert.equal(loading.hidden, false); await new Promise(resolve => setImmediate(resolve)); assert.match(requestedUrl, /^\/media-library\/assets\?/); assert.equal(new URL(requestedUrl, 'http://localhost').searchParams.get('offset'), '24'); assert.equal(loadMoreWrap.hidden, true); assert.equal(loading.hidden, true); }); test('media rename validates filenames and changes only metadata with update permission and auditing', async () => { const handlers = {}; const queries = []; const events = []; const permissions = []; let exists = true; registerMediaLibraryRoutes({ get() {}, post(route, ...middleware) { handlers[route] = middleware.at(-1); } }, { pool: { async query(sql, params) { queries.push({ sql, params }); return [sql.startsWith('SELECT') && exists ? [{ id: 9, original_name: 'old.png', media_path: '/media/uploads/uuid.png' }] : []]; } }, pages: {}, upload: { single() { return function () {}; } }, uploadDir: os.tmpdir(), getAuditUserId() { return 4; }, requirePermission(permission) { permissions.push(permission); return function () {}; }, recordRequestAuditEvent(_pool, _req, event) { events.push(event); } }); assert.ok(permissions.includes('media-library.update')); const rename = handlers['/media-library/:id/rename']; const response = { statusCode: 200, status(code) { this.statusCode = code; return this; }, json(data) { this.data = data; } }; const next = function (error) { throw error; }; for (const name of ['', ' ', '../photo.png', 'a\\b.png', 'a\n.png', 'a?b.png', 'a'.repeat(256), ['photo.png'], '..']) { await rename({ params: { id: '9' }, body: { name } }, response, next); assert.equal(response.statusCode, 400); } assert.equal(queries.length, 0); response.statusCode = 200; await rename({ params: { id: '9' }, body: { name: ' New photo.png ' } }, response, next); assert.deepEqual(response.data, { id: 9, name: 'New photo.png' }); assert.deepEqual(queries[1], { sql: 'UPDATE c_media_assets SET original_name = ?, modified_by = ? WHERE id = ?', params: ['New photo.png', 4, 9] }); assert.equal(events[0].eventType, 'media.renamed'); assert.deepEqual(events[0].details, { mediaPath: '/media/uploads/uuid.png', previousName: 'old.png', name: 'New photo.png' }); await rename({ params: { id: '9' }, body: { name: 'old.png' } }, response, next); assert.equal(queries.filter(query => query.sql.startsWith('UPDATE')).length, 1); exists = false; await rename({ params: { id: '9' }, body: { name: 'missing.png' } }, response, next); assert.equal(response.statusCode, 404); }); test('media preview offers filename editing only with media update permission', () => { const renderMediaLibraryPage = require('../src/web/routes/settings/media-library/list'); const data = { assets: [], assetTotal: 0 }; const readOnly = renderMediaLibraryPage(data, '', { permissions: ['media-library.read'] }); assert.doesNotMatch(readOnly, /data-media-gallery-rename-form/); const editable = renderMediaLibraryPage(data, '', { permissions: ['media-library.read', 'media-library.update'] }); assert.match(editable, /data-media-gallery-rename-form/); assert.match(editable, /maxlength="255" required data-media-gallery-filename/); assert.match(editable, /data-media-gallery-extension/); assert.match(editable, /data-media-gallery-rename-save/); }); test('media preview rename keeps the original filename extension fixed', async () => { const handlers = {}; const submittedNames = []; const input = { value: '', disabled: false, maxLength: 255 }; const extension = { textContent: '' }; const title = { textContent: '' }; const body = { replaceChildren() {}, appendChild() {}, querySelector() { return null; } }; const renameForm = { action: '', addEventListener(name, callback) { handlers['form:' + name] = callback; } }; const renameSave = { disabled: false }; const renameStatus = { hidden: true, classList: { add() {}, remove() {} } }; const modal = { querySelector(selector) { return { '[data-media-gallery-preview-body]': body, '#media-gallery-preview-modal-label': title, '[data-media-gallery-rename-form]': renameForm, '[data-media-gallery-filename]': input, '[data-media-gallery-extension]': extension, '[data-media-gallery-rename-save]': renameSave, '[data-media-gallery-rename-status]': renameStatus }[selector] || null; }, addEventListener(name, callback) { handlers['modal:' + name] = callback; } }; const column = { dataset: { mediaId: '9' }, querySelector() { return null; } }; const preview = { dataset: { mediaName: 'original.png' }, getAttribute(name) { return { 'data-media-path': '/media/uploads/id.png', 'data-media-type': 'image', 'data-media-name': 'original.png' }[name]; }, closest() { return column; }, setAttribute() {} }; const context = vm.createContext({ URLSearchParams, window: { location: { search: '' }, innerWidth: 1200, pulseModal: { show() {} } }, document: { getElementById() { return modal; }, querySelector() { return null; }, addEventListener(name, callback) { handlers['document:' + name] = callback; }, createElement() { return {}; } }, fetch: async (_url, options) => { if (options && options.body) { submittedNames.push(JSON.parse(options.body).name); return { ok: true, async json() { return { name: 'updated.png' }; } }; } return { ok: true, async json() { return { assets: [], nextOffset: 0, hasMore: false, total: 0 }; } }; } }); vm.runInContext(fs.readFileSync(path.join(__dirname, '../src/web/public/js/settings/media-library.js'), 'utf8'), context); handlers['document:click']({ target: { closest() { return preview; } } }); assert.equal(input.value, 'original'); assert.equal(extension.textContent, '.png'); assert.equal(input.maxLength, 251); input.value = 'updated'; await handlers['form:submit']({ preventDefault() {} }); assert.deepEqual(submittedNames, ['updated.png']); });