mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 06:28:12 +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:
@@ -19,7 +19,7 @@ import { AppModule } from "./app.module";
|
||||
* ~13.4MB on the wire. Express defaults to 100kb, which rejected any real stamp
|
||||
* image with a 413 "request entity too large".
|
||||
*/
|
||||
const JSON_BODY_LIMIT = '20mb';
|
||||
const JSON_BODY_LIMIT = "20mb";
|
||||
|
||||
/**
|
||||
* Static /etc/hosts-style overrides from `DNS_HOST_OVERRIDES`, formatted as
|
||||
@@ -44,7 +44,9 @@ function applyDnsHostOverrides(): void {
|
||||
}
|
||||
if (overrides.size === 0) return;
|
||||
|
||||
const dns = createRequire(__filename)("node:dns") as typeof import("node:dns");
|
||||
const dns = createRequire(__filename)(
|
||||
"node:dns",
|
||||
) as typeof import("node:dns");
|
||||
const originalLookup = dns.lookup.bind(dns);
|
||||
// `dns.lookup` is overloaded (options optional, all/family variants); the
|
||||
// cast keeps that surface intact while we intercept only mapped hostnames.
|
||||
@@ -63,7 +65,9 @@ function applyDnsHostOverrides(): void {
|
||||
) => void;
|
||||
const family = ip.includes(":") ? 6 : 4;
|
||||
const wantsAll =
|
||||
typeof options === "object" && options !== null && (options as { all?: boolean }).all;
|
||||
typeof options === "object" &&
|
||||
options !== null &&
|
||||
(options as { all?: boolean }).all;
|
||||
|
||||
process.nextTick(() =>
|
||||
wantsAll ? done(null, [{ address: ip, family }]) : done(null, ip, family),
|
||||
@@ -77,7 +81,15 @@ function applyDnsHostOverrides(): void {
|
||||
|
||||
applyDnsHostOverrides();
|
||||
|
||||
async function bootstrap() {
|
||||
/**
|
||||
* Build the app with every global the production process applies, but do NOT
|
||||
* listen. Exported so a test harness can boot the REAL app in its own process
|
||||
* (integration/src/app.ts) and get the same prefix, pipe, filter, interceptor
|
||||
* and body-parser configuration — replaying this list by hand is how an e2e
|
||||
* harness silently drifts from production (routes 404 without the "api"
|
||||
* prefix, responses lose the transform envelope).
|
||||
*/
|
||||
export async function createFreightApp(): Promise<NestExpressApplication> {
|
||||
const app = await NestFactory.create<NestExpressApplication>(AppModule);
|
||||
|
||||
// Nest's own body-parser API, NOT `app.use(json(...))` from express: express
|
||||
@@ -86,14 +98,14 @@ async function bootstrap() {
|
||||
// pnpm's hoisted dev store and died as MODULE_NOT_FOUND in the production
|
||||
// image, where `pnpm deploy --prod` installs declared dependencies only.
|
||||
// This also RECONFIGURES the default parsers rather than racing them.
|
||||
app.useBodyParser('json', { limit: JSON_BODY_LIMIT });
|
||||
app.useBodyParser('urlencoded', { limit: JSON_BODY_LIMIT, extended: true });
|
||||
app.useBodyParser("json", { limit: JSON_BODY_LIMIT });
|
||||
app.useBodyParser("urlencoded", { limit: JSON_BODY_LIMIT, extended: true });
|
||||
|
||||
// Dev CORS: reflect any localhost origin and allow credentials so the
|
||||
// freight portal (5173), passenger portal (5174), backoffices (5183/5184)
|
||||
// and any other dev port can call the API with cookies + Authorization.
|
||||
// For production, restrict `origin` to known FQDNs.
|
||||
|
||||
|
||||
app.enableCors({
|
||||
origin: true, // reflect request origin
|
||||
credentials: true,
|
||||
@@ -157,13 +169,21 @@ async function bootstrap() {
|
||||
const document = SwaggerModule.createDocument(app, config);
|
||||
SwaggerModule.setup("api/docs", app, document);
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await createFreightApp();
|
||||
const port = parseInt(process.env.PORT ?? "3001", 10);
|
||||
// await app.listen(port, "0.0.0.0");
|
||||
await app.listen(
|
||||
|
||||
port)
|
||||
await app.listen(port);
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`[freight-api] listening on port ${port}`);
|
||||
}
|
||||
|
||||
bootstrap();
|
||||
// Only self-start when this file IS the entrypoint. The Dockerfile's
|
||||
// `CMD ["node", "dist/main.js"]` still boots; importers get `createFreightApp`
|
||||
// without the process binding a port behind their back.
|
||||
if (require.main === module) {
|
||||
bootstrap();
|
||||
}
|
||||
|
||||
@@ -91,7 +91,16 @@ export class BookingWindowService implements OnModuleInit {
|
||||
// 10-second cadence: every transition is derived from persisted timestamps
|
||||
// and applied idempotently, so a finer tick only shrinks the lag between a
|
||||
// deadline passing and the phase actually moving (was a full minute).
|
||||
@Cron('*/10 * * * * *', { name: 'booking-window-tick', timeZone: BATCH_TIMEZONE })
|
||||
//
|
||||
// Overridable because that lag is the integration suite's pacing floor: every
|
||||
// window phase, wagon allocation and expiry it waits on lands on this tick, so
|
||||
// a 10s cadence costs ~5s of pure latency per wait across a few hundred waits.
|
||||
// The suite runs it at `*/1 * * * * *`. Read at class-definition time, so the
|
||||
// env var must be set before the module is imported.
|
||||
@Cron(process.env.BOOKING_WINDOW_TICK_CRON ?? '*/10 * * * * *', {
|
||||
name: 'booking-window-tick',
|
||||
timeZone: BATCH_TIMEZONE,
|
||||
})
|
||||
async tick(): Promise<void> {
|
||||
if (this.ticking) return;
|
||||
this.ticking = true;
|
||||
|
||||
Reference in New Issue
Block a user