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:
Nathnael
2026-08-04 11:48:59 +00:00
parent 3a69b961d4
commit 415ae52143
13 changed files with 1045 additions and 329 deletions

View File

@@ -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);
});