mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 11:55:42 +00:00
add e2e test
This commit is contained in:
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user