Add a minimal payment mock server for end-to-end testing

This commit is contained in:
Marshal
2026-08-01 18:59:14 +00:00
parent 8ccb0e1558
commit 21fdf144ca
8 changed files with 420 additions and 105 deletions

View File

@@ -53,7 +53,7 @@ import {
seedImportContract,
withBooking,
} from "./import-utils";
import { bookAndClear } from "./g1-utils";
import { bookAndClear, clearAndAccept } from "./g1-utils";
const stamp = String(Date.now());
const stampedRef = (suffix: string) => `CTR-IMP-${stamp}-${suffix}`;
@@ -173,15 +173,16 @@ describe("G3·S13: unpaid export holds occupy the train", { retries: 0 }, () =>
});
it("EA holds 30 wagons; EB then sees only 24 free and holds 20", () => {
bookContainers({
// Export: ACCEPT is the reservation (FCFS), not a batch entry — but the
// booking still clears its per-booking document gate first, same as import.
bookAndClear({
suffix: "EA",
runStamp: stamp,
isoSeed: 14_100,
forty: SHAPES.EA.forty,
scheduledDate: BOOKING_DAY,
mode: "export",
});
// Export: ACCEPT is the reservation (FCFS), not a batch entry.
acceptExport("EA");
bookAndClear({
suffix: "EB",
@@ -403,7 +404,7 @@ describe("G3·S15: two trains on one day, picked per booking", { retries: 0 }, (
});
it("A fills most of T1; B and C then choose by what is left", () => {
acceptExport("PA");
clearAndAccept({ suffix: "PA", scheduledDate: BOOKING_DAY, mode: "export" });
markPaid("PA");
pollAllocations("PA", SHAPES.PA.wagons);
@@ -473,15 +474,15 @@ describe("G3·S17: a clearance hold gates one booking only", { retries: 0 }, ()
let isoSeed = 16_600;
ORDER.forEach((suffix) => {
bookContainers({
bookAndClear({
suffix,
runStamp: stamp,
isoSeed,
forty: SHAPES[suffix].forty,
scheduledDate: BOOKING_DAY,
mode: "export",
});
isoSeed += SHAPES[suffix].forty;
acceptExport(suffix);
markPaid(suffix);
pollAllocations(suffix, SHAPES[suffix].wagons);
});
@@ -605,7 +606,7 @@ describe("G3·S16: an expired hold frees space and fits flips back", { retries:
}),
);
acceptExport("HC");
clearAndAccept({ suffix: "HC", scheduledDate: BOOKING_DAY, mode: "export" });
markPaid("HC");
pollAllocations("HC", SHAPES.HC.wagons);
});

View File

@@ -122,15 +122,13 @@ describe("G5·S22: an expired booking moves to the next day intact", { retries:
// The same customer, the same contract, a new day — straight back into
// OPERATION_REQUEST_PENDING as if nothing had gone wrong.
bookContainers({
bookAndClear({
suffix: "RC",
runStamp: stamp,
isoSeed: 18_400,
forty: SHAPES.RC.forty,
scheduledDate: BOOKING_DAY_2,
});
pollBookingStatus("RC", "OPERATION_REQUEST_PENDING", 10);
acceptOperation("RC");
});
it("day 2 carries RC plus a new 40-wagon customer — FULL at 53/53", () => {
@@ -231,15 +229,13 @@ describe("G5·S23: a split's paid part survives the remainder expiring", { retri
configureAndOpenSchedule({ departure: DAY_2, trainCode: G1_TRAIN_2 });
// A split customer must rebook EXACTLY the outstanding remainder.
bookContainers({
bookAndClear({
suffix: "SC",
runStamp: stamp,
isoSeed: 19_200,
twenty: REMAINDER_CONTAINERS,
scheduledDate: BOOKING_DAY_2,
});
pollBookingStatus("SC", "OPERATION_REQUEST_PENDING", 10);
acceptOperation("SC");
closeWindowAndRunBatch(DAY_2);
pollBookingStatus("SC", ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"]);

View File

@@ -539,7 +539,9 @@ export function clearToOperationRequestPending(suffix: string, scheduledDate: st
expect(b.status, `${suffix} starts in the clearance gate`).to.eq("AWAITING_DOCUMENTS");
clearBookingClearance(b.id, scheduledDate);
});
pollBookingStatus(suffix, "OPERATION_REQUEST_PENDING", 5);
// 20 attempts ≈ 60s: the gate runs four sequential API calls (upload →
// review → finalize → proceed) and the status only settles after the last.
pollBookingStatus(suffix, "OPERATION_REQUEST_PENDING", 20);
}
/**
@@ -725,20 +727,23 @@ export function settleViaGateway(suffix: string) {
export function forceReservationExpiry(suffix: string) {
withBooking(suffix, (b) =>
db(
// A full HOUR into the past, not one second.
//
// The sweep races the top-up it triggers: promoting a waiting booking
// calls extendPaymentPhaseForTopUp (booking-batch.service.ts:2795),
// which pushes paymentPhaseEndsAt out — and a deadline only just behind
// `now()` can end up on the wrong side of the moved boundary, leaving
// the reservation un-flipped while its wagons have already been handed
// to the waiting list. An hour is unambiguously overdue under any
// extension the top-up applies.
// An hour into the past, not one second: the settle races the top-up it
// triggers, and promoting a waiting booking calls
// extendPaymentPhaseForTopUp (booking-batch.service.ts:2795) which
// pushes the phase boundary out. A deadline only just behind `now()` can
// land on the wrong side of that move.
`UPDATE freight.bookings SET payment_deadline = now() - interval '1 hour'
WHERE id = $1`,
[b.id],
),
);
// NOTE: this only settles because the e2e stack runs a payment-service mock
// (docker-compose.e2e.yaml → payment-mock-e2e). Before expiring an unpaid
// hold the engine asks the gateway whether a late payment landed, and treats
// an unreachable gateway as "unverifiable" — deferring the expiry forever
// rather than risking expiring someone who paid. Against a stack without
// PAYMENT_API_URL pointed at a reachable service, this poll times out and
// the API logs "expire deferred … settlement unverifiable at the gateway".
pollBookingStatus(suffix, "EXPIRED");
}