mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
207 lines
9.3 KiB
TypeScript
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");
|
|
});
|
|
});
|
|
});
|