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'); const { buildPlayerAssetPlan } = require('../src/web/lib/media/player-assets'); const { getMediaStorageId } = require('../src/media-storage'); test('media storage IDs distinguish separate player volumes from the shared web volume', async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'pulse-storage-identity-')); try { const web = path.join(root, 'web'); const local = path.join(root, 'player-local'); const remote = path.join(root, 'player-remote'); const webId = await getMediaStorageId(web); assert.equal(await getMediaStorageId(web), webId); assert.notEqual(await getMediaStorageId(local), webId); assert.notEqual(await getMediaStorageId(remote), webId); const moved = path.join(root, 'web-moved'); fs.renameSync(web, moved); assert.equal(await getMediaStorageId(moved), webId); const copied = path.join(root, 'web-independent-copy'); fs.cpSync(moved, copied, { recursive: true }); assert.equal(await getMediaStorageId(copied), webId); fs.unlinkSync(path.join(copied, '.storage-id')); assert.notEqual(await getMediaStorageId(copied), webId); } finally { fs.rmSync(root, { recursive: true, force: true }); } }); test('unused generated copies are removed before media sync even with no connected players', async () => { const uploadDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pulse-no-player-copy-cleanup-')); const filename = 'image-crop-' + 'c'.repeat(64) + '.png'; fs.writeFileSync(path.join(uploadDir, filename), Buffer.from('copy')); fs.writeFileSync(path.join(uploadDir, 'original.png'), Buffer.from('original')); const pool = { async query(sql) { if (sql.startsWith('SELECT id, original_name')) return [[{ id: 1, original_name: filename, is_published: 1 }]]; if (sql.includes('COUNT(DISTINCT owner_type, owner_id)')) return [[{ owner_count: 0 }]]; return [[{ ref_count: 0 }]]; } }; const service = createUploadSyncService({ pool, common: {}, playerSnapshotCache: new Map(), notifyPlayerScreens: async () => {}, fetchPlayerRegistrations: async () => [], backgroundTaskQueue: { async enqueueTaskAndWait() {} } }); try { await service.syncPlaylistUploadsOnChange({ key: 'slide:update:1', pool, localUploadDir: uploadDir, previousUploadRefs: ['/media/uploads/' + filename, '/media/uploads/original.png'], nextUploadRefs: [] }); assert.equal(fs.existsSync(path.join(uploadDir, filename)), false); assert.equal(fs.existsSync(path.join(uploadDir, 'original.png')), true); } finally { fs.rmSync(uploadDir, { recursive: true, force: true }); } }); 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: '

' }, html: { value: '

' }, rss: { value: '

' } }), regions: [{ default_content: { value: '

