feat(api): add contract-safe jobs read endpoints
This commit is contained in:
@@ -0,0 +1,207 @@
|
||||
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<string, string | string[] | number | undefined>; 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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user