feat(api): add contract-safe jobs read endpoints

This commit is contained in:
chick
2026-07-18 00:23:45 +08:00
parent e46e81ea77
commit e45b011bb8
6 changed files with 922 additions and 0 deletions
@@ -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();
});
});
+133
View File
@@ -0,0 +1,133 @@
import { JOB_STATUSES, type JobPageQuery, type JobStatus } from '@multi-simadmin/contracts';
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
import { JobQueryError, JobQueryService } from '../../application/jobs/job-query-service.js';
export interface JobRoutesOptions {
readonly jobs: JobQueryService;
}
const QUERY_KEYS = new Set([
'page',
'pageSize',
'sort',
'direction',
'status',
'operationId',
'rootJobId',
'instanceId',
]);
const errorResponse = {
VALIDATION_FAILED: {
status: 400,
title: 'Bad Request',
detail: 'The jobs query is invalid.',
},
NOT_FOUND: {
status: 404,
title: 'Not Found',
detail: 'The requested job does not exist.',
},
UNREPRESENTABLE: {
status: 503,
title: 'Service Unavailable',
detail: 'The requested job data is temporarily unavailable.',
},
} as const;
function invalid(): never {
throw new JobQueryError('VALIDATION_FAILED', 'Invalid jobs query');
}
function parseQuery(query: unknown): JobPageQuery {
if (query === null || typeof query !== 'object' || Array.isArray(query)) invalid();
const input = query as Record<string, unknown>;
for (const key of Object.keys(input)) {
if (!QUERY_KEYS.has(key)) invalid();
if (typeof input[key] !== 'string') invalid();
}
const scalar = (key: string): string | undefined => {
const value = input[key];
return value === undefined ? undefined : (value as string);
};
const integer = (key: 'page' | 'pageSize', fallback: number, maximum: number): number => {
const value = scalar(key);
if (value === undefined) return fallback;
if (!/^[1-9]\d*$/u.test(value)) invalid();
const parsed = Number(value);
if (!Number.isSafeInteger(parsed) || parsed > maximum) invalid();
return parsed;
};
const pageSize = integer('pageSize', 25, 100);
const maximumPage = Math.floor(Number.MAX_SAFE_INTEGER / pageSize) + 1;
const result: {
page: number;
pageSize: number;
sort?: 'createdAt' | 'status' | 'operationId';
direction: 'asc' | 'desc';
status?: JobStatus;
operationId?: string;
rootJobId?: string;
instanceId?: string;
} = {
page: integer('page', 1, maximumPage),
pageSize,
direction: 'asc',
};
const sort = scalar('sort');
if (sort !== undefined) {
if (sort !== 'createdAt' && sort !== 'status' && sort !== 'operationId') invalid();
result.sort = sort;
}
const direction = scalar('direction');
if (direction !== undefined) {
if (direction !== 'asc' && direction !== 'desc') invalid();
result.direction = direction;
}
const status = scalar('status');
if (status !== undefined) {
if (!(JOB_STATUSES as readonly string[]).includes(status)) invalid();
result.status = status as JobStatus;
}
for (const key of ['operationId', 'rootJobId', 'instanceId'] as const) {
const value = scalar(key);
if (value !== undefined) {
if (value.length === 0) invalid();
result[key] = value;
}
}
return result;
}
export function registerJobRoutes(app: FastifyInstance, options: JobRoutesOptions): void {
const handler =
<T>(action: (request: FastifyRequest) => T) =>
async (request: FastifyRequest, reply: FastifyReply) => {
try {
return action(request);
} catch (error) {
if (!(error instanceof JobQueryError)) throw error;
const mapped = errorResponse[error.code];
return reply.code(mapped.status).type('application/problem+json').send({
type: 'about:blank',
title: mapped.title,
status: mapped.status,
code: error.code,
detail: mapped.detail,
requestId: request.id,
});
}
};
app.get(
'/api/v1/jobs',
handler((request) => options.jobs.list(parseQuery(request.query))),
);
app.get(
'/api/v1/jobs/:jobId',
handler((request) => options.jobs.get((request.params as { jobId: string }).jobId)),
);
}