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