Files
edr-platform/integration/src/client.ts
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

250 lines
8.8 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Plumbing for the integration suite: HTTP against this worker's IN-PROCESS
* freight API and its containerized payment API, SQL against the worker's own
* throwaway database, and its own gateway mock's control plane.
*
* Everything here is shard-scoped — see app.ts for the topology. supertest takes
* an `http.Server` exactly where it takes a base URL, so pointing the suite at
* the in-process app is a one-token change at each call site.
*
* This is the Cypress-free port of e2e/freight/cypress/e2e/flows/import-utils.ts —
* same request sequences, same SQL, `pg.Pool` instead of `cy.task`.
*/
import request from "supertest";
import { Pool, type QueryResultRow } from "pg";
import { DB_URL, GATEWAY, PAYMENT_API, SHARD, freightServer, tag } from "./app";
export { GATEWAY, PAYMENT_API, SHARD, freightServer };
export const SERVICE_TOKEN = process.env.IT_SERVICE_TOKEN ?? "e2e-service-token";
// ---------------------------------------------------------------------------
// users (e2e/freight/cypress/fixtures/seed-users.sql + users.json)
// ---------------------------------------------------------------------------
export const customerA = "user@gmail.com";
export const customerB = "user2@gmail.com";
export const opsStaff = "operation@edr.local";
export const chief = "chief@edr.local";
/** isSuperAdmin bypasses assertFreightPermission — used for the GL/clearance steps. */
export const superAdmin = "superadmin@tria.com";
const STAFF_PASSWORD = "password@tria";
const CUSTOMER_PASSWORD = "12345678";
// ---------------------------------------------------------------------------
// database
// ---------------------------------------------------------------------------
const pool = new Pool({ connectionString: DB_URL, max: 12 });
export async function db<T extends QueryResultRow = Record<string, unknown>>(
sql: string,
params: unknown[] = [],
): Promise<T[]> {
const res = await pool.query<T>(sql, params);
return res.rows;
}
/**
* No-op. The pool is per-WORKER, not per-file: with `isolate: false` the module
* registry survives across every spec a worker runs, so the first `afterAll` to
* call this would break every later file on that shard. The pool dies with the
* worker process.
*/
export async function closeDb(): Promise<void> {}
/**
* Poll a query until `check` passes. Async settlement here is broker-driven and
* tick-driven, so the interval is pure overshoot: at the old 2000ms every wait
* in the suite finished up to two seconds after the state it wanted was already
* in the database, several hundred times over. The ceiling
* (`attempts × intervalMs`) is unchanged.
*/
const POLL_INTERVAL_MS = Number(process.env.IT_POLL_INTERVAL_MS ?? 250);
export async function poll<T extends QueryResultRow = Record<string, unknown>>(
label: string,
sql: string,
params: unknown[],
check: (row: T | undefined) => boolean,
{ attempts = 40, intervalMs = 2000 } = {},
): Promise<T> {
// Callers express patience as attempts × their own interval; keep that
// deadline and just sample it finely.
const deadlineMs = attempts * intervalMs;
const tries = Math.ceil(deadlineMs / POLL_INTERVAL_MS);
let last: T | undefined;
for (let i = 0; i < tries; i++) {
last = (await db<T>(sql, params))[0];
if (check(last)) return last as T;
await sleep(POLL_INTERVAL_MS);
}
throw new Error(
tag(
`timed out waiting for ${label} after ${Math.round(deadlineMs / 1000)}s\n` +
` sql: ${sql.replace(/\s+/g, " ").trim()}\n` +
` params: ${JSON.stringify(params)}\n` +
` last row: ${JSON.stringify(last)}`,
),
);
}
export const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
// ---------------------------------------------------------------------------
// auth
// ---------------------------------------------------------------------------
const tokens = new Map<string, string>();
/**
* Bearer token for a seeded account. The login audience is not cosmetic:
* `user_type='individual'` accounts are rejected on the backoffice audience and
* vice versa (EDRFREIGHT-415), so it is derived from the address.
*/
export async function tokenFor(email: string): Promise<string> {
const cached = tokens.get(email);
if (cached) return cached;
const portal = email.endsWith("@gmail.com");
const res = await request(await freightServer())
.post("/api/auth/login")
.set("x-client-app", portal ? "portal" : "backoffice")
.send({ email, password: portal ? CUSTOMER_PASSWORD : STAFF_PASSWORD });
// The login response is flattened by the app (no `.data` envelope) and 201s.
const token = res.body?.token ?? res.body?.data?.token;
if (!token) {
throw new Error(`login failed for ${email}: ${res.status} ${JSON.stringify(res.body)}`);
}
tokens.set(email, token);
return token;
}
/** Login without caching, so audience-rejection can be asserted. */
export async function login(email: string, password: string, app: "portal" | "backoffice") {
return request(await freightServer())
.post("/api/auth/login")
.set("x-client-app", app)
.send({ email, password });
}
// ---------------------------------------------------------------------------
// freight API
// ---------------------------------------------------------------------------
export type Method = "get" | "post" | "patch" | "delete";
/** Authenticated call to the freight API as `email`. Never throws on 4xx/5xx. */
export async function api(
email: string,
method: Method,
path: string,
body?: unknown,
): Promise<request.Response> {
const token = await tokenFor(email);
const req = request(await freightServer())
[method](path)
.set("Authorization", `Bearer ${token}`);
return method === "get" || method === "delete" ? req.send() : req.send(body ?? {});
}
/** Same, but fails loudly on a non-2xx — for arrange steps that must succeed. */
export async function apiOk(
email: string,
method: Method,
path: string,
body?: unknown,
): Promise<request.Response> {
const res = await api(email, method, path, body);
if (res.status < 200 || res.status > 201) {
throw new Error(
`${method.toUpperCase()} ${path} as ${email}${res.status}: ${JSON.stringify(res.body)}`,
);
}
return res;
}
/** Multipart upload (clearance documents). supertest handles the encoding. */
export async function upload(
email: string,
path: string,
filePath: string,
field = "files",
fields: Record<string, string> = {},
): Promise<request.Response> {
const token = await tokenFor(email);
const req = request(await freightServer())
.post(path)
.set("Authorization", `Bearer ${token}`);
for (const [k, v] of Object.entries(fields)) req.field(k, v);
return req.attach(field, filePath);
}
// ---------------------------------------------------------------------------
// payment API (service-to-service surface)
// ---------------------------------------------------------------------------
export function payment(method: Method, path: string, body?: unknown) {
const req = request(PAYMENT_API)[method](path).set("x-service-token", SERVICE_TOKEN);
return method === "get" || method === "delete" ? req.send() : req.send(body ?? {});
}
/** Payment-side rows. The payment service owns its own schema in the same DB. */
export function paymentDb<T extends QueryResultRow = Record<string, unknown>>(
sql: string,
params: unknown[] = [],
) {
return db<T>(sql, params);
}
export interface IntentRow extends QueryResultRow {
id: string;
status: string;
provider: string;
merchant_order_id: string;
amount_minor: string;
currency: string;
reference_id: string;
provider_txn_id: string | null;
expires_at: string | null;
}
export function intentByMerchantOrderId(merchantOrderId: string) {
return db<IntentRow>(
`SELECT * FROM edr_payment.payment_intent WHERE merchant_order_id = $1`,
[merchantOrderId],
);
}
// ---------------------------------------------------------------------------
// gateway mock control plane
// ---------------------------------------------------------------------------
export const gateway = {
reset: () => request(GATEWAY).post("/__control/reset").send({}),
/** Force a provider's next `times` calls (or all of them) into a mode. */
mode: (provider: string, mode: "ok" | "fail" | "timeout" | "pending" | "paid", times?: number) =>
request(GATEWAY).post(`/__control/provider/${provider}`).send({ mode, times }),
/** Mark the order settled at the gateway WITHOUT a callback (polling path). */
settle: (merchantOrderId: string) =>
request(GATEWAY).post("/__control/settle").send({ merchantOrderId }),
/** Fire a signed provider callback at the payment API. */
webhook: (opts: {
merchantOrderId: string;
provider?: string;
status?: string;
eventId?: string;
transactionId?: string;
signature?: "bad";
}) => request(GATEWAY).post("/__control/webhook").send(opts),
calls: () => request(GATEWAY).get("/__control/calls").send(),
};