mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
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.
183 lines
6.6 KiB
JavaScript
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();
|