upload current sources
This commit is contained in:
@@ -0,0 +1,548 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { FastifyInstance, LightMyRequestResponse } from 'fastify';
|
||||
import { ArenaApiError, ArenaSessionManager } from '../src/arena/session.js';
|
||||
import { loadConfig } from '../src/config.js';
|
||||
import { UNCLASSIFIED_ERROR_PHRASE } from '../src/errors/publicPhrase.js';
|
||||
import { HealthRegistry } from '../src/health/registry.js';
|
||||
import type { HttpClient, HttpResponse } from '../src/http/types.js';
|
||||
import { InMemoryEventQueue } from '../src/queue/webhookQueue.js';
|
||||
import { buildServer } from '../src/server.js';
|
||||
import { makeNoopExecutors } from '../src/sync/executors.js';
|
||||
import { InMemoryRunStore } from '../src/sync/runStore.js';
|
||||
import { SyncRunner } from '../src/sync/runner.js';
|
||||
import type { SyncRun } from '../src/sync/types.js';
|
||||
|
||||
const TOKEN = 'test-token';
|
||||
const AUTH = { authorization: `Bearer ${TOKEN}` };
|
||||
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
|
||||
let apps: FastifyInstance[] = [];
|
||||
|
||||
interface HealthBody {
|
||||
status: 'ok' | 'degraded';
|
||||
version: string;
|
||||
uptimeSeconds: number;
|
||||
arenaSession: { state: string };
|
||||
poller?: { lastRunAt?: string; lastSuccessAt?: string; lastError?: string };
|
||||
}
|
||||
|
||||
function makeApp(envOverrides: Record<string, string> = {}) {
|
||||
const config = loadConfig({ WORKER_BEARER_TOKEN: TOKEN, ...envOverrides });
|
||||
const health = new HealthRegistry();
|
||||
const queue = new InMemoryEventQueue({ maxSize: config.webhookQueueMaxSize });
|
||||
const runStore = new InMemoryRunStore();
|
||||
const runner = new SyncRunner(runStore, makeNoopExecutors());
|
||||
// Arena transport is a mock: tests never touch the network. Default answer
|
||||
// rejects so an unexpected Arena call fails loudly.
|
||||
const request = vi.fn<HttpClient['request']>().mockRejectedValue(new Error('no network calls in tests'));
|
||||
const clock = { nowMs: 1_000_000 };
|
||||
const arenaSession = new ArenaSessionManager({
|
||||
http: { request },
|
||||
baseUrl: 'https://arena.invalid/v1',
|
||||
email: 'worker@example.test',
|
||||
password: 'secret',
|
||||
sessionTtlMs: config.arena.sessionTtlMs,
|
||||
now: () => clock.nowMs,
|
||||
health,
|
||||
});
|
||||
const app = buildServer({ config, queue, runner, runStore, arenaSession, health });
|
||||
apps.push(app);
|
||||
return { app, queue, runStore, runner, arenaSession, health, request, clock, config };
|
||||
}
|
||||
|
||||
function loginOk(sessionId: string): HttpResponse {
|
||||
return { status: 200, body: { arenaSessionId: sessionId } };
|
||||
}
|
||||
|
||||
async function getHealth(app: FastifyInstance): Promise<HealthBody> {
|
||||
const res = await app.inject({ method: 'GET', url: '/health' });
|
||||
expect(res.statusCode).toBe(200);
|
||||
return res.json() as HealthBody;
|
||||
}
|
||||
|
||||
/** Contract: every 4xx/5xx body is RFC 9457 application/problem+json. */
|
||||
function expectProblem(res: LightMyRequestResponse, status: number, title: string): void {
|
||||
expect(res.statusCode).toBe(status);
|
||||
expect(res.headers['content-type']).toMatch(/^application\/problem\+json/);
|
||||
const body = res.json() as { title: string; status: number; detail?: string };
|
||||
expect(body.title).toBe(title);
|
||||
expect(body.status).toBe(status);
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(apps.map((app) => app.close()));
|
||||
apps = [];
|
||||
});
|
||||
|
||||
describe('GET /health', () => {
|
||||
it('returns 200 with status, version, uptime, and arena session state — no auth required', async () => {
|
||||
const { app } = makeApp();
|
||||
const body = await getHealth(app);
|
||||
|
||||
expect(body.status).toBe('ok');
|
||||
expect(typeof body.version).toBe('string');
|
||||
expect(body.version).not.toBe('0.0.0');
|
||||
expect(typeof body.uptimeSeconds).toBe('number');
|
||||
expect(body.arenaSession).toEqual({ state: 'none' });
|
||||
// Contract: `poller` is absent until the poller has ticked at least once.
|
||||
expect(body).not.toHaveProperty('poller');
|
||||
});
|
||||
|
||||
describe('arenaSession.state pins every contract enum value', () => {
|
||||
it('"none" before any login', async () => {
|
||||
const { app } = makeApp();
|
||||
expect((await getHealth(app)).arenaSession.state).toBe('none');
|
||||
});
|
||||
|
||||
it('"active" while the cached session is within its TTL', async () => {
|
||||
const { app, arenaSession, request } = makeApp();
|
||||
request.mockResolvedValueOnce(loginOk('sess-1'));
|
||||
await arenaSession.getSessionId();
|
||||
expect((await getHealth(app)).arenaSession.state).toBe('active');
|
||||
});
|
||||
|
||||
it('"expired" once the cached session is past its TTL', async () => {
|
||||
const { app, arenaSession, request, clock, config } = makeApp();
|
||||
request.mockResolvedValueOnce(loginOk('sess-1'));
|
||||
await arenaSession.getSessionId();
|
||||
clock.nowMs += config.arena.sessionTtlMs;
|
||||
expect((await getHealth(app)).arenaSession.state).toBe('expired');
|
||||
});
|
||||
|
||||
it('"refreshing" while a login is in flight', async () => {
|
||||
const { app, arenaSession, request } = makeApp();
|
||||
let resolveLogin!: (res: HttpResponse) => void;
|
||||
request.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<HttpResponse>((resolve) => {
|
||||
resolveLogin = resolve;
|
||||
}),
|
||||
);
|
||||
const pending = arenaSession.getSessionId();
|
||||
expect((await getHealth(app)).arenaSession.state).toBe('refreshing');
|
||||
|
||||
resolveLogin(loginOk('sess-1'));
|
||||
await pending;
|
||||
expect((await getHealth(app)).arenaSession.state).toBe('active');
|
||||
});
|
||||
});
|
||||
|
||||
describe('status pins both contract enum values', () => {
|
||||
it('"degraded" after a failed Arena login, "ok" again after a successful one', async () => {
|
||||
const { app, arenaSession, request } = makeApp();
|
||||
request.mockResolvedValueOnce({ status: 503, body: 'down' });
|
||||
await expect(arenaSession.getSessionId()).rejects.toBeInstanceOf(ArenaApiError);
|
||||
expect((await getHealth(app)).status).toBe('degraded');
|
||||
|
||||
request.mockResolvedValueOnce(loginOk('sess-1'));
|
||||
await arenaSession.getSessionId();
|
||||
expect((await getHealth(app)).status).toBe('ok');
|
||||
});
|
||||
|
||||
it('"degraded" after a failed poll tick with the poller object populated, "ok" after a successful one', async () => {
|
||||
const { app, health } = makeApp();
|
||||
const t1 = new Date('2026-09-14T08:15:00.000Z');
|
||||
health.recordPollFailure(t1, new ArenaApiError('Arena login failed with HTTP 503', 503, 'down'));
|
||||
|
||||
const degraded = await getHealth(app);
|
||||
expect(degraded.status).toBe('degraded');
|
||||
expect(degraded.poller).toEqual({
|
||||
lastRunAt: '2026-09-14T08:15:00.000Z',
|
||||
lastError: 'Upstream Arena PLM request failed (HTTP 503).',
|
||||
});
|
||||
|
||||
const t2 = new Date('2026-09-14T08:20:00.000Z');
|
||||
health.recordPollSuccess(t2);
|
||||
const ok = await getHealth(app);
|
||||
expect(ok.status).toBe('ok');
|
||||
expect(ok.poller).toEqual({
|
||||
lastRunAt: '2026-09-14T08:20:00.000Z',
|
||||
lastSuccessAt: '2026-09-14T08:20:00.000Z',
|
||||
});
|
||||
});
|
||||
|
||||
it('a failed tick keeps the previous lastSuccessAt', async () => {
|
||||
const { app, health } = makeApp();
|
||||
health.recordPollSuccess(new Date('2026-09-14T08:15:00.000Z'));
|
||||
health.recordPollFailure(new Date('2026-09-14T08:20:00.000Z'), 'boom');
|
||||
const body = await getHealth(app);
|
||||
expect(body.status).toBe('degraded');
|
||||
expect(body.poller).toEqual({
|
||||
lastRunAt: '2026-09-14T08:20:00.000Z',
|
||||
lastSuccessAt: '2026-09-14T08:15:00.000Z',
|
||||
lastError: UNCLASSIFIED_ERROR_PHRASE,
|
||||
});
|
||||
});
|
||||
|
||||
it('B5: lastError is a fixed phrase — an ArenaApiError body/message marker never reaches /health', async () => {
|
||||
const { app, health } = makeApp();
|
||||
health.recordPollFailure(
|
||||
new Date('2026-09-14T08:20:00.000Z'),
|
||||
new ArenaApiError('Arena login failed with HTTP 429 MARKER-MESSAGE', 429, {
|
||||
errors: [{ message: 'MARKER-BODY worker@example.test' }],
|
||||
}),
|
||||
);
|
||||
const res = await app.inject({ method: 'GET', url: '/health' });
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.body).not.toContain('MARKER-MESSAGE');
|
||||
expect(res.body).not.toContain('MARKER-BODY');
|
||||
expect(res.body).not.toContain('worker@example.test');
|
||||
expect((res.json() as HealthBody).poller?.lastError).toBe('Upstream Arena PLM request failed (HTTP 429).');
|
||||
});
|
||||
|
||||
it('B5: a fetch TimeoutError maps to the timeout phrase', async () => {
|
||||
const { app, health } = makeApp();
|
||||
health.recordPollFailure(new Date(), new DOMException('The operation was aborted due to timeout', 'TimeoutError'));
|
||||
expect((await getHealth(app)).poller?.lastError).toBe('Upstream Arena PLM request timed out.');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /webhooks/arena', () => {
|
||||
it('rejects requests without a bearer token with 401 problem+json', async () => {
|
||||
const { app, queue } = makeApp();
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/webhooks/arena',
|
||||
payload: { event: 'ITEM.UPDATED' },
|
||||
});
|
||||
|
||||
expectProblem(res, 401, 'Unauthorized');
|
||||
expect(queue.size).toBe(0);
|
||||
});
|
||||
|
||||
it('rejects requests with a wrong token with 401 problem+json', async () => {
|
||||
const { app, queue } = makeApp();
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/webhooks/arena',
|
||||
headers: { authorization: 'Bearer wrong-token' },
|
||||
payload: { event: 'ITEM.UPDATED' },
|
||||
});
|
||||
|
||||
expectProblem(res, 401, 'Unauthorized');
|
||||
expect(queue.size).toBe(0);
|
||||
});
|
||||
|
||||
it('accepts an authenticated JSON event with 202 and enqueues the raw payload', async () => {
|
||||
const { app, queue } = makeApp();
|
||||
const payload = { event: 'ITEM.UPDATED', itemNumber: '900-00001' };
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/webhooks/arena',
|
||||
headers: AUTH,
|
||||
payload,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(202);
|
||||
expect(res.json()).toEqual({ received: true });
|
||||
expect(queue.size).toBe(1);
|
||||
const queued = queue.peekAll()[0];
|
||||
expect(queued?.source).toBe('webhook');
|
||||
expect(queued?.payload).toEqual(payload);
|
||||
expect(queued?.id).toMatch(UUID_RE);
|
||||
});
|
||||
|
||||
it('answers 503 problem+json with Retry-After: 30 when the ingest buffer is full, without queuing', async () => {
|
||||
const { app, queue } = makeApp({ WEBHOOK_QUEUE_MAX_SIZE: '1' });
|
||||
const first = await app.inject({ method: 'POST', url: '/webhooks/arena', headers: AUTH, payload: { n: 1 } });
|
||||
expect(first.statusCode).toBe(202);
|
||||
|
||||
const second = await app.inject({ method: 'POST', url: '/webhooks/arena', headers: AUTH, payload: { n: 2 } });
|
||||
expectProblem(second, 503, 'Service Unavailable');
|
||||
expect(second.headers['retry-after']).toBe('30');
|
||||
expect((second.json() as { detail?: string }).detail).toMatch(/queue is full/i);
|
||||
expect(queue.size).toBe(1);
|
||||
expect(queue.peekAll()[0]?.payload).toEqual({ n: 1 });
|
||||
|
||||
// Backpressure clears once the buffer drains.
|
||||
queue.dequeue();
|
||||
const third = await app.inject({ method: 'POST', url: '/webhooks/arena', headers: AUTH, payload: { n: 3 } });
|
||||
expect(third.statusCode).toBe(202);
|
||||
});
|
||||
|
||||
it('rejects invalid JSON with 400 problem+json', async () => {
|
||||
const { app, queue } = makeApp();
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/webhooks/arena',
|
||||
headers: { ...AUTH, 'content-type': 'application/json' },
|
||||
payload: '{not-json',
|
||||
});
|
||||
|
||||
expectProblem(res, 400, 'Bad Request');
|
||||
expect(queue.size).toBe(0);
|
||||
});
|
||||
|
||||
it('rejects non-object JSON bodies with 400 problem+json', async () => {
|
||||
const { app, queue } = makeApp();
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/webhooks/arena',
|
||||
headers: { ...AUTH, 'content-type': 'application/json' },
|
||||
payload: '[1,2,3]',
|
||||
});
|
||||
|
||||
expectProblem(res, 400, 'Bad Request');
|
||||
expect(queue.size).toBe(0);
|
||||
});
|
||||
|
||||
it('fails closed with 401 when no token is configured', async () => {
|
||||
const { app } = makeApp({ WORKER_BEARER_TOKEN: '' });
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/webhooks/arena',
|
||||
headers: AUTH,
|
||||
payload: { event: 'ITEM.UPDATED' },
|
||||
});
|
||||
|
||||
expectProblem(res, 401, 'Unauthorized');
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /admin/sync-runs', () => {
|
||||
it('requires the bearer token', async () => {
|
||||
const { app } = makeApp();
|
||||
const res = await app.inject({ method: 'POST', url: '/admin/sync-runs', payload: {} });
|
||||
expectProblem(res, 401, 'Unauthorized');
|
||||
});
|
||||
|
||||
it('creates a queued run with the config-default object order when objects is absent', async () => {
|
||||
const { app } = makeApp();
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/admin/sync-runs',
|
||||
headers: AUTH,
|
||||
payload: { scope: 'delta' },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(201);
|
||||
const run = res.json() as SyncRun;
|
||||
expect(run.runId).toMatch(UUID_RE);
|
||||
expect(run.scope).toBe('delta');
|
||||
expect(run.state).toBe('queued');
|
||||
// Order is carried by the objects array itself; no objectOrder on the wire.
|
||||
expect(run).not.toHaveProperty('objectOrder');
|
||||
expect(run.objects.map((o) => o.objectType)).toEqual(['PRODUCT', 'CHANGE_MASTER', 'BOM', 'DOCUMENTS']);
|
||||
expect(run.objects.every((o) => o.state === 'pending')).toBe(true);
|
||||
// B4: no `processed` counter until the object has run.
|
||||
expect(run.objects.every((o) => !('processed' in o))).toBe(true);
|
||||
expect(Number.isNaN(Date.parse(run.startedAt))).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects a missing scope with 400 problem+json (scope is required)', async () => {
|
||||
const { app } = makeApp();
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/admin/sync-runs',
|
||||
headers: AUTH,
|
||||
payload: {},
|
||||
});
|
||||
|
||||
expectProblem(res, 400, 'Bad Request');
|
||||
expect((res.json() as { detail?: string }).detail).toMatch(/scope/);
|
||||
});
|
||||
|
||||
it('accepts an explicit full scope and rejects unknown scopes with 400 problem+json', async () => {
|
||||
const { app } = makeApp();
|
||||
|
||||
const ok = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/admin/sync-runs',
|
||||
headers: AUTH,
|
||||
payload: { scope: 'full' },
|
||||
});
|
||||
expect(ok.statusCode).toBe(201);
|
||||
expect((ok.json() as SyncRun).scope).toBe('full');
|
||||
|
||||
const bad = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/admin/sync-runs',
|
||||
headers: AUTH,
|
||||
payload: { scope: 'bogus' },
|
||||
});
|
||||
expectProblem(bad, 400, 'Bad Request');
|
||||
});
|
||||
|
||||
it('lets the request objects array select AND order the object types for the run', async () => {
|
||||
const { app } = makeApp();
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/admin/sync-runs',
|
||||
headers: AUTH,
|
||||
payload: { scope: 'full', objects: ['BOM', 'PRODUCT'] },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(201);
|
||||
const run = res.json() as SyncRun;
|
||||
expect(run.objects.map((o) => o.objectType)).toEqual(['BOM', 'PRODUCT']);
|
||||
});
|
||||
|
||||
it('rejects empty, duplicate, or unknown objects entries with 400 problem+json', async () => {
|
||||
const { app } = makeApp();
|
||||
|
||||
const empty = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/admin/sync-runs',
|
||||
headers: AUTH,
|
||||
payload: { scope: 'full', objects: [] },
|
||||
});
|
||||
expectProblem(empty, 400, 'Bad Request');
|
||||
|
||||
const dupes = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/admin/sync-runs',
|
||||
headers: AUTH,
|
||||
payload: { scope: 'full', objects: ['BOM', 'BOM'] },
|
||||
});
|
||||
expectProblem(dupes, 400, 'Bad Request');
|
||||
|
||||
const unknown = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/admin/sync-runs',
|
||||
headers: AUTH,
|
||||
payload: { scope: 'full', objects: ['WIDGET'] },
|
||||
});
|
||||
expectProblem(unknown, 400, 'Bad Request');
|
||||
});
|
||||
|
||||
describe('strict validation (no coercion, no stripping, no default injection)', () => {
|
||||
it('rejects an unknown body property with 400 problem+json (no silent stripping)', async () => {
|
||||
const { app, runStore } = makeApp();
|
||||
// Typo'd `object` instead of `objects` must NOT silently fall back to the
|
||||
// default order — additionalProperties: false means 400 (seam ruling).
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/admin/sync-runs',
|
||||
headers: AUTH,
|
||||
payload: { scope: 'full', object: ['BOM'] },
|
||||
});
|
||||
|
||||
expectProblem(res, 400, 'Bad Request');
|
||||
expect((res.json() as { detail?: string }).detail).toMatch(/additional propert/);
|
||||
// Nothing was created for the rejected request.
|
||||
expect(runStore.get('__none__')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('rejects {"scope":"full","x":1} with 400 problem+json', async () => {
|
||||
const { app } = makeApp();
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/admin/sync-runs',
|
||||
headers: AUTH,
|
||||
payload: { scope: 'full', x: 1 },
|
||||
});
|
||||
expectProblem(res, 400, 'Bad Request');
|
||||
});
|
||||
|
||||
it('rejects {"scope":["full"]} with 400 — a one-element array is NOT unwrapped to a string', async () => {
|
||||
const { app } = makeApp();
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/admin/sync-runs',
|
||||
headers: AUTH,
|
||||
payload: { scope: ['full'] },
|
||||
});
|
||||
expectProblem(res, 400, 'Bad Request');
|
||||
expect((res.json() as { detail?: string }).detail).toMatch(/scope/);
|
||||
});
|
||||
|
||||
it('rejects {"scope":"full","objects":"BOM"} with 400 — a scalar is NOT wrapped into an array', async () => {
|
||||
const { app } = makeApp();
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/admin/sync-runs',
|
||||
headers: AUTH,
|
||||
payload: { scope: 'full', objects: 'BOM' },
|
||||
});
|
||||
expectProblem(res, 400, 'Bad Request');
|
||||
expect((res.json() as { detail?: string }).detail).toMatch(/objects/);
|
||||
});
|
||||
});
|
||||
|
||||
it('honors a config-driven sync object order when the request omits objects (ARN-001)', async () => {
|
||||
const { app } = makeApp({ SYNC_OBJECT_ORDER: 'bom, product' });
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/admin/sync-runs',
|
||||
headers: AUTH,
|
||||
payload: { scope: 'delta' },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(201);
|
||||
expect((res.json() as SyncRun).objects.map((o) => o.objectType)).toEqual(['BOM', 'PRODUCT']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /admin/sync-runs/:runId', () => {
|
||||
it('requires the bearer token', async () => {
|
||||
const { app } = makeApp();
|
||||
const res = await app.inject({ method: 'GET', url: '/admin/sync-runs/whatever' });
|
||||
expectProblem(res, 401, 'Unauthorized');
|
||||
});
|
||||
|
||||
it('returns 404 problem+json for an unknown run id', async () => {
|
||||
const { app } = makeApp();
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/admin/sync-runs/00000000-0000-4000-8000-000000000000',
|
||||
headers: AUTH,
|
||||
});
|
||||
|
||||
expectProblem(res, 404, 'Not Found');
|
||||
expect((res.json() as { detail?: string }).detail).toMatch(/00000000-0000-4000-8000-000000000000/);
|
||||
});
|
||||
|
||||
it('returns the run after creation and reflects completion by the no-op executors', async () => {
|
||||
const { app, runner } = makeApp();
|
||||
const created = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/admin/sync-runs',
|
||||
headers: AUTH,
|
||||
payload: { scope: 'delta' },
|
||||
});
|
||||
const { runId } = created.json() as SyncRun;
|
||||
|
||||
await runner.waitFor(runId);
|
||||
|
||||
const res = await app.inject({ method: 'GET', url: `/admin/sync-runs/${runId}`, headers: AUTH });
|
||||
expect(res.statusCode).toBe(200);
|
||||
const run = res.json() as SyncRun;
|
||||
expect(run.runId).toBe(runId);
|
||||
expect(run.state).toBe('completed');
|
||||
expect(run).not.toHaveProperty('objectOrder');
|
||||
expect(run.objects.every((o) => o.state === 'completed')).toBe(true);
|
||||
expect(run.finishedAt).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('error handler', () => {
|
||||
it('maps an ArenaApiError to 502 Bad Gateway problem+json without leaking the upstream body', async () => {
|
||||
const { app } = makeApp();
|
||||
// No production route calls Arena synchronously yet (contract: reserved
|
||||
// BadGateway mapping); a throwaway route exercises the handler seam.
|
||||
app.get('/__test/upstream', async () => {
|
||||
throw new ArenaApiError('Arena login failed with HTTP 500', 500, {
|
||||
errors: [{ message: 'LEAKED-UPSTREAM-BODY worker@example.test' }],
|
||||
});
|
||||
});
|
||||
|
||||
const res = await app.inject({ method: 'GET', url: '/__test/upstream' });
|
||||
|
||||
expectProblem(res, 502, 'Bad Gateway');
|
||||
expect((res.json() as { detail?: string }).detail).toBe('Upstream Arena PLM request failed.');
|
||||
expect(res.body).not.toContain('LEAKED-UPSTREAM-BODY');
|
||||
expect(res.body).not.toContain('HTTP 500');
|
||||
});
|
||||
|
||||
it('still maps unknown errors to 500 problem+json without details', async () => {
|
||||
const { app } = makeApp();
|
||||
app.get('/__test/boom', async () => {
|
||||
throw new Error('internal secret detail');
|
||||
});
|
||||
|
||||
const res = await app.inject({ method: 'GET', url: '/__test/boom' });
|
||||
expectProblem(res, 500, 'Internal Server Error');
|
||||
expect(res.body).not.toContain('internal secret detail');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user