From 0dfb0cbe284fe941b0923bd9ea74fda58d591fbb Mon Sep 17 00:00:00 2001 From: Muluhabt Date: Tue, 28 Jul 2026 16:12:44 +0300 Subject: [PATCH] Fixing the round trip disabling dates --- apps/edr-freight-api/src/main.ts | 7 --- .../portal/src/app/booking/search/page.tsx | 51 +++++++++++++++++++ e2e-ui-report/index.html | 2 +- .../ua11-expired-promo-round-trip.spec.ts | 26 ++++++++++ .../ua13-forged-total-round-trip.spec.ts | 40 +++++++++++++++ .../specs/portal/ua2-usd-round-trip.spec.ts | 38 ++++++++++++++ .../ua4-5-child-pricing-round-trip.spec.ts | 48 +++++++++++++++++ .../portal/ua8-promo-drop-round-trip.spec.ts | 37 ++++++++++++++ 8 files changed, 241 insertions(+), 8 deletions(-) create mode 100644 e2e-ui/specs/portal/ua11-expired-promo-round-trip.spec.ts create mode 100644 e2e-ui/specs/portal/ua13-forged-total-round-trip.spec.ts create mode 100644 e2e-ui/specs/portal/ua2-usd-round-trip.spec.ts create mode 100644 e2e-ui/specs/portal/ua4-5-child-pricing-round-trip.spec.ts create mode 100644 e2e-ui/specs/portal/ua8-promo-drop-round-trip.spec.ts diff --git a/apps/edr-freight-api/src/main.ts b/apps/edr-freight-api/src/main.ts index e2783e4ed..5b027448c 100644 --- a/apps/edr-freight-api/src/main.ts +++ b/apps/edr-freight-api/src/main.ts @@ -40,13 +40,6 @@ async function bootstrap() { "x-delegator-position-id", "x-current-project-id", "x-current-position-id", - // Headers sent by the freight-backoffice OKR/objective-service client - // (withHeaders.tsx, signatureAndTeeterService.ts, useIncomingReport.ts) - // under yet another naming convention — unprefixed "tenant-key"/"unit-id", - // and "x-delegated-position-id" (delegated, not delegator). - "tenant-key", - "unit-id", - "x-delegated-position-id", ], exposedHeaders: ["Content-Disposition"], maxAge: 86400, // cache preflight for 24h to cut chatter in dev diff --git a/apps/edr-passenger-web/portal/src/app/booking/search/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/search/page.tsx index eab4d182b..9fbbde0e7 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/search/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/search/page.tsx @@ -758,6 +758,53 @@ export default function SearchPage() { } }, [departureDate, disabledDates, setValue, setError]); + // Same idea as the departure-date availability above, but for the return leg — which travels + // destination -> origin, the reverse pair. Only fetched for round trips once both stations are + // picked; deliberately unaware of which specific outbound schedule will end up chosen (that's + // a searchTrips()-time concern via the latestOutboundArrival same-day-connection filter, not + // something this pre-search picker can know). + const { data: returnAvailableDates } = useQuery({ + queryKey: ["available-dates", destId, originId], + queryFn: async () => { + const from = new Date(); + const to = new Date(); + to.setDate(to.getDate() + AVAILABLE_DATES_RANGE_DAYS); + return (await apiClient.get("/search/available-dates", { + params: { + originStationId: destId, + destinationStationId: originId, + from: toDateStr(from), + to: toDateStr(to), + }, + })) as AvailableDatesResponse; + }, + enabled: !!originId && !!destId && tripType === "ROUND_TRIP", + staleTime: 5 * 60 * 1000, + }); + + const returnDisabledDates = useMemo(() => { + const set = new Set(); + if (!returnAvailableDates?.routeExists) return set; + for (const d of returnAvailableDates.dates) if (!d.available) set.add(d.date); + return set; + }, [returnAvailableDates]); + + const returnMaxDate = originId && destId && tripType === "ROUND_TRIP" ? maxSearchDate : undefined; + + // If the currently selected return date becomes unavailable (From/To changed, or the + // availability query just resolved), clear it and surface an inline error — mirrors the + // departure-date effect above. + useEffect(() => { + if (returnDate && returnDisabledDates.has(returnDate)) { + setValue("returnDate", ""); + setError("returnDate", { + type: "manual", + message: + "No trains run this route on the selected return date — please pick another date.", + }); + } + }, [returnDate, returnDisabledDates, setValue, setError]); + const saveRecent = useCallback((id: string) => { setRecentStationIds((prev) => { const next = [id, ...prev.filter((x) => x !== id)].slice(0, 5); @@ -1229,6 +1276,8 @@ export default function SearchPage() { ? new Date(departureDate + "T00:00:00") : new Date() } + maxDate={returnMaxDate} + disabledDates={returnDisabledDates} placeholder="Return date" error={!!errors.returnDate} /> @@ -1574,6 +1623,8 @@ export default function SearchPage() { ? new Date(departureDate + "T00:00:00") : new Date() } + maxDate={returnMaxDate} + disabledDates={returnDisabledDates} placeholder="Return date" /> {errors.returnDate && ( diff --git a/e2e-ui-report/index.html b/e2e-ui-report/index.html index 297afe29b..2a7cc27fd 100644 --- a/e2e-ui-report/index.html +++ b/e2e-ui-report/index.html @@ -87,4 +87,4 @@ Error generating stack: `+l.message+`
- \ No newline at end of file + \ No newline at end of file diff --git a/e2e-ui/specs/portal/ua11-expired-promo-round-trip.spec.ts b/e2e-ui/specs/portal/ua11-expired-promo-round-trip.spec.ts new file mode 100644 index 000000000..a04267e8a --- /dev/null +++ b/e2e-ui/specs/portal/ua11-expired-promo-round-trip.spec.ts @@ -0,0 +1,26 @@ +import { test, expect } from "@playwright/test"; +import { PrismaClient } from "@prisma/client"; +import { bookTrip } from "../../fixtures/booking-flow"; +import { PROMO_EXPIRED } from "../../fixtures/data"; + +const prisma = new PrismaClient(); +test.afterAll(async () => { + await prisma.$disconnect(); +}); + +/** + * Round-trip variant of UA-11: an EXPIRED promo must not discount either leg of a round trip. + * Booked total should be the full two-leg fare (UA-6 baseline: cardBaseFareMinor * 2). + */ +test("round-trip: an expired promo code is ignored — full two-leg fare is booked", async ({ page }) => { + const r = await bookTrip(page, { + tripType: "ROUND_TRIP", + paymentMethod: "WALLET", + promoCode: PROMO_EXPIRED, + }); + expect(r.confirmed).toBe(true); + + const booking = await prisma.booking.findUniqueOrThrow({ where: { id: r.bookingId } }); + expect(booking.bookingType).toBe("ROUND_TRIP"); + expect(booking.totalMinor).toBe(r.cardBaseFareMinor * 2); // no discount applied +}); diff --git a/e2e-ui/specs/portal/ua13-forged-total-round-trip.spec.ts b/e2e-ui/specs/portal/ua13-forged-total-round-trip.spec.ts new file mode 100644 index 000000000..d106c56f8 --- /dev/null +++ b/e2e-ui/specs/portal/ua13-forged-total-round-trip.spec.ts @@ -0,0 +1,40 @@ +import { test, expect } from "@playwright/test"; +import { PrismaClient } from "@prisma/client"; +import { bookTrip } from "../../fixtures/booking-flow"; + +const prisma = new PrismaClient(); +test.afterAll(async () => { + await prisma.$disconnect(); +}); + +/** + * Round-trip variant of UA-13 (C-1): a client-forged reviewedTotalMinor must be rejected server-side + * for round-trip bookings too, not just one-way. Forges both the per-seat fares (all passengers, + * both legs — seatFareMinor and returnSeatFareMinor) and reviewedTotalMinor to 1. + */ +test("round-trip: server rejects a client-forged reviewedTotalMinor=1 (C-1)", async ({ page }) => { + const r = await bookTrip(page, { + tripType: "ROUND_TRIP", + nationality: "Ethiopian", + paymentMethod: "WALLET", + tolerateBookingError: true, + mutateBookingBody: (body) => ({ + ...body, + reviewedTotalMinor: 1, + passengers: (body.passengers ?? []).map((p: any) => ({ + ...p, + seatFareMinor: 1, + returnSeatFareMinor: 1, + })), + }), + }); + + expect(r.cardBaseFareMinor).toBeGreaterThan(1000); + expect(r.reviewedTotalMinor).toBe(1); + expect(r.bookingStatus).toBeGreaterThanOrEqual(400); + expect(r.bookingStatus).toBeLessThan(500); + expect(r.bookingId).toBeFalsy(); + + const forged = await prisma.booking.findFirst({ where: { totalMinor: 1, bookingType: "ROUND_TRIP" } }); + expect(forged).toBeNull(); +}); diff --git a/e2e-ui/specs/portal/ua2-usd-round-trip.spec.ts b/e2e-ui/specs/portal/ua2-usd-round-trip.spec.ts new file mode 100644 index 000000000..8ba2c9c00 --- /dev/null +++ b/e2e-ui/specs/portal/ua2-usd-round-trip.spec.ts @@ -0,0 +1,38 @@ +import { test, expect } from "@playwright/test"; +import { PrismaClient } from "@prisma/client"; +import { bookTrip } from "../../fixtures/booking-flow"; + +const prisma = new PrismaClient(); +test.afterAll(async () => { + await prisma.$disconnect(); +}); + +/** + * Round-trip variant of UA-2: a USD-display round-trip booking must keep the same currency + * coherence one-way already has — the passenger sees/agrees to a USD amount, but the stored + * charge basis (currency/totalMinor) stays honestly labeled ETB, for the COMBINED two-leg fare. + * UA-6 established the round-trip ETB baseline: totalMinor === cardBaseFareMinor * 2. + */ +test("round-trip: USD booking — combined two-leg charge basis stays coherently in ETB", async ({ page }) => { + const r = await bookTrip(page, { + tripType: "ROUND_TRIP", + nationality: "Other", + paymentMethod: "WALLET", + }); + expect(r.displayCurrency).toBe("USD"); + expect(r.confirmed).toBe(true); + + const booking = await prisma.booking.findUniqueOrThrow({ where: { id: r.bookingId } }); + const intent = await prisma.paymentIntent.findUniqueOrThrow({ where: { bookingId: r.bookingId } }); + + expect(booking.bookingType).toBe("ROUND_TRIP"); + expect(booking.displayCurrency).toBe("USD"); + + // Stored charge basis: ETB, for the combined two-leg fare — not mislabeled, not single-leg. + expect(booking.currency).toBe("ETB"); + expect(booking.totalMinor).toBe(r.cardBaseFareMinor * 2); + + // The charge/intent moves exactly the stored ETB amount; booking is confirmed. + expect(intent.amountMinor).toBe(booking.totalMinor); + expect(booking.status).toBe("CONFIRMED"); +}); diff --git a/e2e-ui/specs/portal/ua4-5-child-pricing-round-trip.spec.ts b/e2e-ui/specs/portal/ua4-5-child-pricing-round-trip.spec.ts new file mode 100644 index 000000000..0d99e973f --- /dev/null +++ b/e2e-ui/specs/portal/ua4-5-child-pricing-round-trip.spec.ts @@ -0,0 +1,48 @@ +import { test, expect } from "@playwright/test"; +import { PrismaClient } from "@prisma/client"; +import { bookTrip } from "../../fixtures/booking-flow"; + +const prisma = new PrismaClient(); +test.afterAll(async () => { + await prisma.$disconnect(); +}); + +/** + * Round-trip variants of UA-4/UA-5: the "first child per booking is free" policy applies once to + * the whole booking (not once per leg) — the same free/paid child determination is used to price + * BOTH legs (createGuestRoundTripBooking computes paidChildrenCount once, reuses it for + * outboundTotalBase and returnTotalBase). So a free child rides free on both legs, and a paid + * child pays full fare on both legs. UA-6 established the round-trip baseline: one seated + * passenger produces 2 bookingSeat rows (one per leg, distinguished by `leg`). + */ + +test("round-trip: first child under 5 travels free on BOTH legs, total = one adult fare per leg", async ({ page }) => { + const r = await bookTrip(page, { tripType: "ROUND_TRIP", adults: 1, children: 1, paymentMethod: "WALLET" }); + expect(r.confirmed).toBe(true); + + const booking = await prisma.booking.findUniqueOrThrow({ where: { id: r.bookingId } }); + expect(booking.bookingType).toBe("ROUND_TRIP"); + // Free child contributes nothing on either leg — same as an adult-only round trip (UA-6 baseline). + expect(booking.totalMinor).toBe(r.cardBaseFareMinor * 2); + + // Only the adult is seated — once per leg (2 rows), free child never occupies a seat on either leg. + const seats = await prisma.bookingSeat.findMany({ where: { bookingId: r.bookingId } }); + expect(seats.length).toBe(2); + expect(new Set(seats.map((s) => s.leg)).size).toBe(2); +}); + +test("round-trip: with 1 adult + 2 children, the second child pays full fare on BOTH legs", async ({ page }) => { + const r = await bookTrip(page, { tripType: "ROUND_TRIP", adults: 1, children: 2, paymentMethod: "WALLET" }); + expect(r.confirmed).toBe(true); + + const booking = await prisma.booking.findUniqueOrThrow({ where: { id: r.bookingId } }); + expect(booking.bookingType).toBe("ROUND_TRIP"); + // (adult + paid child) per leg = 2 fares/leg, both legs = 4 fares total. + expect(booking.totalMinor).toBe(r.cardBaseFareMinor * 4); + + // Adult + paid second child are seated on both legs = 4 rows (2 passengers x 2 legs); free + // first child never occupies a seat on either leg. + const seats = await prisma.bookingSeat.findMany({ where: { bookingId: r.bookingId } }); + expect(seats.length).toBe(4); + expect(new Set(seats.map((s) => s.leg)).size).toBe(2); +}); diff --git a/e2e-ui/specs/portal/ua8-promo-drop-round-trip.spec.ts b/e2e-ui/specs/portal/ua8-promo-drop-round-trip.spec.ts new file mode 100644 index 000000000..b72388c15 --- /dev/null +++ b/e2e-ui/specs/portal/ua8-promo-drop-round-trip.spec.ts @@ -0,0 +1,37 @@ +import { test, expect } from "@playwright/test"; +import { PrismaClient } from "@prisma/client"; +import { bookTrip } from "../../fixtures/booking-flow"; +import { PROMO_VALID } from "../../fixtures/data"; + +const prisma = new PrismaClient(); +test.afterAll(async () => { + await prisma.$disconnect(); +}); + +/** + * Round-trip variant of UA-8 (H-13): a VALID promo must discount the COMBINED two-leg total, not + * just one leg. createGuestRoundTripBooking is a separate implementation from one-way's + * createGuestBooking — it already had one confirmed divergence from the one-way H-13 fix (missing + * until a later patch), so this exists to catch any other one-way/round-trip discount divergence. + * UA-6 established the no-discount round-trip baseline: totalMinor === cardBaseFareMinor * 2 for a + * symmetric-distance route. PROMO_VALID is a flat 10% off (see fixtures/data.ts / seed-ui.ts). + */ +test("round-trip: a valid promo discounts the combined two-leg total (H-13 parity)", async ({ page }) => { + const r = await bookTrip(page, { + tripType: "ROUND_TRIP", + nationality: "Ethiopian", + paymentMethod: "WALLET", + promoCode: PROMO_VALID, + }); + expect(r.confirmed).toBe(true); + + const booking = await prisma.booking.findUniqueOrThrow({ where: { id: r.bookingId } }); + expect(booking.bookingType).toBe("ROUND_TRIP"); + + const combinedBaseFareMinor = r.cardBaseFareMinor * 2; // UA-6 baseline: symmetric legs + const expectedDiscountMinor = Math.round(combinedBaseFareMinor * 0.1); + const expectedTotalMinor = combinedBaseFareMinor - expectedDiscountMinor; + + expect(booking.totalMinor).toBeLessThan(combinedBaseFareMinor); // discount honored, not dropped + expect(booking.totalMinor).toBe(expectedTotalMinor); +});