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
+38
View File
@@ -0,0 +1,38 @@
# Copy to .env for local development (never commit .env — see .gitignore).
# `npm run dev` loads it through Node's `--env-file-if-exists=.env`
# (stable since Node 24.10; verified on this box with tsx watch 2026-09-14).
# Production gets its env from the platform — nothing else reads this file.
# Config is env-only; business mappings live in the tenant (PRD ARN-004), not here.
# HTTP surface
WORKER_HOST=0.0.0.0
WORKER_PORT=8080
# Shared bearer token for /webhooks/* and /admin/* (placeholder auth — PRD OQ-6).
# Unset => guarded endpoints fail closed with 401.
WORKER_BEARER_TOKEN=
# Arena PLM (api.arenasolutions.com/v1)
ARENA_API_URL=https://api.arenasolutions.com/v1
ARENA_API_EMAIL=
ARENA_API_PASSWORD=
ARENA_WORKSPACE_ID=
# Session cache lifetime in minutes. Arena sessions expire after ~25 min
# inactivity [KNOWLEDGE.md 2026-09-04]; default refreshes 1 min early.
ARENA_SESSION_TTL_MINUTES=24
# Per-request deadline for Arena HTTP calls (AbortSignal.timeout).
ARENA_HTTP_TIMEOUT_MS=30000
# Delta poller
ARENA_POLL_INTERVAL_SECONDS=300
# Ingest buffer bound (transient in-memory buffer, ARN-003 — never durable
# worker-local storage). When full: webhook => 503 + Retry-After, poll => drop.
WEBHOOK_QUEUE_MAX_SIZE=10000
# Graceful shutdown: max wait for in-flight sync runs / poll tick on
# SIGINT/SIGTERM. Exit 0 when drained, 1 when this deadline wins.
SHUTDOWN_TIMEOUT_MS=30000
# Sync chain execution order (ARN-001). Comma-separated permutation/subset of
# PRODUCT,CHANGE_MASTER,BOM,DOCUMENT — each at most once.
SYNC_OBJECT_ORDER=PRODUCT,CHANGE_MASTER,BOM,DOCUMENT
+7
View File
@@ -0,0 +1,7 @@
node_modules/
dist/
coverage/
.env
.env.*
!.env.example
*.log
+27
View File
@@ -0,0 +1,27 @@
// @ts-check
// Flat config per current typescript-eslint quickstart: defineConfig from
// 'eslint/config' (the tseslint.config helper is deprecated).
// [source: context7 /typescript-eslint/typescript-eslint docs/getting-started/Quickstart.mdx, verified 2026-09-10]
import js from '@eslint/js';
import { defineConfig } from 'eslint/config';
import tseslint from 'typescript-eslint';
export default defineConfig(
{
ignores: ['dist/**', 'node_modules/**', 'coverage/**'],
},
{
files: ['**/*.ts'],
extends: [js.configs.recommended, tseslint.configs.recommended],
rules: {
'@typescript-eslint/no-unused-vars': [
'error',
{
argsIgnorePattern: '^_',
varsIgnorePattern: '^_',
caughtErrorsIgnorePattern: '^_',
},
],
},
},
);
+3630
View File
File diff suppressed because it is too large Load Diff
+30
View File
@@ -0,0 +1,30 @@
{
"name": "arena-plm-connectivity-worker",
"version": "0.1.0",
"private": true,
"description": "Hardened TypeScript connectivity worker for Arena PLM -> SAP S/4HANA (ADR-002: the only side-by-side component; machine-to-machine, no UI, no user-facing URLs).",
"type": "module",
"engines": {
"node": ">=24"
},
"scripts": {
"build": "tsc -p tsconfig.build.json",
"lint": "eslint .",
"test": "vitest run",
"dev": "tsx watch --env-file-if-exists=.env src/index.ts"
},
"dependencies": {
"fastify": "^5.12.3"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@types/node": "^24.13.4",
"ajv": "^8.20.0",
"ajv-formats": "^3.0.1",
"eslint": "^10.10.0",
"tsx": "^4.23.13",
"typescript": "~5.9.3",
"typescript-eslint": "^8.70.0",
"vitest": "^4.1.11"
}
}
+36
View File
@@ -0,0 +1,36 @@
/**
* Arena PLM API client — build wave 1 stub.
*
* Real Arena calls will run through `session.withSession(...)` and carry the
* session id in the `arena_session_id` request header (predecessor-verified
* behavior, 2026-09-04). No live network calls are made in this wave.
*/
import type { ArenaSessionManager } from './session.js';
/** Header Arena expects the session id in on every authenticated call. */
export const ARENA_SESSION_HEADER = 'arena_session_id';
export class ArenaClient {
constructor(private readonly session: ArenaSessionManager) {}
get sessionState() {
return this.session.state();
}
/**
* STUB — delta-poll seam. Wave 1 deliberately returns no changes and makes
* no network call (and therefore does not trigger a login).
*
* The real implementation MUST NOT implement `?since=` on trust: Arena's
* `/changes` feed has NO working server-side date filter, so it must either
* filter client-side (paging the feed, O(history) — the predecessor's
* approach) or use Arena's outbound-integration events endpoint
* (`/outboundintegrations/{guid}/events`, `reconciled` flag as the cursor
* held by Arena — the clean delta mechanism, PRD V-3)
* [KNOWLEDGE.md 2026-09-10]. Calls go through `this.session.withSession(...)`.
*/
async fetchChangesSince(_since: Date): Promise<unknown[]> {
return [];
}
}
+174
View File
@@ -0,0 +1,174 @@
/**
* Arena PLM session management.
*
* Behavioral constraints [KNOWLEDGE.md, 2026-09-04, verified by predecessor in
* live use]:
* - `POST /login` returns `arenaSessionId`; sessions have a ~25-minute TTL
* (KNOWLEDGE records only the TTL, not whether it is idle- or absolute-based).
* We cache for a configurable TTL (config default 24 min) to refresh ahead
* of expiry.
* - Concurrent logins race on Arena's side — login is serialized behind a
* promise lock so concurrent callers coalesce onto one in-flight login.
* - On a 401 from any Arena call, invalidate the cached session, re-login,
* and retry exactly once.
*/
import type { ArenaConfig } from '../config.js';
import type { LoginHealthSink } from '../health/registry.js';
import type { HttpClient } from '../http/types.js';
/** Wire values of `HealthStatus.arenaSession.state` (docs/api/worker.openapi.json). */
export type ArenaSessionState = 'none' | 'active' | 'expired' | 'refreshing';
export class ArenaApiError extends Error {
readonly status: number;
readonly body: unknown;
constructor(message: string, status: number, body?: unknown) {
super(message);
this.name = 'ArenaApiError';
this.status = status;
this.body = body;
}
}
/**
* Derived from `ArenaConfig` so the composition root can spread `config.arena`
* straight in: a field rename on either side is a compile error, not a
* silently-dropped env value (the `ttlMs` vs `sessionTtlMs` bug of 0.1.0).
*/
export interface ArenaSessionOptions
extends Pick<ArenaConfig, 'baseUrl' | 'email' | 'password' | 'workspaceId' | 'sessionTtlMs'> {
http: HttpClient;
/** Injectable clock for tests. */
now?: () => number;
/** Reports login outcomes for `GET /health` (`status: degraded` on failure). */
health?: LoginHealthSink;
}
export class ArenaSessionManager {
private readonly http: HttpClient;
private readonly baseUrl: string;
private readonly email: string;
private readonly password: string;
private readonly workspaceId: number | undefined;
private readonly ttlMs: number;
private readonly now: () => number;
private readonly health: LoginHealthSink | undefined;
private sessionId: string | null = null;
private expiresAt = 0;
/** Promise lock: while set, a login is in flight and callers must join it. */
private loginInFlight: Promise<string> | null = null;
constructor(options: ArenaSessionOptions) {
if (!Number.isFinite(options.sessionTtlMs) || options.sessionTtlMs <= 0) {
throw new Error(`ArenaSessionManager: sessionTtlMs must be a positive number, got ${options.sessionTtlMs}`);
}
this.http = options.http;
this.baseUrl = options.baseUrl.replace(/\/+$/, '');
this.email = options.email;
this.password = options.password;
this.workspaceId = options.workspaceId;
this.ttlMs = options.sessionTtlMs;
this.now = options.now ?? Date.now;
this.health = options.health;
}
/** Effective cache lifetime in ms (what the env actually wired). */
get sessionTtlMs(): number {
return this.ttlMs;
}
/**
* `refreshing` wins while a login is in flight (first login or re-login);
* otherwise `none` / `active` / `expired` describe the cached session.
*/
state(): ArenaSessionState {
if (this.loginInFlight !== null) return 'refreshing';
if (this.sessionId === null) return 'none';
return this.now() < this.expiresAt ? 'active' : 'expired';
}
/** Valid session id — cached when fresh, otherwise one coalesced login. */
async getSessionId(): Promise<string> {
if (this.sessionId !== null && this.now() < this.expiresAt) {
return this.sessionId;
}
return this.loginCoalesced();
}
/**
* Drop the cached session so the next call re-logs in. When `staleSessionId`
* is given, only invalidates if it still matches the cache — a fresher login
* may already have replaced the session the caller saw fail.
*/
invalidate(staleSessionId?: string): void {
if (staleSessionId !== undefined && staleSessionId !== this.sessionId) return;
this.sessionId = null;
this.expiresAt = 0;
}
/**
* Run an Arena call with a valid session id. If the call throws an
* `ArenaApiError` with status 401, invalidate + re-login + retry exactly
* once. Any other error (and a second 401) propagates.
*/
async withSession<T>(fn: (sessionId: string) => Promise<T>): Promise<T> {
const sessionId = await this.getSessionId();
try {
return await fn(sessionId);
} catch (err) {
if (err instanceof ArenaApiError && err.status === 401) {
this.invalidate(sessionId);
const freshSessionId = await this.getSessionId();
return fn(freshSessionId);
}
throw err;
}
}
private loginCoalesced(): Promise<string> {
if (this.loginInFlight !== null) return this.loginInFlight;
const login = this.login().finally(() => {
this.loginInFlight = null;
});
this.loginInFlight = login;
return login;
}
private async login(): Promise<string> {
try {
const sessionId = await this.requestLogin();
this.sessionId = sessionId;
this.expiresAt = this.now() + this.ttlMs;
this.health?.recordLoginSuccess();
return sessionId;
} catch (err) {
this.health?.recordLoginFailure(err);
throw err;
}
}
private async requestLogin(): Promise<string> {
const res = await this.http.request({
method: 'POST',
url: `${this.baseUrl}/login`,
headers: { 'Content-Type': 'application/json' },
body: {
email: this.email,
password: this.password,
...(this.workspaceId !== undefined ? { workspaceId: this.workspaceId } : {}),
},
});
if (res.status !== 200) {
throw new ArenaApiError(`Arena login failed with HTTP ${res.status}`, res.status, res.body);
}
const body = res.body as { arenaSessionId?: unknown } | null;
const sessionId = body?.arenaSessionId;
if (typeof sessionId !== 'string' || sessionId.length === 0) {
throw new ArenaApiError('Arena login returned no arenaSessionId', res.status, res.body);
}
return sessionId;
}
}
+39
View File
@@ -0,0 +1,39 @@
/**
* PLACEHOLDER bearer auth: shared token from env, compared timing-safely.
*
* The real webhook/admin auth scheme for the CF-hosted worker is an OPEN
* decision (PRD OQ-6). It is deliberately isolated in this one module — routes
* only ever see the returned Fastify hook — so the scheme can be swapped
* without touching anything else.
*
* Fail-closed: if no token is configured, every guarded request gets 401.
*/
import { timingSafeEqual } from 'node:crypto';
import type { FastifyReply, FastifyRequest } from 'fastify';
import { sendProblem } from '../http/problem.js';
export type AuthHook = (req: FastifyRequest, reply: FastifyReply) => Promise<unknown>;
export function makeBearerAuthHook(expectedToken: string | undefined): AuthHook {
return async function bearerAuthHook(req: FastifyRequest, reply: FastifyReply): Promise<unknown> {
const header = req.headers.authorization;
if (
expectedToken === undefined ||
expectedToken === '' ||
header === undefined ||
!header.startsWith('Bearer ') ||
!tokensMatch(header.slice('Bearer '.length), expectedToken)
) {
return sendProblem(reply, 401, 'Unauthorized', 'Missing or invalid bearer token');
}
return undefined;
};
}
function tokensMatch(candidate: string, expected: string): boolean {
const candidateBuf = Buffer.from(candidate);
const expectedBuf = Buffer.from(expected);
if (candidateBuf.length !== expectedBuf.length) return false;
return timingSafeEqual(candidateBuf, expectedBuf);
}
+118
View File
@@ -0,0 +1,118 @@
/**
* Env-only configuration. No secrets in the repo; `.env` is git-ignored and
* loaded only by `npm run dev` (`--env-file-if-exists`) — production gets its
* env from the platform.
*
* Scope note (ARN-004): business mappings are maintained IN THE TENANT, never
* here. This module only configures connectivity and execution mechanics —
* the sync chain order below is the ARN-001 "config-driven order" knob.
*/
import { DEFAULT_WEBHOOK_QUEUE_MAX_SIZE } from './queue/webhookQueue.js';
import { SYNC_OBJECT_TYPES, type SyncObjectType } from './sync/types.js';
export interface ArenaConfig {
baseUrl: string;
email: string;
password: string;
workspaceId?: number;
/** Session cache lifetime in ms — the ONE name shared with ArenaSessionOptions. */
sessionTtlMs: number;
/** Per-request timeout for the Arena transport (AbortSignal.timeout). */
httpTimeoutMs: number;
}
export interface WorkerConfig {
host: string;
port: number;
/**
* Shared bearer token for guarded endpoints. PLACEHOLDER auth — the real
* scheme is an open decision (PRD OQ-6); swapping it must only touch
* `src/auth/bearerAuth.ts`. Unset => guarded endpoints fail closed (401).
*/
bearerToken?: string;
arena: ArenaConfig;
pollIntervalMs: number;
/** ARN-001 sync chain order. Default PRODUCT → CHANGE_MASTER → BOM → DOCUMENTS. */
syncObjectOrder: SyncObjectType[];
/** Upper bound of the transient ingest buffer (ARN-003: buffer, never a store). */
webhookQueueMaxSize: number;
/** How long graceful shutdown waits for in-flight runs/polls before exiting 1. */
shutdownTimeoutMs: number;
}
export const DEFAULT_SYNC_OBJECT_ORDER: readonly SyncObjectType[] = SYNC_OBJECT_TYPES;
export const DEFAULT_SHUTDOWN_TIMEOUT_MS = 30_000;
export const DEFAULT_ARENA_HTTP_TIMEOUT_MS = 30_000;
export function loadConfig(env: NodeJS.ProcessEnv = process.env): WorkerConfig {
const config: WorkerConfig = {
host: env.WORKER_HOST ?? '0.0.0.0',
port: parsePositiveInt('WORKER_PORT', env.WORKER_PORT, 8080),
arena: {
baseUrl: (env.ARENA_API_URL ?? 'https://api.arenasolutions.com/v1').replace(/\/+$/, ''),
email: env.ARENA_API_EMAIL ?? '',
password: env.ARENA_API_PASSWORD ?? '',
// Arena sessions have a ~25-minute TTL [KNOWLEDGE.md 2026-09-04] (whether
// it is idle- or absolute-based is not recorded — do not assume either);
// cache for 24 min by default to refresh ahead of expiry.
sessionTtlMs: parsePositiveInt('ARENA_SESSION_TTL_MINUTES', env.ARENA_SESSION_TTL_MINUTES, 24) * 60_000,
httpTimeoutMs: parsePositiveInt('ARENA_HTTP_TIMEOUT_MS', env.ARENA_HTTP_TIMEOUT_MS, DEFAULT_ARENA_HTTP_TIMEOUT_MS),
},
pollIntervalMs: parsePositiveInt('ARENA_POLL_INTERVAL_SECONDS', env.ARENA_POLL_INTERVAL_SECONDS, 300) * 1000,
syncObjectOrder: parseSyncObjectOrder(env.SYNC_OBJECT_ORDER),
webhookQueueMaxSize: parsePositiveInt(
'WEBHOOK_QUEUE_MAX_SIZE',
env.WEBHOOK_QUEUE_MAX_SIZE,
DEFAULT_WEBHOOK_QUEUE_MAX_SIZE,
),
shutdownTimeoutMs: parsePositiveInt('SHUTDOWN_TIMEOUT_MS', env.SHUTDOWN_TIMEOUT_MS, DEFAULT_SHUTDOWN_TIMEOUT_MS),
};
if (env.WORKER_BEARER_TOKEN !== undefined && env.WORKER_BEARER_TOKEN !== '') {
config.bearerToken = env.WORKER_BEARER_TOKEN;
}
if (env.ARENA_WORKSPACE_ID !== undefined && env.ARENA_WORKSPACE_ID !== '') {
config.arena.workspaceId = parsePositiveInt('ARENA_WORKSPACE_ID', env.ARENA_WORKSPACE_ID, NaN);
}
return config;
}
/**
* Strict positive integer: the whole string must be digits. `parseInt` alone
* accepted `8080abc` as 8080, silently masking a corrupted env value.
*/
function parsePositiveInt(name: string, raw: string | undefined, fallback: number): number {
if (raw === undefined || raw === '') return fallback;
const trimmed = raw.trim();
const value = /^\d+$/.test(trimmed) ? Number.parseInt(trimmed, 10) : NaN;
if (!Number.isSafeInteger(value) || value <= 0) {
throw new Error(`Invalid ${name}: expected a positive integer, got "${raw}"`);
}
return value;
}
function parseSyncObjectOrder(raw: string | undefined): SyncObjectType[] {
if (raw === undefined || raw.trim() === '') {
return [...DEFAULT_SYNC_OBJECT_ORDER];
}
const entries = raw.split(',').map((entry) => entry.trim().toUpperCase());
const order: SyncObjectType[] = [];
for (const entry of entries) {
if (!isSyncObjectType(entry)) {
throw new Error(
`Invalid SYNC_OBJECT_ORDER entry "${entry}" — valid values: ${SYNC_OBJECT_TYPES.join(', ')}`,
);
}
// Same rule as the HTTP contract (`objects` has uniqueItems): an object
// type may appear at most once in the chain.
if (order.includes(entry)) {
throw new Error(`Invalid SYNC_OBJECT_ORDER: duplicate entry "${entry}" — each object type at most once`);
}
order.push(entry);
}
return order;
}
function isSyncObjectType(value: string): value is SyncObjectType {
return (SYNC_OBJECT_TYPES as readonly string[]).includes(value);
}
+49
View File
@@ -0,0 +1,49 @@
/**
* Public error phrases — the ONLY text derived from a caught error that may
* reach a wire response (`HealthStatus.poller.lastError`, `SyncRun.error`,
* `SyncObjectProgress.error`; governed contract docs/api/worker.openapi.json).
*
* Allowlist keyed on error class / upstream HTTP status, mirroring the 502
* mapping in server.ts: a raw `err.message` is never copied out, because
* Arena error bodies can echo request data (and, for the future S/4HANA leg,
* OData error payloads can echo business data). Anything not on the list gets
* the unclassified phrase; the full error stays in the worker log, which is
* where diagnosis belongs (index.ts logs `{ name, status, message }`).
*
* `TimeoutError` is what Node's fetch rejects with when the request's
* `AbortSignal.timeout(...)` fires (measured on this box, Node 24.15.0,
* 2026-09-14: `name=TimeoutError`, a DOMException).
*/
import { ArenaApiError } from '../arena/session.js';
import { IngestBufferFullError } from '../poller/arenaDeltaPoll.js';
export const UNCLASSIFIED_ERROR_PHRASE = 'Unclassified worker error; see worker logs.';
export const INGEST_BUFFER_FULL_PHRASE = 'Ingest buffer full at poll time.';
export const ARENA_TIMEOUT_PHRASE = 'Upstream Arena PLM request timed out.';
export const ARENA_MALFORMED_PHRASE = 'Upstream Arena PLM response was malformed.';
export function arenaStatusPhrase(status: number): string {
// A 2xx ArenaApiError is a response we could not interpret (e.g. login
// answered 200 without an arenaSessionId), not an upstream failure code.
if (status >= 200 && status < 300) return ARENA_MALFORMED_PHRASE;
return `Upstream Arena PLM request failed (HTTP ${status}).`;
}
export function publicErrorPhrase(err: unknown): string {
if (err instanceof ArenaApiError) return arenaStatusPhrase(err.status);
if (err instanceof IngestBufferFullError) return INGEST_BUFFER_FULL_PHRASE;
if (hasName(err, 'TimeoutError')) return ARENA_TIMEOUT_PHRASE;
return UNCLASSIFIED_ERROR_PHRASE;
}
/** Log-safe summary of an error: class name, message, and upstream status if any. */
export function describeErrorForLog(err: unknown): { name: string; message: string; status?: number } {
if (err instanceof ArenaApiError) return { name: err.name, message: err.message, status: err.status };
if (err instanceof Error) return { name: err.name, message: err.message };
return { name: typeof err, message: String(err) };
}
function hasName(err: unknown, name: string): boolean {
return typeof err === 'object' && err !== null && 'name' in err && (err as { name: unknown }).name === name;
}
+77
View File
@@ -0,0 +1,77 @@
/**
* Health registry — the tiny in-process sink the delta poller and the Arena
* session manager report into; `GET /health` reads it (governed contract:
* docs/api/worker.openapi.json, `HealthStatus`).
*
* `status` is `degraded` when the MOST RECENT poll tick or the MOST RECENT
* Arena login failed, and returns to `ok` on the next success of that same
* dependency. Nothing here is a ledger (ARN-003): it is process-local
* observability state and is meant to reset on restart.
*
* `lastError` is a FIXED PHRASE from the public allowlist
* (src/errors/publicPhrase.ts), never `err.message` — Arena error bodies can
* echo request data and this surface is unauthenticated.
*/
import { publicErrorPhrase } from '../errors/publicPhrase.js';
export interface PollHealthSink {
/** A poll tick that started at `startedAt` completed without error. */
recordPollSuccess(startedAt: Date): void;
/** A poll tick that started at `startedAt` failed with `err`. */
recordPollFailure(startedAt: Date, err: unknown): void;
}
export interface LoginHealthSink {
recordLoginSuccess(): void;
recordLoginFailure(err: unknown): void;
}
/** Wire shape of `HealthStatus.poller`. */
export interface PollerHealth {
lastRunAt?: string;
lastSuccessAt?: string;
lastError?: string;
}
export type HealthStatusValue = 'ok' | 'degraded';
export class HealthRegistry implements PollHealthSink, LoginHealthSink {
private poller: PollerHealth | undefined;
private lastPollOk: boolean | undefined;
private lastLoginOk: boolean | undefined;
recordPollSuccess(startedAt: Date): void {
const at = startedAt.toISOString();
this.poller = { lastRunAt: at, lastSuccessAt: at };
this.lastPollOk = true;
}
recordPollFailure(startedAt: Date, err: unknown): void {
const previous = this.poller ?? {};
this.poller = {
lastRunAt: startedAt.toISOString(),
...(previous.lastSuccessAt !== undefined ? { lastSuccessAt: previous.lastSuccessAt } : {}),
lastError: publicErrorPhrase(err),
};
this.lastPollOk = false;
}
recordLoginSuccess(): void {
this.lastLoginOk = true;
}
recordLoginFailure(_err: unknown): void {
this.lastLoginOk = false;
}
status(): HealthStatusValue {
return this.lastPollOk === false || this.lastLoginOk === false ? 'degraded' : 'ok';
}
/** `undefined` until the poller has ticked at least once (contract: `poller` optional). */
pollerSnapshot(): PollerHealth | undefined {
return this.poller === undefined ? undefined : { ...this.poller };
}
}
+37
View File
@@ -0,0 +1,37 @@
/**
* RFC 9457 Problem Details for HTTP APIs — the governed contract
* (docs/api/worker.openapi.json) specifies `application/problem+json` for all
* error responses (400/401/404/502/503).
*
* We omit the `type` member: per RFC 9457 §3.1.1 an absent `type` is
* equivalent to "about:blank", in which case `title` mirrors the HTTP status
* phrase — which is exactly what we send.
*/
import type { FastifyReply } from 'fastify';
export const PROBLEM_CONTENT_TYPE = 'application/problem+json';
export interface ProblemDetails {
title: string;
status: number;
detail?: string;
}
export function sendProblem(
reply: FastifyReply,
status: number,
title: string,
detail?: string,
/** Extra response headers (e.g. `Retry-After` on 503). */
headers: Record<string, string> = {},
): FastifyReply {
const body: ProblemDetails = detail === undefined ? { title, status } : { title, status, detail };
// Serialize explicitly: Fastify's default JSON serializer targets
// application/json, so send a pre-serialized string under the problem type.
reply.code(status).header('content-type', `${PROBLEM_CONTENT_TYPE}; charset=utf-8`);
for (const [name, value] of Object.entries(headers)) {
reply.header(name, value);
}
return reply.send(JSON.stringify(body));
}
+73
View File
@@ -0,0 +1,73 @@
/**
* Minimal HTTP abstraction for the Arena client side of the worker.
*
* Exists so the Arena session/client modules can be unit-tested against a
* mocked transport (no live network calls in tests). This abstraction is for
* Arena ONLY — S/4HANA calls will go through the SAP Cloud SDK and the
* `mc-s4hana` destination, never through this client (ADR-001).
*/
export interface HttpRequest {
method: 'GET' | 'POST' | 'PUT' | 'DELETE';
url: string;
headers?: Record<string, string>;
/** JSON-serializable request body. */
body?: unknown;
}
export interface HttpResponse {
status: number;
body: unknown;
}
export interface HttpClient {
request(req: HttpRequest): Promise<HttpResponse>;
}
export interface FetchHttpClientOptions {
/** Per-request deadline (config `ARENA_HTTP_TIMEOUT_MS`). */
timeoutMs: number;
/** Injectable fetch for tests; defaults to Node's global fetch. */
fetchImpl?: typeof fetch;
}
/**
* Production transport on Node's global fetch. Every request carries a
* deadline via `AbortSignal.timeout(delay)` — "returns a new AbortSignal
* which will be aborted in `delay` milliseconds"; fetch rejects when its
* signal aborts, so a hung Arena connection can never pin a poll tick or a
* login forever.
* [source: context7 /websites/nodejs_latest-v24_x_api globals "Static method: AbortSignal.timeout(delay)", verified 2026-09-14]
*/
export function createFetchHttpClient(options: FetchHttpClientOptions): HttpClient {
if (!Number.isFinite(options.timeoutMs) || options.timeoutMs <= 0) {
throw new Error(`createFetchHttpClient: timeoutMs must be a positive number, got ${options.timeoutMs}`);
}
const fetchImpl = options.fetchImpl ?? fetch;
return {
async request(req: HttpRequest): Promise<HttpResponse> {
const headers: Record<string, string> = { ...req.headers };
let payload: string | undefined;
if (req.body !== undefined) {
payload = JSON.stringify(req.body);
headers['Content-Type'] ??= 'application/json';
}
const res = await fetchImpl(req.url, {
method: req.method,
headers,
body: payload,
signal: AbortSignal.timeout(options.timeoutMs),
});
const text = await res.text();
let body: unknown = null;
if (text.length > 0) {
try {
body = JSON.parse(text);
} catch {
body = text;
}
}
return { status: res.status, body };
},
};
}
+64
View File
@@ -0,0 +1,64 @@
/**
* Composition root. Builds the worker from env config and starts the HTTP
* surface plus the delta poller. No Arena call happens at startup — the
* session logs in lazily on first use.
*/
import { ArenaClient } from './arena/client.js';
import { ArenaSessionManager } from './arena/session.js';
import { loadConfig } from './config.js';
import { describeErrorForLog } from './errors/publicPhrase.js';
import { HealthRegistry } from './health/registry.js';
import { createFetchHttpClient } from './http/types.js';
import { makeArenaDeltaPoll } from './poller/arenaDeltaPoll.js';
import { DeltaPoller } from './poller/deltaPoller.js';
import { InMemoryEventQueue } from './queue/webhookQueue.js';
import { buildServer } from './server.js';
import { registerGracefulShutdown } from './shutdown.js';
import { makeNoopExecutors } from './sync/executors.js';
import { InMemoryRunStore } from './sync/runStore.js';
import { SyncRunner } from './sync/runner.js';
async function main(): Promise<void> {
const config = loadConfig();
const health = new HealthRegistry();
// `sessionConfig` is exactly ArenaSessionOptions' Pick of ArenaConfig — a
// field mismatch between the two is a compile error here.
const { httpTimeoutMs, ...sessionConfig } = config.arena;
const arenaSession = new ArenaSessionManager({
http: createFetchHttpClient({ timeoutMs: httpTimeoutMs }),
health,
...sessionConfig,
});
const arenaClient = new ArenaClient(arenaSession);
const queue = new InMemoryEventQueue({ maxSize: config.webhookQueueMaxSize });
const runStore = new InMemoryRunStore();
const runner = new SyncRunner(runStore, makeNoopExecutors());
const app = buildServer({ config, queue, runner, runStore, arenaSession, health, logger: true });
const poller = new DeltaPoller({
intervalMs: config.pollIntervalMs,
poll: makeArenaDeltaPoll({ client: arenaClient, queue, log: app.log }),
health,
onError: (err) => {
// Log class/status/message only — never the error object, whose
// ArenaApiError.body can echo request data (same rule as server.ts).
app.log.error(describeErrorForLog(err), 'delta poll failed');
},
});
const shutdown = registerGracefulShutdown({ app, poller, runner, timeoutMs: config.shutdownTimeoutMs });
process.once('SIGINT', shutdown.onSignal);
process.once('SIGTERM', shutdown.onSignal);
await app.listen({ host: config.host, port: config.port });
poller.start();
}
main().catch((err: unknown) => {
console.error(err);
process.exit(1);
});
+66
View File
@@ -0,0 +1,66 @@
/**
* The poll callback the DeltaPoller drives in production: fetch Arena changes
* since the cursor and push them into the ingest buffer. Kept out of the
* composition root so the full-queue path is unit-testable.
*
* Buffer-full policy (review ruling B1, 2026-09-14): a full ingest buffer is
* a TICK FAILURE, never a silent drop. If the buffer is already full the tick
* throws before calling Arena (no wasted fetch, no login); if it fills while
* enqueuing, the tick throws after enqueuing what fit. Either way the
* DeltaPoller keeps its cursor (it advances only on resolve), `GET /health`
* flips to `degraded`, and the next tick re-covers the same window. Duplicates
* on recovery are acceptable — SAP is the ledger (KNOWLEDGE.md 2026-09-04) —
* and the buffer never spills to worker-local storage (ARN-003).
*/
import type { ArenaClient } from '../arena/client.js';
import type { InMemoryEventQueue } from '../queue/webhookQueue.js';
export interface PollLogger {
warn(obj: object, msg: string): void;
}
export interface ArenaDeltaPollDeps {
client: Pick<ArenaClient, 'fetchChangesSince'>;
queue: Pick<InMemoryEventQueue, 'enqueue' | 'isFull'>;
log: PollLogger;
}
/** Thrown by the poll when the ingest buffer cannot take the tick's changes. */
export class IngestBufferFullError extends Error {
readonly fetched: number;
readonly dropped: number;
constructor(fetched: number, dropped: number) {
super(
fetched === 0
? 'ingest buffer full before poll; tick skipped'
: `ingest buffer full during poll; ${dropped} of ${fetched} changes not enqueued`,
);
this.name = 'IngestBufferFullError';
this.fetched = fetched;
this.dropped = dropped;
}
}
export function makeArenaDeltaPoll(deps: ArenaDeltaPollDeps): (since: Date) => Promise<void> {
return async (since: Date): Promise<void> => {
if (deps.queue.isFull) {
deps.log.warn({ since: since.toISOString() }, 'ingest queue full; skipping Arena fetch, tick failed');
throw new IngestBufferFullError(0, 0);
}
const changes = await deps.client.fetchChangesSince(since);
let dropped = 0;
for (const payload of changes) {
const result = deps.queue.enqueue({ source: 'poll', payload });
if (!result.accepted) dropped += 1;
}
if (dropped > 0) {
deps.log.warn(
{ dropped, fetched: changes.length, since: since.toISOString() },
'ingest queue full; polled Arena changes not enqueued, tick failed (cursor holds)',
);
throw new IngestBufferFullError(changes.length, dropped);
}
};
}
+91
View File
@@ -0,0 +1,91 @@
/**
* Delta poller (ARN-005): a start/stop interval loop that OWNS the poll
* cursor. Each tick captures `since` (the cursor) and `asOf` (now) before
* calling `poll(since)`, and advances the cursor to `asOf` only after the poll
* resolves — a failed poll keeps its window, so the next tick re-covers it.
* Overlapping ticks are skipped (a slow poll is never run concurrently with
* itself).
*
* Cursor durability: the cursor starts at construction time (≈ process
* start), so a restart re-polls from then and any window before it is not
* re-derived here. ACCEPTED for now — the durable cursor is PRD V-3 (Arena's
* outbound-integration queue with its `reconciled` flag, held by Arena, see
* KNOWLEDGE.md 2026-09-10) — and it must never become a worker-local file or
* SQLite cursor (ARN-003).
*/
import type { PollHealthSink } from '../health/registry.js';
export interface DeltaPollerOptions {
intervalMs: number;
/** Receives the cursor: fetch changes at or after `since`. */
poll: (since: Date) => Promise<void> | void;
onError?: (err: unknown) => void;
/** Injectable clock for tests. */
now?: () => Date;
/** Reports tick outcomes for `GET /health`. */
health?: PollHealthSink;
}
export class DeltaPoller {
private timer: ReturnType<typeof setInterval> | null = null;
private inFlight: Promise<void> | null = null;
private cursor: Date;
private readonly now: () => Date;
constructor(private readonly options: DeltaPollerOptions) {
this.now = options.now ?? (() => new Date());
this.cursor = this.now();
}
get running(): boolean {
return this.timer !== null;
}
/** The `since` the next tick will use (diagnostics/tests). */
get nextSince(): Date {
return new Date(this.cursor.getTime());
}
start(): void {
if (this.timer !== null) return;
this.timer = setInterval(() => {
this.tick();
}, this.options.intervalMs);
// Do not keep the process alive for the poller alone.
this.timer.unref?.();
}
/**
* Stops scheduling further ticks and resolves once the in-flight tick (if
* any) has settled — so shutdown can await it instead of racing it.
*/
stop(): Promise<void> {
if (this.timer !== null) {
clearInterval(this.timer);
this.timer = null;
}
return this.inFlight ?? Promise.resolve();
}
private tick(): void {
if (this.inFlight !== null) return;
this.inFlight = this.runTick().finally(() => {
this.inFlight = null;
});
}
private async runTick(): Promise<void> {
const since = this.cursor;
const asOf = this.now();
try {
await this.options.poll(since);
// Advance ONLY after success: a rejected poll keeps `since`.
this.cursor = asOf;
this.options.health?.recordPollSuccess(asOf);
} catch (err) {
this.options.health?.recordPollFailure(asOf, err);
this.options.onError?.(err);
}
}
}
+81
View File
@@ -0,0 +1,81 @@
/**
* In-memory, BOUNDED buffer for raw Arena events (webhook pushes + delta-poll
* finds).
*
* Scope note (ARN-003): this queue is a transient ingest BUFFER, not a system
* of record — durable staging lives in the in-tenant RAP BO, and idempotency
* never depends on worker-local state (SAP is the ledger, KNOWLEDGE.md
* 2026-09-04). It must never be "upgraded" to droplet-local SQLite; the
* predecessor lost its queue on every restart doing exactly that. When the
* bound is hit the answer is backpressure (webhook: 503 + Retry-After; poll:
* the tick FAILS so the cursor holds and the window is re-polled — see
* poller/arenaDeltaPoll.ts), never spilling to disk and never unbounded growth.
*/
import { randomUUID } from 'node:crypto';
export type ArenaEventSource = 'webhook' | 'poll';
export interface QueuedArenaEvent {
id: string;
receivedAt: string;
source: ArenaEventSource;
/** Raw, unvalidated Arena payload. */
payload: unknown;
}
export type EnqueueResult =
| { accepted: true; event: QueuedArenaEvent }
| { accepted: false; reason: 'full' };
/** Default bound; config `WEBHOOK_QUEUE_MAX_SIZE` overrides it. */
export const DEFAULT_WEBHOOK_QUEUE_MAX_SIZE = 10_000;
export interface EventQueueOptions {
/** Positive integer; config `WEBHOOK_QUEUE_MAX_SIZE` (default 10000). */
maxSize?: number;
}
export class InMemoryEventQueue {
private readonly items: QueuedArenaEvent[] = [];
readonly maxSize: number;
constructor(options: EventQueueOptions = {}) {
const maxSize = options.maxSize ?? DEFAULT_WEBHOOK_QUEUE_MAX_SIZE;
if (!Number.isSafeInteger(maxSize) || maxSize <= 0) {
throw new Error(`InMemoryEventQueue: maxSize must be a positive integer, got ${maxSize}`);
}
this.maxSize = maxSize;
}
enqueue(input: { source: ArenaEventSource; payload: unknown }): EnqueueResult {
if (this.items.length >= this.maxSize) {
return { accepted: false, reason: 'full' };
}
const event: QueuedArenaEvent = {
id: randomUUID(),
receivedAt: new Date().toISOString(),
source: input.source,
payload: input.payload,
};
this.items.push(event);
return { accepted: true, event };
}
dequeue(): QueuedArenaEvent | undefined {
return this.items.shift();
}
get size(): number {
return this.items.length;
}
get isFull(): boolean {
return this.items.length >= this.maxSize;
}
/** Read-only snapshot (tests, diagnostics). */
peekAll(): readonly QueuedArenaEvent[] {
return [...this.items];
}
}
+211
View File
@@ -0,0 +1,211 @@
/**
* HTTP surface of the connectivity worker — machine-to-machine only, no UI,
* no user-facing URLs (ADR-002). Endpoint shapes follow the governed contract
* docs/api/worker.openapi.json (API-first, CLAUDE.md rule 7); conformance is
* reviewed at that seam.
*/
import { STATUS_CODES } from 'node:http';
import Fastify, { type FastifyInstance } from 'fastify';
import { makeBearerAuthHook } from './auth/bearerAuth.js';
import type { WorkerConfig } from './config.js';
import { ArenaApiError, type ArenaSessionManager } from './arena/session.js';
import type { HealthRegistry, PollerHealth } from './health/registry.js';
import { sendProblem } from './http/problem.js';
import type { InMemoryEventQueue } from './queue/webhookQueue.js';
import type { RunStore } from './sync/runStore.js';
import type { SyncRunner } from './sync/runner.js';
import {
SYNC_OBJECT_TYPES,
SYNC_SCOPES,
type SyncObjectType,
type SyncRun,
type SyncScope,
} from './sync/types.js';
import { WORKER_VERSION } from './version.js';
/** Contract: `Retry-After` delay-seconds sent with a 503 from the webhook intake. */
export const WEBHOOK_RETRY_AFTER_SECONDS = 30;
export interface ServerDeps {
config: WorkerConfig;
queue: InMemoryEventQueue;
runner: SyncRunner;
runStore: RunStore;
arenaSession: ArenaSessionManager;
health: HealthRegistry;
/** Enable request logging (off by default so tests stay quiet). */
logger?: boolean;
/**
* Max request body in bytes. Unset => Fastify's default 1 MiB (1048576);
* over the limit the framework answers 413 before the handler runs
* (contract: `PayloadTooLarge`). Exposed so the conformance suite can
* exercise 413 with a small limit.
* [source: context7 /fastify/fastify docs/Reference/Server.md `bodyLimit`, verified 2026-09-14]
*/
bodyLimit?: number;
}
/** Wire shape of GET /health (contract `HealthStatus`). */
interface HealthStatus {
status: 'ok' | 'degraded';
version: string;
uptimeSeconds: number;
arenaSession: { state: ReturnType<ArenaSessionManager['state']> };
poller?: PollerHealth;
}
export function buildServer(deps: ServerDeps): FastifyInstance {
const { config, queue, runner, runStore, arenaSession, health } = deps;
const app = Fastify({
logger: deps.logger ?? false,
// Seam ruling (2026-09-10): STRICT VALIDATION — the contract's
// `additionalProperties: false` on SyncRunRequest must mean 400, and a
// body is validated exactly as sent. Fastify's BASELINE Ajv config is
// { coerceTypes: 'array', useDefaults: true, removeAdditional: true }
// (Ajv's own defaults are all `false`), which would (a) silently STRIP an
// unknown property such as a typo'd `object`, (b) unwrap `["full"]` to
// `"full"` and stringify scalars, and (c) inject schema defaults. On a
// machine-to-machine admin API each of those hides a caller bug, so all
// three are turned off here. `allErrors` stays at Fastify's `false`
// (documented DoS surface when true).
// [source: context7 /fastify/fastify docs/Reference/Validation-and-Serialization.md "Fastify Baseline Ajv Configuration"; docs/Reference/Server.md "ajv customOptions", verified 2026-09-14]
// [source: context7 /ajv-validator/ajv docs/options.md "removeAdditional / useDefaults / coerceTypes" defaults, verified 2026-09-14]
ajv: { customOptions: { removeAdditional: false, coerceTypes: false, useDefaults: false } },
...(deps.bodyLimit !== undefined ? { bodyLimit: deps.bodyLimit } : {}),
});
// JSON-only surface. Fastify natively parses `application/json` AND
// `text/plain`; removing the latter makes any non-JSON media type (and a
// body without a Content-Type) a 415 before the handler runs, instead of a
// text body reaching schema validation as a string. Contract:
// `UnsupportedMediaType` (415) on both POST operations.
// [source: context7 /fastify/fastify docs/Reference/ContentTypeParser.md "removeContentTypeParser"; docs/Reference/Errors.md FST_ERR_CTP_INVALID_MEDIA_TYPE, verified 2026-09-14]
app.removeContentTypeParser('text/plain');
// Auth runs in `onRequest` (before body parsing/validation) so an
// unauthenticated request is always 401, never 400.
const requireBearer = makeBearerAuthHook(config.bearerToken);
// Contract: ALL error bodies are RFC 9457 application/problem+json.
// Covers schema-validation 400s (error.validation), body-parse 400s,
// framework-raised 413 (over bodyLimit) / 415 (unsupported media type),
// upstream Arena failures (502, body never forwarded), and anything else
// Fastify surfaces (500, the contract's `default`). 5xx details are not
// leaked; 4xx `detail` is the framework's or Ajv's own fixed message.
// [source: context7 /fastify/fastify docs/Reference/Errors.md FST_ERR_CTP_BODY_TOO_LARGE (413), FST_ERR_CTP_INVALID_MEDIA_TYPE (415), verified 2026-09-14]
app.setErrorHandler((err: unknown, req, reply) => {
if (err instanceof ArenaApiError) {
// Upstream failure, not ours. Log the status only — Arena error bodies
// can echo request data — and answer with the fixed contract phrase.
req.log.error({ upstreamStatus: err.status, message: err.message }, 'upstream Arena request failed');
return sendProblem(reply, 502, 'Bad Gateway', 'Upstream Arena PLM request failed.');
}
const statusCode =
typeof err === 'object' &&
err !== null &&
'statusCode' in err &&
typeof (err as { statusCode: unknown }).statusCode === 'number'
? (err as { statusCode: number }).statusCode
: undefined;
const status = statusCode !== undefined && statusCode >= 400 ? statusCode : 500;
const title = STATUS_CODES[status] ?? 'Error';
if (status >= 500) {
req.log.error({ err }, 'unhandled error');
return sendProblem(reply, status, title);
}
const detail = err instanceof Error ? err.message : String(err);
return sendProblem(reply, status, title, detail);
});
// Unmatched path OR unmatched method on a known path: Fastify's router
// routes both to the not-found handler (404, never 405 — probed 2026-09-14).
// Contract: `x-unmatched-routes` / every operation's `default` response.
app.setNotFoundHandler((req, reply) =>
sendProblem(reply, 404, 'Not Found', `Route ${req.method} ${req.url} not found`),
);
app.get('/health', async (): Promise<HealthStatus> => {
const body: HealthStatus = {
status: health.status(),
version: WORKER_VERSION,
uptimeSeconds: Math.round(process.uptime()),
arenaSession: { state: arenaSession.state() },
};
const poller = health.pollerSnapshot();
if (poller !== undefined) body.poller = poller;
return body;
});
app.post(
'/webhooks/arena',
{
onRequest: requireBearer,
schema: {
// Any JSON object is accepted as-is; payloads stay raw until the
// ingest pipeline interprets them. Non-objects and invalid JSON => 400.
body: { type: 'object' },
},
},
async (req, reply) => {
const result = queue.enqueue({ source: 'webhook', payload: req.body });
if (!result.accepted) {
// Bounded buffer full: backpressure to the sender, never a spill to
// worker-local storage (ARN-003).
return sendProblem(reply, 503, 'Service Unavailable', 'Ingest queue is full; retry later.', {
'retry-after': String(WEBHOOK_RETRY_AFTER_SECONDS),
});
}
return reply.code(202).send({ received: true });
},
);
app.post<{ Body: { scope: SyncScope; objects?: SyncObjectType[] } }>(
'/admin/sync-runs',
{
onRequest: requireBearer,
schema: {
body: {
type: 'object',
additionalProperties: false,
required: ['scope'],
properties: {
scope: { type: 'string', enum: [...SYNC_SCOPES] },
// Optional: selects AND orders the object types for this run.
objects: {
type: 'array',
minItems: 1,
uniqueItems: true,
items: { type: 'string', enum: [...SYNC_OBJECT_TYPES] },
},
},
},
},
},
async (req, reply) => {
// Request `objects` wins; otherwise the config-driven default (ARN-001).
const objectOrder: readonly SyncObjectType[] = req.body.objects ?? config.syncObjectOrder;
const run = runner.submit(req.body.scope, objectOrder);
return reply.code(201).send(snapshotRun(run));
},
);
app.get<{ Params: { runId: string } }>(
'/admin/sync-runs/:runId',
{ onRequest: requireBearer },
async (req, reply) => {
const run = runStore.get(req.params.runId);
if (run === undefined) {
return sendProblem(reply, 404, 'Not Found', `No sync run with id ${req.params.runId}`);
}
return reply.code(200).send(snapshotRun(run));
},
);
return app;
}
/** Deep copy so async run-state mutation cannot race response serialization. */
function snapshotRun(run: SyncRun): SyncRun {
return structuredClone(run);
}
+81
View File
@@ -0,0 +1,81 @@
/**
* Graceful shutdown. `fastify.close()` "runs the 'onClose' hook, and stops
* accepting new connections" and "by this time, the HTTP server has stopped
* listening and connections have been drained" — so the onClose hook is the
* right place to stop the poller and wait for in-flight sync runs; the hook
* may return a Promise and close() awaits it.
* [source: context7 /fastify/fastify docs/Reference/Hooks.md "onClose" and docs/Reference/Server.md "close", verified 2026-09-14]
*
* Exit code: 0 when every in-flight run/tick settled inside the timeout,
* 1 when the timeout won (something was still running — an operator should
* check SAP for a half-applied chain; SAP is the ledger, KNOWLEDGE.md
* 2026-09-04) or when close() itself failed.
*/
import type { FastifyInstance } from 'fastify';
export type DrainOutcome = 'drained' | 'timeout';
/** Race `work` against a deadline; the deadline timer never outlives the race. */
export async function drainWithTimeout(work: Promise<unknown>, timeoutMs: number): Promise<DrainOutcome> {
let timer: ReturnType<typeof setTimeout> | undefined;
const deadline = new Promise<'timeout'>((resolve) => {
timer = setTimeout(() => resolve('timeout'), timeoutMs);
});
try {
return await Promise.race([work.then((): DrainOutcome => 'drained'), deadline]);
} finally {
clearTimeout(timer);
}
}
export interface GracefulShutdownDeps {
app: FastifyInstance;
/** Stops scheduling and resolves when the in-flight tick settles. */
poller: { stop(): Promise<void> };
/** Resolves when every in-flight run has settled. */
runner: { drain(): Promise<void> };
timeoutMs: number;
/** Injectable for tests; defaults to process.exit. */
exit?: (code: number) => void;
}
export interface GracefulShutdown {
/** Signal handler: one function registered for both SIGINT and SIGTERM. */
onSignal: (signal: string) => void;
/** Whether the last close() hit the drain timeout (diagnostics/tests). */
readonly timedOut: boolean;
}
export function registerGracefulShutdown(deps: GracefulShutdownDeps): GracefulShutdown {
const { app, timeoutMs } = deps;
const exit = deps.exit ?? ((code: number) => process.exit(code));
let timedOut = false;
app.addHook('onClose', async () => {
const pollerStopped = deps.poller.stop();
const outcome = await drainWithTimeout(Promise.all([deps.runner.drain(), pollerStopped]), timeoutMs);
if (outcome === 'timeout') {
timedOut = true;
app.log.warn({ timeoutMs }, 'shutdown timeout: in-flight sync runs or poll tick did not settle');
}
});
const onSignal = (signal: string): void => {
app.log.info({ signal }, 'shutdown requested');
app.close().then(
() => exit(timedOut ? 1 : 0),
(err: unknown) => {
app.log.error({ err }, 'shutdown failed');
exit(1);
},
);
};
return {
onSignal,
get timedOut() {
return timedOut;
},
};
}
+20
View File
@@ -0,0 +1,20 @@
/**
* Build wave 1: no-op executors only. See `SyncExecutor` in ./types.ts for the
* binding constraints the real S/4HANA implementations must honor (SAP Cloud
* SDK + `mc-s4hana` destination; KNOWLEDGE.md tombstones).
*/
import { SYNC_OBJECT_TYPES } from './types.js';
import type { SyncExecutionContext, SyncExecutionResult, SyncExecutor, SyncObjectType } from './types.js';
export class NoopSyncExecutor implements SyncExecutor {
constructor(readonly objectType: SyncObjectType) {}
async execute(_ctx: SyncExecutionContext): Promise<SyncExecutionResult> {
return { processed: 0 };
}
}
export function makeNoopExecutors(): Map<SyncObjectType, SyncExecutor> {
return new Map(SYNC_OBJECT_TYPES.map((objectType) => [objectType, new NoopSyncExecutor(objectType)]));
}
+28
View File
@@ -0,0 +1,28 @@
/**
* In-memory sync-run registry.
*
* Scope note (ARN-003): run objects are runtime observability state for the
* worker process, not the ledger. Idempotency of SAP writes never depends on
* this store — SAP is the ledger via the Arena ECO number in
* `ReasonForChangeText` [KNOWLEDGE.md 2026-09-04]. Anything that must survive
* a restart belongs in the in-tenant RAP BO, never droplet-local storage.
*/
import type { SyncRun } from './types.js';
export interface RunStore {
put(run: SyncRun): void;
get(runId: string): SyncRun | undefined;
}
export class InMemoryRunStore implements RunStore {
private readonly runs = new Map<string, SyncRun>();
put(run: SyncRun): void {
this.runs.set(run.runId, run);
}
get(runId: string): SyncRun | undefined {
return this.runs.get(runId);
}
}
+105
View File
@@ -0,0 +1,105 @@
/**
* Sync run state machine: queued → running → completed | failed.
*
* Objects execute strictly sequentially in the configured order (ARN-001) —
* later object types depend on earlier ones, so the chain stops at the first
* failure and the remaining objects stay `pending`.
*
* Wire hygiene: `processed` appears only once an object has started (absent
* while `pending`, contract B4), and every `error` string is a fixed phrase
* from the public allowlist (src/errors/publicPhrase.ts) — an executor's raw
* error (which for the S/4HANA leg may echo OData payloads) never reaches the
* run snapshot; it is the caller's job to log it.
*/
import { randomUUID } from 'node:crypto';
import { publicErrorPhrase } from '../errors/publicPhrase.js';
import type { RunStore } from './runStore.js';
import type { SyncExecutor, SyncObjectType, SyncRun, SyncScope } from './types.js';
export class SyncRunner {
private readonly inFlight = new Map<string, Promise<void>>();
constructor(
private readonly store: RunStore,
private readonly executors: ReadonlyMap<SyncObjectType, SyncExecutor>,
private readonly now: () => Date = () => new Date(),
) {}
/**
* Create a run in state `queued` and schedule asynchronous processing.
* Returns synchronously so callers can snapshot the queued state.
*/
submit(scope: SyncScope, objectOrder: readonly SyncObjectType[]): SyncRun {
const run: SyncRun = {
runId: randomUUID(),
scope,
state: 'queued',
// Execution order is carried by the objects array itself (contract shape).
// `processed` is deliberately absent while pending (contract B4).
objects: objectOrder.map((objectType) => ({ objectType, state: 'pending' })),
startedAt: this.now().toISOString(),
};
this.store.put(run);
const done = this.process(run);
this.inFlight.set(run.runId, done);
void done.finally(() => this.inFlight.delete(run.runId));
return run;
}
/** Await completion of a run's processing (test/diagnostic hook). */
async waitFor(runId: string): Promise<SyncRun | undefined> {
await (this.inFlight.get(runId) ?? Promise.resolve());
return this.store.get(runId);
}
/** Number of runs still processing (diagnostics/tests). */
get inFlightCount(): number {
return this.inFlight.size;
}
/**
* Resolve once every run that was in flight at call time has settled —
* success or failure alike (allSettled). Used by graceful shutdown so
* `app.close()` does not exit under a half-executed BOM chain.
*/
async drain(): Promise<void> {
await Promise.allSettled([...this.inFlight.values()]);
}
private async process(run: SyncRun): Promise<void> {
// Yield one microtask so submit() returns while the run is still `queued`.
await Promise.resolve();
run.state = 'running';
for (const objectResult of run.objects) {
const executor = this.executors.get(objectResult.objectType);
if (executor === undefined) {
objectResult.state = 'failed';
objectResult.error = 'no executor registered';
this.fail(run, `${objectResult.objectType}: no executor registered`);
return;
}
objectResult.state = 'running';
objectResult.processed = 0;
try {
const result = await executor.execute({ runId: run.runId, scope: run.scope });
objectResult.processed = result.processed;
objectResult.state = 'completed';
} catch (err) {
const phrase = publicErrorPhrase(err);
objectResult.state = 'failed';
objectResult.error = phrase;
this.fail(run, `${objectResult.objectType}: ${phrase}`);
return;
}
}
run.state = 'completed';
run.finishedAt = this.now().toISOString();
}
private fail(run: SyncRun, error: string): void {
run.state = 'failed';
run.error = error;
run.finishedAt = this.now().toISOString();
}
}
+73
View File
@@ -0,0 +1,73 @@
/**
* Sync domain types — the typed seam between the worker's run state machine
* and the (future) S/4HANA execution leg.
*/
/** Chain tokens per PRD ARN-001 — the last one is `DOCUMENTS` (plural), as in the requirement. */
export const SYNC_OBJECT_TYPES = ['PRODUCT', 'CHANGE_MASTER', 'BOM', 'DOCUMENTS'] as const;
export type SyncObjectType = (typeof SYNC_OBJECT_TYPES)[number];
export const SYNC_SCOPES = ['full', 'delta'] as const;
export type SyncScope = (typeof SYNC_SCOPES)[number];
export type SyncRunState = 'queued' | 'running' | 'completed' | 'failed';
export type SyncObjectState = 'pending' | 'running' | 'completed' | 'failed';
export interface SyncObjectResult {
objectType: SyncObjectType;
state: SyncObjectState;
/** Absent while `pending`; set once the object has started (contract B4). */
processed?: number;
/** Fixed public phrase (src/errors/publicPhrase.ts), never a raw error message. */
error?: string;
}
/**
* Wire shape of a sync run (governed contract: docs/api/worker.openapi.json).
* Execution order is carried by the `objects` array itself (execution-ordered,
* all `pending` at creation) — there is no separate order field on the wire.
* The order is config-driven per ARN-001 (default PRODUCT → CHANGE_MASTER →
* BOM → DOCUMENTS) unless the request selects/orders objects explicitly.
*/
export interface SyncRun {
runId: string;
scope: SyncScope;
state: SyncRunState;
objects: SyncObjectResult[];
startedAt: string;
finishedAt?: string;
error?: string;
}
export interface SyncExecutionContext {
runId: string;
scope: SyncScope;
}
export interface SyncExecutionResult {
processed: number;
}
/**
* Per-object sync executor — STUB SEAM ONLY in build wave 1.
*
* The real implementations are the S/4HANA leg and MUST:
* - go through the SAP Cloud SDK with the named destination `mc-s4hana` —
* never hand-rolled CSRF or auth (ADR-001; CLAUDE.md operating rule 3);
* - honor every KNOWLEDGE.md tombstone (all dated 2026-09-04):
* * BOM items POSTed sequentially after the header POST — parallel item
* POSTs fail SAP's consistency check;
* * BOM reads on the v1 service only; writes on
* `API_BILL_OF_MATERIAL_SRV;v=0002` (v2 rejects guid key reads);
* * `AvailabilityCheckType` via the V2 `API_PRODUCT_SRV` fallback (not
* exposed in Product API V4);
* * OData V2 dates as `/Date(ms)/`; end-of-time `/Date(253402214400000)/`;
* * idempotency: SAP is the ledger — Arena ECO number stamped into
* `ReasonForChangeText`; no local sync-state database.
*
* No OData/S4 client code exists in this wave by design.
*/
export interface SyncExecutor {
readonly objectType: SyncObjectType;
execute(ctx: SyncExecutionContext): Promise<SyncExecutionResult>;
}
+22
View File
@@ -0,0 +1,22 @@
import { readFileSync } from 'node:fs';
function readVersion(): string {
try {
// Resolves from both src/ (tsx dev) and dist/ (compiled) to ../package.json.
const packageJsonUrl = new URL('../package.json', import.meta.url);
const parsed: unknown = JSON.parse(readFileSync(packageJsonUrl, 'utf8'));
if (
parsed !== null &&
typeof parsed === 'object' &&
'version' in parsed &&
typeof (parsed as { version: unknown }).version === 'string'
) {
return (parsed as { version: string }).version;
}
} catch {
// fall through to sentinel
}
return '0.0.0';
}
export const WORKER_VERSION = readVersion();
+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);
});
});
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"noEmit": false,
"outDir": "dist",
"rootDir": "src"
},
"include": ["src"]
}
+17
View File
@@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "es2023",
"lib": ["es2023"],
"module": "nodenext",
"moduleResolution": "nodenext",
"strict": true,
"noUncheckedIndexedAccess": true,
"verbatimModuleSyntax": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"skipLibCheck": true,
"sourceMap": true,
"noEmit": true
},
"include": ["src", "test", "vitest.config.ts"]
}
+8
View File
@@ -0,0 +1,8 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
environment: 'node',
include: ['test/**/*.test.ts'],
},
});