const express = require('express'); const request = require('supertest'); const fs = require('fs'); const path = require('path'); const os = require('os'); // ── shared temp dir ───────────────────────────────────────────────────────── let tmpDir; beforeAll(() => { tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ui-api-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 with the UI API for each test // (module-level state isolation; qbt.test.js's env must not be relied on) function makeApp(client) { let qbtModule; jest.isolateModules(() => { qbtModule = require('../src/qbt'); }); const { registerUiApi } = require('../src/ui-api'); const app = express(); app.use(express.urlencoded({ extended: false })); app.use(express.json()); registerUiApi(app, { qbt: qbtModule, client }); return { app, qbt: qbtModule, client }; } // ── POST /api/queue/cancel ───────────────────────────────────────────────── describe('POST /api/queue/cancel', () => { it('returns 400 when hash is missing', async () => { const { app } = makeApp({ search: jest.fn() }); const res = await request(app).post('/api/queue/cancel').send({}); expect(res.status).toBe(400); expect(res.body).toEqual({ error: 'hash required' }); }); it('returns 404 with error body when hash is not in queue', async () => { const { app } = makeApp({ search: jest.fn() }); const res = await request(app).post('/api/queue/cancel').send({ hash: 'deadbeef' }); expect(res.status).toBe(404); expect(res.body).toEqual({ error: 'hash not in queue' }); }); it('cancels an enqueued download and empties the queue', async () => { const { app, qbt } = makeApp({ search: jest.fn() }); qbt.enqueueDirect('ident1', 'Show.mkv', 1000); const hash = qbt.getQueue()[0].hash; const res = await request(app).post('/api/queue/cancel').send({ hash }); expect(res.status).toBe(200); expect(res.body).toEqual({ ok: true }); const queueRes = await request(app).get('/api/queue'); expect(queueRes.status).toBe(200); expect(queueRes.body).toEqual([]); }); }); // ── regression smoke for the other moved routes ───────────────────────────── describe('moved routes regression', () => { it('GET /api/queue returns 200 with an array', async () => { const { app } = makeApp({ search: jest.fn() }); const res = await request(app).get('/api/queue'); expect(res.status).toBe(200); expect(Array.isArray(res.body)).toBe(true); }); it('POST /api/queue/add: missing ident → 400, duplicate → 409, valid → 200', async () => { const { app } = makeApp({ search: jest.fn() }); const missing = await request(app) .post('/api/queue/add') .send({ name: 'Show.mkv', size: 1000 }); expect(missing.status).toBe(400); expect(missing.body).toEqual({ error: 'ident required' }); const valid = await request(app) .post('/api/queue/add') .send({ ident: 'identX', name: 'Show.mkv', size: 1000 }); expect(valid.status).toBe(200); expect(valid.body).toEqual({ ok: true }); const dup = await request(app) .post('/api/queue/add') .send({ ident: 'identX', name: 'Show.mkv', size: 1000 }); expect(dup.status).toBe(409); expect(dup.body).toEqual({ error: 'already in queue' }); }); it('GET /api/search?q=foo returns the client results', async () => { const results = [ { ident: 'foo-1', name: 'Foo S01E01.mkv', size: 12345 }, { ident: 'foo-2', name: 'Foo S01E02.mkv', size: 12346 }, ]; const { app, client } = makeApp({ search: jest.fn().mockResolvedValue(results) }); const res = await request(app).get('/api/search?q=foo'); expect(res.status).toBe(200); expect(res.body).toEqual(results); expect(client.search).toHaveBeenCalledWith('foo', { limit: 30, offset: 0 }); }); }); // ── 500 error branches + search edge cases ────────────────────────────────── describe('error branches', () => { it('GET /api/search without q → 200 []', async () => { const { app, client } = makeApp({ search: jest.fn() }); const res = await request(app).get('/api/search'); expect(res.status).toBe(200); expect(res.body).toEqual([]); expect(client.search).not.toHaveBeenCalled(); }); it('GET /api/search honors explicit limit and offset', async () => { const { app, client } = makeApp({ search: jest.fn().mockResolvedValue([]) }); const res = await request(app).get('/api/search?q=foo&limit=5&offset=3'); expect(res.status).toBe(200); expect(client.search).toHaveBeenCalledWith('foo', { limit: 5, offset: 3 }); }); it('GET /api/search → 500 when client.search rejects', async () => { const { app } = makeApp({ search: jest.fn().mockRejectedValue(new Error('search failed')) }); const res = await request(app).get('/api/search?q=foo'); expect(res.status).toBe(500); expect(res.body).toEqual({ error: 'search failed' }); }); it('POST /api/queue/add without name/size still enqueues (defaults)', async () => { const { app } = makeApp({ search: jest.fn() }); const res = await request(app).post('/api/queue/add').send({ ident: 'only' }); expect(res.status).toBe(200); expect(res.body).toEqual({ ok: true }); }); it('POST /api/queue/add → 500 with message when enqueueDirect throws', async () => { const { app, qbt } = makeApp({ search: jest.fn() }); jest.spyOn(qbt, 'enqueueDirect').mockImplementation(() => { throw new Error('db down'); }); const res = await request(app).post('/api/queue/add').send({ ident: 'a', name: 'b' }); expect(res.status).toBe(500); expect(res.body).toEqual({ error: 'db down' }); }); it('POST /api/queue/add → 500 with fallback when error has no message', async () => { const { app, qbt } = makeApp({ search: jest.fn() }); jest.spyOn(qbt, 'enqueueDirect').mockImplementation(() => { throw new Error(''); }); const res = await request(app).post('/api/queue/add').send({ ident: 'a', name: 'b' }); expect(res.status).toBe(500); expect(res.body).toEqual({ error: 'queue add failed' }); }); it('GET /api/queue → 500 when getQueue throws', async () => { const { app, qbt } = makeApp({ search: jest.fn() }); jest.spyOn(qbt, 'getQueue').mockImplementation(() => { throw new Error(''); }); const res = await request(app).get('/api/queue'); expect(res.status).toBe(500); expect(res.body).toEqual({ error: 'queue failed' }); }); it('POST /api/queue/cancel → 500 when cancelDownload throws', async () => { const { app, qbt } = makeApp({ search: jest.fn() }); jest.spyOn(qbt, 'cancelDownload').mockImplementation(() => { throw new Error(''); }); const res = await request(app).post('/api/queue/cancel').send({ hash: 'abc' }); expect(res.status).toBe(500); expect(res.body).toEqual({ error: 'cancel failed' }); }); });