mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 11:55:42 +00:00
feat: integration tests
This commit is contained in:
185
integration/src/bulk-import-full-train.it.ts
Normal file
185
integration/src/bulk-import-full-train.it.ts
Normal file
@@ -0,0 +1,185 @@
|
||||
/**
|
||||
* BULK IMPORT — six wheat bookings fill the 54-wagon CW4 train on the long
|
||||
* corridor DJIB_PORT → NAGAD → DIRE_DAWA → E2E_AWASH → MOJO → KALITY, all in
|
||||
* the FIRST window, then the whole life of the train: payment through the real
|
||||
* gateway, allocation, gate pass, T1, dispatch, checkpoint-by-checkpoint
|
||||
* movement, arrival, and the post-arrival customs tail.
|
||||
*
|
||||
* 70 T per CW4 wagon — Σ = 54 wagons / 3 780 T:
|
||||
* BF1 customs + USD 560 T = 8 w
|
||||
* BF2 customs + ETB 420 T = 6 w
|
||||
* BF3 self + ETB 420 T = 6 w
|
||||
* BF4 self + ETB 420 T = 6 w
|
||||
* BF5 customs + USD 1 540 T = 22 w (the ≥22-wagon giant)
|
||||
* BF6 self + USD 420 T = 6 w
|
||||
*
|
||||
* Difference from the Cypress twin: every payment goes through the payment
|
||||
* microservice and a signed gateway callback, not the staff mark-paid shortcut.
|
||||
* Steps are sequential and not idempotent — the file runs as one journey.
|
||||
*/
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { closeDb, db, gateway } from "./client";
|
||||
import {
|
||||
allocatedWagons,
|
||||
bookBulkReady,
|
||||
closeBookingWindow,
|
||||
completeDocReview,
|
||||
createSchedule,
|
||||
departureAt,
|
||||
dispatchSchedule,
|
||||
eatDayStr,
|
||||
endPaymentPhase,
|
||||
extendPayWindow,
|
||||
ensureCorridorRoute,
|
||||
expectMilestoneDone,
|
||||
forceWindowOpen,
|
||||
gatePassGranted,
|
||||
invoiceForBooking,
|
||||
milestoneCount,
|
||||
payViaGateway,
|
||||
pollBookingStatus,
|
||||
pollWindow,
|
||||
releaseUnpaidHolds,
|
||||
resetCorridorDay,
|
||||
runCorridor,
|
||||
runImportCustomsTail,
|
||||
scheduleRow,
|
||||
seedTenantContracts,
|
||||
uploadT1,
|
||||
} from "./flows";
|
||||
|
||||
const DEPARTURE = departureAt(20);
|
||||
const BOOKING_DAY = eatDayStr(DEPARTURE);
|
||||
const STAMP = String(Date.now());
|
||||
|
||||
const BOOKINGS = [
|
||||
{ suffix: "BF1", customs: true, currency: "USD" as const, tons: 560, wagons: 8 },
|
||||
{ suffix: "BF2", customs: true, currency: "ETB" as const, tons: 420, wagons: 6 },
|
||||
{ suffix: "BF3", customs: false, currency: "ETB" as const, tons: 420, wagons: 6 },
|
||||
{ suffix: "BF4", customs: false, currency: "ETB" as const, tons: 420, wagons: 6 },
|
||||
{ suffix: "BF5", customs: true, currency: "USD" as const, tons: 1540, wagons: 22 },
|
||||
{ suffix: "BF6", customs: false, currency: "USD" as const, tons: 420, wagons: 6 },
|
||||
];
|
||||
const CUSTOMS = BOOKINGS.filter((b) => b.customs);
|
||||
const SELF_CLEAR = BOOKINGS.filter((b) => !b.customs);
|
||||
|
||||
describe("bulk import: six wheat bookings fill the 54-wagon CW4 train", () => {
|
||||
const booking = new Map<string, string>();
|
||||
let scheduleId: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
await gateway.reset();
|
||||
await releaseUnpaidHolds();
|
||||
await ensureCorridorRoute();
|
||||
await resetCorridorDay(DEPARTURE);
|
||||
|
||||
const contracts = await seedTenantContracts(
|
||||
STAMP,
|
||||
BOOKINGS.map((b) => ({
|
||||
suffix: b.suffix,
|
||||
currency: b.currency,
|
||||
customs: b.customs,
|
||||
freight: "BULK" as const,
|
||||
})),
|
||||
);
|
||||
|
||||
const schedule = await createSchedule({
|
||||
departure: DEPARTURE,
|
||||
kind: "bulk",
|
||||
locoPair: ["LOCO-IMP-15", "LOCO-IMP-16"],
|
||||
});
|
||||
scheduleId = schedule.id;
|
||||
// The cycle counter turns over when the window opens, not at creation.
|
||||
await forceWindowOpen(scheduleId, 45);
|
||||
expect((await scheduleRow(scheduleId)).booking_cycle_no, "FIRST window cycle").toBe(1);
|
||||
|
||||
for (const b of BOOKINGS) {
|
||||
booking.set(
|
||||
b.suffix,
|
||||
await bookBulkReady({
|
||||
contractId: contracts.get(b.suffix)!,
|
||||
tons: b.tons,
|
||||
scheduledDate: BOOKING_DAY,
|
||||
customs: b.customs,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}, 1_800_000);
|
||||
|
||||
afterAll(closeDb);
|
||||
|
||||
it("reserves all six with invoices in their contract currency — they fit exactly", async () => {
|
||||
await closeBookingWindow(scheduleId);
|
||||
await completeDocReview(scheduleId);
|
||||
|
||||
for (const b of BOOKINGS) {
|
||||
const row = await pollBookingStatus(booking.get(b.suffix)!, [
|
||||
"SELECTED_FOR_BATCH",
|
||||
"AWAITING_PAYMENT",
|
||||
]);
|
||||
expect(row.payment_deadline ?? "pending", `${b.suffix} pay deadline`).toBeTruthy();
|
||||
const invoice = await invoiceForBooking(booking.get(b.suffix)!);
|
||||
expect(invoice.currency, `${b.suffix} invoice currency`).toBe(b.currency);
|
||||
}
|
||||
});
|
||||
|
||||
it("all six pay through the gateway — 54/54 wagons, window FULL, schedule finalized", async () => {
|
||||
await extendPayWindow(scheduleId, [...booking.values()]);
|
||||
for (const b of BOOKINGS) {
|
||||
await payViaGateway(booking.get(b.suffix)!);
|
||||
}
|
||||
await endPaymentPhase(scheduleId);
|
||||
await pollWindow(
|
||||
scheduleId,
|
||||
(s) =>
|
||||
s.booking_window_status === "FULL" &&
|
||||
s.window_phase === "DONE" &&
|
||||
s.status === "SCHEDULED",
|
||||
"FULL + DONE + finalized",
|
||||
);
|
||||
expect(await allocatedWagons(scheduleId), "54 wagons allocated").toBe(54);
|
||||
});
|
||||
|
||||
it("GL Djibouti grants the gate pass and uploads T1 for the customs bookings", async () => {
|
||||
await gatePassGranted(scheduleId);
|
||||
for (const b of CUSTOMS) {
|
||||
const res = await uploadT1(booking.get(b.suffix)!);
|
||||
expect(res.status, `${b.suffix} T1 upload`).toBeLessThanOrEqual(201);
|
||||
}
|
||||
});
|
||||
|
||||
it("the train dispatches and runs the corridor checkpoint by checkpoint", async () => {
|
||||
await dispatchSchedule(scheduleId);
|
||||
await pollWindow(scheduleId, (s) => s.status === "DISPATCHED", "DISPATCHED");
|
||||
for (const b of BOOKINGS) await pollBookingStatus(booking.get(b.suffix)!, "IN_TRANSIT", 15);
|
||||
|
||||
await runCorridor(scheduleId);
|
||||
for (const b of BOOKINGS) await pollBookingStatus(booking.get(b.suffix)!, "ARRIVED", 20);
|
||||
|
||||
const [row] = await db<{ n: string }>(
|
||||
`SELECT count(*)::text AS n FROM freight.wagon_movements WHERE train_schedule_id = $1`,
|
||||
[scheduleId],
|
||||
);
|
||||
expect(Number(row.n), "wagon movement ledger rows").toBeGreaterThanOrEqual(54);
|
||||
});
|
||||
|
||||
it("GL runs the customs tail on every customs booking", async () => {
|
||||
for (const b of CUSTOMS) {
|
||||
const id = booking.get(b.suffix)!;
|
||||
await runImportCustomsTail(id);
|
||||
await expectMilestoneDone(id, "T1_CLOSED");
|
||||
await expectMilestoneDone(id, "RISK_ASSIGNED");
|
||||
await expectMilestoneDone(id, "IMPORT_RELEASE_GRANTED");
|
||||
await expectMilestoneDone(id, "IMPORT_PROCESS_COMPLETED");
|
||||
}
|
||||
});
|
||||
|
||||
it("the self-clearing bookings arrived clean — no customs tail", async () => {
|
||||
for (const b of SELF_CLEAR) {
|
||||
const id = booking.get(b.suffix)!;
|
||||
const row = await pollBookingStatus(id, "ARRIVED", 5);
|
||||
expect(row.status, `${b.suffix} final status`).toBe("ARRIVED");
|
||||
expect(await milestoneCount(id, "T1_CLOSED"), `${b.suffix} has no T1 tail`).toBe(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user