mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 12:41:04 +00:00
chore: test fixes
This commit is contained in:
@@ -61,6 +61,71 @@ services:
|
||||
- mc alias set e2e http://minio-e2e:9000 e2e-minio e2e-minio-secret && mc mb --ignore-existing e2e/fhc
|
||||
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:
|
||||
build:
|
||||
context: .
|
||||
@@ -74,6 +139,10 @@ services:
|
||||
condition: service_healthy
|
||||
minio-init-e2e:
|
||||
condition: service_completed_successfully
|
||||
fayda-mock-e2e:
|
||||
condition: service_healthy
|
||||
etrade-mock-e2e:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
PORT: "3001"
|
||||
DB_HOST: postgres-freight-e2e
|
||||
@@ -104,7 +173,21 @@ services:
|
||||
MINIO_REGION: us-east-1
|
||||
# External integrations off
|
||||
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 —
|
||||
# blackhole it so e2e never sends SMS (failures are logged, non-fatal).
|
||||
OZIKING_SMS_URL: http://127.0.0.1:9/sms
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
acceptExport,
|
||||
apiPost,
|
||||
bookBulk,
|
||||
clearToOperationRequestPending,
|
||||
createImportSchedule,
|
||||
db,
|
||||
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", () => {
|
||||
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) => {
|
||||
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]));
|
||||
@@ -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", () => {
|
||||
bookBulk({ suffix: "CXB", tons: 700, scheduledDate: BOOKING_DAY });
|
||||
clearToOperationRequestPending("CXB", BOOKING_DAY);
|
||||
acceptExport("CXB");
|
||||
markPaid("CXB");
|
||||
pollAllocations("CXB", 10); // 700T / 70T = 10 CW4 wagons
|
||||
|
||||
@@ -19,6 +19,8 @@ import {
|
||||
acceptOperation,
|
||||
apiPost,
|
||||
bookBulk,
|
||||
clearIntercityToFullyExecuted,
|
||||
clearToOperationRequestPending,
|
||||
closeBookingWindow,
|
||||
completeDocReview,
|
||||
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", () => {
|
||||
bookBulk({ suffix: "BM1", tons: 1400, scheduledDate: BOOKING_DAY });
|
||||
clearToOperationRequestPending("BM1", BOOKING_DAY);
|
||||
acceptOperation("BM1");
|
||||
bookBulk({ suffix: "BMSUB", tons: 700, scheduledDate: BOOKING_DAY });
|
||||
clearToOperationRequestPending("BMSUB", BOOKING_DAY);
|
||||
acceptOperation("BMSUB");
|
||||
|
||||
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", () => {
|
||||
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) => {
|
||||
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));
|
||||
|
||||
bookBulk({ suffix: "BMG", tons: 4000, scheduledDate: GIANT_DAY });
|
||||
clearToOperationRequestPending("BMG", GIANT_DAY);
|
||||
acceptOperation("BMG");
|
||||
withSchedule(GIANT_DEPARTURE, (s) => {
|
||||
closeBookingWindow(s.id);
|
||||
@@ -208,7 +215,7 @@ describe("bulk critical matrix: gates, sub-corridor, bulk intercity, whole-train
|
||||
expectFailure: "must take the whole",
|
||||
});
|
||||
bookBulk({ suffix: "BMG", tons: 220, scheduledDate: REMAINDER_DAY });
|
||||
pollBookingStatus("BMG", "OPERATION_REQUEST_PENDING", 5);
|
||||
clearToOperationRequestPending("BMG", REMAINDER_DAY);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
acceptExport,
|
||||
apiPost,
|
||||
bookBulk,
|
||||
clearToOperationRequestPending,
|
||||
completeBookingMilestone,
|
||||
createImportSchedule,
|
||||
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", () => {
|
||||
BOOKINGS.forEach((b) => {
|
||||
bookBulk({ suffix: b.suffix, tons: b.tons, scheduledDate: BOOKING_DAY });
|
||||
pollBookingStatus(b.suffix, "OPERATION_REQUEST_PENDING", 5);
|
||||
clearToOperationRequestPending(b.suffix, BOOKING_DAY);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
acceptOperation,
|
||||
apiPost,
|
||||
bookBulk,
|
||||
clearToOperationRequestPending,
|
||||
closeBookingWindow,
|
||||
completeBookingMilestone,
|
||||
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", () => {
|
||||
BOOKINGS.forEach((b) => {
|
||||
bookBulk({ suffix: b.suffix, tons: b.tons, scheduledDate: BOOKING_DAY });
|
||||
pollBookingStatus(b.suffix, "OPERATION_REQUEST_PENDING", 5);
|
||||
clearToOperationRequestPending(b.suffix, BOOKING_DAY);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
import {
|
||||
acceptOperation,
|
||||
bookBulk,
|
||||
clearToOperationRequestPending,
|
||||
closeBookingWindow,
|
||||
completeDocReview,
|
||||
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", () => {
|
||||
ORDER.forEach((suffix) => {
|
||||
bookBulk({ suffix, tons: TONS[suffix], scheduledDate: BOOKING_DAY });
|
||||
clearToOperationRequestPending(suffix, BOOKING_DAY);
|
||||
acceptOperation(suffix);
|
||||
});
|
||||
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 });
|
||||
pollBookingStatus("BSC", "OPERATION_REQUEST_PENDING", 5);
|
||||
clearToOperationRequestPending("BSC", REMAINDER_DAY);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -73,7 +73,8 @@ describe("contract lifecycle: creation to finalization", { retries: 0 }, () => {
|
||||
cy.mantineSelect(/^Contract Kind/, "General Contract");
|
||||
cy.mantineSelect(/^New or Renewal/, "New Contract");
|
||||
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 });
|
||||
|
||||
// Step 1 — Cargo & Route. Container contracts now auto-cover BOTH 20ft &
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
acceptExport,
|
||||
apiPost,
|
||||
bookContainers,
|
||||
clearToOperationRequestPending,
|
||||
createImportSchedule,
|
||||
db,
|
||||
dbBooking,
|
||||
@@ -118,7 +119,7 @@ describe("export: six bookings fill the 54-wagon corridor train (FCFS)", { retri
|
||||
scheduledDate: BOOKING_DAY,
|
||||
});
|
||||
isoSeed += b.twenty + b.forty;
|
||||
pollBookingStatus(b.suffix, "OPERATION_REQUEST_PENDING", 5);
|
||||
clearToOperationRequestPending(b.suffix, BOOKING_DAY);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -21,6 +21,8 @@
|
||||
* Sequential steps of one journey — retries off (steps are not idempotent).
|
||||
*/
|
||||
|
||||
import { clearBookingClearance } from "./import-utils";
|
||||
|
||||
const customer = "user@gmail.com";
|
||||
const companyTin = "0102030405"; // seed-company.sql
|
||||
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(/^New or Renewal/, "New Contract");
|
||||
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 });
|
||||
|
||||
// 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.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) => {
|
||||
expect(b.status).to.eq("OPERATION_REQUEST_PENDING");
|
||||
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(/^New or Renewal/, "New Contract");
|
||||
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.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.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) => {
|
||||
expect(b.status).to.eq("OPERATION_REQUEST_PENDING");
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
return tokenFor(email).then((token) =>
|
||||
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):
|
||||
* AWAITING_DOCUMENTS → upload one doc → GL approves it → finalize →
|
||||
* CLEARANCE_READY → customer proceeds with the shipment day →
|
||||
* 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.
|
||||
* AWAITING_DOCUMENTS → ... → OPERATION_REQUEST_PENDING (clearToOperationRequestPending)
|
||||
* → ops accept → FULLY_EXECUTED (pool).
|
||||
*/
|
||||
export function clearGeneralBooking(
|
||||
suffix: string,
|
||||
@@ -471,22 +582,7 @@ export function clearGeneralBooking(
|
||||
/** EXPORT ends at acceptExport (FCFS reserves on accept), not the pool. */
|
||||
mode: "import" | "export" = "import",
|
||||
) {
|
||||
withBooking(suffix, (b) => {
|
||||
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]);
|
||||
});
|
||||
clearToOperationRequestPending(suffix, scheduledDate);
|
||||
if (mode === "export") acceptExport(suffix);
|
||||
else acceptOperation(suffix);
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
acceptOperation,
|
||||
apiPost,
|
||||
bookContainers,
|
||||
clearToOperationRequestPending,
|
||||
closeBookingWindow,
|
||||
completeBookingMilestone,
|
||||
completeDocReview,
|
||||
@@ -113,7 +114,7 @@ describe("import: six bookings fill the 54-wagon corridor train", { retries: 0 }
|
||||
scheduledDate: BOOKING_DAY,
|
||||
});
|
||||
isoSeed += b.twenty + b.forty;
|
||||
pollBookingStatus(b.suffix, "OPERATION_REQUEST_PENDING", 5);
|
||||
clearToOperationRequestPending(b.suffix, BOOKING_DAY);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
resetCorridorDay,
|
||||
acceptOperation,
|
||||
bookContainers,
|
||||
clearToOperationRequestPending,
|
||||
closeBookingWindow,
|
||||
completeDocReview,
|
||||
createImportSchedule,
|
||||
@@ -91,6 +92,7 @@ describe("import: split offer, remainder rebooking, expiry + promotion", { retri
|
||||
scheduledDate: BOOKING_DAY,
|
||||
});
|
||||
isoSeed += s.twenty + s.forty;
|
||||
clearToOperationRequestPending(suffix, BOOKING_DAY);
|
||||
acceptOperation(suffix);
|
||||
});
|
||||
ORDER.forEach((suffix, i) => setPriority(suffix, i + 1));
|
||||
@@ -217,7 +219,7 @@ describe("import: split offer, remainder rebooking, expiry + promotion", { retri
|
||||
twenty: 32,
|
||||
scheduledDate: REMAINDER_DAY,
|
||||
});
|
||||
pollBookingStatus("SC", "OPERATION_REQUEST_PENDING", 5);
|
||||
clearToOperationRequestPending("SC", REMAINDER_DAY);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -18,9 +18,11 @@
|
||||
* 7. backoffice — operations approves the document, then finalizes document
|
||||
* approval → FULLY_EXECUTED
|
||||
* 8. portal — customer books 2 × 20ft under the contract (intercity has
|
||||
* no shipment date) → booking OPERATION_REQUEST_PENDING
|
||||
* 9. backoffice — operations accepts the operation request → booking
|
||||
* FULLY_EXECUTED (intercity waiting pool)
|
||||
* no shipment date) → booking AWAITING_DOCUMENTS → customer
|
||||
* uploads a doc, GL approves, finalize → DOMESTIC bookings
|
||||
* 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
|
||||
* Mojo → Dire Dawa → Djibouti Port (distances seeded)
|
||||
* 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).
|
||||
*/
|
||||
|
||||
import { clearIntercityBookingClearance } from "./import-utils";
|
||||
|
||||
const customer = "user@gmail.com";
|
||||
const companyTin = "0102030405"; // seed-company.sql
|
||||
const opsStaff = "operation@edr.local";
|
||||
@@ -153,12 +157,13 @@ function createIntercityContract() {
|
||||
cy.loginPortal(customer);
|
||||
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(/^Contract Kind/, "One-Time Contract");
|
||||
cy.mantineSelect(/^New or Renewal/, "New Contract");
|
||||
cy.contains("button", "Rail Transport Only", { timeout: 15000 }).click();
|
||||
cy.mantineSelect(/^Payment Currency/, /^ETB/);
|
||||
cy.contains("button", "Continue").click({ force: true });
|
||||
|
||||
// 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\/.+/);
|
||||
|
||||
withBooking((b) => {
|
||||
expect(b.status).to.eq("OPERATION_REQUEST_PENDING");
|
||||
expect(b.scheduled_date, "intercity bookings carry no scheduled date").to.eq(null);
|
||||
expect(b.status, "born in the clearance gate").to.eq("AWAITING_DOCUMENTS");
|
||||
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) => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
apiPost,
|
||||
bookBulk,
|
||||
bookContainers,
|
||||
clearToOperationRequestPending,
|
||||
completeBookingMilestone,
|
||||
createImportSchedule,
|
||||
db,
|
||||
@@ -120,7 +121,7 @@ describe("mixed export: containers and wheat share one 54-wagon FCFS train", { r
|
||||
} else {
|
||||
bookBulk({ suffix: b.suffix, tons: b.tons!, scheduledDate: BOOKING_DAY });
|
||||
}
|
||||
pollBookingStatus(b.suffix, "OPERATION_REQUEST_PENDING", 5);
|
||||
clearToOperationRequestPending(b.suffix, BOOKING_DAY);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
apiPost,
|
||||
bookBulk,
|
||||
bookContainers,
|
||||
clearToOperationRequestPending,
|
||||
closeBookingWindow,
|
||||
completeBookingMilestone,
|
||||
completeDocReview,
|
||||
@@ -113,7 +114,7 @@ describe("mixed import: containers and wheat share one 54-wagon train", { retrie
|
||||
} else {
|
||||
bookBulk({ suffix: b.suffix, tons: b.tons!, scheduledDate: BOOKING_DAY });
|
||||
}
|
||||
pollBookingStatus(b.suffix, "OPERATION_REQUEST_PENDING", 5);
|
||||
clearToOperationRequestPending(b.suffix, BOOKING_DAY);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
acceptOperation,
|
||||
bookBulk,
|
||||
bookContainers,
|
||||
clearToOperationRequestPending,
|
||||
closeBookingWindow,
|
||||
completeDocReview,
|
||||
createImportSchedule,
|
||||
@@ -96,10 +97,12 @@ describe("mixed import: container split; container-freed wagons promote the bulk
|
||||
scheduledDate: BOOKING_DAY,
|
||||
});
|
||||
isoSeed += b.twenty;
|
||||
clearToOperationRequestPending(b.suffix, BOOKING_DAY);
|
||||
acceptOperation(b.suffix);
|
||||
});
|
||||
BULKS.forEach((b) => {
|
||||
bookBulk({ suffix: b.suffix, tons: b.tons, scheduledDate: BOOKING_DAY });
|
||||
clearToOperationRequestPending(b.suffix, BOOKING_DAY);
|
||||
acceptOperation(b.suffix);
|
||||
});
|
||||
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,
|
||||
scheduledDate: REMAINDER_DAY,
|
||||
});
|
||||
pollBookingStatus("MSX", "OPERATION_REQUEST_PENDING", 5);
|
||||
clearToOperationRequestPending("MSX", REMAINDER_DAY);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -18,15 +18,15 @@
|
||||
* journey's user/company from the DB instead of module variables.
|
||||
*/
|
||||
|
||||
import { completeFaydaVerification } from "./import-utils";
|
||||
|
||||
const stamp = Date.now();
|
||||
const email = `e2e.onboard.${stamp}@example.com`;
|
||||
// Ethiopian mobile: 9 + 8 digits, unique per run.
|
||||
const phoneNational = `9${String(stamp).slice(-8)}`;
|
||||
const signupPassword = "Password@e2e1";
|
||||
const companyName = `E2E Onboard Co ${stamp}`;
|
||||
const tin = String(stamp).slice(-10).padStart(10, "1");
|
||||
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;
|
||||
|
||||
@@ -61,12 +61,6 @@ function fillPhone(index: number, national: string) {
|
||||
|
||||
describe("customer onboarding journey", { retries: 0 }, () => {
|
||||
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`);
|
||||
|
||||
fill(/^First name/, "Onboard");
|
||||
@@ -91,20 +85,39 @@ describe("customer onboarding journey", { retries: 0 }, () => {
|
||||
cy.contains("button", "Importer").click();
|
||||
cy.get(".mantine-Modal-content").contains("button", "Continue").click();
|
||||
|
||||
// Company step. TIN first — the eTrade auto-lookup fails in e2e (no
|
||||
// external network) and the form allows manual entry.
|
||||
cy.get('input[placeholder="0012345678"]', { timeout: 15000 }).type(tin);
|
||||
fill(/^Company Name/, companyName);
|
||||
fill(/^Company Email/, `ops.${stamp}@example.com`);
|
||||
fillPhone(0, "911234567");
|
||||
fill(/^Location/, "Addis Ababa, Ethiopia");
|
||||
// Ethiopian companies gate the "Owner identity" step on Fayda
|
||||
// verification — a real popup + SMS OTP flow that can't run in e2e.
|
||||
// Complete it via the API against fayda-mock-e2e (the profile this
|
||||
// attaches to was just created by the nationality/role step above).
|
||||
// The wizard already fetched `identity` once when this step mounted —
|
||||
// completing verification out-of-band (no popup, so no onVerified
|
||||
// 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);
|
||||
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();
|
||||
|
||||
// Personnel (general manager).
|
||||
|
||||
@@ -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
|
||||
* approval" stays disabled until both are set.
|
||||
*
|
||||
* Mantine's DateInput parses typed text with its valueFormat, which defaults to
|
||||
* "MMMM D, YYYY" — the same shape en-US toLocaleDateString produces.
|
||||
* The fields are Mantine DateTimePickers — a button that opens a calendar
|
||||
* 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) => {
|
||||
const start = new Date();
|
||||
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 [
|
||||
["Start date", start],
|
||||
["End date", end],
|
||||
] as const) {
|
||||
const pickDate = (label: string, date: Date) => {
|
||||
cy.contains("label", label)
|
||||
.invoke("attr", "for")
|
||||
.then((id) => {
|
||||
cy.get(`[id="${id}"]`)
|
||||
.clear({ force: true })
|
||||
.type(asInput(value), { force: true })
|
||||
// DateInput commits on blur; it also closes the calendar popover,
|
||||
// which would otherwise sit over the submit button.
|
||||
.blur();
|
||||
});
|
||||
}
|
||||
.then((id) => cy.get(`[id="${id}"]`).click({ force: true }));
|
||||
|
||||
// month view -> year view -> decade view.
|
||||
for (let i = 0; i < 2; i++) {
|
||||
cy.get('.mantine-Popover-dropdown [data-direction="previous"]')
|
||||
.siblings("button")
|
||||
.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);
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
124
e2e/freight/etrade-mock/server.js
Normal file
124
e2e/freight/etrade-mock/server.js
Normal 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}`);
|
||||
});
|
||||
52
e2e/freight/fayda-mock/server.js
Normal file
52
e2e/freight/fayda-mock/server.js
Normal 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}`);
|
||||
});
|
||||
@@ -16,6 +16,7 @@
|
||||
*/
|
||||
|
||||
import { execFileSync, spawnSync } from "node:child_process";
|
||||
import { generateKeyPairSync } from "node:crypto";
|
||||
import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { createServer } from "node:net";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
@@ -110,6 +111,24 @@ async function resolvePorts() {
|
||||
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) {
|
||||
return {
|
||||
...process.env,
|
||||
@@ -120,6 +139,8 @@ function envFor(ports) {
|
||||
CYPRESS_API_URL: `http://localhost:${ports.E2E_API_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`,
|
||||
FAYDA_PRIVATE_KEY_BASE64:
|
||||
process.env.FAYDA_PRIVATE_KEY_BASE64 ?? fakeFaydaPrivateKeyBase64(),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user