Files
edr-platform/apps/edr-passenger-api/test/schedule-lifecycle.e2e-spec.ts

207 lines
9.3 KiB
TypeScript

/**
* 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");
});
});
});