import { describe, expect, test } from "vitest"; import { findSlots, inWorkingHours, therapistAvailable, type FindSlotsOptions, type RoomState, type ServiceLite, type TherapistState, type WorkingHoursEntry, } from "@/lib/availability"; // ============================================================ // Fixture helpers // ============================================================ const TZ = "America/New_York"; const D = (iso: string) => new Date(iso); // Tue 2026-05-05 in the New York practice timezone: // 10:00 EDT == 14:00 UTC (EDT = UTC-4) // We'll use this date a lot in fixtures. const TUE_LOCAL_10AM = D("2026-05-05T14:00:00Z"); // 2026-05-05 10:00 EDT const TUE_LOCAL_11AM = D("2026-05-05T15:00:00Z"); const TUE_LOCAL_12PM = D("2026-05-05T16:00:00Z"); const TUE_LOCAL_5PM = D("2026-05-05T21:00:00Z"); const TUE_LOCAL_7PM = D("2026-05-05T23:00:00Z"); const TUE_LOCAL_9AM = D("2026-05-05T13:00:00Z"); // before opening // Spring-forward 2026 in US Eastern: Mar 8 2026, local 02:00 EST → 03:00 EDT. const SPRING_FORWARD_SUN_LOCAL_10AM = D("2026-03-08T14:00:00Z"); // 10:00 EDT after the jump const SERVICE_60MIN: ServiceLite = { id: "svc-60", durationMin: 60, bufferAfterMin: 15, requiredTherapistTags: [], requiredRoomTags: [], }; const SERVICE_90MIN_PRENATAL: ServiceLite = { id: "svc-90-prenatal", durationMin: 90, bufferAfterMin: 20, requiredTherapistTags: ["prenatal-cert"], requiredRoomTags: ["prenatal-table"], }; const FULL_WEEK_10_TO_19: WorkingHoursEntry[] = [0, 1, 2, 3, 4, 5, 6].map((d) => ({ weekday: d, startMin: 10 * 60, endMin: 19 * 60, })); const TUE_THRU_SAT_10_TO_19: WorkingHoursEntry[] = [2, 3, 4, 5, 6].map((d) => ({ weekday: d, startMin: 10 * 60, endMin: 19 * 60, })); function baseTherapist(overrides: Partial = {}): TherapistState { return { id: "t1", active: true, tags: new Set(["swedish"]), serviceIds: new Set([SERVICE_60MIN.id]), workingHours: TUE_THRU_SAT_10_TO_19, overrides: [], bookings: [], ...overrides, }; } function baseRoom(overrides: Partial = {}): RoomState { return { id: "r1", active: true, tags: new Set(), blocks: [], bookings: [], ...overrides, }; } function baseOpts(overrides: Partial = {}): FindSlotsOptions { return { service: SERVICE_60MIN, from: TUE_LOCAL_10AM, to: TUE_LOCAL_12PM, therapists: [baseTherapist()], rooms: [baseRoom()], practiceTz: TZ, slotGranularityMin: 15, ...overrides, }; } // ============================================================ // inWorkingHours — direct tests // ============================================================ describe("inWorkingHours", () => { test("slot inside Tuesday working hours is accepted", () => { expect( inWorkingHours(TUE_THRU_SAT_10_TO_19, TUE_LOCAL_10AM, TUE_LOCAL_11AM, TZ), ).toBe(true); }); test("slot before opening is rejected", () => { expect( inWorkingHours(TUE_THRU_SAT_10_TO_19, TUE_LOCAL_9AM, TUE_LOCAL_10AM, TZ), ).toBe(false); }); test("slot ending exactly at closing is accepted (endMin is inclusive of equality)", () => { expect( inWorkingHours(TUE_THRU_SAT_10_TO_19, TUE_LOCAL_5PM, TUE_LOCAL_7PM, TZ), ).toBe(true); }); test("Monday slot rejected when Mon is not a working day", () => { // 2026-05-04 is a Monday const monLocal10am = D("2026-05-04T14:00:00Z"); const monLocal11am = D("2026-05-04T15:00:00Z"); expect( inWorkingHours(TUE_THRU_SAT_10_TO_19, monLocal10am, monLocal11am, TZ), ).toBe(false); }); test("DST spring-forward Sunday: 10:00 local works because we shift to EDT", () => { // Even though the local clock skipped 02:00–03:00 EST, 10:00 EDT is well after. expect( inWorkingHours( FULL_WEEK_10_TO_19, SPRING_FORWARD_SUN_LOCAL_10AM, D("2026-03-08T15:00:00Z"), // 11:00 EDT TZ, ), ).toBe(true); }); test("effectiveFrom/effectiveTo bracket is enforced", () => { const wh: WorkingHoursEntry[] = [{ weekday: 2, startMin: 10 * 60, endMin: 19 * 60, effectiveFrom: D("2026-06-01T00:00:00Z"), }]; // Slot before effectiveFrom should not match. expect( inWorkingHours(wh, TUE_LOCAL_10AM, TUE_LOCAL_11AM, TZ), ).toBe(false); }); test("cross-midnight slot is rejected", () => { // Hypothetical 11pm-1am slot would span two local days. expect( inWorkingHours( FULL_WEEK_10_TO_19, D("2026-05-06T03:00:00Z"), // 23:00 local Tuesday D("2026-05-06T05:00:00Z"), // 01:00 local Wednesday TZ, ), ).toBe(false); }); }); // ============================================================ // therapistAvailable — direct tests // ============================================================ describe("therapistAvailable", () => { test("free therapist in working hours is available", () => { const t = baseTherapist(); expect(therapistAvailable(t, TUE_LOCAL_10AM, TUE_LOCAL_11AM, TZ)).toBe(true); }); test("booking overlap blocks the slot", () => { const t = baseTherapist({ bookings: [{ startsAt: TUE_LOCAL_10AM, endsAt: TUE_LOCAL_11AM }], }); expect( therapistAvailable( t, D("2026-05-05T14:30:00Z"), // 10:30 EDT — overlaps 10:00–11:00 D("2026-05-05T15:30:00Z"), TZ, ), ).toBe(false); }); test("BLOCK override during working hours blocks the slot", () => { const t = baseTherapist({ overrides: [{ kind: "BLOCK", startsAt: D("2026-05-05T14:30:00Z"), endsAt: D("2026-05-05T15:30:00Z"), }], }); expect(therapistAvailable(t, TUE_LOCAL_10AM, TUE_LOCAL_11AM, TZ)).toBe(false); }); test("EXTRA_HOURS opens a slot outside regular working hours", () => { // Therapist's regular hours don't include Sunday; EXTRA_HOURS does. const t = baseTherapist({ workingHours: TUE_THRU_SAT_10_TO_19, // no Sunday overrides: [{ kind: "EXTRA_HOURS", startsAt: D("2026-05-10T14:00:00Z"), // Sun 10:00 EDT endsAt: D("2026-05-10T18:00:00Z"), // Sun 14:00 EDT }], }); expect( therapistAvailable( t, D("2026-05-10T14:00:00Z"), D("2026-05-10T15:00:00Z"), TZ, ), ).toBe(true); }); }); // ============================================================ // findSlots — integration // ============================================================ describe("findSlots", () => { test("baseline: 60-min service in a 2-hour window emits 5 slots at 15-min granularity", () => { // Window 10:00–12:00 local. Service is 60 min. Slots can start at // 10:00, 10:15, 10:30, 10:45, 11:00 (last finishes at 12:00). const slots = findSlots(baseOpts()); expect(slots).toHaveLength(5); expect(slots[0].startsAt).toEqual(TUE_LOCAL_10AM); expect(slots[4].startsAt).toEqual(TUE_LOCAL_11AM); expect(slots[4].endsAt).toEqual(TUE_LOCAL_12PM); }); test("empty therapists list yields no slots", () => { expect(findSlots(baseOpts({ therapists: [] }))).toEqual([]); }); test("empty rooms list yields no slots", () => { expect(findSlots(baseOpts({ rooms: [] }))).toEqual([]); }); test("therapist tag mismatch excludes therapist", () => { // Service requires prenatal-cert, but the only room has the right tag. const slots = findSlots( baseOpts({ service: SERVICE_90MIN_PRENATAL, // Window large enough for one 90-min slot to: D("2026-05-05T15:30:00Z"), therapists: [ baseTherapist({ tags: new Set(["swedish"]), // missing prenatal-cert serviceIds: new Set([SERVICE_90MIN_PRENATAL.id]), }), ], rooms: [ baseRoom({ tags: new Set(["prenatal-table"]) }), ], }), ); expect(slots).toEqual([]); }); test("room tag mismatch excludes room", () => { const slots = findSlots( baseOpts({ service: SERVICE_90MIN_PRENATAL, to: D("2026-05-05T15:30:00Z"), therapists: [ baseTherapist({ tags: new Set(["prenatal-cert"]), serviceIds: new Set([SERVICE_90MIN_PRENATAL.id]), }), ], rooms: [ baseRoom({ tags: new Set() }), // missing prenatal-table ], }), ); expect(slots).toEqual([]); }); test("ServiceTherapist allowlist excludes therapists not opted in", () => { const slots = findSlots( baseOpts({ therapists: [ baseTherapist({ serviceIds: new Set(["some-other-service"]) }), ], }), ); expect(slots).toEqual([]); }); test("inactive therapist excluded", () => { expect( findSlots(baseOpts({ therapists: [baseTherapist({ active: false })] })), ).toEqual([]); }); test("inactive room excluded", () => { expect( findSlots(baseOpts({ rooms: [baseRoom({ active: false })] })), ).toEqual([]); }); test("therapist on PTO via BLOCK override has no slots in PTO window", () => { const slots = findSlots( baseOpts({ therapists: [ baseTherapist({ overrides: [{ kind: "BLOCK", startsAt: TUE_LOCAL_10AM, endsAt: TUE_LOCAL_12PM, }], }), ], }), ); expect(slots).toEqual([]); }); test("room buffer prevents back-to-back booking inside the buffer window", () => { // Existing booking 10:00–11:00 with 15-min buffer ⇒ room locked till 11:15. // Window 10:00–12:00 ⇒ next legal start is 11:15. With granularity 15 and // duration 60, candidates 11:15 (end 12:15 – out of window!), so 0 slots // beyond the existing one. But the existing booking's slot itself isn't a // candidate (the room is occupied). Expand window to 12:30 to test legal // resumption at 11:15. const slots = findSlots( baseOpts({ to: D("2026-05-05T16:30:00Z"), // 12:30 EDT rooms: [ baseRoom({ bookings: [{ startsAt: TUE_LOCAL_10AM, endsAt: D("2026-05-05T15:15:00Z"), // = 11:00 + 15 min buffer }], }), ], }), ); // Legal slots: 11:15 → 12:15 (only one, since 11:30 → 12:30 also fits). const starts = slots.map((s) => s.startsAt.toISOString()); expect(starts).toContain("2026-05-05T15:15:00.000Z"); // 11:15 EDT expect(starts).toContain("2026-05-05T15:30:00.000Z"); // 11:30 EDT // Should NOT include any time before 11:15 (room locked). expect( starts.filter((s) => s < "2026-05-05T15:15:00.000Z"), ).toEqual([]); }); test("room block excludes that room during the block window", () => { const slots = findSlots( baseOpts({ rooms: [ baseRoom({ blocks: [{ startsAt: D("2026-05-05T14:30:00Z"), // 10:30 EDT endsAt: D("2026-05-05T15:30:00Z"), // 11:30 EDT }], }), ], }), ); // 10:00–11:00 slot? Block starts 10:30, so room buffer on slot 10:00 → 11:15 // overlaps the block — excluded. const starts = slots.map((s) => s.startsAt.toISOString()); expect(starts).not.toContain("2026-05-05T14:00:00.000Z"); // 10:00 expect(starts).not.toContain("2026-05-05T14:15:00.000Z"); // 10:15 }); test("preferredTherapistId restricts results to that therapist", () => { const t1 = baseTherapist({ id: "t1" }); const t2 = baseTherapist({ id: "t2" }); const slots = findSlots( baseOpts({ therapists: [t1, t2], preferredTherapistId: "t2", }), ); for (const s of slots) { expect(s.candidateTherapistIds).toEqual(["t2"]); } expect(slots.length).toBeGreaterThan(0); }); test("from-time not on grid is rounded up to the next granular boundary", () => { const slots = findSlots( baseOpts({ from: D("2026-05-05T14:07:00Z"), // 10:07 EDT — should round up to 10:15 to: D("2026-05-05T16:00:00Z"), // 12:00 EDT }), ); expect(slots[0].startsAt).toEqual(D("2026-05-05T14:15:00Z")); }); test("out-of-working-hours window yields no slots", () => { const slots = findSlots( baseOpts({ from: D("2026-05-05T11:00:00Z"), // 7:00 EDT to: D("2026-05-05T13:00:00Z"), // 9:00 EDT — entirely before open }), ); expect(slots).toEqual([]); }); test("DST spring-forward Sunday: slot grid still produces clean slots after the jump", () => { const slots = findSlots( baseOpts({ from: D("2026-03-08T14:00:00Z"), // 10:00 EDT (after the jump) to: D("2026-03-08T16:00:00Z"), // 12:00 EDT therapists: [ baseTherapist({ workingHours: FULL_WEEK_10_TO_19 }), ], }), ); expect(slots.length).toBeGreaterThan(0); // First slot should be exactly 10:00 EDT. expect(slots[0].startsAt).toEqual(SPRING_FORWARD_SUN_LOCAL_10AM); }); test("returns slots sorted by start time, ascending", () => { const slots = findSlots( baseOpts({ to: D("2026-05-05T17:00:00Z"), // 13:00 EDT — wider window }), ); for (let i = 1; i < slots.length; i++) { expect(slots[i].startsAt.getTime()).toBeGreaterThan( slots[i - 1].startsAt.getTime(), ); } }); test("two therapists, only one available at a given slot — slot still emitted with that one as candidate", () => { const t1 = baseTherapist({ id: "t1", bookings: [{ startsAt: TUE_LOCAL_10AM, endsAt: TUE_LOCAL_11AM, }], }); const t2 = baseTherapist({ id: "t2" }); const slots = findSlots(baseOpts({ therapists: [t1, t2] })); const tenAm = slots.find( (s) => s.startsAt.toISOString() === "2026-05-05T14:00:00.000Z", ); expect(tenAm).toBeDefined(); expect(tenAm!.candidateTherapistIds).toEqual(["t2"]); }); });