test(integration): boot freight API in-process across parallel shards

The suite drove a containerized freight API, so every code change needed an
image rebuild before a test could see it, and there was no way to attach a
debugger. Files also ran strictly in sequence against one shared database,
which is the root of the warm-stack gotchas the README documents: stowaway
paid bookings climbing back aboard, a short consist on the fifth file.

The freight app now boots inside each vitest worker from dist/, and each
worker owns a whole shard of the topology - its own database, payment API,
gateway mock and broker vhost - so nothing mutable is shared and files run
in parallel. Full suite drops from roughly 20 minutes to 196s at 4 shards.

- main.ts exports createFreightApp() so the harness applies the same prefix,
  pipes, filters and interceptors as production instead of replaying them by
  hand; self-start is guarded by require.main so the Dockerfile CMD still boots
- booking-window tick cadence is env-driven (BOOKING_WINDOW_TICK_CRON), */1 in
  the suite, */10 unchanged in production
- prepare-shards.mjs seeds a template database (boot seeders, then the SQL
  fixtures that depend on them) and clones it per shard; it.mjs re-clones on
  every run, so each run is hermetic
- gateway mock and payment API are generated per shard: the mock keeps modes
  and orders process-global and 20 of 25 specs reset it in beforeAll, and the
  inbound CBE bill query has to reach one specific shard's app
- poll() samples every 250ms instead of 2000ms, keeping the caller's deadline
- authz.it.ts seeds its own invoice; it previously read another spec's leftover
  and returned early, which silently passed on a pristine database

Known: an unlocked MAX(sequence_no)+1 in train-scheduling.service.ts races
under concurrent allocation and leaves a short consist, so 1-3 specs fail
intermittently. Pre-existing and reproduces at the production tick cadence.
This commit is contained in:
Nathnael
2026-08-04 11:48:59 +00:00
parent 3a69b961d4
commit 415ae52143
13 changed files with 1045 additions and 329 deletions

View File

