feat(queue): 404 on unknown hash, show error in UI
POST /api/queue/cancel answers 404 {"error":"hash not in queue"} for
untracked hashes instead of 200 ok:true. The UI surfaces the server's
error message instead of swallowing it.
This commit is contained in:
182
__tests__/ui-api.test.js
Normal file
182
__tests__/ui-api.test.js
Normal file
@@ -0,0 +1,182 @@
|
||||
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' });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user