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