chore: test fixes

This commit is contained in:
Nathnael
2026-07-31 10:33:27 +00:00
parent c176b9e7d6
commit 991881108d
21 changed files with 544 additions and 96 deletions

View File

@@ -61,6 +61,71 @@ services:
- mc alias set e2e http://minio-e2e:9000 e2e-minio e2e-minio-secret && mc mb --ignore-existing e2e/fhc - mc alias set e2e http://minio-e2e:9000 e2e-minio e2e-minio-secret && mc mb --ignore-existing e2e/fhc
restart: "no" restart: "no"
# Stand-in for eSignet's token/userinfo endpoints (see fayda-mock/server.js).
# The real Fayda authorization step (phone + SMS OTP) can't run in e2e —
# Cypress bypasses the popup and drives POST start / POST complete directly,
# so this only needs to answer the token exchange + userinfo calls that
# `completeVerification` makes server-side.
fayda-mock-e2e:
image: node:20-alpine
volumes:
- ./e2e/freight/fayda-mock:/app:ro
working_dir: /app
command: ["node", "server.js"]
healthcheck:
test:
[
"CMD",
"node",
"-e",
"fetch('http://localhost:4400/userinfo').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))",
]
interval: 3s
timeout: 3s
retries: 10
# Stand-in for https://etrade.gov.et — ETradeService's base URL is
# hardcoded (not env-configurable like Fayda's endpoints), so this is
# reached by DNS alias instead: the "etrade.gov.et" network alias below
# makes freight-api-e2e's real hardcoded requests land here. See
# etrade-mock/server.js.
etrade-mock-e2e:
# Debian-based (not -alpine): ships openssl in the base image, so the
# self-signed cert below needs no package install at container start
# (alpine's apk would have to fetch it from the network every boot).
image: node:20
volumes:
- ./e2e/freight/etrade-mock:/app:ro
working_dir: /app
environment:
TLS_CERT_PATH: /tmp/etrade-mock/cert.pem
TLS_KEY_PATH: /tmp/etrade-mock/key.pem
# Throwaway self-signed cert generated fresh into /tmp on every
# container start — nothing shaped like a key/cert is committed (the
# code mount above is :ro, so this couldn't write there anyway).
command:
- sh
- -c
- >
mkdir -p /tmp/etrade-mock &&
openssl req -x509 -newkey rsa:2048 -keyout "$$TLS_KEY_PATH" -out "$$TLS_CERT_PATH" -days 1 -nodes -subj "/CN=etrade.gov.et" 2>/dev/null &&
node server.js
networks:
default:
aliases:
- etrade.gov.et
healthcheck:
test:
[
"CMD",
"node",
"-e",
"require('https').get({hostname:'localhost',port:443,path:'/api/BusinessMain/GetBusinessByLicenseNo?LicenseNo=x&Tin=x&Lang=en',rejectUnauthorized:false},r=>process.exit(r.statusCode===200?0:1)).on('error',()=>process.exit(1))",
]
interval: 3s
timeout: 3s
retries: 10
freight-api-e2e: freight-api-e2e:
build: build:
context: . context: .
@@ -74,6 +139,10 @@ services:
condition: service_healthy condition: service_healthy
minio-init-e2e: minio-init-e2e:
condition: service_completed_successfully condition: service_completed_successfully
fayda-mock-e2e:
condition: service_healthy
etrade-mock-e2e:
condition: service_healthy
environment: environment:
PORT: "3001" PORT: "3001"
DB_HOST: postgres-freight-e2e DB_HOST: postgres-freight-e2e
@@ -104,7 +173,21 @@ services:
MINIO_REGION: us-east-1 MINIO_REGION: us-east-1
# External integrations off # External integrations off
RABBITMQ_ENABLED: "false" RABBITMQ_ENABLED: "false"
FAYDA_ENABLED: "false" # Fayda ON, pointed at the local mock (fayda-mock-e2e) instead of the
# real eSignet infra — see that service's comment above. Cypress drives
# verification via the API (start + complete), never the real popup.
FAYDA_ENABLED: "true"
FAYDA_CLIENT_ID: e2e-fayda-client
FAYDA_AUTHORIZATION_ENDPOINT: http://fayda-mock-e2e:4400/authorize
FAYDA_TOKEN_ENDPOINT: http://fayda-mock-e2e:4400/token
FAYDA_USERINFO_ENDPOINT: http://fayda-mock-e2e:4400/userinfo
FAYDA_REDIRECT_URI: http://localhost:${E2E_PORTAL_PORT:-5373}/callback
FAYDA_PORTAL_REDIRECT_URI: http://localhost:${E2E_PORTAL_PORT:-5373}/callback
# Throwaway e2e-only RSA JWK (client_assertion signing) — the mock
# never verifies the signature, this just has to be well-formed.
# Generated fresh per launch by e2e.mjs (fakeFaydaPrivateKeyBase64),
# not committed here, so nothing shaped like a private key sits in git.
FAYDA_PRIVATE_KEY_BASE64: ${FAYDA_PRIVATE_KEY_BASE64}
# SMS strategy has no kill switch and defaults to a real dev endpoint — # SMS strategy has no kill switch and defaults to a real dev endpoint —
# blackhole it so e2e never sends SMS (failures are logged, non-fatal). # blackhole it so e2e never sends SMS (failures are logged, non-fatal).
OZIKING_SMS_URL: http://127.0.0.1:9/sms OZIKING_SMS_URL: http://127.0.0.1:9/sms

View File

