import Database from 'better-sqlite3'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { buildApp } from '../../app.js'; import { JobQueryService } from '../../application/jobs/job-query-service.js'; import { migrateDatabase } from '../../infrastructure/database/migrations.js'; import { registerJobRoutes } from './job-routes.js'; const dbs: Database.Database[] = []; const timestamp = '2026-07-17T10:00:00.000Z'; afterEach(() => { for (const db of dbs.splice(0)) db.close(); }); function fixture() { const db = new Database(':memory:'); db.pragma('foreign_keys=ON'); migrateDatabase(db); dbs.push(db); const jobs = new JobQueryService(db); const app = buildApp({ registerRoutes: (scope) => registerJobRoutes(scope, { jobs }) }); return { app, db, jobs }; } function insertJob(db: Database.Database, id = 'job-1', status = 'succeeded'): void { db.prepare( `INSERT INTO jobs (id, root_job_id, operation_id, risk_level, status, requested_by, request_id, parameters_digest, created_at, updated_at) VALUES (?, ?, 'network.register-auto', 'R1', ?, 'actor', 'request-1', 'digest', ?, ?)`, ).run(id, id, status, timestamp, timestamp); } const exactProblem = ( response: { headers: Record; json(): unknown }, values: { title: string; status: number; code: string; detail: string }, ) => { expect(response.headers['content-type']).toContain('application/problem+json'); expect(response.headers['x-request-id']).toEqual(expect.any(String)); expect(response.json()).toEqual({ type: 'about:blank', ...values, requestId: response.headers['x-request-id'], }); }; describe('job HTTP read routes', () => { it('serves active jobs without exposing internal nonterminal items', async () => { const { app, db } = fixture(); insertJob(db, 'job-1', 'running'); db.prepare( `INSERT INTO job_items (id, job_id, instance_id, attempt_number, status, created_at, updated_at) VALUES ('item-pending', 'job-1', 'instance-1', 1, 'running', ?, ?)`, ).run(timestamp, timestamp); db.prepare( `INSERT INTO job_attempts (id, job_id, status, started_at, finished_at, created_at) VALUES ('attempt-1', 'job-1', 'running', ?, NULL, ?)`, ).run(timestamp, timestamp); const response = await app.inject({ method: 'GET', url: '/api/v1/jobs/job-1' }); expect(response.statusCode).toBe(200); expect(response.json()).toMatchObject({ status: 'running', items: [], attempts: [{ id: 'attempt-1', state: 'running', startedAt: timestamp }], }); await app.close(); }); it('maps an overbound child collection to the stable redacted 503 problem', async () => { const { app, db } = fixture(); insertJob(db); const insert = db.prepare( `INSERT INTO job_attempts (id, job_id, status, started_at, finished_at, created_at) VALUES (?, 'job-1', 'succeeded', ?, ?, ?)`, ); db.transaction(() => { for (let index = 1; index <= 1001; index += 1) insert.run(`attempt-${index}`, timestamp, timestamp, timestamp); })(); const response = await app.inject({ method: 'GET', url: '/api/v1/jobs/job-1' }); expect(response.statusCode).toBe(503); exactProblem(response, { title: 'Service Unavailable', status: 503, code: 'UNREPRESENTABLE', detail: 'The requested job data is temporarily unavailable.', }); expect(response.body).not.toContain('1000'); await app.close(); }); it('lists and gets jobs and supplies the documented list defaults', async () => { const { app, db, jobs } = fixture(); insertJob(db); const listCall = vi.spyOn(jobs, 'list'); const list = await app.inject({ method: 'GET', url: '/api/v1/jobs' }); expect(list.statusCode).toBe(200); expect(listCall).toHaveBeenCalledWith({ page: 1, pageSize: 25, direction: 'asc' }); expect(list.json()).toEqual({ items: [ { id: 'job-1', operationId: 'network.register-auto', status: 'succeeded', rootJobId: 'job-1', items: [], attempts: [], createdAt: timestamp, }, ], page: { page: 1, pageSize: 25, total: 1 }, }); const get = await app.inject({ method: 'GET', url: '/api/v1/jobs/job-1' }); expect(get.statusCode).toBe(200); expect(get.json()).toMatchObject({ id: 'job-1', status: 'succeeded' }); await app.close(); }); it('parses every allowed scalar query exactly', async () => { const { app, jobs } = fixture(); const listCall = vi.spyOn(jobs, 'list'); const response = await app.inject({ method: 'GET', url: '/api/v1/jobs?page=2&pageSize=10&sort=operationId&direction=desc&status=failed&operationId=op.one&rootJobId=root-1&instanceId=instance-1', }); expect(response.statusCode).toBe(200); expect(listCall).toHaveBeenCalledWith({ page: 2, pageSize: 10, sort: 'operationId', direction: 'desc', status: 'failed', operationId: 'op.one', rootJobId: 'root-1', instanceId: 'instance-1', }); await app.close(); }); it.each([ 'unknown=value', 'page=1&page=2', 'status=failed&status=succeeded', 'page=0', 'page=01', 'page=1.5', 'page=9007199254740992', 'pageSize=0', 'pageSize=101', 'sort=', 'sort=id', 'direction=ASC', 'status=complete', 'status=', 'operationId=', 'rootJobId=', 'instanceId=', ])('rejects invalid, unknown, empty, or repeated query input: %s', async (query) => { const { app } = fixture(); const response = await app.inject({ method: 'GET', url: `/api/v1/jobs?${query}` }); expect(response.statusCode).toBe(400); exactProblem(response, { title: 'Bad Request', status: 400, code: 'VALIDATION_FAILED', detail: 'The jobs query is invalid.', }); await app.close(); }); it('maps a missing job to an exact redacted 404 problem', async () => { const { app } = fixture(); const response = await app.inject({ method: 'GET', url: '/api/v1/jobs/missing' }); expect(response.statusCode).toBe(404); exactProblem(response, { title: 'Not Found', status: 404, code: 'NOT_FOUND', detail: 'The requested job does not exist.', }); await app.close(); }); it('maps unrepresentable persisted data to an exact stable redacted 503 problem', async () => { const { app, db } = fixture(); insertJob(db); db.prepare(`UPDATE jobs SET created_at = 'not-a-secret-but-invalid' WHERE id = 'job-1'`).run(); const response = await app.inject({ method: 'GET', url: '/api/v1/jobs/job-1' }); expect(response.statusCode).toBe(503); exactProblem(response, { title: 'Service Unavailable', status: 503, code: 'UNREPRESENTABLE', detail: 'The requested job data is temporarily unavailable.', }); expect(response.body).not.toContain('not-a-secret-but-invalid'); expect(response.body).not.toContain('canonical'); await app.close(); }); });