mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 19:58:11 +00:00
Fixing the round trip disabling dates
This commit is contained in:
@@ -40,13 +40,6 @@ async function bootstrap() {
|
|||||||
"x-delegator-position-id",
|
"x-delegator-position-id",
|
||||||
"x-current-project-id",
|
"x-current-project-id",
|
||||||
"x-current-position-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"],
|
exposedHeaders: ["Content-Disposition"],
|
||||||
maxAge: 86400, // cache preflight for 24h to cut chatter in dev
|
maxAge: 86400, // cache preflight for 24h to cut chatter in dev
|
||||||
|
|||||||
@@ -758,6 +758,53 @@ export default function SearchPage() {
|
|||||||
}
|
}
|
||||||
}, [departureDate, disabledDates, setValue, setError]);
|
}, [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<AvailableDatesResponse>({
|
||||||
|
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<string>();
|
||||||
|
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) => {
|
const saveRecent = useCallback((id: string) => {
|
||||||
setRecentStationIds((prev) => {
|
setRecentStationIds((prev) => {
|
||||||
const next = [id, ...prev.filter((x) => x !== id)].slice(0, 5);
|
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(departureDate + "T00:00:00")
|
||||||
: new Date()
|
: new Date()
|
||||||
}
|
}
|
||||||
|
maxDate={returnMaxDate}
|
||||||
|
disabledDates={returnDisabledDates}
|
||||||
placeholder="Return date"
|
placeholder="Return date"
|
||||||
error={!!errors.returnDate}
|
error={!!errors.returnDate}
|
||||||
/>
|
/>
|
||||||
@@ -1574,6 +1623,8 @@ export default function SearchPage() {
|
|||||||
? new Date(departureDate + "T00:00:00")
|
? new Date(departureDate + "T00:00:00")
|
||||||
: new Date()
|
: new Date()
|
||||||
}
|
}
|
||||||
|
maxDate={returnMaxDate}
|
||||||
|
disabledDates={returnDisabledDates}
|
||||||
placeholder="Return date"
|
placeholder="Return date"
|
||||||
/>
|
/>
|
||||||
{errors.returnDate && (
|
{errors.returnDate && (
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
26
e2e-ui/specs/portal/ua11-expired-promo-round-trip.spec.ts
Normal file
26
e2e-ui/specs/portal/ua11-expired-promo-round-trip.spec.ts
Normal file
@@ -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
|
||||||
|
});
|
||||||
40
e2e-ui/specs/portal/ua13-forged-total-round-trip.spec.ts
Normal file
40
e2e-ui/specs/portal/ua13-forged-total-round-trip.spec.ts
Normal file
@@ -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();
|
||||||
|
});
|
||||||
38
e2e-ui/specs/portal/ua2-usd-round-trip.spec.ts
Normal file
38
e2e-ui/specs/portal/ua2-usd-round-trip.spec.ts
Normal file
@@ -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");
|
||||||
|
});
|
||||||
48
e2e-ui/specs/portal/ua4-5-child-pricing-round-trip.spec.ts
Normal file
48
e2e-ui/specs/portal/ua4-5-child-pricing-round-trip.spec.ts
Normal file
@@ -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);
|
||||||
|
});
|
||||||
37
e2e-ui/specs/portal/ua8-promo-drop-round-trip.spec.ts
Normal file
37
e2e-ui/specs/portal/ua8-promo-drop-round-trip.spec.ts
Normal file
@@ -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);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user