/** * `/search/available-dates` — the data behind the search form's date picker. * * The portal disables the date control entirely when `routeExists` is false, and grays out * individual days that report `available: false`. Both behaviours are only as correct as this * endpoint, so this suite pins: * * 1. routeExists=false (with an empty `dates` array) for a station pair no active route * connects in that direction — the case that disables the whole control. * 2. routeExists=true plus a per-date availability list when a route does connect them. * 3. Direction matters: seed-core's route runs A→B→C, so C→A is NOT a route even though * both stations sit on it. This is the exact regression the UI relies on — a reverse * pair must not be treated as bookable. * 4. A date only counts as available when a bookable schedule actually departs that day. * 5. The range is clamped server-side and never reports dates in the past. * * Uses the slim harness (real Nest DI) for schedule creation so the real interpolation runs, * then instantiates SearchService directly with a real Prisma — SearchModule is not in the * slim harness's DOMAIN_MODULES (it pulls in NotificationsModule → RabbitMQ), mirroring the * Tier-2 pattern in stop-based-booking-segment.e2e-spec.ts. */ import { SchedulesService } from "../src/modules/schedules/schedules.service"; import { SearchService } from "../src/modules/search/search.service"; import { SegmentsService } from "../src/modules/segments/segments.service"; import { CurrencyService } from "../src/modules/currency/currency.service"; import { FareEngineService } from "../src/modules/fare-engine/fare-engine.service"; import { createServiceHarness, ServiceHarness } from "./setup/slim-app"; import { IDS, resetAndSeedCore } from "./fixtures/seed-core"; const ADDIS_OFFSET_MS = 3 * 60 * 60 * 1000; const ONE_DAY_MS = 24 * 60 * 60 * 1000; /** Calendar date in Africa/Addis_Ababa (fixed UTC+3) — matches the service's own conversion. */ function addisDateStr(d: Date): string { return new Date(d.getTime() + ADDIS_OFFSET_MS).toISOString().slice(0, 10); } function daysFromNow(days: number): Date { return new Date(Date.now() + days * ONE_DAY_MS); } describe("GET /search/available-dates", () => { let harness: ServiceHarness; let searchService: SearchService; let schedulesService: SchedulesService; beforeAll(async () => { harness = await createServiceHarness(); schedulesService = await harness.moduleRef.resolve(SchedulesService); const currencyService = harness.moduleRef.get(CurrencyService); const fareEngine = harness.moduleRef.get(FareEngineService); const segmentsService = new SegmentsService(harness.prisma as any); searchService = new SearchService( harness.prisma as any, currencyService, fareEngine, segmentsService, ); }); afterAll(async () => { await harness.close(); }); beforeEach(async () => { await resetAndSeedCore(harness.prisma); }); /** Creates a bookable schedule departing `days` from now on the seeded A→B→C route. */ async function createBookableSchedule(days: number, trainNumber: string) { const departureAt = daysFromNow(days); departureAt.setUTCHours(6, 0, 0, 0); const arrivalAt = new Date(departureAt.getTime() + 6 * 60 * 60 * 1000); const train = await harness.prisma.train.create({ data: { number: trainNumber, name: `Test ${trainNumber}` }, }); const coach = await harness.prisma.coach.create({ data: { coachTypeId: IDS.coachType, number: `${trainNumber}-C1`, capacity: 2, sequence: 1, status: "ACTIVE", }, }); await Promise.all( ["1A", "1B"].map((seatNumber, i) => harness.prisma.seat.create({ data: { coachId: coach.id, seatNumber, row: 1, col: String.fromCharCode(65 + i) }, }), ), ); const schedule = await schedulesService.createSchedule({ trainId: train.id, routeId: IDS.route, originStationId: IDS.stationA, destinationStationId: IDS.stationC, departureAt: departureAt.toISOString(), arrivalAt: arrivalAt.toISOString(), coachIds: [coach.id], } as any); return { schedule, departureDate: addisDateStr(departureAt) }; } function range(days = 30) { return { from: addisDateStr(new Date()), to: addisDateStr(daysFromNow(days)) }; } it("reports routeExists=false and no dates when no route connects the pair", async () => { // seed-core's only route runs A→B→C and no schedule exists yet, so nothing connects C→A. // Every date is unbookable, and the portal disables the date control outright rather than // graying out each day individually. const result = await searchService.getAvailableDates({ originStationId: IDS.stationC, destinationStationId: IDS.stationA, ...range(), } as any); expect(result.routeExists).toBe(false); expect(result.dates).toEqual([]); }); /** * Regression: `routeExists` must agree with what the search can actually sell. * * A return leg reuses the outbound Route but lays its TripStopTimes in the opposite order. * routeExistsForPair originally consulted only RouteStop ordering, so it answered "no route" * for C→A while searchTrips happily returned a bookable trip for that same pair. The portal * disables its date picker on this flag, so the stale answer would have blocked a real, * sellable journey. */ it("reports routeExists=true for a reverse pair a real schedule connects", async () => { const departureAt = daysFromNow(3); departureAt.setUTCHours(6, 0, 0, 0); const train = await harness.prisma.train.create({ data: { number: "AD-REV", name: "Reverse leg" }, }); const coach = await harness.prisma.coach.create({ data: { coachTypeId: IDS.coachType, number: "AD-REV-C1", capacity: 1, sequence: 1, status: "ACTIVE" }, }); await harness.prisma.seat.create({ data: { coachId: coach.id, seatNumber: "1A", row: 1, col: "A" }, }); // Return leg: same Route, but stop times run C → A. const schedule = await harness.prisma.trainSchedule.create({ data: { trainId: train.id, routeId: IDS.route, originStationId: IDS.stationC, destinationStationId: IDS.stationA, departureAt, arrivalAt: new Date(departureAt.getTime() + 6 * 60 * 60 * 1000), durationMinutes: 360, status: "SCHEDULED", }, }); await harness.prisma.tripStopTime.createMany({ data: [ { scheduleId: schedule.id, stationId: IDS.stationC, sequence: 1, plannedDepartureAt: departureAt, status: "OPEN" }, { scheduleId: schedule.id, stationId: IDS.stationA, sequence: 2, plannedArrivalAt: new Date(departureAt.getTime() + 6 * 3600_000), status: "OPEN" }, ], }); await harness.prisma.coachAssignment.create({ data: { scheduleId: schedule.id, coachId: coach.id, positionNumber: 1, isOperational: true }, }); const result = await searchService.getAvailableDates({ originStationId: IDS.stationC, destinationStationId: IDS.stationA, ...range(), } as any); expect(result.routeExists).toBe(true); expect(result.dates.filter((d) => d.available).map((d) => d.date)).toContain( addisDateStr(departureAt), ); }); it("reports routeExists=false for a station pair with no route at all", async () => { const orphan = await harness.prisma.station.create({ data: { code: "ZZZ", name: "Orphan", city: "Nowhere" }, }); const result = await searchService.getAvailableDates({ originStationId: IDS.stationA, destinationStationId: orphan.id, ...range(), } as any); expect(result.routeExists).toBe(false); expect(result.dates).toEqual([]); }); it("reports routeExists=true with a per-date list for a connected pair", async () => { const result = await searchService.getAvailableDates({ originStationId: IDS.stationA, destinationStationId: IDS.stationC, ...range(), } as any); expect(result.routeExists).toBe(true); expect(result.dates.length).toBeGreaterThan(0); for (const d of result.dates) { expect(d).toEqual({ date: expect.any(String), available: expect.any(Boolean) }); } }); it("marks only the days a bookable schedule departs as available", async () => { const { departureDate } = await createBookableSchedule(3, "AD-1"); const result = await searchService.getAvailableDates({ originStationId: IDS.stationA, destinationStationId: IDS.stationC, ...range(), } as any); expect(result.routeExists).toBe(true); const available = result.dates.filter((d) => d.available).map((d) => d.date); expect(available).toContain(departureDate); // Every other day in the window has no schedule, so it must be reported unavailable — // this is what grays out individual days on the picker. expect(available).toEqual([departureDate]); }); it("treats a mid-route segment as its own pair (A→B available, C→B never)", async () => { const { departureDate } = await createBookableSchedule(4, "AD-2"); const forward = await searchService.getAvailableDates({ originStationId: IDS.stationA, destinationStationId: IDS.stationB, ...range(), } as any); expect(forward.routeExists).toBe(true); expect(forward.dates.filter((d) => d.available).map((d) => d.date)).toContain(departureDate); // C sits after B on the route, so C→B is backwards — no route, whatever schedules exist. const backward = await searchService.getAvailableDates({ originStationId: IDS.stationC, destinationStationId: IDS.stationB, ...range(), } as any); expect(backward.routeExists).toBe(false); expect(backward.dates).toEqual([]); }); it("never reports dates before today, even when asked for a past range", async () => { const result = await searchService.getAvailableDates({ originStationId: IDS.stationA, destinationStationId: IDS.stationC, from: addisDateStr(daysFromNow(-30)), to: addisDateStr(daysFromNow(5)), } as any); const today = addisDateStr(new Date()); expect(result.routeExists).toBe(true); for (const d of result.dates) expect(d.date >= today).toBe(true); }); it("clamps an over-long range to the 90-day server maximum", async () => { const result = await searchService.getAvailableDates({ originStationId: IDS.stationA, destinationStationId: IDS.stationC, from: addisDateStr(new Date()), to: addisDateStr(daysFromNow(400)), } as any); expect(result.routeExists).toBe(true); // Inclusive of both ends, 90 days spans at most 91 calendar dates. expect(result.dates.length).toBeLessThanOrEqual(91); expect(result.to <= addisDateStr(daysFromNow(91))).toBe(true); }); it("does not mark a package-only schedule's day as available", async () => { const { schedule, departureDate } = await createBookableSchedule(5, "AD-3"); await harness.prisma.trainSchedule.update({ where: { id: schedule.id }, data: { isPackageOnly: true }, }); const result = await searchService.getAvailableDates({ originStationId: IDS.stationA, destinationStationId: IDS.stationC, ...range(), } as any); const available = result.dates.filter((d) => d.available).map((d) => d.date); expect(available).not.toContain(departureDate); }); it("does not mark a cancelled schedule's day as available", async () => { const { schedule, departureDate } = await createBookableSchedule(6, "AD-4"); await harness.prisma.trainSchedule.update({ where: { id: schedule.id }, data: { status: "CANCELLED" }, }); const result = await searchService.getAvailableDates({ originStationId: IDS.stationA, destinationStationId: IDS.stationC, ...range(), } as any); const available = result.dates.filter((d) => d.available).map((d) => d.date); expect(available).not.toContain(departureDate); }); });