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

@@ -139,3 +139,47 @@ cast past the type checker (`last-mile-invoice.service.ts:41`), while
varchar with no constraint, so both spellings persist. Writes and reads agree
within each module so billing works — but S39's "invoice source LASTMILE"
assertion must match `'last_mile'`, not the enum value.
## Two engine changes the committed specs predate
Both were found by running the suite, and both broke EVERY scenario until
fixed. They are recorded here because neither is visible from the scenario
document — only from the API source.
**1. Every contract booking is born in the clearance gate.**
`contract-booking.service.ts:211` — *"EVERY contract booking clears per booking
now — both contract kinds, both paths, intercity included."* A booking is
created in `AWAITING_DOCUMENTS` regardless of whether customs clearance is
enabled, so `bookContainers` followed by `acceptOperation` always 409s with
`Cannot perform this action on status "AWAITING_DOCUMENTS". Allowed:
OPERATION_REQUEST_PENDING`.
The gate is upload → GL approve → finalize → customer proceeds with the day.
`clearToOperationRequestPending` (import-utils) runs it; `bookAndClear`
(g1-utils) wraps book + clear + accept and is what the g-specs use.
NOTE: the pre-existing corridor specs (e.g. `import_full_train.cy.ts`) still
call `acceptOperation` directly and fail for this reason — 3 passing / 10
failing when last run. They predate the gate and need the same treatment.
**2. An unpaid hold cannot expire without a reachable payment gateway.**
Before expiring a reservation the engine asks the gateway whether a late
payment landed (`booking-batch.service.ts:3484-3506`), and treats ANY error as
`unverifiable: true` — deferring the expiry rather than risk expiring a
customer who paid:
[BATCH] expire deferred for BK-… — settlement unverifiable at the
gateway; retrying next settle tick
`PAYMENT_API_URL` defaults to the real `https://paymentcallback.triaplc.com`
(`payment-client.service.ts:25`), unreachable from e2e, so every expiry
deferred forever. Six scenarios turn on an expiry: G1·S1, G1·S5, G1·S6,
G3·S16, G5·S22, G5·S24.
Fixed with a stand-in service — `e2e/freight/payment-mock/server.js`, wired as
`payment-mock-e2e` in `docker-compose.e2e.yaml` with
`PAYMENT_API_URL: http://payment-mock-e2e:4500`. It answers
`POST /payments/reconcile` with `{paid:false, unverifiable:false}` so the
engine gets a definite "no payment exists" and expires the hold as designed.
A stack that does NOT point PAYMENT_API_URL at a reachable service will hang
on every expiry assertion.

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

View File

@@ -0,0 +1,80 @@
// Minimal payment-service stand-in for the e2e stack.
//
// WHY THIS EXISTS
//
// `expireBooking` will not expire an unpaid reservation until it has asked the
// payment gateway whether the money landed late — reconcile-before-expire
// (booking-batch.service.ts:3484-3506). Any error answering that question is
// treated as `unverifiable: true`, and an unverifiable answer DEFERS the
// expiry rather than risk expiring a customer who actually paid:
//
// [BATCH] expire deferred for BK-… — settlement unverifiable at the
// gateway; retrying next settle tick
//
// That is correct in production. In e2e there is no payment microservice, and
// PAYMENT_API_URL defaults to the real https://paymentcallback.triaplc.com
// (payment-client.service.ts:25), so every reconcile call fails and EVERY
// unpaid hold defers forever. Six scenarios turn on a reservation expiring
// (G1·S1, G1·S5, G1·S6, G3·S16, G5·S22, G5·S24), and all of them hang on it.
//
// This server answers the two calls that path makes, so the engine gets a
// definite "no payment exists" and expires the hold as designed. It is
// deliberately dumb: nothing here simulates a real gateway, and the specs that
// need a SUCCESSFUL payment do not come through here at all — they deliver
// `payment.succeeded` to the API's own internal webhook (see settleViaGateway
// in import-utils.ts), which is the real production path for a settled
// payment.
const http = require("node:http");
const PORT = process.env.PORT || 4500;
/**
* `paid: false, unverifiable: false` = "the gateway is reachable and holds no
* settled payment for this reference". That is the answer that lets an expiry
* proceed. Returning `unverifiable: true` here would reproduce the exact
* deadlock this mock exists to remove.
*/
const NOT_PAID = { paid: false, unverifiable: false };
const server = http.createServer((req, res) => {
let body = "";
req.on("data", (chunk) => {
body += chunk;
});
req.on("end", () => {
const send = (status, payload) => {
const json = JSON.stringify(payload);
res.writeHead(status, {
"content-type": "application/json",
"content-length": Buffer.byteLength(json),
});
res.end(json);
};
// POST /payments/reconcile — the reconcile-before-expire call.
if (req.method === "POST" && req.url.startsWith("/payments/reconcile")) {
console.log(`[payment-mock] reconcile ${body || "(no body)"} → not paid`);
return send(200, NOT_PAID);
}
// GET /payments/intents?… — the intent lookup. 404 is a valid "no intent
// for this reference" answer and the client maps it to null rather than
// treating it as an error (payment-client.service.ts:72-73).
if (req.method === "GET" && req.url.startsWith("/payments/intents")) {
console.log(`[payment-mock] intents ${req.url} → 404 (none)`);
return send(404, { message: "No intent for this reference" });
}
// Health probe for the compose healthcheck.
if (req.method === "GET" && req.url.startsWith("/health")) {
return send(200, { ok: true });
}
console.log(`[payment-mock] unhandled ${req.method} ${req.url}`);
send(404, { message: `Unhandled ${req.method} ${req.url}` });
});
});
server.listen(PORT, () => {
console.log(`payment-mock listening on ${PORT}`);
});