feat(api): add contract-safe jobs read endpoints
This commit is contained in:
@@ -0,0 +1,296 @@
|
||||
import Database from 'better-sqlite3';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { migrateDatabase } from '../../infrastructure/database/migrations.js';
|
||||
import { JobQueryError, JobQueryService } from './job-query-service.js';
|
||||
|
||||
const t1 = '2026-07-17T10:00:00.000Z';
|
||||
const t2 = '2026-07-17T11:00:00.000Z';
|
||||
|
||||
function setup(): { db: Database.Database; subject: JobQueryService } {
|
||||
const db = new Database(':memory:');
|
||||
migrateDatabase(db);
|
||||
return { db, subject: new JobQueryService(db) };
|
||||
}
|
||||
|
||||
function insertJob(
|
||||
db: Database.Database,
|
||||
values: Partial<Record<'id' | 'root' | 'operation' | 'status' | 'created', unknown>> = {},
|
||||
): void {
|
||||
const id = values.id ?? 'job-1';
|
||||
const root = Object.prototype.hasOwnProperty.call(values, 'root') ? values.root : id;
|
||||
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 (?, ?, ?, 'R1', ?, 'actor', 'request-1', 'digest', ?, ?)`,
|
||||
).run(
|
||||
id,
|
||||
root,
|
||||
values.operation ?? 'op.one',
|
||||
values.status ?? 'succeeded',
|
||||
values.created ?? t1,
|
||||
values.created ?? t1,
|
||||
);
|
||||
}
|
||||
|
||||
function insertItem(db: Database.Database, overrides: Record<string, unknown> = {}): void {
|
||||
const row = {
|
||||
id: 'item-1',
|
||||
job: 'job-1',
|
||||
instance: 'instance-1',
|
||||
status: 'succeeded',
|
||||
source: null,
|
||||
error: null,
|
||||
created: t1,
|
||||
...overrides,
|
||||
};
|
||||
db.prepare(
|
||||
`INSERT INTO job_items
|
||||
(id, job_id, instance_id, attempt_number, status, error_json, source_job_item_id,
|
||||
created_at, updated_at)
|
||||
VALUES (?, ?, ?, 1, ?, ?, ?, ?, ?)`,
|
||||
).run(row.id, row.job, row.instance, row.status, row.error, row.source, row.created, row.created);
|
||||
}
|
||||
|
||||
function insertAttempt(db: Database.Database, overrides: Record<string, unknown> = {}): void {
|
||||
const row = {
|
||||
id: 'attempt-1',
|
||||
job: 'job-1',
|
||||
status: 'succeeded',
|
||||
started: t1,
|
||||
finished: t2,
|
||||
created: t1,
|
||||
...overrides,
|
||||
};
|
||||
db.prepare(
|
||||
`INSERT INTO job_attempts (id, job_id, status, started_at, finished_at, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
).run(row.id, row.job, row.status, row.started, row.finished, row.created);
|
||||
}
|
||||
|
||||
function expectCode(action: () => unknown, code: string): void {
|
||||
try {
|
||||
action();
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(JobQueryError);
|
||||
expect((error as JobQueryError).code).toBe(code);
|
||||
return;
|
||||
}
|
||||
throw new Error('expected JobQueryError');
|
||||
}
|
||||
|
||||
describe('JobQueryService', () => {
|
||||
it.each(['queued', 'running', 'cancelling'])(
|
||||
'projects only terminal items while a %s job is active and retains its running attempt',
|
||||
(status) => {
|
||||
const { db, subject } = setup();
|
||||
insertJob(db, { status });
|
||||
insertItem(db, { id: 'terminal', instance: 'done', status: 'succeeded' });
|
||||
insertItem(db, { id: 'internal', instance: 'pending', status: 'running' });
|
||||
insertAttempt(db, { status: 'running', finished: null });
|
||||
|
||||
expect(subject.get('job-1')).toMatchObject({
|
||||
status,
|
||||
items: [{ id: 'terminal', targetId: 'done', state: 'succeeded' }],
|
||||
attempts: [{ id: 'attempt-1', state: 'running', startedAt: t1 }],
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it('returns the exact frozen projection with deterministic child ordering', () => {
|
||||
const { db, subject } = setup();
|
||||
insertJob(db);
|
||||
insertItem(db, { id: 'item-z', instance: 'target-z' });
|
||||
insertItem(db, { id: 'item-a', instance: 'target-a', source: 'item-z' });
|
||||
insertAttempt(db, { id: 'attempt-z', started: t1 });
|
||||
insertAttempt(db, { id: 'attempt-a', started: t1, status: 'running', finished: null });
|
||||
|
||||
expect(subject.get('job-1')).toEqual({
|
||||
id: 'job-1',
|
||||
operationId: 'op.one',
|
||||
status: 'succeeded',
|
||||
rootJobId: 'job-1',
|
||||
items: [
|
||||
{ id: 'item-a', targetId: 'target-a', state: 'succeeded', sourceJobItemId: 'item-z' },
|
||||
{ id: 'item-z', targetId: 'target-z', state: 'succeeded' },
|
||||
],
|
||||
attempts: [
|
||||
{ id: 'attempt-a', state: 'running', startedAt: t1 },
|
||||
{ id: 'attempt-z', state: 'succeeded', startedAt: t1, finishedAt: t2 },
|
||||
],
|
||||
createdAt: t1,
|
||||
});
|
||||
});
|
||||
|
||||
it('paginates in SQL with filters and an ascending id tie-break independent of direction', () => {
|
||||
const { db, subject } = setup();
|
||||
insertJob(db, { id: 'z', operation: 'same', created: t2 });
|
||||
insertJob(db, { id: 'a', operation: 'same', created: t2 });
|
||||
insertJob(db, { id: 'other', operation: 'other', created: t1 });
|
||||
insertItem(db, { id: 'z-item', job: 'z', instance: 'wanted' });
|
||||
insertItem(db, { id: 'a-item', job: 'a', instance: 'wanted' });
|
||||
|
||||
expect(
|
||||
subject.list({
|
||||
page: 1,
|
||||
pageSize: 1,
|
||||
sort: 'createdAt',
|
||||
direction: 'desc',
|
||||
operationId: 'same',
|
||||
status: 'succeeded',
|
||||
rootJobId: 'z',
|
||||
instanceId: 'wanted',
|
||||
}),
|
||||
).toEqual({
|
||||
items: [expect.objectContaining({ id: 'z' })],
|
||||
page: { page: 1, pageSize: 1, total: 1 },
|
||||
});
|
||||
expect(
|
||||
subject.list({ sort: 'createdAt', direction: 'desc' }).items.map(({ id }) => id),
|
||||
).toEqual(['a', 'z', 'other']);
|
||||
});
|
||||
|
||||
it('reports not found without exposing database details', () => {
|
||||
const { subject } = setup();
|
||||
expect(() => subject.get('missing')).toThrow('Job was not found');
|
||||
expectCode(() => subject.get('missing'), 'NOT_FOUND');
|
||||
});
|
||||
|
||||
it.each([
|
||||
[{ page: 0 }, 'page'],
|
||||
[{ page: 1.5 }, 'page'],
|
||||
[{ pageSize: 0 }, 'pageSize'],
|
||||
[{ pageSize: 101 }, 'pageSize'],
|
||||
[{ sort: 'id' }, 'sort'],
|
||||
[{ direction: 'sideways' }, 'direction'],
|
||||
])('rejects invalid query input: %j', (query, field) => {
|
||||
const { subject } = setup();
|
||||
expect(() => subject.list(query as never)).toThrow(new RegExp(field, 'i'));
|
||||
expectCode(() => subject.list(query as never), 'VALIDATION_FAILED');
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
'job status',
|
||||
() => {
|
||||
const x = setup();
|
||||
insertJob(x.db, { status: 'invented' });
|
||||
return x;
|
||||
},
|
||||
],
|
||||
[
|
||||
'missing root',
|
||||
() => {
|
||||
const x = setup();
|
||||
insertJob(x.db, { root: null });
|
||||
return x;
|
||||
},
|
||||
],
|
||||
[
|
||||
'running item',
|
||||
() => {
|
||||
const x = setup();
|
||||
insertJob(x.db);
|
||||
insertItem(x.db, { status: 'running' });
|
||||
return x;
|
||||
},
|
||||
],
|
||||
[
|
||||
'attempt state',
|
||||
() => {
|
||||
const x = setup();
|
||||
insertJob(x.db);
|
||||
x.db.pragma('ignore_check_constraints = ON');
|
||||
insertAttempt(x.db, { status: 'queued' });
|
||||
return x;
|
||||
},
|
||||
],
|
||||
[
|
||||
'timestamp',
|
||||
() => {
|
||||
const x = setup();
|
||||
insertJob(x.db, { created: '2026-07-17T10:00:00Z' });
|
||||
return x;
|
||||
},
|
||||
],
|
||||
])('fails the whole selection as UNREPRESENTABLE for invalid persisted %s', (_name, make) => {
|
||||
const { subject } = make();
|
||||
expectCode(() => subject.list(), 'UNREPRESENTABLE');
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
'items',
|
||||
(db: Database.Database, count: number) => {
|
||||
const insert = db.prepare(
|
||||
`INSERT INTO job_items
|
||||
(id, job_id, instance_id, attempt_number, status, created_at, updated_at)
|
||||
VALUES (?, 'job-1', 'instance-1', ?, 'succeeded', ?, ?)`,
|
||||
);
|
||||
db.transaction(() => {
|
||||
for (let index = 1; index <= count; index += 1)
|
||||
insert.run(`item-${index}`, index, t1, t1);
|
||||
})();
|
||||
},
|
||||
],
|
||||
[
|
||||
'attempts',
|
||||
(db: Database.Database, count: number) => {
|
||||
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 <= count; index += 1)
|
||||
insert.run(`attempt-${index}`, t1, t2, t1);
|
||||
})();
|
||||
},
|
||||
],
|
||||
])('bounds hydrated child %s at 1000 rows per selected job', (_name, populate) => {
|
||||
const atBound = setup();
|
||||
insertJob(atBound.db);
|
||||
populate(atBound.db, 1000);
|
||||
expect(atBound.subject.get('job-1')).toBeDefined();
|
||||
|
||||
const overBound = setup();
|
||||
insertJob(overBound.db);
|
||||
populate(overBound.db, 1001);
|
||||
expectCode(() => overBound.subject.get('job-1'), 'UNREPRESENTABLE');
|
||||
});
|
||||
|
||||
it('emits only a closed redacted item error and omits malformed optional errors', () => {
|
||||
const { db, subject } = setup();
|
||||
insertJob(db);
|
||||
insertItem(db, {
|
||||
id: 'good',
|
||||
instance: 'instance-good',
|
||||
error: JSON.stringify({
|
||||
type: 'https://evil/secret',
|
||||
title: 'database password leaked',
|
||||
status: 502,
|
||||
detail: 'password=secret',
|
||||
code: 'UPSTREAM_FAILED',
|
||||
requestId: 'req-safe',
|
||||
extra: 'secret',
|
||||
}),
|
||||
});
|
||||
insertItem(db, { id: 'bad', instance: 'instance-bad', error: '{bad json' });
|
||||
expect(subject.get('job-1').items).toEqual([
|
||||
{ id: 'bad', targetId: 'instance-bad', state: 'succeeded' },
|
||||
{
|
||||
id: 'good',
|
||||
targetId: 'instance-good',
|
||||
state: 'succeeded',
|
||||
error: {
|
||||
type: 'about:blank',
|
||||
title: 'Job item failed',
|
||||
status: 502,
|
||||
detail: 'The job item did not complete successfully.',
|
||||
code: 'UPSTREAM_FAILED',
|
||||
requestId: 'req-safe',
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user