@@ -20,6 +20,7 @@ import {
acceptExport, acceptExport,
apiPost, apiPost,
bookBulk, bookBulk,
clearToOperationRequestPending,
createImportSchedule, createImportSchedule,
db, db,
departureAt, departureAt,
@@ -83,8 +84,9 @@ describe("booking cancel: pre-commit only, blocked once allocated", { retries: 0
it("an un-accepted booking cancels, its invoice is expired, and re-cancel is rejected", () => { it("an un-accepted booking cancels, its invoice is expired, and re-cancel is rejected", () => {
bookBulk({ suffix: "CXA", tons: 700, scheduledDate: BOOKING_DAY }); bookBulk({ suffix: "CXA", tons: 700, scheduledDate: BOOKING_DAY });
clearToOperationRequestPending("CXA", BOOKING_DAY);
// Fresh non-customs export booking sits at OPERATION_REQUEST_PENDING. // Cleared non-customs export booking sits at OPERATION_REQUEST_PENDING.
withBooking("CXA", (b) => { withBooking("CXA", (b) => {
expect(b.status, "pre-accept status").to.eq("OPERATION_REQUEST_PENDING"); expect(b.status, "pre-accept status").to.eq("OPERATION_REQUEST_PENDING");
cancel(b.id).then((res) => expect(res.status, "cancelled").to.be.oneOf([200, 201])); cancel(b.id).then((res) => expect(res.status, "cancelled").to.be.oneOf([200, 201]));
@@ -112,6 +114,7 @@ describe("booking cancel: pre-commit only, blocked once allocated", { retries: 0
it("a PAID + allocated booking cannot be cancelled — the commit gate blocks it", () => { it("a PAID + allocated booking cannot be cancelled — the commit gate blocks it", () => {
bookBulk({ suffix: "CXB", tons: 700, scheduledDate: BOOKING_DAY }); bookBulk({ suffix: "CXB", tons: 700, scheduledDate: BOOKING_DAY });
clearToOperationRequestPending("CXB", BOOKING_DAY);
acceptExport("CXB"); acceptExport("CXB");
markPaid("CXB"); markPaid("CXB");
pollAllocations("CXB", 10); // 700T / 70T = 10 CW4 wagons pollAllocations("CXB", 10); // 700T / 70T = 10 CW4 wagons

View File

@@ -19,6 +19,8 @@ import {
acceptOperation, acceptOperation,
apiPost, apiPost,
bookBulk, bookBulk,
clearIntercityToFullyExecuted,
clearToOperationRequestPending,
closeBookingWindow, closeBookingWindow,
completeDocReview, completeDocReview,
createImportSchedule, createImportSchedule,
@@ -98,8 +100,10 @@ describe("bulk critical matrix: gates, sub-corridor, bulk intercity, whole-train
it("a through-corridor 1 400 T booking and a NAGAD→MOJO 700 T booking share the train", () => { it("a through-corridor 1 400 T booking and a NAGAD→MOJO 700 T booking share the train", () => {
bookBulk({ suffix: "BM1", tons: 1400, scheduledDate: BOOKING_DAY }); bookBulk({ suffix: "BM1", tons: 1400, scheduledDate: BOOKING_DAY });
clearToOperationRequestPending("BM1", BOOKING_DAY);
acceptOperation("BM1"); acceptOperation("BM1");
bookBulk({ suffix: "BMSUB", tons: 700, scheduledDate: BOOKING_DAY }); bookBulk({ suffix: "BMSUB", tons: 700, scheduledDate: BOOKING_DAY });
clearToOperationRequestPending("BMSUB", BOOKING_DAY);
acceptOperation("BMSUB"); acceptOperation("BMSUB");
withSchedule(DEPARTURE, (s) => { withSchedule(DEPARTURE, (s) => {
@@ -122,7 +126,9 @@ describe("bulk critical matrix: gates, sub-corridor, bulk intercity, whole-train
it("bulk intercity ride-along: dateless DOMESTIC wheat accepted onto the import train's free leg", () => { it("bulk intercity ride-along: dateless DOMESTIC wheat accepted onto the import train's free leg", () => {
bookBulk({ suffix: "BMIC", tons: 140 }); // 2 wagons MOJO → KALITY, dateless bookBulk({ suffix: "BMIC", tons: 140 }); // 2 wagons MOJO → KALITY, dateless
acceptOperation("BMIC"); // DOMESTIC bookings skip the shipment-day step entirely — finalize alone
// lands FULLY_EXECUTED; staff assign it to a passing train below.
clearIntercityToFullyExecuted("BMIC");
withSchedule(DEPARTURE, (s) => { withSchedule(DEPARTURE, (s) => {
withBooking("BMIC", (b) => { withBooking("BMIC", (b) => {
@@ -159,6 +165,7 @@ describe("bulk critical matrix: gates, sub-corridor, bulk intercity, whole-train
withSchedule(GIANT_DEPARTURE, (s) => forceWindowOpen(s.id, 45)); withSchedule(GIANT_DEPARTURE, (s) => forceWindowOpen(s.id, 45));
bookBulk({ suffix: "BMG", tons: 4000, scheduledDate: GIANT_DAY }); bookBulk({ suffix: "BMG", tons: 4000, scheduledDate: GIANT_DAY });
clearToOperationRequestPending("BMG", GIANT_DAY);
acceptOperation("BMG"); acceptOperation("BMG");
withSchedule(GIANT_DEPARTURE, (s) => { withSchedule(GIANT_DEPARTURE, (s) => {
closeBookingWindow(s.id); closeBookingWindow(s.id);
@@ -208,7 +215,7 @@ describe("bulk critical matrix: gates, sub-corridor, bulk intercity, whole-train
expectFailure: "must take the whole", expectFailure: "must take the whole",
}); });
bookBulk({ suffix: "BMG", tons: 220, scheduledDate: REMAINDER_DAY }); bookBulk({ suffix: "BMG", tons: 220, scheduledDate: REMAINDER_DAY });
pollBookingStatus("BMG", "OPERATION_REQUEST_PENDING", 5); clearToOperationRequestPending("BMG", REMAINDER_DAY);
}); });
}); });

View File

@@ -19,6 +19,7 @@ import {
acceptExport, acceptExport,
apiPost, apiPost,
bookBulk, bookBulk,
clearToOperationRequestPending,
completeBookingMilestone, completeBookingMilestone,
createImportSchedule, createImportSchedule,
db, db,
@@ -103,7 +104,7 @@ describe("bulk export: six wheat bookings fill the 54-wagon CW4 train (FCFS)", {
it("six exporters book wheat inside the one window", () => { it("six exporters book wheat inside the one window", () => {
BOOKINGS.forEach((b) => { BOOKINGS.forEach((b) => {
bookBulk({ suffix: b.suffix, tons: b.tons, scheduledDate: BOOKING_DAY }); bookBulk({ suffix: b.suffix, tons: b.tons, scheduledDate: BOOKING_DAY });
pollBookingStatus(b.suffix, "OPERATION_REQUEST_PENDING", 5); clearToOperationRequestPending(b.suffix, BOOKING_DAY);
}); });
}); });

View File

@@ -20,6 +20,7 @@ import {
acceptOperation, acceptOperation,
apiPost, apiPost,
bookBulk, bookBulk,
clearToOperationRequestPending,
closeBookingWindow, closeBookingWindow,
completeBookingMilestone, completeBookingMilestone,
completeDocReview, completeDocReview,
@@ -103,7 +104,7 @@ describe("bulk import: six wheat bookings fill the 54-wagon CW4 train", { retrie
it("customer books all six wheat shipments inside the first window", () => { it("customer books all six wheat shipments inside the first window", () => {
BOOKINGS.forEach((b) => { BOOKINGS.forEach((b) => {
bookBulk({ suffix: b.suffix, tons: b.tons, scheduledDate: BOOKING_DAY }); bookBulk({ suffix: b.suffix, tons: b.tons, scheduledDate: BOOKING_DAY });
pollBookingStatus(b.suffix, "OPERATION_REQUEST_PENDING", 5); clearToOperationRequestPending(b.suffix, BOOKING_DAY);
}); });
}); });

View File

@@ -19,6 +19,7 @@
import { import {
acceptOperation, acceptOperation,
bookBulk, bookBulk,
clearToOperationRequestPending,
closeBookingWindow, closeBookingWindow,
completeDocReview, completeDocReview,
createImportSchedule, createImportSchedule,
@@ -84,6 +85,7 @@ describe("bulk import: split offer, remainder rebooking, expiry + promotion", {
it("seven customers book wheat in the first window; operations accepts them in priority order", () => { it("seven customers book wheat in the first window; operations accepts them in priority order", () => {
ORDER.forEach((suffix) => { ORDER.forEach((suffix) => {
bookBulk({ suffix, tons: TONS[suffix], scheduledDate: BOOKING_DAY }); bookBulk({ suffix, tons: TONS[suffix], scheduledDate: BOOKING_DAY });
clearToOperationRequestPending(suffix, BOOKING_DAY);
acceptOperation(suffix); acceptOperation(suffix);
}); });
ORDER.forEach((suffix, i) => setPriority(suffix, i + 1)); ORDER.forEach((suffix, i) => setPriority(suffix, i + 1));
@@ -192,7 +194,7 @@ describe("bulk import: split offer, remainder rebooking, expiry + promotion", {
}); });
bookBulk({ suffix: "BSC", tons: 1120, scheduledDate: REMAINDER_DAY }); bookBulk({ suffix: "BSC", tons: 1120, scheduledDate: REMAINDER_DAY });
pollBookingStatus("BSC", "OPERATION_REQUEST_PENDING", 5); clearToOperationRequestPending("BSC", REMAINDER_DAY);
}); });
}); });

View File

@@ -73,7 +73,8 @@ describe("contract lifecycle: creation to finalization", { retries: 0 }, () => {
cy.mantineSelect(/^Contract Kind/, "General Contract"); cy.mantineSelect(/^Contract Kind/, "General Contract");
cy.mantineSelect(/^New or Renewal/, "New Contract"); cy.mantineSelect(/^New or Renewal/, "New Contract");
cy.contains("Rail Transport Only", { timeout: 15000 }).click(); cy.contains("Rail Transport Only", { timeout: 15000 }).click();
cy.mantineSelect(/^Payment Currency/, /^ETB/); // Contracts are always quoted in USD now — the billing currency is picked
// per shipment at booking time, not here.
cy.contains("button", "Continue").click({ force: true }); cy.contains("button", "Continue").click({ force: true });
// Step 1 — Cargo & Route. Container contracts now auto-cover BOTH 20ft & // Step 1 — Cargo & Route. Container contracts now auto-cover BOTH 20ft &

View File

@@ -23,6 +23,7 @@ import {
acceptExport, acceptExport,
apiPost, apiPost,
bookContainers, bookContainers,
clearToOperationRequestPending,
createImportSchedule, createImportSchedule,
db, db,
dbBooking, dbBooking,
@@ -118,7 +119,7 @@ describe("export: six bookings fill the 54-wagon corridor train (FCFS)", { retri
scheduledDate: BOOKING_DAY, scheduledDate: BOOKING_DAY,
}); });
isoSeed += b.twenty + b.forty; isoSeed += b.twenty + b.forty;
pollBookingStatus(b.suffix, "OPERATION_REQUEST_PENDING", 5); clearToOperationRequestPending(b.suffix, BOOKING_DAY);
}); });
}); });

View File

@@ -21,6 +21,8 @@
* Sequential steps of one journey — retries off (steps are not idempotent). * Sequential steps of one journey — retries off (steps are not idempotent).
*/ */
import { clearBookingClearance } from "./import-utils";
const customer = "user@gmail.com"; const customer = "user@gmail.com";
const companyTin = "0102030405"; // seed-company.sql const companyTin = "0102030405"; // seed-company.sql
const opsStaff = "operation@edr.local"; const opsStaff = "operation@edr.local";
@@ -477,7 +479,8 @@ describe("export one-time journeys: container + bulk on one train", { retries: 0
cy.mantineSelect(/^Contract Kind/, "One-Time Contract"); cy.mantineSelect(/^Contract Kind/, "One-Time Contract");
cy.mantineSelect(/^New or Renewal/, "New Contract"); cy.mantineSelect(/^New or Renewal/, "New Contract");
cy.contains("button", "Rail Transport Only", { timeout: 15000 }).click({ force: true }); cy.contains("button", "Rail Transport Only", { timeout: 15000 }).click({ force: true });
cy.mantineSelect(/^Payment Currency/, /^ETB/); // Contracts are always quoted in USD now — the billing currency is picked
// per shipment at booking time, not here.
cy.contains("button", "Continue").click({ force: true }); cy.contains("button", "Continue").click({ force: true });
// Container contracts auto-cover both 20ft & 40ft (no size picker) and the // Container contracts auto-cover both 20ft & 40ft (no size picker) and the
@@ -537,6 +540,12 @@ describe("export one-time journeys: container + bulk on one train", { retries: 0
cy.contains("button", "Confirm & book").click(); cy.contains("button", "Confirm & book").click();
cy.location("pathname", { timeout: 30000 }).should("match", /^\/bookings\/.+/); cy.location("pathname", { timeout: 30000 }).should("match", /^\/bookings\/.+/);
withBooking("CONTAINER", (b) => {
expect(b.status, "born in the clearance gate").to.eq("AWAITING_DOCUMENTS");
expect(b.scheduled_date, "export bookings carry a shipment day").to.be.a("string");
clearBookingClearance(b.id, b.scheduled_date!);
});
withBooking("CONTAINER", (b) => { withBooking("CONTAINER", (b) => {
expect(b.status).to.eq("OPERATION_REQUEST_PENDING"); expect(b.status).to.eq("OPERATION_REQUEST_PENDING");
expect(b.scheduled_date, "export bookings carry a shipment day").to.be.a("string"); expect(b.scheduled_date, "export bookings carry a shipment day").to.be.a("string");
@@ -561,7 +570,8 @@ describe("export one-time journeys: container + bulk on one train", { retries: 0
cy.mantineSelect(/^Contract Kind/, "One-Time Contract"); cy.mantineSelect(/^Contract Kind/, "One-Time Contract");
cy.mantineSelect(/^New or Renewal/, "New Contract"); cy.mantineSelect(/^New or Renewal/, "New Contract");
cy.contains("button", "Rail Transport Only", { timeout: 15000 }).click({ force: true }); cy.contains("button", "Rail Transport Only", { timeout: 15000 }).click({ force: true });
cy.mantineSelect(/^Payment Currency/, /^ETB/); // Contracts are always quoted in USD now — the billing currency is picked
// per shipment at booking time, not here.
cy.contains("button", "Continue").click({ force: true }); cy.contains("button", "Continue").click({ force: true });
cy.mantineSelect(/^Cargo Scope/, /General \/ Bulk cargo/); cy.mantineSelect(/^Cargo Scope/, /General \/ Bulk cargo/);
@@ -605,6 +615,11 @@ describe("export one-time journeys: container + bulk on one train", { retries: 0
cy.contains("button", "Confirm & book").click(); cy.contains("button", "Confirm & book").click();
cy.location("pathname", { timeout: 30000 }).should("match", /^\/bookings\/.+/); cy.location("pathname", { timeout: 30000 }).should("match", /^\/bookings\/.+/);
withBooking("BULK", (b) => {
expect(b.status, "born in the clearance gate").to.eq("AWAITING_DOCUMENTS");
clearBookingClearance(b.id, b.scheduled_date!);
});
withBooking("BULK", (b) => { withBooking("BULK", (b) => {
expect(b.status).to.eq("OPERATION_REQUEST_PENDING"); expect(b.status).to.eq("OPERATION_REQUEST_PENDING");
}); });

View File

@@ -69,6 +69,44 @@ export function apiPost(
); );
} }
/**
* Drive Fayda identity verification via the API, bypassing the real
* popup+SMS flow entirely — `fayda-mock-e2e` stands in for eSignet's
* token/userinfo endpoints (see docker-compose.e2e.yaml), so a throwaway
* code is enough: start a session, then complete it — mirrors what
* FaydaVerifyPanel's popup + postMessage dance does server-side, minus the
* UI. Uses the browser's OWN auth-token cookie rather than `tokenFor` —
* this only ever runs for a user already logged in in the running test
* (the real flow requires being on an authenticated page to see the
* "Verify with Fayda" button at all), and `tokenFor` only knows how to log
* in fixed seeded accounts, not a signup created earlier in the same test
* with its own freshly-chosen password.
*/
export function completeFaydaVerification(subject: "owner" | "poa") {
return cy.getCookie("auth-token").then((cookie) => {
expect(cookie, "authenticated session (auth-token cookie)").to.not.be.null;
const headers = { Authorization: `Bearer ${cookie!.value}` };
return cy
.request({
method: "POST",
url: `${apiUrl()}/api/fayda/verification/start`,
headers,
body: { purpose: "VERIFY", platform: "PORTAL" },
})
.then((res) => {
const authorizationUrl: string = res.body.data.authorizationUrl;
const state = new URL(authorizationUrl).searchParams.get("state");
expect(state, "fayda session state").to.be.a("string");
return cy.request({
method: "POST",
url: `${apiUrl()}/api/companies/identity/fayda/complete`,
headers,
body: { subject, code: "e2e-mock-code", state },
});
});
});
}
export function apiGet(email: string, path: string, failOnStatusCode = true) { export function apiGet(email: string, path: string, failOnStatusCode = true) {
return tokenFor(email).then((token) => return tokenFor(email).then((token) =>
cy.request({ cy.request({
@@ -457,13 +495,86 @@ export function bookBulk(opts: {
}); });
} }
/**
* Upload one ad-hoc doc → GL approves it → finalize → customer proceeds with
* the shipment day. The e2e seed configures no required documents, so one
* doc satisfies the 100%-approved gate. Takes a bookingId directly so specs
* with their own booking lookup (not the *-suffix convention) can reuse it.
*
* KNOWN GAP — customs (`customsClearingEnabled`) bookings do NOT use this
* path: `finalizeClearance` rejects them outright ("General customs bookings
* use phased clearance — complete milestones via the phased actions instead
* of finalize"). Their real flow is a separate multi-step phased chain
* (transit permit upload → finalizePreClearance → delivery order upload for
* IMPORT; a shorter EXPORT_RELEASED milestone for EXPORT) that no spec here
* drives yet — see booking-clearance.service.ts / clearance-workflow.service.ts.
* Calling this on a customs booking 400s at the finalize step; callers with
* customs suffixes in the mix (the full_train specs' BF-series customs:
* true bookings) currently accept that those specific bookings — and only
* those — fail here until the phased-clearance walk is written.
*/
export function clearBookingClearance(bookingId: string, scheduledDate: string) {
glUpload(`/api/bookings/${bookingId}/clearance/documents`, {}, "custom_e2e");
apiPost(superAdmin, `/api/bookings/${bookingId}/clearance/review`, {
fileKey: "custom_e2e",
status: "APPROVED",
})
.its("status")
.should("be.oneOf", [200, 201]);
apiPost(superAdmin, `/api/bookings/${bookingId}/clearance/finalize`)
.its("status")
.should("be.oneOf", [200, 201]);
apiPost(customer, `/api/bookings/${bookingId}/clearance/proceed`, { scheduledDate })
.its("status")
.should("be.oneOf", [200, 201]);
}
/**
* Every contract booking is now born in the clearance gate — walk a *-suffix
* booking from AWAITING_DOCUMENTS to OPERATION_REQUEST_PENDING via
* {@link clearBookingClearance}.
*/
export function clearToOperationRequestPending(suffix: string, scheduledDate: string) {
withBooking(suffix, (b) => {
expect(b.status, `${suffix} starts in the clearance gate`).to.eq("AWAITING_DOCUMENTS");
clearBookingClearance(b.id, scheduledDate);
});
pollBookingStatus(suffix, "OPERATION_REQUEST_PENDING", 5);
}
/**
* DOMESTIC (intercity) bookings never pick a shipment day — there is no
* `clearance/proceed` step for them. `finalizeClearance` sends an approved
* intercity booking straight to FULLY_EXECUTED (the ride-along pool); staff
* assign it to a passing train separately. Takes a bookingId directly, like
* {@link clearBookingClearance}.
*/
export function clearIntercityBookingClearance(bookingId: string) {
glUpload(`/api/bookings/${bookingId}/clearance/documents`, {}, "custom_e2e");
apiPost(superAdmin, `/api/bookings/${bookingId}/clearance/review`, {
fileKey: "custom_e2e",
status: "APPROVED",
})
.its("status")
.should("be.oneOf", [200, 201]);
apiPost(superAdmin, `/api/bookings/${bookingId}/clearance/finalize`)
.its("status")
.should("be.oneOf", [200, 201]);
}
/** Walk a *-suffix DOMESTIC booking from AWAITING_DOCUMENTS to FULLY_EXECUTED. */
export function clearIntercityToFullyExecuted(suffix: string) {
withBooking(suffix, (b) => {
expect(b.status, `${suffix} starts in the clearance gate`).to.eq("AWAITING_DOCUMENTS");
clearIntercityBookingClearance(b.id);
});
pollBookingStatus(suffix, "FULLY_EXECUTED", 10);
}
/** /**
* Walk a GENERAL booking through its PER-BOOKING clearance chain (Path A): * Walk a GENERAL booking through its PER-BOOKING clearance chain (Path A):
* AWAITING_DOCUMENTS → upload one doc → GL approves it → finalize → * AWAITING_DOCUMENTS → ... → OPERATION_REQUEST_PENDING (clearToOperationRequestPending)
* CLEARANCE_READY → customer proceeds with the shipment day → * → ops accept → FULLY_EXECUTED (pool).
* OPERATION_REQUEST_PENDING → ops accept → FULLY_EXECUTED (pool). The e2e
* seed configures no required documents, so one ad-hoc doc satisfies the
* 100%-approved gate.
*/ */
export function clearGeneralBooking( export function clearGeneralBooking(
suffix: string, suffix: string,
@@ -471,22 +582,7 @@ export function clearGeneralBooking(
/** EXPORT ends at acceptExport (FCFS reserves on accept), not the pool. */ /** EXPORT ends at acceptExport (FCFS reserves on accept), not the pool. */
mode: "import" | "export" = "import", mode: "import" | "export" = "import",
) { ) {
withBooking(suffix, (b) => { clearToOperationRequestPending(suffix, scheduledDate);
expect(b.status, `${suffix} starts in the clearance gate`).to.eq("AWAITING_DOCUMENTS");
glUpload(`/api/bookings/${b.id}/clearance/documents`, {}, "custom_e2e");
apiPost(superAdmin, `/api/bookings/${b.id}/clearance/review`, {
fileKey: "custom_e2e",
status: "APPROVED",
})
.its("status")
.should("be.oneOf", [200, 201]);
apiPost(superAdmin, `/api/bookings/${b.id}/clearance/finalize`)
.its("status")
.should("be.oneOf", [200, 201]);
apiPost(customer, `/api/bookings/${b.id}/clearance/proceed`, { scheduledDate })
.its("status")
.should("be.oneOf", [200, 201]);
});
if (mode === "export") acceptExport(suffix); if (mode === "export") acceptExport(suffix);
else acceptOperation(suffix); else acceptOperation(suffix);
} }

View File

@@ -21,6 +21,7 @@ import {
acceptOperation, acceptOperation,
apiPost, apiPost,
bookContainers, bookContainers,
clearToOperationRequestPending,
closeBookingWindow, closeBookingWindow,
completeBookingMilestone, completeBookingMilestone,
completeDocReview, completeDocReview,
@@ -113,7 +114,7 @@ describe("import: six bookings fill the 54-wagon corridor train", { retries: 0 }
scheduledDate: BOOKING_DAY, scheduledDate: BOOKING_DAY,
}); });
isoSeed += b.twenty + b.forty; isoSeed += b.twenty + b.forty;
pollBookingStatus(b.suffix, "OPERATION_REQUEST_PENDING", 5); clearToOperationRequestPending(b.suffix, BOOKING_DAY);
}); });
}); });

View File

@@ -22,6 +22,7 @@ import {
resetCorridorDay, resetCorridorDay,
acceptOperation, acceptOperation,
bookContainers, bookContainers,
clearToOperationRequestPending,
closeBookingWindow, closeBookingWindow,
completeDocReview, completeDocReview,
createImportSchedule, createImportSchedule,
@@ -91,6 +92,7 @@ describe("import: split offer, remainder rebooking, expiry + promotion", { retri
scheduledDate: BOOKING_DAY, scheduledDate: BOOKING_DAY,
}); });
isoSeed += s.twenty + s.forty; isoSeed += s.twenty + s.forty;
clearToOperationRequestPending(suffix, BOOKING_DAY);
acceptOperation(suffix); acceptOperation(suffix);
}); });
ORDER.forEach((suffix, i) => setPriority(suffix, i + 1)); ORDER.forEach((suffix, i) => setPriority(suffix, i + 1));
@@ -217,7 +219,7 @@ describe("import: split offer, remainder rebooking, expiry + promotion", { retri
twenty: 32, twenty: 32,
scheduledDate: REMAINDER_DAY, scheduledDate: REMAINDER_DAY,
}); });
pollBookingStatus("SC", "OPERATION_REQUEST_PENDING", 5); clearToOperationRequestPending("SC", REMAINDER_DAY);
}); });
}); });

View File

@@ -18,9 +18,11 @@
* 7. backoffice — operations approves the document, then finalizes document * 7. backoffice — operations approves the document, then finalizes document
* approval → FULLY_EXECUTED * approval → FULLY_EXECUTED
* 8. portal — customer books 2 × 20ft under the contract (intercity has * 8. portal — customer books 2 × 20ft under the contract (intercity has
* no shipment date) → booking OPERATION_REQUEST_PENDING * no shipment date) → booking AWAITING_DOCUMENTS → customer
* 9. backoffice — operations accepts the operation request → booking * uploads a doc, GL approves, finalize → DOMESTIC bookings
* FULLY_EXECUTED (intercity waiting pool) * skip the shipment-day step entirely, so finalize alone
* lands FULLY_EXECUTED (the intercity waiting pool) — there
* is no separate operations "accept the request" step.
* 10. backoffice — operations creates the EXPORT route * 10. backoffice — operations creates the EXPORT route
* Mojo → Dire Dawa → Djibouti Port (distances seeded) * Mojo → Dire Dawa → Djibouti Port (distances seeded)
* 11. backoffice — operations schedules the export train (built Train-Builder * 11. backoffice — operations schedules the export train (built Train-Builder
@@ -34,6 +36,8 @@
* Sequential steps of one journey — retries off (steps are not idempotent). * Sequential steps of one journey — retries off (steps are not idempotent).
*/ */
import { clearIntercityBookingClearance } from "./import-utils";
const customer = "user@gmail.com"; const customer = "user@gmail.com";
const companyTin = "0102030405"; // seed-company.sql const companyTin = "0102030405"; // seed-company.sql
const opsStaff = "operation@edr.local"; const opsStaff = "operation@edr.local";
@@ -153,12 +157,13 @@ function createIntercityContract() {
cy.loginPortal(customer); cy.loginPortal(customer);
cy.visitPortal("/contracts/new"); cy.visitPortal("/contracts/new");
// Step 0 — Setup. Intercity forces ETB and hides the customs section. // Step 0 — Setup. Intercity hides the customs section; the billing currency
// step moved to booking time (intercity always bills ETB there, per app
// comment in step2-service-type.tsx), so it is not selected here.
cy.mantineSelect(/^Operation Type/, /^Intercity$/); cy.mantineSelect(/^Operation Type/, /^Intercity$/);
cy.mantineSelect(/^Contract Kind/, "One-Time Contract"); cy.mantineSelect(/^Contract Kind/, "One-Time Contract");
cy.mantineSelect(/^New or Renewal/, "New Contract"); cy.mantineSelect(/^New or Renewal/, "New Contract");
cy.contains("button", "Rail Transport Only", { timeout: 15000 }).click(); cy.contains("button", "Rail Transport Only", { timeout: 15000 }).click();
cy.mantineSelect(/^Payment Currency/, /^ETB/);
cy.contains("button", "Continue").click({ force: true }); cy.contains("button", "Continue").click({ force: true });
// Step 1 — Cargo & Route (Ethiopian yards only for intercity). Container // Step 1 — Cargo & Route (Ethiopian yards only for intercity). Container
@@ -422,21 +427,15 @@ describe("intercity one-time journey: contract → booking → export train", {
cy.location("pathname", { timeout: 30000 }).should("match", /^\/bookings\/.+/); cy.location("pathname", { timeout: 30000 }).should("match", /^\/bookings\/.+/);
withBooking((b) => { withBooking((b) => {
expect(b.status).to.eq("OPERATION_REQUEST_PENDING"); expect(b.status, "born in the clearance gate").to.eq("AWAITING_DOCUMENTS");
expect(b.scheduled_date, "intercity bookings carry no scheduled date").to.eq(null); clearIntercityBookingClearance(b.id);
}); });
});
it("operations accepts the operation request — booking joins the intercity pool", () => {
cy.loginBackoffice(opsStaff);
withBooking((b) => cy.visit(`/dashboard/booking-requests/${b.id}`));
cy.contains("button", /Accept operation|^Accept$/, { timeout: 20000 }).click();
cy.contains("Accept operation request?", { timeout: 15000 }).should("be.visible");
cy.get(".mantine-Modal-content").contains("button", /^Accept$/).click();
withBooking((b) => { withBooking((b) => {
expect(b.status, "accepted intercity booking waits in the pool").to.eq("FULLY_EXECUTED"); expect(b.status, "DOMESTIC finalize skips straight to the pool").to.eq(
"FULLY_EXECUTED",
);
expect(b.scheduled_date, "intercity bookings carry no scheduled date").to.eq(null);
expect(b.train_schedule_id).to.eq(null); expect(b.train_schedule_id).to.eq(null);
}); });
}); });

View File

@@ -21,6 +21,7 @@ import {
apiPost, apiPost,
bookBulk, bookBulk,
bookContainers, bookContainers,
clearToOperationRequestPending,
completeBookingMilestone, completeBookingMilestone,
createImportSchedule, createImportSchedule,
db, db,
@@ -120,7 +121,7 @@ describe("mixed export: containers and wheat share one 54-wagon FCFS train", { r
} else { } else {
bookBulk({ suffix: b.suffix, tons: b.tons!, scheduledDate: BOOKING_DAY }); bookBulk({ suffix: b.suffix, tons: b.tons!, scheduledDate: BOOKING_DAY });
} }
pollBookingStatus(b.suffix, "OPERATION_REQUEST_PENDING", 5); clearToOperationRequestPending(b.suffix, BOOKING_DAY);
}); });
}); });

View File

@@ -21,6 +21,7 @@ import {
apiPost, apiPost,
bookBulk, bookBulk,
bookContainers, bookContainers,
clearToOperationRequestPending,
closeBookingWindow, closeBookingWindow,
completeBookingMilestone, completeBookingMilestone,
completeDocReview, completeDocReview,
@@ -113,7 +114,7 @@ describe("mixed import: containers and wheat share one 54-wagon train", { retrie
} else { } else {
bookBulk({ suffix: b.suffix, tons: b.tons!, scheduledDate: BOOKING_DAY }); bookBulk({ suffix: b.suffix, tons: b.tons!, scheduledDate: BOOKING_DAY });
} }
pollBookingStatus(b.suffix, "OPERATION_REQUEST_PENDING", 5); clearToOperationRequestPending(b.suffix, BOOKING_DAY);
}); });
}); });

View File

@@ -23,6 +23,7 @@ import {
acceptOperation, acceptOperation,
bookBulk, bookBulk,
bookContainers, bookContainers,
clearToOperationRequestPending,
closeBookingWindow, closeBookingWindow,
completeDocReview, completeDocReview,
createImportSchedule, createImportSchedule,
@@ -96,10 +97,12 @@ describe("mixed import: container split; container-freed wagons promote the bulk
scheduledDate: BOOKING_DAY, scheduledDate: BOOKING_DAY,
}); });
isoSeed += b.twenty; isoSeed += b.twenty;
clearToOperationRequestPending(b.suffix, BOOKING_DAY);
acceptOperation(b.suffix); acceptOperation(b.suffix);
}); });
BULKS.forEach((b) => { BULKS.forEach((b) => {
bookBulk({ suffix: b.suffix, tons: b.tons, scheduledDate: BOOKING_DAY }); bookBulk({ suffix: b.suffix, tons: b.tons, scheduledDate: BOOKING_DAY });
clearToOperationRequestPending(b.suffix, BOOKING_DAY);
acceptOperation(b.suffix); acceptOperation(b.suffix);
}); });
CONTAINERS.forEach((b, i) => setPriority(b.suffix, i + 1)); CONTAINERS.forEach((b, i) => setPriority(b.suffix, i + 1));
@@ -209,7 +212,7 @@ describe("mixed import: container split; container-freed wagons promote the bulk
twenty: 20, twenty: 20,
scheduledDate: REMAINDER_DAY, scheduledDate: REMAINDER_DAY,
}); });
pollBookingStatus("MSX", "OPERATION_REQUEST_PENDING", 5); clearToOperationRequestPending("MSX", REMAINDER_DAY);
}); });
}); });

