Files
NextChair/src/services/scheduling/__tests__/booking-notifications.integration.test.ts
T
Stefan Lazic 1442d33d55
CI / ci (push) Canceled after 0s
Fixes
Guest merge

Guest history now joins the account when the email is proven, in three places: OTP sign-in, Google sign-in, and staff adding a client. Unverified emails are never matched.

What moves: appointments, notes, push subscriptions and notifications go onto the account's profile, and the guest profile is deleted.
One email, several guests: if the email has several guest profiles (one per phone number), all are merged.
No profile yet: the oldest guest profile becomes the account's profile.
Account wins: it keeps its own name and phone, and only gaps get filled.
Stamps: summed, with the ledger entries moved across. If the combined total reaches the threshold, the reward is earned and the customer is notified.
Safety: it runs in one transaction and is audit-logged. A failure never blocks sign-in; the next sign-in retries.

Reminders
Staff: one reminder, 2h before; the 24h one is gone. Push and in-app are on by default and email is opt-in. Defaults are now per channel, and the settings page shows email off, push and in-app on.
Customer: one reminder at 24h. If booked too late for that, they get one at 2h instead, and none if the appointment is under 2h away.
In-app reminders: held back and released by the worker at the reminder time, so they no longer appear the moment you book. Cancelling or rescheduling clears them, and "mark all read" no longer marks unseen ones.
Guest links: every guest email now carries a working link. The booking-time token is unchanged, and later emails use one derived from the appointment id and AUTH_SECRET. Revoking a guest appointment's access kills both.
Reward expiry: two emails per reward at most, at 7 days and 1 day before.
Stale guard: the worker drops a reminder, without counting a failure, if the appointment has started or isn't confirmed.

OTP rate-limit tests (2): the failures came from my scratch environment, not the app. I had E2E_TEST_MODE=true in the scratch .env, which turns off the OTP rate limits, so the tests had nothing to assert on. I'd also used MariaDB, which throws error 1020 on concurrent upserts, while your real stack runs mysql:8.0. On MySQL 8 without the flag they pass every time. I also pinned that flag off in the integration config, so a .env with it set can't break them again. I tested that by turning it on in .env, and the OTP tests still passed.
Authorization and nav-role suites (2): this was a real bug. Both files import @/lib/auth/permissions, which builds the database connection when it's imported, before the test fixtures load .env. The suites crashed before running a single test, so the owner-permission checks (barbers can't do owner actions, nothing leaks across businesses) hadn't been running at all. I now load dotenv once in the integration config, so no test depends on import order. They run and pass (13 tests).
2026-10-07 16:39:12 +01:00

313 lines
14 KiB
TypeScript

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");
});
});
});