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('skips torrent with no url-list', async () => { // A buffer that decodes to a dict without url-list const invalid = Buffer.from('d4:infod6:lengthi0e4:name4:teste12:piece lengthi524288e6:piecese', 'utf8'); await request(app).post('/api/v2/torrents/add').attach('torrents', invalid, 'bad.torrent'); const { body } = await request(app).get('/api/v2/torrents/info'); expect(body).toHaveLength(0); }); 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('deleteFiles=false does not delete file', async () => { 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(true); fs.unlinkSync(fpath); }); 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 re-queues uploading items when file is missing', async () => { 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(1); expect(body[0].state).toBe('queuedDL'); }); it('loadState re-queues error items', 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('queuedDL'); }); 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 expect(fs.existsSync(fpath)).toBe(false); fs.unlinkSync(link); // cleanup the link }); 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('preserves files that are currently downloading', () => { const fname = 'active-download.mkv'; const fpath = path.join(tmpDir, fname); fs.writeFileSync(fpath, 'partial'); // Backdate to 3 hours ago — would normally be deleted const old = new Date(Date.now() - 3 * 60 * 60 * 1000); fs.utimesSync(fpath, old, old); // Write state showing this file is actively downloading 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(); // triggers cleanupStaging // File should still exist because it's in active download state expect(fs.existsSync(fpath)).toBe(true); fs.unlinkSync(fpath); }); 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('retries 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(() => { if (callCount === 1) { res.emit('error', new Error('ECONNRESET')); } else { 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; })() ); 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)); expect(callCount).toBeGreaterThan(1); }); 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'); }); });