' } }] })).sort(), [ '/media/uploads/clock-image.png', '/media/uploads/html-image.png', '/media/uploads/rss-image.png', '/media/uploads/template-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 manifestDir = path.join(uploadDir, 'player-cache', 'media-sync'); fs.mkdirSync(manifestDir, { recursive: true }); fs.writeFileSync(path.join(manifestDir, require('node:crypto').createHash('sha256').update('player-one').digest('hex') + '.json'), JSON.stringify({ playerIdentifier: 'player-one', files: ['/media/uploads/remove.bin'], clients: [{ id: 'client-one', deviceId: 'client-one', playerIdentifier: 'player-one', screenSlug: 'screen-one', seenAt: Date.now() }] })); 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 { screens: [{ id: 1, slug: 'screen-one', playlist_id: 1 }], playlistSlides: [{ playlist_id: 1, slide_id: 1 }], slides: [ { id: 1, 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: { async fetchAdminData() { return { screens: [{ id: 1, slug: 'screen-one', playlist_id: 1 }, { id: 2, slug: 'screen-two', playlist_id: 1 }], playlistSlides: [{ playlist_id: 1, slide_id: 1 }], slides: [{ id: 1, content_json: { photo: { value: '/media/uploads/sample.bin' } } }], templates: [] }; } }, 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([ ['screen-one', { connections: [{ deviceId: 'client-one', playerDeviceId: 'player-one' }] }], ['screen-two', { connections: [{ deviceId: 'client-two', playerDeviceId: 'player-two' }] }] ]), 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 }); } }); test('playback reference collection excludes editing originals without weakening retention references', () => { const service = createUploadSyncService({ common: {}, playerSnapshotCache: new Map(), notifyPlayerScreens: async () => {} }); const content = { photo: { type: 'image', value: '/media/uploads/playback.png', original_value: '/media/uploads/original.png' }, text: { value: '' }, small: { type: 'image', value: '/media/uploads/small.png', original_value: '/media/uploads/small.png' } }; assert.deepEqual([...service.collectPlaybackUploadReferences(content)].sort(), ['/media/uploads/inline.png', '/media/uploads/playback.png', '/media/uploads/small.png']); const retained = new Set(); service.collectUploadReferencesFromValue(content, retained); assert.ok(retained.has('/media/uploads/original.png')); }); test('player asset plans separate direct and bridged players and retain offline client assignments', () => { const service = createUploadSyncService({ common: {}, playerSnapshotCache: new Map(), notifyPlayerScreens: async () => {} }); const players = [{ identifier: 'local', public_base_url: 'http://player:8081' }, { identifier: 'remote', public_base_url: '' }]; const data = { screens: [{ id: 1, slug: 'one', playlist_id: 10 }, { id: 2, slug: 'two', playlist_id: 20 }], playlistSlides: [{ playlist_id: 10, slide_id: 1 }, { playlist_id: 20, slide_id: 2 }], slides: [{ id: 1, template_id: 1, content_json: { photo: { value: '/media/uploads/local-copy.png', original_value: '/media/uploads/local-original.png' }, brand: { value: '/media/uploads/ignored.png' } } }, { id: 2, template_id: 2, content_json: { photo: { value: '/media/uploads/remote-copy.png', original_value: '/media/uploads/remote-original.png' } } }], templates: [{ id: 1, background_image_path: '/media/uploads/background.png' }, { id: 2 }], templateRegions: [{ template_id: 1, region_key: 'photo', region_type: 'image' }, { template_id: 1, region_key: 'brand', region_type: 'image', read_only: 1, default_content: { value: '/media/uploads/brand.png' } }, { template_id: 2, region_key: 'photo', region_type: 'image' }] }; const snapshots = new Map([['one', { connections: [{ deviceId: 'client-one', playerPublicBaseUrl: 'http://player:8081' }] }], ['two', { connections: [{ deviceId: 'client-two', playerDeviceId: 'remote' }] }]]); const plan = buildPlayerAssetPlan(data, snapshots, players, [], service.collectPlaybackUploadReferences); assert.deepEqual([...plan.assets.get('local')].sort(), ['/media/uploads/background.png', '/media/uploads/brand.png', '/media/uploads/local-copy.png']); assert.deepEqual([...plan.assets.get('remote')], ['/media/uploads/remote-copy.png']); const offline = buildPlayerAssetPlan(data, new Map(), players, plan.clients, service.collectPlaybackUploadReferences); assert.deepEqual([...offline.assets.get('remote')], ['/media/uploads/remote-copy.png']); const moved = buildPlayerAssetPlan({ ...data, onboardingDevices: [{ device_id: 'client-two', screen_id: 1 }] }, new Map(), players, plan.clients, service.collectPlaybackUploadReferences); assert.ok(moved.assets.get('remote').has('/media/uploads/local-copy.png')); assert.ok(!moved.assets.get('remote').has('/media/uploads/remote-copy.png')); }); test('scoped sync sends only assigned playback files through direct and bridge transports and persists offline assignments', async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'pulse-scoped-players-')); const uploads = path.join(root, 'uploads'); fs.mkdirSync(uploads); for (const name of ['local.png', 'remote.png', 'original.png', 'unassigned.png', 'remote.variant-2048.png']) fs.writeFileSync(path.join(uploads, name), name); const players = [ { identifier: 'local', internal_base_url: 'http://player:8081', public_base_url: 'http://local.test', last_seen_at: new Date() }, { identifier: 'remote', internal_base_url: '', public_base_url: '', last_seen_at: new Date() } ]; const data = { screens: [{ id: 1, slug: 'one', playlist_id: 1 }, { id: 2, slug: 'two', playlist_id: 2 }], playlistSlides: [{ playlist_id: 1, slide_id: 1 }, { playlist_id: 2, slide_id: 2 }], slides: [{ id: 1, content_json: { photo: { value: '/media/uploads/local.png', original_value: '/media/uploads/original.png' } } }, { id: 2, content_json: { photo: { value: '/media/uploads/remote.png', original_value: '/media/uploads/original.png' } } }], templates: [] }; const pool = { async query(sql) { if (sql.includes('FROM d_players')) return [players]; if (sql.includes('FROM d_onboarding_devices')) return [[{ device_id: 'a', screen_id: 1 }, { device_id: 'b', screen_id: 2 }]]; return [[]]; } }; const calls = []; const originalFetch = global.fetch; let failTransfer = false; global.fetch = async function (url, init) { calls.push({ url, method: init.method, player: init.headers['x-pulse-player-device-id'] }); return { ok: !(failTransfer && init.method === 'PUT'), status: failTransfer ? 503 : 200 }; }; const snapshots = new Map([['one', { connections: [{ deviceId: 'a', playerDeviceId: 'local' }] }], ['two', { connections: [{ deviceId: 'b', playerDeviceId: 'remote' }] }]]); let service; const options = { pool, common: { async fetchAdminData() { return data; } }, bridgeInternalBaseUrl: 'http://player-bridge:8090', playerSnapshotCache: snapshots, notifyPlayerScreens: async () => {}, backgroundTaskQueue: { async enqueueTaskAndWait(task) { await service.runMediaSyncTask(task.payload); } } }; try { service = createUploadSyncService(options); const savedSnapshots = [...snapshots]; snapshots.clear(); await service.runMediaSyncTask({ mode: 'playlist', playerIdentifier: 'remote', operation: { localUploadDir: uploads } }); assert.equal(calls.length, 0); for (const [slug, snapshot] of savedSnapshots) snapshots.set(slug, snapshot); failTransfer = true; await assert.rejects(service.syncPlaylistUploadsOnChange({ key: 'slide:update:2', localUploadDir: uploads, nextUploadRefs: ['/media/uploads/remote.png'] }), /required playback asset/); snapshots.delete('two'); failTransfer = false; calls.length = 0; service = createUploadSyncService(options); await service.syncPlaylistUploadsOnChange({ key: 'slide:update:2', localUploadDir: uploads, nextUploadRefs: ['/media/uploads/remote.png'] }); assert.deepEqual(calls.filter(call => call.method === 'PUT'), [{ url: 'http://player-bridge:8090/api/media/uploads%2Fremote.png', method: 'PUT', player: 'remote' }]); assert.ok(calls.some(call => call.method === 'DELETE' && call.url.endsWith('original.png'))); calls.length = 0; await service.runMediaSyncTask({ mode: 'initial', uploadDir: root, playerIdentifier: 'local' }); assert.deepEqual(calls.filter(call => call.method === 'PUT'), [{ url: 'http://player:8081/api/media/uploads%2Flocal.png', method: 'PUT', player: 'local' }]); snapshots.clear(); service = createUploadSyncService(options); calls.length = 0; await service.runMediaSyncTask({ mode: 'playlist', playerIdentifier: 'remote', operation: { localUploadDir: uploads } }); assert.deepEqual(calls.filter(call => call.method === 'PUT').map(call => call.url), ['http://player-bridge:8090/api/media/uploads%2Fremote.png']); assert.equal(fs.existsSync(path.join(uploads, 'original.png')), true); assert.equal(fs.existsSync(path.join(uploads, 'local.png')), true); } finally { global.fetch = originalFetch; fs.rmSync(root, { recursive: true, force: true }); } }); test('client-assignment sync refreshes its display after required media transfer', async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'pulse-assignment-refresh-')); const originalTimer = global.setTimeout; let scheduled; let queuedResolve; const queued = new Promise(resolve => { queuedResolve = resolve; }); const service = createUploadSyncService({ pool: { async query(sql) { return sql.includes('FROM d_players') ? [[{ identifier: 'one', internal_base_url: 'http://player:8081', last_seen_at: new Date() }]] : [[]]; } }, common: { async fetchAdminData() { return { screens: [{ id: 1, slug: 'screen-one', playlist_id: 1 }], playlistSlides: [], slides: [], templates: [] }; } }, playerSnapshotCache: new Map([['screen-one', { connections: [{ deviceId: 'client-one', playerDeviceId: 'one' }] }]]), notifyPlayerScreens: async () => {}, backgroundTaskQueue: { async enqueueTaskAndWait(task) { queuedResolve(task); } } }); try { global.setTimeout = callback => { scheduled = callback; return { unref() {} }; }; service.scheduleAssignmentSync(root); global.setTimeout = originalTimer; scheduled(); const task = await queued; assert.deepEqual(task.payload.operation.refreshScreenSlugs, ['screen-one']); } finally { global.setTimeout = originalTimer; fs.rmSync(root, { recursive: true, force: true }); } }); test('explicit player refresh retries failed local media even when playlist refresh is already in flight', () => { const vm = require('node:vm'); const source = fs.readFileSync(path.join(__dirname, '../src/player/public/js/player-page-playback.js'), 'utf8'); const start = source.indexOf('function refresh(applyImmediately, bypassDebounce)'); const end = source.indexOf(' playlistRefreshInFlight = true;', start); const failed = { complete: true, naturalWidth: 0, src: 'http://player.test/media/uploads/crop.png' }; const loaded = { complete: true, naturalWidth: 1280, src: 'http://player.test/media/uploads/loaded.png' }; const remote = { complete: true, naturalWidth: 0, src: 'https://other.test/image.png' }; const pending = { complete: false, naturalWidth: 0, src: 'http://player.test/media/uploads/pending.png' }; const refresh = vm.runInNewContext('(' + source.slice(start, end) + '\n})', { document: { querySelectorAll() { return [failed, loaded, remote, pending]; } }, window: { location: { href: 'http://player.test/screen/test', origin: 'http://player.test' } }, URL, Date, playlistRefreshInFlight: true }); refresh(true); assert.match(failed.src, /crop\.png\?_mediaRefresh=\d+/); assert.equal(loaded.src, 'http://player.test/media/uploads/loaded.png'); assert.equal(remote.src, 'https://other.test/image.png'); assert.equal(pending.src, 'http://player.test/media/uploads/pending.png'); }); test('player keeps an initial loading screen until slide images decode and cancels stale readiness waits', async () => { const vm = require('node:vm'); const source = fs.readFileSync(path.join(__dirname, '../src/player/public/js/player-page-media.js'), 'utf8'); const start = source.indexOf('async function waitForSlideImages('); const images = []; const loaders = []; const context = vm.createContext({ Image: class { constructor() { images.push(this); } decode() { return Promise.resolve(); } }, document: { createElement(type) { return type === 'template' ? { content: { querySelectorAll() { return [{ getAttribute() { return '/media/uploads/crop.png'; } }]; } } } : { setAttribute() {} }; } }, app: { querySelector() { return loaders[0] || null; }, appendChild(element) { loaders.push(element); } }, window: { setTimeout() { return 1; }, clearTimeout() {}, location: { href: 'http://player.test/screen/test', origin: 'http://player.test' } }, lastRenderedViewKey: '', slideRenderRequestId: 1, URL, Date }); vm.runInContext(source.slice(start), context); const waiting = context.waitForSlideImages('', 1); assert.equal(loaders.length, 1); assert.match(loaders[0].innerHTML, /Loading media/); assert.equal(images.length, 1); images[0].naturalWidth = 1280; await images[0].onload(); assert.equal(await waiting, ''); context.slideRenderRequestId = 2; const stale = context.waitForSlideImages('', 2); context.slideRenderRequestId = 3; images[1].naturalWidth = 1280; await images[1].onload(); assert.equal(await stale, false); }); test('player watchdog does not reload while initial media is still loading', () => { const vm = require('node:vm'); const source = fs.readFileSync(path.join(__dirname, '../src/player/public/js/player-page-transition.js'), 'utf8'); const start = source.indexOf('function startPlayerWatchdog()'); const end = source.indexOf('// Cancel the current slide-advance timer.', start); let tick; let reloaded = false; const context = vm.createContext({ playerWatchdogTimer: null, playerLastProgressAt: 0, playerWatchdogRecoveryKey: 'check', isPaused: false, slides: [{}], lastRenderedSlide: {}, document: { visibilityState: 'visible', querySelector() { return {}; } }, window: { setInterval(callback) { tick = callback; return 1; }, sessionStorage: { getItem() { return '0'; }, setItem() {} }, location: { reload() { reloaded = true; } } }, Date, getSlideAdvanceDelay() { return 1000; } }); vm.runInContext(source.slice(start, end) + '\nstartPlayerWatchdog();', context); tick(); assert.equal(reloaded, false); context.document.querySelector = () => null; tick(); assert.equal(reloaded, true); });