import type { CalendarSubscriptionEventItem } from "@calcom/features/calendar-subscription/lib/CalendarSubscriptionPort.interface"; import type { BookingRepository } from "@calcom/lib/server/repository/booking"; import type { SelectedCalendar } from "@calcom/prisma/client"; import { beforeEach, describe, expect, test, vi } from "vitest"; import { CalendarSyncService } from "../CalendarSyncService"; const { mockHandleCancelBooking, mockCreateBooking } = vi.hoisted(() => ({ mockHandleCancelBooking: vi.fn().mockResolvedValue(undefined), mockCreateBooking: vi.fn().mockResolvedValue(undefined), })); vi.mock("@calcom/features/bookings/lib/handleCancelBooking", () => ({ default: mockHandleCancelBooking, })); vi.mock("@calcom/features/bookings/di/RegularBookingService.container", () => ({ getRegularBookingService: () => ({ createBooking: mockCreateBooking, }), })); vi.mock("@calcom/lib/idempotencyKey/idempotencyKeyService", () => ({ IdempotencyKeyService: { generate: vi.fn(() => "test-idempotency-key"), }, })); vi.mock("@sentry/nextjs", () => ({ metrics: { count: vi.fn(), distribution: vi.fn(), }, })); const mockSelectedCalendar: SelectedCalendar = { id: "test-calendar-id", userId: 1, credentialId: 1, integration: "google_calendar", externalId: "test@example.com", eventTypeId: null, delegationCredentialId: null, googleChannelId: null, googleChannelKind: null, googleChannelResourceId: null, googleChannelResourceUri: null, googleChannelExpiration: null, error: null, lastErrorAt: null, watchAttempts: 0, maxAttempts: 3, unwatchAttempts: 0, createdAt: new Date(), updatedAt: new Date(), channelId: "test-channel-id", channelKind: "web_hook", channelResourceId: "test-resource-id", channelResourceUri: "test-resource-uri", channelExpiration: new Date(Date.now() + 86400000), syncSubscribedAt: new Date(), syncToken: "test-sync-token", syncedAt: new Date(), syncErrorAt: null, syncErrorCount: 0, }; const mockBooking = { id: 1, uid: "test-booking-uid", userId: 1, userPrimaryEmail: "user@example.com", startTime: new Date("2023-12-01T10:00:00Z"), endTime: new Date("2023-12-01T11:00:00Z"), title: "Test Booking", description: "Initial notes", location: "Test Location", smsReminderNumber: "+123456789", responses: { name: "John Doe", email: "john@example.com", notes: "Initial notes", location: { value: "Test Location", label: "Test Location", optionValue: "Test Location", }, }, eventTypeId: 1, eventType: { id: 1, title: "Test Event Type", }, }; const mockCalComEvent: CalendarSubscriptionEventItem = { id: "event-1", iCalUID: "test-booking-uid@cal.com", start: new Date("2023-12-01T10:00:00Z"), end: new Date("2023-12-01T11:00:00Z"), busy: true, summary: "Test Event", description: "Test Description", location: "Test Location", status: "confirmed", isAllDay: false, timeZone: "UTC", recurringEventId: null, originalStartDate: null, createdAt: new Date(), updatedAt: new Date(), etag: "test-etag", kind: "calendar#event", }; const mockNonCalComEvent: CalendarSubscriptionEventItem = { id: "event-2", iCalUID: "external-event@external.com", start: new Date("2023-12-01T12:00:00Z"), end: new Date("2023-12-01T13:00:00Z"), busy: true, summary: "External Event", description: "External Description", location: "External Location", status: "confirmed", isAllDay: false, timeZone: "UTC", recurringEventId: null, originalStartDate: null, createdAt: new Date(), updatedAt: new Date(), etag: "test-etag", kind: "calendar#event", }; const mockCancelledEvent: CalendarSubscriptionEventItem = { ...mockCalComEvent, id: "event-3", iCalUID: "cancelled-booking-uid@cal.com", status: "cancelled", }; describe("CalendarSyncService", () => { let service: CalendarSyncService; let mockBookingRepository: BookingRepository; beforeEach(() => { mockBookingRepository = { findBookingByUidWithEventType: vi.fn(), } as unknown as BookingRepository; service = new CalendarSyncService({ bookingRepository: mockBookingRepository, }); vi.clearAllMocks(); }); describe("handleEvents", () => { test("should process only Cal.diy events", async () => { const events = [mockCalComEvent, mockNonCalComEvent, mockCancelledEvent]; mockBookingRepository.findBookingByUidWithEventType = vi .fn() .mockResolvedValueOnce(mockBooking) .mockResolvedValueOnce(mockBooking); await service.handleEvents(mockSelectedCalendar, events); expect(mockBookingRepository.findBookingByUidWithEventType).toHaveBeenCalledTimes(2); expect(mockBookingRepository.findBookingByUidWithEventType).toHaveBeenCalledWith({ bookingUid: "test-booking-uid", }); expect(mockBookingRepository.findBookingByUidWithEventType).toHaveBeenCalledWith({ bookingUid: "cancelled-booking-uid", }); }); test("should return early when no Cal.diy events", async () => { const events = [mockNonCalComEvent]; await service.handleEvents(mockSelectedCalendar, events); expect(mockBookingRepository.findBookingByUidWithEventType).not.toHaveBeenCalled(); }); test("should return early when no events", async () => { await service.handleEvents(mockSelectedCalendar, []); expect(mockBookingRepository.findBookingByUidWithEventType).not.toHaveBeenCalled(); }); test("should handle mixed case iCalUID", async () => { const eventWithMixedCase: CalendarSubscriptionEventItem = { ...mockCalComEvent, iCalUID: "test-booking-uid@CAL.COM", }; mockBookingRepository.findBookingByUidWithEventType = vi.fn().mockResolvedValue(mockBooking); await service.handleEvents(mockSelectedCalendar, [eventWithMixedCase]); expect(mockBookingRepository.findBookingByUidWithEventType).toHaveBeenCalledWith({ bookingUid: "test-booking-uid", }); }); test("should handle default Cal.diy iCalUID", async () => { const eventWithCalDiyUID: CalendarSubscriptionEventItem = { ...mockCalComEvent, iCalUID: "test-booking-uid@Cal.diy", }; mockBookingRepository.findBookingByUidWithEventType = vi.fn().mockResolvedValue(mockBooking); await service.handleEvents(mockSelectedCalendar, [eventWithCalDiyUID]); expect(mockBookingRepository.findBookingByUidWithEventType).toHaveBeenCalledWith({ bookingUid: "test-booking-uid", }); }); test("should handle events with null iCalUID", async () => { const eventWithNullUID: CalendarSubscriptionEventItem = { ...mockCalComEvent, iCalUID: null, }; await service.handleEvents(mockSelectedCalendar, [eventWithNullUID]); expect(mockBookingRepository.findBookingByUidWithEventType).not.toHaveBeenCalled(); }); }); describe("cancelBooking", () => { test("should successfully cancel a booking", async () => { mockBookingRepository.findBookingByUidWithEventType = vi.fn().mockResolvedValue(mockBooking); await service.cancelBooking(mockCancelledEvent, mockSelectedCalendar.userId); expect(mockBookingRepository.findBookingByUidWithEventType).toHaveBeenCalledWith({ bookingUid: "cancelled-booking-uid", }); expect(mockHandleCancelBooking).toHaveBeenCalledWith({ userId: mockBooking.userId, bookingData: { uid: mockBooking.uid, cancellationReason: "Cancelled on user's calendar", cancelledBy: mockBooking.userPrimaryEmail, skipCalendarSyncTaskCancellation: true, }, }); }); test("should return early when booking UID is missing", async () => { const eventWithoutUID: CalendarSubscriptionEventItem = { ...mockCancelledEvent, iCalUID: null, }; await service.cancelBooking(eventWithoutUID, mockSelectedCalendar.userId); expect(mockBookingRepository.findBookingByUidWithEventType).not.toHaveBeenCalled(); expect(mockHandleCancelBooking).not.toHaveBeenCalled(); }); test("should return early when booking UID is malformed", async () => { const eventWithMalformedUID: CalendarSubscriptionEventItem = { ...mockCancelledEvent, iCalUID: "@cal.com", }; await service.cancelBooking(eventWithMalformedUID, mockSelectedCalendar.userId); expect(mockBookingRepository.findBookingByUidWithEventType).not.toHaveBeenCalled(); expect(mockHandleCancelBooking).not.toHaveBeenCalled(); }); test("should return early when booking is not found", async () => { mockBookingRepository.findBookingByUidWithEventType = vi.fn().mockResolvedValue(null); await service.cancelBooking(mockCancelledEvent, mockSelectedCalendar.userId); expect(mockBookingRepository.findBookingByUidWithEventType).toHaveBeenCalledWith({ bookingUid: "cancelled-booking-uid", }); expect(mockHandleCancelBooking).not.toHaveBeenCalled(); }); test("should handle cancellation errors gracefully without throwing", async () => { mockBookingRepository.findBookingByUidWithEventType = vi.fn().mockResolvedValue(mockBooking); mockHandleCancelBooking.mockRejectedValue(new Error("Cancellation failed")); // Should not throw - errors are caught and logged await expect( service.cancelBooking(mockCancelledEvent, mockSelectedCalendar.userId) ).resolves.not.toThrow(); expect(mockBookingRepository.findBookingByUidWithEventType).toHaveBeenCalled(); expect(mockHandleCancelBooking).toHaveBeenCalled(); }); test("should handle database errors gracefully without throwing", async () => { mockBookingRepository.findBookingByUidWithEventType = vi .fn() .mockRejectedValue(new Error("DB connection failed")); await expect( service.cancelBooking(mockCancelledEvent, mockSelectedCalendar.userId) ).resolves.not.toThrow(); expect(mockBookingRepository.findBookingByUidWithEventType).toHaveBeenCalled(); expect(mockHandleCancelBooking).not.toHaveBeenCalled(); }); }); describe("rescheduleBooking", () => { test("should successfully reschedule a booking preserving original duration", async () => { const updatedEvent: CalendarSubscriptionEventItem = { ...mockCalComEvent, start: new Date("2023-12-01T14:00:00Z"), end: new Date("2023-12-01T15:00:00Z"), summary: "Updated summary", description: "Updated description", location: "Updated location", }; mockBookingRepository.findBookingByUidWithEventType = vi.fn().mockResolvedValue(mockBooking); await service.rescheduleBooking(updatedEvent, mockSelectedCalendar.userId); expect(mockBookingRepository.findBookingByUidWithEventType).toHaveBeenCalledWith({ bookingUid: "test-booking-uid", }); // Original booking is 10:00-11:00 (60min). New start is 14:00, so end should be 15:00 (60min preserved). expect(mockCreateBooking).toHaveBeenCalledWith({ bookingData: { eventTypeId: mockBooking.eventTypeId, start: "2023-12-01T14:00:00.000Z", end: "2023-12-01T15:00:00.000Z", timeZone: "UTC", language: "en", metadata: expect.objectContaining({ calendarSubscriptionEvent: expect.any(String), }), rescheduleUid: mockBooking.uid, idempotencyKey: expect.any(String), responses: expect.objectContaining({ title: "Updated summary", notes: "Updated description", location: { label: "Updated location", optionValue: "Updated location", value: "Updated location", }, }), }, bookingMeta: { skipCalendarSyncTaskCreation: true, skipAvailabilityCheck: true, skipEventLimitsCheck: true, }, }); const lastCall = mockCreateBooking.mock.calls.at(-1)!; const parsedMetadataUpdated = JSON.parse( lastCall[0].bookingData.metadata.calendarSubscriptionEvent as string ); expect(parsedMetadataUpdated).toEqual( expect.objectContaining({ summary: "Updated summary", description: "Updated description", location: "Updated location", }) ); }); test("should preserve original duration when external event has different duration", async () => { const stretchedEvent: CalendarSubscriptionEventItem = { ...mockCalComEvent, start: new Date("2023-12-01T14:00:00Z"), end: new Date("2023-12-01T15:30:00Z"), // 90min, but original booking is 60min }; mockBookingRepository.findBookingByUidWithEventType = vi.fn().mockResolvedValue(mockBooking); await service.rescheduleBooking(stretchedEvent, mockSelectedCalendar.userId); // Original booking is 60min. External event is 90min. Duration should stay 60min. expect(mockCreateBooking).toHaveBeenCalledWith( expect.objectContaining({ bookingData: expect.objectContaining({ start: "2023-12-01T14:00:00.000Z", end: "2023-12-01T15:00:00.000Z", // 14:00 + 60min = 15:00, NOT 15:30 }), }) ); }); test("should skip reschedule when event times are null (no change detected)", async () => { const eventWithNullTimes: CalendarSubscriptionEventItem = { ...mockCalComEvent, start: null, end: null, }; mockBookingRepository.findBookingByUidWithEventType = vi.fn().mockResolvedValue(mockBooking); await service.rescheduleBooking(eventWithNullTimes, mockSelectedCalendar.userId); // Null start means no change detected, reschedule is skipped expect(mockCreateBooking).not.toHaveBeenCalled(); }); test("should build fallback responses when booking responses are missing", async () => { const bookingWithoutResponses = { ...mockBooking, responses: null, }; const eventWithDifferentStart: CalendarSubscriptionEventItem = { ...mockCalComEvent, start: new Date("2023-12-01T14:00:00Z"), end: new Date("2023-12-01T15:00:00Z"), }; mockBookingRepository.findBookingByUidWithEventType = vi .fn() .mockResolvedValue(bookingWithoutResponses); await service.rescheduleBooking(eventWithDifferentStart, mockSelectedCalendar.userId); expect(mockCreateBooking).toHaveBeenCalledWith({ bookingData: expect.objectContaining({ responses: expect.objectContaining({ name: bookingWithoutResponses.title, email: bookingWithoutResponses.userPrimaryEmail, guests: [], notes: mockCalComEvent.description, smsReminderNumber: bookingWithoutResponses.smsReminderNumber, location: { label: mockCalComEvent.location, value: mockCalComEvent.location, optionValue: mockCalComEvent.location, }, title: mockCalComEvent.summary, }), metadata: expect.objectContaining({ calendarSubscriptionEvent: expect.any(String), }), idempotencyKey: expect.any(String), }), bookingMeta: { skipCalendarSyncTaskCreation: true, skipAvailabilityCheck: true, skipEventLimitsCheck: true, }, }); const baseCall = mockCreateBooking.mock.calls.at(-1)!; const parsedMetadataBase = JSON.parse( baseCall[0].bookingData.metadata.calendarSubscriptionEvent as string ); expect(parsedMetadataBase.summary).toEqual(mockCalComEvent.summary); }); test("should return early when booking UID is missing", async () => { const eventWithoutUID: CalendarSubscriptionEventItem = { ...mockCalComEvent, iCalUID: null, }; await service.rescheduleBooking(eventWithoutUID, mockSelectedCalendar.userId); expect(mockBookingRepository.findBookingByUidWithEventType).not.toHaveBeenCalled(); expect(mockCreateBooking).not.toHaveBeenCalled(); }); test("should return early when booking UID is malformed", async () => { const eventWithMalformedUID: CalendarSubscriptionEventItem = { ...mockCalComEvent, iCalUID: "@cal.com", }; await service.rescheduleBooking(eventWithMalformedUID, mockSelectedCalendar.userId); expect(mockBookingRepository.findBookingByUidWithEventType).not.toHaveBeenCalled(); expect(mockCreateBooking).not.toHaveBeenCalled(); }); test("should return early when booking is not found", async () => { mockBookingRepository.findBookingByUidWithEventType = vi.fn().mockResolvedValue(null); await service.rescheduleBooking(mockCalComEvent, mockSelectedCalendar.userId); expect(mockBookingRepository.findBookingByUidWithEventType).toHaveBeenCalledWith({ bookingUid: "test-booking-uid", }); expect(mockCreateBooking).not.toHaveBeenCalled(); }); test("should handle rescheduling errors gracefully without throwing", async () => { const eventWithDifferentStart: CalendarSubscriptionEventItem = { ...mockCalComEvent, start: new Date("2023-12-01T14:00:00Z"), end: new Date("2023-12-01T15:00:00Z"), }; mockBookingRepository.findBookingByUidWithEventType = vi.fn().mockResolvedValue(mockBooking); mockCreateBooking.mockRejectedValue(new Error("Rescheduling failed")); // Should not throw - errors are caught and logged await expect( service.rescheduleBooking(eventWithDifferentStart, mockSelectedCalendar.userId) ).resolves.not.toThrow(); expect(mockBookingRepository.findBookingByUidWithEventType).toHaveBeenCalled(); expect(mockCreateBooking).toHaveBeenCalled(); }); test("should handle database errors gracefully without throwing", async () => { mockBookingRepository.findBookingByUidWithEventType = vi .fn() .mockRejectedValue(new Error("DB connection failed")); await expect( service.rescheduleBooking(mockCalComEvent, mockSelectedCalendar.userId) ).resolves.not.toThrow(); expect(mockBookingRepository.findBookingByUidWithEventType).toHaveBeenCalled(); expect(mockCreateBooking).not.toHaveBeenCalled(); }); }); });