Files
webshare-api/__tests__/qbt.test.js
Michal Pemcak b04e70e261 Raise Jest coverage above global thresholds.
Add real MCP HTTP/tool/entrypoint tests and extend torrent/qbt coverage
so statements, branches, functions, and lines all meet the configured floors.
2026-10-19 19:14:15 +02:00

882 lines
35 KiB
JavaScript

const express = require('express');
const request = require('supertest');
const fs = require('fs');
const path = require('path');
const os = require('os');
const { makeTorrent } = require('../src/torrent');
// ── shared temp dir ─────────────────────────────────────────────────────────
let tmpDir;
beforeAll(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'qbt-test-'));
process.env.DOWNLOAD_PATH = tmpDir;
process.env.MEDIA_ROOT = tmpDir;
process.env.MAX_CONCURRENT_DOWNLOADS = '0'; // no actual downloads
});
afterAll(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
// Fresh qbt module + express app for each test (module-level state isolation)
function makeApp() {
let qbtModule;
jest.isolateModules(() => {
qbtModule = require('../src/qbt');
});
const app = express();
app.use(express.urlencoded({ extended: false }));
app.use(express.json());
qbtModule.register(app);
return { app, qbt: qbtModule };
}
// Helper: clean state file between tests that check persistence
function cleanState() {
try { fs.unlinkSync(path.join(tmpDir, '.queue-state.json')); } catch {}
}
// Helper: build a real .torrent buffer
function makeTorrentBuf(webseedUrl = 'http://webshare-api:3001/stream/testident') {
return makeTorrent({ name: 'test.mkv', size: 1000, webseedUrl });
}
// ── auth endpoints ───────────────────────────────────────────────────────────
describe('auth', () => {
let app;
beforeAll(() => { cleanState(); ({ app } = makeApp()); });
it('POST /api/v2/auth/login → Ok.', async () => {
const res = await request(app).post('/api/v2/auth/login').send('username=a&password=b');
expect(res.status).toBe(200);
expect(res.text).toBe('Ok.');
});
it('GET /api/v2/auth/logout → Ok.', async () => {
const res = await request(app).get('/api/v2/auth/logout');
expect(res.status).toBe(200);
expect(res.text).toBe('Ok.');
});
});
// ── app info endpoints ───────────────────────────────────────────────────────
describe('app info', () => {
let app;
beforeAll(() => { cleanState(); ({ app } = makeApp()); });
it('GET /api/v2/app/version → 5.0.0', async () => {
expect((await request(app).get('/api/v2/app/version')).text).toBe('5.0.0');
});
it('GET /api/v2/app/webapiVersion → 2.8.3', async () => {
expect((await request(app).get('/api/v2/app/webapiVersion')).text).toBe('2.8.3');
});
it('GET /api/v2/app/buildInfo → JSON with version fields', async () => {
const res = await request(app).get('/api/v2/app/buildInfo');
expect(res.status).toBe(200);
expect(res.body).toMatchObject({ bitness: 64, libtorrent: '2.0.10' });
});
it('GET /api/v2/app/preferences → JSON with save_path', async () => {
const res = await request(app).get('/api/v2/app/preferences');
expect(res.status).toBe(200);
expect(res.body.save_path).toBe(tmpDir + '/');
expect(res.body.temp_path_enabled).toBe(false);
});
});
// ── torrent list / properties / categories ───────────────────────────────────
describe('torrent list', () => {
let app;
beforeAll(() => { cleanState(); ({ app } = makeApp()); });
it('GET /api/v2/torrents/info → empty array initially', async () => {
const res = await request(app).get('/api/v2/torrents/info');
expect(res.status).toBe(200);
expect(res.body).toEqual([]);
});
it('GET /api/v2/torrents/properties → 404 for unknown hash', async () => {
const res = await request(app).get('/api/v2/torrents/properties?hash=nonexistent');
expect(res.status).toBe(404);
});
it('GET /api/v2/torrents/categories → returns webshare category', async () => {
const res = await request(app).get('/api/v2/torrents/categories');
expect(res.status).toBe(200);
expect(res.body).toHaveProperty('webshare');
expect(res.body.webshare.savePath).toBe(tmpDir + '/');
});
});
// ── add via .torrent upload ──────────────────────────────────────────────────
describe('POST /api/v2/torrents/add (.torrent upload)', () => {
let app;
beforeEach(() => { cleanState(); ({ app } = makeApp()); });
it('responds Ok. and item appears in info list', async () => {
const torrent = makeTorrentBuf();
const addRes = await request(app)
.post('/api/v2/torrents/add')
.attach('torrents', torrent, 'test.torrent');
expect(addRes.status).toBe(200);
expect(addRes.text).toBe('Ok.');
const infoRes = await request(app).get('/api/v2/torrents/info');
expect(infoRes.body).toHaveLength(1);
expect(infoRes.body[0].name).toBe('test');
expect(infoRes.body[0].state).toBe('queuedDL');
expect(infoRes.body[0].eta).toBe(9999);
});
it('sets category from request body', async () => {
const torrent = makeTorrentBuf('http://webshare-api:3001/stream/cattest');
await request(app)
.post('/api/v2/torrents/add')
.field('category', 'webshare')
.attach('torrents', torrent, 'cat.torrent');
const { body } = await request(app).get('/api/v2/torrents/info');
expect(body[0].category).toBe('webshare');
});
it('skips duplicate (same webseed URL)', async () => {
const torrent = makeTorrentBuf('http://webshare-api:3001/stream/dup');
await request(app).post('/api/v2/torrents/add').attach('torrents', torrent, 'dup.torrent');
await request(app).post('/api/v2/torrents/add').attach('torrents', torrent, 'dup.torrent');
const { body } = await request(app).get('/api/v2/torrents/info');
expect(body).toHaveLength(1);
});
it('records torrent with no url-list as error (does not queue download)', async () => {
// Valid bencode dict with info but no url-list (pieces empty string)
const noWebseed = Buffer.from(
'd4:infod6:lengthi0e4:name4:test12:piece lengthi524288e6:pieces0:ee',
'utf8',
);
await request(app).post('/api/v2/torrents/add').attach('torrents', noWebseed, 'bad.torrent');
const { body } = await request(app).get('/api/v2/torrents/info');
// Product keeps an error entry so Sonarr stops re-adding the release
expect(body).toHaveLength(1);
expect(body[0].state).toBe('error');
expect(body[0].name).toBe('bad');
});
it('item has correct save_path and content_path', async () => {
const torrent = makeTorrentBuf('http://webshare-api:3001/stream/pathtest');
await request(app).post('/api/v2/torrents/add').attach('torrents', torrent, 'path.torrent');
const { body } = await request(app).get('/api/v2/torrents/info');
expect(body[0].save_path).toBe(tmpDir + '/');
expect(body[0].content_path).toContain(tmpDir);
});
it('GET /api/v2/torrents/properties returns data for added item', async () => {
const torrent = makeTorrentBuf('http://webshare-api:3001/stream/proptest');
await request(app).post('/api/v2/torrents/add').attach('torrents', torrent, 'prop.torrent');
const { body: items } = await request(app).get('/api/v2/torrents/info');
const hash = items[0].hash;
const res = await request(app).get(`/api/v2/torrents/properties?hash=${hash}`);
expect(res.status).toBe(200);
expect(res.body.hash).toBe(hash);
});
});
// ── add via URL list (legacy fallback) ──────────────────────────────────────
describe('POST /api/v2/torrents/add (url list)', () => {
let app;
beforeEach(() => { cleanState(); ({ app } = makeApp()); });
it('adds item from url list', async () => {
await request(app)
.post('/api/v2/torrents/add')
.send('urls=http://webshare-api:3001/stream/urltest1');
const { body } = await request(app).get('/api/v2/torrents/info');
expect(body).toHaveLength(1);
expect(body[0].name).toBe('urltest1');
});
it('extracts ident from /download/ path', async () => {
await request(app)
.post('/api/v2/torrents/add')
.send('urls=http://webshare-api:3001/download/myident99');
const { body } = await request(app).get('/api/v2/torrents/info');
expect(body[0].name).toBe('myident99');
});
it('skips duplicate URLs', async () => {
const url = 'http://webshare-api:3001/stream/dup2';
await request(app).post('/api/v2/torrents/add').send(`urls=${url}`);
await request(app).post('/api/v2/torrents/add').send(`urls=${url}`);
const { body } = await request(app).get('/api/v2/torrents/info');
expect(body).toHaveLength(1);
});
});
// ── delete ───────────────────────────────────────────────────────────────────
describe('POST /api/v2/torrents/delete', () => {
let app;
beforeEach(() => { cleanState(); ({ app } = makeApp()); });
async function addAndGetHash(webseedUrl = 'http://webshare-api:3001/stream/deltest') {
const torrent = makeTorrentBuf(webseedUrl);
await request(app).post('/api/v2/torrents/add').attach('torrents', torrent, 't.torrent');
const { body } = await request(app).get('/api/v2/torrents/info');
return body[0].hash;
}
it('removes item from list', async () => {
const hash = await addAndGetHash();
await request(app).post('/api/v2/torrents/delete').send(`hashes=${hash}&deleteFiles=false`);
const { body } = await request(app).get('/api/v2/torrents/info');
expect(body).toHaveLength(0);
});
it('responds Ok.', async () => {
const hash = await addAndGetHash('http://webshare-api:3001/stream/delok');
const res = await request(app).post('/api/v2/torrents/delete')
.send(`hashes=${hash}&deleteFiles=false`);
expect(res.text).toBe('Ok.');
});
it('deleteFiles=true attempts fs.unlink on the file', async () => {
const hash = await addAndGetHash('http://webshare-api:3001/stream/delfile');
// Write a fake file so unlinkSync has something to delete
const name = (await request(app).get('/api/v2/torrents/info')).body[0].name;
const fpath = path.join(tmpDir, name);
fs.writeFileSync(fpath, 'fake data');
await request(app).post('/api/v2/torrents/delete').send(`hashes=${hash}&deleteFiles=true`);
expect(fs.existsSync(fpath)).toBe(false);
const { body } = await request(app).get('/api/v2/torrents/info');
expect(body).toHaveLength(0);
});
it('delete always cancels and removes incomplete staging file', async () => {
// cancelDownload unlinks partials for queuedDL/downloading regardless of deleteFiles
const hash = await addAndGetHash('http://webshare-api:3001/stream/nodelf');
const name = (await request(app).get('/api/v2/torrents/info')).body[0].name;
const fpath = path.join(tmpDir, name);
fs.writeFileSync(fpath, 'keep me');
await request(app).post('/api/v2/torrents/delete').send(`hashes=${hash}&deleteFiles=false`);
expect(fs.existsSync(fpath)).toBe(false);
const { body } = await request(app).get('/api/v2/torrents/info');
expect(body).toHaveLength(0);
});
it('ignores unknown hashes gracefully', async () => {
const res = await request(app).post('/api/v2/torrents/delete')
.send('hashes=nonexistenthash&deleteFiles=false');
expect(res.status).toBe(200);
expect(res.text).toBe('Ok.');
});
});
// ── no-op control endpoints ──────────────────────────────────────────────────
describe('no-op control endpoints', () => {
let app;
beforeAll(() => { cleanState(); ({ app } = makeApp()); });
const endpoints = [
'pause', 'resume', 'setCategory', 'setLocation',
'rename', 'createCategory', 'editCategory', 'removeCategories',
];
for (const ep of endpoints) {
it(`POST /api/v2/torrents/${ep} → Ok.`, async () => {
const res = await request(app).post(`/api/v2/torrents/${ep}`).send('hashes=abc');
expect(res.status).toBe(200);
expect(res.text).toBe('Ok.');
});
}
});
// ── state persistence ────────────────────────────────────────────────────────
describe('state persistence', () => {
beforeEach(cleanState);
it('saveState writes JSON file and loadState restores queuedDL items', async () => {
const { app: app1 } = makeApp();
const torrent = makeTorrentBuf('http://webshare-api:3001/stream/persist1');
await request(app1).post('/api/v2/torrents/add').attach('torrents', torrent, 't.torrent');
// State file should exist
const stateFile = path.join(tmpDir, '.queue-state.json');
expect(fs.existsSync(stateFile)).toBe(true);
const state = JSON.parse(fs.readFileSync(stateFile));
expect(state).toHaveLength(1);
expect(state[0].state).toBe('queuedDL');
expect(state[0].webseedUrl).toBe('http://webshare-api:3001/stream/persist1');
});
it('loadState re-queues downloading items and deletes partial files', async () => {
const webseedUrl = 'http://webshare-api:3001/stream/requeue1';
const partialFile = path.join(tmpDir, 'partial.mkv');
fs.writeFileSync(partialFile, 'partial data');
// Write a state file manually as if a download was in progress
const state = [{ hash: 'abc123', name: 'partial.mkv', size: 0, progress: 0.5,
state: 'downloading', category: '', webseedUrl }];
fs.writeFileSync(path.join(tmpDir, '.queue-state.json'), JSON.stringify(state));
const { app: app2 } = makeApp();
const { body } = await request(app2).get('/api/v2/torrents/info');
// Should be re-queued
expect(body).toHaveLength(1);
expect(body[0].state).toBe('queuedDL');
expect(body[0].progress).toBe(0);
// Partial file should be deleted
expect(fs.existsSync(partialFile)).toBe(false);
});
it('loadState restores uploading items when file exists', async () => {
const fname = 'done.mkv';
fs.writeFileSync(path.join(tmpDir, fname), 'complete file');
const state = [{ hash: 'def456', name: fname, size: 100, progress: 1.0,
state: 'uploading', category: '' }];
fs.writeFileSync(path.join(tmpDir, '.queue-state.json'), JSON.stringify(state));
const { app: app3 } = makeApp();
const { body } = await request(app3).get('/api/v2/torrents/info');
expect(body).toHaveLength(1);
expect(body[0].state).toBe('uploading');
expect(body[0].progress).toBe(1.0);
fs.unlinkSync(path.join(tmpDir, fname));
});
it('loadState drops uploading items when file is missing', async () => {
// Already imported/cleaned — Sonarr has hasFile; do not re-queue
const webseedUrl = 'http://webshare-api:3001/stream/missingup';
const state = [{ hash: 'ghi789', name: 'missing.mkv', size: 0, progress: 1.0,
state: 'uploading', category: '', webseedUrl }];
fs.writeFileSync(path.join(tmpDir, '.queue-state.json'), JSON.stringify(state));
const { app: app4 } = makeApp();
const { body } = await request(app4).get('/api/v2/torrents/info');
expect(body).toHaveLength(0);
});
it('loadState keeps error items as error (no auto-retry on restart)', async () => {
const webseedUrl = 'http://webshare-api:3001/stream/errretry';
const state = [{ hash: 'jkl000', name: 'errored.mkv', size: 0, progress: 0,
state: 'error', category: '', webseedUrl }];
fs.writeFileSync(path.join(tmpDir, '.queue-state.json'), JSON.stringify(state));
const { app: app5 } = makeApp();
const { body } = await request(app5).get('/api/v2/torrents/info');
expect(body).toHaveLength(1);
expect(body[0].state).toBe('error');
});
it('loadState preserves other states as-is', async () => {
const state = [{ hash: 'mno111', name: 'other.mkv', size: 0, progress: 0,
state: 'stalledUP', category: '' }];
fs.writeFileSync(path.join(tmpDir, '.queue-state.json'), JSON.stringify(state));
const { app: app6 } = makeApp();
const { body } = await request(app6).get('/api/v2/torrents/info');
expect(body).toHaveLength(1);
expect(body[0].state).toBe('stalledUP');
});
it('loadState handles missing state file gracefully', () => {
expect(() => {
const { app: app7 } = makeApp();
return app7; // just needs to not throw
}).not.toThrow();
});
it('loadState handles corrupt state file gracefully', () => {
fs.writeFileSync(path.join(tmpDir, '.queue-state.json'), 'not valid json{{{');
expect(() => { makeApp(); }).not.toThrow();
});
});
// ── cleanupStaging ───────────────────────────────────────────────────────────
describe('cleanupStaging (runs on startup)', () => {
beforeEach(cleanState);
afterEach(cleanState);
it('deletes files with nlink > 1 immediately', () => {
const fpath = path.join(tmpDir, 'hardlinked.mkv');
fs.writeFileSync(fpath, 'data');
// Create a hardlink so nlink > 1
const link = path.join(tmpDir, 'hardlinked-link.mkv');
fs.linkSync(fpath, link);
makeApp(); // triggers cleanupStaging
// First name in readdir with nlink>1 is unlinked; the other then has nlink=1 and stays
const remaining = [fpath, link].filter(p => fs.existsSync(p));
expect(remaining).toHaveLength(1);
remaining.forEach(p => fs.unlinkSync(p));
});
it('deletes files older than 2 hours that are not active downloads', () => {
const fpath = path.join(tmpDir, 'stale.mkv');
fs.writeFileSync(fpath, 'old data');
// Backdate mtime to 3 hours ago
const threeHoursAgo = new Date(Date.now() - 3 * 60 * 60 * 1000);
fs.utimesSync(fpath, threeHoursAgo, threeHoursAgo);
makeApp(); // triggers cleanupStaging
expect(fs.existsSync(fpath)).toBe(false);
});
it('loadState deletes partial files when re-queueing a downloading entry', () => {
const fname = 'active-download.mkv';
const fpath = path.join(tmpDir, fname);
fs.writeFileSync(fpath, 'partial');
// Backdate to 3 hours ago
const old = new Date(Date.now() - 3 * 60 * 60 * 1000);
fs.utimesSync(fpath, old, old);
// Interrupted download — loadState unlinks partial and re-queues
fs.writeFileSync(path.join(tmpDir, '.queue-state.json'), JSON.stringify([{
hash: 'activehash', name: fname, size: 0, progress: 0.5,
state: 'downloading', category: '', webseedUrl: 'http://webshare-api:3001/stream/active',
}]));
makeApp();
expect(fs.existsSync(fpath)).toBe(false);
});
it('preserves recent files (< 2 hours old)', () => {
const fpath = path.join(tmpDir, 'recent.mkv');
fs.writeFileSync(fpath, 'fresh data');
// mtime is just now, well under 2 hours
makeApp();
expect(fs.existsSync(fpath)).toBe(true);
fs.unlinkSync(fpath);
});
it('skips dotfiles (like .queue-state.json)', () => {
// State file must not be deleted even if it's old
const stateContent = JSON.stringify([]);
fs.writeFileSync(path.join(tmpDir, '.queue-state.json'), stateContent);
const old = new Date(Date.now() - 3 * 60 * 60 * 1000);
fs.utimesSync(path.join(tmpDir, '.queue-state.json'), old, old);
makeApp();
expect(fs.existsSync(path.join(tmpDir, '.queue-state.json'))).toBe(true);
});
});
// ── download pipeline (mocked http) ─────────────────────────────────────────
describe('download pipeline', () => {
const { EventEmitter } = require('events');
beforeEach(() => {
cleanState();
// Allow 1 concurrent download for these tests
process.env.MAX_CONCURRENT_DOWNLOADS = '1';
});
afterEach(() => {
process.env.MAX_CONCURRENT_DOWNLOADS = '0';
jest.restoreAllMocks();
});
it('marks entry as uploading after successful download', async () => {
// Mock http.get to write a small file
const httpMod = require('http');
jest.spyOn(httpMod, 'get').mockImplementation((url, cb) => {
const res = new EventEmitter();
res.statusCode = 200;
res.headers = { 'content-length': '10' };
res.resume = jest.fn();
res.destroy = jest.fn();
res.pipe = jest.fn((dest) => {
setImmediate(() => {
// Write actual content to the file so createWriteStream's finish fires
dest.emit('finish');
});
return dest;
});
setImmediate(() => cb(res));
return { on: jest.fn() };
});
// Also mock fs.createWriteStream to avoid writing real files here
jest.spyOn(fs, 'createWriteStream').mockReturnValue(
(() => {
const w = new EventEmitter();
w.close = (cb) => cb && cb();
return w;
})()
);
const { app } = makeApp();
const torrent = makeTorrentBuf('http://webshare-api:3001/stream/dltest');
await request(app).post('/api/v2/torrents/add').attach('torrents', torrent, 'dl.torrent');
// Wait for async download to complete
await new Promise(r => setTimeout(r, 100));
const { body } = await request(app).get('/api/v2/torrents/info');
expect(body[0].state).toBe('uploading');
expect(body[0].progress).toBe(1.0);
});
it('marks entry as error on HTTP failure', async () => {
const httpMod = require('http');
jest.spyOn(httpMod, 'get').mockImplementation((url, cb) => {
const res = new EventEmitter();
res.statusCode = 404;
res.resume = jest.fn();
setImmediate(() => cb(res));
return { on: jest.fn() };
});
const { app } = makeApp();
const torrent = makeTorrentBuf('http://webshare-api:3001/stream/err404');
await request(app).post('/api/v2/torrents/add').attach('torrents', torrent, 'err.torrent');
await new Promise(r => setTimeout(r, 100));
const { body } = await request(app).get('/api/v2/torrents/info');
expect(body[0].state).toBe('error');
});
it('schedules retry on stalled download (ECONNRESET)', async () => {
const httpMod = require('http');
let callCount = 0;
jest.spyOn(httpMod, 'get').mockImplementation((url, cb) => {
callCount++;
const res = new EventEmitter();
res.statusCode = 200;
res.headers = { 'content-length': '10' };
res.resume = jest.fn();
res.destroy = jest.fn();
res.pipe = jest.fn((dest) => {
setImmediate(() => {
res.emit('error', new Error('ECONNRESET'));
});
return dest;
});
setImmediate(() => cb(res));
return { on: jest.fn() };
});
jest.spyOn(fs, 'createWriteStream').mockReturnValue(
(() => { const w = new EventEmitter(); w.close = (cb) => cb && cb(); return w; })()
);
jest.spyOn(fs, 'unlinkSync').mockImplementation(() => {});
const { app } = makeApp();
const torrent = makeTorrentBuf('http://webshare-api:3001/stream/retry');
await request(app).post('/api/v2/torrents/add').attach('torrents', torrent, 'retry.torrent');
await new Promise(r => setTimeout(r, 200));
// Immediate second attempt is deferred (2 min); entry returns to queuedDL
expect(callCount).toBe(1);
const { body } = await request(app).get('/api/v2/torrents/info');
expect(body[0].state).toBe('queuedDL');
});
it('follows HTTP redirects', async () => {
const httpMod = require('http');
let call = 0;
jest.spyOn(httpMod, 'get').mockImplementation((url, cb) => {
call++;
const res = new EventEmitter();
if (call === 1) {
res.statusCode = 302;
res.headers = { location: 'http://cdn.example.com/file.mkv' };
res.resume = jest.fn();
setImmediate(() => cb(res));
} else {
res.statusCode = 200;
res.headers = { 'content-length': '5' };
res.destroy = jest.fn();
res.pipe = jest.fn((dest) => { setImmediate(() => dest.emit('finish')); return dest; });
setImmediate(() => cb(res));
}
return { on: jest.fn() };
});
jest.spyOn(fs, 'createWriteStream').mockReturnValue(
(() => { const w = new EventEmitter(); w.close = (cb) => cb && cb(); return w; })()
);
const { app } = makeApp();
const torrent = makeTorrentBuf('http://webshare-api:3001/stream/redir');
await request(app).post('/api/v2/torrents/add').attach('torrents', torrent, 'redir.torrent');
await new Promise(r => setTimeout(r, 150));
expect(call).toBe(2); // original + redirected
});
it('rejects on too many redirects', async () => {
const httpMod = require('http');
jest.spyOn(httpMod, 'get').mockImplementation((url, cb) => {
const res = new EventEmitter();
res.statusCode = 301;
res.headers = { location: url }; // redirect to itself
res.resume = jest.fn();
setImmediate(() => cb(res));
return { on: jest.fn() };
});
const { app } = makeApp();
const torrent = makeTorrentBuf('http://webshare-api:3001/stream/loop');
await request(app).post('/api/v2/torrents/add').attach('torrents', torrent, 'loop.torrent');
await new Promise(r => setTimeout(r, 200));
const { body } = await request(app).get('/api/v2/torrents/info');
expect(body[0].state).toBe('error');
});
it('updates filename from Content-Disposition header', async () => {
const httpMod = require('http');
jest.spyOn(httpMod, 'get').mockImplementation((url, cb) => {
const res = new EventEmitter();
res.statusCode = 200;
res.headers = {
'content-length': '10',
'content-disposition': 'attachment; filename="renamed.mkv"',
};
res.destroy = jest.fn();
res.pipe = jest.fn((dest) => { setImmediate(() => dest.emit('finish')); return dest; });
setImmediate(() => cb(res));
return { on: jest.fn() };
});
jest.spyOn(fs, 'createWriteStream').mockReturnValue(
(() => { const w = new EventEmitter(); w.close = (cb) => cb && cb(); return w; })()
);
const { app } = makeApp();
const torrent = makeTorrentBuf('http://webshare-api:3001/stream/rename');
await request(app).post('/api/v2/torrents/add').attach('torrents', torrent, 'orig.torrent');
await new Promise(r => setTimeout(r, 100));
const { body } = await request(app).get('/api/v2/torrents/info');
expect(body[0].name).toBe('renamed.mkv');
});
});
// ── exported helpers + extra routes ──────────────────────────────────────────
describe('getQueue / enqueueDirect / files / re-hash', () => {
beforeEach(() => {
cleanState();
process.env.MAX_CONCURRENT_DOWNLOADS = '0';
});
it('getQueue returns queue snapshot', async () => {
const { app, qbt } = makeApp();
const torrent = makeTorrentBuf('http://webshare-api:3001/stream/gq1');
await request(app).post('/api/v2/torrents/add').attach('torrents', torrent, 'gq.torrent');
const q = qbt.getQueue();
expect(q).toHaveLength(1);
expect(q[0]).toMatchObject({ name: 'gq', state: 'queuedDL' });
expect(q[0].path).toContain(tmpDir);
});
it('enqueueDirect stages a webshare download and rejects duplicates', () => {
process.env.BASE_URL = 'http://localhost:3001';
const { qbt } = makeApp();
expect(qbt.enqueueDirect('ident99', 'Show.S01E01.mkv', 1000)).toBe(true);
expect(qbt.enqueueDirect('ident99', 'Show.S01E01.mkv', 1000)).toBe(false);
const q = qbt.getQueue();
expect(q).toHaveLength(1);
expect(q[0].name).toBe('Show.S01E01.mkv');
expect(q[0].path).toContain('Show.S01E01.mkv');
});
it('GET /api/v2/torrents/files returns file list for known hash', async () => {
const { app } = makeApp();
const torrent = makeTorrentBuf('http://webshare-api:3001/stream/files1');
await request(app).post('/api/v2/torrents/add').attach('torrents', torrent, 'files.torrent');
const { body: items } = await request(app).get('/api/v2/torrents/info');
const hash = items[0].hash;
const res = await request(app).get(`/api/v2/torrents/files?hash=${hash}`);
expect(res.status).toBe(200);
expect(res.body[0].name).toBe('files');
expect(res.body[0].priority).toBe(1);
const missing = await request(app).get('/api/v2/torrents/files?hash=nope');
expect(missing.status).toBe(404);
});
it('re-maps existing entry when same webseed is re-added with different hash', async () => {
const { app } = makeApp();
const webseed = 'http://webshare-api:3001/stream/rehash1';
const t1 = makeTorrentBuf(webseed);
await request(app).post('/api/v2/torrents/add').attach('torrents', t1, 'old.torrent');
// Manually corrupt hash in state by adding via url first then torrent — add via url uses sha1(url)
await request(app).post('/api/v2/torrents/add').send(`urls=${webseed}`);
// second torrent add with same webseed should re-hash path, not create second download
await request(app).post('/api/v2/torrents/add').attach('torrents', t1, 'new.torrent');
const { body } = await request(app).get('/api/v2/torrents/info');
// one logical download for the webseed
const sameUrl = body.filter((b) => b.name === 'old' || b.name === 'new' || b.name === 'rehash1');
expect(sameUrl.length).toBeGreaterThanOrEqual(1);
// overall should not keep exploding
expect(body.length).toBeLessThanOrEqual(2);
});
it('cancelDownload returns false for unknown hash and true for queued', async () => {
const { app, qbt } = makeApp();
expect(qbt.cancelDownload('missing')).toBe(false);
expect(qbt.cancelDownload('')).toBe(false);
const torrent = makeTorrentBuf('http://webshare-api:3001/stream/canc1');
await request(app).post('/api/v2/torrents/add').attach('torrents', torrent, 'c.torrent');
const { body } = await request(app).get('/api/v2/torrents/info');
expect(qbt.cancelDownload(body[0].hash)).toBe(true);
expect((await request(app).get('/api/v2/torrents/info')).body).toHaveLength(0);
});
it('safeFileName strips path and unsafe characters via enqueueDirect', () => {
const { qbt } = makeApp();
qbt.enqueueDirect('x', '../../evil:name?.mkv', 1);
expect(qbt.getQueue()[0].name).toBe('evil_name_.mkv');
});
it('re-hashes entry when torrent re-add has same webseed but different info-hash name', async () => {
const { app } = makeApp();
const webseed = 'http://webshare-api:3001/stream/samews';
// First: add via URL (hash = sha1(url), name = ident)
await request(app).post('/api/v2/torrents/add').send(`urls=${webseed}`);
let { body } = await request(app).get('/api/v2/torrents/info');
expect(body).toHaveLength(1);
const oldHash = body[0].hash;
// Second: same webseed via .torrent → extractInfoHash differs from sha1(url)
const torrent = makeTorrentBuf(webseed);
await request(app).post('/api/v2/torrents/add').attach('torrents', torrent, 'samews.torrent');
({ body } = await request(app).get('/api/v2/torrents/info'));
expect(body).toHaveLength(1);
expect(body[0].hash).not.toBe(oldHash);
});
it('cancelDownload aborts active HTTP request', async () => {
process.env.MAX_CONCURRENT_DOWNLOADS = '1';
const { EventEmitter } = require('events');
const httpMod = require('http');
const reqObj = { destroy: jest.fn(), on: jest.fn() };
jest.spyOn(httpMod, 'get').mockImplementation((url, cb) => {
const res = new EventEmitter();
res.statusCode = 200;
res.headers = { 'content-length': '1000000' };
res.destroy = jest.fn();
res.resume = jest.fn();
res.pipe = jest.fn(() => res);
// never finish — stays "downloading"
setImmediate(() => cb(res));
return reqObj;
});
jest.spyOn(fs, 'createWriteStream').mockReturnValue(
(() => {
const w = new EventEmitter();
w.close = (cb) => cb && cb();
return w;
})()
);
const { app, qbt } = makeApp();
const torrent = makeTorrentBuf('http://webshare-api:3001/stream/actcancel');
await request(app).post('/api/v2/torrents/add').attach('torrents', torrent, 'ac.torrent');
await new Promise((r) => setTimeout(r, 50));
const { body } = await request(app).get('/api/v2/torrents/info');
expect(body[0].state).toBe('downloading');
expect(qbt.cancelDownload(body[0].hash)).toBe(true);
expect(reqObj.destroy).toHaveBeenCalled();
process.env.MAX_CONCURRENT_DOWNLOADS = '0';
jest.restoreAllMocks();
});
it('follows 303 redirect then completes', async () => {
process.env.MAX_CONCURRENT_DOWNLOADS = '1';
const { EventEmitter } = require('events');
const httpMod = require('http');
let call = 0;
jest.spyOn(httpMod, 'get').mockImplementation((url, cb) => {
call++;
const res = new EventEmitter();
if (call === 1) {
res.statusCode = 303;
res.headers = { location: 'http://cdn.example.com/f.mkv' };
res.resume = jest.fn();
setImmediate(() => cb(res));
} else {
res.statusCode = 200;
res.headers = { 'content-length': '4' };
res.destroy = jest.fn();
res.pipe = jest.fn((dest) => {
setImmediate(() => dest.emit('finish'));
return dest;
});
setImmediate(() => cb(res));
}
return { on: jest.fn() };
});
jest.spyOn(fs, 'createWriteStream').mockReturnValue(
(() => {
const w = new EventEmitter();
w.close = (cb) => cb && cb();
return w;
})()
);
const { app } = makeApp();
await request(app)
.post('/api/v2/torrents/add')
.attach('torrents', makeTorrentBuf('http://webshare-api:3001/stream/r303'), 'r.torrent');
await new Promise((r) => setTimeout(r, 120));
expect(call).toBe(2);
process.env.MAX_CONCURRENT_DOWNLOADS = '0';
jest.restoreAllMocks();
});
it('updates speed on data chunks during download', async () => {
process.env.MAX_CONCURRENT_DOWNLOADS = '1';
const { EventEmitter } = require('events');
const httpMod = require('http');
jest.spyOn(httpMod, 'get').mockImplementation((url, cb) => {
const res = new EventEmitter();
res.statusCode = 200;
res.headers = { 'content-length': '10000' };
res.destroy = jest.fn();
res.resume = jest.fn();
res.pipe = jest.fn((dest) => {
setImmediate(() => {
res.emit('data', Buffer.alloc(3000));
res.emit('data', Buffer.alloc(3000));
dest.emit('finish');
});
return dest;
});
setImmediate(() => cb(res));
return { on: jest.fn() };
});
jest.spyOn(fs, 'createWriteStream').mockReturnValue(
(() => {
const w = new EventEmitter();
w.close = (cb) => cb && cb();
return w;
})()
);
const { app } = makeApp();
const torrent = makeTorrentBuf('http://webshare-api:3001/stream/speed1');
await request(app).post('/api/v2/torrents/add').attach('torrents', torrent, 'sp.torrent');
await new Promise((r) => setTimeout(r, 150));
const { body } = await request(app).get('/api/v2/torrents/info');
expect(body[0].state).toBe('uploading');
process.env.MAX_CONCURRENT_DOWNLOADS = '0';
jest.restoreAllMocks();
});
});