const { describe, it, mock, beforeEach } = require('node:test'); const assert = require('node:assert/strict'); const path = require('path'); const { EventEmitter } = require('events'); // We need to mock fs.statSync and the hoster upload functions before requiring upload-manager // Use node:test mock.module (available in Node 22+) describe('UploadManager', () => { let UploadManager; let mockUploadFile; let fakeFileSize; beforeEach(() => { fakeFileSize = 1024 * 1024; // 1 MB default // Clear module cache for fresh mocks each test delete require.cache[require.resolve('../lib/upload-manager')]; // Mock the hosters module mockUploadFile = mock.fn(async (hoster, filePath, apiKey, onProgress, signal, throttle) => { // Simulate upload progress if (onProgress) { onProgress(fakeFileSize / 2, fakeFileSize); onProgress(fakeFileSize, fakeFileSize); } return { download_url: `https://${hoster}/test123`, embed_url: null, file_code: 'test123' }; }); // Override require for hosters const origRequire = module.constructor.prototype.require; const hosters = require('../lib/hosters'); hosters.uploadFile = mockUploadFile; // Mock fs.statSync for test file paths const fs = require('fs'); const origStatSync = fs.statSync; fs.statSync = function(p) { if (typeof p === 'string' && p.startsWith('/test/')) { return { size: fakeFileSize }; } return origStatSync.call(this, p); }; UploadManager = require('../lib/upload-manager'); }); it('emits progress events for each task', async () => { const mgr = new UploadManager({}); const events = []; mgr.on('progress', (data) => events.push(data)); await mgr.startBatch([ { file: '/test/video1.mp4', hoster: 'doodstream.com', apiKey: 'key1' } ]); const statuses = events.map(e => e.status); assert.ok(statuses.includes('queued'), 'should have queued status'); assert.ok(statuses.includes('done'), 'should have done status'); }); it('emits batch-done with correct summary', async () => { const mgr = new UploadManager({}); let summary = null; mgr.on('batch-done', (s) => { summary = s; }); await mgr.startBatch([ { file: '/test/video1.mp4', hoster: 'doodstream.com', apiKey: 'key1' }, { file: '/test/video2.mp4', hoster: 'doodstream.com', apiKey: 'key1' } ]); assert.ok(summary); assert.equal(summary.total, 2); assert.equal(summary.succeeded, 2); assert.equal(summary.failed, 0); assert.equal(summary.files.length, 2); }); it('retries on failure then succeeds', async () => { let callCount = 0; mockUploadFile.mock.mockImplementation(async (hoster, filePath, apiKey, onProgress) => { callCount++; if (callCount <= 2) throw new Error('network error'); if (onProgress) onProgress(fakeFileSize, fakeFileSize); return { download_url: 'https://test/ok', embed_url: null, file_code: 'ok' }; }); const mgr = new UploadManager({ 'doodstream.com': { retries: 3, parallelCount: 1, maxSpeedKbs: 0, restartBelowKbs: 0, timeIntervalSec: 0, maxSizeMb: 0 } }); const statuses = []; mgr.on('progress', (d) => statuses.push(d.status)); await mgr.startBatch([ { file: '/test/video.mp4', hoster: 'doodstream.com', apiKey: 'key1' } ]); assert.ok(statuses.includes('retrying'), 'should show retrying status'); assert.ok(statuses.includes('done'), 'should eventually succeed'); assert.equal(callCount, 3); }); it('exhausted retries result in error', async () => { mockUploadFile.mock.mockImplementation(async () => { throw new Error('permanent failure'); }); const mgr = new UploadManager({ 'doodstream.com': { retries: 1, parallelCount: 1, maxSpeedKbs: 0, restartBelowKbs: 0, timeIntervalSec: 0, maxSizeMb: 0 } }); let summary = null; mgr.on('batch-done', (s) => { summary = s; }); await mgr.startBatch([ { file: '/test/video.mp4', hoster: 'doodstream.com', apiKey: 'key1' } ]); assert.ok(summary); assert.equal(summary.failed, 1); assert.equal(summary.succeeded, 0); }); it('cancel aborts running uploads', async () => { mockUploadFile.mock.mockImplementation(async (hoster, filePath, apiKey, onProgress, signal) => { // Simulate a slow upload await new Promise((resolve, reject) => { const timer = setTimeout(() => resolve({ download_url: 'x', embed_url: null, file_code: 'x' }), 10000); if (signal) signal.addEventListener('abort', () => { clearTimeout(timer); reject(new Error('Aborted')); }); }); }); const mgr = new UploadManager({}); let batchDone = false; mgr.on('batch-done', () => { batchDone = true; }); const batchPromise = mgr.startBatch([ { file: '/test/video.mp4', hoster: 'doodstream.com', apiKey: 'key1' } ]); // Wait a bit then cancel await new Promise(r => setTimeout(r, 100)); mgr.cancel(); await batchPromise; assert.equal(mgr.running, false); assert.ok(batchDone, 'batch-done should be emitted even after cancel'); }); it('maxSizeMb filter skips oversized files', async () => { fakeFileSize = 5 * 1024 * 1024; // 5 MB const mgr = new UploadManager({ 'doodstream.com': { retries: 0, parallelCount: 1, maxSpeedKbs: 0, restartBelowKbs: 0, timeIntervalSec: 0, maxSizeMb: 1 } }); const statuses = []; mgr.on('progress', (d) => statuses.push(d.status)); await mgr.startBatch([ { file: '/test/big.mp4', hoster: 'doodstream.com', apiKey: 'key1' } ]); assert.ok(statuses.includes('skipped'), 'oversized file should be skipped'); assert.ok(!statuses.includes('uploading'), 'should not attempt upload'); }); it('per-hoster semaphore limits concurrency', async () => { let concurrent = 0; let maxConcurrent = 0; mockUploadFile.mock.mockImplementation(async (hoster, filePath, apiKey, onProgress, signal) => { concurrent++; maxConcurrent = Math.max(maxConcurrent, concurrent); await new Promise(r => setTimeout(r, 50)); concurrent--; if (onProgress) onProgress(fakeFileSize, fakeFileSize); return { download_url: 'https://test/ok', embed_url: null, file_code: 'ok' }; }); const mgr = new UploadManager({ 'doodstream.com': { retries: 0, parallelCount: 1, maxSpeedKbs: 0, restartBelowKbs: 0, timeIntervalSec: 0, maxSizeMb: 0 } }); await mgr.startBatch([ { file: '/test/a.mp4', hoster: 'doodstream.com', apiKey: 'k' }, { file: '/test/b.mp4', hoster: 'doodstream.com', apiKey: 'k' }, { file: '/test/c.mp4', hoster: 'doodstream.com', apiKey: 'k' } ]); assert.equal(maxConcurrent, 1, 'should only run 1 upload at a time'); }); it('_combineSignals propagates abort from either source', () => { const mgr = new UploadManager({}); const ac1 = new AbortController(); const ac2 = new AbortController(); const { signal, cleanup } = mgr._combineSignals(ac1.signal, ac2.signal); assert.equal(signal.aborted, false); ac2.abort(); assert.equal(signal.aborted, true); cleanup(); }); it('_combineSignals returns aborted signal if input already aborted', () => { const mgr = new UploadManager({}); const ac1 = new AbortController(); ac1.abort(); const ac2 = new AbortController(); const { signal, cleanup } = mgr._combineSignals(ac1.signal, ac2.signal); assert.equal(signal.aborted, true); cleanup(); }); it('_sleep resolves after delay', async () => { const mgr = new UploadManager({}); const start = Date.now(); await mgr._sleep(100); assert.ok(Date.now() - start >= 90); }); it('_sleep rejects on abort', async () => { const mgr = new UploadManager({}); const ac = new AbortController(); setTimeout(() => ac.abort(), 10); await assert.rejects(mgr._sleep(5000, ac.signal), /Aborted/); }); it('stats event contains expected fields', async () => { // Make upload take long enough for stats interval to fire mockUploadFile.mock.mockImplementation(async (hoster, filePath, apiKey, onProgress, signal) => { await new Promise(r => setTimeout(r, 1500)); if (onProgress) onProgress(fakeFileSize, fakeFileSize); return { download_url: 'https://test/ok', embed_url: null, file_code: 'ok' }; }); const mgr = new UploadManager({}); const statsEvents = []; mgr.on('stats', (d) => statsEvents.push(d)); await mgr.startBatch([ { file: '/test/video.mp4', hoster: 'doodstream.com', apiKey: 'key1' } ]); assert.ok(statsEvents.length > 0, 'should have received stats events'); const stat = statsEvents[0]; assert.ok('globalSpeedKbs' in stat); assert.ok('totalBytes' in stat); assert.ok('elapsed' in stat); assert.ok('activeJobs' in stat); }); });