Multi-Hoster-Upload/tests/upload-manager.test.js
Administrator 9c56fabce1 fix: critical updater and retry bugs, cleanup listener leaks
- updater: replace undici.request() with fetch() (fixes maxRedirections
  error that blocked auto-update from v1.0.0 to v1.1.0)
- upload-manager: move signalCleanup declaration outside try block
  (was causing ReferenceError in catch, silently breaking ALL retries)
- upload-manager: _combineSignals now returns cleanup fn to prevent
  abort listener accumulation over batch lifetime
- upload-manager: _sleep removes abort listener on normal timer fire
- hosters: apiGet removes abort listener in finally block

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 12:30:06 +01:00

246 lines
8.7 KiB
JavaScript

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);
});
});