Release v2.13.1
This commit is contained in:
+25
-13
@@ -1,6 +1,8 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
|
||||
require('../src/common');
|
||||
|
||||
@@ -172,7 +174,11 @@ test('canvas size update returns a warning when an in-use canvas changes dimensi
|
||||
assert.equal(queries.length, 2);
|
||||
});
|
||||
|
||||
test('slide upload cleanup route removes unused uploads', async () => {
|
||||
test('slide upload cleanup route removes pending media assets', async () => {
|
||||
const uploadRoot = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'pulse-slide-upload-cleanup-'));
|
||||
const uploadDir = path.join(uploadRoot, 'media', 'uploads');
|
||||
await fs.promises.mkdir(uploadDir, { recursive: true });
|
||||
await fs.promises.writeFile(path.join(uploadDir, 'test-file.png'), Buffer.from('temporary upload'));
|
||||
const handlers = {};
|
||||
const app = {
|
||||
get(path, ...routeHandlers) {
|
||||
@@ -183,12 +189,16 @@ test('slide upload cleanup route removes unused uploads', async () => {
|
||||
}
|
||||
};
|
||||
|
||||
let cleanupCall = null;
|
||||
|
||||
const deps = {
|
||||
pool: {
|
||||
async query() {
|
||||
return [[]];
|
||||
async query(sql) {
|
||||
if (String(sql).includes('is_published = 0')) {
|
||||
return [[{ id: 1 }]];
|
||||
}
|
||||
if (String(sql).includes('DELETE FROM c_media_assets')) {
|
||||
return [{ affectedRows: 1 }];
|
||||
}
|
||||
return [[{ ref_count: 0 }]];
|
||||
}
|
||||
},
|
||||
common: {
|
||||
@@ -239,9 +249,7 @@ test('slide upload cleanup route removes unused uploads', async () => {
|
||||
collectUploadReferencesFromSlide: () => [],
|
||||
collectUploadReferencesFromTemplate: () => [],
|
||||
collectUploadReferencesFromPayload: () => [],
|
||||
removeUnusedUploadFiles: async (pool, uploadDir, uploadPaths) => {
|
||||
cleanupCall = { pool, uploadDir, uploadPaths };
|
||||
},
|
||||
removeUnusedUploadFiles: async () => {},
|
||||
syncPlaylistUploadsOnChange: async () => {},
|
||||
getAuditUserId: () => 1,
|
||||
redirectAfterSave: () => {},
|
||||
@@ -257,7 +265,7 @@ test('slide upload cleanup route removes unused uploads', async () => {
|
||||
};
|
||||
},
|
||||
hasAnyPermission: () => true,
|
||||
uploadDir: 'e:\\Projects Git\\pulse-signage\\media\\uploads'
|
||||
uploadDir
|
||||
};
|
||||
|
||||
registerContentRoutes(app, deps);
|
||||
@@ -286,11 +294,15 @@ test('slide upload cleanup route removes unused uploads', async () => {
|
||||
}
|
||||
};
|
||||
|
||||
await routeHandlers[1](req, res, () => {});
|
||||
try {
|
||||
await routeHandlers[1](req, res, () => {});
|
||||
|
||||
assert.equal(res.statusCode, 204);
|
||||
assert.equal(cleanupCall.uploadDir, 'e:\\Projects Git\\pulse-signage\\media\\uploads');
|
||||
assert.deepEqual(cleanupCall.uploadPaths, ['/media/uploads/test-file.png']);
|
||||
assert.equal(res.statusCode, 204);
|
||||
assert.equal(fs.existsSync(path.join(uploadDir, 'test-file.png')), false);
|
||||
assert.equal(res.statusCode, 204);
|
||||
} finally {
|
||||
await fs.promises.rm(uploadRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('wysiwyg image uploads are capped below the dedicated image region limit', async () => {
|
||||
|
||||
@@ -210,7 +210,7 @@ test('fonts page renders the upload card above the table card', () => {
|
||||
nextUrl: '?page=2',
|
||||
pages: [{ number: 1, active: true, url: '' }]
|
||||
},
|
||||
stylesheetHref: ''
|
||||
stylesheetHref: '/media/fonts/fonts.css?v=1'
|
||||
}, '', { id: 1 });
|
||||
|
||||
assert.ok(html.indexOf('Upload font') < html.indexOf('Managed fonts'));
|
||||
@@ -219,6 +219,9 @@ test('fonts page renders the upload card above the table card', () => {
|
||||
assert.match(html, /data-async-command/);
|
||||
assert.match(html, /data-font-toggle-row/);
|
||||
assert.match(html, /data-font-status-badge/);
|
||||
assert.match(html, /<link rel="stylesheet" href="\/media\/fonts\/fonts\.css\?v=1" \/>/);
|
||||
assert.match(html, /<th>Preview<\/th>/);
|
||||
assert.match(html, /font-family: 'Alpha Sans';/);
|
||||
assert.match(html, /data-table-sort-key="family"/);
|
||||
assert.match(html, /data-table-sort-key="file"/);
|
||||
assert.match(html, /data-table-sort-key="format"/);
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const {
|
||||
normalizeMediaPath,
|
||||
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('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 and template references', async () => {
|
||||
const queries = [];
|
||||
const pool = {
|
||||
async query(sql, params) {
|
||||
queries.push({ sql, params });
|
||||
if (sql.includes('FROM c_slides')) {
|
||||
return [[{ ref_count: 2 }]];
|
||||
}
|
||||
return [[{ ref_count: 1 }]];
|
||||
}
|
||||
};
|
||||
|
||||
assert.equal(await countMediaAssetReferences(pool, '/media/uploads/shared.png'), 3);
|
||||
assert.equal(queries.length, 2);
|
||||
assert.equal(queries[0].params[0], '/media/uploads/shared.png');
|
||||
assert.equal(queries[1].params[0], '/media/uploads/shared.png');
|
||||
assert.match(queries[0].sql, /LOCATE/);
|
||||
});
|
||||
@@ -28,7 +28,7 @@ test('pending migrations are empty when the schema already matches the app versi
|
||||
match(sql) {
|
||||
return sql.includes('FROM information_schema.COLUMNS') && sql.includes('TABLE_NAME = ?') && sql.includes('COLUMN_NAME = ?');
|
||||
},
|
||||
result: [[{ column_count: 0 }]]
|
||||
result: [[{ column_count: 1 }]]
|
||||
},
|
||||
{
|
||||
match(_sql, params) {
|
||||
@@ -44,7 +44,7 @@ test('pending migrations are empty when the schema already matches the app versi
|
||||
}
|
||||
]);
|
||||
|
||||
const pendingMigrations = await getPendingMigrations(pool, { currentVersion: '2.11.1' });
|
||||
const pendingMigrations = await getPendingMigrations(pool, { currentVersion: '2.13.1' });
|
||||
|
||||
assert.equal(pendingMigrations.length, 0);
|
||||
});
|
||||
|
||||
@@ -9,6 +9,29 @@ require('../src/common');
|
||||
|
||||
const { createUploadSyncService } = require('../src/web/lib/media');
|
||||
|
||||
test('upload reference collection includes WYSIWYG images across slide regions', () => {
|
||||
const uploadSyncService = createUploadSyncService({
|
||||
common: { parseJsonSafe: value => value ? JSON.parse(value) : null },
|
||||
playerSnapshotCache: new Map(),
|
||||
notifyPlayerScreens: async () => {}
|
||||
});
|
||||
|
||||
assert.deepEqual(
|
||||
Array.from(uploadSyncService.collectUploadReferencesFromPayload({
|
||||
contentJson: JSON.stringify({
|
||||
clock: { value: '<p><img src="http://localhost:8080/media/uploads/clock-image.png"></p>' },
|
||||
html: { value: '<p><img src="/media/uploads/html-image.png"></p>' },
|
||||
rss: { value: '<p><img src="/media/uploads/rss-image.png"></p>' }
|
||||
})
|
||||
})).sort(),
|
||||
[
|
||||
'/media/uploads/clock-image.png',
|
||||
'/media/uploads/html-image.png',
|
||||
'/media/uploads/rss-image.png'
|
||||
].sort()
|
||||
);
|
||||
});
|
||||
|
||||
test('multipart uploads accept large text fields used by slide and template forms', async () => {
|
||||
const uploadDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pulse-signage-upload-'));
|
||||
const uploadSyncService = createUploadSyncService({
|
||||
@@ -277,7 +300,7 @@ test('stale player registrations stop media sync retries and warnings', async ()
|
||||
}
|
||||
});
|
||||
|
||||
test('slide update sync removes uploads that were removed from the slide on the player', async () => {
|
||||
test('slide update sync removes uploads only after the web-managed file is gone', async () => {
|
||||
const uploadDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pulse-signage-upload-sync-slide-remove-'));
|
||||
fs.mkdirSync(path.join(uploadDir, 'uploads'), { recursive: true });
|
||||
fs.writeFileSync(path.join(uploadDir, 'uploads', 'keep.bin'), Buffer.from('keep'));
|
||||
@@ -332,7 +355,7 @@ test('slide update sync removes uploads that were removed from the slide on the
|
||||
return [[
|
||||
{
|
||||
identifier: 'player-one',
|
||||
internal_base_url: 'http://player-one:8081',
|
||||
internal_base_url: 'http://player:8081',
|
||||
last_seen_at: liveLastSeenAt
|
||||
}
|
||||
]];
|
||||
@@ -358,10 +381,23 @@ test('slide update sync removes uploads that were removed from the slide on the
|
||||
});
|
||||
|
||||
assert.equal(fs.existsSync(path.join(uploadDir, 'uploads', 'keep.bin')), true);
|
||||
assert.equal(fs.existsSync(path.join(uploadDir, 'uploads', 'remove.bin')), false);
|
||||
assert.equal(fs.existsSync(path.join(uploadDir, 'uploads', 'remove.bin')), true);
|
||||
assert.equal(fetchCalls.filter(function (call) {
|
||||
return call.method === 'PUT';
|
||||
}).length, 1);
|
||||
assert.equal(fetchCalls.filter(function (call) {
|
||||
return call.method === 'DELETE';
|
||||
}).length, 0);
|
||||
|
||||
fs.unlinkSync(path.join(uploadDir, 'uploads', 'remove.bin'));
|
||||
await uploadSyncService.syncPlaylistUploadsOnChange({
|
||||
key: 'slide:update:124',
|
||||
pool: {},
|
||||
localUploadDir: uploadDir,
|
||||
previousUploadRefs: ['/media/uploads/remove.bin'],
|
||||
nextUploadRefs: []
|
||||
});
|
||||
|
||||
assert.equal(fetchCalls.filter(function (call) {
|
||||
return call.method === 'DELETE';
|
||||
}).length, 1);
|
||||
@@ -498,4 +534,37 @@ test('remote media sync uses the bridge device route', async () => {
|
||||
global.fetch = originalFetch;
|
||||
fs.rmSync(uploadDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('remote media deletion uses the bridge device route', async () => {
|
||||
const uploadDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pulse-signage-upload-delete-bridge-'));
|
||||
const activeLastSeenAt = new Date(Date.now() - 10_000).toISOString();
|
||||
const fetchCalls = [];
|
||||
const originalFetch = global.fetch;
|
||||
global.fetch = async function (url, init) {
|
||||
fetchCalls.push({ url, init });
|
||||
return { ok: true, status: 200, statusText: 'OK', headers: { get() { return null; } }, async text() { return ''; } };
|
||||
};
|
||||
|
||||
const uploadSyncService = createUploadSyncService({
|
||||
common: {},
|
||||
bridgeInternalBaseUrl: 'http://player-bridge:8090',
|
||||
pool: {
|
||||
async query() {
|
||||
return [[{ identifier: 'player-remote', internal_base_url: 'https://remote-player.example', last_seen_at: activeLastSeenAt }]];
|
||||
}
|
||||
},
|
||||
playerSnapshotCache: new Map(),
|
||||
notifyPlayerScreens: async () => {}
|
||||
});
|
||||
|
||||
try {
|
||||
assert.equal(await uploadSyncService.removeUploadFileFromPlayer('/media/uploads/sample.bin', uploadDir), true);
|
||||
assert.equal(fetchCalls[0].url, 'http://player-bridge:8090/api/media/uploads%2Fsample.bin');
|
||||
assert.equal(fetchCalls[0].init.method, 'DELETE');
|
||||
assert.equal(fetchCalls[0].init.headers['x-pulse-player-device-id'], 'player-remote');
|
||||
} finally {
|
||||
global.fetch = originalFetch;
|
||||
fs.rmSync(uploadDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user