@@ -1,12 +1,24 @@
# EDR Freight — API integration stack (headless).
# EDR Freight — API integration stack (headless, sharded).
#
# Overlay on docker-compose.e2e.yaml. Same base services (postgres, minio,
# mocks, freight-api), except the payment microservice is REAL here instead of
# `payment-mock-e2e`, and only the bank gateways are stubbed:
# Overlay on docker-compose.e2e.yaml, plus a GENERATED third file holding the
# per-shard services (integration/.it-shards.yaml, written by gen-shards.mjs).
#
# freight-api-it ──HTTP──> payment-api-it ──HTTP──> gateway-mock-it
# ^ │
# └────── RabbitMQ ───────┘ (outbox → payment.events → consumer)
# The freight API is NOT a container here — it boots inside each vitest worker,
# on the host, so a code change needs no image rebuild and a breakpoint works.
# Each worker is a full shard of the topology; nothing mutable is shared:
#
# shard i:
# freight app (in vitest worker, host :3111+i)
# │ ▲
# │ └──── HTTP ─── payment-api-it-{i} :3131+i (inbound CBE bill query,
# │ │ via host.docker.internal)
# └── HTTP ──────────────► │ ──HTTP──> gateway-mock-it-{i} :4600+i
# ▲ │
# └──── RabbitMQ vhost payment_s{i} ──┘ (outbox → payment.events → consumer)
#
# Shared by every shard: postgres (one container, one database per shard cloned
# from a seeded template), rabbitmq (one container, one vhost per shard), minio,
# and the one-shot migration.
#
# Its own compose project (`name:` below overrides the base) and its own host
# ports, so it can run side by side with the Cypress e2e stack.
@@ -15,13 +27,16 @@
#
# Never start it with plain `docker compose -f docker-compose.it.yaml` — it is
# an OVERLAY and needs the base file first:
# docker compose -f docker-compose.e2e.yaml -f docker-compose.it.yaml ...
# docker compose -f docker-compose.e2e.yaml -f docker-compose.it.yaml \
# -f integration/.it-shards.yaml ...
name: edr-freight-it
services:
# Outbox transport. The payment API publishes payment.succeeded/failed here
# and freight consumes it — the production path. Copied from the passenger
# harness (e2e/docker-compose.yml).
# harness (e2e/docker-compose.yml). One broker, one vhost per shard
# (payment_s0, payment_s1, …) created by it.mjs — a shared vhost would let one
# shard's freight consumer eat another shard's settlement event.
rabbitmq-it:
image: rabbitmq:3-management
environment:
@@ -32,139 +47,14 @@ services:
- "${IT_RABBIT_PORT:-5772}:5672"
- "${IT_RABBIT_UI_PORT:-15772}:15672"
healthcheck:
test: ["CMD", "rabbitmq-diagnostics", "-q", "ping"]
# check_running, NOT ping: ping only proves the Erlang node answers, and
# it.mjs runs `rabbitmqctl add_vhost` the moment this goes healthy — which
# on a cold boot failed with "this command requires the 'rabbit' app to be
# running on the target node". check_running waits for the application.
test: ["CMD", "rabbitmq-diagnostics", "-q", "check_running"]
interval: 5s
timeout: 5s
timeout: 10s
retries: 20
# Stand-in for every bank/wallet gateway the payment API talks to, plus a
# control plane the tests drive (force a provider to fail/hang, fire a
# correctly-signed webhook, read back what was called). See
# integration/gateway-mock/server.js.
gateway-mock-it:
image: node:20-alpine
volumes:
- ./integration/gateway-mock:/app:ro
working_dir: /app
environment:
PORT: "4600"
# Same secrets the payment API gets — so webhooks the mock signs pass the
# API's REAL signature verification instead of bypassing it.
CBE_SECRET_KEY: it-cbe-secret
CBE_MERCHANT_ID: it-cbe-merchant
PAYMENT_API_URL: http://payment-api-it:3003
command: ["node", "server.js"]
ports:
- "${IT_GATEWAY_PORT:-4600}:4600"
healthcheck:
test:
[
"CMD",
"node",
"-e",
"fetch('http://localhost:4600/__control/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))",
]
interval: 3s
timeout: 3s
retries: 10
payment-api-it:
build:
context: .
dockerfile: apps/edr-payment-api/Dockerfile
secrets:
- npmrc
depends_on:
postgres-freight-e2e:
condition: service_healthy
rabbitmq-it:
condition: service_healthy
gateway-mock-it:
condition: service_healthy
environment:
PORT: "3003"
NODE_ENV: test
# Payment tables live in their own schema of the same throwaway DB;
# main.ts ensurePaymentSchema() creates it, migrationsRun does the rest.
DB_HOST: postgres-freight-e2e
DB_PORT: "5432"
DB_USER: edr_e2e
DB_PASSWORD: edr_e2e
DB_NAME: edr_freight_e2e
DB_SCHEMA: edr_payment
# Same token freight already uses in the base stack.
SERVICE_AUTH_TOKEN: e2e-service-token
PUBLISHER_TRANSPORT: rabbitmq
PAYMENT_RABBITMQ_URL: amqp://edr:edr_secret@rabbitmq-it:5672/payment
# HTTP fallback targets (only used with PUBLISHER_TRANSPORT=http).
PAYMENT_NOTIFY_FREIGHT_URL: http://freight-api-e2e:3001/api/internal/payments/mark-paid
# Fast relay + sweep so retry/reconciliation are observable inside a test
# rather than a minute later.
OUTBOX_RELAY_INTERVAL_MS: "1000"
RECONCILE_STALE_AFTER_MS: "5000"
# Every gateway points at the one mock. Paths are per-provider prefixes.
CBE_BASE_URL: http://gateway-mock-it:4600/cbe-birr
CBE_MERCHANT_ID: it-cbe-merchant
CBE_SECRET_KEY: it-cbe-secret
CBE_NOTIFY_URL: http://payment-api-it:3003/webhooks/cbe-birr
CBE_RETURN_URL: http://localhost/return
TELEBIRR_BASE_URL: http://gateway-mock-it:4600/telebirr
TELEBIRR_WEB_BASE_URL: http://gateway-mock-it:4600/telebirr/web
TELEBIRR_FABRIC_APP_ID: it-fabric
TELEBIRR_APP_SECRET: it-secret
TELEBIRR_MERCHANT_APP_ID: it-merchant-app
TELEBIRR_MERCHANT_CODE: "999999"
TELEBIRR_NOTIFY_URL: http://payment-api-it:3003/webhooks/telebirr
# Telebirr PSS-signs every request object — a throwaway key generated per
# launch by it.mjs (nothing key-shaped lives in git).
TELEBIRR_PRIVATE_KEY: ${IT_TELEBIRR_PRIVATE_KEY}
EBIRR_BASE_URL: http://gateway-mock-it:4600/ebirr
DMONEY_BASE_URL: http://gateway-mock-it:4600/dmoney
CARD_BASE_URL: http://gateway-mock-it:4600/card
WAAFI_BASE_URL: http://gateway-mock-it:4600/waafi
CAC_BASE_URL: http://gateway-mock-it:4600/cac
CAC_USERNAME: it-cac
CAC_PASSWORD: it-cac
CAC_APP_KEY: it-cac-key
CAC_API_KEY: it-cac-api
CAC_COMPANY_SERVICES_ID: "1"
# Inbound CBE Unified Bill — we are the biller; bill-query hops back into
# the freight API, so this direction runs real code on both sides.
CBE_BILL_ENABLED: "true"
CBE_BILL_CLIENT_ID: it-cbe-bill
CBE_BILL_CLIENT_SECRET: it-cbe-bill-secret
CBE_BILL_JWT_SECRET: it-cbe-bill-jwt
FREIGHT_API_BASE_URL: http://freight-api-e2e:3001/api
ports:
- "${IT_PAYMENT_PORT:-3113}:3003"
healthcheck:
test:
[
"CMD",
"node",
"-e",
"fetch('http://localhost:3003/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))",
]
interval: 5s
timeout: 5s
retries: 12
start_period: 40s
# Base-stack service, re-pointed at the real payment API.
freight-api-e2e:
depends_on:
payment-api-it:
condition: service_healthy
environment:
PAYMENT_API_URL: http://payment-api-it:3003
# Freight's payment module skips RabbitMQModule entirely when this is
# unset (payment.module.ts) — without it, outbox events never arrive.
PAYMENT_RABBITMQ_URL: amqp://edr:edr_secret@rabbitmq-it:5672/payment
# RABBITMQ_ENABLED stays "false" (base stack) — it only gates the SMS/email
# clients, which must remain off. The payment consumer is wired by
# PAYMENT_RABBITMQ_URL alone.
# Drain tail on every pay window. Production defaults to 5 minutes; a
# reservation here lives ~60s, so 5 would push every natural expiry past
# the suite's 180s timeouts. One minute keeps the tail real and observable
# (src/expired-invoice-late-settle.it.ts asserts both sides of it).
FREIGHT_PAYMENT_DRAIN_MINUTES: "1"
# The per-shard `gateway-mock-it-{i}` and `payment-api-it-{i}` services live in
# integration/.it-shards.yaml. The base file's `freight-api-e2e` is never
# started — the app runs in-process (integration/src/app.ts).