Add media filename renaming and preserve library filters
Publish Docker Image / build-and-push-existing-registry (./build/Dockerfile, web, pulse-signage-web) (push) Successful in 1m14s
Publish Docker Image / build-and-push-existing-registry (./build/Dockerfile.player, player, pulse-signage-player) (push) Successful in 41s

This commit is contained in:
lzstealth committed 2026-10-17 19:57:03 +01:00
1 parent 2fd059d35a
commit eb724167bc
6 files changed
+310 -20

No files matched your search

+159
View File
@@ -4,6 +4,7 @@ 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');
@@ -212,3 +213,161 @@ test('media library upload and delete routes audit successful asset changes', as
await fs.promises.rm(root, { recursive: true, force: true });
}
});
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-rename-save/);
});