/** * Per-station check-in cutoff — proves booking closure is now based on each stop's own * ESTIMATED ARRIVAL time (computed from RouteStop.travelMinutesToStop), not the schedule's * overall departure. The regression this guards: before this change, all stops effectively * shared one cutoff basis, so a later station could be wrongly blocked (or an earlier one * wrongly left open) together with the rest of the route. * * Uses the slim harness (SchedulesService, real Nest DI) for schedule creation — this exercises * the actual cumulative travel-time interpolation in SchedulesService.createSchedule. SeatsService * and TasksService are NOT in the slim harness's DOMAIN_MODULES (they pull in NotificationsModule * → RabbitMQ, which the slim harness deliberately avoids — see test/setup/slim-app.ts), so they're * instantiated directly with a real Prisma + stubbed collaborators, mirroring the Tier-2 pattern in * money-integrity.e2e-spec.ts. */ import { SchedulesService } from "../src/modules/schedules/schedules.service"; import { SeatsService } from "../src/modules/seats/seats.service"; import { TasksService } from "../src/modules/tasks/tasks.service"; import { SystemConfigService } from "../src/modules/system-config/system-config.service"; import { createServiceHarness, ServiceHarness } from "./setup/slim-app"; import { IDS, DISTANCE, resetAndSeedCore } from "./fixtures/seed-core"; /** A Proxy whose every property is an async no-op — satisfies unused collaborator method calls. */ function asyncStub(): any { return new Proxy({}, { get: () => async () => undefined }); } /** Creates a fresh Train + TrainSchedule on the seed-core route via the real interpolation logic. */ async function createTestSchedule( harness: ServiceHarness, schedules: SchedulesService, opts: { trainNumber: string; departureAt: Date; arrivalAt: Date }, ) { const train = await harness.prisma.train.create({ data: { number: opts.trainNumber, name: `Test ${opts.trainNumber}` }, }); // createSchedule now rejects a schedule with zero coaches (see schedules.service.ts's // "must have at least one coach assigned" guard) — the coach has to exist and be passed // via coachIds BEFORE creation, not attached afterward. const coach = await harness.prisma.coach.create({ data: { coachTypeId: IDS.coachType, number: `${opts.trainNumber}-C1`, capacity: 4, sequence: 1, status: "ACTIVE" }, }); const seats = await Promise.all( ["1A", "1B", "1C", "1D"].map((seatNumber, i) => harness.prisma.seat.create({ data: { coachId: coach.id, seatNumber, row: 1, col: seatNumber.slice(-1), isWindow: i === 0, isAisle: i === 1 }, }), ), ); const schedule = await schedules.createSchedule({ trainId: train.id, routeId: IDS.route, departureAt: opts.departureAt.toISOString(), arrivalAt: opts.arrivalAt.toISOString(), coachIds: [coach.id], } as any); return { schedule, seats }; } describe("Check-in cutoff — arrival-time basis, per-station independence", () => { let harness: ServiceHarness; let schedulesService: SchedulesService; let seatsService: SeatsService; let tasksService: TasksService; beforeAll(async () => { harness = await createServiceHarness(); schedulesService = await harness.moduleRef.resolve(SchedulesService); const systemConfig = new SystemConfigService(harness.prisma as any); seatsService = new SeatsService(harness.prisma as any, asyncStub(), systemConfig, asyncStub(), asyncStub()); tasksService = new TasksService(harness.prisma as any, asyncStub(), asyncStub()); }); afterAll(async () => { await harness?.close(); }); it("a later station remains independently bookable after an earlier station's cutoff has passed", async () => { await resetAndSeedCore(harness.prisma); // dep only 5 min out (createSchedule requires a future departureAt). Route-level default // checkinMinutesBefore is 30 (schema default, unset here), so A's cutoff (dep - 30min) is // already ~25 min in the past by the time this runs — but B, with a 60-min travel time from // A, has an arrival far enough out (dep + 60min) that its own cutoff (arrival - 30min) is // still ~35 min in the future. const dep = new Date(Date.now() + 5 * 60_000); const arr = new Date(dep.getTime() + 100 * 60_000); // A->B 60min + B->C 40min await harness.prisma.routeStop.update({ where: { routeId_sequence: { routeId: IDS.route, sequence: 2 } }, data: { travelMinutesToStop: 60 }, }); await harness.prisma.routeStop.update({ where: { routeId_sequence: { routeId: IDS.route, sequence: 3 } }, data: { travelMinutesToStop: 40 }, }); const { schedule, seats } = await createTestSchedule(harness, schedulesService, { trainNumber: `CUTOFF-A-${Date.now()}`, departureAt: dep, arrivalAt: arr, }); await expect( seatsService.holdSeats({ scheduleId: schedule.id, originStationId: IDS.stationA, destinationStationId: IDS.stationB, passengers: [{ passengerId: "11111111-1111-4111-8111-111111111111", seatId: seats[0].id }], } as any), ).rejects.toThrow(/cannot be held within/i); const held = await seatsService.holdSeats({ scheduleId: schedule.id, originStationId: IDS.stationB, destinationStationId: IDS.stationC, passengers: [{ passengerId: "22222222-2222-4222-8222-222222222222", seatId: seats[1].id }], } as any); expect(held).toBeTruthy(); }); it("a stop-level checkinMinutesBefore override wins over the route-level default", async () => { // Override B with a LARGE cutoff (90 min) — under the route default (30 min) this exact // schedule's B segment would still be OPEN (see previous test), so a rejection here proves // the stop-level override, not the default, is what's actually being applied. await resetAndSeedCore(harness.prisma, { B: { checkinMinutesBefore: 90 } }); await harness.prisma.routeStop.update({ where: { routeId_sequence: { routeId: IDS.route, sequence: 2 } }, data: { travelMinutesToStop: 60 }, }); const dep = new Date(Date.now() + 5 * 60_000); const arr = new Date(dep.getTime() + 100 * 60_000); const { schedule, seats } = await createTestSchedule(harness, schedulesService, { trainNumber: `CUTOFF-B-${Date.now()}`, departureAt: dep, arrivalAt: arr, }); await expect( seatsService.holdSeats({ scheduleId: schedule.id, originStationId: IDS.stationB, destinationStationId: IDS.stationC, passengers: [{ passengerId: "33333333-3333-4333-8333-333333333333", seatId: seats[0].id }], } as any), ).rejects.toThrow(/cannot be held within 90 minute/i); }); it("syncScheduleStatuses closes only the specific stops past their own arrival-based cutoff", async () => { await resetAndSeedCore(harness.prisma); await harness.prisma.routeStop.update({ where: { routeId_sequence: { routeId: IDS.route, sequence: 2 } }, data: { travelMinutesToStop: 60 }, }); await harness.prisma.routeStop.update({ where: { routeId_sequence: { routeId: IDS.route, sequence: 3 } }, data: { travelMinutesToStop: 40 }, }); const dep = new Date(Date.now() + 5 * 60_000); const arr = new Date(dep.getTime() + 100 * 60_000); const { schedule } = await createTestSchedule(harness, schedulesService, { trainNumber: `CUTOFF-C-${Date.now()}`, departureAt: dep, arrivalAt: arr, }); await tasksService.syncScheduleStatuses(); const stopTimes = await harness.prisma.tripStopTime.findMany({ where: { scheduleId: schedule.id }, orderBy: { sequence: "asc" }, }); const byStation = Object.fromEntries(stopTimes.map((s) => [s.stationId, s.status])); expect(byStation[IDS.stationA]).toBe("CHECKIN_CLOSED"); expect(byStation[IDS.stationB]).toBe("OPEN"); expect(byStation[IDS.stationC]).toBe("OPEN"); }); it("a stop missing travelMinutesToStop falls back to distance interpolation without failing schedule creation", async () => { await resetAndSeedCore(harness.prisma); // no travelMinutesToStop set on any stop const dep = new Date(Date.now() + 60 * 60_000); const arr = new Date(dep.getTime() + 240 * 60_000); // 4h, matches seed-ui's convention const { schedule } = await createTestSchedule(harness, schedulesService, { trainNumber: `CUTOFF-D-${Date.now()}`, departureAt: dep, arrivalAt: arr, }); const stopTimes = await harness.prisma.tripStopTime.findMany({ where: { scheduleId: schedule.id }, orderBy: { sequence: "asc" }, }); const totalDuration = arr.getTime() - dep.getTime(); const bProgress = DISTANCE.B / DISTANCE.C; const expectedBArrival = new Date(dep.getTime() + totalDuration * bProgress); const bStop = stopTimes.find((s) => s.stationId === IDS.stationB)!; expect(bStop.plannedArrivalAt?.getTime()).toBe(expectedBArrival.getTime()); }); });