304 lines
9.0 KiB
TypeScript
304 lines
9.0 KiB
TypeScript
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:00–19: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);
|
||
});
|
||
});
|