mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Merge pull request #959 from Tria-plc/tests
Adding exact reason why train is not available to users search results
This commit is contained in:
@@ -590,8 +590,22 @@ export class GuestBookingService {
|
|||||||
|
|
||||||
// Release the reservation using the SAME scope it was created with (global vs
|
// Release the reservation using the SAME scope it was created with (global vs
|
||||||
// schedule-scoped) — unblockSeat already correctly resets Seat.status for a global
|
// schedule-scoped) — unblockSeat already correctly resets Seat.status for a global
|
||||||
// block; reimplementing that here would risk missing that reset.
|
// block; reimplementing that here would risk missing that reset. This alone leaves a
|
||||||
|
// window where the seat has no SeatBlock, no SeatHold, and no JourneySegment (the
|
||||||
|
// latter is only created on payment success — see PaymentsService.createJourneySegments)
|
||||||
|
// — i.e. fully available to the public — the instant this returns, since confirmSeats()
|
||||||
|
// is a no-op with no existing hold to extend. holdSeats() immediately re-reserves the
|
||||||
|
// seat with the same createdBy segment-range metadata the search/hold-conflict checks
|
||||||
|
// already rely on (getSeatAvailabilityMap); confirmSeats() then extends that hold to the
|
||||||
|
// real payment deadline (same mechanism createGuestOneWayBooking uses), so the seat stays
|
||||||
|
// unavailable to everyone else until the passenger pays or the hold/booking expires.
|
||||||
await this.seatsService.unblockSeat(seatId, seatBlock.scheduleId ?? undefined);
|
await this.seatsService.unblockSeat(seatId, seatBlock.scheduleId ?? undefined);
|
||||||
|
await this.seatsService.holdSeats({
|
||||||
|
scheduleId: dto.scheduleId,
|
||||||
|
originStationId: dto.originStationId,
|
||||||
|
destinationStationId: dto.destinationStationId,
|
||||||
|
passengers: [{ passengerId: guestPassengerId, seatId }],
|
||||||
|
});
|
||||||
await this.seatsService.confirmSeats([seatId]);
|
await this.seatsService.confirmSeats([seatId]);
|
||||||
this.eventEmitter.emit('booking.created', { booking });
|
this.eventEmitter.emit('booking.created', { booking });
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { SegmentsService } from "../segments/segments.service";
|
|||||||
import { resolveCurrencyFromNationality } from "../fare-engine/fare-engine.dto";
|
import { resolveCurrencyFromNationality } from "../fare-engine/fare-engine.dto";
|
||||||
import { resolveCheckinCutoff } from "../../common/utils/checkin-cutoff.utils";
|
import { resolveCheckinCutoff } from "../../common/utils/checkin-cutoff.utils";
|
||||||
import { Currency, Prisma } from "@prisma/client";
|
import { Currency, Prisma } from "@prisma/client";
|
||||||
|
import { Passenger } from "@edr/types";
|
||||||
|
|
||||||
const POINTS_TO_MINOR = 10;
|
const POINTS_TO_MINOR = 10;
|
||||||
|
|
||||||
@@ -102,19 +103,23 @@ export class SearchService {
|
|||||||
const outbound = [...direct, ...transit];
|
const outbound = [...direct, ...transit];
|
||||||
|
|
||||||
if (outbound.length === 0 && dto.journeyType !== "ROUND_TRIP") {
|
if (outbound.length === 0 && dto.journeyType !== "ROUND_TRIP") {
|
||||||
const alternativesOutbound = await this.searchAlternatives(
|
const [alternativesOutbound, outboundReason] = await Promise.all([
|
||||||
dto.originStationId,
|
this.searchAlternatives(
|
||||||
dto.destinationStationId,
|
dto.originStationId,
|
||||||
dto.date,
|
dto.destinationStationId,
|
||||||
dto.adultCount,
|
dto.date,
|
||||||
dto.childCount,
|
dto.adultCount,
|
||||||
dto.nationality,
|
dto.childCount,
|
||||||
);
|
dto.nationality,
|
||||||
|
),
|
||||||
|
this.classifyEmptySearch(dto.originStationId, dto.destinationStationId, dto.date),
|
||||||
|
]);
|
||||||
return {
|
return {
|
||||||
journeyType: "ONE_WAY",
|
journeyType: "ONE_WAY",
|
||||||
outbound: [],
|
outbound: [],
|
||||||
alternativeOutbound: alternativesOutbound,
|
alternativeOutbound: alternativesOutbound,
|
||||||
requestedDate: dto.date,
|
requestedDate: dto.date,
|
||||||
|
outboundReason,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -157,7 +162,7 @@ export class SearchService {
|
|||||||
const returnDate = dto.returnDate ?? dto.date;
|
const returnDate = dto.returnDate ?? dto.date;
|
||||||
|
|
||||||
if (outbound.length === 0 || inbound.length === 0) {
|
if (outbound.length === 0 || inbound.length === 0) {
|
||||||
const [alternativeOutbound, alternativeInbound] = await Promise.all([
|
const [alternativeOutbound, alternativeInbound, outboundReason, inboundReason] = await Promise.all([
|
||||||
outbound.length === 0
|
outbound.length === 0
|
||||||
? this.searchAlternatives(
|
? this.searchAlternatives(
|
||||||
dto.originStationId,
|
dto.originStationId,
|
||||||
@@ -178,6 +183,12 @@ export class SearchService {
|
|||||||
dto.nationality,
|
dto.nationality,
|
||||||
)
|
)
|
||||||
: Promise.resolve([]),
|
: Promise.resolve([]),
|
||||||
|
outbound.length === 0
|
||||||
|
? this.classifyEmptySearch(dto.originStationId, dto.destinationStationId, dto.date)
|
||||||
|
: Promise.resolve(undefined),
|
||||||
|
inbound.length === 0
|
||||||
|
? this.classifyEmptySearch(dto.destinationStationId, dto.originStationId, returnDate)
|
||||||
|
: Promise.resolve(undefined),
|
||||||
]);
|
]);
|
||||||
return {
|
return {
|
||||||
journeyType: "ROUND_TRIP",
|
journeyType: "ROUND_TRIP",
|
||||||
@@ -187,6 +198,8 @@ export class SearchService {
|
|||||||
alternativeInbound,
|
alternativeInbound,
|
||||||
requestedDate: dto.date,
|
requestedDate: dto.date,
|
||||||
requestedReturnDate: returnDate,
|
requestedReturnDate: returnDate,
|
||||||
|
outboundReason,
|
||||||
|
inboundReason,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -344,6 +357,101 @@ export class SearchService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Only called when searchSchedules/searchTransitOptions found zero bookable results for a
|
||||||
|
* leg — classifies WHY, cheaply, by re-querying without the filters that already excluded
|
||||||
|
* everything. Priority order (most specific/actionable first): a station pair EDR never
|
||||||
|
* connects at all beats "nothing on this exact date", which beats "something exists but
|
||||||
|
* every option is cancelled/package-only/past cutoff/full" — see SearchEmptyReasonCode.
|
||||||
|
*/
|
||||||
|
private async classifyEmptySearch(
|
||||||
|
originStationId: string,
|
||||||
|
destinationStationId: string,
|
||||||
|
dateStr: string,
|
||||||
|
): Promise<Passenger.ISearchEmptyReason> {
|
||||||
|
const [origin, destination] = await Promise.all([
|
||||||
|
this.prisma.station.findUnique({ where: { id: originStationId }, select: { name: true } }),
|
||||||
|
this.prisma.station.findUnique({ where: { id: destinationStationId }, select: { name: true } }),
|
||||||
|
]);
|
||||||
|
const originStationName = origin?.name ?? "the origin station";
|
||||||
|
const destinationStationName = destination?.name ?? "the destination station";
|
||||||
|
const withCode = (code: Passenger.SearchEmptyReasonCode) => ({
|
||||||
|
code,
|
||||||
|
originStationName,
|
||||||
|
destinationStationName,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 1. Does any active route connect these two stations, in this direction, at all —
|
||||||
|
// ignoring date entirely?
|
||||||
|
const candidateRoutes = await this.prisma.route.findMany({
|
||||||
|
where: { active: true, stops: { some: { stationId: originStationId } } },
|
||||||
|
select: { stops: { select: { stationId: true, sequence: true } } },
|
||||||
|
});
|
||||||
|
const routeExists = candidateRoutes.some((r) => {
|
||||||
|
const o = r.stops.find((s) => s.stationId === originStationId);
|
||||||
|
const d = r.stops.find((s) => s.stationId === destinationStationId);
|
||||||
|
return !!o && !!d && o.sequence < d.sequence;
|
||||||
|
});
|
||||||
|
if (!routeExists) return withCode(Passenger.SearchEmptyReasonCode.NoRoute);
|
||||||
|
|
||||||
|
// 2. A route exists — is there any schedule at all on the requested date for this pair
|
||||||
|
// (regardless of status/package/coach/cutoff — those are checked next)?
|
||||||
|
const [y, m, d] = dateStr.split("-").map(Number);
|
||||||
|
const date = new Date(
|
||||||
|
`${String(y)}-${String(m).padStart(2, "0")}-${String(d).padStart(2, "0")}T00:00:00+03:00`,
|
||||||
|
);
|
||||||
|
const nextDay = new Date(
|
||||||
|
`${String(y)}-${String(m).padStart(2, "0")}-${String(d + 1).padStart(2, "0")}T00:00:00+03:00`,
|
||||||
|
);
|
||||||
|
const dayCandidates = await this.prisma.trainSchedule.findMany({
|
||||||
|
where: { departureAt: { gte: date, lt: nextDay }, stopTimes: { some: { stationId: originStationId } } },
|
||||||
|
select: {
|
||||||
|
status: true,
|
||||||
|
isPackageOnly: true,
|
||||||
|
departureAt: true,
|
||||||
|
route: {
|
||||||
|
select: { checkinMinutesBefore: true, stops: { select: { stationId: true, checkinMinutesBefore: true } } },
|
||||||
|
},
|
||||||
|
stopTimes: { select: { stationId: true, sequence: true, plannedArrivalAt: true, plannedDepartureAt: true } },
|
||||||
|
coachAssignments: { select: { id: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const sameDayForPair = dayCandidates.filter((s) => {
|
||||||
|
const o = s.stopTimes.find((st) => st.stationId === originStationId);
|
||||||
|
const dst = s.stopTimes.find((st) => st.stationId === destinationStationId);
|
||||||
|
return !!o && !!dst && o.sequence < dst.sequence;
|
||||||
|
});
|
||||||
|
if (sameDayForPair.length === 0) return withCode(Passenger.SearchEmptyReasonCode.NoScheduleOnDate);
|
||||||
|
|
||||||
|
// 3. Schedules exist that date — narrow to ones that would otherwise be bookable
|
||||||
|
// (right status, not package-only, has at least one coach assigned).
|
||||||
|
const bookable = sameDayForPair.filter(
|
||||||
|
(s) =>
|
||||||
|
(["SCHEDULED", "BOARDING", "EN_ROUTE"] as string[]).includes(s.status) &&
|
||||||
|
!s.isPackageOnly &&
|
||||||
|
s.coachAssignments.length > 0,
|
||||||
|
);
|
||||||
|
if (bookable.length === 0) {
|
||||||
|
if (sameDayForPair.every((s) => s.status === "CANCELLED"))
|
||||||
|
return withCode(Passenger.SearchEmptyReasonCode.Cancelled);
|
||||||
|
if (sameDayForPair.every((s) => s.isPackageOnly))
|
||||||
|
return withCode(Passenger.SearchEmptyReasonCode.PackageOnly);
|
||||||
|
return withCode(Passenger.SearchEmptyReasonCode.NoScheduleOnDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Bookable schedules exist — did every one of them already pass its check-in cutoff
|
||||||
|
// for this origin stop?
|
||||||
|
const allCutoffPassed = bookable.every((s) => {
|
||||||
|
const originStop = s.stopTimes.find((st) => st.stationId === originStationId) ?? null;
|
||||||
|
return Date.now() >= resolveCheckinCutoff(s, originStop, originStationId).cutoffAt.getTime();
|
||||||
|
});
|
||||||
|
if (allCutoffPassed) return withCode(Passenger.SearchEmptyReasonCode.CheckinClosed);
|
||||||
|
|
||||||
|
// 5. A bookable, still-open schedule exists for this pair/date — the only remaining reason
|
||||||
|
// searchSchedules dropped it is zero/insufficient seat availability.
|
||||||
|
return withCode(Passenger.SearchEmptyReasonCode.FullyBooked);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Transit search ─────────────────────────────────────────────────────────
|
// ── Transit search ─────────────────────────────────────────────────────────
|
||||||
private readonly MIN_CONNECTION_MINUTES = 30;
|
private readonly MIN_CONNECTION_MINUTES = 30;
|
||||||
private readonly MAX_CONNECTION_MINUTES = 360;
|
private readonly MAX_CONNECTION_MINUTES = 360;
|
||||||
|
|||||||
@@ -294,6 +294,37 @@ describe("Reserve seat — issue booking (STAFF / PASSENGER)", () => {
|
|||||||
expect(byToken.schedule.origin.id).toBe(IDS.stationA);
|
expect(byToken.schedule.origin.id).toBe(IDS.stationA);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("PASSENGER path: the seat stays reserved (not publicly available) after the payment link is sent", async () => {
|
||||||
|
// Regression for: unblockSeat() released the reservation's SeatBlock and confirmSeats()
|
||||||
|
// was a no-op with no SeatHold to extend, so the seat had no SeatBlock, no SeatHold, and
|
||||||
|
// no JourneySegment (only created on payment success) the instant the payment link went
|
||||||
|
// out — fully bookable by the general public before the traveler had even paid.
|
||||||
|
await resetAndSeedCore(harness.prisma);
|
||||||
|
const dep = new Date(Date.now() + 3 * 60 * 60_000);
|
||||||
|
const arr = new Date(dep.getTime() + 100 * 60_000);
|
||||||
|
const { schedule, seats } = await createTestSchedule({ trainNumber: `RES-STAYS-BLOCKED-${Date.now()}`, departureAt: dep, arrivalAt: arr });
|
||||||
|
|
||||||
|
await seatsService.blockSeat(seats[0].id, "Reserved pending payment", schedule.id);
|
||||||
|
|
||||||
|
const result: any = await guestBookingService.issueBookingFromReservation(
|
||||||
|
seats[0].id,
|
||||||
|
baseDto({ scheduleId: schedule.id, bookingKind: ReservationBookingKind.PASSENGER, phone: "+253771234567" }) as any,
|
||||||
|
"staff-user-4",
|
||||||
|
);
|
||||||
|
expect(result.booking.status).toBe("PENDING_PAYMENT");
|
||||||
|
|
||||||
|
// A member of the public trying to hold the exact same seat/leg must be rejected —
|
||||||
|
// proves the seat is covered by a real SeatHold (or equivalent), not silently free.
|
||||||
|
await expect(
|
||||||
|
seatsService.holdSeats({
|
||||||
|
scheduleId: schedule.id,
|
||||||
|
originStationId: IDS.stationA,
|
||||||
|
destinationStationId: IDS.stationB,
|
||||||
|
passengers: [{ passengerId: "someone-else", seatId: seats[0].id }],
|
||||||
|
} as any),
|
||||||
|
).rejects.toThrow(/already (held|booked)/i);
|
||||||
|
});
|
||||||
|
|
||||||
it("requires a phone number for a PASSENGER booking", async () => {
|
it("requires a phone number for a PASSENGER booking", async () => {
|
||||||
await resetAndSeedCore(harness.prisma);
|
await resetAndSeedCore(harness.prisma);
|
||||||
const dep = new Date(Date.now() + 3 * 60 * 60_000);
|
const dep = new Date(Date.now() + 3 * 60 * 60_000);
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import { formatTime, getTimePeriod, toZonedDate } from "@/utils/format";
|
|||||||
import { formatFare } from "@/utils/fare-utils";
|
import { formatFare } from "@/utils/fare-utils";
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import AlternativeDatesCalendar from "@/components/AlternativeDatesCalendar";
|
import AlternativeDatesCalendar from "@/components/AlternativeDatesCalendar";
|
||||||
|
import { Passenger } from "@edr/types";
|
||||||
|
|
||||||
// Shared by the compact schedule card's coach-type badges and the "Choose Your Coach"
|
// Shared by the compact schedule card's coach-type badges and the "Choose Your Coach"
|
||||||
// modal, so both pick the same icon for a given coach type name.
|
// modal, so both pick the same icon for a given coach type name.
|
||||||
@@ -35,6 +36,73 @@ const getCoachIcon = (typeName: string) => {
|
|||||||
return Armchair;
|
return Armchair;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function formatSearchDate(dateStr: string): string {
|
||||||
|
if (!dateStr) return "your selected date";
|
||||||
|
try {
|
||||||
|
return format(new Date(`${dateStr}T00:00:00`), "MMM d, yyyy");
|
||||||
|
} catch {
|
||||||
|
return dateStr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Turns the /search response's outboundReason/inboundReason (absent on older responses,
|
||||||
|
// or when the emptiness cause couldn't be classified) into copy for the empty-state cards
|
||||||
|
// below. `showAlternatives` gates whether an alternative-dates calendar makes sense — a
|
||||||
|
// station pair EDR never serves can't be fixed by picking a different date.
|
||||||
|
function emptyReasonCopy(
|
||||||
|
reason: Passenger.ISearchEmptyReason | undefined,
|
||||||
|
dateStr: string,
|
||||||
|
): { title: string; message: string; showAlternatives: boolean } {
|
||||||
|
const date = formatSearchDate(dateStr);
|
||||||
|
if (!reason) {
|
||||||
|
return {
|
||||||
|
title: "No trains available",
|
||||||
|
message: `No trains available on ${date}. Please choose another date below.`,
|
||||||
|
showAlternatives: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const { code, originStationName, destinationStationName } = reason;
|
||||||
|
switch (code) {
|
||||||
|
case Passenger.SearchEmptyReasonCode.NoRoute:
|
||||||
|
return {
|
||||||
|
title: "Route not available",
|
||||||
|
message: `EDR doesn't currently run trains between ${originStationName} and ${destinationStationName}.`,
|
||||||
|
showAlternatives: false,
|
||||||
|
};
|
||||||
|
case Passenger.SearchEmptyReasonCode.FullyBooked:
|
||||||
|
return {
|
||||||
|
title: "Fully booked",
|
||||||
|
message: `This train is fully booked on ${date}. Please choose another date below.`,
|
||||||
|
showAlternatives: true,
|
||||||
|
};
|
||||||
|
case Passenger.SearchEmptyReasonCode.Cancelled:
|
||||||
|
return {
|
||||||
|
title: "Departure cancelled",
|
||||||
|
message: `The ${date} departure between ${originStationName} and ${destinationStationName} was cancelled. Please choose another date below.`,
|
||||||
|
showAlternatives: true,
|
||||||
|
};
|
||||||
|
case Passenger.SearchEmptyReasonCode.CheckinClosed:
|
||||||
|
return {
|
||||||
|
title: "Booking closed for today",
|
||||||
|
message: `Booking for the ${date} departure has closed. Please choose another date below.`,
|
||||||
|
showAlternatives: true,
|
||||||
|
};
|
||||||
|
case Passenger.SearchEmptyReasonCode.PackageOnly:
|
||||||
|
return {
|
||||||
|
title: "Only a travel package is available",
|
||||||
|
message: `The only train between ${originStationName} and ${destinationStationName} on ${date} is bookable as part of a travel package, not as a standalone ticket. Please choose another date below.`,
|
||||||
|
showAlternatives: true,
|
||||||
|
};
|
||||||
|
case Passenger.SearchEmptyReasonCode.NoScheduleOnDate:
|
||||||
|
default:
|
||||||
|
return {
|
||||||
|
title: "No trains available",
|
||||||
|
message: `No trains available on ${date}. Please choose another date below.`,
|
||||||
|
showAlternatives: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export default function ResultsPage() {
|
export default function ResultsPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
@@ -1097,18 +1165,35 @@ export default function ResultsPage() {
|
|||||||
(results?.alternativeOutbound || []).length === 0 &&
|
(results?.alternativeOutbound || []).length === 0 &&
|
||||||
(results?.alternativeInbound || []).length === 0;
|
(results?.alternativeInbound || []).length === 0;
|
||||||
if (isRoundTripNoResults) {
|
if (isRoundTripNoResults) {
|
||||||
|
const outboundCopy = emptyReasonCopy(results?.outboundReason, searchData.date);
|
||||||
|
const inboundCopy = emptyReasonCopy(results?.inboundReason, searchData.returnDate || searchData.date);
|
||||||
|
const sameReason =
|
||||||
|
!!results?.outboundReason &&
|
||||||
|
results?.outboundReason?.code === results?.inboundReason?.code;
|
||||||
return (
|
return (
|
||||||
<div className="booking-page">
|
<div className="booking-page">
|
||||||
<div className="container mx-auto px-4">
|
<div className="container mx-auto px-4">
|
||||||
<div className="max-w-6xl mx-auto">
|
<div className="max-w-6xl mx-auto">
|
||||||
<div className="flex items-center justify-between gap-4 px-4 py-3 rounded-xl bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800">
|
<div className="flex flex-col gap-2 px-4 py-3 rounded-xl bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800">
|
||||||
<div className="flex items-center gap-2.5 text-sm text-red-800 dark:text-red-300">
|
<div className="flex items-center justify-between gap-4">
|
||||||
<Calendar className="w-4 h-4 flex-shrink-0" />
|
<div className="flex items-center gap-2.5 text-sm text-red-800 dark:text-red-300">
|
||||||
<span>No trains found for your selected dates or route.</span>
|
<Calendar className="w-4 h-4 flex-shrink-0" />
|
||||||
|
<span>
|
||||||
|
{sameReason
|
||||||
|
? outboundCopy.message
|
||||||
|
: "No trains found for your selected dates or route."}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<button onClick={() => router.push(buildSearchUrl())} className="text-sm font-semibold text-primary hover:underline flex-shrink-0">
|
||||||
|
Modify search
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<button onClick={() => router.push(buildSearchUrl())} className="text-sm font-semibold text-primary hover:underline flex-shrink-0">
|
{!sameReason && (results?.outboundReason || results?.inboundReason) && (
|
||||||
Modify search
|
<div className="pl-6 space-y-1 text-sm text-red-800 dark:text-red-300">
|
||||||
</button>
|
<p>Outbound: {outboundCopy.message}</p>
|
||||||
|
<p>Return: {inboundCopy.message}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1125,6 +1210,8 @@ export default function ResultsPage() {
|
|||||||
const bothCalendarsAvailable = alternativeOutbound.length > 0 && alternativeInbound.length > 0;
|
const bothCalendarsAvailable = alternativeOutbound.length > 0 && alternativeInbound.length > 0;
|
||||||
const outboundValue = pendingOutboundDate ?? (searchData.date ? new Date(`${searchData.date}T00:00:00`) : undefined);
|
const outboundValue = pendingOutboundDate ?? (searchData.date ? new Date(`${searchData.date}T00:00:00`) : undefined);
|
||||||
const inboundValue = pendingInboundDate ?? (searchData.returnDate ? new Date(`${searchData.returnDate}T00:00:00`) : undefined);
|
const inboundValue = pendingInboundDate ?? (searchData.returnDate ? new Date(`${searchData.returnDate}T00:00:00`) : undefined);
|
||||||
|
const outboundCopy = emptyReasonCopy(results?.outboundReason, searchData.date);
|
||||||
|
const inboundCopy = emptyReasonCopy(results?.inboundReason, searchData.returnDate || searchData.date);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="booking-page">
|
<div className="booking-page">
|
||||||
@@ -1137,50 +1224,62 @@ export default function ResultsPage() {
|
|||||||
<h2 className="text-xl font-bold text-gray-900 dark:text-white mb-2">
|
<h2 className="text-xl font-bold text-gray-900 dark:text-white mb-2">
|
||||||
No trains available
|
No trains available
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-6">
|
<div className="space-y-5 text-left">
|
||||||
No trains available on your selected dates. Please choose another
|
<div>
|
||||||
date below.
|
<p className="text-sm font-semibold text-gray-800 dark:text-gray-200 mb-1">
|
||||||
</p>
|
Outbound — {outboundCopy.title}
|
||||||
<div className="space-y-4 text-left">
|
|
||||||
{alternativeOutbound.length > 0 ? (
|
|
||||||
<AlternativeDatesCalendar
|
|
||||||
label="Outbound date"
|
|
||||||
alternatives={alternativeOutbound}
|
|
||||||
value={outboundValue}
|
|
||||||
minDate={new Date()}
|
|
||||||
onChange={(date) => {
|
|
||||||
if (!bothCalendarsAvailable) {
|
|
||||||
pushResultsWithDates({ date: format(date, "yyyy-MM-dd") });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setPendingOutboundDate(date);
|
|
||||||
if (pendingInboundDate && pendingInboundDate < date) setPendingInboundDate(undefined);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
|
||||||
No alternative outbound dates found nearby.
|
|
||||||
</p>
|
</p>
|
||||||
)}
|
<p className="text-sm text-gray-600 dark:text-gray-400 mb-3">{outboundCopy.message}</p>
|
||||||
{alternativeInbound.length > 0 ? (
|
{outboundCopy.showAlternatives ? (
|
||||||
<AlternativeDatesCalendar
|
alternativeOutbound.length > 0 ? (
|
||||||
label="Return date"
|
<AlternativeDatesCalendar
|
||||||
alternatives={alternativeInbound}
|
label="Outbound date"
|
||||||
value={inboundValue}
|
alternatives={alternativeOutbound}
|
||||||
minDate={pendingOutboundDate ?? (searchData.date ? new Date(`${searchData.date}T00:00:00`) : new Date())}
|
value={outboundValue}
|
||||||
onChange={(date) => {
|
minDate={new Date()}
|
||||||
if (!bothCalendarsAvailable) {
|
onChange={(date) => {
|
||||||
pushResultsWithDates({ returnDate: format(date, "yyyy-MM-dd") });
|
if (!bothCalendarsAvailable) {
|
||||||
return;
|
pushResultsWithDates({ date: format(date, "yyyy-MM-dd") });
|
||||||
}
|
return;
|
||||||
setPendingInboundDate(date);
|
}
|
||||||
}}
|
setPendingOutboundDate(date);
|
||||||
/>
|
if (pendingInboundDate && pendingInboundDate < date) setPendingInboundDate(undefined);
|
||||||
) : (
|
}}
|
||||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
/>
|
||||||
No alternative return dates found nearby.
|
) : (
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
No alternative outbound dates found nearby.
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-semibold text-gray-800 dark:text-gray-200 mb-1">
|
||||||
|
Return — {inboundCopy.title}
|
||||||
</p>
|
</p>
|
||||||
)}
|
<p className="text-sm text-gray-600 dark:text-gray-400 mb-3">{inboundCopy.message}</p>
|
||||||
|
{inboundCopy.showAlternatives ? (
|
||||||
|
alternativeInbound.length > 0 ? (
|
||||||
|
<AlternativeDatesCalendar
|
||||||
|
label="Return date"
|
||||||
|
alternatives={alternativeInbound}
|
||||||
|
value={inboundValue}
|
||||||
|
minDate={pendingOutboundDate ?? (searchData.date ? new Date(`${searchData.date}T00:00:00`) : new Date())}
|
||||||
|
onChange={(date) => {
|
||||||
|
if (!bothCalendarsAvailable) {
|
||||||
|
pushResultsWithDates({ returnDate: format(date, "yyyy-MM-dd") });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setPendingInboundDate(date);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
No alternative return dates found nearby.
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{bothCalendarsAvailable && pendingOutboundDate && !pendingInboundDate && (
|
{bothCalendarsAvailable && pendingOutboundDate && !pendingInboundDate && (
|
||||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-4">
|
<p className="text-xs text-gray-500 dark:text-gray-400 mt-4">
|
||||||
@@ -1201,6 +1300,10 @@ export default function ResultsPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (isOneWayNoOutbound) {
|
if (isOneWayNoOutbound) {
|
||||||
|
const { title, message, showAlternatives } = emptyReasonCopy(
|
||||||
|
results?.outboundReason,
|
||||||
|
searchData.date,
|
||||||
|
);
|
||||||
return (
|
return (
|
||||||
<div className="booking-page">
|
<div className="booking-page">
|
||||||
{renderClassModal()}
|
{renderClassModal()}
|
||||||
@@ -1211,26 +1314,34 @@ export default function ResultsPage() {
|
|||||||
<Calendar className="w-8 h-8 text-red-500 dark:text-red-400" />
|
<Calendar className="w-8 h-8 text-red-500 dark:text-red-400" />
|
||||||
</div>
|
</div>
|
||||||
<h2 className="text-xl font-bold text-gray-900 dark:text-white mb-2">
|
<h2 className="text-xl font-bold text-gray-900 dark:text-white mb-2">
|
||||||
No trains available
|
{title}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-6">
|
<p className="text-sm text-gray-600 dark:text-gray-400 mb-6">
|
||||||
No trains available on your selected date. Please choose another
|
{message}
|
||||||
date below.
|
|
||||||
</p>
|
</p>
|
||||||
{alternativeOutbound.length > 0 ? (
|
{showAlternatives ? (
|
||||||
<AlternativeDatesCalendar
|
alternativeOutbound.length > 0 ? (
|
||||||
alternatives={alternativeOutbound}
|
<AlternativeDatesCalendar
|
||||||
value={searchData.date ? new Date(`${searchData.date}T00:00:00`) : undefined}
|
alternatives={alternativeOutbound}
|
||||||
minDate={new Date()}
|
value={searchData.date ? new Date(`${searchData.date}T00:00:00`) : undefined}
|
||||||
onChange={(date) => pushResultsWithDates({ date: format(date, "yyyy-MM-dd") })}
|
minDate={new Date()}
|
||||||
/>
|
onChange={(date) => pushResultsWithDates({ date: format(date, "yyyy-MM-dd") })}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={() => router.push(buildSearchUrl())}
|
||||||
|
className="btn-primary inline-flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<Calendar className="w-4 h-4" />
|
||||||
|
Change Date
|
||||||
|
</button>
|
||||||
|
)
|
||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
onClick={() => router.push(buildSearchUrl())}
|
onClick={() => router.push(buildSearchUrl())}
|
||||||
className="btn-primary inline-flex items-center gap-2"
|
className="btn-primary inline-flex items-center gap-2"
|
||||||
>
|
>
|
||||||
<Calendar className="w-4 h-4" />
|
Modify search
|
||||||
Change Date
|
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
122
e2e-ui/specs/portal/search-empty-reasons.spec.ts
Normal file
122
e2e-ui/specs/portal/search-empty-reasons.spec.ts
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
import { test, expect } from "@playwright/test";
|
||||||
|
import { API_URL, STATIONS, resultsUrl, sampleDepartDate, staffToken } from "../../fixtures/data";
|
||||||
|
import { UI_IDS } from "../../../apps/edr-passenger-api/test/fixtures/seed-ui";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Portal search empty-state — asserts /search's outboundReason drives a message specific to WHY
|
||||||
|
* zero results came back, instead of the old one-size-fits-all "No trains available" copy (see
|
||||||
|
* SearchService.classifyEmptySearch + results/page.tsx's emptyReasonCopy). Each test isolates its
|
||||||
|
* own route/station/schedule (never mutates the shared seed fixtures) so it can run alongside the
|
||||||
|
* rest of the portal suite without disturbing other specs' assumptions about the seeded trip.
|
||||||
|
*/
|
||||||
|
|
||||||
|
function auth() {
|
||||||
|
return { Authorization: `Bearer ${staffToken()}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Days-from-now at 06:00Z, matching seed-ui's sampleDepartAt() convention (same-day in Addis TZ). */
|
||||||
|
function daysFromNowDate(days: number): string {
|
||||||
|
const d = new Date();
|
||||||
|
d.setUTCDate(d.getUTCDate() + days);
|
||||||
|
d.setUTCHours(6, 0, 0, 0);
|
||||||
|
return d.toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** resultsUrl() hardcodes STATIONS.A→C and sampleDepartDate() — these tests need other pairs/dates. */
|
||||||
|
function customResultsUrl(originId: string, destinationId: string, date: string, adults = 1) {
|
||||||
|
const p = new URLSearchParams({
|
||||||
|
origin: originId,
|
||||||
|
destination: destinationId,
|
||||||
|
date,
|
||||||
|
tripType: "ONE_WAY",
|
||||||
|
adults: String(adults),
|
||||||
|
children: "0",
|
||||||
|
nationality: "ETHIOPIAN",
|
||||||
|
});
|
||||||
|
return `/booking/results?${p.toString()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
test("empty-state: fully booked shows a fully-booked message, not a generic one", async ({ page }) => {
|
||||||
|
// The seeded trip (UI_IDS.schedule) has 48 seats total — asking for far more than that guarantees
|
||||||
|
// zero seat classes satisfy the party size, without needing to actually consume real seats.
|
||||||
|
await page.goto(resultsUrl({ adults: 500 }), { waitUntil: "domcontentloaded" });
|
||||||
|
|
||||||
|
await expect(page.getByRole("heading", { name: "Fully booked" })).toBeVisible({ timeout: 20_000 });
|
||||||
|
await expect(page.getByText(/fully booked on/i)).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("empty-state: no schedule that day shows the generic no-trains message", async ({ page }) => {
|
||||||
|
// ROUTE_ID/STATIONS A→C is a real, active route — just pick a date far enough out that no
|
||||||
|
// schedule was ever created for it.
|
||||||
|
const farDate = daysFromNowDate(300);
|
||||||
|
await page.goto(customResultsUrl(STATIONS.A, STATIONS.C, farDate), { waitUntil: "domcontentloaded" });
|
||||||
|
|
||||||
|
await expect(page.getByRole("heading", { name: "No trains available" })).toBeVisible({ timeout: 20_000 });
|
||||||
|
await expect(page.getByText(/no trains available on/i)).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("empty-state: a cancelled departure says so, not \"no trains available\"", async ({ page, request }) => {
|
||||||
|
const routeRes = await request.post(`${API_URL}/routes`, {
|
||||||
|
headers: auth(),
|
||||||
|
data: {
|
||||||
|
code: `E2E-EMPTY-CANCELLED-${Date.now()}`,
|
||||||
|
name: "E2E Empty Cancelled Route",
|
||||||
|
effectiveFrom: "2020-01-01T00:00:00Z",
|
||||||
|
stops: [
|
||||||
|
{ stationId: STATIONS.A, sequence: 1, distanceKm: 0 },
|
||||||
|
{ stationId: STATIONS.C, sequence: 2, distanceKm: 100 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(routeRes.ok()).toBeTruthy();
|
||||||
|
const route = (await routeRes.json())?.data;
|
||||||
|
|
||||||
|
const templateRes = await request.put(`${API_URL}/routes/${route.id}/coaches`, {
|
||||||
|
headers: auth(),
|
||||||
|
data: { coaches: [{ coachId: UI_IDS.coach, positionNumber: 1 }] },
|
||||||
|
});
|
||||||
|
expect(templateRes.ok()).toBeTruthy();
|
||||||
|
|
||||||
|
const date = daysFromNowDate(60);
|
||||||
|
const dep = new Date(`${date}T06:00:00Z`);
|
||||||
|
const arr = new Date(dep.getTime() + 4 * 3600_000);
|
||||||
|
const scheduleRes = await request.post(`${API_URL}/schedules`, {
|
||||||
|
headers: auth(),
|
||||||
|
data: { trainId: UI_IDS.train, routeId: route.id, departureAt: dep.toISOString(), arrivalAt: arr.toISOString() },
|
||||||
|
});
|
||||||
|
expect(scheduleRes.ok()).toBeTruthy();
|
||||||
|
const schedule = (await scheduleRes.json())?.data;
|
||||||
|
|
||||||
|
const cancelRes = await request.patch(`${API_URL}/schedules/${schedule.id}/status`, {
|
||||||
|
headers: auth(),
|
||||||
|
data: { status: "CANCELLED" },
|
||||||
|
});
|
||||||
|
expect(cancelRes.ok()).toBeTruthy();
|
||||||
|
|
||||||
|
await page.goto(customResultsUrl(STATIONS.A, STATIONS.C, date), { waitUntil: "domcontentloaded" });
|
||||||
|
|
||||||
|
await expect(page.getByText("Departure cancelled")).toBeVisible({ timeout: 20_000 });
|
||||||
|
await expect(page.getByText(/was cancelled/i)).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("empty-state: an unconnected station pair says EDR doesn't run there, with no date picker", async ({
|
||||||
|
page,
|
||||||
|
request,
|
||||||
|
}) => {
|
||||||
|
const stationRes = await request.post(`${API_URL}/stations`, {
|
||||||
|
headers: auth(),
|
||||||
|
data: { code: `E2E-ISO-${Date.now() % 100000}`, name: "E2E Isolated Station", city: "Nowhere" },
|
||||||
|
});
|
||||||
|
expect(stationRes.ok()).toBeTruthy();
|
||||||
|
const isolatedStation = (await stationRes.json())?.data;
|
||||||
|
|
||||||
|
await page.goto(customResultsUrl(STATIONS.A, isolatedStation.id, sampleDepartDate()), {
|
||||||
|
waitUntil: "domcontentloaded",
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(page.getByText("Route not available")).toBeVisible({ timeout: 20_000 });
|
||||||
|
await expect(page.getByText(/doesn't currently run trains between/i)).toBeVisible();
|
||||||
|
// NO_ROUTE is not fixable by picking another date — no calendar/"Change Date" affordance.
|
||||||
|
await expect(page.getByText(/change date/i)).toHaveCount(0);
|
||||||
|
await expect(page.getByRole("button", { name: /modify search/i })).toBeVisible();
|
||||||
|
});
|
||||||
@@ -34,6 +34,34 @@ export enum ScheduleStatus {
|
|||||||
Delayed = "DELAYED",
|
Delayed = "DELAYED",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Why a /search leg (outbound or inbound) came back with zero bookable schedules.
|
||||||
|
* Priority order applied by the API when classifying: NoRoute > NoScheduleOnDate >
|
||||||
|
* Cancelled > PackageOnly > CheckinClosed > FullyBooked (see search.service.ts
|
||||||
|
* classifyEmptySearch). The frontend uses this to show a specific empty-state
|
||||||
|
* message instead of a generic "no trains available".
|
||||||
|
*/
|
||||||
|
export enum SearchEmptyReasonCode {
|
||||||
|
/** No route (in either direction) ever connects these two stations. */
|
||||||
|
NoRoute = "NO_ROUTE",
|
||||||
|
/** A route connects these stations, but no schedule lands on the requested date at all. */
|
||||||
|
NoScheduleOnDate = "NO_SCHEDULE_ON_DATE",
|
||||||
|
/** Every schedule for this pair on this date was cancelled. */
|
||||||
|
Cancelled = "CANCELLED",
|
||||||
|
/** Every schedule for this pair on this date is package-only (excluded from ticket search). */
|
||||||
|
PackageOnly = "PACKAGE_ONLY",
|
||||||
|
/** A bookable schedule exists, but its check-in cutoff has already passed for every option. */
|
||||||
|
CheckinClosed = "CHECKIN_CLOSED",
|
||||||
|
/** A bookable, still-open schedule exists but has no seats left for the requested party. */
|
||||||
|
FullyBooked = "FULLY_BOOKED",
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ISearchEmptyReason {
|
||||||
|
code: SearchEmptyReasonCode;
|
||||||
|
originStationName: string;
|
||||||
|
destinationStationName: string;
|
||||||
|
}
|
||||||
|
|
||||||
export enum PaymentStatus {
|
export enum PaymentStatus {
|
||||||
Pending = "PENDING",
|
Pending = "PENDING",
|
||||||
Paid = "PAID",
|
Paid = "PAID",
|
||||||
|
|||||||
Reference in New Issue
Block a user