From ec8517a82cbc399a9e84c7cd8a8a3e2b887bf524 Mon Sep 17 00:00:00 2001 From: root Date: Mon, 1 Jun 2026 03:29:52 +1000 Subject: [PATCH] feat(api): jobs routes (list/get/retry/delete, owner-only) Co-Authored-By: Claude Opus 4.7 --- lib/api/index.js | 2 ++ lib/api/routes/jobs.js | 59 ++++++++++++++++++++++++++++++++++++++++++ tests/api/jobs.test.js | 53 +++++++++++++++++++++++++++++++++++++ 3 files changed, 114 insertions(+) create mode 100644 lib/api/routes/jobs.js create mode 100644 tests/api/jobs.test.js diff --git a/lib/api/index.js b/lib/api/index.js index b2f4688..a193a3e 100644 --- a/lib/api/index.js +++ b/lib/api/index.js @@ -20,6 +20,7 @@ import { router as linksRouter } from './routes/links.js'; import { router as pendingChangesRouter } from './routes/pending_changes.js'; import { router as auditRouter } from './routes/audit.js'; import { router as searchRouter } from './routes/search.js'; +import { router as jobsRouter } from './routes/jobs.js'; export function mountApi(app) { const api = Router(); @@ -47,6 +48,7 @@ export function mountApi(app) { api.use('/pending-changes', pendingChangesRouter); api.use('/audit', auditRouter); api.use('/search', searchRouter); + api.use('/jobs', jobsRouter); api.use('/:entity_type/:entity_id/tags', tagsByEntityRouter); api.use((_req, _res, next) => next(new NotFoundError('route not found'))); diff --git a/lib/api/routes/jobs.js b/lib/api/routes/jobs.js new file mode 100644 index 0000000..7feb2e2 --- /dev/null +++ b/lib/api/routes/jobs.js @@ -0,0 +1,59 @@ +import { Router } from 'express'; +import { z } from 'zod'; +import * as repo from '../../db/repos/jobs.js'; +import { validate } from '../validate.js'; +import { requireOwner } from '../cap.js'; +import { NotFoundError, asyncWrap } from '../errors.js'; +import { parsePagination } from '../pagination.js'; + +const STATES = ['created','retry','active','completed','expired','cancelled','failed']; + +const listQuery = z.object({ + state: z.enum(STATES).optional(), + name: z.string().optional(), + limit: z.string().optional(), + offset: z.string().optional() +}); + +const idParams = z.object({ id: z.string().uuid() }); + +export const router = Router(); +router.use(requireOwner); + +router.get('/', + validate({ query: listQuery }), + asyncWrap(async (req, res) => { + const { limit } = parsePagination(req); + res.json(await repo.list({ + state: req.validatedQuery.state, + name: req.validatedQuery.name, + limit + })); + }) +); + +router.get('/:id', + validate({ params: idParams }), + asyncWrap(async (req, res) => { + const row = await repo.getById(req.params.id); + if (!row) throw new NotFoundError('job not found'); + res.json(row); + }) +); + +router.post('/:id/retry', + validate({ params: idParams }), + asyncWrap(async (req, res) => { + const row = await repo.retry(req.params.id); + if (!row) throw new NotFoundError('job not found'); + res.json(row); + }) +); + +router.delete('/:id', + validate({ params: idParams }), + asyncWrap(async (req, res) => { + await repo.remove(req.params.id); + res.status(204).end(); + }) +); diff --git a/tests/api/jobs.test.js b/tests/api/jobs.test.js new file mode 100644 index 0000000..826bd49 --- /dev/null +++ b/tests/api/jobs.test.js @@ -0,0 +1,53 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import request from 'supertest'; +import { setup } from './helpers.js'; +import { stopBoss, waitForJob } from '../helpers/boss.js'; +import { pool } from '../../lib/db/pool.js'; +import * as queue from '../../lib/jobs/queue.js'; +import { registerWorkers } from '../../lib/jobs/index.js'; + +let app, ownerHeaders; +beforeEach(async () => { + ({ app, ownerHeaders } = await setup()); + await queue.start(); + await registerWorkers(); +}); +afterEach(async () => { await stopBoss(); }); + +describe('jobs api', () => { + it('GET /api/jobs returns recent jobs', async () => { + const id = await queue.enqueue('echo', { ping: 7 }); + await waitForJob('echo', id); + const res = await request(app).get('/api/jobs?limit=10').set(ownerHeaders); + expect(res.status).toBe(200); + expect(res.body.find(r => r.id === id)).toBeTruthy(); + }); + + it('GET /api/jobs/:id 404 on unknown', async () => { + const res = await request(app) + .get('/api/jobs/00000000-0000-0000-0000-000000000000') + .set(ownerHeaders); + expect(res.status).toBe(404); + }); + + it('unauthenticated → 401', async () => { + const res = await request(app).get('/api/jobs'); + expect(res.status).toBe(401); + }); + + it('POST :id/retry resubmits a failed job', async () => { + const id = await queue.enqueue('echo', { ping: 'r' }); + await waitForJob('echo', id); + await pool.query(`UPDATE pgboss.job SET state='failed' WHERE id=$1`, [id]); + const res = await request(app).post(`/api/jobs/${id}/retry`).set(ownerHeaders); + expect(res.status).toBe(200); + expect(res.body.state).toBe('retry'); + }); + + it('DELETE :id removes', async () => { + const id = await queue.enqueue('echo', { ping: 'd' }); + await waitForJob('echo', id); + const res = await request(app).delete(`/api/jobs/${id}`).set(ownerHeaders); + expect(res.status).toBe(204); + }); +});