booking flow, loader, email, admin cli
This commit is contained in:
303
test/availability-loader.test.ts
Normal file
303
test/availability-loader.test.ts
Normal file
@@ -0,0 +1,303 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
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");
|
||||
});
|
||||
});
|
||||
154
test/e2e-booking.test.ts
Normal file
154
test/e2e-booking.test.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
// End-to-end: simulate "admin takes a phone booking on behalf of a customer".
|
||||
// Exercises load → find → hold → confirm → email in one flow, then validates
|
||||
// the DB-level safety net catches a concurrent attempt at the same slot.
|
||||
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "vitest";
|
||||
import { PrismaPg } from "@prisma/adapter-pg";
|
||||
import { PrismaClient } from "@/generated/prisma/client";
|
||||
import { findSlots } from "@/lib/availability";
|
||||
import { loadAvailabilityState } from "@/lib/availability-loader";
|
||||
import {
|
||||
BookingConflictError,
|
||||
confirmHold,
|
||||
createHold,
|
||||
} from "@/lib/booking";
|
||||
import { resetEmailTransport, sendBookingConfirmation } from "@/lib/email";
|
||||
import { seed, type SeedResult } from "@/lib/seed";
|
||||
|
||||
const adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL });
|
||||
const db = new PrismaClient({ adapter });
|
||||
|
||||
const MAILPIT = "http://localhost:8025";
|
||||
const TZ = "America/Detroit";
|
||||
|
||||
let fx: SeedResult;
|
||||
let serviceId: string;
|
||||
let customer: { id: string; email: string };
|
||||
|
||||
beforeAll(async () => {
|
||||
fx = await seed(db);
|
||||
serviceId = fx.services.find((s) => s.name === "60-minute Swedish")!.id;
|
||||
customer = fx.customers[0];
|
||||
process.env.SMTP_HOST = "localhost";
|
||||
process.env.SMTP_PORT = "1025";
|
||||
process.env.SMTP_FROM = "TouchBase <noreply@touchbase.local>";
|
||||
process.env.APP_TZ = TZ;
|
||||
resetEmailTransport();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await db.$disconnect();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await db.notification.deleteMany();
|
||||
await db.booking.deleteMany();
|
||||
await fetch(`${MAILPIT}/api/v1/messages`, { method: "DELETE" });
|
||||
});
|
||||
|
||||
// 2026-05-05 10:00 America/Detroit (EDT) = 14:00 UTC
|
||||
const SLOT_LOCAL_10AM_TUE = new Date("2026-05-05T14:00:00Z");
|
||||
|
||||
describe("end-to-end: admin takes a booking", () => {
|
||||
test("happy path: load → find → hold → confirm → email → notification recorded", async () => {
|
||||
// Window: 2 hours starting at 10:00 local Tue.
|
||||
const from = SLOT_LOCAL_10AM_TUE;
|
||||
const to = new Date("2026-05-05T16:00:00Z"); // 12:00 local
|
||||
|
||||
const state = await loadAvailabilityState(db, { from, to, serviceId });
|
||||
expect(state).not.toBeNull();
|
||||
|
||||
const slots = findSlots({
|
||||
service: state!.service,
|
||||
therapists: state!.therapists,
|
||||
rooms: state!.rooms,
|
||||
practiceTz: TZ,
|
||||
from,
|
||||
to,
|
||||
});
|
||||
expect(slots.length).toBeGreaterThan(0);
|
||||
|
||||
const slot = slots.find((s) => s.startsAt.getTime() === from.getTime())!;
|
||||
expect(slot).toBeDefined();
|
||||
expect(slot.candidateTherapistIds.length).toBeGreaterThan(0);
|
||||
expect(slot.candidateRoomIds.length).toBeGreaterThan(0);
|
||||
|
||||
const hold = await createHold(db, {
|
||||
customerId: customer.id,
|
||||
serviceId,
|
||||
therapistId: slot.candidateTherapistIds[0],
|
||||
roomId: slot.candidateRoomIds[0],
|
||||
startsAt: slot.startsAt,
|
||||
});
|
||||
|
||||
await confirmHold(db, hold.id);
|
||||
|
||||
const result = await sendBookingConfirmation({ db, bookingId: hold.id });
|
||||
expect(result.status).toBe("sent");
|
||||
|
||||
// Booking is CONFIRMED in DB
|
||||
const dbRow = await db.booking.findUnique({ where: { id: hold.id } });
|
||||
expect(dbRow?.status).toBe("CONFIRMED");
|
||||
expect(dbRow?.holdExpiresAt).toBeNull();
|
||||
|
||||
// Notification row recorded with status=sent
|
||||
const noti = await db.notification.findUnique({
|
||||
where: { id: result.notificationId },
|
||||
});
|
||||
expect(noti?.status).toBe("sent");
|
||||
|
||||
// Mailpit received exactly one message addressed to the customer
|
||||
const res = await fetch(`${MAILPIT}/api/v1/messages`);
|
||||
const json = (await res.json()) as { messages: { To: { Address: string }[] }[] };
|
||||
expect(json.messages).toHaveLength(1);
|
||||
expect(json.messages[0].To[0].Address).toBe(customer.email);
|
||||
});
|
||||
|
||||
test("a second concurrent createHold for the same slot is rejected by the DB", async () => {
|
||||
const from = SLOT_LOCAL_10AM_TUE;
|
||||
const to = new Date("2026-05-05T16:00:00Z");
|
||||
|
||||
const state = await loadAvailabilityState(db, { from, to, serviceId });
|
||||
const slots = findSlots({
|
||||
service: state!.service,
|
||||
therapists: state!.therapists,
|
||||
rooms: state!.rooms,
|
||||
practiceTz: TZ,
|
||||
from,
|
||||
to,
|
||||
});
|
||||
const slot = slots[0];
|
||||
|
||||
// Race: two callers see the slot as available and both try to create a HOLD.
|
||||
const therapist = slot.candidateTherapistIds[0];
|
||||
const room = slot.candidateRoomIds[0];
|
||||
const [firstResult, secondResult] = await Promise.allSettled([
|
||||
createHold(db, {
|
||||
customerId: customer.id,
|
||||
serviceId,
|
||||
therapistId: therapist,
|
||||
roomId: room,
|
||||
startsAt: slot.startsAt,
|
||||
}),
|
||||
createHold(db, {
|
||||
customerId: fx.customers[1].id,
|
||||
serviceId,
|
||||
therapistId: therapist,
|
||||
roomId: room,
|
||||
startsAt: slot.startsAt,
|
||||
}),
|
||||
]);
|
||||
// Exactly one wins; the other is rejected as a conflict.
|
||||
const fulfilled = [firstResult, secondResult].filter(
|
||||
(r) => r.status === "fulfilled",
|
||||
);
|
||||
const rejected = [firstResult, secondResult].filter(
|
||||
(r) => r.status === "rejected",
|
||||
);
|
||||
expect(fulfilled).toHaveLength(1);
|
||||
expect(rejected).toHaveLength(1);
|
||||
expect((rejected[0] as PromiseRejectedResult).reason).toBeInstanceOf(
|
||||
BookingConflictError,
|
||||
);
|
||||
});
|
||||
});
|
||||
102
test/email.test.ts
Normal file
102
test/email.test.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "vitest";
|
||||
import { PrismaPg } from "@prisma/adapter-pg";
|
||||
import { PrismaClient } from "@/generated/prisma/client";
|
||||
import { createHold } from "@/lib/booking";
|
||||
import { resetEmailTransport, sendBookingConfirmation } from "@/lib/email";
|
||||
import { seed, type SeedResult } from "@/lib/seed";
|
||||
|
||||
const adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL });
|
||||
const db = new PrismaClient({ adapter });
|
||||
|
||||
const MAILPIT = "http://localhost:8025";
|
||||
|
||||
let fx: SeedResult;
|
||||
let serviceId: string;
|
||||
let therapistA: string;
|
||||
let roomA: string;
|
||||
let customer: { id: string; email: string };
|
||||
|
||||
beforeAll(async () => {
|
||||
fx = await seed(db);
|
||||
serviceId = fx.services.find((s) => s.name === "60-minute Swedish")!.id;
|
||||
therapistA = fx.therapists[0].id;
|
||||
roomA = fx.rooms[0].id;
|
||||
customer = fx.customers[0];
|
||||
// Point email transport at Mailpit even though .env.test doesn't define it.
|
||||
process.env.SMTP_HOST = "localhost";
|
||||
process.env.SMTP_PORT = "1025";
|
||||
process.env.SMTP_FROM = "TouchBase <noreply@touchbase.local>";
|
||||
resetEmailTransport();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await db.$disconnect();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await db.notification.deleteMany();
|
||||
await db.booking.deleteMany();
|
||||
// Clear Mailpit between tests to keep assertions clean.
|
||||
await fetch(`${MAILPIT}/api/v1/messages`, { method: "DELETE" });
|
||||
});
|
||||
|
||||
const D = (iso: string) => new Date(iso);
|
||||
|
||||
describe("sendBookingConfirmation", () => {
|
||||
test("delivers an email to Mailpit and writes a sent Notification row", async () => {
|
||||
const hold = await createHold(db, {
|
||||
customerId: customer.id,
|
||||
serviceId,
|
||||
therapistId: therapistA,
|
||||
roomId: roomA,
|
||||
startsAt: D("2026-05-05T14:00:00Z"),
|
||||
});
|
||||
|
||||
const result = await sendBookingConfirmation({ db, bookingId: hold.id });
|
||||
expect(result.status).toBe("sent");
|
||||
|
||||
// Notification row recorded
|
||||
const noti = await db.notification.findUnique({
|
||||
where: { id: result.notificationId },
|
||||
});
|
||||
expect(noti?.status).toBe("sent");
|
||||
expect(noti?.template).toBe("booking_confirmation");
|
||||
expect(noti?.bookingId).toBe(hold.id);
|
||||
expect(noti?.userId).toBe(customer.id);
|
||||
expect(noti?.to).toBe(customer.email);
|
||||
expect(noti?.sentAt).toBeTruthy();
|
||||
|
||||
// Mailpit received the message
|
||||
const res = await fetch(`${MAILPIT}/api/v1/messages`);
|
||||
const json = (await res.json()) as { messages: { To: { Address: string }[]; Subject: string }[] };
|
||||
expect(json.messages).toHaveLength(1);
|
||||
expect(json.messages[0].To[0].Address).toBe(customer.email);
|
||||
expect(json.messages[0].Subject).toContain("60-minute Swedish");
|
||||
});
|
||||
|
||||
test("formats local time in the practice timezone (America/Detroit)", async () => {
|
||||
process.env.APP_TZ = "America/Detroit";
|
||||
const hold = await createHold(db, {
|
||||
customerId: customer.id,
|
||||
serviceId,
|
||||
therapistId: therapistA,
|
||||
roomId: roomA,
|
||||
startsAt: D("2026-05-05T14:00:00Z"), // 10:00 EDT
|
||||
});
|
||||
await sendBookingConfirmation({ db, bookingId: hold.id });
|
||||
|
||||
const res = await fetch(`${MAILPIT}/api/v1/messages`);
|
||||
const list = (await res.json()) as { messages: { ID: string }[] };
|
||||
const msgRes = await fetch(`${MAILPIT}/api/v1/message/${list.messages[0].ID}`);
|
||||
const msg = (await msgRes.json()) as { Text: string };
|
||||
// 14:00 UTC → 10:00 EDT
|
||||
expect(msg.Text).toContain("10:00");
|
||||
expect(msg.Text).toContain("EDT");
|
||||
});
|
||||
|
||||
test("missing booking id throws", async () => {
|
||||
await expect(
|
||||
sendBookingConfirmation({ db, bookingId: "no-such-booking" }),
|
||||
).rejects.toThrow(/not found/);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user