Files
NextChair/src/services/calendar/__tests__/calendar-sync.service.integration.test.ts
T
2026-08-04 09:57:58 +01:00

188 lines
8.0 KiB
TypeScript

import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
import {
cleanupTestBusiness,
createTestBusiness,
createTestCustomer,
createTestStaff,
prisma,
} from "@/services/scheduling/__tests__/fixtures";
import type { TestBusiness } from "@/services/scheduling/__tests__/fixtures";
import { AppointmentStatus, CalendarSyncStatus } from "@/generated/prisma/enums";
import { encryptToken } from "@/lib/security/token-encryption";
import { MockCalendarProvider } from "../mock-calendar.provider";
import { runInitialSync, syncAppointmentSafely } from "../calendar-sync.service";
/**
* Exercises the real sync policy (create/update/cancel/duplicate
* prevention/reassignment) against a real database, with a
* `MockCalendarProvider` standing in for Google — see that file's own
* header for why (the brief: "Do not call real Google Calendar APIs in
* automated tests").
*/
describe("calendar-sync.service (integration)", () => {
let business: TestBusiness;
let staffId: string;
let customerId: string;
let provider: MockCalendarProvider;
beforeAll(() => {
process.env.CALENDAR_TOKEN_ENCRYPTION_KEY = "test-key-not-for-production-use";
});
beforeEach(async () => {
business = await createTestBusiness();
const staff = await createTestStaff(business);
staffId = staff.staffId;
const customer = await createTestCustomer(business);
customerId = customer.customerId;
provider = new MockCalendarProvider();
});
afterEach(async () => {
await cleanupTestBusiness(business);
});
async function connectCalendar(): Promise<string> {
const connection = await prisma.staffCalendarConnection.create({
data: {
staffId,
externalAccountId: "mock-account",
accountEmail: "barber@example.com",
encryptedAccessToken: encryptToken("mock-access-token"),
encryptedRefreshToken: encryptToken("mock-refresh-token"),
tokenExpiresAt: new Date(Date.now() + 3600_000),
selectedCalendarId: "primary",
selectedCalendarName: "Primary Calendar",
},
});
return connection.id;
}
async function createAppointment(status: AppointmentStatus, overrides: { staffId?: string } = {}) {
return prisma.appointment.create({
data: {
businessId: business.businessId,
staffId: overrides.staffId ?? staffId,
customerId,
status,
startAt: new Date(Date.now() + 24 * 60 * 60 * 1000),
endAt: new Date(Date.now() + 24 * 60 * 60 * 1000 + 30 * 60_000),
services: { create: [{ serviceId: business.haircutServiceId, priceAtBooking: 20, durationAtBookingMinutes: 30 }] },
},
});
}
it("creates a remote event for a CONFIRMED appointment", async () => {
await connectCalendar();
const appointment = await createAppointment(AppointmentStatus.CONFIRMED);
await syncAppointmentSafely(prisma, appointment.id, provider);
const event = await prisma.appointmentCalendarEvent.findFirst({ where: { appointmentId: appointment.id } });
expect(event?.status).toBe(CalendarSyncStatus.SYNCED);
expect(provider.events.size).toBe(1);
const [providerEvent] = [...provider.events.values()];
expect(providerEvent?.summary).toContain("Test Customer");
});
it("does not sync a PENDING appointment", async () => {
await connectCalendar();
const appointment = await createAppointment(AppointmentStatus.PENDING);
await syncAppointmentSafely(prisma, appointment.id, provider);
const event = await prisma.appointmentCalendarEvent.findFirst({ where: { appointmentId: appointment.id } });
expect(event).toBeNull();
expect(provider.events.size).toBe(0);
});
it("is idempotent — syncing an already-synced appointment updates the same remote event rather than creating a second one", async () => {
await connectCalendar();
const appointment = await createAppointment(AppointmentStatus.CONFIRMED);
await syncAppointmentSafely(prisma, appointment.id, provider);
await syncAppointmentSafely(prisma, appointment.id, provider);
const events = await prisma.appointmentCalendarEvent.findMany({ where: { appointmentId: appointment.id } });
expect(events).toHaveLength(1);
expect(provider.events.size).toBe(1);
});
it("deletes the remote event when the appointment is cancelled", async () => {
await connectCalendar();
const appointment = await createAppointment(AppointmentStatus.CONFIRMED);
await syncAppointmentSafely(prisma, appointment.id, provider);
expect(provider.events.size).toBe(1);
await prisma.appointment.update({ where: { id: appointment.id }, data: { status: AppointmentStatus.CANCELLED } });
await syncAppointmentSafely(prisma, appointment.id, provider);
expect(provider.events.size).toBe(0);
const event = await prisma.appointmentCalendarEvent.findFirst({ where: { appointmentId: appointment.id } });
expect(event?.status).toBe(CalendarSyncStatus.CANCELLED);
});
it("cancels the previous staff member's event on reassignment and creates one under the new staff member's connection", async () => {
await connectCalendar();
const otherStaff = await createTestStaff(business);
const otherConnection = await prisma.staffCalendarConnection.create({
data: {
staffId: otherStaff.staffId,
externalAccountId: "mock-account-2",
accountEmail: "other-barber@example.com",
encryptedAccessToken: encryptToken("mock-access-token-2"),
encryptedRefreshToken: encryptToken("mock-refresh-token-2"),
tokenExpiresAt: new Date(Date.now() + 3600_000),
selectedCalendarId: "primary",
selectedCalendarName: "Primary Calendar",
},
});
const appointment = await createAppointment(AppointmentStatus.CONFIRMED);
await syncAppointmentSafely(prisma, appointment.id, provider);
expect(provider.events.size).toBe(1);
await prisma.appointment.update({ where: { id: appointment.id }, data: { staffId: otherStaff.staffId } });
await syncAppointmentSafely(prisma, appointment.id, provider);
const originalConnectionEvent = await prisma.appointmentCalendarEvent.findFirst({
where: { appointmentId: appointment.id, connection: { staffId } },
});
expect(originalConnectionEvent?.status).toBe(CalendarSyncStatus.CANCELLED);
const newConnectionEvent = await prisma.appointmentCalendarEvent.findFirst({
where: { appointmentId: appointment.id, connectionId: otherConnection.id },
});
expect(newConnectionEvent?.status).toBe(CalendarSyncStatus.SYNCED);
await prisma.staffCalendarConnection.delete({ where: { id: otherConnection.id } }).catch(() => {});
});
it("runInitialSync syncs every future non-pending, non-cancelled appointment for a newly connected staff member", async () => {
await connectCalendar();
const confirmed = await createAppointment(AppointmentStatus.CONFIRMED);
const pending = await createAppointment(AppointmentStatus.PENDING);
const cancelled = await createAppointment(AppointmentStatus.CANCELLED);
const result = await runInitialSync(prisma, staffId, provider);
expect(result.synced).toBe(1);
expect(provider.events.size).toBe(1);
const syncedEvent = await prisma.appointmentCalendarEvent.findFirst({ where: { appointmentId: confirmed.id } });
expect(syncedEvent).toBeTruthy();
const pendingEvent = await prisma.appointmentCalendarEvent.findFirst({ where: { appointmentId: pending.id } });
expect(pendingEvent).toBeNull();
const cancelledEvent = await prisma.appointmentCalendarEvent.findFirst({ where: { appointmentId: cancelled.id } });
expect(cancelledEvent).toBeNull();
});
it("does nothing when no calendar connection exists for the staff member", async () => {
const appointment = await createAppointment(AppointmentStatus.CONFIRMED);
await syncAppointmentSafely(prisma, appointment.id, provider);
const event = await prisma.appointmentCalendarEvent.findFirst({ where: { appointmentId: appointment.id } });
expect(event).toBeNull();
expect(provider.events.size).toBe(0);
});
});