diff --git a/packages/features/bookings/lib/service/RegularBookingService.test.ts b/packages/features/bookings/lib/service/RegularBookingService.test.ts new file mode 100644 index 00000000000000..ab14c020229267 --- /dev/null +++ b/packages/features/bookings/lib/service/RegularBookingService.test.ts @@ -0,0 +1,267 @@ +import { + createBookingScenario, + getBooker, + getDate, + getGoogleCalendarCredential, + getOrganizer, + getScenarioData, + mockCalendarToCrashOnCreateEvent, + mockCalendarToHaveNoBusySlots, + mockSuccessfulVideoMeetingCreation, + TestData, +} from "@calcom/testing/lib/bookingScenario/bookingScenario"; +import process from "node:process"; +import { getRegularBookingService } from "@calcom/features/bookings/di/RegularBookingService.container"; +import { BookingStatus } from "@calcom/prisma/enums"; +import { expectBookingToBeInDatabase } from "@calcom/testing/lib/bookingScenario/expects"; +import { getMockRequestDataForBooking } from "@calcom/testing/lib/bookingScenario/getMockRequestDataForBooking"; +import { setupAndTeardown } from "@calcom/testing/lib/bookingScenario/setupAndTeardown"; +import { test } from "@calcom/testing/lib/fixtures/fixtures"; +import { describe } from "vitest"; + +const timeout = process.env.CI ? 5000 : 20000; + +describe("RegularBookingService", () => { + setupAndTeardown(); + + describe("Calendar sync failure handling", () => { + test( + `should set booking to PENDING when all calendar event creations fail`, + async () => { + const booker = getBooker({ + email: "booker@example.com", + name: "Booker", + }); + + const organizer = getOrganizer({ + name: "Organizer", + email: "organizer@example.com", + id: 101, + schedules: [TestData.schedules.IstWorkHours], + credentials: [getGoogleCalendarCredential()], + selectedCalendars: [TestData.selectedCalendars.google], + destinationCalendar: { + integration: "google_calendar", + externalId: "organizer@google-calendar.com", + }, + }); + + const plus1DateString = getDate({ dateIncrement: 1 }).dateString; + + await createBookingScenario( + getScenarioData({ + eventTypes: [ + { + id: 1, + slotInterval: 30, + length: 30, + users: [{ id: 101 }], + destinationCalendar: { + integration: "google_calendar", + externalId: "event-type-1@google-calendar.com", + }, + }, + ], + organizer, + apps: [TestData.apps["google-calendar"], TestData.apps["daily-video"]], + }) + ); + + mockSuccessfulVideoMeetingCreation({ + metadataLookupKey: "dailyvideo", + videoMeetingData: { + id: "MOCK_ID", + password: "MOCK_PASS", + url: `http://mock-dailyvideo.example.com/meeting-1`, + }, + }); + + // Mock calendar to CRASH on event creation — simulating API failure + await mockCalendarToCrashOnCreateEvent("googlecalendar"); + + const mockBookingData = getMockRequestDataForBooking({ + data: { + eventTypeId: 1, + start: `${plus1DateString}T04:00:00.000Z`, + end: `${plus1DateString}T04:30:00.000Z`, + responses: { + email: booker.email, + name: booker.name, + location: { optionValue: "", value: "integrations:daily" }, + }, + }, + }); + + const regularBookingService = getRegularBookingService(); + const createdBooking = await regularBookingService.createBooking({ + bookingData: mockBookingData, + }); + + // After fix: booking should be PENDING when calendar sync fails, + // so the user knows the calendar event was not created. + await expectBookingToBeInDatabase({ + uid: createdBooking.uid, + status: BookingStatus.PENDING, + }); + }, + timeout + ); + + test( + `should create booking successfully when calendar event creation succeeds`, + async () => { + const booker = getBooker({ + email: "booker@example.com", + name: "Booker", + }); + + const organizer = getOrganizer({ + name: "Organizer", + email: "organizer@example.com", + id: 101, + schedules: [TestData.schedules.IstWorkHours], + credentials: [getGoogleCalendarCredential()], + selectedCalendars: [TestData.selectedCalendars.google], + destinationCalendar: { + integration: "google_calendar", + externalId: "organizer@google-calendar.com", + }, + }); + + const plus1DateString = getDate({ dateIncrement: 1 }).dateString; + + await createBookingScenario( + getScenarioData({ + eventTypes: [ + { + id: 1, + slotInterval: 30, + length: 30, + users: [{ id: 101 }], + destinationCalendar: { + integration: "google_calendar", + externalId: "event-type-1@google-calendar.com", + }, + }, + ], + organizer, + apps: [TestData.apps["google-calendar"], TestData.apps["daily-video"]], + }) + ); + + mockSuccessfulVideoMeetingCreation({ + metadataLookupKey: "dailyvideo", + videoMeetingData: { + id: "MOCK_ID", + password: "MOCK_PASS", + url: `http://mock-dailyvideo.example.com/meeting-1`, + }, + }); + + // Mock calendar to succeed + await mockCalendarToHaveNoBusySlots("googlecalendar", { + create: { + id: "MOCKED_GOOGLE_CALENDAR_EVENT_ID", + iCalUID: "MOCKED_GOOGLE_CALENDAR_ICS_ID", + }, + }); + + const mockBookingData = getMockRequestDataForBooking({ + data: { + eventTypeId: 1, + start: `${plus1DateString}T04:00:00.000Z`, + end: `${plus1DateString}T04:30:00.000Z`, + responses: { + email: booker.email, + name: booker.name, + location: { optionValue: "", value: "integrations:daily" }, + }, + }, + }); + + const regularBookingService = getRegularBookingService(); + const createdBooking = await regularBookingService.createBooking({ + bookingData: mockBookingData, + }); + + // Happy path: booking should be ACCEPTED with calendar references + await expectBookingToBeInDatabase({ + uid: createdBooking.uid, + status: BookingStatus.ACCEPTED, + }); + }, + timeout + ); + + test( + `should set booking to PENDING when calendar fails and no video integration is configured`, + async () => { + const booker = getBooker({ + email: "booker@example.com", + name: "Booker", + }); + + const organizer = getOrganizer({ + name: "Organizer", + email: "organizer@example.com", + id: 101, + schedules: [TestData.schedules.IstWorkHours], + credentials: [getGoogleCalendarCredential()], + selectedCalendars: [TestData.selectedCalendars.google], + destinationCalendar: { + integration: "google_calendar", + externalId: "organizer@google-calendar.com", + }, + }); + + const plus1DateString = getDate({ dateIncrement: 1 }).dateString; + + await createBookingScenario( + getScenarioData({ + eventTypes: [ + { + id: 1, + slotInterval: 30, + length: 30, + users: [{ id: 101 }], + destinationCalendar: { + integration: "google_calendar", + externalId: "event-type-1@google-calendar.com", + }, + }, + ], + organizer, + apps: [TestData.apps["google-calendar"]], + }) + ); + + // No video mock — calendar-only scenario + await mockCalendarToCrashOnCreateEvent("googlecalendar"); + + const mockBookingData = getMockRequestDataForBooking({ + data: { + eventTypeId: 1, + start: `${plus1DateString}T04:00:00.000Z`, + end: `${plus1DateString}T04:30:00.000Z`, + responses: { + email: booker.email, + name: booker.name, + location: { optionValue: "", value: "attendee" }, + }, + }, + }); + + const regularBookingService = getRegularBookingService(); + const createdBooking = await regularBookingService.createBooking({ + bookingData: mockBookingData, + }); + + await expectBookingToBeInDatabase({ + uid: createdBooking.uid, + status: BookingStatus.PENDING, + }); + }, + timeout + ); + }); +}); diff --git a/packages/features/bookings/lib/service/RegularBookingService.ts b/packages/features/bookings/lib/service/RegularBookingService.ts index 644a80a3e3e1bf..fa1904a367d337 100644 --- a/packages/features/bookings/lib/service/RegularBookingService.ts +++ b/packages/features/bookings/lib/service/RegularBookingService.ts @@ -2061,6 +2061,12 @@ async function handler( referencesToCreate = createManager.referencesToCreate; videoCallUrl = evt.videoCallData?.url ? evt.videoCallData.url : null; + const calendarResults = results.filter((res) => res.type.includes("_calendar")); + // Only downgrade to PENDING when ALL calendar integrations fail — if at least one + // calendar got the event, the organizer still has visibility on the booking. + const allCalendarEventsFailed = + calendarResults.length > 0 && calendarResults.every((res) => !res.success); + if (results.length > 0 && results.every((res) => !res.success)) { const error = { errorCode: "BookingCreatingMeetingFailed", @@ -2071,7 +2077,22 @@ async function handler( `EventManager.create failure in some of the integrations ${organizerUser.username}`, safeStringify({ error, results }) ); - } else { + } + + if (allCalendarEventsFailed) { + await deps.prismaClient.booking.update({ + where: { id: booking.id }, + data: { status: BookingStatus.PENDING }, + }); + booking.status = BookingStatus.PENDING; + + tracingLogger.error( + `Calendar event creation failed for all calendars, booking ${booking.uid} set to PENDING`, + safeStringify({ results: calendarResults }) + ); + } + + if (!results.every((res) => !res.success)) { const additionalInformation: AdditionalInformation = {}; if (results.length) { @@ -2148,7 +2169,7 @@ async function handler( }); } } - if (!noEmail) { + if (!noEmail && !allCalendarEventsFailed) { if (!isDryRun && !(eventType.seatsPerTimeSlot && rescheduleUid)) { await emailsAndSmsHandler.send({ action: BookingActionMap.confirmed, @@ -2245,7 +2266,7 @@ async function handler( : undefined, metadata: { ...metadata, ...reqBody.metadata }, eventTypeId, - status: "ACCEPTED", + status: booking?.status === BookingStatus.PENDING ? "PENDING" : "ACCEPTED", smsReminderNumber: booking?.smsReminderNumber || undefined, rescheduledBy: reqBody.rescheduledBy, location: webhookLocation, @@ -2430,13 +2451,15 @@ async function handler( } // Send Webhook call if hooked to BOOKING_CREATED & BOOKING_RESCHEDULED - await handleWebhookTrigger({ - subscriberOptions, - eventTrigger, - webhookData, - isDryRun, - traceContext, - }); + if (booking?.status === BookingStatus.ACCEPTED) { + await handleWebhookTrigger({ + subscriberOptions, + eventTrigger, + webhookData, + isDryRun, + traceContext, + }); + } } if (!booking) throw new HttpError({ statusCode: 400, message: "Booking failed" }); @@ -2492,7 +2515,7 @@ async function handler( }; try { - if (isConfirmedByDefault) { + if (isConfirmedByDefault && booking.status === BookingStatus.ACCEPTED) { await scheduleNoShowTriggers({ booking: { startTime: booking.startTime,