View File

@@ -18,15 +18,15 @@
* journey's user/company from the DB instead of module variables. * journey's user/company from the DB instead of module variables.
*/ */
import { completeFaydaVerification } from "./import-utils";
const stamp = Date.now(); const stamp = Date.now();
const email = `e2e.onboard.${stamp}@example.com`; const email = `e2e.onboard.${stamp}@example.com`;
// Ethiopian mobile: 9 + 8 digits, unique per run. // Ethiopian mobile: 9 + 8 digits, unique per run.
const phoneNational = `9${String(stamp).slice(-8)}`; const phoneNational = `9${String(stamp).slice(-8)}`;
const signupPassword = "Password@e2e1"; const signupPassword = "Password@e2e1";
const companyName = `E2E Onboard Co ${stamp}`;
const tin = String(stamp).slice(-10).padStart(10, "1"); const tin = String(stamp).slice(-10).padStart(10, "1");
const vat = String(stamp + 1).slice(-10).padStart(10, "2"); const vat = String(stamp + 1).slice(-10).padStart(10, "2");
const fan = String(stamp).slice(-13).padStart(16, "3");
const portal = () => Cypress.env("portalUrl") as string; const portal = () => Cypress.env("portalUrl") as string;
@@ -61,12 +61,6 @@ function fillPhone(index: number, national: string) {
describe("customer onboarding journey", { retries: 0 }, () => { describe("customer onboarding journey", { retries: 0 }, () => {
it("signs up with OTP and completes the onboarding wizard", () => { it("signs up with OTP and completes the onboarding wizard", () => {
// The eTrade TIN lookup 400s in e2e (external service unreachable). The
// form handles it ("fill in the details manually") but axios also throws
// an uncaught rejection — ignore just that one.
cy.on("uncaught:exception", (err) =>
err.message.includes("Request failed with status code 400") ? false : true,
);
cy.visit(`${portal()}/signup`); cy.visit(`${portal()}/signup`);
fill(/^First name/, "Onboard"); fill(/^First name/, "Onboard");
@@ -91,20 +85,39 @@ describe("customer onboarding journey", { retries: 0 }, () => {
cy.contains("button", "Importer").click(); cy.contains("button", "Importer").click();
cy.get(".mantine-Modal-content").contains("button", "Continue").click(); cy.get(".mantine-Modal-content").contains("button", "Continue").click();
// Company step. TIN first — the eTrade auto-lookup fails in e2e (no // Ethiopian companies gate the "Owner identity" step on Fayda
// external network) and the form allows manual entry. // verification — a real popup + SMS OTP flow that can't run in e2e.
cy.get('input[placeholder="0012345678"]', { timeout: 15000 }).type(tin); // Complete it via the API against fayda-mock-e2e (the profile this
fill(/^Company Name/, companyName); // attaches to was just created by the nationality/role step above).
fill(/^Company Email/, `ops.${stamp}@example.com`); // The wizard already fetched `identity` once when this step mounted —
fillPhone(0, "911234567"); // completing verification out-of-band (no popup, so no onVerified
fill(/^Location/, "Addis Ababa, Ethiopia"); // callback fires) leaves that fetch stale, so reload to force a fresh
// one. Wizard progress resumes server-side, so this doesn't lose the
// nationality/role step just completed.
completeFaydaVerification("owner");
cy.reload();
cy.contains("Confirm your VAT number", { timeout: 20000 }).should(
"be.visible",
);
// Company step. TIN auto-triggers the eTrade lookup once it's a full 10
// digits (mocked in e2e — see docker-compose.e2e.yaml's etrade-mock-e2e).
// A successful lookup locks Company Name/Region/Zone/Woreda/Kebele/House
// No as read-only (ETradeCompanyCard) — nothing left to type there, and
// Company Email/Phone/Location were dropped from this step entirely (the
// Fayda-verified owner supplies contact details now). By label, not
// placeholder: the VAT Number field on this same step shares the TIN
// field's "0012345678" placeholder, so a placeholder selector matches 2.
fill(/^TIN Number/, tin);
cy.contains("Verified with eTrade", { timeout: 15000 }).should(
"be.visible",
);
// handleETradeDataLoaded sets several fields in sequence (name, region,
// zone, woreda, kebele, houseNo) — each a render, still settling right
// after the badge appears. Typing into VAT immediately raced one of
// those and detached mid-type; let it finish before touching the form.
cy.wait(500);
fill(/^VAT Number/, vat); fill(/^VAT Number/, vat);
cy.get('input[placeholder="1234567890123456"]').type(fan);
cy.mantineSelect(/^Region/, "Addis Ababa");
fill(/^Zone/, "Zone 1");
fill(/^Woreda/, "Woreda 1");
fill(/^Kebele/, "Kebele 1");
fill(/^House No/, "123");
cy.get(".mantine-Modal-content").contains("button", "Continue").click(); cy.get(".mantine-Modal-content").contains("button", "Continue").click();
// Personnel (general manager). // Personnel (general manager).

View File

@@ -141,34 +141,55 @@ Cypress.Commands.add("mantineSelect", (label: string | RegExp, option: string |
* takes explicit Start/End dates and pre-fills neither, so "Accept & start * takes explicit Start/End dates and pre-fills neither, so "Accept & start
* approval" stays disabled until both are set. * approval" stays disabled until both are set.
* *
* Mantine's DateInput parses typed text with its valueFormat, which defaults to * The fields are Mantine DateTimePickers — a button that opens a calendar
* "MMMM D, YYYY" — the same shape en-US toLocaleDateString produces. * popover, not a typeable input. Zoom out to the decade view (2 clicks on the
* header's middle control, which has no [data-direction]) then drill back
* down year → month → day, and confirm with the popover's submit (check)
* button — clicking a day alone only stages the value, it does not close
* the popover or commit the pick.
*/ */
const MONTH_ABBR = [
"Jan", "Feb", "Mar", "Apr", "May", "Jun",
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
] as const;
const MONTH_NAMES = [
"January", "February", "March", "April", "May", "June",
"July", "August", "September", "October", "November", "December",
] as const;
Cypress.Commands.add("acceptValidityWindow", (days = 365) => { Cypress.Commands.add("acceptValidityWindow", (days = 365) => {
const start = new Date(); const start = new Date();
const end = new Date(start.getTime() + days * 24 * 60 * 60 * 1000); const end = new Date(start.getTime() + days * 24 * 60 * 60 * 1000);
const asInput = (d: Date) =>
d.toLocaleDateString("en-US", {
month: "long",
day: "numeric",
year: "numeric",
});
for (const [label, value] of [ const pickDate = (label: string, date: Date) => {
["Start date", start],
["End date", end],
] as const) {
cy.contains("label", label) cy.contains("label", label)
.invoke("attr", "for") .invoke("attr", "for")
.then((id) => { .then((id) => cy.get(`[id="${id}"]`).click({ force: true }));
cy.get(`[id="${id}"]`)
.clear({ force: true }) // month view -> year view -> decade view.
.type(asInput(value), { force: true }) for (let i = 0; i < 2; i++) {
// DateInput commits on blur; it also closes the calendar popover, cy.get('.mantine-Popover-dropdown [data-direction="previous"]')
// which would otherwise sit over the submit button. .siblings("button")
.blur(); .first()
}); .click({ force: true });
} }
cy.get(".mantine-Popover-dropdown")
.contains("button", new RegExp(`^${date.getFullYear()}$`))
.click({ force: true });
cy.get(".mantine-Popover-dropdown")
.contains("button", new RegExp(`^${MONTH_ABBR[date.getMonth()]}$`))
.click({ force: true });
const dayAriaLabel = `${date.getDate()} ${MONTH_NAMES[date.getMonth()]} ${date.getFullYear()}`;
cy.get(`.mantine-Popover-dropdown [aria-label="${dayAriaLabel}"]`).click({
force: true,
});
cy.get(".mantine-DateTimePicker-submitButton").click({ force: true });
};
pickDate("Start date", start);
pickDate("End date", end);
}); });
/** /**

View File

@@ -0,0 +1,124 @@
// Stand-in for https://etrade.gov.et in the e2e stack. ETradeService's base
// URL is hardcoded (apps/edr-freight-api/src/modules/companies/services/etrade.service.ts),
// not env-configurable, so this is reached by aliasing this container AS
// "etrade.gov.et" on the compose network (see docker-compose.e2e.yaml) rather
// than by pointing an env var here. HTTPS because the real service is HTTPS
// — the cert itself only needs to exist, never be trusted, since
// ETradeService's httpsAgent sets rejectUnauthorized:false. The compose
// service's command generates a throwaway self-signed cert into
// TLS_CERT_PATH/TLS_KEY_PATH before starting this — nothing shaped like a
// key/cert is committed here.
//
// Every TIN resolves to the same canned company — these specs don't care
// about per-TIN business logic, only that the lookup succeeds so the rest
// of the company-info form (name, address, manager) auto-fills instead of
// staying gated behind a "No matching business record" alert.
const https = require("node:https");
const fs = require("node:fs");
const PORT = process.env.PORT || 443;
const options = {
key: fs.readFileSync(process.env.TLS_KEY_PATH || "/tmp/etrade-mock/key.pem"),
cert: fs.readFileSync(
process.env.TLS_CERT_PATH || "/tmp/etrade-mock/cert.pem",
),
};
function companyInfo(tin) {
return {
Tin: tin,
LegalCondtion: "Private Limited Company",
RegNo: "REG-E2E-0001",
RegDate: "2020-01-01",
// Embeds the (per-run-unique) TIN rather than a static name — specs
// that look a company up by name (e.g. onboarding.cy.ts) need this to
// stay unique across repeated e2e runs against the same warm DB, same
// as it would be if the customer had typed a real company name.
BusinessName: `E2E Mock Trading PLC ${tin}`,
BusinessNameAmh: "ኢቱኢ ሞክ ትሬዲንግ",
PaidUpCapital: 100000,
AssociateShortInfos: [],
Businesses: [
{
MainGuid: "e2e-guid-0001",
OwnerTIN: tin,
DateRegistered: "2020-01-01",
TradeNameAmh: "ኢቱኢ",
TradesName: "E2E Mock Trading",
LicenceNumber: "LIC-E2E-0001",
RenewalDate: "2026-01-01",
RenewedFrom: "2025-01-01",
RenewedTo: "2027-01-01",
BusinessLicensingGroupMain: null,
SubGroups: null,
},
],
};
}
function businessInfo(tin) {
return {
MainGuid: "e2e-guid-0001",
OwnerTIN: tin,
DateRegistered: "2020-01-01",
TradeName: "E2E Mock Trading",
LicenceNumber: "LIC-E2E-0001",
Status: 1,
StatusDescription: "Active",
Capital: 100000,
AssociateShortInfos: [
{
Position: "Manager",
ManagerName: "አበበ በቀለ",
ManagerNameEng: "Abebe Bekele",
Photo: null,
MobilePhone: "+251911223344",
RegularPhone: null,
},
],
AddressInfo: {
Region: "ADDIS ABABA",
Zone: "Zone 1",
Woreda: "Woreda 1",
Kebele: "Kebele 1",
HouseNo: "123",
MobilePhone: "+251911223344",
RegularPhone: "",
},
RenewedTo: "2027-01-01",
RenewedToDateString: "2027-01-01",
RenewalDate: "2026-01-01",
RenewedFrom: "2025-01-01",
CancellationDate: null,
};
}
const server = https.createServer(options, (req, res) => {
const url = new URL(req.url, `https://${req.headers.host}`);
const regMatch = url.pathname.match(
/^\/api\/Registration\/GetRegistrationInfoByTin\/([^/]+)\/en$/,
);
if (req.method === "GET" && regMatch) {
res.writeHead(200, { "content-type": "application/json" });
res.end(JSON.stringify(companyInfo(regMatch[1])));
return;
}
if (
req.method === "GET" &&
url.pathname === "/api/BusinessMain/GetBusinessByLicenseNo"
) {
const tin = url.searchParams.get("Tin") || "";
res.writeHead(200, { "content-type": "application/json" });
res.end(JSON.stringify(businessInfo(tin)));
return;
}
res.writeHead(404).end();
});
server.listen(PORT, () => {
console.log(`etrade-mock listening on ${PORT}`);
});

View File

@@ -0,0 +1,52 @@
// Minimal eSignet stand-in for the e2e stack. freight-api-e2e's
// FAYDA_TOKEN_ENDPOINT / FAYDA_USERINFO_ENDPOINT point here instead of the
// real (unreachable) Fayda infrastructure. It does not validate the
// client_assertion, code, or code_verifier it's sent — Cypress drives the
// verification via the API directly (POST start, then POST complete with a
// throwaway code), never through the real popup+SMS flow, so nothing here
// needs to be cryptographically real, only shaped like a working OIDC token
// + userinfo response.
const http = require("node:http");
const PORT = process.env.PORT || 4400;
const USERINFO = {
sub: "e2e-fayda-sub-0001",
name: "Abebe Bekele",
"name#en": "Abebe Bekele",
email: "abebe.bekele@example.com",
phone_number: "+251911223344",
gender: "Male",
"gender#en": "Male",
birthdate: "1990-01-01",
"address#en": "Bole Sub City, Addis Ababa, Ethiopia",
};
const server = http.createServer((req, res) => {
// Drain the body so the client's request completes cleanly even though we
// never read it (POST /token sends a form-urlencoded body we don't parse).
req.on("data", () => {});
req.on("end", () => {
if (req.method === "POST" && req.url.startsWith("/token")) {
res.writeHead(200, { "content-type": "application/json" });
res.end(
JSON.stringify({
access_token: "e2e-mock-access-token",
token_type: "Bearer",
expires_in: 3600,
}),
);
return;
}
if (req.method === "GET" && req.url.startsWith("/userinfo")) {
res.writeHead(200, { "content-type": "application/json" });
res.end(JSON.stringify(USERINFO));
return;
}
res.writeHead(404).end();
});
});
server.listen(PORT, () => {
console.log(`fayda-mock listening on ${PORT}`);
});

View File

@@ -16,6 +16,7 @@
*/ */
import { execFileSync, spawnSync } from "node:child_process"; import { execFileSync, spawnSync } from "node:child_process";
import { generateKeyPairSync } from "node:crypto";
import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { createServer } from "node:net"; import { createServer } from "node:net";
import { dirname, join, resolve } from "node:path"; import { dirname, join, resolve } from "node:path";
@@ -110,6 +111,24 @@ async function resolvePorts() {
return ports; return ports;
} }
/**
* Throwaway RSA JWK for FAYDA_PRIVATE_KEY_BASE64 — signs a client_assertion
* JWT that fayda-mock-e2e never verifies (see docker-compose.e2e.yaml), so
* a fresh one every launch is fine; nothing needs it to stay stable across
* runs the way ports do. Generated here instead of committed as a literal
* blob in the compose file so nothing that looks like a private key sits in
* git history, even though this one only unlocks a local mock.
*/
function fakeFaydaPrivateKeyBase64() {
const { privateKey } = generateKeyPairSync("rsa", { modulusLength: 2048 });
const jwk = privateKey.export({ format: "jwk" });
jwk.kty = "RSA";
jwk.use = "sig";
jwk.alg = "RS256";
jwk.kid = "e2e-fayda-mock";
return Buffer.from(JSON.stringify(jwk)).toString("base64");
}
function envFor(ports) { function envFor(ports) {
return { return {
...process.env, ...process.env,
@@ -120,6 +139,8 @@ function envFor(ports) {
CYPRESS_API_URL: `http://localhost:${ports.E2E_API_PORT}`, CYPRESS_API_URL: `http://localhost:${ports.E2E_API_PORT}`,
CYPRESS_PORTAL_URL: `http://localhost:${ports.E2E_PORTAL_PORT}`, CYPRESS_PORTAL_URL: `http://localhost:${ports.E2E_PORTAL_PORT}`,
E2E_DB_URL: `postgres://edr_e2e:edr_e2e@localhost:${ports.E2E_DB_PORT}/edr_freight_e2e`, E2E_DB_URL: `postgres://edr_e2e:edr_e2e@localhost:${ports.E2E_DB_PORT}/edr_freight_e2e`,
FAYDA_PRIVATE_KEY_BASE64:
process.env.FAYDA_PRIVATE_KEY_BASE64 ?? fakeFaydaPrivateKeyBase64(),
}; };
} }