Initial PHP rebuild of the app
This commit is contained in:
690 files changed
+33944
-12046
No files matched your search
@@ -1,208 +0,0 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user