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 = {}) { 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().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 { 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((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'); }); });