Phase 8
This commit is contained in:
82 files changed
+4903
-287
No files matched your search
@@ -0,0 +1,187 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user