mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 05:18: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:
133
integration/src/app.ts
Normal file
133
integration/src/app.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* The freight API, booted IN THIS PROCESS — one instance per vitest worker.
|
||||
*
|
||||
* Each worker is a self-contained shard of the whole topology, so spec files can
|
||||
* run in parallel without sharing anything mutable:
|
||||
*
|
||||
* shard i = this in-process freight app on :3111+i
|
||||
* + database edr_it_s{i} (cloned from the seeded template)
|
||||
* + payment-api-it-{i} container on :3113+i, DB_NAME=edr_it_s{i}
|
||||
* + gateway-mock-it-{i} container on :4600+i
|
||||
* + RabbitMQ vhost payment_s{i}
|
||||
*
|
||||
* The payment schema lives INSIDE the shard's own database under the usual
|
||||
* `edr_payment` name, so every raw `edr_payment.*` query in the specs works
|
||||
* unchanged and `paymentDb()` stays an alias for `db()`.
|
||||
*
|
||||
* Why the app is loaded from `dist/` and not from TypeScript: the freight source
|
||||
* is CJS-flavoured — `src/config/database.config.ts` uses `__dirname`,
|
||||
* `require.resolve` and an entity glob, none of which survive vitest's ESM
|
||||
* transform. `createRequire` anchored at the freight app's own package.json
|
||||
* keeps all Nest / typeorm / @tria-plc resolution inside its node_modules, and
|
||||
* `dist` is rebuilt by `it.mjs test` on every run so it can never go stale.
|
||||
*/
|
||||
import { createRequire } from "node:module";
|
||||
import { existsSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import type { Server } from "node:http";
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const freightDir = join(here, "..", "..", "apps", "edr-freight-api");
|
||||
const distMain = join(freightDir, "dist", "main.js");
|
||||
|
||||
/**
|
||||
* `VITEST_POOL_ID` is 1-based and stable per worker for the life of the run.
|
||||
* Absent outside a worker (global-setup, scripts) — that context gets shard 0.
|
||||
*/
|
||||
export const SHARD = Math.max(0, Number(process.env.VITEST_POOL_ID ?? "1") - 1);
|
||||
|
||||
/**
|
||||
* Each service takes a contiguous block of `IT_SHARDS` ports from its base, so
|
||||
* the bases must stay far apart — see the PORTS comment in scripts/it.mjs.
|
||||
*/
|
||||
const port = (base: string, fallback: number) =>
|
||||
Number(process.env[base] ?? fallback) + SHARD;
|
||||
|
||||
export const API_PORT = port("IT_API_PORT_BASE", 3111);
|
||||
export const PAYMENT_API = `http://localhost:${port("IT_PAYMENT_PORT_BASE", 3131)}`;
|
||||
export const GATEWAY = `http://localhost:${port("IT_GATEWAY_PORT_BASE", 4600)}`;
|
||||
|
||||
export const SHARD_DB = `${process.env.IT_DB_PREFIX ?? "edr_it_s"}${SHARD}`;
|
||||
|
||||
export const DB_URL =
|
||||
`postgres://${process.env.DB_USER ?? "edr_e2e"}:${process.env.DB_PASSWORD ?? "edr_e2e"}` +
|
||||
`@${process.env.DB_HOST ?? "localhost"}:${process.env.DB_PORT ?? "5543"}/${SHARD_DB}`;
|
||||
|
||||
const RABBIT_URL =
|
||||
`amqp://edr:edr_secret@localhost:${process.env.IT_RABBIT_PORT ?? "5772"}` +
|
||||
`/payment_s${SHARD}`;
|
||||
|
||||
/** Prefix every diagnostic — parallel worker output interleaves. */
|
||||
export const tag = (msg: string) => `[shard ${SHARD}] ${msg}`;
|
||||
|
||||
interface FreightMain {
|
||||
createFreightApp: () => Promise<{
|
||||
listen: (port: number) => Promise<unknown>;
|
||||
getHttpServer: () => Server;
|
||||
close: () => Promise<void>;
|
||||
}>;
|
||||
}
|
||||
|
||||
let booted: Promise<Server> | undefined;
|
||||
|
||||
/**
|
||||
* The shard's HTTP server, booted once per worker. supertest takes this
|
||||
* directly in place of a base URL, so every call site in client.ts is a
|
||||
* one-token change.
|
||||
*/
|
||||
export function freightServer(): Promise<Server> {
|
||||
return (booted ??= boot());
|
||||
}
|
||||
|
||||
async function boot(): Promise<Server> {
|
||||
if (!existsSync(distMain)) {
|
||||
throw new Error(
|
||||
tag(
|
||||
`${distMain} is missing — build the API first ` +
|
||||
`(\`pnpm --filter @edr/freight-api run build\`, which \`it.mjs test\` does for you).`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Must be set BEFORE the require: payment.module.ts reads
|
||||
// PAYMENT_RABBITMQ_URL at module-definition time to decide whether
|
||||
// RabbitMQModule is in the graph at all, and database.config.ts reads DB_NAME
|
||||
// when ConfigModule loads it.
|
||||
process.env.DB_NAME = SHARD_DB;
|
||||
process.env.PORT = String(API_PORT);
|
||||
process.env.PAYMENT_API_URL = PAYMENT_API;
|
||||
process.env.PAYMENT_RABBITMQ_URL = RABBIT_URL;
|
||||
|
||||
const { createFreightApp } = createRequire(join(freightDir, "package.json"))(
|
||||
"./dist/main.js",
|
||||
) as FreightMain;
|
||||
|
||||
// Postgres and the broker are up before vitest starts, but a shard's payment
|
||||
// API may still be finishing its own migrations when the first worker boots.
|
||||
// Retry the whole app rather than a connection: a half-initialised Nest app
|
||||
// cannot be resumed, only closed and rebuilt.
|
||||
let lastErr: unknown;
|
||||
for (let attempt = 1; attempt <= 3; attempt++) {
|
||||
const app = await createFreightApp().catch((err) => {
|
||||
lastErr = err;
|
||||
return undefined;
|
||||
});
|
||||
if (app) {
|
||||
try {
|
||||
// A real port, not an ephemeral one: payment-api-it-{SHARD} calls back
|
||||
// in here for the inbound CBE Unified Bill query (cbe-bill.it.ts).
|
||||
await app.listen(API_PORT);
|
||||
console.log(tag(`freight-api in-process on :${API_PORT} → ${SHARD_DB}`));
|
||||
return app.getHttpServer();
|
||||
} catch (err) {
|
||||
lastErr = err;
|
||||
await app.close().catch(() => {});
|
||||
}
|
||||
}
|
||||
if (attempt < 3) await new Promise((r) => setTimeout(r, 2000 * attempt));
|
||||
}
|
||||
|
||||
console.error(tag(`freight-api failed to boot on :${API_PORT} (${SHARD_DB})`), lastErr);
|
||||
throw lastErr;
|
||||
}
|
||||
Reference in New Issue
Block a user