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
|
||||
// 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.holdSeats({
|
||||
scheduleId: dto.scheduleId,
|
||||
originStationId: dto.originStationId,
|
||||
destinationStationId: dto.destinationStationId,
|
||||
passengers: [{ passengerId: guestPassengerId, seatId }],
|
||||
});
|
||||
await this.seatsService.confirmSeats([seatId]);
|
||||
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 { resolveCheckinCutoff } from "../../common/utils/checkin-cutoff.utils";
|
||||
import { Currency, Prisma } from "@prisma/client";
|
||||
import { Passenger } from "@edr/types";
|
||||
|
||||
const POINTS_TO_MINOR = 10;
|
||||
|
||||
@@ -102,19 +103,23 @@ export class SearchService {
|
||||
const outbound = [...direct, ...transit];
|
||||
|
||||
if (outbound.length === 0 && dto.journeyType !== "ROUND_TRIP") {
|
||||
const alternativesOutbound = await this.searchAlternatives(
|
||||
dto.originStationId,
|
||||
dto.destinationStationId,
|
||||
dto.date,
|
||||
dto.adultCount,
|
||||
dto.childCount,
|
||||
dto.nationality,
|
||||
);
|
||||
const [alternativesOutbound, outboundReason] = await Promise.all([
|
||||
this.searchAlternatives(
|
||||
dto.originStationId,
|
||||
dto.destinationStationId,
|
||||
dto.date,
|
||||
dto.adultCount,
|
||||
dto.childCount,
|
||||
dto.nationality,
|
||||
),
|
||||
this.classifyEmptySearch(dto.originStationId, dto.destinationStationId, dto.date),
|
||||
]);
|
||||
return {
|
||||
journeyType: "ONE_WAY",
|
||||
outbound: [],
|
||||
alternativeOutbound: alternativesOutbound,
|
||||
requestedDate: dto.date,
|
||||
outboundReason,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -157,7 +162,7 @@ export class SearchService {
|
||||
const returnDate = dto.returnDate ?? dto.date;
|
||||
|
||||
if (outbound.length === 0 || inbound.length === 0) {
|
||||
const [alternativeOutbound, alternativeInbound] = await Promise.all([
|
||||
const [alternativeOutbound, alternativeInbound, outboundReason, inboundReason] = await Promise.all([
|
||||
outbound.length === 0
|
||||
? this.searchAlternatives(
|
||||
dto.originStationId,
|
||||
@@ -178,6 +183,12 @@ export class SearchService {
|
||||
dto.nationality,
|
||||
)
|
||||
: 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 {
|
||||
journeyType: "ROUND_TRIP",
|
||||
@@ -187,6 +198,8 @@ export class SearchService {
|
||||
alternativeInbound,
|
||||
requestedDate: dto.date,
|
||||
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 ─────────────────────────────────────────────────────────
|
||||
private readonly MIN_CONNECTION_MINUTES = 30;
|
||||
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);
|
||||
});
|
||||
|
||||
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 () => {
|
||||
await resetAndSeedCore(harness.prisma);
|
||||
const dep = new Date(Date.now() + 3 * 60 * 60_000);
|
||||
|
||||
Reference in New Issue
Block a user