Files
touchbase/test/availability-loader.test.ts

304 lines
9.0 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "vitest";
import { PrismaPg } from "@prisma/adapter-pg";
import { PrismaClient } from "@/generated/prisma/client";
import { loadAvailabilityState } from "@/lib/availability-loader";
import { findSlots } from "@/lib/availability";
import { seed, type SeedResult } from "@/lib/seed";
const adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL });
const db = new PrismaClient({ adapter });
let fx: SeedResult;
let serviceId: string;
let prenatalServiceId: string;
let therapistMei: string;
let therapistDaniel: string;
let roomA: string;
beforeAll(async () => {
fx = await seed(db);
serviceId = fx.services.find((s) => s.name === "60-minute Swedish")!.id;
prenatalServiceId = fx.services.find((s) => s.name === "75-minute Prenatal")!.id;
therapistMei = fx.therapists.find((t) => t.name === "Mei Tanaka")!.id;
therapistDaniel = fx.therapists.find((t) => t.name === "Daniel Costa")!.id;
roomA = fx.rooms[0].id;
});
afterAll(async () => {
await db.$disconnect();
});
beforeEach(async () => {
await db.booking.deleteMany();
await db.availabilityOverride.deleteMany();
await db.roomBlock.deleteMany();
});
const D = (iso: string) => new Date(iso);
const FROM = D("2026-05-05T00:00:00Z");
const TO = D("2026-05-12T00:00:00Z");
describe("loadAvailabilityState", () => {
test("returns null for unknown service", async () => {
const result = await loadAvailabilityState(db, {
from: FROM,
to: TO,
serviceId: "no-such-service",
});
expect(result).toBeNull();
});
test("returns null for inactive service", async () => {
await db.service.update({
where: { id: serviceId },
data: { active: false },
});
try {
const result = await loadAvailabilityState(db, {
from: FROM,
to: TO,
serviceId,
});
expect(result).toBeNull();
} finally {
await db.service.update({
where: { id: serviceId },
data: { active: true },
});
}
});
test("Swedish service: all 10 seeded therapists are eligible", async () => {
const result = await loadAvailabilityState(db, {
from: FROM,
to: TO,
serviceId,
});
expect(result).not.toBeNull();
expect(result!.therapists).toHaveLength(10);
});
test("Prenatal service: only the 3 prenatal-cert therapists are loaded", async () => {
const result = await loadAvailabilityState(db, {
from: FROM,
to: TO,
serviceId: prenatalServiceId,
});
expect(result).not.toBeNull();
// ServiceTherapist allowlist filters to qualified therapists only.
expect(result!.therapists).toHaveLength(3);
for (const t of result!.therapists) {
expect(t.tags.has("prenatal-cert")).toBe(true);
}
});
test("returns service in ServiceLite shape", async () => {
const result = await loadAvailabilityState(db, {
from: FROM,
to: TO,
serviceId,
});
expect(result!.service).toEqual({
id: serviceId,
durationMin: 60,
bufferAfterMin: 15,
requiredTherapistTags: ["swedish"],
requiredRoomTags: [],
});
});
test("loads all 10 active rooms with tag sets", async () => {
const result = await loadAvailabilityState(db, {
from: FROM,
to: TO,
serviceId,
});
expect(result!.rooms).toHaveLength(10);
const prenatalRooms = result!.rooms.filter((r) =>
r.tags.has("prenatal-table"),
);
expect(prenatalRooms.length).toBeGreaterThanOrEqual(1);
});
test("inactive room is excluded", async () => {
await db.room.update({
where: { id: roomA },
data: { active: false },
});
try {
const result = await loadAvailabilityState(db, {
from: FROM,
to: TO,
serviceId,
});
expect(result!.rooms.find((r) => r.id === roomA)).toBeUndefined();
} finally {
await db.room.update({
where: { id: roomA },
data: { active: true },
});
}
});
test("only loads bookings within the window for therapists", async () => {
// One booking inside the window, one outside (next month).
await db.booking.createMany({
data: [
{
customerId: fx.customers[0].id,
therapistId: therapistMei,
roomId: roomA,
serviceId,
startsAt: D("2026-05-06T14:00:00Z"),
endsAt: D("2026-05-06T15:00:00Z"),
roomReleasedAt: D("2026-05-06T15:15:00Z"),
status: "CONFIRMED",
},
{
customerId: fx.customers[0].id,
therapistId: therapistMei,
roomId: roomA,
serviceId,
startsAt: D("2026-06-15T14:00:00Z"),
endsAt: D("2026-06-15T15:00:00Z"),
roomReleasedAt: D("2026-06-15T15:15:00Z"),
status: "CONFIRMED",
},
],
});
const result = await loadAvailabilityState(db, {
from: FROM,
to: TO,
serviceId,
});
const mei = result!.therapists.find((t) => t.id === therapistMei)!;
expect(mei.bookings).toHaveLength(1);
expect(mei.bookings[0].startsAt).toEqual(D("2026-05-06T14:00:00Z"));
});
test("CANCELLED and COMPLETED bookings are NOT loaded (only active)", async () => {
await db.booking.create({
data: {
customerId: fx.customers[0].id,
therapistId: therapistMei,
roomId: roomA,
serviceId,
startsAt: D("2026-05-06T14:00:00Z"),
endsAt: D("2026-05-06T15:00:00Z"),
roomReleasedAt: D("2026-05-06T15:15:00Z"),
status: "CANCELLED",
},
});
const result = await loadAvailabilityState(db, {
from: FROM,
to: TO,
serviceId,
});
const mei = result!.therapists.find((t) => t.id === therapistMei)!;
expect(mei.bookings).toHaveLength(0);
});
test("BLOCK overrides are loaded for the window", async () => {
await db.availabilityOverride.create({
data: {
therapistId: therapistMei,
startsAt: D("2026-05-06T14:00:00Z"),
endsAt: D("2026-05-06T18:00:00Z"),
kind: "BLOCK",
reason: "doctor visit",
},
});
const result = await loadAvailabilityState(db, {
from: FROM,
to: TO,
serviceId,
});
const mei = result!.therapists.find((t) => t.id === therapistMei)!;
expect(mei.overrides).toHaveLength(1);
expect(mei.overrides[0].kind).toBe("BLOCK");
});
test("RoomBlocks within the window are loaded", async () => {
await db.roomBlock.create({
data: {
roomId: roomA,
startsAt: D("2026-05-07T14:00:00Z"),
endsAt: D("2026-05-07T15:00:00Z"),
reason: "deep clean",
},
});
const result = await loadAvailabilityState(db, {
from: FROM,
to: TO,
serviceId,
});
const room = result!.rooms.find((r) => r.id === roomA)!;
expect(room.blocks).toHaveLength(1);
});
test("loader output integrates with findSlots end-to-end", async () => {
// Tuesday May 5 in America/Detroit: working hours Tue-Sat 10:0019:00 local.
// Detroit is UTC-4 (EDT in May), so 10:00 local = 14:00 UTC.
const result = await loadAvailabilityState(db, {
from: D("2026-05-05T14:00:00Z"), // 10:00 local
to: D("2026-05-05T16:00:00Z"), // 12:00 local
serviceId,
});
expect(result).not.toBeNull();
const slots = findSlots({
service: result!.service,
therapists: result!.therapists,
rooms: result!.rooms,
practiceTz: "America/Detroit",
from: D("2026-05-05T14:00:00Z"),
to: D("2026-05-05T16:00:00Z"),
});
// 60-min service in a 2-hour window: 5 slots (10:00, 10:15, 10:30, 10:45, 11:00).
expect(slots).toHaveLength(5);
// Every slot should have all 10 therapists and all 10 rooms as candidates
// (no bookings exist in this window after beforeEach).
for (const slot of slots) {
expect(slot.candidateTherapistIds).toHaveLength(10);
expect(slot.candidateRoomIds).toHaveLength(10);
}
});
test("a CONFIRMED booking removes that therapist from candidate list end-to-end", async () => {
await db.booking.create({
data: {
customerId: fx.customers[0].id,
therapistId: therapistDaniel,
roomId: roomA,
serviceId,
startsAt: D("2026-05-05T14:00:00Z"), // 10:00 local
endsAt: D("2026-05-05T15:00:00Z"), // 11:00 local
roomReleasedAt: D("2026-05-05T15:15:00Z"),
status: "CONFIRMED",
},
});
const result = await loadAvailabilityState(db, {
from: D("2026-05-05T14:00:00Z"),
to: D("2026-05-05T16:00:00Z"),
serviceId,
});
const slots = findSlots({
service: result!.service,
therapists: result!.therapists,
rooms: result!.rooms,
practiceTz: "America/Detroit",
from: D("2026-05-05T14:00:00Z"),
to: D("2026-05-05T16:00:00Z"),
});
// 10:00 slot should not have Daniel as a candidate.
const tenAm = slots.find(
(s) => s.startsAt.toISOString() === "2026-05-05T14:00:00.000Z",
)!;
expect(tenAm.candidateTherapistIds).not.toContain(therapistDaniel);
expect(tenAm.candidateTherapistIds).toHaveLength(9);
});
});