mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-02 18:33:39 +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;
|
||||
}
|
||||
@@ -3,44 +3,75 @@
|
||||
* failures here are the expensive kind: a tenant reading another tenant's
|
||||
* invoice, or an unauthenticated caller marking one paid.
|
||||
*/
|
||||
import { afterAll, describe, expect, it } from "vitest";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import request from "supertest";
|
||||
import {
|
||||
API,
|
||||
PAYMENT_API,
|
||||
api,
|
||||
closeDb,
|
||||
customerA,
|
||||
customerB,
|
||||
db,
|
||||
freightServer,
|
||||
login,
|
||||
payment,
|
||||
} from "./client";
|
||||
|
||||
/** Company A, from e2e/freight/cypress/fixtures/seed-company.sql. */
|
||||
const TENANT_A_TIN = "0102030405";
|
||||
|
||||
/**
|
||||
* This file used to read "the newest invoice of company A" and `return` early
|
||||
* when there wasn't one — which passed silently on a database no payment spec
|
||||
* had run against yet. Now that every shard starts from a pristine clone, that
|
||||
* would be *every* run. It bills itself instead.
|
||||
*
|
||||
* Straight SQL, not the booking chain: the point of this file is that it is
|
||||
* cheap, and a cross-tenant read is refused on ownership alone — nothing here
|
||||
* cares how the invoice came to exist.
|
||||
*/
|
||||
async function seedTenantAInvoice(): Promise<string> {
|
||||
const rows = await db<{ id: string }>(
|
||||
`INSERT INTO freight.invoices (
|
||||
id, invoice_number, company_id, company_profile_id,
|
||||
subtotal_amount, tax_amount, total_amount, paid_amount, balance_amount,
|
||||
currency, status, source, source_id, type, issued_at, due_at, payments
|
||||
)
|
||||
SELECT gen_random_uuid(), $1, c.id, p.id,
|
||||
1000, 0, 1000, 0, 1000,
|
||||
'ETB', 'ISSUED', 'booking', gen_random_uuid()::text, 'PREPAID',
|
||||
now(), now() + interval '7 days', '[]'::jsonb
|
||||
FROM freight.companies c
|
||||
JOIN freight.company_profiles p ON p.company_id = c.id AND p.deleted_at IS NULL
|
||||
WHERE c.tin = $2 AND c.deleted_at IS NULL
|
||||
LIMIT 1
|
||||
RETURNING id`,
|
||||
[`INV-AUTHZ-${Date.now()}`, TENANT_A_TIN],
|
||||
);
|
||||
const id = rows[0]?.id;
|
||||
if (!id) {
|
||||
throw new Error(
|
||||
`authz: could not bill company ${TENANT_A_TIN} — seed-company.sql missing from this shard?`,
|
||||
);
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
describe("payment authorization boundaries", () => {
|
||||
let invoiceId: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
invoiceId = await seedTenantAInvoice();
|
||||
});
|
||||
afterAll(closeDb);
|
||||
|
||||
it("hides one tenant's invoice from the other", async () => {
|
||||
const rows = await db<{ id: string; company_id: string }>(
|
||||
`SELECT i.id, i.company_id FROM freight.invoices i
|
||||
JOIN freight.companies c ON c.id = i.company_id
|
||||
WHERE c.tin = '0102030405' AND i.deleted_at IS NULL
|
||||
ORDER BY i.created_at DESC LIMIT 1`,
|
||||
);
|
||||
if (!rows[0]) return; // nothing billed yet in this run — payment files cover it
|
||||
const res = await api(customerB, "get", `/api/billing/my-invoices/${rows[0].id}`);
|
||||
const res = await api(customerB, "get", `/api/billing/my-invoices/${invoiceId}`);
|
||||
expect([403, 404]).toContain(res.status);
|
||||
});
|
||||
|
||||
it("refuses to let one tenant pay the other's invoice", async () => {
|
||||
const rows = await db<{ id: string }>(
|
||||
`SELECT i.id FROM freight.invoices i
|
||||
JOIN freight.companies c ON c.id = i.company_id
|
||||
WHERE c.tin = '0102030405' AND i.status <> 'PAID' AND i.deleted_at IS NULL
|
||||
ORDER BY i.created_at DESC LIMIT 1`,
|
||||
);
|
||||
if (!rows[0]) return;
|
||||
const res = await api(customerB, "post", `/api/billing/my-invoices/${rows[0].id}/pay`, {
|
||||
const res = await api(customerB, "post", `/api/billing/my-invoices/${invoiceId}/pay`, {
|
||||
method: "CBE_BIRR",
|
||||
platform: "web",
|
||||
});
|
||||
@@ -71,7 +102,9 @@ describe("payment authorization boundaries", () => {
|
||||
amountMinor: 1,
|
||||
currency: "ETB",
|
||||
};
|
||||
const res = await request(API).post("/api/internal/payments/mark-paid").send(body);
|
||||
const res = await request(await freightServer())
|
||||
.post("/api/internal/payments/mark-paid")
|
||||
.send(body);
|
||||
expect([401, 403]).toContain(res.status);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
/**
|
||||
* Plumbing for the integration suite: HTTP against the containerized freight
|
||||
* and payment APIs, SQL against their shared throwaway Postgres, and the
|
||||
* gateway mock's control plane.
|
||||
* 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`.
|
||||
@@ -9,13 +13,11 @@
|
||||
import request from "supertest";
|
||||
import { Pool, type QueryResultRow } from "pg";
|
||||
|
||||
export const API = process.env.IT_API_URL ?? "http://localhost:3111";
|
||||
export const PAYMENT_API = process.env.IT_PAYMENT_URL ?? "http://localhost:3113";
|
||||
export const GATEWAY = process.env.IT_GATEWAY_URL ?? "http://localhost:4600";
|
||||
export const SERVICE_TOKEN = process.env.IT_SERVICE_TOKEN ?? "e2e-service-token";
|
||||
import { DB_URL, GATEWAY, PAYMENT_API, SHARD, freightServer, tag } from "./app";
|
||||
|
||||
const DB_URL =
|
||||
process.env.IT_DB_URL ?? "postgres://edr_e2e:edr_e2e@localhost:5543/edr_freight_e2e";
|
||||
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)
|
||||
@@ -45,11 +47,23 @@ export async function db<T extends QueryResultRow = Record<string, unknown>>(
|
||||
return res.rows;
|
||||
}
|
||||
|
||||
export async function closeDb(): Promise<void> {
|
||||
await pool.end();
|
||||
}
|
||||
/**
|
||||
* 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);
|
||||
|
||||
/** Poll a query until `check` passes. Async settlement here is broker-driven. */
|
||||
export async function poll<T extends QueryResultRow = Record<string, unknown>>(
|
||||
label: string,
|
||||
sql: string,
|
||||
@@ -57,14 +71,24 @@ export async function poll<T extends QueryResultRow = Record<string, 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 < attempts; i++) {
|
||||
for (let i = 0; i < tries; i++) {
|
||||
last = (await db<T>(sql, params))[0];
|
||||
if (check(last)) return last as T;
|
||||
await sleep(intervalMs);
|
||||
await sleep(POLL_INTERVAL_MS);
|
||||
}
|
||||
throw new Error(
|
||||
`timed out waiting for ${label} after ${attempts} attempts — last row: ${JSON.stringify(last)}`,
|
||||
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)}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -86,7 +110,7 @@ export async function tokenFor(email: string): Promise<string> {
|
||||
if (cached) return cached;
|
||||
|
||||
const portal = email.endsWith("@gmail.com");
|
||||
const res = await request(API)
|
||||
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 });
|
||||
@@ -101,8 +125,11 @@ export async function tokenFor(email: string): Promise<string> {
|
||||
}
|
||||
|
||||
/** Login without caching, so audience-rejection can be asserted. */
|
||||
export function login(email: string, password: string, app: "portal" | "backoffice") {
|
||||
return request(API).post("/api/auth/login").set("x-client-app", app).send({ email, password });
|
||||
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 });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -119,7 +146,9 @@ export async function api(
|
||||
body?: unknown,
|
||||
): Promise<request.Response> {
|
||||
const token = await tokenFor(email);
|
||||
const req = request(API)[method](path).set("Authorization", `Bearer ${token}`);
|
||||
const req = request(await freightServer())
|
||||
[method](path)
|
||||
.set("Authorization", `Bearer ${token}`);
|
||||
return method === "get" || method === "delete" ? req.send() : req.send(body ?? {});
|
||||
}
|
||||
|
||||
@@ -148,7 +177,9 @@ export async function upload(
|
||||
fields: Record<string, string> = {},
|
||||
): Promise<request.Response> {
|
||||
const token = await tokenFor(email);
|
||||
const req = request(API).post(path).set("Authorization", `Bearer ${token}`);
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -1,41 +1,17 @@
|
||||
/**
|
||||
* Runs once before any spec: wait for both APIs, then seed.
|
||||
* Runs once, in its own process, before any spec: confirm every shard's
|
||||
* containerized half is answering, then clear its gateway mock.
|
||||
*
|
||||
* Seeds are the Cypress suite's fixtures, reused verbatim (they are idempotent
|
||||
* `insert … where not exists`), plus one of our own for the second tenant:
|
||||
* seed-users.sql → seed-company.sql (order matters; company needs the users)
|
||||
* seed-import-corridor.sql (yards, locos, wagons, rates, distances)
|
||||
* seed-bulk-items.sql (PER_ITEM break-bulk cargo types)
|
||||
* seed-g1-train.sql (the 53-wagon BUILT container train)
|
||||
* seed-g2-weight.sql (the two 3 500 T weight-bound trains)
|
||||
* seed-government.sql (the kind='government' company)
|
||||
* seed-company-b.sql (user2@gmail.com's company — this suite)
|
||||
* seed-customs-service-type.sql (a service type that bundles customs)
|
||||
* Seeding is NOT here any more. The order is load-bearing —
|
||||
* `seed-users.sql` names its prerequisites as "created by the API's always-on
|
||||
* boot seeders" (iam.organizations / units / positions) — and the freight app
|
||||
* now boots inside the vitest *workers*, which this process cannot reach. So the
|
||||
* template database is seeded once by `scripts/prepare-shards.mjs` (boot the app
|
||||
* → boot seeders → SQL fixtures) and each shard is a clone of it.
|
||||
*/
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { Client } from "pg";
|
||||
|
||||
const API = process.env.IT_API_URL ?? "http://localhost:3111";
|
||||
const PAYMENT_API = process.env.IT_PAYMENT_URL ?? "http://localhost:3113";
|
||||
const GATEWAY = process.env.IT_GATEWAY_URL ?? "http://localhost:4600";
|
||||
const DB_URL =
|
||||
process.env.IT_DB_URL ?? "postgres://edr_e2e:edr_e2e@localhost:5543/edr_freight_e2e";
|
||||
|
||||
const CYPRESS_FIXTURES = join(process.cwd(), "..", "e2e", "freight", "cypress", "fixtures");
|
||||
const OWN_FIXTURES = join(process.cwd(), "sql");
|
||||
|
||||
const SEEDS: Array<[dir: string, file: string]> = [
|
||||
[CYPRESS_FIXTURES, "seed-users.sql"],
|
||||
[CYPRESS_FIXTURES, "seed-company.sql"],
|
||||
[CYPRESS_FIXTURES, "seed-import-corridor.sql"],
|
||||
[CYPRESS_FIXTURES, "seed-bulk-items.sql"],
|
||||
[CYPRESS_FIXTURES, "seed-g1-train.sql"],
|
||||
[CYPRESS_FIXTURES, "seed-g2-weight.sql"],
|
||||
[CYPRESS_FIXTURES, "seed-government.sql"],
|
||||
[OWN_FIXTURES, "seed-company-b.sql"],
|
||||
[OWN_FIXTURES, "seed-customs-service-type.sql"],
|
||||
];
|
||||
const SHARDS = Number(process.env.IT_SHARDS ?? 1);
|
||||
const PAYMENT_BASE = Number(process.env.IT_PAYMENT_PORT_BASE ?? 3131);
|
||||
const GATEWAY_BASE = Number(process.env.IT_GATEWAY_PORT_BASE ?? 4600);
|
||||
|
||||
async function waitFor(label: string, url: string, attempts = 60): Promise<void> {
|
||||
for (let i = 0; i < attempts; i++) {
|
||||
@@ -51,22 +27,20 @@ async function waitFor(label: string, url: string, attempts = 60): Promise<void>
|
||||
}
|
||||
|
||||
export async function setup(): Promise<void> {
|
||||
await Promise.all([
|
||||
waitFor("freight-api", `${API}/api/health`),
|
||||
waitFor("payment-api", `${PAYMENT_API}/health`),
|
||||
waitFor("gateway-mock", `${GATEWAY}/__control/health`),
|
||||
]);
|
||||
const shards = Array.from({ length: SHARDS }, (_, i) => i);
|
||||
|
||||
const client = new Client({ connectionString: DB_URL });
|
||||
await client.connect();
|
||||
try {
|
||||
for (const [dir, file] of SEEDS) {
|
||||
await client.query(readFileSync(join(dir, file), "utf8"));
|
||||
console.log(`it: seeded ${file}`);
|
||||
}
|
||||
} finally {
|
||||
await client.end();
|
||||
}
|
||||
await Promise.all(
|
||||
shards.flatMap((i) => [
|
||||
waitFor(`payment-api (shard ${i})`, `http://localhost:${PAYMENT_BASE + i}/health`),
|
||||
waitFor(`gateway-mock (shard ${i})`, `http://localhost:${GATEWAY_BASE + i}/__control/health`),
|
||||
]),
|
||||
);
|
||||
|
||||
await fetch(`${GATEWAY}/__control/reset`, { method: "POST" });
|
||||
await Promise.all(
|
||||
shards.map((i) =>
|
||||
fetch(`http://localhost:${GATEWAY_BASE + i}/__control/reset`, { method: "POST" }),
|
||||
),
|
||||
);
|
||||
|
||||
console.log(`it: ${SHARDS} shard(s) ready`);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user