Files
edr-platform/integration/scripts/gen-shards.mjs
Nathnael 415ae52143 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.
2026-08-04 12:43:25 +00:00

183 lines
6.6 KiB
JavaScript

#!/usr/bin/env node
/**
* Generate `integration/.it-shards.yaml` — one payment API + one gateway mock
* per shard. Compose has no loops, so the per-shard services are written out.
*
* Every shard is the real topology in miniature; nothing crosses between them:
*
* gateway-mock-it-{i} its own `modes`/`calls`/`orders` — the mock keeps those
* process-global, and 20 of 25 specs call
* POST /__control/reset in beforeAll, so a shared mock
* would have each starting spec wipe every running spec's
* live orders (which fails as a wrong *assertion*: an
* unknown order still gets a correctly-signed webhook,
* just with the mock's default amount).
* payment-api-it-{i} its own DB_NAME (the shard's database, `edr_payment`
* schema inside it), its own broker vhost, and its own
* FREIGHT_API_BASE_URL — the inbound CBE-bill query has
* to land on THIS shard's in-process app, and a single
* container could only ever point at one of them.
*
* Written by it.mjs, gitignored. Do not edit by hand.
*/
import { writeFileSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const itDir = resolve(dirname(fileURLToPath(import.meta.url)), "..");
/**
* All shards share one build and one image tag, so the payment API image is
* built once and reused rather than N times.
*/
const PAYMENT_IMAGE = "edr-payment-api-it:local";
export function renderShards({
shards,
apiPortBase,
paymentPortBase,
gatewayPortBase,
dbPrefix,
telebirrKey,
}) {
const services = [];
for (let i = 0; i < shards; i++) {
const gw = `gateway-mock-it-${i}`;
const pay = `payment-api-it-${i}`;
const db = `${dbPrefix}${i}`;
const vhost = `payment_s${i}`;
// The freight app for this shard runs on the HOST, inside the vitest worker.
const freightBase = `http://host.docker.internal:${apiPortBase + i}/api`;
services.push(`
${gw}:
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://${pay}:3003
command: ["node", "server.js"]
ports:
- "${gatewayPortBase + i}: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
${pay}:
image: ${PAYMENT_IMAGE}
build:
context: .
dockerfile: apps/edr-payment-api/Dockerfile
secrets:
- npmrc
depends_on:
postgres-freight-e2e:
condition: service_healthy
rabbitmq-it:
condition: service_healthy
${gw}:
condition: service_healthy
# The freight app is on the host now, not in this network.
extra_hosts:
- "host.docker.internal:host-gateway"
environment:
PORT: "3003"
NODE_ENV: test
# Payment tables live in their own schema of THIS SHARD's database;
# main.ts ensurePaymentSchema() creates it, migrationsRun does the rest.
# Same database as freight, so every raw edr_payment.* query in the specs
# reads through the one connection.
DB_HOST: postgres-freight-e2e
DB_PORT: "5432"
DB_USER: edr_e2e
DB_PASSWORD: edr_e2e
DB_NAME: ${db}
DB_SCHEMA: edr_payment
SERVICE_AUTH_TOKEN: e2e-service-token
PUBLISHER_TRANSPORT: rabbitmq
PAYMENT_RABBITMQ_URL: amqp://edr:edr_secret@rabbitmq-it:5672/${vhost}
PAYMENT_NOTIFY_FREIGHT_URL: ${freightBase}/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 this shard's mock. Paths are per-provider prefixes.
CBE_BASE_URL: http://${gw}:4600/cbe-birr
CBE_MERCHANT_ID: it-cbe-merchant
CBE_SECRET_KEY: it-cbe-secret
CBE_NOTIFY_URL: http://${pay}:3003/webhooks/cbe-birr
CBE_RETURN_URL: http://localhost/return
TELEBIRR_BASE_URL: http://${gw}:4600/telebirr
TELEBIRR_WEB_BASE_URL: http://${gw}: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://${pay}: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: ${JSON.stringify(telebirrKey)}
EBIRR_BASE_URL: http://${gw}:4600/ebirr
DMONEY_BASE_URL: http://${gw}:4600/dmoney
CARD_BASE_URL: http://${gw}:4600/card
WAAFI_BASE_URL: http://${gw}:4600/waafi
CAC_BASE_URL: http://${gw}: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: ${freightBase}
ports:
- "${paymentPortBase + i}: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`);
}
return `# GENERATED by integration/scripts/gen-shards.mjs — do not edit.
# ${shards} shard(s). Overlaid on docker-compose.e2e.yaml + docker-compose.it.yaml.
services:${services.join("\n")}
`;
}
export const SHARD_FILE = join(itDir, ".it-shards.yaml");
export function writeShards(opts) {
writeFileSync(SHARD_FILE, renderShards(opts));
return SHARD_FILE;
}
export const shardServices = (shards) =>
Array.from({ length: shards }, (_, i) => [`gateway-mock-it-${i}`, `payment-api-it-${i}`]).flat();