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('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?deviceId=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 that were removed from the slide on the player', 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-one: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')), false); assert.equal(fetchCalls.filter(function (call) { return call.method === 'PUT'; }).length, 1); 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?deviceId=player-one', 'http://player-two:8081/api/media/uploads%2Fsample.bin?deviceId=player-two' ].sort()); } finally { global.fetch = originalFetch; fs.rmSync(uploadDir, { recursive: true, force: true }); } });