add e2e test

This commit is contained in:
Marshal
2026-07-22 23:44:32 +00:00
parent b0f561a935
commit 2481f43f1f
32 changed files with 4702 additions and 125 deletions

View File

@@ -27,6 +27,9 @@ export const superAdmin = "superadmin@tria.com";
export const CORRIDOR = ["DJIB_PORT", "NAGAD", "DIRE_DAWA", "E2E_AWASH", "MOJO", "KALITY"] as const;
export const ORIGIN = "DJIB_PORT";
export const DEST = "KALITY";
/** Export rides the same corridor reversed (ET → DJ). */
export const EXP_ORIGIN = "KALITY";
export const EXP_DEST = "DJIB_PORT";
export const apiUrl = () => Cypress.env("apiUrl") as string;
@@ -113,7 +116,8 @@ export interface SeedContractOpts {
reference: string;
currency?: "ETB" | "USD";
customs?: boolean;
direction?: "IMPORT" | "DOMESTIC";
direction?: "IMPORT" | "EXPORT" | "DOMESTIC";
freight?: "CONTAINER" | "BULK";
originCode?: string;
destCode?: string;
}
@@ -122,6 +126,9 @@ export function seedImportContract(opts: SeedContractOpts) {
const currency = opts.currency ?? "ETB";
const customs = opts.customs ?? false;
const direction = opts.direction ?? "IMPORT";
const freight = opts.freight ?? "CONTAINER";
// Pre-booking boundary milestone for Path B contracts differs by direction.
const boundary = direction === "EXPORT" ? "EXPORT_RELEASED" : "DO_COLLECTED";
db(
`WITH c AS (
INSERT INTO freight.contracts
@@ -133,9 +140,13 @@ export function seedImportContract(opts: SeedContractOpts) {
SELECT $1, comp.id,
(SELECT p.id FROM freight.company_profiles p
WHERE p.company_id = comp.id AND p.deleted_at IS NULL
ORDER BY CASE WHEN p.type = 'importer' THEN 0 ELSE 1 END
ORDER BY CASE
WHEN $2::text = 'EXPORT' AND p.type = 'exporter' THEN 0
WHEN $2::text <> 'EXPORT' AND p.type = 'importer' THEN 0
ELSE 1
END
LIMIT 1),
'ONE_TIME', $2, 'CONTAINER',
'ONE_TIME', $2::text, $9::text,
(SELECT st.id FROM freight.service_types st ORDER BY st.created_at LIMIT 1),
$3, $4,
CASE WHEN $4 THEN 'CLEARANCE_READY_FOR_BOOKING' ELSE 'NOT_APPLICABLE' END,
@@ -162,17 +173,24 @@ export function seedImportContract(opts: SeedContractOpts) {
JOIN freight.yards o ON o.code = $6
JOIN freight.yards d ON d.code = $7
RETURNING id
), scope AS (
), scope_container AS (
INSERT INTO freight.contract_cargo_scope
(contract_id, container_size, cargo_free_text)
SELECT c.id, v.size, 'E2E import corridor cargo'
FROM c CROSS JOIN (VALUES ('20ft'), ('40ft')) AS v(size)
WHERE $9::text = 'CONTAINER'
), scope_bulk AS (
INSERT INTO freight.contract_cargo_scope
(contract_id, cargo_type_id, cargo_free_text)
SELECT c.id, ct.id, 'E2E import wheat'
FROM c JOIN freight.cargo_types ct ON ct.code = 'E2E_IMP_WHEAT'
WHERE $9::text = 'BULK'
)
-- Path B gate: ONE_TIME customs bookings require the pre-booking boundary
-- milestone (IMPORT → DO_COLLECTED) COMPLETED at contract level.
-- milestone (IMPORT → DO_COLLECTED, EXPORT → EXPORT_RELEASED) COMPLETED.
INSERT INTO freight.clearance_milestones
(contract_id, milestone_code, milestone_label, status, triggered_at, sort_order)
SELECT c.id, 'DO_COLLECTED', 'Delivery order collected', 'COMPLETED', now(), 0
SELECT c.id, $10, 'Pre-booking clearance boundary', 'COMPLETED', now(), 0
FROM c WHERE $4`,
[
opts.reference,
@@ -183,6 +201,8 @@ export function seedImportContract(opts: SeedContractOpts) {
opts.originCode ?? ORIGIN,
opts.destCode ?? DEST,
opts.suffix,
freight,
boundary,
],
);
// Backfill the boundary milestone when the insert above was skipped because
@@ -191,15 +211,15 @@ export function seedImportContract(opts: SeedContractOpts) {
db(
`INSERT INTO freight.clearance_milestones
(contract_id, milestone_code, milestone_label, status, triggered_at, sort_order)
SELECT ct.id, 'DO_COLLECTED', 'Delivery order collected', 'COMPLETED', now(), 0
SELECT ct.id, $2::text, 'Pre-booking clearance boundary', 'COMPLETED', now(), 0
FROM freight.contracts ct
WHERE ct.reference LIKE 'CTR-IMP-%-' || $1 AND ct.deleted_at IS NULL
AND NOT EXISTS (
SELECT 1 FROM freight.clearance_milestones m
WHERE m.contract_id = ct.id AND m.milestone_code = 'DO_COLLECTED'
WHERE m.contract_id = ct.id AND m.milestone_code = $2::text
AND m.deleted_at IS NULL
)`,
[opts.suffix],
[opts.suffix, boundary],
);
}
}
@@ -292,7 +312,7 @@ export function bookContainers(opts: {
forty?: number;
scheduledDate?: string; // omit for DOMESTIC (intercity)
vgmTons?: number;
expectFailure?: string; // substring of the expected 4xx error message
expectFailure?: string | RegExp; // substring/regex of the expected 4xx error
}) {
const vgm = opts.vgmTons ?? 10;
const lines: Array<Record<string, unknown>> = [];
@@ -338,7 +358,55 @@ export function bookContainers(opts: {
).then((res) => {
if (opts.expectFailure) {
expect(res.status, `${opts.suffix} booking rejected`).to.be.within(400, 422);
expect(JSON.stringify(res.body)).to.include(opts.expectFailure);
if (opts.expectFailure instanceof RegExp) {
expect(JSON.stringify(res.body)).to.match(opts.expectFailure);
} else {
expect(JSON.stringify(res.body)).to.include(opts.expectFailure);
}
} else {
expect(res.status, `${opts.suffix} booking created`).to.be.oneOf([200, 201]);
}
});
});
}
/**
* Book bulk tons under a seeded BULK contract via the API. Wagon demand =
* ceil(tons / 70) on CW4 covered gondolas.
*/
export function bookBulk(opts: {
suffix: string;
tons: number;
scheduledDate?: string; // omit for DOMESTIC (intercity)
expectFailure?: string | RegExp;
}) {
db<{ id: string; customs_clearing_enabled: boolean; cargo_type_id: string }>(
`SELECT ct.id, ct.customs_clearing_enabled,
(SELECT t.id FROM freight.cargo_types t WHERE t.code = 'E2E_IMP_WHEAT') AS cargo_type_id
FROM freight.contracts ct
WHERE ct.reference LIKE 'CTR-IMP-%-' || $1
ORDER BY ct.created_at DESC LIMIT 1`,
[opts.suffix],
).then(({ rows }) => {
expect(rows, `seeded contract *-${opts.suffix}`).to.have.length(1);
const actor = rows[0].customs_clearing_enabled ? superAdmin : customer;
apiPost(
actor,
`/api/contracts/${rows[0].id}/bookings`,
{
...(opts.scheduledDate ? { scheduledDate: opts.scheduledDate } : {}),
bulkLines: [{ cargoTypeId: rows[0].cargo_type_id, cargoWeightTons: opts.tons }],
cargoFreeText: "E2E import wheat",
},
!opts.expectFailure,
).then((res) => {
if (opts.expectFailure) {
expect(res.status, `${opts.suffix} booking rejected`).to.be.within(400, 422);
if (opts.expectFailure instanceof RegExp) {
expect(JSON.stringify(res.body)).to.match(opts.expectFailure);
} else {
expect(JSON.stringify(res.body)).to.include(opts.expectFailure);
}
} else {
expect(res.status, `${opts.suffix} booking created`).to.be.oneOf([200, 201]);
}
@@ -470,6 +538,40 @@ export function forceReservationExpiry(suffix: string) {
pollBookingStatus(suffix, "EXPIRED");
}
/**
* Type-integrity assert for mixed trains: every wagon slot allocated to the
* booking is of ONE expected wagon type (containers → NW5, bulk → CW4), and
* the slot count matches. No wheat on a flat wagon, no box in a gondola.
*/
export function expectWagonType(suffix: string, code: string, wagons: number) {
withBooking(suffix, (b) =>
pollDb<{ code: string; n: string }>(
`${suffix} rides ${wagons}× ${code}`,
`SELECT wt.code, count(*) AS n
FROM freight.wagon_booking_allocations wba
JOIN freight.train_set_wagons tsw ON tsw.id = wba.train_set_wagon_id
JOIN freight.wagon_types wt ON wt.id = tsw.wagon_type_id
WHERE wba.booking_id = $1 AND wba.deleted_at IS NULL
GROUP BY wt.code`,
[b.id],
(row) => row?.code === code && Number(row?.n) === wagons,
20,
),
);
// A grouped second row would mean mixed wagon types under one booking.
withBooking(suffix, (b) =>
db<{ k: string }>(
`SELECT count(DISTINCT tsw.wagon_type_id) AS k
FROM freight.wagon_booking_allocations wba
JOIN freight.train_set_wagons tsw ON tsw.id = wba.train_set_wagon_id
WHERE wba.booking_id = $1 AND wba.deleted_at IS NULL`,
[b.id],
).then(({ rows }) =>
expect(Number(rows[0].k), `${suffix} single wagon type`).to.eq(1),
),
);
}
export function pollAllocations(suffix: string, minWagons = 1) {
withBooking(suffix, (b) =>
pollDb<{ n: string }>(
@@ -533,7 +635,7 @@ export function ensureCorridorRoute() {
* scope CTR-IMP-% only — never touches other suites' data). A wiped schedule
* must never leave bookings pointing at it (ghost refs break assign).
*/
export function resetCorridorDay(departure: Date, destCode = DEST) {
export function resetCorridorDay(departure: Date, destCode = DEST, originCode = ORIGIN) {
db(
`WITH stale AS (
SELECT ts.id FROM freight.train_schedules ts
@@ -556,7 +658,7 @@ export function resetCorridorDay(departure: Date, destCode = DEST) {
)
UPDATE freight.train_schedules SET deleted_at = now()
WHERE id IN (SELECT id FROM stale)`,
[ORIGIN, destCode, departure.toISOString()],
[originCode, destCode, departure.toISOString()],
);
// Prior-run pool leftovers (never reserved, so no schedule ref) would
// contaminate this run's batch — a stale high-priority booking steals the
@@ -572,6 +674,48 @@ export function resetCorridorDay(departure: Date, destCode = DEST) {
);
}
/** Create the reversed 6-stop corridor (ET → DJ = EXPORT) if missing. */
export function ensureExportRoute() {
dbRouteId(EXP_ORIGIN, EXP_DEST).then(({ rows }) => {
if (rows.length > 0) return;
const stops = [...CORRIDOR].reverse();
db<{ id: string; code: string }>(
`SELECT id, code FROM freight.yards WHERE code = ANY($1::text[])`,
[stops],
).then(({ rows: yards }) => {
expect(yards, "corridor yards").to.have.length(stops.length);
const byCode = new Map(yards.map((y) => [y.code, y.id]));
apiPost(opsStaff, "/api/routes", {
milestones: stops.map((code) => ({ yardId: byCode.get(code) })),
})
.its("status")
.should("be.oneOf", [200, 201]);
});
db<{ direction: string }>(
`SELECT r.direction FROM freight.routes r
JOIN freight.yards o ON o.id = r.origin_yard_id AND o.code = $1
JOIN freight.yards d ON d.id = r.destination_yard_id AND d.code = $2
WHERE r.deleted_at IS NULL ORDER BY r.created_at DESC LIMIT 1`,
[EXP_ORIGIN, EXP_DEST],
).then(({ rows: created }) => {
expect(created[0]?.direction, "export corridor direction").to.eq("EXPORT");
});
});
}
/**
* Ops accepts an EXPORT operation request — FCFS: the accept itself reserves
* the train slot and opens a pay window clamped to the window close.
*/
export function acceptExport(suffix: string) {
withBooking(suffix, (b) => {
apiPost(opsStaff, `/api/bookings/${b.id}/operation/review`, { decision: "ACCEPT" })
.its("status")
.should("be.oneOf", [200, 201]);
});
pollBookingStatus(suffix, ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"], 10);
}
export interface ScheduleRow {
id: string;
status: string;
@@ -590,7 +734,7 @@ const SCHEDULE_COLS = `ts.id, ts.status, ts.window_phase, ts.booking_window_stat
ts.payment_phase_ends_at, ts.scheduled_departure_date`;
/** The corridor schedule departing within ±1h of `departure` (12:00 EAT pin). */
export function dbSchedule(departure: Date, destCode = DEST) {
export function dbSchedule(departure: Date, destCode = DEST, originCode = ORIGIN) {
return db<ScheduleRow>(
`SELECT ${SCHEDULE_COLS}
FROM freight.train_schedules ts
@@ -599,7 +743,7 @@ export function dbSchedule(departure: Date, destCode = DEST) {
WHERE ts.deleted_at IS NULL
AND abs(extract(epoch FROM (ts.scheduled_departure_date - $3::timestamptz))) < 3600
ORDER BY ts.created_at DESC LIMIT 1`,
[ORIGIN, destCode, departure.toISOString()],
[originCode, destCode, departure.toISOString()],
);
}
@@ -610,6 +754,14 @@ export function withSchedule(departure: Date, fn: (s: ScheduleRow) => void) {
});
}
/** Export-corridor (KALITY → DJIB_PORT) variant of withSchedule. */
export function withExportSchedule(departure: Date, fn: (s: ScheduleRow) => void) {
dbSchedule(departure, EXP_DEST, EXP_ORIGIN).then(({ rows }) => {
expect(rows, `export schedule departing ${departure.toISOString()}`).to.have.length(1);
fn(rows[0]);
});
}
/**
* Ops creates an import schedule on the corridor via the API — loco-pair mode
* (no built train): capacity comes from maxWagonsPerTrain (54, the corridor
@@ -619,17 +771,22 @@ export function createImportSchedule(opts: {
departure: Date;
locoPair: [string, string];
maxWagons?: number;
kind?: "container" | "bulk";
originCode?: string;
destCode?: string;
}) {
dbSchedule(opts.departure).then(({ rows }) => {
const originCode = opts.originCode ?? ORIGIN;
const destCode = opts.destCode ?? DEST;
dbSchedule(opts.departure, destCode, originCode).then(({ rows }) => {
if (rows.length > 0) return;
dbRouteId().then(({ rows: routes }) => {
dbRouteId(originCode, destCode).then(({ rows: routes }) => {
expect(routes, "corridor route").to.have.length(1);
db<{ id: string }>(
`SELECT id FROM freight.locomotives WHERE code = ANY($1::text[]) ORDER BY code`,
[opts.locoPair],
).then(({ rows: locos }) => {
expect(locos, `locomotives ${opts.locoPair.join(",")}`).to.have.length(2);
apiPost(opsStaff, "/api/train-scheduling/container/schedules", {
apiPost(opsStaff, `/api/train-scheduling/${opts.kind ?? "container"}/schedules`, {
routeId: routes[0].id,
scheduleDate: opts.departure.toISOString(),
locomotiveIds: locos.map((l) => l.id),
@@ -640,8 +797,9 @@ export function createImportSchedule(opts: {
});
});
});
withSchedule(opts.departure, (s) => {
expect(s.max_wagons, "54-wagon consist").to.eq(opts.maxWagons ?? 54);
dbSchedule(opts.departure, destCode, originCode).then(({ rows }) => {
expect(rows, "created schedule").to.have.length(1);
expect(rows[0].max_wagons, "54-wagon consist").to.eq(opts.maxWagons ?? 54);
});
}