188 lines
8.0 KiB
TypeScript
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);
|
|
});
|
|
});
|