This commit is contained in:
Stefan Lazic committed 2026-08-03 21:15:24 +01:00
1 parent ec5583f972
commit 546613e677
189 files changed
+9873 -655

No files matched your search

@@ -360,6 +360,50 @@ describe("booking engine (integration)", () => {
});
expect(laterValidation.valid).toBe(true);
});
// Regression test for a real bug found via a MySQL E2E run (see
// docs/phase-7-summary.md): with an asymmetric buffer
// (bufferBeforeMinutes 0, bufferAfterMinutes > 0 — the seeded
// production default), assertNoConflict in scheduling.service.ts was
// applying the wrong side's buffer to the candidate appointment,
// wrongly rejecting a slot placed immediately BEFORE an existing
// appointment using that appointment's *after* buffer instead of its
// (zero) *before* buffer. The test above only exercises
// validateBookingSlot (AvailabilityService) for a candidate placed
// AFTER an existing appointment; createBooking's actual DB-level
// conflict check (assertNoConflict) was never exercised for the
// "before" direction until now.
it("does not apply a later appointment's after-buffer to a candidate slot placed immediately before it", async () => {
const { staffId } = await createTestStaff(business);
const { customerId } = await createTestCustomer(business);
await prisma.bookingRule.update({
where: { businessId: business.businessId },
data: { bufferBeforeMinutes: 0, bufferAfterMinutes: 5 },
});
// Book the later appointment first: 10:30–11:00.
const later = await createBooking(prisma, {
businessId: business.businessId,
customerId,
staffId,
serviceIds: [business.haircutServiceId], // 30 min
startAt: wallClockToInstant(monday, 10 * 60 + 30, business.timezone),
});
expect(later.success).toBe(true);
// A candidate ending exactly when the later appointment starts (10:00–10:30) needs no gap —
// only the LATER appointment's own trailing (after) buffer applies once IT finishes, not
// before it starts, since bufferBeforeMinutes is 0.
const earlier = await createBooking(prisma, {
businessId: business.businessId,
customerId,
staffId,
serviceIds: [business.haircutServiceId],
startAt: wallClockToInstant(monday, 10 * 60, business.timezone),
});
expect(earlier.success).toBe(true);
});
});
describe("booking validation", () => {
@@ -0,0 +1,208 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { Weekday } from "@/generated/prisma/enums";
import { addDays, todayIn, wallClockToInstant, weekdayOf } from "@/lib/time/business-time";
import type { CalendarDate } from "@/lib/time/business-time";
import { cancelBooking, createBooking, rescheduleBooking } from "../booking.service";
import { cleanupTestBusiness, createTestBusiness, createTestCustomer, createTestGuestCustomer, createTestStaff, prisma } from "./fixtures";
import type { TestBusiness } from "./fixtures";
function nextWeekday(timezone: string, target: Weekday, minDaysAhead = 7): CalendarDate {
let date = addDays(todayIn(timezone), minDaysAhead);
for (let i = 0; i < 8; i++) {
if (weekdayOf(date) === target) return date;
date = addDays(date, 1);
}
throw new Error("unreachable");
}
/**
* Verifies booking.service.ts actually queues the right Notification rows
* for the right recipients on create/reschedule/cancel — see
* booking-notifications.ts. The full channel-resolution/preference logic
* is covered by notification.service.integration.test.ts; this file is
* specifically "does the booking workflow call notify() correctly".
*/
describe("booking notifications (integration)", () => {
let business: TestBusiness;
let monday: CalendarDate;
let guestProfileIds: string[];
beforeEach(async () => {
business = await createTestBusiness();
monday = nextWeekday(business.timezone, Weekday.MONDAY);
guestProfileIds = [];
});
afterEach(async () => {
await cleanupTestBusiness(business);
if (guestProfileIds.length > 0) {
await prisma.customerProfile.deleteMany({ where: { id: { in: guestProfileIds } } }).catch(() => {});
}
});
async function grantOwnerPermission(userId: string) {
const role = await prisma.role.create({
data: {
businessId: business.businessId,
name: "Test Owner",
permissions: { create: [{ permission: "VIEW_REPORTING" }] },
},
});
await prisma.staffProfile.update({ where: { userId }, data: { roleId: role.id } });
}
it("creating a booking notifies the customer, the staff member, and every owner", async () => {
const { staffId, userId: staffUserId } = await createTestStaff(business);
const { customerId, userId: customerUserId } = await createTestCustomer(business);
await grantOwnerPermission(staffUserId); // this staff member is also the "owner" for this test
const result = await createBooking(prisma, {
businessId: business.businessId,
customerId,
staffId,
serviceIds: [business.haircutServiceId],
startAt: wallClockToInstant(monday, 10 * 60, business.timezone),
});
expect(result.success).toBe(true);
if (!result.success) return;
const notifications = await prisma.notification.findMany({ where: { appointmentId: result.appointment.id } });
const types = notifications.map((n) => n.type).sort();
// A booking far enough out (monday is >= 7 days ahead — see nextWeekday) also schedules both
// 24h/2h reminders for the customer and the staff member — see reminder-scheduling.ts.
expect(types).toEqual(
[
"BOOKING_CONFIRMED",
"BOOKING_REMINDER",
"BOOKING_REMINDER",
"OWNER_NEW_BOOKING",
"STAFF_APPOINTMENT_REMINDER",
"STAFF_APPOINTMENT_REMINDER",
"STAFF_NEW_APPOINTMENT",
].sort(),
);
const customerNotification = notifications.find((n) => n.type === "BOOKING_CONFIRMED");
expect(customerNotification?.recipientUserId).toBe(customerUserId);
expect(customerNotification?.recipientCustomerId).toBe(customerId);
const staffNotification = notifications.find((n) => n.type === "STAFF_NEW_APPOINTMENT");
expect(staffNotification?.recipientUserId).toBe(staffUserId);
// Same staff member holds both roles in this test, so OWNER_NEW_BOOKING also targets them.
const ownerNotification = notifications.find((n) => n.type === "OWNER_NEW_BOOKING");
expect(ownerNotification?.recipientUserId).toBe(staffUserId);
});
it("a guest booking's customer notification has no recipientUserId but does have a deep link with the guest token", async () => {
const { staffId } = await createTestStaff(business);
const guest = await createTestGuestCustomer();
guestProfileIds.push(guest.customerId);
const result = await createBooking(prisma, {
businessId: business.businessId,
customerId: guest.customerId,
staffId,
serviceIds: [business.haircutServiceId],
startAt: wallClockToInstant(monday, 10 * 60, business.timezone),
});
expect(result.success).toBe(true);
if (!result.success) return;
expect(result.guestAccessToken).toBeTruthy();
const notification = await prisma.notification.findFirst({
where: { appointmentId: result.appointment.id, type: "BOOKING_CONFIRMED" },
});
expect(notification?.recipientUserId).toBeNull();
expect(notification?.recipientCustomerId).toBe(guest.customerId);
expect(notification?.deepLinkPath).toContain(result.guestAccessToken);
});
it("requireApproval creates a BOOKING_PENDING_APPROVAL notification instead of BOOKING_CONFIRMED", async () => {
await prisma.bookingRule.update({ where: { businessId: business.businessId }, data: { requireApproval: true } });
const { staffId } = await createTestStaff(business);
const { customerId } = await createTestCustomer(business);
const result = await createBooking(prisma, {
businessId: business.businessId,
customerId,
staffId,
serviceIds: [business.haircutServiceId],
startAt: wallClockToInstant(monday, 10 * 60, business.timezone),
});
expect(result.success).toBe(true);
if (!result.success) return;
const notification = await prisma.notification.findFirst({
where: { appointmentId: result.appointment.id, recipientCustomerId: customerId },
});
expect(notification?.type).toBe("BOOKING_PENDING_APPROVAL");
});
it("rescheduling notifies the customer and staff, and a second reschedule creates a fresh notification rather than deduping", async () => {
const { staffId, userId: staffUserId } = await createTestStaff(business);
const { customerId } = await createTestCustomer(business);
const created = await createBooking(prisma, {
businessId: business.businessId,
customerId,
staffId,
serviceIds: [business.haircutServiceId],
startAt: wallClockToInstant(monday, 10 * 60, business.timezone),
});
if (!created.success) throw new Error("setup failed");
const first = await rescheduleBooking(prisma, {
appointmentId: created.appointment.id,
startAt: wallClockToInstant(monday, 11 * 60, business.timezone),
});
expect(first.success).toBe(true);
const second = await rescheduleBooking(prisma, {
appointmentId: created.appointment.id,
startAt: wallClockToInstant(monday, 13 * 60, business.timezone),
});
expect(second.success).toBe(true);
const rescheduledNotifications = await prisma.notification.findMany({
where: { appointmentId: created.appointment.id, type: "BOOKING_RESCHEDULED" },
});
expect(rescheduledNotifications).toHaveLength(2);
const staffNotifications = await prisma.notification.findMany({
where: { appointmentId: created.appointment.id, type: "STAFF_APPOINTMENT_RESCHEDULED", recipientUserId: staffUserId },
});
expect(staffNotifications).toHaveLength(2);
});
it("cancelling notifies the customer, staff, and every owner, and only once even if called again on an already-cancelled appointment", async () => {
const { staffId, userId: staffUserId } = await createTestStaff(business);
const { customerId } = await createTestCustomer(business);
await grantOwnerPermission(staffUserId);
const created = await createBooking(prisma, {
businessId: business.businessId,
customerId,
staffId,
serviceIds: [business.haircutServiceId],
startAt: wallClockToInstant(monday, 10 * 60, business.timezone),
});
if (!created.success) throw new Error("setup failed");
const cancelled = await cancelBooking(prisma, { appointmentId: created.appointment.id });
expect(cancelled.success).toBe(true);
const cancelNotifications = await prisma.notification.findMany({
where: { appointmentId: created.appointment.id, type: { in: ["BOOKING_CANCELLED", "STAFF_APPOINTMENT_CANCELLED", "OWNER_BOOKING_CANCELLED"] } },
});
expect(cancelNotifications).toHaveLength(3);
// Cancelling again is rejected by cancelBooking itself (already CANCELLED) — never reaches notify() a second time.
const secondAttempt = await cancelBooking(prisma, { appointmentId: created.appointment.id });
expect(secondAttempt.success).toBe(false);
const stillOnlyOnce = await prisma.notification.count({
where: { appointmentId: created.appointment.id, type: "BOOKING_CANCELLED" },
});
expect(stillOnlyOnce).toBe(1);
});
});
+16 -4
View File
@@ -1,15 +1,17 @@
import "dotenv/config";
import { PrismaClient } from "@/generated/prisma/client";
import { PrismaPg } from "@prisma/adapter-pg";
import { PrismaMariaDb } from "@prisma/adapter-mariadb";
import { buildMysqlPoolConfig } from "@/lib/db/mysql-pool-config";
import { Weekday } from "@/generated/prisma/enums";
/**
* Integration-test fixtures — real Postgres, real transactions. Not used
* Integration-test fixtures — real MySQL, real transactions. Not used
* by the unit test suite (`npm test`); only by `npm run test:integration`.
* See docs/phase-3-summary.md for how to point this at a database.
* See docs/phase-7-summary.md for how to point this at a database
* (migrated from PostgreSQL to MySQL in Phase 7).
*/
const adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL });
const adapter = new PrismaMariaDb(buildMysqlPoolConfig(process.env.DATABASE_URL));
export const prisma = new PrismaClient({ adapter });
let counter = 0;
@@ -176,6 +178,16 @@ export async function cleanupTestBusiness(business: TestBusiness): Promise<void>
// appointments first lets that cascade complete instead of the business
// delete silently failing and leaking a "Test Barbershop" row per run.
await prisma.appointment.deleteMany({ where: { businessId: business.businessId } }).catch(() => {});
// StaffProfile.roleId is ALSO onDelete: Restrict, and Business cascades
// to both Role and StaffProfile. On Postgres, the database's own native
// FK cascade resolver orders this correctly; empirically (Phase 7, see
// docs/phase-7-summary.md), Prisma 7's client-engine-runtime interprets
// cascading deletes itself against MySQL and does NOT reliably delete
// StaffProfile before Role, leaving a P2003 "Foreign key constraint
// violated on (roleId)" that this function's own .catch(() => {}) was
// silently swallowing — leaking a "Test Barbershop" business per run.
// Deleting StaffProfile explicitly first removes the ordering ambiguity.
await prisma.staffProfile.deleteMany({ where: { businessId: business.businessId } }).catch(() => {});
await prisma.business.delete({ where: { id: business.businessId } }).catch(() => {});
if (business.createdUserIds.length > 0) {
await prisma.user.deleteMany({ where: { id: { in: business.createdUserIds } } }).catch(() => {});
@@ -0,0 +1,158 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { Weekday } from "@/generated/prisma/enums";
import { addDays, todayIn, wallClockToInstant, weekdayOf } from "@/lib/time/business-time";
import type { CalendarDate } from "@/lib/time/business-time";
import { cancelBooking, createBooking, rescheduleBooking } from "../booking.service";
import { cleanupTestBusiness, createTestBusiness, createTestCustomer, createTestStaff, prisma } from "./fixtures";
import type { TestBusiness } from "./fixtures";
function nextWeekday(timezone: string, target: Weekday, minDaysAhead = 7): CalendarDate {
let date = addDays(todayIn(timezone), minDaysAhead);
for (let i = 0; i < 8; i++) {
if (weekdayOf(date) === target) return date;
date = addDays(date, 1);
}
throw new Error("unreachable");
}
async function reminderDeliveries(appointmentId: string) {
return prisma.notificationDelivery.findMany({
where: {
notification: { appointmentId, type: { in: ["BOOKING_REMINDER", "STAFF_APPOINTMENT_REMINDER"] } },
channel: { in: ["EMAIL", "PUSH"] },
},
include: { notification: true },
});
}
describe("reminder scheduling (integration)", () => {
let business: TestBusiness;
let monday: CalendarDate;
beforeEach(async () => {
business = await createTestBusiness();
monday = nextWeekday(business.timezone, Weekday.MONDAY);
});
afterEach(async () => {
await cleanupTestBusiness(business);
});
it("schedules 24h and 2h EMAIL reminders for both customer and staff, as SCHEDULED (not PENDING)", async () => {
const { staffId } = await createTestStaff(business);
const { customerId } = await createTestCustomer(business);
const result = await createBooking(prisma, {
businessId: business.businessId,
customerId,
staffId,
serviceIds: [business.haircutServiceId],
startAt: wallClockToInstant(monday, 10 * 60, business.timezone),
});
if (!result.success) throw new Error("setup failed");
const deliveries = await reminderDeliveries(result.appointment.id);
expect(deliveries).toHaveLength(4); // 2 offsets × (customer + staff)
for (const d of deliveries) {
expect(d.status).toBe("SCHEDULED");
expect(d.scheduledFor).not.toBeNull();
expect(d.scheduledFor!.getTime()).toBeLessThan(result.appointment.startAt.getTime());
}
});
it("does not schedule reminders for a PENDING (requireApproval) booking", async () => {
await prisma.bookingRule.update({ where: { businessId: business.businessId }, data: { requireApproval: true } });
const { staffId } = await createTestStaff(business);
const { customerId } = await createTestCustomer(business);
const result = await createBooking(prisma, {
businessId: business.businessId,
customerId,
staffId,
serviceIds: [business.haircutServiceId],
startAt: wallClockToInstant(monday, 10 * 60, business.timezone),
});
if (!result.success) throw new Error("setup failed");
const deliveries = await reminderDeliveries(result.appointment.id);
expect(deliveries).toHaveLength(0);
});
it("does not schedule a reminder offset that would already be in the past", async () => {
const { staffId } = await createTestStaff(business);
const { customerId } = await createTestCustomer(business);
// Book for 1 hour from now — the 24h and 2h-before offsets are both already past.
const result = await createBooking(prisma, {
businessId: business.businessId,
customerId,
staffId,
serviceIds: [business.haircutServiceId],
startAt: new Date(Date.now() + 3 * 60 * 60_000), // 3h out, staff working hours permitting — see note below
}).catch(() => null);
// This may fail validation depending on min-notice/working-hours fixtures; only assert the
// reminder-skipping behaviour when the booking itself actually succeeded.
if (!result || !result.success) return;
const deliveries = await reminderDeliveries(result.appointment.id);
// 3h out: the 2h-before offset is still in the future, the 24h-before offset is in the past.
expect(deliveries.length).toBeLessThanOrEqual(2);
expect(deliveries.every((d) => d.notification.idempotencyKey.includes("reminder-2h"))).toBe(true);
});
it("rescheduling cancels the old reminders and schedules fresh ones for the new time", async () => {
const { staffId } = await createTestStaff(business);
const { customerId } = await createTestCustomer(business);
const created = await createBooking(prisma, {
businessId: business.businessId,
customerId,
staffId,
serviceIds: [business.haircutServiceId],
startAt: wallClockToInstant(monday, 10 * 60, business.timezone),
});
if (!created.success) throw new Error("setup failed");
const originalDeliveries = await reminderDeliveries(created.appointment.id);
expect(originalDeliveries).toHaveLength(4);
const rescheduled = await rescheduleBooking(prisma, {
appointmentId: created.appointment.id,
startAt: wallClockToInstant(monday, 14 * 60, business.timezone),
});
expect(rescheduled.success).toBe(true);
if (!rescheduled.success) return;
const allDeliveries = await reminderDeliveries(created.appointment.id);
const cancelledCount = allDeliveries.filter((d) => d.status === "CANCELLED").length;
const scheduledCount = allDeliveries.filter((d) => d.status === "SCHEDULED").length;
expect(cancelledCount).toBe(4); // the original 4, now superseded
expect(scheduledCount).toBe(4); // 4 fresh ones for the new time
const freshOnes = allDeliveries.filter((d) => d.status === "SCHEDULED");
for (const d of freshOnes) {
expect(d.scheduledFor!.getTime()).toBeLessThan(rescheduled.appointment.startAt.getTime());
}
});
it("cancelling the appointment cancels its scheduled reminders", async () => {
const { staffId } = await createTestStaff(business);
const { customerId } = await createTestCustomer(business);
const created = await createBooking(prisma, {
businessId: business.businessId,
customerId,
staffId,
serviceIds: [business.haircutServiceId],
startAt: wallClockToInstant(monday, 10 * 60, business.timezone),
});
if (!created.success) throw new Error("setup failed");
const cancelled = await cancelBooking(prisma, { appointmentId: created.appointment.id });
expect(cancelled.success).toBe(true);
const deliveries = await reminderDeliveries(created.appointment.id);
expect(deliveries.every((d) => d.status === "CANCELLED")).toBe(true);
expect(deliveries).toHaveLength(4);
});
});