Files
multi-simadmin/apps/api/src/application/jobs/job-reconcile-service.test.ts
T
chick f9186bd851 feat(api): absorb the Hub control plane into the local instance model
Add central notification channels, rules, queue and delivery logs, fleet
organization groups and tags, device discovery, the device action catalog,
instance module reads, the log centre, connection settings and system
maintenance as native /api/v1 routes backed by the existing secret store,
audit trail and pinned upstream transport.
2026-09-05 18:53:04 +08:00

109 lines
4.1 KiB
TypeScript

import Database from 'better-sqlite3';
import { describe, expect, it } from 'vitest';
import { migrateDatabase } from '../../infrastructure/database/migrations.js';
import { DEFAULT_RECONCILE_GRACE_MS, JobReconcileService } from './job-reconcile-service.js';
const NOW = new Date('2026-07-20T12:00:00.000Z');
const minutesAgo = (minutes: number): string =>
new Date(NOW.getTime() - minutes * 60_000).toISOString();
function setup(): { db: Database.Database; subject: JobReconcileService } {
const db = new Database(':memory:');
migrateDatabase(db);
return {
db,
subject: new JobReconcileService({ db, now: () => NOW }),
};
}
function insertJob(db: Database.Database, id: string, status: string, createdAt: string): 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 (?, ?, 'op.one', 'R2', ?, 'actor', 'request-1', 'digest', ?, ?)`,
).run(id, id, status, createdAt, createdAt);
db.prepare(
`INSERT INTO job_items
(id, job_id, instance_id, attempt_number, status, created_at, updated_at)
VALUES (?, ?, 'instance-1', 1, ?, ?, ?)`,
).run(`${id}-item`, id, status === 'queued' ? 'queued' : 'running', createdAt, createdAt);
if (status !== 'queued') {
db.prepare(
`INSERT INTO job_attempts (id, job_id, status, started_at, created_at)
VALUES (?, ?, 'running', ?, ?)`,
).run(`${id}-attempt`, id, createdAt, createdAt);
}
}
const rows = (db: Database.Database, table: string): Array<Record<string, unknown>> =>
db.prepare(`SELECT * FROM ${table}`).all() as Array<Record<string, unknown>>;
describe('JobReconcileService', () => {
it('closes dispatched jobs that can no longer finish', () => {
const { db, subject } = setup();
insertJob(db, 'job-running', 'running', minutesAgo(60));
expect(subject.summary().pending).toBe(1);
expect(subject.reconcile()).toEqual({ interrupted: 1, pending: 0 });
expect(rows(db, 'jobs')[0]).toMatchObject({
id: 'job-running',
status: 'unknown-result',
finished_at: NOW.toISOString(),
});
expect(rows(db, 'job_items')[0]).toMatchObject({
status: 'unknown-result',
result_code: 'INTERRUPTED',
});
expect(rows(db, 'job_attempts')[0]).toMatchObject({
status: 'unknown-result',
finished_at: NOW.toISOString(),
});
});
it('cancels queued jobs that never reached a transport', () => {
const { db, subject } = setup();
insertJob(db, 'job-queued', 'queued', minutesAgo(30));
expect(subject.reconcile()).toEqual({ interrupted: 1, pending: 0 });
expect(rows(db, 'jobs')[0]).toMatchObject({ status: 'cancelled' });
expect(rows(db, 'job_items')[0]).toMatchObject({ result_code: 'NEVER_DISPATCHED' });
});
it('leaves jobs inside the grace window alone', () => {
const { db, subject } = setup();
insertJob(db, 'job-fresh', 'running', minutesAgo(2));
insertJob(db, 'job-old', 'cancelling', minutesAgo(DEFAULT_RECONCILE_GRACE_MS + 1));
expect(subject.reconcile()).toEqual({ interrupted: 1, pending: 1 });
const byId = new Map(rows(db, 'jobs').map((row) => [row.id, row.status]));
expect(byId.get('job-fresh')).toBe('running');
expect(byId.get('job-old')).toBe('unknown-result');
});
it('reports the oldest pending job so the console can warn about it', () => {
const { db, subject } = setup();
insertJob(db, 'job-newer', 'running', minutesAgo(20));
insertJob(db, 'job-older', 'running', minutesAgo(40));
insertJob(db, 'job-done', 'succeeded', minutesAgo(90));
expect(subject.summary()).toEqual({
pending: 2,
dispatched: 2,
queued: 0,
oldestCreatedAt: minutesAgo(40),
});
});
it('is idempotent and rejects a negative window', () => {
const { db, subject } = setup();
insertJob(db, 'job-once', 'running', minutesAgo(60));
expect(subject.reconcile(0).interrupted).toBe(1);
expect(subject.reconcile(0)).toEqual({ interrupted: 0, pending: 0 });
expect(() => subject.reconcile(-1)).toThrow(RangeError);
});
});