Files
pulse-signage/test/upload-sync.test.js
lzstealth 394d23bb4d
Publish Docker Image / build-and-push-existing-registry (./build/Dockerfile, web, pulse-signage-web) (push) Successful in 1m15s
Publish Docker Image / build-and-push-existing-registry (./build/Dockerfile.player, player, pulse-signage-player) (push) Successful in 32s
Release v2.13.1
2026-09-11 20:46:37 +01:00

570 lines
18 KiB
JavaScript

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');
const express = require('express');
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({
common: {},
playerSnapshotCache: new Map(),
notifyPlayerScreens: async () => {}
});
const upload = uploadSyncService.createUploadMiddleware(uploadDir);
const app = express();
app.post('/upload', upload.any(), function (_req, res) {
res.sendStatus(204);
});
app.use(function (error, _req, res, _next) {
res.status(error.statusCode || 500).send(String(error && error.message ? error.message : error));
});
const server = app.listen(0);
try {
const address = server.address();
const largeValue = 'x'.repeat(1_100_000);
const formData = new FormData();
formData.set('region_text_6', largeValue);
const response = await fetch(`http://127.0.0.1:${address.port}/upload`, {
method: 'POST',
body: formData
});
assert.equal(response.status, 204);
} finally {
await new Promise((resolve) => server.close(resolve));
fs.rmSync(uploadDir, { recursive: true, force: true });
}
});
test('fqdn player registration wins over a local configured player target for media sync', async () => {
const uploadDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pulse-signage-upload-sync-'));
fs.mkdirSync(path.join(uploadDir, 'uploads'), { recursive: true });
fs.writeFileSync(path.join(uploadDir, 'uploads', 'sample.bin'), Buffer.from('hello world'));
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 JSON.stringify({ ok: true });
}
};
};
const uploadSyncService = createUploadSyncService({
common: {},
pool: {
async query() {
return [[
{
identifier: 'player-local',
internal_base_url: 'http://player:8081',
last_seen_at: activeLastSeenAt
},
{
identifier: 'player-remote',
internal_base_url: 'https://pulse-dev-bridge.lzstealth.com',
last_seen_at: activeLastSeenAt
}
]];
}
},
playerSnapshotCache: new Map(),
notifyPlayerScreens: async () => {}
});
try {
const success = await uploadSyncService.pushUploadFileToPlayer('/media/uploads/sample.bin', uploadDir);
assert.equal(success, true);
assert.equal(fetchCalls.length, 1);
assert.equal(fetchCalls[0].url, 'https://pulse-dev-bridge.lzstealth.com/api/media/uploads%2Fsample.bin');
assert.equal(fetchCalls[0].init.headers['x-pulse-player-device-id'], 'player-remote');
} finally {
global.fetch = originalFetch;
fs.rmSync(uploadDir, { recursive: true, force: true });
}
});
test('media sync retry warnings include the resolved player label', async () => {
const uploadDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pulse-signage-upload-sync-log-'));
fs.mkdirSync(path.join(uploadDir, 'uploads'), { recursive: true });
fs.writeFileSync(path.join(uploadDir, 'uploads', 'sample.bin'), Buffer.from('hello world'));
const activeLastSeenAt = new Date(Date.now() - 10_000).toISOString();
const originalFetch = global.fetch;
const originalWarn = console.warn;
const warned = [];
console.warn = function () {
warned.push(Array.from(arguments).join(' '));
};
global.fetch = async function () {
const error = new Error('getaddrinfo ENOTFOUND player-remote');
error.code = 'ENOTFOUND';
throw error;
};
const uploadSyncService = createUploadSyncService({
common: {},
pool: {
async query() {
return [[
{
identifier: 'player-remote',
internal_base_url: 'https://pulse-dev-bridge.lzstealth.com',
last_seen_at: activeLastSeenAt
}
]];
}
},
playerSnapshotCache: new Map(),
notifyPlayerScreens: async () => {}
});
try {
await uploadSyncService.syncUploadRefsToPlayer(['/media/uploads/sample.bin'], uploadDir);
await uploadSyncService.flushPendingPlayerUploadSyncs();
assert.ok(warned.some(function (message) {
return message.includes('[media-sync] Player unavailable, retry queued for 1 upload for player-remote');
}));
} finally {
global.fetch = originalFetch;
console.warn = originalWarn;
fs.rmSync(uploadDir, { recursive: true, force: true });
}
});
test('media sync treats 503 responses as unavailable without logging a per-upload warning', async () => {
const uploadDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pulse-signage-upload-sync-503-'));
fs.mkdirSync(path.join(uploadDir, 'uploads'), { recursive: true });
fs.writeFileSync(path.join(uploadDir, 'uploads', 'sample.bin'), Buffer.from('hello world'));
const activeLastSeenAt = new Date(Date.now() - 10_000).toISOString();
const originalFetch = global.fetch;
const originalWarn = console.warn;
const warned = [];
console.warn = function () {
warned.push(Array.from(arguments).join(' '));
};
global.fetch = async function () {
return {
ok: false,
status: 503,
statusText: 'Service Unavailable',
headers: {
get() {
return null;
}
},
async text() {
return 'Service Unavailable';
}
};
};
const uploadSyncService = createUploadSyncService({
common: {},
pool: {
async query() {
return [[
{
identifier: 'player-remote',
internal_base_url: 'https://pulse-dev-bridge.lzstealth.com',
last_seen_at: activeLastSeenAt
}
]];
}
},
playerSnapshotCache: new Map(),
notifyPlayerScreens: async () => {}
});
try {
await uploadSyncService.syncUploadRefsToPlayer(['/media/uploads/sample.bin'], uploadDir);
await uploadSyncService.flushPendingPlayerUploadSyncs();
assert.ok(warned.some(function (message) {
return message.includes('[media-sync] Player unavailable, retry queued for 1 upload for player-remote');
}));
assert.ok(warned.every(function (message) {
return !message.includes('Unable to sync upload to player: sample.bin 503 Service Unavailable');
}));
} finally {
global.fetch = originalFetch;
console.warn = originalWarn;
fs.rmSync(uploadDir, { recursive: true, force: true });
}
});
test('stale player registrations stop media sync retries and warnings', async () => {
const uploadDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pulse-signage-upload-sync-stale-'));
fs.mkdirSync(path.join(uploadDir, 'uploads'), { recursive: true });
fs.writeFileSync(path.join(uploadDir, 'uploads', 'sample.bin'), Buffer.from('hello world'));
const originalFetch = global.fetch;
const originalWarn = console.warn;
const fetchCalls = [];
const warned = [];
console.warn = function () {
warned.push(Array.from(arguments).join(' '));
};
global.fetch = async function (url, init) {
fetchCalls.push({ url, init });
return {
ok: false,
status: 503,
statusText: 'Service Unavailable',
headers: {
get() {
return null;
}
},
async text() {
return 'Service Unavailable';
}
};
};
const uploadSyncService = createUploadSyncService({
common: {},
pool: {
async query() {
return [[
{
identifier: 'player-remote',
internal_base_url: 'https://pulse-dev-bridge.lzstealth.com',
last_seen_at: new Date(Date.now() - 5 * 60 * 1000).toISOString()
}
]];
}
},
playerSnapshotCache: new Map(),
notifyPlayerScreens: async () => {}
});
try {
await uploadSyncService.syncUploadRefsToPlayer(['/media/uploads/sample.bin'], uploadDir);
await uploadSyncService.flushPendingPlayerUploadSyncs();
assert.equal(fetchCalls.length, 0);
assert.equal(warned.length, 0);
} finally {
global.fetch = originalFetch;
console.warn = originalWarn;
fs.rmSync(uploadDir, { recursive: true, force: true });
}
});
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'));
fs.writeFileSync(path.join(uploadDir, 'uploads', 'remove.bin'), Buffer.from('remove'));
const liveLastSeenAt = new Date(Date.now() - 10_000).toISOString();
const fetchCalls = [];
const originalFetch = global.fetch;
global.fetch = async function (url, init) {
fetchCalls.push({ url, method: init && init.method ? init.method : 'GET' });
return {
ok: true,
status: 200,
statusText: 'OK',
headers: {
get() {
return null;
}
},
async text() {
return JSON.stringify({ ok: true });
}
};
};
const uploadSyncService = createUploadSyncService({
common: {
async fetchAdminData() {
return {
slides: [
{
content_json: JSON.stringify({
imageRegion: {
type: 'image',
value: '/media/uploads/keep.bin'
}
})
}
],
templates: []
};
}
},
pool: {
async query(sql, params) {
if (String(sql).includes('COUNT(*) AS ref_count')) {
return [[{
ref_count: params && params[0] === '/media/uploads/keep.bin' ? 1 : 0
}]];
}
return [[
{
identifier: 'player-one',
internal_base_url: 'http://player:8081',
last_seen_at: liveLastSeenAt
}
]];
}
},
playerSnapshotCache: new Map(),
notifyPlayerScreens: async () => {},
backgroundTaskQueue: {
async enqueueTaskAndWait(definition) {
await uploadSyncService.runMediaSyncTask(definition.payload);
return definition;
}
}
});
try {
await uploadSyncService.syncPlaylistUploadsOnChange({
key: 'slide:update:123',
pool: {},
localUploadDir: uploadDir,
previousUploadRefs: ['/media/uploads/keep.bin', '/media/uploads/remove.bin'],
nextUploadRefs: ['/media/uploads/keep.bin']
});
assert.equal(fs.existsSync(path.join(uploadDir, 'uploads', 'keep.bin')), true);
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);
assert.ok(fetchCalls.some(function (call) {
return call.method === 'DELETE' && call.url.includes('remove.bin');
}));
} finally {
global.fetch = originalFetch;
fs.rmSync(uploadDir, { recursive: true, force: true });
}
});
test('slide update sync queues one media task per live player and targets each player base url', async () => {
const uploadDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pulse-signage-upload-sync-multi-'));
fs.mkdirSync(path.join(uploadDir, 'uploads'), { recursive: true });
fs.writeFileSync(path.join(uploadDir, 'uploads', 'sample.bin'), Buffer.from('hello world'));
const liveLastSeenAt = new Date(Date.now() - 10_000).toISOString();
const staleLastSeenAt = new Date(Date.now() - 5 * 60 * 1000).toISOString();
const fetchCalls = [];
const queuedTasks = [];
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 JSON.stringify({ ok: true });
}
};
};
let uploadSyncService;
uploadSyncService = createUploadSyncService({
common: {},
pool: {
async query() {
return [[
{
identifier: 'player-one',
internal_base_url: 'http://player-one:8081',
last_seen_at: liveLastSeenAt
},
{
identifier: 'player-two',
internal_base_url: 'http://player-two:8081',
last_seen_at: liveLastSeenAt
},
{
identifier: 'player-stale',
internal_base_url: 'http://player-stale:8081',
last_seen_at: staleLastSeenAt
}
]];
}
},
playerSnapshotCache: new Map(),
notifyPlayerScreens: async () => {},
backgroundTaskQueue: {
async enqueueTaskAndWait(definition) {
queuedTasks.push(definition);
await uploadSyncService.runMediaSyncTask(definition.payload);
return definition;
}
}
});
try {
await uploadSyncService.syncPlaylistUploadsOnChange({
key: 'slide:update:123',
pool: {},
localUploadDir: uploadDir,
nextUploadRefs: ['/media/uploads/sample.bin']
});
assert.equal(queuedTasks.length, 2);
assert.deepEqual(queuedTasks.map(function (task) {
return task.metadata.playerIdentifier;
}).sort(), ['player-one', 'player-two']);
assert.ok(queuedTasks.every(function (task) {
return task.key.startsWith('media-sync:slide:update:123:');
}));
assert.deepEqual(fetchCalls.map(function (call) {
return call.url;
}).sort(), [
'http://player-one:8081/api/media/uploads%2Fsample.bin',
'http://player-two:8081/api/media/uploads%2Fsample.bin'
].sort());
assert.deepEqual(fetchCalls.map(function (call) {
return call.init.headers['x-pulse-player-device-id'];
}).sort(), ['player-one', 'player-two']);
} finally {
global.fetch = originalFetch;
fs.rmSync(uploadDir, { recursive: true, force: true });
}
});
test('remote media sync uses the bridge device route', async () => {
const uploadDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pulse-signage-upload-bridge-'));
fs.mkdirSync(path.join(uploadDir, 'uploads'), { recursive: true });
fs.writeFileSync(path.join(uploadDir, 'uploads', 'sample.bin'), Buffer.from('hello world'));
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.pushUploadFileToPlayer('/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.headers['x-pulse-player-device-id'], 'player-remote');
} finally {
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 });
}
});