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

View File

@@ -0,0 +1,195 @@
/**
* BULK IMPORT — split offer, exact-remainder rebooking, pay-window expiry and
* priority-ordered waiting-list promotion on one 54-wagon CW4 train. Bulk
* splits are FULL-WAGONS-ONLY at the base 70 T cap.
*
* reserved (priority order): BSA 1 400 T = 20 w, BSB 980 T = 14 w,
* BSD 840 T = 12 w → 46 w. BSC 1 680 T = 24 w does NOT fit whole → PARTIAL
* offer of the remaining 8 wagons = 560 T. BSC settles through the payment
* service → the split applies (is_split + pre-split snapshot); the
* outstanding 1 120 T must later be rebooked EXACTLY.
* BSD never pays → EXPIRES; the freed 12 wagons promote BS1 (6 w) and
* BS2 (6 w) in priority order; BS3 (20 w) never fits and expires.
*
* Final consist: 20 + 14 + 8 + 6 + 6 = 54/54.
*
* The split is the reason this file pays through the gateway rather than the
* staff shortcut: applying a pending partial offer hangs off
* `booking.invoice.paid`, which only a real settlement emits.
*/
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { closeDb, gateway } from "./client";
import {
allocatedWagons,
bookBulk,
bookBulkReady,
bookingRow,
closeBookingWindow,
completeDocReview,
createSchedule,
departureAt,
eatDayStr,
endPaymentPhase,
extendPayWindow,
ensureCorridorRoute,
forceReservationExpiry,
forceWindowOpen,
payViaGateway,
pollAllocations,
pollBookingStatus,
pollPartialOffer,
pollWindow,
releaseUnpaidHolds,
resetCorridorDay,
seedTenantContracts,
setPriority,
} from "./flows";
const DEPARTURE = departureAt(23);
const BOOKING_DAY = eatDayStr(DEPARTURE);
const REMAINDER_DEPARTURE = departureAt(24);
const REMAINDER_DAY = eatDayStr(REMAINDER_DEPARTURE);
const STAMP = String(Date.now());
const ORDER = ["BSA", "BSB", "BSD", "BSC", "BS1", "BS2", "BS3"] as const;
const TONS: Record<string, number> = {
BSA: 1400,
BSB: 980,
BSD: 840,
BSC: 1680,
BS1: 420,
BS2: 420,
BS3: 1400,
};
describe("bulk import: split offer, remainder rebooking, expiry + promotion", () => {
const booking = new Map<string, string>();
let contracts: Map<string, string>;
let scheduleId: string;
beforeAll(async () => {
await gateway.reset();
await releaseUnpaidHolds();
await ensureCorridorRoute();
await resetCorridorDay(DEPARTURE);
await resetCorridorDay(REMAINDER_DEPARTURE);
contracts = await seedTenantContracts(
STAMP,
ORDER.map((suffix) => ({ suffix, freight: "BULK" as const })),
);
scheduleId = (
await createSchedule({
departure: DEPARTURE,
kind: "bulk",
locoPair: ["LOCO-IMP-19", "LOCO-IMP-20"],
})
).id;
await forceWindowOpen(scheduleId, 45);
for (const [i, suffix] of ORDER.entries()) {
const id = await bookBulkReady({
contractId: contracts.get(suffix)!,
tons: TONS[suffix],
scheduledDate: BOOKING_DAY,
});
booking.set(suffix, id);
await setPriority(id, i + 1);
}
}, 1_800_000);
afterAll(closeDb);
it("reserves BSA/BSB/BSD whole and offers BSC a PARTIAL for the last 8 wagons", async () => {
await closeBookingWindow(scheduleId);
await completeDocReview(scheduleId);
for (const suffix of ["BSA", "BSB", "BSD", "BSC"]) {
await pollBookingStatus(booking.get(suffix)!, ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"]);
}
const offer = await pollPartialOffer(booking.get("BSC")!);
expect(Number(offer.offered_wagons), "BSC offered the remaining 8 wagons").toBe(8);
for (const suffix of ["BS1", "BS2", "BS3"]) {
expect((await bookingRow(booking.get(suffix)!)).status, `${suffix} waiting`).toBe(
"FULLY_EXECUTED",
);
}
});
it("BSA and BSB pay; BSC settles through the gateway — the split applies", async () => {
// Three sequential gateway settlements outlast the ~60s hold; BSD is left
// alone because the next test is about its expiry.
await extendPayWindow(scheduleId, [booking.get("BSA")!, booking.get("BSB")!, booking.get("BSC")!]);
await payViaGateway(booking.get("BSA")!);
await pollAllocations(booking.get("BSA")!, 20);
await payViaGateway(booking.get("BSB")!);
await pollAllocations(booking.get("BSB")!, 14);
await payViaGateway(booking.get("BSC")!);
await pollAllocations(booking.get("BSC")!, 8);
const bsc = await bookingRow(booking.get("BSC")!);
expect(bsc.is_split, "BSC is split").toBe(true);
expect(bsc.pre_split_quantities, "pre-split snapshot kept").toBeTruthy();
});
it("BSD misses its pay window — the freed wagons promote BS1 + BS2 in priority order", async () => {
await forceReservationExpiry(booking.get("BSD")!);
for (const suffix of ["BS1", "BS2"]) {
const row = await pollBookingStatus(booking.get(suffix)!, [
"SELECTED_FOR_BATCH",
"AWAITING_PAYMENT",
]);
expect(row.status, `${suffix} promoted`).not.toBe("FULLY_EXECUTED");
expect(
(await bookingRow(booking.get(suffix)!)).payment_deadline,
`${suffix} got a pay window`,
).toBeTruthy();
}
expect((await bookingRow(booking.get("BS3")!)).status, "BS3 still has no seat").toBe(
"FULLY_EXECUTED",
);
});
it("BS1 and BS2 pay — the train is FULL at 54; BS3 expires with the day", async () => {
await extendPayWindow(scheduleId, [booking.get("BS1")!, booking.get("BS2")!]);
await payViaGateway(booking.get("BS1")!);
await pollAllocations(booking.get("BS1")!, 6);
await payViaGateway(booking.get("BS2")!);
await pollAllocations(booking.get("BS2")!, 6);
await endPaymentPhase(scheduleId);
await pollWindow(
scheduleId,
(s) => s.booking_window_status === "FULL" && s.window_phase === "DONE",
"FULL + DONE",
);
expect(await allocatedWagons(scheduleId), "54 wagons allocated").toBe(54);
await pollBookingStatus(booking.get("BS3")!, "EXPIRED", 40);
});
it("the split customer must rebook EXACTLY the 1 120 T remainder", async () => {
const remainderSchedule = await createSchedule({
departure: REMAINDER_DEPARTURE,
kind: "bulk",
locoPair: ["LOCO-IMP-21", "LOCO-IMP-22"],
});
await forceWindowOpen(remainderSchedule.id, 45);
// 1 680 booked 560 shipped by the split = 1 120 T outstanding.
const wrong = await bookBulk({
contractId: contracts.get("BSC")!,
tons: 560,
scheduledDate: REMAINDER_DAY,
});
expect(wrong.status, "partial remainder rejected").toBeGreaterThanOrEqual(400);
expect(JSON.stringify(wrong.body)).toMatch(/must take the whole/i);
const exact = await bookBulk({
contractId: contracts.get("BSC")!,
tons: 1120,
scheduledDate: REMAINDER_DAY,
});
expect(exact.status, JSON.stringify(exact.body)).toBeLessThanOrEqual(201);
});
});