mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Add a minimal payment mock server for end-to-end testing
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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"]);
|
||||
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
|
||||
80
e2e/freight/payment-mock/server.js
Normal file
80
e2e/freight/payment-mock/server.js
Normal 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}`);
|
||||
});
|
||||
Reference in New Issue
Block a user