feat: integration tests

This commit is contained in:
Nathnael
2026-08-03 10:51:43 +00:00
parent 6f137a11c3
commit f103b51de5
38 changed files with 7306 additions and 228 deletions

218
integration/src/client.ts Normal file
View File

@@ -0,0 +1,218 @@
/**
* 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.
*
* 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";
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";
const DB_URL =
process.env.IT_DB_URL ?? "postgres://edr_e2e:edr_e2e@localhost:5543/edr_freight_e2e";
// ---------------------------------------------------------------------------
// 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;
}
export async function closeDb(): Promise<void> {
await pool.end();
}
/** 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,
params: unknown[],
check: (row: T | undefined) => boolean,
{ attempts = 40, intervalMs = 2000 } = {},
): Promise<T> {
let last: T | undefined;
for (let i = 0; i < attempts; i++) {
last = (await db<T>(sql, params))[0];
if (check(last)) return last as T;
await sleep(intervalMs);
}
throw new Error(
`timed out waiting for ${label} after ${attempts} attempts — 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(API)
.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 function login(email: string, password: string, app: "portal" | "backoffice") {
return request(API).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(API)[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(API).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(),
};