booking flow, loader, email, admin cli
This commit is contained in:
208
test/booking.test.ts
Normal file
208
test/booking.test.ts
Normal file
@@ -0,0 +1,208 @@
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "vitest";
|
||||
import { PrismaPg } from "@prisma/adapter-pg";
|
||||
import { PrismaClient } from "@/generated/prisma/client";
|
||||
import {
|
||||
BookingConflictError,
|
||||
confirmHold,
|
||||
createHold,
|
||||
expireStaleHolds,
|
||||
} from "@/lib/booking";
|
||||
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 therapistA: string;
|
||||
let therapistB: string;
|
||||
let roomA: string;
|
||||
let roomB: string;
|
||||
let customerId: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
fx = await seed(db);
|
||||
serviceId = fx.services.find((s) => s.name === "60-minute Swedish")!.id;
|
||||
therapistA = fx.therapists[0].id;
|
||||
therapistB = fx.therapists[1].id;
|
||||
roomA = fx.rooms[0].id;
|
||||
roomB = fx.rooms[1].id;
|
||||
customerId = fx.customers[0].id;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await db.$disconnect();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await db.booking.deleteMany();
|
||||
});
|
||||
|
||||
const D = (iso: string) => new Date(iso);
|
||||
|
||||
describe("createHold", () => {
|
||||
test("inserts a HOLD with computed endsAt and roomReleasedAt", async () => {
|
||||
const startsAt = D("2026-05-05T14:00:00Z");
|
||||
const hold = await createHold(db, {
|
||||
customerId,
|
||||
serviceId,
|
||||
therapistId: therapistA,
|
||||
roomId: roomA,
|
||||
startsAt,
|
||||
});
|
||||
// 60-min Swedish, 15-min buffer
|
||||
expect(hold.endsAt).toEqual(D("2026-05-05T15:00:00Z"));
|
||||
expect(hold.roomReleasedAt).toEqual(D("2026-05-05T15:15:00Z"));
|
||||
expect(hold.priceCents).toBe(9000);
|
||||
expect(hold.depositCents).toBe(2000);
|
||||
|
||||
const row = await db.booking.findUnique({ where: { id: hold.id } });
|
||||
expect(row?.status).toBe("HOLD");
|
||||
expect(row?.holdExpiresAt).toBeTruthy();
|
||||
});
|
||||
|
||||
test("conflicting createHold (same therapist, overlapping) throws BookingConflictError", async () => {
|
||||
await createHold(db, {
|
||||
customerId,
|
||||
serviceId,
|
||||
therapistId: therapistA,
|
||||
roomId: roomA,
|
||||
startsAt: D("2026-05-05T14:00:00Z"),
|
||||
});
|
||||
await expect(
|
||||
createHold(db, {
|
||||
customerId,
|
||||
serviceId,
|
||||
therapistId: therapistA, // same therapist
|
||||
roomId: roomB, // different room
|
||||
startsAt: D("2026-05-05T14:30:00Z"),
|
||||
}),
|
||||
).rejects.toBeInstanceOf(BookingConflictError);
|
||||
});
|
||||
|
||||
test("conflicting createHold (same room within buffer) throws BookingConflictError", async () => {
|
||||
await createHold(db, {
|
||||
customerId,
|
||||
serviceId,
|
||||
therapistId: therapistA,
|
||||
roomId: roomA,
|
||||
startsAt: D("2026-05-05T14:00:00Z"),
|
||||
});
|
||||
// Next slot after 11:00 is 11:15 because of the buffer.
|
||||
await expect(
|
||||
createHold(db, {
|
||||
customerId,
|
||||
serviceId,
|
||||
therapistId: therapistB,
|
||||
roomId: roomA, // same room
|
||||
startsAt: D("2026-05-05T15:00:00Z"), // exactly at end — within buffer
|
||||
}),
|
||||
).rejects.toBeInstanceOf(BookingConflictError);
|
||||
});
|
||||
|
||||
test("non-overlapping createHold succeeds after the room buffer", async () => {
|
||||
await createHold(db, {
|
||||
customerId,
|
||||
serviceId,
|
||||
therapistId: therapistA,
|
||||
roomId: roomA,
|
||||
startsAt: D("2026-05-05T14:00:00Z"),
|
||||
});
|
||||
const hold2 = await createHold(db, {
|
||||
customerId,
|
||||
serviceId,
|
||||
therapistId: therapistB,
|
||||
roomId: roomA,
|
||||
startsAt: D("2026-05-05T15:15:00Z"), // exactly when buffer ends
|
||||
});
|
||||
expect(hold2.id).toBeTruthy();
|
||||
});
|
||||
|
||||
test("inactive service is rejected", async () => {
|
||||
await db.service.update({
|
||||
where: { id: serviceId },
|
||||
data: { active: false },
|
||||
});
|
||||
try {
|
||||
await expect(
|
||||
createHold(db, {
|
||||
customerId,
|
||||
serviceId,
|
||||
therapistId: therapistA,
|
||||
roomId: roomA,
|
||||
startsAt: D("2026-05-05T14:00:00Z"),
|
||||
}),
|
||||
).rejects.toThrow(/inactive/);
|
||||
} finally {
|
||||
await db.service.update({
|
||||
where: { id: serviceId },
|
||||
data: { active: true },
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test("custom holdMinutes is honored", async () => {
|
||||
const before = Date.now();
|
||||
const hold = await createHold(db, {
|
||||
customerId,
|
||||
serviceId,
|
||||
therapistId: therapistA,
|
||||
roomId: roomA,
|
||||
startsAt: D("2026-05-05T14:00:00Z"),
|
||||
holdMinutes: 30,
|
||||
});
|
||||
const expected = before + 30 * 60_000;
|
||||
// Allow 5 seconds of drift for the test execution time.
|
||||
expect(hold.holdExpiresAt.getTime()).toBeGreaterThanOrEqual(expected - 5000);
|
||||
expect(hold.holdExpiresAt.getTime()).toBeLessThanOrEqual(expected + 5000);
|
||||
});
|
||||
});
|
||||
|
||||
describe("confirmHold", () => {
|
||||
test("flips a HOLD to CONFIRMED and clears holdExpiresAt", async () => {
|
||||
const hold = await createHold(db, {
|
||||
customerId,
|
||||
serviceId,
|
||||
therapistId: therapistA,
|
||||
roomId: roomA,
|
||||
startsAt: D("2026-05-05T14:00:00Z"),
|
||||
});
|
||||
await confirmHold(db, hold.id);
|
||||
const row = await db.booking.findUnique({ where: { id: hold.id } });
|
||||
expect(row?.status).toBe("CONFIRMED");
|
||||
expect(row?.holdExpiresAt).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("expireStaleHolds", () => {
|
||||
test("expires only HOLDs whose holdExpiresAt is past", async () => {
|
||||
// Create one fresh hold (10 min default) and manually backdate another.
|
||||
const fresh = await createHold(db, {
|
||||
customerId,
|
||||
serviceId,
|
||||
therapistId: therapistA,
|
||||
roomId: roomA,
|
||||
startsAt: D("2026-05-05T14:00:00Z"),
|
||||
});
|
||||
const stale = await createHold(db, {
|
||||
customerId,
|
||||
serviceId,
|
||||
therapistId: therapistB,
|
||||
roomId: roomB,
|
||||
startsAt: D("2026-05-05T16:00:00Z"),
|
||||
});
|
||||
await db.booking.update({
|
||||
where: { id: stale.id },
|
||||
data: { holdExpiresAt: new Date(Date.now() - 60_000) },
|
||||
});
|
||||
|
||||
const count = await expireStaleHolds(db);
|
||||
expect(count).toBe(1);
|
||||
|
||||
const freshRow = await db.booking.findUnique({ where: { id: fresh.id } });
|
||||
const staleRow = await db.booking.findUnique({ where: { id: stale.id } });
|
||||
expect(freshRow?.status).toBe("HOLD");
|
||||
expect(staleRow?.status).toBe("CANCELLED");
|
||||
expect(staleRow?.cancelReason).toBe("hold expired");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user