import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { NotificationChannel, NotificationType, Weekday } from "@/generated/prisma/enums"; import { addDays, todayIn, wallClockToInstant, weekdayOf } from "@/lib/time/business-time"; import type { CalendarDate } from "@/lib/time/business-time"; import { setNotificationPreference } from "@/services/notifications/notification.service"; import { getAppointmentById } from "../appointment.service"; import { notifyBookingBarberChanged, notifyBookingCompleted } from "../booking-notifications"; import { cancelBooking, createBooking, rescheduleBooking } from "../booking.service"; import { cleanupTestBusiness, createTestAppointment, 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 }, include: { deliveries: true }, }); const types = notifications.map((n) => n.type).sort(); // A booking far enough out (monday is >= 7 days ahead — see // nextWeekday) also schedules the customer's single 24h reminder and the // staff member's single 2h reminder (see reminder-scheduling.ts). expect(types).toEqual( [ "BOOKING_CONFIRMED", "BOOKING_REMINDER", "OWNER_NEW_BOOKING", "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 — but the row has zero deliveries: dedup suppresses // it in favour of the STAFF_NEW_APPOINTMENT they already got (see // notification.service.ts's EQUIVALENCE_GROUPS). const ownerNotification = notifications.find((n) => n.type === "OWNER_NEW_BOOKING"); expect(ownerNotification?.recipientUserId).toBe(staffUserId); expect(ownerNotification?.deliveries).toHaveLength(0); }); it("an owner who is a DIFFERENT person from the assigned staff gets their own full, undeduped OWNER_NEW_BOOKING", async () => { const { staffId, userId: staffUserId } = await createTestStaff(business); const otherOwner = await createTestStaff(business); await grantOwnerPermission(otherOwner.userId); const { customerId } = await createTestCustomer(business); // STAFF_NEW_APPOINTMENT/OWNER_NEW_BOOKING both default to off (see // types.ts) — enable both explicitly so this test isolates "different // people don't dedup against each other" from the unrelated default. await setNotificationPreference(prisma, { userId: staffUserId, type: NotificationType.STAFF_NEW_APPOINTMENT, channel: NotificationChannel.IN_APP, isEnabled: true }); await setNotificationPreference(prisma, { userId: otherOwner.userId, type: NotificationType.OWNER_NEW_BOOKING, channel: NotificationChannel.IN_APP, isEnabled: true }); 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 staffNotification = await prisma.notification.findFirst({ where: { appointmentId: result.appointment.id, type: "STAFF_NEW_APPOINTMENT" }, include: { deliveries: true }, }); expect(staffNotification?.recipientUserId).toBe(staffUserId); expect(staffNotification?.deliveries.length).toBeGreaterThan(0); const ownerNotification = await prisma.notification.findFirst({ where: { appointmentId: result.appointment.id, type: "OWNER_NEW_BOOKING" }, include: { deliveries: true }, }); expect(ownerNotification?.recipientUserId).toBe(otherOwner.userId); expect(ownerNotification?.deliveries.length).toBeGreaterThan(0); }); 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"] } }, include: { deliveries: true }, }); expect(cancelNotifications).toHaveLength(3); // Same staff member holds both roles — OWNER_BOOKING_CANCELLED is // deduped against STAFF_APPOINTMENT_CANCELLED (zero deliveries), same // as the new-booking case above. const ownerCancelNotification = cancelNotifications.find((n) => n.type === "OWNER_BOOKING_CANCELLED"); expect(ownerCancelNotification?.deliveries).toHaveLength(0); // 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); }); it("notifyBookingCompleted no longer emails by default (still in-app)", async () => { const { staffId } = await createTestStaff(business); const { customerId } = await createTestCustomer(business); const { appointmentId } = await createTestAppointment(business, { customerId, staffId }); const appointment = await getAppointmentById(prisma, appointmentId); await notifyBookingCompleted(prisma, appointment!); const notification = await prisma.notification.findFirst({ where: { appointmentId, type: "BOOKING_COMPLETED" }, include: { deliveries: true }, }); const channels = notification?.deliveries.map((d) => d.channel) ?? []; expect(channels).not.toContain("EMAIL"); expect(channels).toContain("IN_APP"); }); describe("notifyBookingBarberChanged — email depends on the ORIGINAL booking's staff selection", () => { it("emails the customer when they originally picked this staff member specifically", async () => { const { staffId } = await createTestStaff(business); const { customerId } = await createTestCustomer(business); const { appointmentId } = await createTestAppointment(business, { customerId, staffId, staffSelectedByCustomer: true }); const appointment = await getAppointmentById(prisma, appointmentId); await notifyBookingBarberChanged(prisma, appointment!, { customerSelectedStaff: true }); const notification = await prisma.notification.findFirst({ where: { appointmentId, type: "BOOKING_BARBER_CHANGED" }, include: { deliveries: true }, }); expect(notification?.deliveries.map((d) => d.channel)).toContain("EMAIL"); }); it("does not email the customer when they originally booked 'any available barber'", async () => { const { staffId } = await createTestStaff(business); const { customerId } = await createTestCustomer(business); const { appointmentId } = await createTestAppointment(business, { customerId, staffId, staffSelectedByCustomer: false }); const appointment = await getAppointmentById(prisma, appointmentId); await notifyBookingBarberChanged(prisma, appointment!, { customerSelectedStaff: false }); const notification = await prisma.notification.findFirst({ where: { appointmentId, type: "BOOKING_BARBER_CHANGED" }, include: { deliveries: true }, }); const channels = notification?.deliveries.map((d) => d.channel) ?? []; expect(channels).not.toContain("EMAIL"); // Still notified on other channels — this is "don't email", not "don't notify". expect(channels).toContain("IN_APP"); }); }); });