mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-06 15:55:02 +00:00
add dispute functionality for contract duty and implement collection dates
This commit is contained in:
@@ -41,7 +41,108 @@ export default defineConfig({
|
||||
process.env.E2E_DB_URL ??
|
||||
"postgres://edr_e2e:edr_e2e@localhost:5533/edr_freight_e2e";
|
||||
|
||||
// Shapes already retired in THIS cypress run. The Node plugin process
|
||||
// outlives spec-bundle re-evaluation (Cypress re-runs the bundle — and
|
||||
// so `before()` — on every cross-origin visit), so a run-scoped set here
|
||||
// is what keeps the cleanup from firing a second time and cancelling the
|
||||
// contract the spec had just created. Browser-side state cannot do this:
|
||||
// Cypress.env() is reset by the reload.
|
||||
const retiredContractShapes = new Set<string>();
|
||||
/** Keys already executed by `db:queryOnce` in this cypress run. */
|
||||
const onceRunKeys = new Set<string>();
|
||||
// Stamped when the plugin loads, i.e. once per cypress run. Cleanup only
|
||||
// ever touches rows older than this, so nothing the current run creates
|
||||
// can be cancelled out from under it.
|
||||
const runStartedAt = new Date().toISOString();
|
||||
|
||||
on("task", {
|
||||
/**
|
||||
* Cancel a previous run's contracts of one shape so a spec can run
|
||||
* again against a warm DB (the API allows one active contract per
|
||||
* customer + service type + route). Runs at most once per shape per
|
||||
* cypress run — see `retiredContractShapes`.
|
||||
*/
|
||||
async "db:retireStaleContracts"({
|
||||
tin,
|
||||
kind,
|
||||
direction,
|
||||
}: {
|
||||
tin: string;
|
||||
kind: string;
|
||||
direction: string;
|
||||
}) {
|
||||
const key = `${tin}:${kind}:${direction}`;
|
||||
if (retiredContractShapes.has(key)) return { skipped: true };
|
||||
retiredContractShapes.add(key);
|
||||
|
||||
const client = new Client({ connectionString: dbUrl });
|
||||
await client.connect();
|
||||
try {
|
||||
const result = await client.query(
|
||||
`UPDATE freight.contracts ct
|
||||
SET status = 'CANCELLED'
|
||||
FROM freight.companies c
|
||||
WHERE c.id = ct.company_id
|
||||
AND c.tin = $1
|
||||
AND ct.deleted_at IS NULL
|
||||
AND ct.contract_kind = $2
|
||||
AND ct.trade_direction = $3
|
||||
-- Only ever previous runs' rows.
|
||||
AND ct.created_at < $4::timestamptz
|
||||
-- Corridor fixtures are re-seeded by reference, so a
|
||||
-- cancelled one would never come back — leave them alone.
|
||||
AND ct.reference NOT LIKE 'CTR-IMP-%'
|
||||
-- Segment fixtures are stamped per run and always booked by
|
||||
-- their own spec. An UNBOOKED leftover is debris that still
|
||||
-- holds the lane (one active contract per service + route),
|
||||
-- which blocked the intercity spec from filing its own.
|
||||
AND (
|
||||
ct.reference NOT LIKE 'CTR-SEG-%'
|
||||
OR NOT EXISTS (
|
||||
SELECT 1 FROM freight.bookings b
|
||||
WHERE b.contract_id = ct.id AND b.deleted_at IS NULL
|
||||
)
|
||||
)
|
||||
AND ct.status NOT IN
|
||||
('REJECTED','CANCELLED','CONTRACT_CLOSED','ARCHIVED','EXPIRED')`,
|
||||
[tin, kind, direction, runStartedAt],
|
||||
);
|
||||
return { skipped: false, cancelled: result.rowCount };
|
||||
} finally {
|
||||
await client.end();
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Run a statement at most ONCE per cypress run, keyed by `key`.
|
||||
*
|
||||
* For arrange-data that must not repeat: Cypress re-evaluates the spec
|
||||
* bundle on every cross-origin visit, so a `before()` hook fires again
|
||||
* mid-spec and a plain cleanup would then wipe what the run had just
|
||||
* created. The Node plugin process outlives those reloads, so the guard
|
||||
* lives here rather than in the browser.
|
||||
*/
|
||||
async "db:queryOnce"({
|
||||
key,
|
||||
sql,
|
||||
params = [],
|
||||
}: {
|
||||
key: string;
|
||||
sql: string;
|
||||
params?: unknown[];
|
||||
}) {
|
||||
if (onceRunKeys.has(key)) return { skipped: true };
|
||||
onceRunKeys.add(key);
|
||||
const client = new Client({ connectionString: dbUrl });
|
||||
await client.connect();
|
||||
try {
|
||||
const result = await client.query(sql, params as never[]);
|
||||
return { skipped: false, rowCount: result.rowCount };
|
||||
} finally {
|
||||
await client.end();
|
||||
}
|
||||
},
|
||||
|
||||
/** Run an arbitrary SQL statement against the ephemeral e2e database. */
|
||||
async "db:query"({ sql, params = [] }: { sql: string; params?: unknown[] }) {
|
||||
const client = new Client({ connectionString: dbUrl });
|
||||
|
||||
@@ -50,6 +50,20 @@ function expectStatus(expected: string) {
|
||||
}
|
||||
|
||||
describe("contract lifecycle: creation to finalization", { retries: 0 }, () => {
|
||||
before(() => {
|
||||
// Re-runnable against a warm DB. The API allows one active contract per
|
||||
// customer + service type + route, so this spec's own contract from an
|
||||
// earlier run 409s the new one at creation — and since staff accept it
|
||||
// with a year's validity, it would keep doing so for a year. Retire just
|
||||
// the shape this spec creates (its own wizard-made GENERAL imports),
|
||||
// leaving the seeded corridor/segment fixtures alone.
|
||||
cy.retireStaleContracts({
|
||||
tin: companyTin,
|
||||
kind: "GENERAL",
|
||||
direction: "IMPORT",
|
||||
});
|
||||
});
|
||||
|
||||
it("customer creates and submits a GENERAL import container contract", () => {
|
||||
cy.loginPortal(customer);
|
||||
cy.visitPortal("/contracts/new");
|
||||
|
||||
@@ -117,9 +117,16 @@ function dbUpcomingSchedule() {
|
||||
return cy.task<{
|
||||
rows: Array<{ id: string; window_closes_at: string; booking_window_status: string }>;
|
||||
}>("db:query", {
|
||||
// Scoped to THIS journey's corridor (… → Djibouti Port). Direction + a 25h
|
||||
// window alone also matches the segment-weight spec's trains, which run
|
||||
// Mojo → Nagad on the same day — and one of those is deliberately driven
|
||||
// FULL, so the unscoped query handed this spec a full schedule whose
|
||||
// booking window never opens.
|
||||
sql: `SELECT ts.id, ts.window_closes_at, ts.booking_window_status
|
||||
FROM freight.train_schedules ts
|
||||
JOIN freight.yards d ON d.id = ts.destination_station_id
|
||||
WHERE ts.direction = 'EXPORT' AND ts.deleted_at IS NULL
|
||||
AND d.code = 'DJIB_PORT'
|
||||
AND ts.scheduled_departure_date > now()
|
||||
AND ts.scheduled_departure_date < now() + interval '25 hours'
|
||||
ORDER BY ts.created_at DESC LIMIT 1`,
|
||||
@@ -137,14 +144,17 @@ function fill(label: string | RegExp, value: string) {
|
||||
});
|
||||
}
|
||||
|
||||
/** Fill the N-th input whose label matches (two container-size editors both say "Quantity *"). */
|
||||
function fillNth(label: RegExp, index: number, value: string) {
|
||||
cy.get("label").then(($labels) => {
|
||||
const matches = $labels.filter((_, el) => label.test(el.textContent ?? ""));
|
||||
expect(matches.length, `labels matching ${label}`).to.be.greaterThan(index);
|
||||
const id = matches.eq(index).attr("for");
|
||||
cy.get(`[id="${id}"]`).clear({ force: true }).type(value, { force: true });
|
||||
});
|
||||
/**
|
||||
* Set one container-size line's quantity, found by its "20ft containers" /
|
||||
* "40ft containers" heading rather than by position in the list.
|
||||
*/
|
||||
function fillSizeQuantity(size: "20ft" | "40ft", value: string) {
|
||||
cy.contains(`${size} containers`, { timeout: 15000 })
|
||||
.closest("div.rounded-xl")
|
||||
.find('input[type="number"]')
|
||||
.first()
|
||||
.clear({ force: true })
|
||||
.type(value, { force: true });
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -315,6 +325,51 @@ describe("export one-time journeys: container + bulk on one train", { retries: 0
|
||||
before(() => {
|
||||
cy.task("db:seedFile", "seed-intercity.sql");
|
||||
cy.task("db:seedFile", "seed-export.sql");
|
||||
// Re-runnable against a warm DB: one active contract per customer +
|
||||
// service type + route, so an earlier run's ONE_TIME export contract
|
||||
// 409s this run at creation. A SPENT one (already booked) no longer
|
||||
// blocks, but a run that died before booking leaves a live one behind.
|
||||
cy.retireStaleContracts({
|
||||
tin: companyTin,
|
||||
kind: "ONE_TIME",
|
||||
direction: "EXPORT",
|
||||
});
|
||||
// The schedule step reuses an upcoming export schedule when one exists, but
|
||||
// a previous run's train is already loaded — and an export booking must
|
||||
// ride a single train WHOLE, so this run's booking then fails the space
|
||||
// check with "no open train on this day can carry it". Retire the older
|
||||
// schedules (unlinking their bookings) so this run gets an empty train.
|
||||
// Once per run: before() fires again on cross-origin visits, and a second
|
||||
// pass would delete the schedule this run had just created.
|
||||
cy.task("db:queryOnce", {
|
||||
key: "export_one_time:reset-upcoming-export-schedule",
|
||||
sql: `WITH stale AS (
|
||||
SELECT ts.id
|
||||
FROM freight.train_schedules ts
|
||||
JOIN freight.yards d
|
||||
ON d.id = ts.destination_station_id AND d.code = 'DJIB_PORT'
|
||||
WHERE ts.direction = 'EXPORT'
|
||||
AND ts.deleted_at IS NULL
|
||||
AND ts.scheduled_departure_date > now()
|
||||
AND ts.scheduled_departure_date < now() + interval '25 hours'
|
||||
), unlink AS (
|
||||
UPDATE freight.bookings b
|
||||
SET train_schedule_id = NULL,
|
||||
scheduling_status = 'NOT_SCHEDULED',
|
||||
status = CASE
|
||||
WHEN b.status IN ('FULLY_EXECUTED','SELECTED_FOR_BATCH','AWAITING_PAYMENT')
|
||||
THEN 'EXPIRED' ELSE b.status END
|
||||
WHERE b.train_schedule_id IN (SELECT id FROM stale)
|
||||
), drop_links AS (
|
||||
UPDATE freight.train_schedule_bookings
|
||||
SET deleted_at = now()
|
||||
WHERE train_schedule_id IN (SELECT id FROM stale)
|
||||
AND deleted_at IS NULL
|
||||
)
|
||||
UPDATE freight.train_schedules
|
||||
SET deleted_at = now()
|
||||
WHERE id IN (SELECT id FROM stale)`,
|
||||
});
|
||||
});
|
||||
|
||||
// ── Shared infrastructure ─────────────────────────────────────────────────
|
||||
@@ -457,9 +512,13 @@ describe("export one-time journeys: container + bulk on one train", { retries: 0
|
||||
withContract("CONTAINER", (c) => cy.visitPortal(`/contracts/${c.id}/bookings/new`));
|
||||
cy.contains("New Shipment Booking", { timeout: 20000 }).should("be.visible");
|
||||
|
||||
// Two size editors, each with its own "Quantity *" (20ft first, then 40ft).
|
||||
fillNth(/^Quantity/, 0, "2");
|
||||
fillNth(/^Quantity/, 1, "1");
|
||||
// Address each size editor by its heading, not by position: the two lines
|
||||
// come out of the contract's cargo scope and are not guaranteed to be in
|
||||
// 20ft-then-40ft order. Reversed, this booked 1 × 20ft — an odd count,
|
||||
// which the form blocks (a lone 20ft can never be paired onto a wagon), so
|
||||
// "Review price & book" stayed disabled.
|
||||
fillSizeQuantity("20ft", "2");
|
||||
fillSizeQuantity("40ft", "1");
|
||||
|
||||
cy.get('input[placeholder*="MSCU"]', { timeout: 15000 }).should("have.length", 3);
|
||||
cy.get('input[placeholder*="MSCU"]').eq(0).type(isoNumber("MSCU", 0));
|
||||
|
||||
@@ -67,6 +67,23 @@ function requestByReason(reason: string) {
|
||||
describe("fleet: wagon transfer requests between yards", { retries: 0 }, () => {
|
||||
before(() => {
|
||||
cy.task("db:seedFile", "seed-import-corridor.sql");
|
||||
// This spec asserts the "no available wagons" guard against E2E_AWASH, so
|
||||
// that yard must actually hold none. Run alone it does, but in a full-suite
|
||||
// run an earlier corridor spec can leave idle wagons standing there and the
|
||||
// guard then returns 201 instead of 400. Park them back at KALITY — only
|
||||
// loose AVAILABLE wagons move, so nothing another spec is using is touched.
|
||||
cy.task("db:query", {
|
||||
sql: `UPDATE freight.wagons w
|
||||
SET current_yard_id = k.id
|
||||
FROM freight.yards a, freight.yards k
|
||||
WHERE a.code = 'E2E_AWASH'
|
||||
AND k.code = 'KALITY'
|
||||
AND w.current_yard_id = a.id
|
||||
AND w.train_id IS NULL
|
||||
AND w.current_train_schedule_id IS NULL
|
||||
AND w.status = 'AVAILABLE'
|
||||
AND w.deleted_at IS NULL`,
|
||||
});
|
||||
});
|
||||
|
||||
it("files a count-only request — same-yard and empty-source are rejected", () => {
|
||||
|
||||
@@ -136,13 +136,13 @@ function dbSchedule() {
|
||||
* Fill the N-th labelled Mantine input (label[for] → input id). Indexed
|
||||
* because the booking form renders one "Quantity *" per container size.
|
||||
*/
|
||||
function fillNth(label: RegExp, index: number, value: string) {
|
||||
cy.get("label").then(($labels) => {
|
||||
const matches = $labels.filter((_, el) => label.test(el.textContent ?? ""));
|
||||
expect(matches.length, `labels matching ${label}`).to.be.greaterThan(index);
|
||||
const id = matches.eq(index).attr("for");
|
||||
cy.get(`[id="${id}"]`).clear({ force: true }).type(value, { force: true });
|
||||
});
|
||||
function fillSizeQuantity(size: "20ft" | "40ft", value: string) {
|
||||
cy.contains(`${size} containers`, { timeout: 15000 })
|
||||
.closest("div.rounded-xl")
|
||||
.find('input[type="number"]')
|
||||
.first()
|
||||
.clear({ force: true })
|
||||
.type(value, { force: true });
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -188,6 +188,14 @@ describe("intercity one-time journey: contract → booking → export train", {
|
||||
// Container types, locomotives, built train, yard distances — the
|
||||
// infrastructure the UI journey cannot create in-flow.
|
||||
cy.task("db:seedFile", "seed-intercity.sql");
|
||||
// Re-runnable against a warm DB: an earlier run's DOMESTIC ONE_TIME
|
||||
// contract on this lane would 409 this run's at creation (a spent one no
|
||||
// longer blocks, but an unbooked leftover does).
|
||||
cy.retireStaleContracts({
|
||||
tin: companyTin,
|
||||
kind: "ONE_TIME",
|
||||
direction: "DOMESTIC",
|
||||
});
|
||||
});
|
||||
|
||||
// ── Contract: submit → reject → resubmit → approve → sign ────────────────
|
||||
@@ -390,8 +398,8 @@ describe("intercity one-time journey: contract → booking → export train", {
|
||||
// 40ft line, each seeded quantity 1. Book 2 × 20ft (pairs share a wagon,
|
||||
// so the count must be even) and zero the 40ft line — otherwise its
|
||||
// default unit adds a third container-number input.
|
||||
fillNth(/^Quantity/, 0, "2");
|
||||
fillNth(/^Quantity/, 1, "0");
|
||||
fillSizeQuantity("20ft", "2");
|
||||
fillSizeQuantity("40ft", "0");
|
||||
|
||||
// One ISO container number per unit.
|
||||
cy.get('input[placeholder*="MSCU"]', { timeout: 15000 }).should("have.length", 2);
|
||||
|
||||
@@ -199,6 +199,26 @@ Cypress.Commands.add("fillCargoDescription", (text = "Electronics") => {
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Retire a previous run's contracts so a spec is re-runnable against a warm DB.
|
||||
*
|
||||
* The API allows one active contract per customer + service type + route, so
|
||||
* yesterday's contract 409s today's at creation — and staff accept contracts
|
||||
* with a year's validity, so it would keep doing so for a year.
|
||||
*
|
||||
* The cutoff is frozen on first use: Cypress re-evaluates the spec bundle on
|
||||
* cross-origin visits, so `before()` fires again mid-run, and a naive "cancel
|
||||
* this shape" would then cancel the contract THIS run had just created.
|
||||
* Anything created after the spec started is ours and must survive.
|
||||
*/
|
||||
Cypress.Commands.add(
|
||||
"retireStaleContracts",
|
||||
(opts: { tin: string; kind: string; direction: string }) => {
|
||||
// The once-per-run guard lives in the Node task (see cypress.config.ts).
|
||||
cy.task("db:retireStaleContracts", opts);
|
||||
},
|
||||
);
|
||||
|
||||
/** Type a 6-digit code into a Mantine PinInput. */
|
||||
Cypress.Commands.add("typeOtp", (code: string) => {
|
||||
cy.get(".mantine-PinInput-root input").should("have.length.at.least", code.length);
|
||||
@@ -257,6 +277,12 @@ declare global {
|
||||
uploadCompanyStamp(): Chainable<void>;
|
||||
/** Fill the required per-booking cargo description (container only). */
|
||||
fillCargoDescription(text?: string): Chainable<void>;
|
||||
/** Cancel a previous run's contracts of this shape (warm-DB re-runs). */
|
||||
retireStaleContracts(opts: {
|
||||
tin: string;
|
||||
kind: string;
|
||||
direction: string;
|
||||
}): Chainable<void>;
|
||||
/** Fill a Mantine PinInput with a code. */
|
||||
typeOtp(code: string): Chainable<void>;
|
||||
/** Scribble on the signature-pad canvas inside the open modal. */
|
||||
|
||||
Reference in New Issue
Block a user