Files
arena-plm-fiori/worker/test/server.test.ts
T
2026-09-16 01:47:28 -07:00

549 lines
20 KiB
TypeScript

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');
});
});