diff --git a/__tests__/ui-api.test.js b/__tests__/ui-api.test.js new file mode 100644 index 0000000..e7d4c39 --- /dev/null +++ b/__tests__/ui-api.test.js @@ -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' }); + }); +}); diff --git a/doc/log.md b/doc/log.md index 0958ce1..51242ee 100644 --- a/doc/log.md +++ b/doc/log.md @@ -1,4 +1,5 @@ # Update Log ## 2026-10-19 +- **Update**: Queue cancel now returns `404 {error: 'hash not in queue'}` for unknown hashes and the Web UI surfaces the server's error instead of swallowing it; [Web UI JSON API](/structure/ui-api.md) added documenting the four UI routes and the deliberate non-change to the fake qBittorrent API. - **Creation**: Bundle created; [project overview](/structure/overview.md) added. diff --git a/doc/structure/index.md b/doc/structure/index.md index 79fdcfb..cee6010 100644 --- a/doc/structure/index.md +++ b/doc/structure/index.md @@ -3,3 +3,4 @@ What the project is, how it is laid out, and how to build, test and run it. - [Project Overview](./overview.md) - purpose, stack, repo layout, verified build/test/run commands. +- [Web UI JSON API](./ui-api.md) - the UI-facing JSON routes (search, queue add/list/cancel), the 404-on-cancel contract, and UI error surfacing. diff --git a/doc/structure/overview.md b/doc/structure/overview.md index 50a8530..028afe3 100644 --- a/doc/structure/overview.md +++ b/doc/structure/overview.md @@ -36,6 +36,7 @@ Webshare credentials are **optional**: with empty `WEBSHARE_USERNAME`/`WEBSHARE_ src/ # source (CommonJS) index.js # express app + app.listen at module load (server entry) qbt.js # fake qBittorrent client / download queue; cancelDownload returns a boolean + ui-api.js # Web UI JSON API routes (search, queue add/list/cancel), mounted before static mcp.js # MCP server entry (Streamable HTTP) webshare.js # Webshare API client torznab.js # Torznab caps/feed builders @@ -48,7 +49,7 @@ compose.yaml # Docker Compose data/ # host download data dir (Compose mount target) ``` -The `src/` modules: `qbt.js` is the shared download engine used by both the Web UI queue and the fake qBittorrent API; `cancelDownload(hash)` returns a boolean indicating whether a matching download was cancelled. +The `src/` modules: `qbt.js` is the shared download engine used by both the Web UI queue and the fake qBittorrent API; `cancelDownload(hash)` returns a boolean indicating whether a matching download was cancelled. The UI-facing JSON routes live in `ui-api.js` (extracted from `index.js` so they are testable with supertest); see [Web UI JSON API](./ui-api.md) for their contracts, including the `404` on cancelling a hash that is not in the queue. The fake qBittorrent API in `qbt.js` deliberately keeps its always-`Ok.` contract for real *arr clients. ## Build / test / run (verified) diff --git a/doc/structure/ui-api.md b/doc/structure/ui-api.md new file mode 100644 index 0000000..f99dc47 --- /dev/null +++ b/doc/structure/ui-api.md @@ -0,0 +1,36 @@ +--- +type: API +title: Web UI JSON API +description: The UI-facing JSON routes served by src/ui-api.js (search, queue add/list/cancel), the 404-on-cancel contract, and the UI error surfacing. +tags: [api, ui, queue, express] +timestamp: 2026-10-19T00:00:00Z +--- + +# Web UI JSON API + +The Web UI talks to the app over four JSON routes registered by `registerUiApi(app, { qbt, client })` in `src/ui-api.js`, which `src/index.js` mounts **before** static file serving so `/api/*` is never swallowed. `__tests__/ui-api.test.js` covers all four routes. + +| Route | Success | Error | +|-------|---------|-------| +| `GET /api/search?q=…` | `200` result array (`[]` when `q` is empty) | `500` `{error}` when the Webshare client fails | +| `POST /api/queue/add` | `200` `{ok: true}` | `400` `{error: 'ident required'}`, `409` `{error: 'already in queue'}`, `500` `{error}` | +| `GET /api/queue` | `200` queue array | `500` `{error}` | +| `POST /api/queue/cancel` | `200` `{ok: true}` | `400` `{error: 'hash required'}`, **`404` `{error: 'hash not in queue'}`** when the hash is not in the queue, `500` `{error}` | + +## Cancel returns 404 for unknown hashes + +`POST /api/queue/cancel` honors the return value of `qbt.cancelDownload(hash)` (see [Project Overview](./overview.md)), which is a boolean: `false` means no queued download matched the hash, and the route responds `404 {error: 'hash not in queue'}`. Before this, the route always answered `200 {ok: true}` even when nothing was cancelled. + +The 404 body message is deliberately human-readable — the UI surfaces `error` verbatim (below), and a raw body read is still intelligible. + +## The UI surfaces the error + +`apiFetch` in `ui/scripts.js` parses a failed response's JSON body when `content-type` is `application/json` and throws `new Error(data.error)` (falling back to the raw text or `HTTP ` for non-JSON or malformed bodies). The cancel-button handler then shows the failure inline as a transient `[err]` button state (re-enabled after 2 s) instead of swallowing it in `catch {}`; the 2 s queue poll repaints the row. This mirrors the established inline-error pattern of the add button — the codebase has no toasts or alerts. + +## The fake qBittorrent API is intentionally different + +The fake qBittorrent Web API in `src/qbt.js` (`POST /api/v2/torrents/delete` and friends) was **not** changed: it answers `Ok.` for real qBittorrent-API clients such as Sonarr, whose contract is always `Ok.`, and Sonarr deletes finished entries — a `404` there would break it. The two APIs serve different consumers and keep different error contracts on purpose. + +## Related + +- [Project Overview](./overview.md) — module layout and build/test commands. diff --git a/src/index.js b/src/index.js index 221c94f..693751f 100644 --- a/src/index.js +++ b/src/index.js @@ -6,6 +6,7 @@ const { caps, feed } = require('./torznab'); const { makeTorrent } = require('./torrent'); const qbt = require('./qbt'); const { registerMcpHttp } = require('./mcp-http'); +const { registerUiApi } = require('./ui-api'); const PORT = process.env.PORT || 3001; const BASE_URL = process.env.BASE_URL || `http://localhost:${PORT}`; @@ -124,53 +125,7 @@ app.get('/resolve/:ident', async (req, res) => { }); // ── UI API (before static so /api/* is never swallowed) ─────────────────── -app.get('/api/search', async (req, res) => { - const q = (req.query.q || '').trim(); - if (!q) return res.json([]); - try { - const results = await client.search(q, { - limit: Math.min(parseInt(req.query.limit) || 30, 100), - offset: parseInt(req.query.offset) || 0, - }); - res.json(results); - } catch (err) { - res.status(500).json({ error: err.message }); - } -}); - -app.post('/api/queue/add', (req, res) => { - try { - const { ident, name, size } = req.body || {}; - if (!ident) return res.status(400).json({ error: 'ident required' }); - const ok = qbt.enqueueDirect(ident, name || ident, parseInt(size) || 0); - if (!ok) return res.status(409).json({ error: 'already in queue' }); - res.json({ ok: true }); - } catch (err) { - console.error('queue/add', err); - res.status(500).json({ error: err.message || 'queue add failed' }); - } -}); - -app.get('/api/queue', (req, res) => { - try { - res.json(qbt.getQueue()); - } catch (err) { - console.error('queue', err); - res.status(500).json({ error: err.message || 'queue failed' }); - } -}); - -app.post('/api/queue/cancel', (req, res) => { - try { - const { hash } = req.body || {}; - if (!hash) return res.status(400).json({ error: 'hash required' }); - qbt.cancelDownload(hash); - res.json({ ok: true }); - } catch (err) { - console.error('queue/cancel', err); - res.status(500).json({ error: err.message || 'cancel failed' }); - } -}); +registerUiApi(app, { qbt, client }); app.get('/api/health', (req, res) => { res.json({ ok: true, guest: client.guest }); diff --git a/src/ui-api.js b/src/ui-api.js new file mode 100644 index 0000000..cb41de0 --- /dev/null +++ b/src/ui-api.js @@ -0,0 +1,59 @@ +/** + * Mount the Web UI's JSON API on an Express app. + * Must be registered before static file serving so /api/* is never swallowed. + * + * @param {import('express').Express} app + * @param {{ qbt: import('./qbt'), client: import('./webshare').WebshareClient }} options + */ +function registerUiApi(app, { qbt, client }) { + app.get('/api/search', async (req, res) => { + const q = (req.query.q || '').trim(); + if (!q) return res.json([]); + try { + const results = await client.search(q, { + limit: Math.min(parseInt(req.query.limit) || 30, 100), + offset: parseInt(req.query.offset) || 0, + }); + res.json(results); + } catch (err) { + res.status(500).json({ error: err.message }); + } + }); + + app.post('/api/queue/add', (req, res) => { + try { + const { ident, name, size } = req.body || {}; + if (!ident) return res.status(400).json({ error: 'ident required' }); + const ok = qbt.enqueueDirect(ident, name || ident, parseInt(size) || 0); + if (!ok) return res.status(409).json({ error: 'already in queue' }); + res.json({ ok: true }); + } catch (err) { + console.error('queue/add', err); + res.status(500).json({ error: err.message || 'queue add failed' }); + } + }); + + app.get('/api/queue', (req, res) => { + try { + res.json(qbt.getQueue()); + } catch (err) { + console.error('queue', err); + res.status(500).json({ error: err.message || 'queue failed' }); + } + }); + + app.post('/api/queue/cancel', (req, res) => { + try { + const { hash } = req.body || {}; + if (!hash) return res.status(400).json({ error: 'hash required' }); + const ok = qbt.cancelDownload(hash); + if (!ok) return res.status(404).json({ error: 'hash not in queue' }); + res.json({ ok: true }); + } catch (err) { + console.error('queue/cancel', err); + res.status(500).json({ error: err.message || 'cancel failed' }); + } + }); +} + +module.exports = { registerUiApi }; diff --git a/ui/scripts.js b/ui/scripts.js index 95f3632..44b8062 100644 --- a/ui/scripts.js +++ b/ui/scripts.js @@ -11,11 +11,18 @@ async function apiFetch(path, opts) { ...(opts && opts.headers), }, }); + const ct = res.headers.get('content-type') || ''; if (!res.ok) { const text = await res.text().catch(() => ''); - throw new Error(text || `HTTP ${res.status}`); + let msg = text || `HTTP ${res.status}`; + if (ct.includes('application/json')) { + try { + const data = JSON.parse(text); + if (data && data.error) msg = data.error; + } catch {} // Malformed JSON: keep the raw text + } + throw new Error(msg); } - const ct = res.headers.get('content-type') || ''; if (ct.includes('application/json')) return res.json(); return res.text(); } @@ -209,8 +216,11 @@ function renderQueue() { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ hash }), }); - } catch {} - renderQueue(); + renderQueue(); + } catch { + btn.textContent = '[err]'; + setTimeout(() => { btn.disabled = false; btn.textContent = '[x]'; }, 2000); + } }); }); }).catch(err => {