/** * Schedule lifecycle coverage — bulkGenerateSchedules, createSchedule's "must have at least * one coach assigned" guard (added recently — the exact validation whose ordering broke * checkin-cutoff.e2e-spec.ts's fixture helper earlier this session), and * TasksService.syncScheduleStatuses' full schedule-level state machine * (SCHEDULED -> BOARDING -> EN_ROUTE -> ARRIVED). checkin-cutoff.e2e-spec.ts already covers * the per-stop CHECKIN_CLOSED/OPEN half of syncScheduleStatuses — not repeated here. */ import { SchedulesService } from "../src/modules/schedules/schedules.service"; import { TasksService } from "../src/modules/tasks/tasks.service"; import { createServiceHarness, ServiceHarness } from "./setup/slim-app"; import { IDS, resetAndSeedCore } from "./fixtures/seed-core"; function asyncStub(): any { return new Proxy({}, { get: () => async () => undefined }); } describe("Schedule lifecycle — bulk generate, coach guard, status transitions", () => { let harness: ServiceHarness; let schedulesService: SchedulesService; let tasksService: TasksService; beforeAll(async () => { harness = await createServiceHarness(); schedulesService = await harness.moduleRef.resolve(SchedulesService); tasksService = new TasksService(harness.prisma as any, asyncStub(), asyncStub()); }); afterAll(async () => { await harness?.close(); }); beforeEach(async () => { await resetAndSeedCore(harness.prisma); }); const future = (mins: number) => new Date(Date.now() + mins * 60_000); async function createCoach(trainNumber: string) { return harness.prisma.coach.create({ data: { coachTypeId: IDS.coachType, number: `${trainNumber}-C1`, capacity: 4, sequence: 1, status: "ACTIVE" }, }); } describe("createSchedule coach-assignment guard", () => { it("rejects a schedule with no coachIds and no route coach template", async () => { const train = await harness.prisma.train.create({ data: { number: `SCH-NOCOACH-${Date.now()}`, name: "Test" } }); await expect( schedulesService.createSchedule({ trainId: train.id, routeId: IDS.route, departureAt: future(180).toISOString(), arrivalAt: future(280).toISOString(), } as any), ).rejects.toThrow(/must have at least one coach assigned/i); const orphan = await harness.prisma.trainSchedule.findFirst({ where: { trainId: train.id } }); expect(orphan).toBeNull(); // no dead schedule left behind }); it("auto-applies the route's coach template when coachIds are omitted", async () => { const train = await harness.prisma.train.create({ data: { number: `SCH-TEMPLATE-${Date.now()}`, name: "Test" } }); const coach = await createCoach(train.number); await harness.prisma.routeCoachTemplate.create({ data: { routeId: IDS.route, coachId: coach.id, positionNumber: 1 }, }); const schedule = await schedulesService.createSchedule({ trainId: train.id, routeId: IDS.route, departureAt: future(180).toISOString(), arrivalAt: future(280).toISOString(), } as any); const assignments = await harness.prisma.coachAssignment.findMany({ where: { scheduleId: schedule.id } }); expect(assignments).toHaveLength(1); expect(assignments[0].coachId).toBe(coach.id); }); it("explicit coachIds override the route's coach template", async () => { const train = await harness.prisma.train.create({ data: { number: `SCH-OVERRIDE-${Date.now()}`, name: "Test" } }); const templateCoach = await createCoach(`${train.number}-tmpl`); const explicitCoach = await createCoach(`${train.number}-explicit`); await harness.prisma.routeCoachTemplate.create({ data: { routeId: IDS.route, coachId: templateCoach.id, positionNumber: 1 }, }); const schedule = await schedulesService.createSchedule({ trainId: train.id, routeId: IDS.route, departureAt: future(180).toISOString(), arrivalAt: future(280).toISOString(), coachIds: [explicitCoach.id], } as any); const assignments = await harness.prisma.coachAssignment.findMany({ where: { scheduleId: schedule.id } }); expect(assignments).toHaveLength(1); expect(assignments[0].coachId).toBe(explicitCoach.id); }); }); describe("bulkGenerateSchedules", () => { it("generates one schedule per repeat interval across the date range", async () => { const train = await harness.prisma.train.create({ data: { number: `BULK-${Date.now()}`, name: "Test" } }); const coach = await createCoach(train.number); await harness.prisma.routeCoachTemplate.create({ data: { routeId: IDS.route, coachId: coach.id, positionNumber: 1 } }); const start = future(2 * 24 * 60); // 2 days out, clear of "today" edge cases const result = await schedulesService.bulkGenerateSchedules({ trainId: train.id, routeId: IDS.route, startDateTime: start.toISOString(), forNextDays: 5, repeatEveryDays: 2, durationHours: 3, } as any); // while(currentDate < endDate) stepping by 2 days over a 5-day window: day 0, 2, 4. expect(result.schedulesCreated).toBe(3); expect(result.errors).toHaveLength(0); expect(result.scheduleIds).toHaveLength(3); const schedules = await harness.prisma.trainSchedule.findMany({ where: { trainId: train.id }, orderBy: { departureAt: "asc" } }); expect(schedules).toHaveLength(3); const daysBetween = (a: Date, b: Date) => Math.round((b.getTime() - a.getTime()) / (24 * 60 * 60 * 1000)); expect(daysBetween(schedules[0].departureAt, schedules[1].departureAt)).toBe(2); expect(daysBetween(schedules[1].departureAt, schedules[2].departureAt)).toBe(2); }); it("collects per-day errors without aborting the rest of the run", async () => { const train = await harness.prisma.train.create({ data: { number: `BULK-ERR-${Date.now()}`, name: "Test" } }); const coach = await createCoach(train.number); await harness.prisma.routeCoachTemplate.create({ data: { routeId: IDS.route, coachId: coach.id, positionNumber: 1 } }); const start = future(2 * 24 * 60); const dto = { trainId: train.id, routeId: IDS.route, startDateTime: start.toISOString(), forNextDays: 4, repeatEveryDays: 1, durationHours: 3, } as any; const first = await schedulesService.bulkGenerateSchedules(dto); expect(first.schedulesCreated).toBe(4); expect(first.errors).toHaveLength(0); // Re-running the exact same range collides with EVERY day just created (same // train+route+date already exists) — proves errors are collected per-day, not thrown, // and the count/errors array accurately reflect zero successes this time. const second = await schedulesService.bulkGenerateSchedules(dto); expect(second.schedulesCreated).toBe(0); expect(second.errors).toHaveLength(4); expect(second.errors[0]).toMatch(/already exists/i); }); }); describe("syncScheduleStatuses — schedule-level state machine", () => { async function scheduleWithCoach(trainNumber: string, departureAt: Date, arrivalAt: Date) { const train = await harness.prisma.train.create({ data: { number: trainNumber, name: "Test" } }); const coach = await createCoach(trainNumber); return schedulesService.createSchedule({ trainId: train.id, routeId: IDS.route, departureAt: departureAt.toISOString(), arrivalAt: arrivalAt.toISOString(), coachIds: [coach.id], } as any); } it("SCHEDULED -> BOARDING once within the 30-min cutoff window", async () => { const schedule = await scheduleWithCoach(`SYNC-BOARD-${Date.now()}`, future(20), future(80)); expect((await harness.prisma.trainSchedule.findUnique({ where: { id: schedule.id } }))?.status).toBe("SCHEDULED"); await tasksService.syncScheduleStatuses(); expect((await harness.prisma.trainSchedule.findUnique({ where: { id: schedule.id } }))?.status).toBe("BOARDING"); }); it("BOARDING -> EN_ROUTE once departure has passed", async () => { const schedule = await scheduleWithCoach(`SYNC-ENROUTE-${Date.now()}`, future(20), future(80)); await tasksService.syncScheduleStatuses(); await harness.prisma.trainSchedule.update({ where: { id: schedule.id }, data: { departureAt: new Date(Date.now() - 60_000) } }); await tasksService.syncScheduleStatuses(); expect((await harness.prisma.trainSchedule.findUnique({ where: { id: schedule.id } }))?.status).toBe("EN_ROUTE"); }); it("EN_ROUTE -> ARRIVED once arrival has passed", async () => { const schedule = await scheduleWithCoach(`SYNC-ARRIVED-${Date.now()}`, future(20), future(80)); await tasksService.syncScheduleStatuses(); await harness.prisma.trainSchedule.update({ where: { id: schedule.id }, data: { departureAt: new Date(Date.now() - 120_000), arrivalAt: new Date(Date.now() - 60_000) }, }); await tasksService.syncScheduleStatuses(); await tasksService.syncScheduleStatuses(); // BOARDING->EN_ROUTE and EN_ROUTE->ARRIVED are separate updateMany calls in one pass — one call suffices, second call is a no-op idempotency check expect((await harness.prisma.trainSchedule.findUnique({ where: { id: schedule.id } }))?.status).toBe("ARRIVED"); }); }); });