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:
2026-10-19 19:11:40 +02:00
parent 44c2888a2e
commit 99dcb298f8
8 changed files with 297 additions and 52 deletions

182
__tests__/ui-api.test.js Normal file
View 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' });
});
});