457 lines
14 KiB
TypeScript
457 lines
14 KiB
TypeScript
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> = {}): 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> = {}): RoomState {
|
||
return {
|
||
id: "r1",
|
||
active: true,
|
||
tags: new Set<string>(),
|
||
blocks: [],
|
||
bookings: [],
|
||
...overrides,
|
||
};
|
||
}
|
||
|
||
function baseOpts(overrides: Partial<FindSlotsOptions> = {}): 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<string>() }), // 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"]);
|
||
});
|
||
});
|