upload current sources

This commit is contained in:
2026-09-16 01:47:28 -07:00
commit f8b45ea4a5
109 changed files with 26012 additions and 0 deletions
+134
View File
@@ -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();
});
});
+68
View File
@@ -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/);
});
});
+892
View File
@@ -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);
});
});
});
+59
View File
@@ -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,
});
});
});
+216
View File
@@ -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();
});
});
});
+43
View File
@@ -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();
});
});
+174
View File
@@ -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');
});
});
+548
View File
@@ -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');
});
});
+232
View File
@@ -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');
});
});
+94
View File
@@ -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);
});
});