added availability
This commit is contained in:
456
test/availability.test.ts
Normal file
456
test/availability.test.ts
Normal file
@@ -0,0 +1,456 @@
|
||||
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"]);
|
||||
});
|
||||
});
|
||||
249
test/booking-exclusion.test.ts
Normal file
249
test/booking-exclusion.test.ts
Normal file
@@ -0,0 +1,249 @@
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "vitest";
|
||||
import { PrismaPg } from "@prisma/adapter-pg";
|
||||
import { PrismaClient } from "@/generated/prisma/client";
|
||||
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 therapistA: string;
|
||||
let therapistB: string;
|
||||
let roomA: string;
|
||||
let roomB: string;
|
||||
let serviceId: string;
|
||||
let customerId: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
fx = await seed(db);
|
||||
therapistA = fx.therapists[0].id;
|
||||
therapistB = fx.therapists[1].id;
|
||||
roomA = fx.rooms[0].id;
|
||||
roomB = fx.rooms[1].id;
|
||||
// 60-minute Swedish — both seed therapists have the "swedish" tag and no
|
||||
// room tag is required, so any room/therapist pair works for these tests.
|
||||
serviceId = fx.services.find((s) => s.name.startsWith("60-minute Swedish"))!.id;
|
||||
customerId = fx.customers[0].id;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await db.$disconnect();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await db.booking.deleteMany();
|
||||
});
|
||||
|
||||
const PG_EXCLUSION_VIOLATION = "23P01";
|
||||
const D = (iso: string) => new Date(iso);
|
||||
|
||||
const findPgCode = (e: unknown): string | undefined => {
|
||||
const seen = new Set<unknown>();
|
||||
let cur: unknown = e;
|
||||
while (cur && typeof cur === "object" && !seen.has(cur)) {
|
||||
seen.add(cur);
|
||||
const obj = cur as Record<string, unknown>;
|
||||
if (typeof obj.code === "string" && /^[0-9A-Z]{5}$/.test(obj.code)) {
|
||||
return obj.code as string;
|
||||
}
|
||||
if (obj.meta && typeof obj.meta === "object") {
|
||||
const meta = obj.meta as Record<string, unknown>;
|
||||
if (typeof meta.code === "string") return meta.code;
|
||||
if (
|
||||
typeof meta.constraint === "string" &&
|
||||
meta.constraint.includes("_overlap")
|
||||
) {
|
||||
return PG_EXCLUSION_VIOLATION;
|
||||
}
|
||||
}
|
||||
cur = (obj.cause as unknown) ?? undefined;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const expectExclusionViolation = (e: unknown) => {
|
||||
const code = findPgCode(e);
|
||||
if (code !== PG_EXCLUSION_VIOLATION) {
|
||||
throw new Error(
|
||||
`Expected exclusion_violation (23P01); got code=${code ?? "<none>"}; full error:\n${JSON.stringify(
|
||||
e,
|
||||
Object.getOwnPropertyNames(e as object),
|
||||
2,
|
||||
)}`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
type BookingArgs = {
|
||||
therapistId?: string;
|
||||
roomId?: string;
|
||||
customerId?: string;
|
||||
serviceId?: string;
|
||||
startsAt: Date;
|
||||
endsAt: Date;
|
||||
roomReleasedAt?: Date;
|
||||
status?: "HOLD" | "CONFIRMED" | "COMPLETED" | "NO_SHOW" | "CANCELLED";
|
||||
};
|
||||
|
||||
const insert = (a: BookingArgs) =>
|
||||
db.booking.create({
|
||||
data: {
|
||||
customerId: a.customerId ?? customerId,
|
||||
therapistId: a.therapistId ?? therapistA,
|
||||
roomId: a.roomId ?? roomA,
|
||||
serviceId: a.serviceId ?? serviceId,
|
||||
startsAt: a.startsAt,
|
||||
endsAt: a.endsAt,
|
||||
roomReleasedAt: a.roomReleasedAt ?? a.endsAt,
|
||||
status: a.status ?? "HOLD",
|
||||
},
|
||||
});
|
||||
|
||||
describe("Booking exclusion constraints", () => {
|
||||
test("non-overlapping bookings on the same therapist succeed", async () => {
|
||||
await insert({
|
||||
startsAt: D("2026-05-01T10:00:00Z"),
|
||||
endsAt: D("2026-05-01T11:00:00Z"),
|
||||
});
|
||||
await expect(
|
||||
insert({
|
||||
roomId: roomB,
|
||||
startsAt: D("2026-05-01T11:00:00Z"),
|
||||
endsAt: D("2026-05-01T12:00:00Z"),
|
||||
}),
|
||||
).resolves.toBeTruthy();
|
||||
});
|
||||
|
||||
test("overlapping bookings on the same therapist are rejected by the DB", async () => {
|
||||
await insert({
|
||||
startsAt: D("2026-05-01T10:00:00Z"),
|
||||
endsAt: D("2026-05-01T11:00:00Z"),
|
||||
});
|
||||
try {
|
||||
await insert({
|
||||
roomId: roomB, // different room — only therapist conflict
|
||||
startsAt: D("2026-05-01T10:30:00Z"),
|
||||
endsAt: D("2026-05-01T11:30:00Z"),
|
||||
});
|
||||
throw new Error("expected exclusion_violation");
|
||||
} catch (e) {
|
||||
expectExclusionViolation(e);
|
||||
}
|
||||
});
|
||||
|
||||
test("two therapists, same time slot, different rooms — both succeed", async () => {
|
||||
await insert({
|
||||
therapistId: therapistA,
|
||||
roomId: roomA,
|
||||
startsAt: D("2026-05-01T10:00:00Z"),
|
||||
endsAt: D("2026-05-01T11:00:00Z"),
|
||||
});
|
||||
await expect(
|
||||
insert({
|
||||
therapistId: therapistB,
|
||||
roomId: roomB,
|
||||
startsAt: D("2026-05-01T10:00:00Z"),
|
||||
endsAt: D("2026-05-01T11:00:00Z"),
|
||||
}),
|
||||
).resolves.toBeTruthy();
|
||||
});
|
||||
|
||||
test("room buffer is enforced — second booking inside the buffer window is rejected", async () => {
|
||||
await insert({
|
||||
startsAt: D("2026-05-01T10:00:00Z"),
|
||||
endsAt: D("2026-05-01T11:00:00Z"),
|
||||
roomReleasedAt: D("2026-05-01T11:15:00Z"),
|
||||
});
|
||||
try {
|
||||
await insert({
|
||||
therapistId: therapistB, // different therapist; only room conflicts
|
||||
startsAt: D("2026-05-01T11:10:00Z"),
|
||||
endsAt: D("2026-05-01T12:10:00Z"),
|
||||
roomReleasedAt: D("2026-05-01T12:25:00Z"),
|
||||
});
|
||||
throw new Error("expected exclusion_violation");
|
||||
} catch (e) {
|
||||
expectExclusionViolation(e);
|
||||
}
|
||||
});
|
||||
|
||||
test("a booking starting exactly when the room is released is allowed", async () => {
|
||||
await insert({
|
||||
startsAt: D("2026-05-01T10:00:00Z"),
|
||||
endsAt: D("2026-05-01T11:00:00Z"),
|
||||
roomReleasedAt: D("2026-05-01T11:15:00Z"),
|
||||
});
|
||||
await expect(
|
||||
insert({
|
||||
therapistId: therapistB,
|
||||
startsAt: D("2026-05-01T11:15:00Z"),
|
||||
endsAt: D("2026-05-01T12:15:00Z"),
|
||||
roomReleasedAt: D("2026-05-01T12:30:00Z"),
|
||||
}),
|
||||
).resolves.toBeTruthy();
|
||||
});
|
||||
|
||||
test("CANCELLED bookings do not block new bookings on the same slot", async () => {
|
||||
await insert({
|
||||
status: "CANCELLED",
|
||||
startsAt: D("2026-05-01T10:00:00Z"),
|
||||
endsAt: D("2026-05-01T11:00:00Z"),
|
||||
});
|
||||
await expect(
|
||||
insert({
|
||||
startsAt: D("2026-05-01T10:00:00Z"),
|
||||
endsAt: D("2026-05-01T11:00:00Z"),
|
||||
}),
|
||||
).resolves.toBeTruthy();
|
||||
});
|
||||
|
||||
test("HOLD blocks CONFIRMED in the same slot — race-condition safety", async () => {
|
||||
await insert({
|
||||
status: "HOLD",
|
||||
startsAt: D("2026-05-01T10:00:00Z"),
|
||||
endsAt: D("2026-05-01T11:00:00Z"),
|
||||
});
|
||||
try {
|
||||
await insert({
|
||||
status: "CONFIRMED",
|
||||
startsAt: D("2026-05-01T10:00:00Z"),
|
||||
endsAt: D("2026-05-01T11:00:00Z"),
|
||||
});
|
||||
throw new Error("expected exclusion_violation");
|
||||
} catch (e) {
|
||||
expectExclusionViolation(e);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("Booking foreign-key constraints", () => {
|
||||
test("inserting a booking with a non-existent therapistId fails", async () => {
|
||||
await expect(
|
||||
insert({
|
||||
therapistId: "no-such-therapist",
|
||||
startsAt: D("2026-05-02T10:00:00Z"),
|
||||
endsAt: D("2026-05-02T11:00:00Z"),
|
||||
}),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
test("inserting a booking with a non-existent roomId fails", async () => {
|
||||
await expect(
|
||||
insert({
|
||||
roomId: "no-such-room",
|
||||
startsAt: D("2026-05-02T10:00:00Z"),
|
||||
endsAt: D("2026-05-02T11:00:00Z"),
|
||||
}),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
test("inserting a booking with a non-existent customerId fails", async () => {
|
||||
await expect(
|
||||
insert({
|
||||
customerId: "no-such-customer",
|
||||
startsAt: D("2026-05-02T10:00:00Z"),
|
||||
endsAt: D("2026-05-02T11:00:00Z"),
|
||||
}),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
10
test/setup.ts
Normal file
10
test/setup.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { config } from "dotenv";
|
||||
import path from "node:path";
|
||||
|
||||
config({ path: path.resolve(process.cwd(), ".env.test"), override: true });
|
||||
|
||||
if (!process.env.DATABASE_URL?.includes("touchbase_test")) {
|
||||
throw new Error(
|
||||
`Refusing to run tests against non-test database. DATABASE_URL=${process.env.DATABASE_URL}`,
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user