upload current sources
This commit is contained in:
@@ -0,0 +1,134 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { HealthRegistry } from '../src/health/registry.js';
|
||||
import { IngestBufferFullError, makeArenaDeltaPoll } from '../src/poller/arenaDeltaPoll.js';
|
||||
import { DeltaPoller } from '../src/poller/deltaPoller.js';
|
||||
import { InMemoryEventQueue } from '../src/queue/webhookQueue.js';
|
||||
|
||||
describe('makeArenaDeltaPoll', () => {
|
||||
it('fetches changes since the cursor and enqueues each as a poll-sourced event', async () => {
|
||||
const queue = new InMemoryEventQueue({ maxSize: 10 });
|
||||
const fetchChangesSince = vi.fn().mockResolvedValue([{ id: 'c1' }, { id: 'c2' }]);
|
||||
const warn = vi.fn();
|
||||
const poll = makeArenaDeltaPoll({ client: { fetchChangesSince }, queue, log: { warn } });
|
||||
|
||||
const since = new Date('2026-09-14T08:00:00Z');
|
||||
await poll(since);
|
||||
|
||||
expect(fetchChangesSince).toHaveBeenCalledWith(since);
|
||||
expect(queue.peekAll().map((e) => [e.source, e.payload])).toEqual([
|
||||
['poll', { id: 'c1' }],
|
||||
['poll', { id: 'c2' }],
|
||||
]);
|
||||
expect(warn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('fails the tick WITHOUT calling Arena when the buffer is already full (B1)', async () => {
|
||||
const queue = new InMemoryEventQueue({ maxSize: 1 });
|
||||
queue.enqueue({ source: 'webhook', payload: { n: 1 } });
|
||||
const fetchChangesSince = vi.fn().mockResolvedValue([{ id: 'c1' }]);
|
||||
const warn = vi.fn();
|
||||
const poll = makeArenaDeltaPoll({ client: { fetchChangesSince }, queue, log: { warn } });
|
||||
|
||||
await expect(poll(new Date(0))).rejects.toBeInstanceOf(IngestBufferFullError);
|
||||
|
||||
expect(fetchChangesSince).not.toHaveBeenCalled();
|
||||
expect(queue.size).toBe(1);
|
||||
expect(warn).toHaveBeenCalledTimes(1);
|
||||
expect(warn.mock.calls[0]?.[1]).toMatch(/queue full/);
|
||||
});
|
||||
|
||||
it('fails the tick after a partial enqueue when the buffer fills mid-poll (B1)', async () => {
|
||||
const queue = new InMemoryEventQueue({ maxSize: 1 });
|
||||
const fetchChangesSince = vi.fn().mockResolvedValue([{ id: 'c1' }, { id: 'c2' }, { id: 'c3' }]);
|
||||
const warn = vi.fn();
|
||||
const poll = makeArenaDeltaPoll({ client: { fetchChangesSince }, queue, log: { warn } });
|
||||
|
||||
const failure = await poll(new Date(0)).catch((err: unknown) => err);
|
||||
expect(failure).toBeInstanceOf(IngestBufferFullError);
|
||||
expect(failure).toMatchObject({ fetched: 3, dropped: 2 });
|
||||
|
||||
// What fit stays queued; the rest is re-covered by the next tick (same window).
|
||||
expect(queue.size).toBe(1);
|
||||
expect(queue.peekAll()[0]?.payload).toEqual({ id: 'c1' });
|
||||
expect(warn).toHaveBeenCalledTimes(1);
|
||||
expect(warn.mock.calls[0]?.[0]).toMatchObject({ dropped: 2, fetched: 3 });
|
||||
});
|
||||
|
||||
it('propagates a fetch failure so the poller keeps its cursor', async () => {
|
||||
const queue = new InMemoryEventQueue({ maxSize: 1 });
|
||||
const fetchChangesSince = vi.fn().mockRejectedValue(new Error('arena unreachable'));
|
||||
const poll = makeArenaDeltaPoll({ client: { fetchChangesSince }, queue, log: { warn: vi.fn() } });
|
||||
|
||||
await expect(poll(new Date(0))).rejects.toThrow('arena unreachable');
|
||||
expect(queue.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('makeArenaDeltaPoll driven by DeltaPoller (B1 end to end)', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('a partial drop holds the cursor, flips health to degraded, and the next tick re-covers the window', async () => {
|
||||
const queue = new InMemoryEventQueue({ maxSize: 1 });
|
||||
const fetchChangesSince = vi.fn().mockResolvedValue([{ id: 'c1' }, { id: 'c2' }]);
|
||||
const health = new HealthRegistry();
|
||||
const start = new Date('2026-09-14T08:00:00Z');
|
||||
let nowMs = start.getTime();
|
||||
const poller = new DeltaPoller({
|
||||
intervalMs: 1000,
|
||||
poll: makeArenaDeltaPoll({ client: { fetchChangesSince }, queue, log: { warn: vi.fn() } }),
|
||||
health,
|
||||
now: () => new Date(nowMs),
|
||||
});
|
||||
const initialSince = poller.nextSince;
|
||||
|
||||
poller.start();
|
||||
nowMs += 1000;
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
|
||||
expect(fetchChangesSince).toHaveBeenCalledTimes(1);
|
||||
expect(fetchChangesSince).toHaveBeenLastCalledWith(initialSince);
|
||||
expect(poller.nextSince).toEqual(initialSince); // cursor did NOT advance
|
||||
expect(health.status()).toBe('degraded');
|
||||
expect(health.pollerSnapshot()?.lastError).toBe('Ingest buffer full at poll time.');
|
||||
|
||||
// Buffer drains; the next tick re-covers the SAME window and succeeds.
|
||||
queue.dequeue();
|
||||
fetchChangesSince.mockResolvedValue([{ id: 'c2' }]);
|
||||
nowMs += 1000;
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
|
||||
expect(fetchChangesSince).toHaveBeenCalledTimes(2);
|
||||
expect(fetchChangesSince).toHaveBeenLastCalledWith(initialSince);
|
||||
expect(poller.nextSince.getTime()).toBeGreaterThan(initialSince.getTime());
|
||||
expect(health.status()).toBe('ok');
|
||||
|
||||
await poller.stop();
|
||||
});
|
||||
|
||||
it('a full buffer before the fetch never calls Arena and leaves the cursor untouched', async () => {
|
||||
const queue = new InMemoryEventQueue({ maxSize: 1 });
|
||||
queue.enqueue({ source: 'webhook', payload: {} });
|
||||
const fetchChangesSince = vi.fn().mockResolvedValue([]);
|
||||
const health = new HealthRegistry();
|
||||
const poller = new DeltaPoller({
|
||||
intervalMs: 1000,
|
||||
poll: makeArenaDeltaPoll({ client: { fetchChangesSince }, queue, log: { warn: vi.fn() } }),
|
||||
health,
|
||||
});
|
||||
const initialSince = poller.nextSince;
|
||||
|
||||
poller.start();
|
||||
await vi.advanceTimersByTimeAsync(2500);
|
||||
|
||||
expect(fetchChangesSince).not.toHaveBeenCalled();
|
||||
expect(poller.nextSince).toEqual(initialSince);
|
||||
expect(health.status()).toBe('degraded');
|
||||
await poller.stop();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { loadConfig } from '../src/config.js';
|
||||
|
||||
describe('loadConfig', () => {
|
||||
it('applies defaults from an empty environment', () => {
|
||||
const config = loadConfig({});
|
||||
|
||||
expect(config.host).toBe('0.0.0.0');
|
||||
expect(config.port).toBe(8080);
|
||||
expect(config.bearerToken).toBeUndefined();
|
||||
expect(config.arena.baseUrl).toBe('https://api.arenasolutions.com/v1');
|
||||
expect(config.arena.sessionTtlMs).toBe(24 * 60_000); // Arena TTL ~25 min; refresh 1 min early
|
||||
expect(config.arena.httpTimeoutMs).toBe(30_000);
|
||||
expect(config.pollIntervalMs).toBe(300_000);
|
||||
expect(config.syncObjectOrder).toEqual(['PRODUCT', 'CHANGE_MASTER', 'BOM', 'DOCUMENTS']);
|
||||
expect(config.webhookQueueMaxSize).toBe(10_000);
|
||||
expect(config.shutdownTimeoutMs).toBe(30_000);
|
||||
});
|
||||
|
||||
it('parses a custom sync order case-insensitively (ARN-001 config knob)', () => {
|
||||
const config = loadConfig({ SYNC_OBJECT_ORDER: 'bom, product' });
|
||||
expect(config.syncObjectOrder).toEqual(['BOM', 'PRODUCT']);
|
||||
});
|
||||
|
||||
it('rejects unknown sync order entries', () => {
|
||||
expect(() => loadConfig({ SYNC_OBJECT_ORDER: 'PRODUCT,WIDGET' })).toThrow(/WIDGET/);
|
||||
});
|
||||
|
||||
it('rejects duplicate sync order entries (same rule as the HTTP contract uniqueItems)', () => {
|
||||
expect(() => loadConfig({ SYNC_OBJECT_ORDER: 'BOM,BOM' })).toThrow(/duplicate entry "BOM"/);
|
||||
expect(() => loadConfig({ SYNC_OBJECT_ORDER: 'product, bom, Product' })).toThrow(/duplicate entry "PRODUCT"/);
|
||||
});
|
||||
|
||||
it('strips trailing slashes from the Arena base URL and parses numbers', () => {
|
||||
const config = loadConfig({
|
||||
ARENA_API_URL: 'https://api.arenasolutions.com/v1//',
|
||||
ARENA_WORKSPACE_ID: '4242',
|
||||
ARENA_SESSION_TTL_MINUTES: '10',
|
||||
ARENA_HTTP_TIMEOUT_MS: '5000',
|
||||
WORKER_PORT: '9090',
|
||||
WEBHOOK_QUEUE_MAX_SIZE: '25',
|
||||
SHUTDOWN_TIMEOUT_MS: '1500',
|
||||
});
|
||||
|
||||
expect(config.arena.baseUrl).toBe('https://api.arenasolutions.com/v1');
|
||||
expect(config.arena.workspaceId).toBe(4242);
|
||||
expect(config.arena.sessionTtlMs).toBe(600_000);
|
||||
expect(config.arena.httpTimeoutMs).toBe(5000);
|
||||
expect(config.port).toBe(9090);
|
||||
expect(config.webhookQueueMaxSize).toBe(25);
|
||||
expect(config.shutdownTimeoutMs).toBe(1500);
|
||||
});
|
||||
|
||||
it('rejects non-numeric numeric env values', () => {
|
||||
expect(() => loadConfig({ WORKER_PORT: 'abc' })).toThrow(/WORKER_PORT/);
|
||||
});
|
||||
|
||||
it('rejects numeric env values with trailing garbage (whole string must be digits)', () => {
|
||||
expect(() => loadConfig({ WORKER_PORT: '8080abc' })).toThrow(/WORKER_PORT.*"8080abc"/);
|
||||
expect(() => loadConfig({ ARENA_SESSION_TTL_MINUTES: '24m' })).toThrow(/ARENA_SESSION_TTL_MINUTES/);
|
||||
expect(() => loadConfig({ WEBHOOK_QUEUE_MAX_SIZE: '1e4' })).toThrow(/WEBHOOK_QUEUE_MAX_SIZE/);
|
||||
});
|
||||
|
||||
it('rejects zero and negative numeric env values', () => {
|
||||
expect(() => loadConfig({ WEBHOOK_QUEUE_MAX_SIZE: '0' })).toThrow(/WEBHOOK_QUEUE_MAX_SIZE/);
|
||||
expect(() => loadConfig({ SHUTDOWN_TIMEOUT_MS: '-1' })).toThrow(/SHUTDOWN_TIMEOUT_MS/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,892 @@
|
||||
/**
|
||||
* Schema-driven conformance suite: validates the worker's actual responses
|
||||
* against the contract defined in docs/api/worker.openapi.json (OpenAPI 3.1,
|
||||
* JSON Schema 2020-12 dialect). Contract drift becomes a failing test instead
|
||||
* of a review finding.
|
||||
*
|
||||
* Coverage:
|
||||
* 1. Compile all schemas with Ajv 2020 + ajv-formats; assert compilation succeeds.
|
||||
* 2. Test endpoints with required status codes and response bodies against schemas.
|
||||
* 3. Reverse check: the set of response keys documented for every operation
|
||||
* (including `default`, 413, 415) equals the set this suite exercises —
|
||||
* read from the contract, not a hand-kept list.
|
||||
* 4. Validate problem+json content type and that 5xx bodies leak no upstream details.
|
||||
*
|
||||
* [source: brief contract.test.ts v1, verified 2026-09-14]
|
||||
* [source: context7 /fastify/fastify docs/Reference/Errors.md FST_ERR_CTP_BODY_TOO_LARGE (413) / FST_ERR_CTP_INVALID_MEDIA_TYPE (415); docs/Reference/Server.md `bodyLimit`, verified 2026-09-14]
|
||||
*/
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
import Ajv from 'ajv/dist/2020';
|
||||
import addFormats from 'ajv-formats';
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { ArenaApiError, ArenaSessionManager } from '../src/arena/session.js';
|
||||
import { loadConfig } from '../src/config.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}` };
|
||||
|
||||
// Load OpenAPI contract
|
||||
const __dirname = resolve(fileURLToPath(import.meta.url), '..');
|
||||
const contractPath = resolve(__dirname, '../../docs/api/worker.openapi.json');
|
||||
const contract = JSON.parse(readFileSync(contractPath, 'utf-8'));
|
||||
|
||||
// Compile all schemas with Ajv 2020
|
||||
const ajv = new Ajv({ strict: true });
|
||||
addFormats(ajv);
|
||||
|
||||
interface ValidatorMap {
|
||||
[schemaName: string]: (data: unknown) => boolean;
|
||||
}
|
||||
|
||||
const schemas: ValidatorMap = {};
|
||||
let apps: FastifyInstance[] = [];
|
||||
|
||||
function makeApp(envOverrides: Record<string, string> = {}, serverOverrides: { bodyLimit?: number } = {}) {
|
||||
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());
|
||||
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, ...serverOverrides });
|
||||
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) {
|
||||
const res = await app.inject({ method: 'GET', url: '/health' });
|
||||
expect(res.statusCode).toBe(200);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
function validateAgainstSchema(
|
||||
data: unknown,
|
||||
schemaName: string,
|
||||
context: string,
|
||||
): { valid: boolean; error?: string } {
|
||||
const validate = schemas[schemaName];
|
||||
if (!validate) {
|
||||
return { valid: false, error: `Schema '${schemaName}' not compiled` };
|
||||
}
|
||||
const valid = validate(data);
|
||||
if (!valid) {
|
||||
const errors = (validate as { errors?: unknown }).errors;
|
||||
return {
|
||||
valid: false,
|
||||
error: `Validation failed for ${schemaName} at ${context}: ${JSON.stringify(errors)}`,
|
||||
};
|
||||
}
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
/** A JSON object comfortably over a 64-byte body limit. */
|
||||
const OVERSIZED_JSON = JSON.stringify({ scope: 'full', pad: 'x'.repeat(512) });
|
||||
const SMALL_BODY_LIMIT = 64;
|
||||
|
||||
async function expectProblemStatus(
|
||||
res: { statusCode: number; headers: Record<string, unknown>; json: () => unknown; body: string },
|
||||
status: number,
|
||||
title: string,
|
||||
context: string,
|
||||
) {
|
||||
expect(res.statusCode).toBe(status);
|
||||
expect(res.headers['content-type']).toMatch(/application\/problem\+json/);
|
||||
const body = res.json() as { title: string; status: number };
|
||||
expect(body.title).toBe(title);
|
||||
expect(body.status).toBe(status);
|
||||
const validation = validateAgainstSchema(body, 'Problem', context);
|
||||
expect(validation.valid, validation.error).toBe(true);
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(apps.map((app) => app.close()));
|
||||
apps = [];
|
||||
});
|
||||
|
||||
describe('OpenAPI contract conformance', () => {
|
||||
beforeAll(() => {
|
||||
// Compile all schemas from components.schemas.
|
||||
// Register the entire contract with Ajv so cross-schema references (#/components/schemas/*)
|
||||
// can be resolved.
|
||||
if (!contract.components?.schemas) {
|
||||
throw new Error('No schemas found in contract');
|
||||
}
|
||||
|
||||
// Add the contract's schemas to Ajv's schema store for reference resolution
|
||||
for (const [schemaName, schemaObj] of Object.entries(contract.components.schemas)) {
|
||||
ajv.addSchema(schemaObj, `#/components/schemas/${schemaName}`);
|
||||
}
|
||||
|
||||
// Now compile each schema (references will resolve)
|
||||
for (const [schemaName, schemaObj] of Object.entries(contract.components.schemas)) {
|
||||
try {
|
||||
const validate = ajv.compile(schemaObj);
|
||||
schemas[schemaName] = validate;
|
||||
} catch (err) {
|
||||
throw new Error(`Failed to compile schema '${schemaName}': ${err instanceof Error ? err.message : String(err)}`, {
|
||||
cause: err,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
describe('1. Schema compilation succeeds for all components.schemas', () => {
|
||||
it('all schemas compile without error (JSON Schema 2020-12 + ajv-formats)', () => {
|
||||
expect(Object.keys(schemas).length).toBeGreaterThan(0);
|
||||
expect(schemas).toHaveProperty('HealthStatus');
|
||||
expect(schemas).toHaveProperty('ArenaWebhookEvent');
|
||||
expect(schemas).toHaveProperty('WebhookAccepted');
|
||||
expect(schemas).toHaveProperty('SyncRunRequest');
|
||||
expect(schemas).toHaveProperty('SyncRun');
|
||||
expect(schemas).toHaveProperty('SyncObjectProgress');
|
||||
expect(schemas).toHaveProperty('Problem');
|
||||
});
|
||||
|
||||
it('8 core schemas are compiled', () => {
|
||||
expect(Object.keys(schemas).length).toBe(8);
|
||||
expect(schemas).toHaveProperty('SyncObjectType');
|
||||
});
|
||||
});
|
||||
|
||||
describe('2. GET /health — all states validated against HealthStatus schema', () => {
|
||||
it('state="none" before any login', async () => {
|
||||
const { app } = makeApp();
|
||||
const body = await getHealth(app);
|
||||
|
||||
expect(body.arenaSession.state).toBe('none');
|
||||
const validation = validateAgainstSchema(body, 'HealthStatus', 'GET /health (state=none)');
|
||||
expect(validation.valid).toBe(true);
|
||||
});
|
||||
|
||||
it('state="active" while cached session is within TTL', async () => {
|
||||
const { app, arenaSession, request } = makeApp();
|
||||
request.mockResolvedValueOnce(loginOk('sess-1'));
|
||||
await arenaSession.getSessionId();
|
||||
|
||||
const body = await getHealth(app);
|
||||
expect(body.arenaSession.state).toBe('active');
|
||||
const validation = validateAgainstSchema(body, 'HealthStatus', 'GET /health (state=active)');
|
||||
expect(validation.valid).toBe(true);
|
||||
});
|
||||
|
||||
it('state="expired" once cached session is past TTL', async () => {
|
||||
const { app, arenaSession, request, clock, config } = makeApp();
|
||||
request.mockResolvedValueOnce(loginOk('sess-1'));
|
||||
await arenaSession.getSessionId();
|
||||
clock.nowMs += config.arena.sessionTtlMs;
|
||||
|
||||
const body = await getHealth(app);
|
||||
expect(body.arenaSession.state).toBe('expired');
|
||||
const validation = validateAgainstSchema(body, 'HealthStatus', 'GET /health (state=expired)');
|
||||
expect(validation.valid).toBe(true);
|
||||
});
|
||||
|
||||
it('state="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();
|
||||
|
||||
const body = await getHealth(app);
|
||||
expect(body.arenaSession.state).toBe('refreshing');
|
||||
const validation = validateAgainstSchema(body, 'HealthStatus', 'GET /health (state=refreshing)');
|
||||
expect(validation.valid).toBe(true);
|
||||
|
||||
resolveLogin(loginOk('sess-1'));
|
||||
await pending;
|
||||
});
|
||||
|
||||
it('status="ok" when operational', async () => {
|
||||
const { app, arenaSession, request } = makeApp();
|
||||
request.mockResolvedValueOnce(loginOk('sess-1'));
|
||||
await arenaSession.getSessionId();
|
||||
|
||||
const body = await getHealth(app);
|
||||
expect(body.status).toBe('ok');
|
||||
const validation = validateAgainstSchema(body, 'HealthStatus', 'GET /health (status=ok)');
|
||||
expect(validation.valid).toBe(true);
|
||||
});
|
||||
|
||||
it('status="degraded" after failed Arena login', async () => {
|
||||
const { app, arenaSession, request } = makeApp();
|
||||
request.mockResolvedValueOnce({ status: 503, body: 'down' });
|
||||
await expect(arenaSession.getSessionId()).rejects.toBeInstanceOf(ArenaApiError);
|
||||
|
||||
const body = await getHealth(app);
|
||||
expect(body.status).toBe('degraded');
|
||||
const validation = validateAgainstSchema(body, 'HealthStatus', 'GET /health (status=degraded)');
|
||||
expect(validation.valid).toBe(true);
|
||||
});
|
||||
|
||||
it('status="degraded" after failed poll tick', async () => {
|
||||
const { app, health } = makeApp();
|
||||
health.recordPollFailure(new Date('2026-09-14T08:15:00Z'), new Error('Arena login failed with HTTP 503'));
|
||||
|
||||
const body = await getHealth(app);
|
||||
expect(body.status).toBe('degraded');
|
||||
const validation = validateAgainstSchema(body, 'HealthStatus', 'GET /health (status=degraded from poll)');
|
||||
expect(validation.valid).toBe(true);
|
||||
});
|
||||
|
||||
it('poller.lastError is a fixed phrase: an ArenaApiError message/body marker never appears in /health (B5)', async () => {
|
||||
const { app, health } = makeApp();
|
||||
health.recordPollFailure(
|
||||
new Date('2026-09-14T08:15:00Z'),
|
||||
new ArenaApiError('Arena login failed with HTTP 503 MARKER-MESSAGE', 503, {
|
||||
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');
|
||||
const body = res.json();
|
||||
expect(body.poller.lastError).toBe('Upstream Arena PLM request failed (HTTP 503).');
|
||||
const validation = validateAgainstSchema(body, 'HealthStatus', 'GET /health (B5 lastError)');
|
||||
expect(validation.valid).toBe(true);
|
||||
});
|
||||
|
||||
it('default (unmatched method on /health) is 404 problem+json', async () => {
|
||||
const { app } = makeApp();
|
||||
const res = await app.inject({ method: 'DELETE', url: '/health' });
|
||||
await expectProblemStatus(res, 404, 'Not Found', 'DELETE /health (default: unmatched method)');
|
||||
});
|
||||
});
|
||||
|
||||
describe('2. POST /webhooks/arena — all documented response codes with body validation', () => {
|
||||
it('202 Accepted with WebhookAccepted body', async () => {
|
||||
const { app } = makeApp();
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/webhooks/arena',
|
||||
headers: AUTH,
|
||||
payload: { eventType: 'ITEM_MODIFIED', itemNumber: '900-00042' },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(202);
|
||||
expect(res.headers['content-type']).toContain('application/json');
|
||||
const body = res.json();
|
||||
const validation = validateAgainstSchema(body, 'WebhookAccepted', 'POST /webhooks/arena (202)');
|
||||
expect(validation.valid).toBe(true);
|
||||
});
|
||||
|
||||
it('400 Bad Request (invalid JSON)', async () => {
|
||||
const { app } = makeApp();
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/webhooks/arena',
|
||||
headers: { ...AUTH, 'content-type': 'application/json' },
|
||||
payload: '{invalid-json',
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(res.headers['content-type']).toMatch(/application\/problem\+json/);
|
||||
const body = res.json();
|
||||
const validation = validateAgainstSchema(body, 'Problem', 'POST /webhooks/arena (400 invalid JSON)');
|
||||
expect(validation.valid).toBe(true);
|
||||
expect(body.title).toBe('Bad Request');
|
||||
});
|
||||
|
||||
it('400 Bad Request (non-object body)', async () => {
|
||||
const { app } = makeApp();
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/webhooks/arena',
|
||||
headers: { ...AUTH, 'content-type': 'application/json' },
|
||||
payload: '[1,2,3]',
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(res.headers['content-type']).toMatch(/application\/problem\+json/);
|
||||
const body = res.json();
|
||||
const validation = validateAgainstSchema(body, 'Problem', 'POST /webhooks/arena (400 non-object)');
|
||||
expect(validation.valid).toBe(true);
|
||||
});
|
||||
|
||||
it('401 Unauthorized (missing bearer token)', async () => {
|
||||
const { app } = makeApp();
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/webhooks/arena',
|
||||
payload: { event: 'ITEM.UPDATED' },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(401);
|
||||
expect(res.headers['content-type']).toMatch(/application\/problem\+json/);
|
||||
const body = res.json();
|
||||
const validation = validateAgainstSchema(body, 'Problem', 'POST /webhooks/arena (401)');
|
||||
expect(validation.valid).toBe(true);
|
||||
expect(body.title).toBe('Unauthorized');
|
||||
});
|
||||
|
||||
it('401 Unauthorized (wrong bearer token)', async () => {
|
||||
const { app } = makeApp();
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/webhooks/arena',
|
||||
headers: { authorization: 'Bearer wrong-token' },
|
||||
payload: { event: 'ITEM.UPDATED' },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(401);
|
||||
expect(res.headers['content-type']).toMatch(/application\/problem\+json/);
|
||||
const body = res.json();
|
||||
const validation = validateAgainstSchema(body, 'Problem', 'POST /webhooks/arena (401 wrong token)');
|
||||
expect(validation.valid).toBe(true);
|
||||
});
|
||||
|
||||
it('503 Service Unavailable (queue full) with Retry-After header', async () => {
|
||||
const { app } = makeApp({ WEBHOOK_QUEUE_MAX_SIZE: '1' });
|
||||
// Fill queue
|
||||
await app.inject({ method: 'POST', url: '/webhooks/arena', headers: AUTH, payload: { n: 1 } });
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/webhooks/arena',
|
||||
headers: AUTH,
|
||||
payload: { n: 2 },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(503);
|
||||
expect(res.headers['content-type']).toMatch(/application\/problem\+json/);
|
||||
expect(res.headers['retry-after']).toBe('30');
|
||||
const body = res.json();
|
||||
const validation = validateAgainstSchema(body, 'Problem', 'POST /webhooks/arena (503)');
|
||||
expect(validation.valid).toBe(true);
|
||||
expect(body.title).toBe('Service Unavailable');
|
||||
});
|
||||
|
||||
it('415 Unsupported Media Type (text/plain body — built-in parser removed)', async () => {
|
||||
const { app, queue } = makeApp();
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/webhooks/arena',
|
||||
headers: { ...AUTH, 'content-type': 'text/plain' },
|
||||
payload: 'eventType=ITEM_MODIFIED',
|
||||
});
|
||||
await expectProblemStatus(res, 415, 'Unsupported Media Type', 'POST /webhooks/arena (415 text/plain)');
|
||||
expect(queue.size).toBe(0);
|
||||
});
|
||||
|
||||
it('415 Unsupported Media Type (application/xml body)', async () => {
|
||||
const { app, queue } = makeApp();
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/webhooks/arena',
|
||||
headers: { ...AUTH, 'content-type': 'application/xml' },
|
||||
payload: '<event/>',
|
||||
});
|
||||
await expectProblemStatus(res, 415, 'Unsupported Media Type', 'POST /webhooks/arena (415 xml)');
|
||||
expect(queue.size).toBe(0);
|
||||
});
|
||||
|
||||
it('413 Payload Too Large (body over bodyLimit) — nothing queued', async () => {
|
||||
const { app, queue } = makeApp({}, { bodyLimit: SMALL_BODY_LIMIT });
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/webhooks/arena',
|
||||
headers: { ...AUTH, 'content-type': 'application/json' },
|
||||
payload: OVERSIZED_JSON,
|
||||
});
|
||||
await expectProblemStatus(res, 413, 'Payload Too Large', 'POST /webhooks/arena (413)');
|
||||
expect(queue.size).toBe(0);
|
||||
});
|
||||
|
||||
it('default (unmatched method on /webhooks/arena) is 404 problem+json', async () => {
|
||||
const { app } = makeApp();
|
||||
const res = await app.inject({ method: 'GET', url: '/webhooks/arena', headers: AUTH });
|
||||
await expectProblemStatus(res, 404, 'Not Found', 'GET /webhooks/arena (default: unmatched method)');
|
||||
});
|
||||
});
|
||||
|
||||
describe('2. POST /admin/sync-runs — all documented response codes with body validation', () => {
|
||||
it('201 Created with SyncRun body (full scope, default objects order)', async () => {
|
||||
const { app } = makeApp();
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/admin/sync-runs',
|
||||
headers: AUTH,
|
||||
payload: { scope: 'full' },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(201);
|
||||
expect(res.headers['content-type']).toContain('application/json');
|
||||
const body = res.json();
|
||||
const validation = validateAgainstSchema(body, 'SyncRun', 'POST /admin/sync-runs (201)');
|
||||
expect(validation.valid).toBe(true);
|
||||
expect(body.scope).toBe('full');
|
||||
expect(body.state).toBe('queued');
|
||||
});
|
||||
|
||||
it('201 Created with custom objects order', async () => {
|
||||
const { app } = makeApp();
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/admin/sync-runs',
|
||||
headers: AUTH,
|
||||
payload: { scope: 'delta', objects: ['BOM', 'PRODUCT'] },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(201);
|
||||
const body = res.json();
|
||||
const validation = validateAgainstSchema(body, 'SyncRun', 'POST /admin/sync-runs (201 custom order)');
|
||||
expect(validation.valid).toBe(true);
|
||||
expect(body.objects.map((o: { objectType: string }) => o.objectType)).toEqual(['BOM', 'PRODUCT']);
|
||||
});
|
||||
|
||||
it('400 Bad Request (missing scope)', async () => {
|
||||
const { app } = makeApp();
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/admin/sync-runs',
|
||||
headers: AUTH,
|
||||
payload: {},
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(res.headers['content-type']).toMatch(/application\/problem\+json/);
|
||||
const body = res.json();
|
||||
const validation = validateAgainstSchema(body, 'Problem', 'POST /admin/sync-runs (400 missing scope)');
|
||||
expect(validation.valid).toBe(true);
|
||||
});
|
||||
|
||||
it('400 Bad Request (invalid scope)', async () => {
|
||||
const { app } = makeApp();
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/admin/sync-runs',
|
||||
headers: AUTH,
|
||||
payload: { scope: 'bogus' },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
const body = res.json();
|
||||
const validation = validateAgainstSchema(body, 'Problem', 'POST /admin/sync-runs (400 invalid scope)');
|
||||
expect(validation.valid).toBe(true);
|
||||
});
|
||||
|
||||
it('400 Bad Request (empty objects array)', async () => {
|
||||
const { app } = makeApp();
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/admin/sync-runs',
|
||||
headers: AUTH,
|
||||
payload: { scope: 'full', objects: [] },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
const body = res.json();
|
||||
const validation = validateAgainstSchema(body, 'Problem', 'POST /admin/sync-runs (400 empty objects)');
|
||||
expect(validation.valid).toBe(true);
|
||||
});
|
||||
|
||||
it('400 Bad Request (duplicate objects)', async () => {
|
||||
const { app } = makeApp();
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/admin/sync-runs',
|
||||
headers: AUTH,
|
||||
payload: { scope: 'full', objects: ['BOM', 'BOM'] },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
const body = res.json();
|
||||
const validation = validateAgainstSchema(body, 'Problem', 'POST /admin/sync-runs (400 duplicates)');
|
||||
expect(validation.valid).toBe(true);
|
||||
});
|
||||
|
||||
it('400 Bad Request (unknown object type)', async () => {
|
||||
const { app } = makeApp();
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/admin/sync-runs',
|
||||
headers: AUTH,
|
||||
payload: { scope: 'full', objects: ['WIDGET'] },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
const body = res.json();
|
||||
const validation = validateAgainstSchema(body, 'Problem', 'POST /admin/sync-runs (400 unknown type)');
|
||||
expect(validation.valid).toBe(true);
|
||||
});
|
||||
|
||||
it('400 Bad Request (unknown property additionalProperties: false)', async () => {
|
||||
const { app } = makeApp();
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/admin/sync-runs',
|
||||
headers: AUTH,
|
||||
payload: { scope: 'full', unknownProp: 'x' },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
const body = res.json();
|
||||
const validation = validateAgainstSchema(body, 'Problem', 'POST /admin/sync-runs (400 extra property)');
|
||||
expect(validation.valid).toBe(true);
|
||||
});
|
||||
|
||||
it('400 Bad Request (no type coercion: {"scope":["full"]})', async () => {
|
||||
const { app } = makeApp();
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/admin/sync-runs',
|
||||
headers: AUTH,
|
||||
payload: { scope: ['full'] },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
const body = res.json();
|
||||
const validation = validateAgainstSchema(body, 'Problem', 'POST /admin/sync-runs (400 scope as array)');
|
||||
expect(validation.valid).toBe(true);
|
||||
});
|
||||
|
||||
it('400 Bad Request (no type coercion: {"objects":"BOM"})', async () => {
|
||||
const { app } = makeApp();
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/admin/sync-runs',
|
||||
headers: AUTH,
|
||||
payload: { scope: 'full', objects: 'BOM' },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
const body = res.json();
|
||||
const validation = validateAgainstSchema(body, 'Problem', 'POST /admin/sync-runs (400 objects as scalar)');
|
||||
expect(validation.valid).toBe(true);
|
||||
});
|
||||
|
||||
it('401 Unauthorized (missing bearer token)', async () => {
|
||||
const { app } = makeApp();
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/admin/sync-runs',
|
||||
payload: { scope: 'full' },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(401);
|
||||
expect(res.headers['content-type']).toMatch(/application\/problem\+json/);
|
||||
const body = res.json();
|
||||
const validation = validateAgainstSchema(body, 'Problem', 'POST /admin/sync-runs (401)');
|
||||
expect(validation.valid).toBe(true);
|
||||
});
|
||||
|
||||
it('415 Unsupported Media Type (text/plain body) — no run created', async () => {
|
||||
const { app, runner } = makeApp();
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/admin/sync-runs',
|
||||
headers: { ...AUTH, 'content-type': 'text/plain' },
|
||||
payload: 'scope=full',
|
||||
});
|
||||
await expectProblemStatus(res, 415, 'Unsupported Media Type', 'POST /admin/sync-runs (415)');
|
||||
expect(runner.inFlightCount).toBe(0);
|
||||
});
|
||||
|
||||
it('413 Payload Too Large (body over bodyLimit) — no run created', async () => {
|
||||
const { app, runner } = makeApp({}, { bodyLimit: SMALL_BODY_LIMIT });
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/admin/sync-runs',
|
||||
headers: { ...AUTH, 'content-type': 'application/json' },
|
||||
payload: OVERSIZED_JSON,
|
||||
});
|
||||
await expectProblemStatus(res, 413, 'Payload Too Large', 'POST /admin/sync-runs (413)');
|
||||
expect(runner.inFlightCount).toBe(0);
|
||||
});
|
||||
|
||||
it('default (unmatched method on /admin/sync-runs) is 404 problem+json', async () => {
|
||||
const { app } = makeApp();
|
||||
const res = await app.inject({ method: 'DELETE', url: '/admin/sync-runs', headers: AUTH });
|
||||
await expectProblemStatus(res, 404, 'Not Found', 'DELETE /admin/sync-runs (default: unmatched method)');
|
||||
});
|
||||
});
|
||||
|
||||
describe('2. GET /admin/sync-runs/{runId} — all documented response codes with body validation', () => {
|
||||
it('200 OK with SyncRun body', 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);
|
||||
expect(res.headers['content-type']).toContain('application/json');
|
||||
const body = res.json();
|
||||
const validation = validateAgainstSchema(body, 'SyncRun', 'GET /admin/sync-runs/{runId} (200)');
|
||||
expect(validation.valid).toBe(true);
|
||||
expect(body.runId).toBe(runId);
|
||||
});
|
||||
|
||||
it('401 Unauthorized (missing bearer token)', async () => {
|
||||
const { app } = makeApp();
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/admin/sync-runs/00000000-0000-4000-8000-000000000000',
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(401);
|
||||
expect(res.headers['content-type']).toMatch(/application\/problem\+json/);
|
||||
const body = res.json();
|
||||
const validation = validateAgainstSchema(body, 'Problem', 'GET /admin/sync-runs/{runId} (401)');
|
||||
expect(validation.valid).toBe(true);
|
||||
});
|
||||
|
||||
it('404 Not Found with Problem body', async () => {
|
||||
const { app } = makeApp();
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/admin/sync-runs/00000000-0000-4000-8000-000000000000',
|
||||
headers: AUTH,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(404);
|
||||
expect(res.headers['content-type']).toMatch(/application\/problem\+json/);
|
||||
const body = res.json();
|
||||
const validation = validateAgainstSchema(body, 'Problem', 'GET /admin/sync-runs/{runId} (404)');
|
||||
expect(validation.valid).toBe(true);
|
||||
expect(body.title).toBe('Not Found');
|
||||
});
|
||||
|
||||
it('default (unmatched method on /admin/sync-runs/{runId}) is 404 problem+json', async () => {
|
||||
const { app } = makeApp();
|
||||
const res = await app.inject({
|
||||
method: 'PUT',
|
||||
url: '/admin/sync-runs/00000000-0000-4000-8000-000000000000',
|
||||
headers: AUTH,
|
||||
});
|
||||
await expectProblemStatus(res, 404, 'Not Found', 'PUT /admin/sync-runs/{runId} (default: unmatched method)');
|
||||
});
|
||||
|
||||
it('default (unknown path) is 404 problem+json — x-unmatched-routes', async () => {
|
||||
const { app } = makeApp();
|
||||
const res = await app.inject({ method: 'GET', url: '/admin/nope' });
|
||||
await expectProblemStatus(res, 404, 'Not Found', 'GET /admin/nope (x-unmatched-routes)');
|
||||
expect(contract['x-unmatched-routes']).toMatch(/404/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('3. Reverse check: every documented response code is tested', () => {
|
||||
// The set of response keys THIS SUITE exercises per operation. The test
|
||||
// below compares it against the contract, so adding a code to the contract
|
||||
// without a test here fails, and so does a test-only code that is not
|
||||
// documented. `default` is exercised by the unmatched-method/path 404
|
||||
// cases per operation plus the operation-independent 500 case in §4.
|
||||
const exercised: Record<string, string[]> = {
|
||||
'get /health': ['200', 'default'],
|
||||
'post /webhooks/arena': ['202', '400', '401', '413', '415', '503', 'default'],
|
||||
'post /admin/sync-runs': ['201', '400', '401', '413', '415', 'default'],
|
||||
'get /admin/sync-runs/{runId}': ['200', '401', '404', 'default'],
|
||||
};
|
||||
// Reserved (contract-defined, no live route emits it yet): tested at the
|
||||
// error-handler seam in §4, not attached to an operation.
|
||||
const reserved: Record<string, string> = { BadGateway: '502' };
|
||||
|
||||
it('documented response keys per operation equal the exercised set (read from the contract)', () => {
|
||||
const documented: Record<string, string[]> = {};
|
||||
for (const [path, item] of Object.entries(contract.paths as Record<string, Record<string, unknown>>)) {
|
||||
for (const [method, op] of Object.entries(item)) {
|
||||
const responses = (op as { responses: Record<string, unknown> }).responses;
|
||||
documented[`${method} ${path}`] = Object.keys(responses).sort();
|
||||
}
|
||||
}
|
||||
const expected = Object.fromEntries(Object.entries(exercised).map(([k, v]) => [k, [...v].sort()]));
|
||||
expect(documented).toEqual(expected);
|
||||
});
|
||||
|
||||
it('every operation documents a `default` problem+json response and the reserved 502 exists', () => {
|
||||
for (const item of Object.values(contract.paths as Record<string, Record<string, unknown>>)) {
|
||||
for (const op of Object.values(item)) {
|
||||
const responses = (op as { responses: Record<string, { $ref?: string }> }).responses;
|
||||
expect(responses.default).toEqual({ $ref: '#/components/responses/Default' });
|
||||
}
|
||||
}
|
||||
expect(contract.components.responses.Default.content).toHaveProperty('application/problem+json');
|
||||
expect(contract.components.responses.BadGateway.content['application/problem+json'].example.status).toBe(
|
||||
Number(reserved.BadGateway),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('4. problem+json and 5xx validation', () => {
|
||||
it('all 4xx/5xx bodies are application/problem+json (contract RFC 9457)', async () => {
|
||||
const { app } = makeApp();
|
||||
|
||||
// Test 400 (invalid JSON)
|
||||
const res400 = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/webhooks/arena',
|
||||
headers: { ...AUTH, 'content-type': 'application/json' },
|
||||
payload: '{invalid-json',
|
||||
});
|
||||
expect(res400.statusCode).toBe(400);
|
||||
expect(res400.headers['content-type']).toMatch(/application\/problem\+json/);
|
||||
|
||||
// Test 401
|
||||
const res401 = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/webhooks/arena',
|
||||
payload: { event: 'test' },
|
||||
});
|
||||
expect(res401.statusCode).toBe(401);
|
||||
expect(res401.headers['content-type']).toMatch(/application\/problem\+json/);
|
||||
|
||||
// Test 404
|
||||
const res404 = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/admin/sync-runs/00000000-0000-4000-8000-000000000000',
|
||||
headers: AUTH,
|
||||
});
|
||||
expect(res404.statusCode).toBe(404);
|
||||
expect(res404.headers['content-type']).toMatch(/application\/problem\+json/);
|
||||
|
||||
// Test 503
|
||||
const { app: app503 } = makeApp({ WEBHOOK_QUEUE_MAX_SIZE: '1' });
|
||||
await app503.inject({ method: 'POST', url: '/webhooks/arena', headers: AUTH, payload: { n: 1 } });
|
||||
const res503 = await app503.inject({
|
||||
method: 'POST',
|
||||
url: '/webhooks/arena',
|
||||
headers: AUTH,
|
||||
payload: { n: 2 },
|
||||
});
|
||||
expect(res503.statusCode).toBe(503);
|
||||
expect(res503.headers['content-type']).toMatch(/application\/problem\+json/);
|
||||
await app503.close();
|
||||
});
|
||||
|
||||
it('5xx bodies contain no detail derived from upstream (ArenaApiError -> 502)', async () => {
|
||||
const { app } = makeApp();
|
||||
// Throwaway test route to exercise ArenaApiError handling
|
||||
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' });
|
||||
|
||||
expect(res.statusCode).toBe(502);
|
||||
expect(res.headers['content-type']).toMatch(/application\/problem\+json/);
|
||||
const body = res.json();
|
||||
expect(body.title).toBe('Bad Gateway');
|
||||
expect(body.detail).toBe('Upstream Arena PLM request failed.');
|
||||
expect(res.body).not.toContain('LEAKED-UPSTREAM-BODY');
|
||||
expect(res.body).not.toContain('HTTP 500');
|
||||
|
||||
const validation = validateAgainstSchema(body, 'Problem', '502 ArenaApiError');
|
||||
expect(validation.valid).toBe(true);
|
||||
});
|
||||
|
||||
it('other 5xx errors do not leak details', async () => {
|
||||
const { app } = makeApp();
|
||||
app.get('/__test/boom', async () => {
|
||||
throw new Error('internal secret');
|
||||
});
|
||||
|
||||
const res = await app.inject({ method: 'GET', url: '/__test/boom' });
|
||||
|
||||
expect(res.statusCode).toBe(500);
|
||||
expect(res.headers['content-type']).toMatch(/application\/problem\+json/);
|
||||
const body = res.json();
|
||||
expect(body.title).toBe('Internal Server Error');
|
||||
expect(res.body).not.toContain('internal secret');
|
||||
|
||||
const validation = validateAgainstSchema(body, 'Problem', '500 generic error');
|
||||
expect(validation.valid).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Contract schema validation compliance', () => {
|
||||
it('ArenaWebhookEvent allows arbitrary properties (provisional schema)', async () => {
|
||||
const { app } = makeApp();
|
||||
const payload = {
|
||||
eventType: 'ITEM_MODIFIED',
|
||||
itemNumber: '900-00042',
|
||||
unknownField: 'allowed',
|
||||
nestedUnknown: { foo: 'bar' },
|
||||
};
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/webhooks/arena',
|
||||
headers: AUTH,
|
||||
payload,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(202);
|
||||
const validation = validateAgainstSchema(payload, 'ArenaWebhookEvent', 'POST /webhooks/arena with extra properties');
|
||||
expect(validation.valid).toBe(true);
|
||||
});
|
||||
|
||||
it('SyncObjectProgress validates with all states and optional fields', async () => {
|
||||
// The schema defines state: pending|running|completed|failed
|
||||
// and optional processed/error fields.
|
||||
// This is validated implicitly through SyncRun responses.
|
||||
const { app, runner } = makeApp();
|
||||
const created = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/admin/sync-runs',
|
||||
headers: AUTH,
|
||||
payload: { scope: 'full' },
|
||||
});
|
||||
const { runId } = created.json() as SyncRun;
|
||||
await runner.waitFor(runId);
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: `/admin/sync-runs/${runId}`,
|
||||
headers: AUTH,
|
||||
});
|
||||
const run = res.json() as SyncRun;
|
||||
|
||||
// Verify each object has valid state
|
||||
for (const obj of run.objects) {
|
||||
expect(['pending', 'running', 'completed', 'failed']).toContain(obj.state);
|
||||
}
|
||||
|
||||
const validation = validateAgainstSchema(run, 'SyncRun', 'SyncRun with SyncObjectProgress');
|
||||
expect(validation.valid).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { createFetchHttpClient } from '../src/http/types.js';
|
||||
|
||||
type FetchImpl = typeof fetch;
|
||||
|
||||
function okResponse(body: string, status = 200): Response {
|
||||
return new Response(body, { status, headers: { 'content-type': 'application/json' } });
|
||||
}
|
||||
|
||||
describe('createFetchHttpClient', () => {
|
||||
it('rejects a non-positive timeout at construction', () => {
|
||||
expect(() => createFetchHttpClient({ timeoutMs: 0 })).toThrow(/timeoutMs/);
|
||||
});
|
||||
|
||||
it('attaches an AbortSignal deadline to every request and parses JSON bodies', async () => {
|
||||
const fetchImpl = vi.fn<FetchImpl>().mockResolvedValue(okResponse('{"arenaSessionId":"s1"}'));
|
||||
const client = createFetchHttpClient({ timeoutMs: 30_000, fetchImpl });
|
||||
|
||||
const res = await client.request({ method: 'POST', url: 'https://arena.invalid/v1/login', body: { a: 1 } });
|
||||
|
||||
expect(res).toEqual({ status: 200, body: { arenaSessionId: 's1' } });
|
||||
const init = fetchImpl.mock.calls[0]?.[1];
|
||||
expect(init?.signal).toBeInstanceOf(AbortSignal);
|
||||
expect(init?.signal?.aborted).toBe(false);
|
||||
expect(init?.method).toBe('POST');
|
||||
expect(init?.body).toBe('{"a":1}');
|
||||
expect((init?.headers as Record<string, string>)['Content-Type']).toBe('application/json');
|
||||
});
|
||||
|
||||
it('rejects with a TimeoutError when the upstream does not answer within timeoutMs', async () => {
|
||||
// A fetch that only settles when its signal aborts — i.e. a hung socket.
|
||||
const fetchImpl: FetchImpl = (_url, init) =>
|
||||
new Promise<Response>((_resolve, reject) => {
|
||||
init?.signal?.addEventListener('abort', () => reject(init.signal?.reason as Error));
|
||||
});
|
||||
const client = createFetchHttpClient({ timeoutMs: 20, fetchImpl });
|
||||
|
||||
await expect(client.request({ method: 'GET', url: 'https://arena.invalid/v1/items' })).rejects.toMatchObject({
|
||||
name: 'TimeoutError',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns raw text when the body is not JSON and null when empty', async () => {
|
||||
const fetchImpl = vi
|
||||
.fn<FetchImpl>()
|
||||
.mockResolvedValueOnce(okResponse('not json', 502))
|
||||
.mockResolvedValueOnce(new Response(null, { status: 204 }));
|
||||
const client = createFetchHttpClient({ timeoutMs: 1000, fetchImpl });
|
||||
|
||||
expect(await client.request({ method: 'GET', url: 'https://arena.invalid/x' })).toEqual({
|
||||
status: 502,
|
||||
body: 'not json',
|
||||
});
|
||||
expect(await client.request({ method: 'GET', url: 'https://arena.invalid/y' })).toEqual({
|
||||
status: 204,
|
||||
body: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,216 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { UNCLASSIFIED_ERROR_PHRASE } from '../src/errors/publicPhrase.js';
|
||||
import { HealthRegistry } from '../src/health/registry.js';
|
||||
import { DeltaPoller } from '../src/poller/deltaPoller.js';
|
||||
|
||||
describe('DeltaPoller', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('polls on the configured interval after start and stops cleanly', async () => {
|
||||
const poll = vi.fn().mockResolvedValue(undefined);
|
||||
const poller = new DeltaPoller({ intervalMs: 1000, poll });
|
||||
|
||||
expect(poller.running).toBe(false);
|
||||
poller.start();
|
||||
poller.start(); // idempotent
|
||||
expect(poller.running).toBe(true);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(3000);
|
||||
expect(poll).toHaveBeenCalledTimes(3);
|
||||
|
||||
await poller.stop();
|
||||
await poller.stop(); // idempotent
|
||||
expect(poller.running).toBe(false);
|
||||
await vi.advanceTimersByTimeAsync(3000);
|
||||
expect(poll).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('skips a tick while the previous poll is still in flight', async () => {
|
||||
let release!: () => void;
|
||||
const poll = vi.fn(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
}),
|
||||
);
|
||||
const poller = new DeltaPoller({ intervalMs: 1000, poll });
|
||||
|
||||
poller.start();
|
||||
await vi.advanceTimersByTimeAsync(2500); // ticks at 1000 and 2000 — second skipped
|
||||
expect(poll).toHaveBeenCalledTimes(1);
|
||||
|
||||
release();
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
expect(poll).toHaveBeenCalledTimes(2);
|
||||
|
||||
// stop() now awaits the in-flight (second) tick, so release it first.
|
||||
const stopped = poller.stop();
|
||||
release();
|
||||
await stopped;
|
||||
});
|
||||
|
||||
it('reports poll errors via onError and keeps polling', async () => {
|
||||
const onError = vi.fn();
|
||||
const poll = vi.fn().mockRejectedValue(new Error('arena unreachable'));
|
||||
const poller = new DeltaPoller({ intervalMs: 1000, poll, onError });
|
||||
|
||||
poller.start();
|
||||
await vi.advanceTimersByTimeAsync(2000);
|
||||
|
||||
expect(poll).toHaveBeenCalledTimes(2);
|
||||
expect(onError).toHaveBeenCalledTimes(2);
|
||||
expect(onError.mock.calls[0]?.[0]).toBeInstanceOf(Error);
|
||||
|
||||
await poller.stop();
|
||||
});
|
||||
|
||||
describe('cursor ownership', () => {
|
||||
it('starts the cursor at construction time and advances it to the tick time only after a successful poll', async () => {
|
||||
let nowMs = 1_000;
|
||||
const poll = vi.fn().mockResolvedValue(undefined);
|
||||
const poller = new DeltaPoller({ intervalMs: 1000, poll, now: () => new Date(nowMs) });
|
||||
expect(poller.nextSince.getTime()).toBe(1_000);
|
||||
|
||||
poller.start();
|
||||
nowMs = 2_000;
|
||||
await vi.advanceTimersByTimeAsync(1000); // tick 1 at t=2000: since=1000
|
||||
nowMs = 3_000;
|
||||
await vi.advanceTimersByTimeAsync(1000); // tick 2 at t=3000: since=2000
|
||||
|
||||
expect(poll.mock.calls.map((call) => (call[0] as Date).getTime())).toEqual([1_000, 2_000]);
|
||||
expect(poller.nextSince.getTime()).toBe(3_000);
|
||||
await poller.stop();
|
||||
});
|
||||
|
||||
it('keeps the same `since` across consecutive ticks while the poll keeps rejecting', async () => {
|
||||
let nowMs = 1_000;
|
||||
const poll = vi.fn().mockRejectedValue(new Error('arena unreachable'));
|
||||
const poller = new DeltaPoller({
|
||||
intervalMs: 1000,
|
||||
poll,
|
||||
onError: () => undefined,
|
||||
now: () => new Date(nowMs),
|
||||
});
|
||||
|
||||
poller.start();
|
||||
nowMs = 2_000;
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
nowMs = 3_000;
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
nowMs = 4_000;
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
|
||||
// A failed poll must not lose its window: every tick re-covers from 1000.
|
||||
expect(poll.mock.calls.map((call) => (call[0] as Date).getTime())).toEqual([1_000, 1_000, 1_000]);
|
||||
expect(poller.nextSince.getTime()).toBe(1_000);
|
||||
|
||||
// First success advances to that tick's `asOf`, and the next tick uses it.
|
||||
poll.mockResolvedValue(undefined);
|
||||
nowMs = 5_000;
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
nowMs = 6_000;
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
expect(poll.mock.calls.slice(3).map((call) => (call[0] as Date).getTime())).toEqual([1_000, 5_000]);
|
||||
await poller.stop();
|
||||
});
|
||||
|
||||
it('captures asOf BEFORE the fetch so changes arriving mid-poll are not skipped', async () => {
|
||||
let nowMs = 1_000;
|
||||
let release!: () => void;
|
||||
const poll = vi.fn(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
}),
|
||||
);
|
||||
const poller = new DeltaPoller({ intervalMs: 1000, poll, now: () => new Date(nowMs) });
|
||||
|
||||
poller.start();
|
||||
nowMs = 2_000;
|
||||
await vi.advanceTimersByTimeAsync(1000); // tick starts at t=2000
|
||||
nowMs = 2_900; // time passes while the poll is in flight
|
||||
release();
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
// Cursor is the tick's start time, not its completion time.
|
||||
expect(poller.nextSince.getTime()).toBe(2_000);
|
||||
await poller.stop();
|
||||
});
|
||||
});
|
||||
|
||||
describe('stop()', () => {
|
||||
it('resolves immediately when no tick is in flight', async () => {
|
||||
const poller = new DeltaPoller({ intervalMs: 1000, poll: vi.fn() });
|
||||
poller.start();
|
||||
await expect(poller.stop()).resolves.toBeUndefined();
|
||||
expect(poller.running).toBe(false);
|
||||
});
|
||||
|
||||
it('returns the in-flight tick promise so shutdown can await it', async () => {
|
||||
let release!: () => void;
|
||||
const poll = vi.fn(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
}),
|
||||
);
|
||||
const poller = new DeltaPoller({ intervalMs: 1000, poll });
|
||||
poller.start();
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
expect(poll).toHaveBeenCalledTimes(1);
|
||||
|
||||
let settled = false;
|
||||
const stopped = poller.stop().then(() => {
|
||||
settled = true;
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(5000);
|
||||
expect(poller.running).toBe(false);
|
||||
expect(settled).toBe(false); // still waiting on the tick
|
||||
expect(poll).toHaveBeenCalledTimes(1); // and no new ticks were scheduled
|
||||
|
||||
release();
|
||||
await stopped;
|
||||
expect(settled).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('health reporting', () => {
|
||||
it('records success and failure with the tick start time', async () => {
|
||||
let nowMs = 10_000;
|
||||
const health = new HealthRegistry();
|
||||
const poll = vi.fn().mockRejectedValueOnce(new Error('boom')).mockResolvedValueOnce(undefined);
|
||||
const poller = new DeltaPoller({
|
||||
intervalMs: 1000,
|
||||
poll,
|
||||
health,
|
||||
onError: () => undefined,
|
||||
now: () => new Date(nowMs),
|
||||
});
|
||||
|
||||
poller.start();
|
||||
nowMs = 11_000;
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
expect(health.status()).toBe('degraded');
|
||||
// B5: the registry stores a fixed public phrase, never the raw message.
|
||||
expect(health.pollerSnapshot()).toEqual({
|
||||
lastRunAt: new Date(11_000).toISOString(),
|
||||
lastError: UNCLASSIFIED_ERROR_PHRASE,
|
||||
});
|
||||
|
||||
nowMs = 12_000;
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
expect(health.status()).toBe('ok');
|
||||
expect(health.pollerSnapshot()).toEqual({
|
||||
lastRunAt: new Date(12_000).toISOString(),
|
||||
lastSuccessAt: new Date(12_000).toISOString(),
|
||||
});
|
||||
await poller.stop();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { DEFAULT_WEBHOOK_QUEUE_MAX_SIZE, InMemoryEventQueue } from '../src/queue/webhookQueue.js';
|
||||
|
||||
describe('InMemoryEventQueue (bounded transient buffer, ARN-003)', () => {
|
||||
it('defaults maxSize to 10000', () => {
|
||||
expect(DEFAULT_WEBHOOK_QUEUE_MAX_SIZE).toBe(10_000);
|
||||
expect(new InMemoryEventQueue().maxSize).toBe(10_000);
|
||||
});
|
||||
|
||||
it('rejects a non-positive or non-integer maxSize', () => {
|
||||
expect(() => new InMemoryEventQueue({ maxSize: 0 })).toThrow(/maxSize/);
|
||||
expect(() => new InMemoryEventQueue({ maxSize: -5 })).toThrow(/maxSize/);
|
||||
expect(() => new InMemoryEventQueue({ maxSize: 1.5 })).toThrow(/maxSize/);
|
||||
});
|
||||
|
||||
it('accepts up to maxSize events, then answers {accepted:false, reason:"full"} without growing', () => {
|
||||
const queue = new InMemoryEventQueue({ maxSize: 2 });
|
||||
|
||||
const first = queue.enqueue({ source: 'webhook', payload: { n: 1 } });
|
||||
const second = queue.enqueue({ source: 'poll', payload: { n: 2 } });
|
||||
expect(first.accepted).toBe(true);
|
||||
expect(second.accepted).toBe(true);
|
||||
if (first.accepted) expect(first.event.payload).toEqual({ n: 1 });
|
||||
expect(queue.isFull).toBe(true);
|
||||
|
||||
const third = queue.enqueue({ source: 'webhook', payload: { n: 3 } });
|
||||
expect(third).toEqual({ accepted: false, reason: 'full' });
|
||||
expect(queue.size).toBe(2);
|
||||
expect(queue.peekAll().map((e) => e.payload)).toEqual([{ n: 1 }, { n: 2 }]);
|
||||
});
|
||||
|
||||
it('accepts again once an event has been dequeued (FIFO)', () => {
|
||||
const queue = new InMemoryEventQueue({ maxSize: 1 });
|
||||
queue.enqueue({ source: 'webhook', payload: 'a' });
|
||||
expect(queue.enqueue({ source: 'webhook', payload: 'b' }).accepted).toBe(false);
|
||||
|
||||
expect(queue.dequeue()?.payload).toBe('a');
|
||||
expect(queue.isFull).toBe(false);
|
||||
expect(queue.enqueue({ source: 'webhook', payload: 'b' }).accepted).toBe(true);
|
||||
expect(queue.dequeue()?.payload).toBe('b');
|
||||
expect(queue.dequeue()).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,174 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { ArenaApiError } from '../src/arena/session.js';
|
||||
import { UNCLASSIFIED_ERROR_PHRASE } from '../src/errors/publicPhrase.js';
|
||||
import { InMemoryRunStore } from '../src/sync/runStore.js';
|
||||
import { SyncRunner } from '../src/sync/runner.js';
|
||||
import type {
|
||||
SyncExecutionContext,
|
||||
SyncExecutionResult,
|
||||
SyncExecutor,
|
||||
SyncObjectType,
|
||||
} from '../src/sync/types.js';
|
||||
|
||||
class RecordingExecutor implements SyncExecutor {
|
||||
constructor(
|
||||
readonly objectType: SyncObjectType,
|
||||
private readonly log: SyncObjectType[],
|
||||
private readonly failWith?: Error,
|
||||
) {}
|
||||
|
||||
async execute(_ctx: SyncExecutionContext): Promise<SyncExecutionResult> {
|
||||
this.log.push(this.objectType);
|
||||
if (this.failWith) throw this.failWith;
|
||||
return { processed: 1 };
|
||||
}
|
||||
}
|
||||
|
||||
function makeExecutors(log: SyncObjectType[], failing?: Partial<Record<SyncObjectType, Error>>) {
|
||||
const types: SyncObjectType[] = ['PRODUCT', 'CHANGE_MASTER', 'BOM', 'DOCUMENTS'];
|
||||
return new Map<SyncObjectType, SyncExecutor>(
|
||||
types.map((t) => [t, new RecordingExecutor(t, log, failing?.[t])]),
|
||||
);
|
||||
}
|
||||
|
||||
describe('SyncRunner', () => {
|
||||
it('moves a run queued → completed, executing objects sequentially in order', async () => {
|
||||
const log: SyncObjectType[] = [];
|
||||
const store = new InMemoryRunStore();
|
||||
const runner = new SyncRunner(store, makeExecutors(log));
|
||||
|
||||
const run = runner.submit('full', ['PRODUCT', 'CHANGE_MASTER', 'BOM', 'DOCUMENTS']);
|
||||
expect(run.state).toBe('queued');
|
||||
expect(log).toEqual([]); // nothing executed before submit() returns
|
||||
|
||||
const final = await runner.waitFor(run.runId);
|
||||
expect(final?.state).toBe('completed');
|
||||
expect(final?.finishedAt).toBeDefined();
|
||||
expect(log).toEqual(['PRODUCT', 'CHANGE_MASTER', 'BOM', 'DOCUMENTS']);
|
||||
expect(final?.objects.map((o) => ({ t: o.objectType, s: o.state, p: o.processed }))).toEqual([
|
||||
{ t: 'PRODUCT', s: 'completed', p: 1 },
|
||||
{ t: 'CHANGE_MASTER', s: 'completed', p: 1 },
|
||||
{ t: 'BOM', s: 'completed', p: 1 },
|
||||
{ t: 'DOCUMENTS', s: 'completed', p: 1 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('respects a custom object order', async () => {
|
||||
const log: SyncObjectType[] = [];
|
||||
const runner = new SyncRunner(new InMemoryRunStore(), makeExecutors(log));
|
||||
|
||||
const run = runner.submit('delta', ['BOM', 'PRODUCT']);
|
||||
await runner.waitFor(run.runId);
|
||||
|
||||
expect(log).toEqual(['BOM', 'PRODUCT']);
|
||||
});
|
||||
|
||||
it('stops the chain at the first failure and marks the run failed', async () => {
|
||||
const log: SyncObjectType[] = [];
|
||||
const runner = new SyncRunner(
|
||||
new InMemoryRunStore(),
|
||||
makeExecutors(log, { PRODUCT: new Error('boom') }),
|
||||
);
|
||||
|
||||
const run = runner.submit('full', ['PRODUCT', 'CHANGE_MASTER', 'BOM', 'DOCUMENTS']);
|
||||
const final = await runner.waitFor(run.runId);
|
||||
|
||||
expect(final?.state).toBe('failed');
|
||||
// B5: an unknown error class maps to the fixed unclassified phrase, never its message.
|
||||
expect(final?.error).toBe(`PRODUCT: ${UNCLASSIFIED_ERROR_PHRASE}`);
|
||||
expect(final?.objects[0]?.error).toBe(UNCLASSIFIED_ERROR_PHRASE);
|
||||
expect(JSON.stringify(final)).not.toContain('boom');
|
||||
expect(log).toEqual(['PRODUCT']); // later objects never executed
|
||||
expect(final?.objects.map((o) => o.state)).toEqual(['failed', 'pending', 'pending', 'pending']);
|
||||
});
|
||||
|
||||
it('B4: `processed` is absent while pending and present once an object has run', async () => {
|
||||
const log: SyncObjectType[] = [];
|
||||
const runner = new SyncRunner(
|
||||
new InMemoryRunStore(),
|
||||
makeExecutors(log, { CHANGE_MASTER: new Error('boom') }),
|
||||
);
|
||||
|
||||
const queued = runner.submit('full', ['PRODUCT', 'CHANGE_MASTER', 'BOM']);
|
||||
expect(queued.objects.every((o) => !('processed' in o))).toBe(true);
|
||||
|
||||
const final = await runner.waitFor(queued.runId);
|
||||
expect(final?.objects.map((o) => [o.state, 'processed' in o])).toEqual([
|
||||
['completed', true],
|
||||
['failed', true], // started, so the counter exists (0 here)
|
||||
['pending', false],
|
||||
]);
|
||||
expect(final?.objects[1]?.processed).toBe(0);
|
||||
});
|
||||
|
||||
it('B5: an ArenaApiError from an executor becomes a fixed status phrase; its body and message never reach the snapshot', async () => {
|
||||
const log: SyncObjectType[] = [];
|
||||
const upstream = new ArenaApiError('Arena GET /items failed with HTTP 503 MARKER-MESSAGE', 503, {
|
||||
errors: [{ message: 'MARKER-BODY worker@example.test' }],
|
||||
});
|
||||
const runner = new SyncRunner(new InMemoryRunStore(), makeExecutors(log, { BOM: upstream }));
|
||||
|
||||
const run = runner.submit('full', ['PRODUCT', 'BOM', 'DOCUMENTS']);
|
||||
const final = await runner.waitFor(run.runId);
|
||||
|
||||
expect(final?.state).toBe('failed');
|
||||
expect(final?.objects[1]?.error).toBe('Upstream Arena PLM request failed (HTTP 503).');
|
||||
expect(final?.error).toBe('BOM: Upstream Arena PLM request failed (HTTP 503).');
|
||||
const wire = JSON.stringify(final);
|
||||
expect(wire).not.toContain('MARKER-MESSAGE');
|
||||
expect(wire).not.toContain('MARKER-BODY');
|
||||
expect(wire).not.toContain('worker@example.test');
|
||||
});
|
||||
|
||||
it('drain() resolves only after every in-flight run has settled (success and failure alike)', async () => {
|
||||
const log: SyncObjectType[] = [];
|
||||
let releaseBom!: () => void;
|
||||
const gate = new Promise<void>((resolve) => {
|
||||
releaseBom = resolve;
|
||||
});
|
||||
class GatedExecutor implements SyncExecutor {
|
||||
readonly objectType: SyncObjectType = 'BOM';
|
||||
async execute(): Promise<SyncExecutionResult> {
|
||||
await gate;
|
||||
log.push('BOM');
|
||||
return { processed: 1 };
|
||||
}
|
||||
}
|
||||
const executors = makeExecutors(log, { PRODUCT: new Error('boom') });
|
||||
executors.set('BOM', new GatedExecutor());
|
||||
const runner = new SyncRunner(new InMemoryRunStore(), executors);
|
||||
|
||||
const failing = runner.submit('full', ['PRODUCT']);
|
||||
const gated = runner.submit('full', ['BOM']);
|
||||
expect(runner.inFlightCount).toBe(2);
|
||||
|
||||
let drained = false;
|
||||
const drain = runner.drain().then(() => {
|
||||
drained = true;
|
||||
});
|
||||
await new Promise((r) => setImmediate(r));
|
||||
expect(drained).toBe(false); // the gated BOM run is still executing
|
||||
expect(runner.inFlightCount).toBe(1); // the failing run has already settled
|
||||
|
||||
releaseBom();
|
||||
await drain;
|
||||
expect(drained).toBe(true);
|
||||
expect(runner.inFlightCount).toBe(0);
|
||||
expect((await runner.waitFor(failing.runId))?.state).toBe('failed');
|
||||
expect((await runner.waitFor(gated.runId))?.state).toBe('completed');
|
||||
});
|
||||
|
||||
it('drain() resolves immediately when nothing is in flight', async () => {
|
||||
const runner = new SyncRunner(new InMemoryRunStore(), makeExecutors([]));
|
||||
await expect(runner.drain()).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('fails the run when no executor is registered for an object type', async () => {
|
||||
const runner = new SyncRunner(new InMemoryRunStore(), new Map());
|
||||
const run = runner.submit('full', ['PRODUCT']);
|
||||
const final = await runner.waitFor(run.runId);
|
||||
|
||||
expect(final?.state).toBe('failed');
|
||||
expect(final?.error).toBe('PRODUCT: no executor registered');
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,232 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { ArenaApiError, ArenaSessionManager } from '../src/arena/session.js';
|
||||
import { loadConfig } from '../src/config.js';
|
||||
import { HealthRegistry, type LoginHealthSink } from '../src/health/registry.js';
|
||||
import type { HttpClient, HttpRequest, HttpResponse } from '../src/http/types.js';
|
||||
|
||||
function makeManager(overrides?: {
|
||||
sessionTtlMs?: number;
|
||||
now?: () => number;
|
||||
health?: LoginHealthSink;
|
||||
}): { manager: ArenaSessionManager; request: ReturnType<typeof vi.fn> } {
|
||||
const request = vi.fn();
|
||||
const http: HttpClient = { request: request as HttpClient['request'] };
|
||||
const manager = new ArenaSessionManager({
|
||||
http,
|
||||
baseUrl: 'https://arena.invalid/v1',
|
||||
email: 'worker@example.test',
|
||||
password: 'secret',
|
||||
workspaceId: 123,
|
||||
sessionTtlMs: 24 * 60_000,
|
||||
...overrides,
|
||||
});
|
||||
return { manager, request };
|
||||
}
|
||||
|
||||
function loginOk(sessionId: string): HttpResponse {
|
||||
return { status: 200, body: { arenaSessionId: sessionId } };
|
||||
}
|
||||
|
||||
describe('ArenaSessionManager', () => {
|
||||
it('coalesces concurrent callers onto a single in-flight login', async () => {
|
||||
const { manager, request } = makeManager();
|
||||
let resolveLogin!: (res: HttpResponse) => void;
|
||||
request.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<HttpResponse>((resolve) => {
|
||||
resolveLogin = resolve;
|
||||
}),
|
||||
);
|
||||
|
||||
const pending = Promise.all([manager.getSessionId(), manager.getSessionId(), manager.getSessionId()]);
|
||||
resolveLogin(loginOk('sess-1'));
|
||||
const ids = await pending;
|
||||
|
||||
expect(ids).toEqual(['sess-1', 'sess-1', 'sess-1']);
|
||||
expect(request).toHaveBeenCalledTimes(1);
|
||||
const loginReq = request.mock.calls[0]?.[0] as HttpRequest;
|
||||
expect(loginReq.method).toBe('POST');
|
||||
expect(loginReq.url).toBe('https://arena.invalid/v1/login');
|
||||
expect(loginReq.body).toEqual({
|
||||
email: 'worker@example.test',
|
||||
password: 'secret',
|
||||
workspaceId: 123,
|
||||
});
|
||||
});
|
||||
|
||||
it('reuses the cached session until the TTL and re-logs in after expiry', async () => {
|
||||
let nowMs = 0;
|
||||
const sessionTtlMs = 24 * 60_000;
|
||||
const { manager, request } = makeManager({ sessionTtlMs, now: () => nowMs });
|
||||
|
||||
request.mockResolvedValueOnce(loginOk('sess-1'));
|
||||
expect(manager.state()).toBe('none');
|
||||
expect(await manager.getSessionId()).toBe('sess-1');
|
||||
expect(await manager.getSessionId()).toBe('sess-1');
|
||||
expect(request).toHaveBeenCalledTimes(1);
|
||||
expect(manager.state()).toBe('active');
|
||||
|
||||
nowMs = sessionTtlMs + 1;
|
||||
expect(manager.state()).toBe('expired');
|
||||
request.mockResolvedValueOnce(loginOk('sess-2'));
|
||||
expect(await manager.getSessionId()).toBe('sess-2');
|
||||
expect(request).toHaveBeenCalledTimes(2);
|
||||
expect(manager.state()).toBe('active');
|
||||
});
|
||||
|
||||
it('wires ARENA_SESSION_TTL_MINUTES through loadConfig to the effective TTL (was silently dropped in 0.1.0)', async () => {
|
||||
const config = loadConfig({ ARENA_SESSION_TTL_MINUTES: '1' });
|
||||
let nowMs = 0;
|
||||
const request = vi.fn().mockResolvedValue(loginOk('sess-1'));
|
||||
// Same spread the composition root uses: ArenaSessionOptions is a Pick of
|
||||
// ArenaConfig, so a field-name drift would fail to compile here.
|
||||
const { httpTimeoutMs: _httpTimeoutMs, ...sessionConfig } = config.arena;
|
||||
const manager = new ArenaSessionManager({
|
||||
http: { request: request as HttpClient['request'] },
|
||||
now: () => nowMs,
|
||||
...sessionConfig,
|
||||
});
|
||||
|
||||
expect(manager.sessionTtlMs).toBe(60_000);
|
||||
|
||||
await manager.getSessionId();
|
||||
nowMs = 59_999;
|
||||
expect(manager.state()).toBe('active');
|
||||
nowMs = 60_000;
|
||||
expect(manager.state()).toBe('expired');
|
||||
});
|
||||
|
||||
it('rejects a non-positive sessionTtlMs at construction', () => {
|
||||
expect(() => makeManager({ sessionTtlMs: 0 })).toThrow(/sessionTtlMs/);
|
||||
});
|
||||
|
||||
it('reports state "refreshing" while a login is in flight, then "active"', async () => {
|
||||
const { manager, request } = makeManager();
|
||||
let resolveLogin!: (res: HttpResponse) => void;
|
||||
request.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<HttpResponse>((resolve) => {
|
||||
resolveLogin = resolve;
|
||||
}),
|
||||
);
|
||||
|
||||
expect(manager.state()).toBe('none');
|
||||
const pending = manager.getSessionId();
|
||||
expect(manager.state()).toBe('refreshing');
|
||||
|
||||
resolveLogin(loginOk('sess-1'));
|
||||
await pending;
|
||||
expect(manager.state()).toBe('active');
|
||||
});
|
||||
|
||||
it('reports "refreshing" during a re-login after expiry (refreshing wins over expired)', async () => {
|
||||
let nowMs = 0;
|
||||
const { manager, request } = makeManager({ sessionTtlMs: 1000, now: () => nowMs });
|
||||
request.mockResolvedValueOnce(loginOk('sess-1'));
|
||||
await manager.getSessionId();
|
||||
|
||||
nowMs = 5000;
|
||||
expect(manager.state()).toBe('expired');
|
||||
let resolveLogin!: (res: HttpResponse) => void;
|
||||
request.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<HttpResponse>((resolve) => {
|
||||
resolveLogin = resolve;
|
||||
}),
|
||||
);
|
||||
const pending = manager.getSessionId();
|
||||
expect(manager.state()).toBe('refreshing');
|
||||
resolveLogin(loginOk('sess-2'));
|
||||
await pending;
|
||||
expect(manager.state()).toBe('active');
|
||||
});
|
||||
|
||||
it('reports login outcomes to the health sink (degraded on failure, ok again on success)', async () => {
|
||||
const health = new HealthRegistry();
|
||||
const { manager, request } = makeManager({ health });
|
||||
|
||||
expect(health.status()).toBe('ok');
|
||||
request.mockResolvedValueOnce({ status: 503, body: 'upstream down' });
|
||||
await expect(manager.getSessionId()).rejects.toBeInstanceOf(ArenaApiError);
|
||||
expect(health.status()).toBe('degraded');
|
||||
|
||||
request.mockResolvedValueOnce(loginOk('sess-1'));
|
||||
await manager.getSessionId();
|
||||
expect(health.status()).toBe('ok');
|
||||
});
|
||||
|
||||
it('reports a transport error (network/timeout) as a login failure too', async () => {
|
||||
const health = new HealthRegistry();
|
||||
const { manager, request } = makeManager({ health });
|
||||
request.mockRejectedValueOnce(new Error('TimeoutError'));
|
||||
|
||||
await expect(manager.getSessionId()).rejects.toThrow('TimeoutError');
|
||||
expect(health.status()).toBe('degraded');
|
||||
expect(manager.state()).toBe('none');
|
||||
});
|
||||
|
||||
it('withSession re-logs in and retries exactly once on 401', async () => {
|
||||
const { manager, request } = makeManager();
|
||||
request.mockResolvedValueOnce(loginOk('sess-1')).mockResolvedValueOnce(loginOk('sess-2'));
|
||||
|
||||
const fn = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new ArenaApiError('unauthorized', 401))
|
||||
.mockResolvedValueOnce('ok');
|
||||
|
||||
const result = await manager.withSession(fn as (sessionId: string) => Promise<string>);
|
||||
|
||||
expect(result).toBe('ok');
|
||||
expect(fn.mock.calls.map((call) => call[0])).toEqual(['sess-1', 'sess-2']);
|
||||
expect(request).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('withSession does not retry more than once on repeated 401', async () => {
|
||||
const { manager, request } = makeManager();
|
||||
request.mockResolvedValueOnce(loginOk('sess-1')).mockResolvedValueOnce(loginOk('sess-2'));
|
||||
|
||||
const fn = vi.fn().mockRejectedValue(new ArenaApiError('unauthorized', 401));
|
||||
|
||||
await expect(manager.withSession(fn as (sessionId: string) => Promise<never>)).rejects.toBeInstanceOf(
|
||||
ArenaApiError,
|
||||
);
|
||||
expect(fn).toHaveBeenCalledTimes(2);
|
||||
expect(request).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('withSession propagates non-401 errors without re-login', async () => {
|
||||
const { manager, request } = makeManager();
|
||||
request.mockResolvedValueOnce(loginOk('sess-1'));
|
||||
|
||||
const fn = vi.fn().mockRejectedValue(new ArenaApiError('server error', 500));
|
||||
|
||||
await expect(manager.withSession(fn as (sessionId: string) => Promise<never>)).rejects.toThrow(
|
||||
'server error',
|
||||
);
|
||||
expect(fn).toHaveBeenCalledTimes(1);
|
||||
expect(request).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('rejects all coalesced callers on login failure and allows a later retry', async () => {
|
||||
const { manager, request } = makeManager();
|
||||
request.mockResolvedValueOnce({ status: 401, body: { errors: [{ message: 'bad creds' }] } });
|
||||
|
||||
const results = await Promise.allSettled([manager.getSessionId(), manager.getSessionId()]);
|
||||
expect(results.map((r) => r.status)).toEqual(['rejected', 'rejected']);
|
||||
expect(request).toHaveBeenCalledTimes(1);
|
||||
|
||||
// The promise lock must clear so a subsequent call can log in again.
|
||||
expect(manager.state()).toBe('none');
|
||||
request.mockResolvedValueOnce(loginOk('sess-1'));
|
||||
expect(await manager.getSessionId()).toBe('sess-1');
|
||||
expect(request).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('rejects a login response without arenaSessionId', async () => {
|
||||
const { manager, request } = makeManager();
|
||||
request.mockResolvedValueOnce({ status: 200, body: {} });
|
||||
|
||||
await expect(manager.getSessionId()).rejects.toThrow('no arenaSessionId');
|
||||
expect(manager.state()).toBe('none');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
import Fastify from 'fastify';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { drainWithTimeout, registerGracefulShutdown } from '../src/shutdown.js';
|
||||
|
||||
function deferred<T = void>() {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>((r) => {
|
||||
resolve = r;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
describe('drainWithTimeout', () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('resolves "drained" when the work settles first and clears its timer', async () => {
|
||||
vi.useFakeTimers();
|
||||
const work = deferred();
|
||||
const outcome = drainWithTimeout(work.promise, 30_000);
|
||||
work.resolve();
|
||||
await expect(outcome).resolves.toBe('drained');
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
});
|
||||
|
||||
it('resolves "timeout" when the deadline wins', async () => {
|
||||
vi.useFakeTimers();
|
||||
const never = new Promise<void>(() => undefined);
|
||||
const outcome = drainWithTimeout(never, 30_000);
|
||||
await vi.advanceTimersByTimeAsync(30_000);
|
||||
await expect(outcome).resolves.toBe('timeout');
|
||||
});
|
||||
});
|
||||
|
||||
describe('registerGracefulShutdown', () => {
|
||||
it('on signal: stops the poller, drains the runner inside app.close(), and exits 0', async () => {
|
||||
const app = Fastify({ logger: false });
|
||||
const drain = deferred();
|
||||
const stop = deferred();
|
||||
const runner = { drain: vi.fn(() => drain.promise) };
|
||||
const poller = { stop: vi.fn(() => stop.promise) };
|
||||
const exited = deferred<number>();
|
||||
const shutdown = registerGracefulShutdown({ app, poller, runner, timeoutMs: 5000, exit: exited.resolve });
|
||||
await app.ready();
|
||||
|
||||
shutdown.onSignal('SIGTERM');
|
||||
// Wait for the onClose hook to actually start the drain.
|
||||
await vi.waitFor(() => expect(runner.drain).toHaveBeenCalledTimes(1));
|
||||
expect(poller.stop).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Exit must wait for BOTH the runner drain and the in-flight poll tick.
|
||||
let exitCode: number | undefined;
|
||||
void exited.promise.then((code) => {
|
||||
exitCode = code;
|
||||
});
|
||||
drain.resolve();
|
||||
await new Promise((r) => setImmediate(r));
|
||||
expect(exitCode).toBeUndefined();
|
||||
|
||||
stop.resolve();
|
||||
expect(await exited.promise).toBe(0);
|
||||
expect(shutdown.timedOut).toBe(false);
|
||||
});
|
||||
|
||||
it('exits 1 when in-flight work does not settle within the timeout', async () => {
|
||||
const app = Fastify({ logger: false });
|
||||
const runner = { drain: () => new Promise<void>(() => undefined) }; // never settles
|
||||
const poller = { stop: () => Promise.resolve() };
|
||||
const exited = deferred<number>();
|
||||
const shutdown = registerGracefulShutdown({ app, poller, runner, timeoutMs: 50, exit: exited.resolve });
|
||||
await app.ready();
|
||||
|
||||
shutdown.onSignal('SIGINT');
|
||||
expect(await exited.promise).toBe(1);
|
||||
expect(shutdown.timedOut).toBe(true);
|
||||
});
|
||||
|
||||
it('exits 1 when close() itself rejects', async () => {
|
||||
const app = Fastify({ logger: false });
|
||||
const exited = deferred<number>();
|
||||
const shutdown = registerGracefulShutdown({
|
||||
app,
|
||||
poller: { stop: () => Promise.resolve() },
|
||||
runner: { drain: () => Promise.reject(new Error('drain exploded')) },
|
||||
timeoutMs: 5000,
|
||||
exit: exited.resolve,
|
||||
});
|
||||
await app.ready();
|
||||
|
||||
shutdown.onSignal('SIGTERM');
|
||||
expect(await exited.promise).toBe(1);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user