mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 08:48:11 +00:00
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:
182
integration/scripts/gen-shards.mjs
Normal file
182
integration/scripts/gen-shards.mjs
Normal file
@@ -0,0 +1,182 @@
|
||||
#!/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();
|
||||
Reference in New Issue
Block a user