Files
pulse-signage/test/media-library.test.js
T
lzstealth b80d4aa9c7
Publish Docker Image / build-and-push-existing-registry (./build/Dockerfile, web, pulse-signage-web) (push) Successful in 1m20s
Publish Docker Image / build-and-push-existing-registry (./build/Dockerfile.player, player, pulse-signage-player) (push) Successful in 33s
Release 2.21.4: media library thumbnails and scrolling
2026-10-27 00:56:43 +00:00

1249 lines
68 KiB
JavaScript

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 '<div class="card-header">' + context.headerActions + '</div><div class="card-body">' + context.bodyHtml + '</div>'; } },
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"><button type="button" class="slide-image-region-crop"/);
assert.ok(card.indexOf('data-edit-image-crop') < card.indexOf('slide-image-region-preview-box'));
assert.match(card, /title="Edit crop" aria-label="Edit crop"><i class="bi bi-crop" aria-hidden="true"><\/i><\/button>/);
assert.match(card, /name="region_image_original_22"[^>]*original\.svg/);
assert.doesNotMatch(card, /type="range"|region_image_fit/);
const previewContext = modules.editor.buildPreviewRenderContext(region, null, { photo });
const preview = modules.editor.renderPreview(region, previewContext);
const player = modules.player.renderRegion(region, photo);
for (const html of [preview, player]) {
assert.match(html, /crop\.png/);
assert.doesNotMatch(html, /original\.svg/);
}
assert.equal(previewContext.original_value, photo.original_value);
assert.deepEqual(previewContext.crop, photo.crop);
});
test('image uploads open the cropper and library selections wait for the picker to close', () => {
const handlers = {};
const timers = [];
let opens = 0;
let pickerVisible = false;
let afterPickerHidden;
const original = { value: '' };
const crop = { value: 'old crop' };
const button = { disabled: false, click() { opens += 1; } };
const card = { isConnected: true, querySelector(selector) { return selector.includes('original') ? original : selector.includes('crop_') ? crop : button; } };
const input = { name: 'existing_region_image_22', value: '/media/uploads/photo.jpg', dataset: {}, closest() { return card; } };
const picker = { classList: { contains() { return pickerVisible; } }, addEventListener(name, handler, options) { assert.equal(name, 'hidden.bs.modal'); assert.equal(options.once, true); afterPickerHidden = handler; } };
vm.runInNewContext(fs.readFileSync(path.join(__dirname, '../src/web/public/js/regions/type/image.js'), 'utf8'), {
window: { pulseRegionTypes: { register() {} }, setTimeout(handler) { timers.push(handler); } },
document: { addEventListener(name, handler) { handlers[name] = handler; }, getElementById() { return picker; } }
});
handlers.change({ target: input });
assert.equal(original.value, input.value);
assert.equal(crop.value, '');
assert.equal(opens, 0);
timers.shift()();
assert.equal(opens, 1);
pickerVisible = true;
input.value = '/media/uploads/library.jpg';
handlers.change({ target: input });
assert.equal(opens, 1);
assert.equal(timers.length, 0);
pickerVisible = false;
afterPickerHidden();
assert.equal(opens, 2);
input.dataset.applyingCrop = '1';
input.value = '/media/uploads/copy.png';
handlers.change({ target: input });
assert.equal(original.value, '/media/uploads/library.jpg');
assert.equal(timers.length, 0);
delete input.dataset.applyingCrop;
input.value = '';
handlers.change({ target: input });
assert.equal(timers.length, 0);
input.value = '/media/uploads/pending.jpg';
handlers.change({ target: input });
input.value = '';
timers.shift()();
assert.equal(opens, 2);
});
test('opening another image waits until the previous cropper has finished closing', async () => {
const source = fs.readFileSync(path.join(__dirname, '../src/web/public/js/slides/slide-image-cropper.js'), 'utf8');
const marker = "document.addEventListener('click', ";
const start = source.lastIndexOf(marker) + marker.length;
const end = source.lastIndexOf('\n });');
let afterHidden;
let clicks = 0;
const button = { isConnected: true, click() { clicks += 1; } };
const modal = { dataset: { cropClosing: '1' }, addEventListener(name, callback, options) { assert.equal(name, 'hidden.bs.modal'); assert.equal(options.once, true); afterHidden = callback; } };
const open = vm.runInNewContext('(' + source.slice(start, end + 4) + ')', { modal, busy: false });
await open({ target: { closest() { return button; } } });
assert.equal(clicks, 0);
delete modal.dataset.cropClosing;
afterHidden();
assert.equal(clicks, 1);
});
test('cropped draft copies are cleaned on replacement, removal and editor cancellation without deleting originals', () => {
const source = fs.readFileSync(path.join(__dirname, '../src/web/public/js/slides/slide-image-cropper.js'), 'utf8');
const handlers = {};
const requests = [];
const context = vm.createContext({
Map, Set,
document: { addEventListener(name, handler) { handlers[name] = handler; } },
window: { addEventListener(name, handler) { handlers[name] = handler; } },
fetch(url, options) { requests.push({ url, paths: JSON.parse(options.body).uploadPaths, keepalive: options.keepalive }); return Promise.resolve({ ok: true }); }
});
vm.runInContext(source.slice(source.indexOf('var draftCopies'), source.indexOf('function loadCropper()')), context);
const input = { name: 'existing_region_image_22', value: '/media/uploads/crop-1.png', dataset: {} };
context.input = input;
vm.runInContext("trackCopy(input, '/media/uploads/crop-1.png')", context);
assert.equal(requests.length, 0);
input.value = '/media/uploads/crop-2.png';
vm.runInContext("trackCopy(input, '/media/uploads/crop-2.png')", context);
assert.deepEqual(requests.pop().paths, ['/media/uploads/crop-1.png']);
input.dataset.applyingCrop = '1';
handlers.change({ target: input });
assert.equal(requests.length, 0);
delete input.dataset.applyingCrop;
input.value = '';
handlers.change({ target: input });
assert.deepEqual(requests.pop().paths, ['/media/uploads/crop-2.png']);
input.value = '/media/uploads/original.png';
handlers.change({ target: input });
assert.equal(requests.length, 0);
vm.runInContext("trackCopy(input, '/media/uploads/abandoned.png')", context);
assert.equal(handlers.beforeunload, undefined);
handlers.pagehide();
const cancelled = requests.pop();
assert.equal(cancelled.url, '/slides/uploads/cleanup');
assert.equal(cancelled.keepalive, true);
assert.deepEqual(cancelled.paths, ['/media/uploads/abandoned.png']);
});
test('cropper footer contains apply and cancel without a separate original-image action', () => {
const source = fs.readFileSync(path.join(__dirname, '../src/web/public/js/slides/slide-image-cropper.js'), 'utf8');
assert.doesNotMatch(source, /data-crop-original|Use original/);
assert.match(source, /data-crop-apply>Apply crop/);
assert.match(source, /data-bs-dismiss="modal">Cancel/);
assert.match(source, /data-crop-action="reset"/);
});
test('cropper toolbar retains labeled rotate and flip groups, ratio presets and a separate danger reset', () => {
const source = fs.readFileSync(path.join(__dirname, '../src/web/public/js/slides/slide-image-cropper.js'), 'utf8');
const start = source.indexOf('function createModal()');
const end = source.indexOf('document.body.appendChild(modal);', start);
const context = vm.createContext({ modal: null, document: { createElement() { return { setAttribute() {} }; } } });
const html = vm.runInContext(source.slice(start, end) + '\nreturn modal.innerHTML; }\ncreateModal();', context);
assert.match(html, /aria-label="Rotate"/);
assert.match(html, /aria-label="Flip"/);
for (const label of ['Rotate left', 'Rotate right', 'Flip horizontal', 'Flip vertical']) assert.ok(html.includes('</i> ' + label + '</button>'));
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, '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 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']);
});