1119 lines
60 KiB
JavaScript
1119 lines
60 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,
|
|
removeImageVariants,
|
|
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('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 submit the original File and reject 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: [], 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 setUploadFile('), source.indexOf('if (uploadForm) {')), context);
|
|
for (const type of ['image/png', 'image/gif', 'image/svg+xml']) {
|
|
const file = { name: 'original', type, size: 42 };
|
|
input.files = [file];
|
|
handlers.change();
|
|
assert.equal(input.files[0], file);
|
|
handlers.drop({ preventDefault() {}, dataTransfer: { files: [file] } });
|
|
assert.equal(input.files[0], file);
|
|
}
|
|
assert.equal(submissions, 6);
|
|
input.files = [{ type: 'image/png', size: 1001 }];
|
|
handlers.change();
|
|
handlers.drop({ preventDefault() {}, dataTransfer: { files: [{ type: 'text/plain', size: 1 }] } });
|
|
assert.equal(submissions, 6);
|
|
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 assets = [
|
|
{ id: 1, original_name: 'October 14', media_type: 'image', file_size: 1, created_at: new Date('2026-10-14T12:00:00Z') },
|
|
{ id: 2, original_name: 'October 15', media_type: 'image', file_size: 1, created_at: new Date('2026-10-15T12:00:00Z') },
|
|
{ id: 3, original_name: 'October 17', media_type: 'image', file_size: 1, created_at: new Date('2026-10-17T12:00:00Z') }
|
|
];
|
|
const handlers = {};
|
|
mediaLibrary.syncMediaAssetsFromDirectory = async function () { return 0; };
|
|
mediaLibrary.countMediaAssetReferences = async function () { return 0; };
|
|
|
|
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']);
|
|
|
|
request.query.sort = 'date-asc';
|
|
await handlers['/media-library/assets'](request, response, next);
|
|
assert.deepEqual(response.data.assets.map(asset => asset.original_name), ['October 14', 'October 15', 'October 17']);
|
|
} finally {
|
|
mediaLibrary.syncMediaAssetsFromDirectory = originalSyncMediaAssets;
|
|
mediaLibrary.countMediaAssetReferences = originalCountReferences;
|
|
}
|
|
});
|
|
|
|
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 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.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 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.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.registerMediaAsset = originalRegisterAsset;
|
|
mediaLibrary.countMediaAssetReferences = originalCountReferences;
|
|
mediaLibrary.removeImageVariants = originalRemoveVariants;
|
|
await fs.promises.rm(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test('media library drop-zone upload reports transfer progress, processing, errors and success', 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: 'original.png' }], value: 'original.png' };
|
|
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, false); } },
|
|
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');
|
|
requests[0].upload.onprogress({ lengthComputable: true, loaded: 250, total: 1000 });
|
|
assert.equal(fill.style.width, '25%');
|
|
assert.equal(label.textContent, 'Uploading 25%');
|
|
assert.equal(attributes['aria-valuenow'], '25');
|
|
requests[0].upload.onload();
|
|
assert.equal(label.textContent, 'Processing...');
|
|
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();
|
|
assert.deepEqual(messages, ['Upload rejected.']);
|
|
assert.equal(input.disabled, false);
|
|
assert.equal(closeButton.disabled, false);
|
|
assert.equal(progress.hidden, true);
|
|
submit();
|
|
requests[1].ontimeout();
|
|
assert.equal(input.disabled, false);
|
|
assert.match(messages[1], /timed out/);
|
|
submit();
|
|
requests[2].status = 200;
|
|
requests[2].responseText = JSON.stringify({ success: true });
|
|
await requests[2].onload();
|
|
assert.equal(hidden, 1);
|
|
assert.equal(refreshed, 1);
|
|
assert.equal(input.value, '');
|
|
assert.equal(progress.hidden, true);
|
|
assert.equal(attributes['aria-busy'], 'false');
|
|
const view = fs.readFileSync(path.join(__dirname, '../src/web/views/settings/media-library/list.hbs'), 'utf8');
|
|
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-no-results>/);
|
|
});
|
|
|
|
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']);
|
|
});
|