From caf1b4c5d7ce58e1f71a05705e6a5c0980c92321 Mon Sep 17 00:00:00 2001 From: Hariom Balhara Date: Sat, 30 Sep 2023 18:58:52 +0530 Subject: [PATCH] test: More tests in booking flow (#11510) --- .../utils/bookingScenario/bookingScenario.ts | 181 +- .../web/test/utils/bookingScenario/expects.ts | 117 +- packages/core/CalendarManager.ts | 11 +- packages/core/EventManager.ts | 27 +- packages/core/getCalendarsEvents.ts | 9 + packages/core/videoClient.ts | 3 +- .../bookings/lib/handleNewBooking.test.ts | 1687 ++++++++++++++--- .../features/bookings/lib/handleNewBooking.ts | 12 +- packages/lib/piiFreeData.ts | 29 + packages/lib/safeStringify.ts | 8 + 10 files changed, 1716 insertions(+), 368 deletions(-) create mode 100644 packages/lib/safeStringify.ts diff --git a/apps/web/test/utils/bookingScenario/bookingScenario.ts b/apps/web/test/utils/bookingScenario/bookingScenario.ts index ce11e1b89b519f..9aebffa764c3f9 100644 --- a/apps/web/test/utils/bookingScenario/bookingScenario.ts +++ b/apps/web/test/utils/bookingScenario/bookingScenario.ts @@ -10,7 +10,7 @@ import "vitest-fetch-mock"; import { appStoreMetadata } from "@calcom/app-store/appStoreMetaData"; import { handleStripePaymentSuccess } from "@calcom/features/ee/payments/api/webhook"; -import { HttpError } from "@calcom/lib/http-error"; +import type { HttpError } from "@calcom/lib/http-error"; import logger from "@calcom/lib/logger"; import type { SchedulingType } from "@calcom/prisma/enums"; import type { BookingStatus } from "@calcom/prisma/enums"; @@ -74,6 +74,7 @@ type InputUser = typeof TestData.users.example & { id: number } & { }[]; timeZone: string; }[]; + destinationCalendar?: Prisma.DestinationCalendarCreateInput; }; export type InputEventType = { @@ -92,6 +93,7 @@ export type InputEventType = { beforeEventBuffer?: number; afterEventBuffer?: number; requiresConfirmation?: boolean; + destinationCalendar?: Prisma.DestinationCalendarCreateInput; } & Partial>; type InputBooking = { @@ -124,17 +126,31 @@ const Timezones = { logger.setSettings({ minLevel: "silly" }); async function addEventTypesToDb( - eventTypes: (Prisma.EventTypeCreateInput & { + eventTypes: (Omit & { // eslint-disable-next-line @typescript-eslint/no-explicit-any users?: any[]; // eslint-disable-next-line @typescript-eslint/no-explicit-any workflows?: any[]; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + destinationCalendar?: any; })[] ) { logger.silly("TestData: Add EventTypes to DB", JSON.stringify(eventTypes)); await prismock.eventType.createMany({ data: eventTypes, }); + logger.silly( + "TestData: All EventTypes in DB are", + JSON.stringify({ + eventTypes: await prismock.eventType.findMany({ + include: { + users: true, + workflows: true, + destinationCalendar: true, + }, + }), + }) + ); } async function addEventTypes(eventTypes: InputEventType[], usersStore: InputUser[]) { @@ -174,6 +190,11 @@ async function addEventTypes(eventTypes: InputEventType[], usersStore: InputUser ...eventType, workflows: [], users, + destinationCalendar: eventType.destinationCalendar + ? { + create: eventType.destinationCalendar, + } + : eventType.destinationCalendar, }; }); logger.silly("TestData: Creating EventType", JSON.stringify(eventTypesWithUsers)); @@ -266,6 +287,9 @@ async function addUsersToDb(users: (Prisma.UserCreateInput & { schedules: Prisma await prismock.user.createMany({ data: users, }); + logger.silly("Added users to Db", { + allUsers: await prismock.user.findMany(), + }); } async function addUsers(users: InputUser[]) { @@ -298,6 +322,15 @@ async function addUsers(users: InputUser[]) { }, }; } + if (user.selectedCalendars) { + newUser.selectedCalendars = { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + //@ts-ignore + createMany: { + data: user.selectedCalendars, + }, + }; + } return newUser; }); // eslint-disable-next-line @typescript-eslint/ban-ts-comment @@ -569,6 +602,7 @@ export function getOrganizer({ schedules, credentials, selectedCalendars, + destinationCalendar, }: { name: string; email: string; @@ -576,6 +610,7 @@ export function getOrganizer({ schedules: InputUser["schedules"]; credentials?: InputCredential[]; selectedCalendars?: InputSelectedCalendar[]; + destinationCalendar?: Prisma.DestinationCalendarCreateInput; }) { return { ...TestData.users.example, @@ -585,6 +620,7 @@ export function getOrganizer({ schedules, credentials, selectedCalendars, + destinationCalendar, }; } @@ -599,7 +635,7 @@ export function getScenarioData({ { organizer: ReturnType; eventTypes: ScenarioData["eventTypes"]; - apps: ScenarioData["apps"]; + apps?: ScenarioData["apps"]; usersApartFromOrganizer?: ScenarioData["users"]; webhooks?: ScenarioData["webhooks"]; bookings?: ScenarioData["bookings"]; @@ -624,7 +660,19 @@ export function getScenarioData({ description: `It's a test event type - ${index + 1}`, }; }), - users, + users: users.map((user) => { + const newUser = { + ...user, + }; + if (user.destinationCalendar) { + newUser.destinationCalendar = { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + create: user.destinationCalendar, + }; + } + return newUser; + }), apps: [...apps], webhooks, bookings: bookings || [], @@ -651,15 +699,23 @@ export function mockNoTranslations() { }); } -export function mockCalendarToHaveNoBusySlots( +/** + * @param metadataLookupKey + * @param calendarData Specify uids and other data to be faked to be returned by createEvent and updateEvent + */ +export function mockCalendar( metadataLookupKey: keyof typeof appStoreMetadata, calendarData?: { - create: { - uid: string; + create?: { + uid?: string; }; update?: { uid: string; }; + busySlots?: { start: `${string}Z`; end: `${string}Z` }[]; + creationCrash?: boolean; + updationCrash?: boolean; + getAvailabilityCrash?: boolean; } ) { const appStoreLookupKey = metadataLookupKey; @@ -685,17 +741,17 @@ export function mockCalendarToHaveNoBusySlots( return { // eslint-disable-next-line @typescript-eslint/no-explicit-any createEvent: async function (...rest: any[]): Promise { + if (calendarData?.creationCrash) { + throw new Error("MockCalendarService.createEvent fake error"); + } const [calEvent, credentialId] = rest; - logger.silly( - "mockCalendarToHaveNoBusySlots.createEvent", - JSON.stringify({ calEvent, credentialId }) - ); + logger.silly("mockCalendar.createEvent", JSON.stringify({ calEvent, credentialId })); createEventCalls.push(rest); return Promise.resolve({ type: app.type, additionalInfo: {}, uid: "PROBABLY_UNUSED_UID", - id: normalizedCalendarData.create.uid, + id: normalizedCalendarData.create?.uid || "FALLBACK_MOCK_ID", // Password and URL seems useless for CalendarService, plan to remove them if that's the case password: "MOCK_PASSWORD", url: "https://UNUSED_URL", @@ -703,11 +759,11 @@ export function mockCalendarToHaveNoBusySlots( }, // eslint-disable-next-line @typescript-eslint/no-explicit-any updateEvent: async function (...rest: any[]): Promise { + if (calendarData?.updationCrash) { + throw new Error("MockCalendarService.updateEvent fake error"); + } const [uid, event, externalCalendarId] = rest; - logger.silly( - "mockCalendarToHaveNoBusySlots.updateEvent", - JSON.stringify({ uid, event, externalCalendarId }) - ); + logger.silly("mockCalendar.updateEvent", JSON.stringify({ uid, event, externalCalendarId })); // eslint-disable-next-line prefer-rest-params updateEventCalls.push(rest); return Promise.resolve({ @@ -715,15 +771,18 @@ export function mockCalendarToHaveNoBusySlots( additionalInfo: {}, uid: "PROBABLY_UNUSED_UID", // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - id: normalizedCalendarData.update!.uid!, + id: normalizedCalendarData.update?.uid || "FALLBACK_MOCK_ID", // Password and URL seems useless for CalendarService, plan to remove them if that's the case password: "MOCK_PASSWORD", url: "https://UNUSED_URL", }); }, - getAvailability: (): Promise => { + getAvailability: async (): Promise => { + if (calendarData?.getAvailabilityCrash) { + throw new Error("MockCalendarService.getAvailability fake error"); + } return new Promise((resolve) => { - resolve([]); + resolve(calendarData?.busySlots || []); }); }, }; @@ -736,10 +795,42 @@ export function mockCalendarToHaveNoBusySlots( }; } -export function mockSuccessfulVideoMeetingCreation({ +export function mockCalendarToHaveNoBusySlots( + metadataLookupKey: keyof typeof appStoreMetadata, + calendarData?: { + create: { + uid?: string; + }; + update?: { + uid: string; + }; + } +) { + calendarData = calendarData || { + create: { + uid: "MOCK_ID", + }, + update: { + uid: "UPDATED_MOCK_ID", + }, + }; + return mockCalendar(metadataLookupKey, { ...calendarData, busySlots: [] }); +} + +export function mockCalendarToCrashOnCreateEvent(metadataLookupKey: keyof typeof appStoreMetadata) { + return mockCalendar(metadataLookupKey, { creationCrash: true }); +} + +export function mockCalendarToCrashOnUpdateEvent(metadataLookupKey: keyof typeof appStoreMetadata) { + return mockCalendar(metadataLookupKey, { updationCrash: true }); +} + +export function mockVideoApp({ metadataLookupKey, appStoreLookupKey, videoMeetingData, + creationCrash, + updationCrash, }: { metadataLookupKey: string; appStoreLookupKey?: string; @@ -748,6 +839,8 @@ export function mockSuccessfulVideoMeetingCreation({ id: string; url: string; }; + creationCrash?: boolean; + updationCrash?: boolean; }) { appStoreLookupKey = appStoreLookupKey || metadataLookupKey; videoMeetingData = videoMeetingData || { @@ -772,7 +865,11 @@ export function mockSuccessfulVideoMeetingCreation({ VideoApiAdapter: () => ({ // eslint-disable-next-line @typescript-eslint/no-explicit-any createMeeting: (...rest: any[]) => { + if (creationCrash) { + throw new Error("MockVideoApiAdapter.createMeeting fake error"); + } createMeetingCalls.push(rest); + return Promise.resolve({ type: appStoreMetadata[metadataLookupKey as keyof typeof appStoreMetadata].type, ...videoMeetingData, @@ -780,6 +877,9 @@ export function mockSuccessfulVideoMeetingCreation({ }, // eslint-disable-next-line @typescript-eslint/no-explicit-any updateMeeting: async (...rest: any[]) => { + if (updationCrash) { + throw new Error("MockVideoApiAdapter.updateMeeting fake error"); + } const [bookingRef, calEvent] = rest; updateMeetingCalls.push(rest); if (!bookingRef.type) { @@ -808,6 +908,40 @@ export function mockSuccessfulVideoMeetingCreation({ }; } +export function mockSuccessfulVideoMeetingCreation({ + metadataLookupKey, + appStoreLookupKey, + videoMeetingData, +}: { + metadataLookupKey: string; + appStoreLookupKey?: string; + videoMeetingData?: { + password: string; + id: string; + url: string; + }; +}) { + return mockVideoApp({ + metadataLookupKey, + appStoreLookupKey, + videoMeetingData, + }); +} + +export function mockVideoAppToCrashOnCreateMeeting({ + metadataLookupKey, + appStoreLookupKey, +}: { + metadataLookupKey: string; + appStoreLookupKey?: string; +}) { + return mockVideoApp({ + metadataLookupKey, + appStoreLookupKey, + creationCrash: true, + }); +} + export function mockPaymentApp({ metadataLookupKey, appStoreLookupKey, @@ -885,11 +1019,8 @@ export async function mockPaymentSuccessWebhookFromStripe({ externalId }: { exte try { await handleStripePaymentSuccess(getMockedStripePaymentEvent({ paymentIntentId: externalId })); } catch (e) { - if (!(e instanceof HttpError)) { - logger.silly("mockPaymentSuccessWebhookFromStripe:catch", JSON.stringify(e)); - } else { - logger.error("mockPaymentSuccessWebhookFromStripe:catch", JSON.stringify(e)); - } + logger.silly("mockPaymentSuccessWebhookFromStripe:catch", JSON.stringify(e)); + webhookResponse = e as HttpError; } return { webhookResponse }; diff --git a/apps/web/test/utils/bookingScenario/expects.ts b/apps/web/test/utils/bookingScenario/expects.ts index 356c9752a38890..550b07b5c61374 100644 --- a/apps/web/test/utils/bookingScenario/expects.ts +++ b/apps/web/test/utils/bookingScenario/expects.ts @@ -1,11 +1,12 @@ import prismaMock from "../../../../../tests/libs/__mocks__/prisma"; -import type { Booking, BookingReference } from "@prisma/client"; -import type { WebhookTriggerEvents } from "@prisma/client"; +import type { WebhookTriggerEvents, Booking, BookingReference } from "@prisma/client"; import { expect } from "vitest"; import "vitest-fetch-mock"; import logger from "@calcom/lib/logger"; +import { BookingStatus } from "@calcom/prisma/enums"; +import type { CalendarEvent } from "@calcom/types/Calendar"; import type { Fixtures } from "@calcom/web/test/fixtures/fixtures"; import type { InputEventType } from "./bookingScenario"; @@ -30,7 +31,9 @@ expect.extend({ to: string ) { const testEmail = emails.get().find((email) => email.to.includes(to)); + const emailsToLog = emails.get().map((email) => ({ to: email.to, html: email.html })); if (!testEmail) { + logger.silly("All Emails", JSON.stringify({ numEmails: emailsToLog.length, emailsToLog })); return { pass: false, message: () => `No email sent to ${to}`, @@ -43,6 +46,10 @@ expect.extend({ } isToAddressExpected = expectedEmail.to === testEmail.to; + if (!isHtmlContained || !isToAddressExpected) { + logger.silly("All Emails", JSON.stringify({ numEmails: emailsToLog.length, emailsToLog })); + } + return { pass: isHtmlContained && isToAddressExpected, message: () => { @@ -150,6 +157,59 @@ export function expectSuccessfulBookingCreationEmails({ ); } +export function expectBrokenIntegrationEmails({ + emails, + organizer, + booker, +}: { + emails: Fixtures["emails"]; + organizer: { email: string; name: string }; + booker: { email: string; name: string }; +}) { + // Broken Integration email is only sent to the Organizer + expect(emails).toHaveEmail( + { + htmlToContain: "broken_integration", + to: `${organizer.email}`, + }, + `${organizer.email}` + ); + + // expect(emails).toHaveEmail( + // { + // htmlToContain: "confirmed_event_type_subject", + // to: `${booker.name} <${booker.email}>`, + // }, + // `${booker.name} <${booker.email}>` + // ); +} + +export function expectCalendarEventCreationFailureEmails({ + emails, + organizer, + booker, +}: { + emails: Fixtures["emails"]; + organizer: { email: string; name: string }; + booker: { email: string; name: string }; +}) { + expect(emails).toHaveEmail( + { + htmlToContain: "broken_integration", + to: `${organizer.email}`, + }, + `${organizer.email}` + ); + + expect(emails).toHaveEmail( + { + htmlToContain: "calendar_event_creation_failure_subject", + to: `${booker.name} <${booker.email}>`, + }, + `${booker.name} <${booker.email}>` + ); +} + export function expectSuccessfulBookingRescheduledEmails({ emails, organizer, @@ -394,20 +454,28 @@ export function expectSuccessfulCalendarEventCreationInCalendar( updateEventCalls: any[]; }, expected: { - externalCalendarId: string; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - calEvent: any; - uid: string; + calendarId: string | null; + videoCallUrl: string; } ) { expect(calendarMock.createEventCalls.length).toBe(1); const call = calendarMock.createEventCalls[0]; - const uid = call[0]; - const calendarEvent = call[1]; - const externalId = call[2]; - expect(uid).toBe(expected.uid); - expect(calendarEvent).toEqual(expect.objectContaining(expected.calEvent)); - expect(externalId).toBe(expected.externalCalendarId); + const calEvent = call[0]; + + expect(calEvent).toEqual( + expect.objectContaining({ + destinationCalendar: expected.calendarId + ? [ + expect.objectContaining({ + externalId: expected.calendarId, + }), + ] + : null, + videoCallData: expect.objectContaining({ + url: expected.videoCallUrl, + }), + }) + ); } export function expectSuccessfulCalendarEventUpdationInCalendar( @@ -419,8 +487,7 @@ export function expectSuccessfulCalendarEventUpdationInCalendar( }, expected: { externalCalendarId: string; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - calEvent: any; + calEvent: Partial; uid: string; } ) { @@ -443,8 +510,7 @@ export function expectSuccessfulVideoMeetingCreationInCalendar( }, expected: { externalCalendarId: string; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - calEvent: any; + calEvent: Partial; uid: string; } ) { @@ -468,8 +534,7 @@ export function expectSuccessfulVideoMeetingUpdationInCalendar( expected: { // eslint-disable-next-line @typescript-eslint/no-explicit-any bookingRef: any; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - calEvent: any; + calEvent: Partial; } ) { expect(videoMock.updateMeetingCalls.length).toBe(1); @@ -479,3 +544,19 @@ export function expectSuccessfulVideoMeetingUpdationInCalendar( expect(bookingRef).toEqual(expect.objectContaining(expected.bookingRef)); expect(calendarEvent).toEqual(expect.objectContaining(expected.calEvent)); } + +export async function expectBookingInDBToBeRescheduledFromTo({ from, to }: { from: any; to: any }) { + // Expect previous booking to be cancelled + await expectBookingToBeInDatabase({ + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + ...from, + status: BookingStatus.CANCELLED, + }); + + // Expect new booking to be created + await expectBookingToBeInDatabase({ + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + ...to, + status: BookingStatus.ACCEPTED, + }); +} diff --git a/packages/core/CalendarManager.ts b/packages/core/CalendarManager.ts index 0e91f10ea8b32a..094e0f7750700b 100644 --- a/packages/core/CalendarManager.ts +++ b/packages/core/CalendarManager.ts @@ -8,6 +8,7 @@ import dayjs from "@calcom/dayjs"; import { getUid } from "@calcom/lib/CalEventParser"; import logger from "@calcom/lib/logger"; import { getPiiFreeCalendarEvent } from "@calcom/lib/piiFreeData"; +import { safeStringify } from "@calcom/lib/safeStringify"; import { performance } from "@calcom/lib/server/perfObserver"; import type { CalendarEvent, @@ -103,6 +104,8 @@ export const getConnectedCalendars = async ( } } + log.error("getConnectedCalendars failed", safeStringify({ error, item })); + return { integration: cleanIntegrationKeys(item.integration), credentialId: item.credential.id, @@ -211,7 +214,7 @@ export const getBusyCalendarTimes = async ( const endDate = dayjs(dateTo).endOf("month").add(14, "hours").format(); results = await getCalendarsEvents(withCredentials, startDate, endDate, selectedCalendars); } catch (e) { - logger.warn(e); + log.warn(safeStringify(e)); } return results.reduce((acc, availability) => acc.concat(availability), []); }; @@ -292,7 +295,7 @@ export const updateEvent = async ( let calWarnings: string[] | undefined = []; log.debug( "Updating calendar event", - JSON.stringify({ + safeStringify({ bookingRefUid, calEvent: getPiiFreeCalendarEvent(calEvent), }) @@ -301,7 +304,7 @@ export const updateEvent = async ( log.error( "updateEvent failed", "bookingRefUid is empty", - JSON.stringify({ calEvent: getPiiFreeCalendarEvent(calEvent) }) + safeStringify({ calEvent: getPiiFreeCalendarEvent(calEvent) }) ); } const updatedResult: NewCalendarEventType | NewCalendarEventType[] | undefined = @@ -318,7 +321,7 @@ export const updateEvent = async ( // await sendBrokenIntegrationEmail(calEvent, "calendar"); log.error( "updateEvent failed", - JSON.stringify({ e, calEvent: getPiiFreeCalendarEvent(calEvent) }) + safeStringify({ e, calEvent: getPiiFreeCalendarEvent(calEvent) }) ); if (e?.calError) { calError = e.calError; diff --git a/packages/core/EventManager.ts b/packages/core/EventManager.ts index 6993a50dad4b1c..39215f580008d4 100644 --- a/packages/core/EventManager.ts +++ b/packages/core/EventManager.ts @@ -1,4 +1,4 @@ -import type { Booking, DestinationCalendar } from "@prisma/client"; +import type { DestinationCalendar } from "@prisma/client"; // eslint-disable-next-line no-restricted-imports import { cloneDeep, merge } from "lodash"; import { v5 as uuidv5 } from "uuid"; @@ -10,6 +10,7 @@ import { appKeysSchema as calVideoKeysSchema } from "@calcom/app-store/dailyvide import { getEventLocationTypeFromApp, MeetLocationType } from "@calcom/app-store/locations"; import getApps from "@calcom/app-store/utils"; import logger from "@calcom/lib/logger"; +import { safeStringify } from "@calcom/lib/safeStringify"; import prisma from "@calcom/prisma"; import { credentialForCalendarServiceSelect } from "@calcom/prisma/selects/credential"; import { createdEventSchema } from "@calcom/prisma/zod-utils"; @@ -403,7 +404,7 @@ export default class EventManager { } else { logger.silly( "No destination Calendar found, falling back to first connected calendar", - JSON.stringify({ + safeStringify({ calendarCredentials: this.calendarCredentials, }) ); @@ -659,26 +660,4 @@ export default class EventManager { ); } } - - /** - * Update event to set a cancelled event placeholder on users calendar - * remove if virtual calendar is already done and user availability its read from there - * and not only in their calendars - * @param event - * @param booking - * @public - */ - public async updateAndSetCancelledPlaceholder(event: CalendarEvent, booking: PartialBooking) { - await this.updateAllCalendarEvents(event, booking); - } - - public async rescheduleBookingWithSeats( - originalBooking: Booking, - newTimeSlotBooking?: Booking, - owner?: boolean - ) { - // Get originalBooking - // If originalBooking has only one attendee we should do normal reschedule - // Change current event attendees in everyone calendar - } } diff --git a/packages/core/getCalendarsEvents.ts b/packages/core/getCalendarsEvents.ts index 21103a27ff1305..27b371e5e4d20d 100644 --- a/packages/core/getCalendarsEvents.ts +++ b/packages/core/getCalendarsEvents.ts @@ -1,10 +1,13 @@ import type { SelectedCalendar } from "@prisma/client"; import { getCalendar } from "@calcom/app-store/_utils/getCalendar"; +import logger from "@calcom/lib/logger"; +import { getPiiFreeCredential, getPiiFreeSelectedCalendar } from "@calcom/lib/piiFreeData"; import { performance } from "@calcom/lib/server/perfObserver"; import type { EventBusyDate } from "@calcom/types/Calendar"; import type { CredentialPayload } from "@calcom/types/Credential"; +const log = logger.getChildLogger({ prefix: ["getCalendarsEvents"] }); const getCalendarsEvents = async ( withCredentials: CredentialPayload[], dateFrom: string, @@ -15,6 +18,7 @@ const getCalendarsEvents = async ( .filter((credential) => credential.type.endsWith("_calendar")) // filter out invalid credentials - these won't work. .filter((credential) => !credential.invalid); + const calendars = await Promise.all(calendarCredentials.map((credential) => getCalendar(credential))); performance.mark("getBusyCalendarTimesStart"); const results = calendars.map(async (c, i) => { @@ -48,6 +52,11 @@ const getCalendarsEvents = async ( "getBusyCalendarTimesStart", "getBusyCalendarTimesEnd" ); + log.debug({ + calendarCredentials: calendarCredentials.map(getPiiFreeCredential), + selectedCalendars: selectedCalendars.map(getPiiFreeSelectedCalendar), + calendarEvents: awaitedResults, + }); return awaitedResults; }; diff --git a/packages/core/videoClient.ts b/packages/core/videoClient.ts index 8d3e44f4d44ee0..52b9194f710642 100644 --- a/packages/core/videoClient.ts +++ b/packages/core/videoClient.ts @@ -7,6 +7,7 @@ import { sendBrokenIntegrationEmail } from "@calcom/emails"; import { getUid } from "@calcom/lib/CalEventParser"; import logger from "@calcom/lib/logger"; import { getPiiFreeCalendarEvent } from "@calcom/lib/piiFreeData"; +import { safeStringify } from "@calcom/lib/safeStringify"; import { prisma } from "@calcom/prisma"; import type { GetRecordingsResponseSchema } from "@calcom/prisma/zod-utils"; import type { CalendarEvent, EventBusyDate } from "@calcom/types/Calendar"; @@ -99,7 +100,7 @@ const createMeeting = async (credential: CredentialPayload, calEvent: CalendarEv returnObject = { ...returnObject, createdEvent: createdMeeting, success: true }; } catch (err) { await sendBrokenIntegrationEmail(calEvent, "video"); - log.error("createMeeting failed", JSON.stringify({ err, calEvent: getPiiFreeCalendarEvent(calEvent) })); + log.error("createMeeting failed", safeStringify({ err, calEvent: getPiiFreeCalendarEvent(calEvent) })); // Default to calVideo const defaultMeeting = await createMeetingWithCalVideo(calEvent); diff --git a/packages/features/bookings/lib/handleNewBooking.test.ts b/packages/features/bookings/lib/handleNewBooking.test.ts index 7ec4d425915919..c497246b77ab81 100644 --- a/packages/features/bookings/lib/handleNewBooking.test.ts +++ b/packages/features/bookings/lib/handleNewBooking.test.ts @@ -1,5 +1,11 @@ /** - * How to ensure that unmocked prisma queries aren't called? + * These tests are integration tests that test the flow from receiving a api/book/event request and then verifying + * - database entries created in In-MEMORY DB using prismock + * - emails sent by checking the testEmails global variable + * - webhooks fired by mocking fetch + * - APIs of various apps called by mocking those apps' modules + * + * They don't intend to test what the apps logic should do, but rather test if the apps are called with the correct data. For testing that, once should write tests within each app. */ import prismaMock from "../../../../tests/libs/__mocks__/prisma"; @@ -30,6 +36,10 @@ import { MockError, mockPaymentApp, mockPaymentSuccessWebhookFromStripe, + mockCalendar, + mockCalendarToCrashOnCreateEvent, + mockVideoAppToCrashOnCreateMeeting, + mockCalendarToCrashOnUpdateEvent, } from "@calcom/web/test/utils/bookingScenario/bookingScenario"; import { expectWorkflowToBeTriggered, @@ -44,6 +54,9 @@ import { expectSuccessfulBookingRescheduledEmails, expectSuccessfulCalendarEventUpdationInCalendar, expectSuccessfulVideoMeetingUpdationInCalendar, + expectBrokenIntegrationEmails, + expectSuccessfulCalendarEventCreationInCalendar, + expectBookingInDBToBeRescheduledFromTo, } from "@calcom/web/test/utils/bookingScenario/expects"; type CustomNextApiRequest = NextApiRequest & Request; @@ -68,6 +81,7 @@ describe("handleNewBooking", () => { `should create a successful booking with Cal Video(Daily Video) if no explicit location is provided 1. Should create a booking in the database 2. Should send emails to the booker as well as organizer + 3. Should create a booking in the event's destination calendar 3. Should trigger BOOKING_CREATED webhook `, async ({ emails }) => { @@ -84,6 +98,10 @@ describe("handleNewBooking", () => { schedules: [TestData.schedules.IstWorkHours], credentials: [getGoogleCalendarCredential()], selectedCalendars: [TestData.selectedCalendars.google], + destinationCalendar: { + integration: "google_calendar", + externalId: "organizer@google-calendar.com", + }, }); await createBookingScenario( getScenarioData({ @@ -107,6 +125,10 @@ describe("handleNewBooking", () => { id: 101, }, ], + destinationCalendar: { + integration: "google_calendar", + externalId: "event-type-1@google-calendar.com", + }, }, ], organizer, @@ -119,11 +141,11 @@ describe("handleNewBooking", () => { videoMeetingData: { id: "MOCK_ID", password: "MOCK_PASS", - url: `http://mock-dailyvideo.example.com`, + url: `http://mock-dailyvideo.example.com/meeting-1`, }, }); - mockCalendarToHaveNoBusySlots("googlecalendar", { + const calendarMock = mockCalendarToHaveNoBusySlots("googlecalendar", { create: { uid: "MOCK_ID", }, @@ -167,7 +189,7 @@ describe("handleNewBooking", () => { uid: "MOCK_ID", meetingId: "MOCK_ID", meetingPassword: "MOCK_PASS", - meetingUrl: "http://mock-dailyvideo.example.com", + meetingUrl: "http://mock-dailyvideo.example.com/meeting-1", }, { type: "google_calendar", @@ -180,6 +202,10 @@ describe("handleNewBooking", () => { }); expectWorkflowToBeTriggered(); + expectSuccessfulCalendarEventCreationInCalendar(calendarMock, { + calendarId: "event-type-1@google-calendar.com", + videoCallUrl: "http://mock-dailyvideo.example.com/meeting-1", + }); expectSuccessfulBookingCreationEmails({ booker, organizer, emails }); expectBookingCreatedWebhookToHaveBeenFired({ @@ -193,16 +219,16 @@ describe("handleNewBooking", () => { timeout ); - describe("Event Type that requires confirmation", () => { + describe("Calendar events should be created in the appropriate calendar", () => { test( - `should create a booking request for event that requires confirmation - 1. Should create a booking in the database with status PENDING - 2. Should send emails to the booker as well as organizer for booking request and awaiting approval - 3. Should trigger BOOKING_REQUESTED webhook + `should create a successful booking in the first connected calendar i.e. using the first credential(in the scenario when there is no event-type or organizer destination calendar) + 1. Should create a booking in the database + 2. Should send emails to the booker as well as organizer + 3. Should fallback to creating the booking in the first connected Calendar when neither event nor organizer has a destination calendar - This doesn't practically happen because organizer is always required to have a schedule set + 3. Should trigger BOOKING_CREATED webhook `, async ({ emails }) => { const handleNewBooking = (await import("@calcom/features/bookings/lib/handleNewBooking")).default; - const subscriberUrl = "http://my-webhook.example.com"; const booker = getBooker({ email: "booker@example.com", name: "Booker", @@ -216,40 +242,50 @@ describe("handleNewBooking", () => { credentials: [getGoogleCalendarCredential()], selectedCalendars: [TestData.selectedCalendars.google], }); - const scenarioData = getScenarioData({ - webhooks: [ - { - userId: organizer.id, - eventTriggers: ["BOOKING_CREATED"], - subscriberUrl, - active: true, - eventTypeId: 1, - appId: null, - }, - ], - eventTypes: [ - { - id: 1, - slotInterval: 45, - requiresConfirmation: true, - length: 45, - users: [ - { - id: 101, - }, - ], - }, - ], - organizer, - apps: [TestData.apps["google-calendar"], TestData.apps["daily-video"]], - }); - await createBookingScenario(scenarioData); + + await createBookingScenario( + getScenarioData({ + webhooks: [ + { + userId: organizer.id, + eventTriggers: ["BOOKING_CREATED"], + subscriberUrl: "http://my-webhook.example.com", + active: true, + eventTypeId: 1, + appId: null, + }, + ], + eventTypes: [ + { + id: 1, + slotInterval: 45, + length: 45, + users: [ + { + id: 101, + }, + ], + }, + ], + 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`, + }, }); - mockCalendarToHaveNoBusySlots("googlecalendar"); + const calendarMock = mockCalendarToHaveNoBusySlots("googlecalendar", { + create: { + uid: "MOCK_ID", + }, + }); const mockBookingData = getMockRequestDataForBooking({ data: { @@ -282,33 +318,52 @@ describe("handleNewBooking", () => { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion uid: createdBooking.uid!, eventTypeId: mockBookingData.eventTypeId, - status: BookingStatus.PENDING, + status: BookingStatus.ACCEPTED, + references: [ + { + type: "daily_video", + uid: "MOCK_ID", + meetingId: "MOCK_ID", + meetingPassword: "MOCK_PASS", + meetingUrl: "http://mock-dailyvideo.example.com/meeting-1", + }, + { + type: "google_calendar", + uid: "MOCK_ID", + meetingId: "MOCK_ID", + meetingPassword: "MOCK_PASSWORD", + meetingUrl: "https://UNUSED_URL", + }, + ], }); expectWorkflowToBeTriggered(); - - expectBookingRequestedEmails({ - booker, - organizer, - emails, + expectSuccessfulCalendarEventCreationInCalendar(calendarMock, { + videoCallUrl: "http://mock-dailyvideo.example.com/meeting-1", + // We won't be sending evt.destinationCalendar in this case. + // Google Calendar in this case fallbacks to the "primary" calendar - https://github.com/calcom/cal.com/blob/7d5dad7fea78ff24dddbe44f1da5d7e08e1ff568/packages/app-store/googlecalendar/lib/CalendarService.ts#L217 + // Not sure if it's the correct behaviour. Right now, it isn't possible to have an organizer with connected calendar but no destination calendar - As soon as the Google Calendar app is installed, a destination calendar is created. + calendarId: null, }); - expectBookingRequestedWebhookToHaveBeenFired({ + expectSuccessfulBookingCreationEmails({ booker, organizer, emails }); + expectBookingCreatedWebhookToHaveBeenFired({ booker, organizer, location: "integrations:daily", - subscriberUrl, - eventType: scenarioData.eventTypes[0], + subscriberUrl: "http://my-webhook.example.com", + videoCallUrl: `${WEBAPP_URL}/video/DYNAMIC_UID`, }); }, timeout ); test( - `should create a booking for event that requires confirmation based on a booking notice duration threshold, if threshold is not met - 1. Should create a booking in the database with status ACCEPTED - 2. Should send emails to the booker as well as organizer - 3. Should trigger BOOKING_CREATED webhook + `should create a successful booking in the organizer calendar(in the scenario when event type doesn't have destination calendar) + 1. Should create a booking in the database + 2. Should send emails to the booker as well as organizer + 3. Should fallback to create a booking in the Organizer Calendar if event doesn't have destination calendar + 3. Should trigger BOOKING_CREATED webhook `, async ({ emails }) => { const handleNewBooking = (await import("@calcom/features/bookings/lib/handleNewBooking")).default; @@ -316,7 +371,6 @@ describe("handleNewBooking", () => { email: "booker@example.com", name: "Booker", }); - const subscriberUrl = "http://my-webhook.example.com"; const organizer = getOrganizer({ name: "Organizer", @@ -325,15 +379,18 @@ describe("handleNewBooking", () => { schedules: [TestData.schedules.IstWorkHours], credentials: [getGoogleCalendarCredential()], selectedCalendars: [TestData.selectedCalendars.google], + destinationCalendar: { + integration: "google_calendar", + externalId: "organizer@google-calendar.com", + }, }); - await createBookingScenario( getScenarioData({ webhooks: [ { userId: organizer.id, eventTriggers: ["BOOKING_CREATED"], - subscriberUrl, + subscriberUrl: "http://my-webhook.example.com", active: true, eventTypeId: 1, appId: null, @@ -343,13 +400,6 @@ describe("handleNewBooking", () => { { id: 1, slotInterval: 45, - requiresConfirmation: true, - metadata: { - requiresConfirmationThreshold: { - time: 30, - unit: "minutes", - }, - }, length: 45, users: [ { @@ -365,9 +415,18 @@ describe("handleNewBooking", () => { mockSuccessfulVideoMeetingCreation({ metadataLookupKey: "dailyvideo", + videoMeetingData: { + id: "MOCK_ID", + password: "MOCK_PASS", + url: `http://mock-dailyvideo.example.com/meeting-1`, + }, }); - mockCalendarToHaveNoBusySlots("googlecalendar"); + const calendarMock = mockCalendarToHaveNoBusySlots("googlecalendar", { + create: { + uid: "MOCK_ID", + }, + }); const mockBookingData = getMockRequestDataForBooking({ data: { @@ -401,17 +460,36 @@ describe("handleNewBooking", () => { uid: createdBooking.uid!, eventTypeId: mockBookingData.eventTypeId, status: BookingStatus.ACCEPTED, + references: [ + { + type: "daily_video", + uid: "MOCK_ID", + meetingId: "MOCK_ID", + meetingPassword: "MOCK_PASS", + meetingUrl: "http://mock-dailyvideo.example.com/meeting-1", + }, + { + type: "google_calendar", + uid: "MOCK_ID", + meetingId: "MOCK_ID", + meetingPassword: "MOCK_PASSWORD", + meetingUrl: "https://UNUSED_URL", + }, + ], }); expectWorkflowToBeTriggered(); + expectSuccessfulCalendarEventCreationInCalendar(calendarMock, { + calendarId: "organizer@google-calendar.com", + videoCallUrl: "http://mock-dailyvideo.example.com/meeting-1", + }); expectSuccessfulBookingCreationEmails({ booker, organizer, emails }); - expectBookingCreatedWebhookToHaveBeenFired({ booker, organizer, location: "integrations:daily", - subscriberUrl, + subscriberUrl: "http://my-webhook.example.com", videoCallUrl: `${WEBAPP_URL}/video/DYNAMIC_UID`, }); }, @@ -419,14 +497,9 @@ describe("handleNewBooking", () => { ); test( - `should create a booking for event that requires confirmation based on a booking notice duration threshold, if threshold IS MET - 1. Should create a booking in the database with status PENDING - 2. Should send emails to the booker as well as organizer for booking request and awaiting approval - 3. Should trigger BOOKING_REQUESTED webhook - `, + `an error in creating a calendar event should not stop the booking creation - Current behaviour is wrong as the booking is created but no-one is notified of it`, async ({ emails }) => { const handleNewBooking = (await import("@calcom/features/bookings/lib/handleNewBooking")).default; - const subscriberUrl = "http://my-webhook.example.com"; const booker = getBooker({ email: "booker@example.com", name: "Booker", @@ -439,48 +512,41 @@ describe("handleNewBooking", () => { schedules: [TestData.schedules.IstWorkHours], credentials: [getGoogleCalendarCredential()], selectedCalendars: [TestData.selectedCalendars.google], + destinationCalendar: { + integration: "google_calendar", + externalId: "organizer@google-calendar.com", + }, }); - const scenarioData = getScenarioData({ - webhooks: [ - { - userId: organizer.id, - eventTriggers: ["BOOKING_CREATED"], - subscriberUrl, - active: true, - eventTypeId: 1, - appId: null, - }, - ], - eventTypes: [ - { - id: 1, - slotInterval: 45, - requiresConfirmation: true, - metadata: { - requiresConfirmationThreshold: { - time: 120, - unit: "hours", - }, + await createBookingScenario( + getScenarioData({ + webhooks: [ + { + userId: organizer.id, + eventTriggers: ["BOOKING_CREATED"], + subscriberUrl: "http://my-webhook.example.com", + active: true, + eventTypeId: 1, + appId: null, }, - length: 45, - users: [ - { - id: 101, - }, - ], - }, - ], - organizer, - apps: [TestData.apps["google-calendar"], TestData.apps["daily-video"]], - }); - - await createBookingScenario(scenarioData); - - mockSuccessfulVideoMeetingCreation({ - metadataLookupKey: "dailyvideo", - }); + ], + eventTypes: [ + { + id: 1, + slotInterval: 45, + length: 45, + users: [ + { + id: 101, + }, + ], + }, + ], + organizer, + apps: [TestData.apps["google-calendar"], TestData.apps["daily-video"]], + }) + ); - mockCalendarToHaveNoBusySlots("googlecalendar"); + const calendarMock = mockCalendarToCrashOnCreateEvent("googlecalendar"); const mockBookingData = getMockRequestDataForBooking({ data: { @@ -488,7 +554,7 @@ describe("handleNewBooking", () => { responses: { email: booker.email, name: booker.name, - location: { optionValue: "", value: "integrations:daily" }, + location: { optionValue: "", value: "New York" }, }, }, }); @@ -505,7 +571,7 @@ describe("handleNewBooking", () => { }); expect(createdBooking).toContain({ - location: "integrations:daily", + location: "New York", }); await expectBookingToBeInDatabase({ @@ -513,45 +579,805 @@ describe("handleNewBooking", () => { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion uid: createdBooking.uid!, eventTypeId: mockBookingData.eventTypeId, - status: BookingStatus.PENDING, + status: BookingStatus.ACCEPTED, + references: [ + { + type: "google_calendar", + // A reference is still created in case of event creation failure, with nullish values. Not sure what's the purpose for this. + uid: "", + meetingId: null, + meetingPassword: null, + meetingUrl: null, + }, + ], }); expectWorkflowToBeTriggered(); - expectBookingRequestedEmails({ booker, organizer, emails }); + // FIXME: We should send Broken Integration emails on calendar event creation failure + // expectCalendarEventCreationFailureEmails({ booker, organizer, emails }); - expectBookingRequestedWebhookToHaveBeenFired({ + expectBookingCreatedWebhookToHaveBeenFired({ booker, organizer, - location: "integrations:daily", - subscriberUrl, - eventType: scenarioData.eventTypes[0], + location: "New York", + subscriberUrl: "http://my-webhook.example.com", }); }, timeout ); }); - // FIXME: We shouldn't throw error here, the behaviour should be fixed. - test( - `if booking with Cal Video(Daily Video) fails, booking creation fails with uncaught error`, - async ({}) => { - const handleNewBooking = (await import("@calcom/features/bookings/lib/handleNewBooking")).default; - const booker = getBooker({ - email: "booker@example.org", - name: "Booker", - }); - const organizer = TestData.users.example; + describe("Video Meeting Creation", () => { + test( + `should create a successful booking with Zoom if used`, + async ({ emails }) => { + const handleNewBooking = (await import("@calcom/features/bookings/lib/handleNewBooking")).default; + const subscriberUrl = "http://my-webhook.example.com"; + const booker = getBooker({ + email: "booker@example.com", + name: "Booker", + }); - await createBookingScenario({ - eventTypes: [ - { - id: 1, - slotInterval: 45, - length: 45, - users: [ + const organizer = getOrganizer({ + name: "Organizer", + email: "organizer@example.com", + id: 101, + schedules: [TestData.schedules.IstWorkHours], + credentials: [getZoomAppCredential()], + selectedCalendars: [TestData.selectedCalendars.google], + }); + await createBookingScenario( + getScenarioData({ + organizer, + eventTypes: [ { - id: 101, + id: 1, + slotInterval: 45, + length: 45, + users: [ + { + id: 101, + }, + ], + }, + ], + apps: [TestData.apps["zoomvideo"]], + webhooks: [ + { + userId: organizer.id, + eventTriggers: ["BOOKING_CREATED"], + subscriberUrl, + active: true, + eventTypeId: 1, + appId: null, + }, + ], + }) + ); + mockSuccessfulVideoMeetingCreation({ + metadataLookupKey: "zoomvideo", + }); + + const { req } = createMockNextJsRequest({ + method: "POST", + body: getMockRequestDataForBooking({ + data: { + eventTypeId: 1, + responses: { + email: booker.email, + name: booker.name, + location: { optionValue: "", value: "integrations:zoom" }, + }, + }, + }), + }); + await handleNewBooking(req); + + expectSuccessfulBookingCreationEmails({ booker, organizer, emails }); + + expectBookingCreatedWebhookToHaveBeenFired({ + booker, + organizer, + location: "integrations:zoom", + subscriberUrl, + videoCallUrl: "http://mock-zoomvideo.example.com", + }); + }, + timeout + ); + + test( + `Booking should still be created if booking with Zoom errors`, + async ({ emails }) => { + const handleNewBooking = (await import("@calcom/features/bookings/lib/handleNewBooking")).default; + const subscriberUrl = "http://my-webhook.example.com"; + 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: [getZoomAppCredential()], + selectedCalendars: [TestData.selectedCalendars.google], + }); + await createBookingScenario( + getScenarioData({ + organizer, + eventTypes: [ + { + id: 1, + slotInterval: 45, + length: 45, + users: [ + { + id: 101, + }, + ], + }, + ], + apps: [TestData.apps["zoomvideo"]], + webhooks: [ + { + userId: organizer.id, + eventTriggers: ["BOOKING_CREATED"], + subscriberUrl, + active: true, + eventTypeId: 1, + appId: null, + }, + ], + }) + ); + + mockVideoAppToCrashOnCreateMeeting({ + metadataLookupKey: "zoomvideo", + }); + + const { req } = createMockNextJsRequest({ + method: "POST", + body: getMockRequestDataForBooking({ + data: { + eventTypeId: 1, + responses: { + email: booker.email, + name: booker.name, + location: { optionValue: "", value: "integrations:zoom" }, + }, + }, + }), + }); + await handleNewBooking(req); + + expectBrokenIntegrationEmails({ booker, organizer, emails }); + + expectBookingCreatedWebhookToHaveBeenFired({ + booker, + organizer, + location: "integrations:zoom", + subscriberUrl, + videoCallUrl: null, + }); + }, + timeout + ); + }); + + describe( + "Availability Check during booking", + () => { + test( + `should fail a booking if there is already a Cal.com booking overlapping the time`, + async ({}) => { + const handleNewBooking = (await import("@calcom/features/bookings/lib/handleNewBooking")).default; + + 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], + }); + + const { dateString: plus1DateString } = getDate({ dateIncrement: 1 }); + const uidOfOverlappingBooking = "harWv3eHgconAED2j4gcVhP"; + await createBookingScenario( + getScenarioData({ + eventTypes: [ + { + id: 1, + slotInterval: 45, + length: 45, + users: [ + { + id: 101, + }, + ], + }, + ], + bookings: [ + { + uid: uidOfOverlappingBooking, + eventTypeId: 1, + userId: 101, + status: BookingStatus.ACCEPTED, + startTime: `${plus1DateString}T05:00:00.000Z`, + endTime: `${plus1DateString}T05:15:00.000Z`, + }, + ], + organizer, + }) + ); + + const mockBookingData = getMockRequestDataForBooking({ + data: { + start: `${getDate({ dateIncrement: 1 }).dateString}T04:00:00.000Z`, + end: `${getDate({ dateIncrement: 1 }).dateString}T05:30:00.000Z`, + eventTypeId: 1, + responses: { + email: booker.email, + name: booker.name, + location: { optionValue: "", value: "New York" }, + }, + }, + }); + + const { req } = createMockNextJsRequest({ + method: "POST", + body: mockBookingData, + }); + + await expect(async () => await handleNewBooking(req)).rejects.toThrowError( + "No available users found" + ); + }, + timeout + ); + + test( + `should fail a booking if there is already a booking in the organizer's selectedCalendars(Single Calendar) with the overlapping time`, + async () => { + const handleNewBooking = (await import("@calcom/features/bookings/lib/handleNewBooking")).default; + const organizerId = 101; + const booker = getBooker({ + email: "booker@example.com", + name: "Booker", + }); + + const organizer = getOrganizer({ + name: "Organizer", + email: "organizer@example.com", + id: organizerId, + schedules: [TestData.schedules.IstWorkHours], + credentials: [getGoogleCalendarCredential()], + selectedCalendars: [TestData.selectedCalendars.google], + }); + const { dateString: plus1DateString } = getDate({ dateIncrement: 1 }); + + await createBookingScenario( + getScenarioData({ + webhooks: [ + { + userId: organizer.id, + eventTriggers: ["BOOKING_CREATED"], + subscriberUrl: "http://my-webhook.example.com", + active: true, + eventTypeId: 1, + appId: null, + }, + ], + eventTypes: [ + { + id: 1, + slotInterval: 45, + length: 45, + users: [ + { + id: 101, + }, + ], + }, + ], + organizer, + apps: [TestData.apps["google-calendar"]], + }) + ); + + const calendarMock = mockCalendar("googlecalendar", { + create: { + uid: "MOCK_ID", + }, + busySlots: [ + { + start: `${plus1DateString}T05:00:00.000Z`, + end: `${plus1DateString}T05:15:00.000Z`, + }, + ], + }); + + const mockBookingData = getMockRequestDataForBooking({ + data: { + start: `${getDate({ dateIncrement: 1 }).dateString}T04:00:00.000Z`, + end: `${getDate({ dateIncrement: 1 }).dateString}T05:30:00.000Z`, + eventTypeId: 1, + responses: { + email: booker.email, + name: booker.name, + location: { optionValue: "", value: "New York" }, + }, + }, + }); + + const { req } = createMockNextJsRequest({ + method: "POST", + body: mockBookingData, + }); + + await expect(async () => await handleNewBooking(req)).rejects.toThrowError( + "No available users found" + ); + }, + timeout + ); + + test( + `should fail a booking if there is already a booking in the organizer's selectedCalendars(Single Calendar) with the overlapping time`, + async () => { + const handleNewBooking = (await import("@calcom/features/bookings/lib/handleNewBooking")).default; + const organizerId = 101; + const booker = getBooker({ + email: "booker@example.com", + name: "Booker", + }); + + const organizer = getOrganizer({ + name: "Organizer", + email: "organizer@example.com", + id: organizerId, + schedules: [TestData.schedules.IstWorkHours], + credentials: [getGoogleCalendarCredential()], + selectedCalendars: [TestData.selectedCalendars.google], + }); + const { dateString: plus1DateString } = getDate({ dateIncrement: 1 }); + + await createBookingScenario( + getScenarioData({ + webhooks: [ + { + userId: organizer.id, + eventTriggers: ["BOOKING_CREATED"], + subscriberUrl: "http://my-webhook.example.com", + active: true, + eventTypeId: 1, + appId: null, + }, + ], + eventTypes: [ + { + id: 1, + slotInterval: 45, + length: 45, + users: [ + { + id: 101, + }, + ], + }, + ], + organizer, + apps: [TestData.apps["google-calendar"]], + }) + ); + + const calendarMock = mockCalendar("googlecalendar", { + create: { + uid: "MOCK_ID", + }, + busySlots: [ + { + start: `${plus1DateString}T05:00:00.000Z`, + end: `${plus1DateString}T05:15:00.000Z`, + }, + ], + }); + + const mockBookingData = getMockRequestDataForBooking({ + data: { + start: `${getDate({ dateIncrement: 1 }).dateString}T04:00:00.000Z`, + end: `${getDate({ dateIncrement: 1 }).dateString}T05:30:00.000Z`, + eventTypeId: 1, + responses: { + email: booker.email, + name: booker.name, + location: { optionValue: "", value: "New York" }, + }, + }, + }); + + const { req } = createMockNextJsRequest({ + method: "POST", + body: mockBookingData, + }); + + await expect(async () => await handleNewBooking(req)).rejects.toThrowError( + "No available users found" + ); + }, + timeout + ); + }, + timeout + ); + + describe("Event Type that requires confirmation", () => { + test( + `should create a booking request for event that requires confirmation + 1. Should create a booking in the database with status PENDING + 2. Should send emails to the booker as well as organizer for booking request and awaiting approval + 3. Should trigger BOOKING_REQUESTED webhook + `, + async ({ emails }) => { + const handleNewBooking = (await import("@calcom/features/bookings/lib/handleNewBooking")).default; + const subscriberUrl = "http://my-webhook.example.com"; + 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], + }); + const scenarioData = getScenarioData({ + webhooks: [ + { + userId: organizer.id, + eventTriggers: ["BOOKING_CREATED"], + subscriberUrl, + active: true, + eventTypeId: 1, + appId: null, + }, + ], + eventTypes: [ + { + id: 1, + slotInterval: 45, + requiresConfirmation: true, + length: 45, + users: [ + { + id: 101, + }, + ], + }, + ], + organizer, + apps: [TestData.apps["google-calendar"], TestData.apps["daily-video"]], + }); + await createBookingScenario(scenarioData); + + mockSuccessfulVideoMeetingCreation({ + metadataLookupKey: "dailyvideo", + }); + + mockCalendarToHaveNoBusySlots("googlecalendar"); + + const mockBookingData = getMockRequestDataForBooking({ + data: { + eventTypeId: 1, + responses: { + email: booker.email, + name: booker.name, + location: { optionValue: "", value: "integrations:daily" }, + }, + }, + }); + + const { req } = createMockNextJsRequest({ + method: "POST", + body: mockBookingData, + }); + + const createdBooking = await handleNewBooking(req); + expect(createdBooking.responses).toContain({ + email: booker.email, + name: booker.name, + }); + + expect(createdBooking).toContain({ + location: "integrations:daily", + }); + + await expectBookingToBeInDatabase({ + description: "", + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + uid: createdBooking.uid!, + eventTypeId: mockBookingData.eventTypeId, + status: BookingStatus.PENDING, + }); + + expectWorkflowToBeTriggered(); + + expectBookingRequestedEmails({ + booker, + organizer, + emails, + }); + + expectBookingRequestedWebhookToHaveBeenFired({ + booker, + organizer, + location: "integrations:daily", + subscriberUrl, + eventType: scenarioData.eventTypes[0], + }); + }, + timeout + ); + + test( + `should create a booking for event that requires confirmation based on a booking notice duration threshold, if threshold is not met + 1. Should create a booking in the database with status ACCEPTED + 2. Should send emails to the booker as well as organizer + 3. Should trigger BOOKING_CREATED webhook + `, + async ({ emails }) => { + const handleNewBooking = (await import("@calcom/features/bookings/lib/handleNewBooking")).default; + const booker = getBooker({ + email: "booker@example.com", + name: "Booker", + }); + const subscriberUrl = "http://my-webhook.example.com"; + + const organizer = getOrganizer({ + name: "Organizer", + email: "organizer@example.com", + id: 101, + schedules: [TestData.schedules.IstWorkHours], + credentials: [getGoogleCalendarCredential()], + selectedCalendars: [TestData.selectedCalendars.google], + }); + + await createBookingScenario( + getScenarioData({ + webhooks: [ + { + userId: organizer.id, + eventTriggers: ["BOOKING_CREATED"], + subscriberUrl, + active: true, + eventTypeId: 1, + appId: null, + }, + ], + eventTypes: [ + { + id: 1, + slotInterval: 45, + requiresConfirmation: true, + metadata: { + requiresConfirmationThreshold: { + time: 30, + unit: "minutes", + }, + }, + length: 45, + users: [ + { + id: 101, + }, + ], + }, + ], + organizer, + apps: [TestData.apps["google-calendar"], TestData.apps["daily-video"]], + }) + ); + + mockSuccessfulVideoMeetingCreation({ + metadataLookupKey: "dailyvideo", + }); + + mockCalendarToHaveNoBusySlots("googlecalendar"); + + const mockBookingData = getMockRequestDataForBooking({ + data: { + eventTypeId: 1, + responses: { + email: booker.email, + name: booker.name, + location: { optionValue: "", value: "integrations:daily" }, + }, + }, + }); + + const { req } = createMockNextJsRequest({ + method: "POST", + body: mockBookingData, + }); + + const createdBooking = await handleNewBooking(req); + expect(createdBooking.responses).toContain({ + email: booker.email, + name: booker.name, + }); + + expect(createdBooking).toContain({ + location: "integrations:daily", + }); + + await expectBookingToBeInDatabase({ + description: "", + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + uid: createdBooking.uid!, + eventTypeId: mockBookingData.eventTypeId, + status: BookingStatus.ACCEPTED, + }); + + expectWorkflowToBeTriggered(); + + expectSuccessfulBookingCreationEmails({ booker, organizer, emails }); + + expectBookingCreatedWebhookToHaveBeenFired({ + booker, + organizer, + location: "integrations:daily", + subscriberUrl, + videoCallUrl: `${WEBAPP_URL}/video/DYNAMIC_UID`, + }); + }, + timeout + ); + + test( + `should create a booking for event that requires confirmation based on a booking notice duration threshold, if threshold IS MET + 1. Should create a booking in the database with status PENDING + 2. Should send emails to the booker as well as organizer for booking request and awaiting approval + 3. Should trigger BOOKING_REQUESTED webhook + `, + async ({ emails }) => { + const handleNewBooking = (await import("@calcom/features/bookings/lib/handleNewBooking")).default; + const subscriberUrl = "http://my-webhook.example.com"; + 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], + }); + const scenarioData = getScenarioData({ + webhooks: [ + { + userId: organizer.id, + eventTriggers: ["BOOKING_CREATED"], + subscriberUrl, + active: true, + eventTypeId: 1, + appId: null, + }, + ], + eventTypes: [ + { + id: 1, + slotInterval: 45, + requiresConfirmation: true, + metadata: { + requiresConfirmationThreshold: { + time: 120, + unit: "hours", + }, + }, + length: 45, + users: [ + { + id: 101, + }, + ], + }, + ], + organizer, + apps: [TestData.apps["google-calendar"], TestData.apps["daily-video"]], + }); + + await createBookingScenario(scenarioData); + + mockSuccessfulVideoMeetingCreation({ + metadataLookupKey: "dailyvideo", + }); + + mockCalendarToHaveNoBusySlots("googlecalendar"); + + const mockBookingData = getMockRequestDataForBooking({ + data: { + eventTypeId: 1, + responses: { + email: booker.email, + name: booker.name, + location: { optionValue: "", value: "integrations:daily" }, + }, + }, + }); + + const { req } = createMockNextJsRequest({ + method: "POST", + body: mockBookingData, + }); + + const createdBooking = await handleNewBooking(req); + expect(createdBooking.responses).toContain({ + email: booker.email, + name: booker.name, + }); + + expect(createdBooking).toContain({ + location: "integrations:daily", + }); + + await expectBookingToBeInDatabase({ + description: "", + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + uid: createdBooking.uid!, + eventTypeId: mockBookingData.eventTypeId, + status: BookingStatus.PENDING, + }); + + expectWorkflowToBeTriggered(); + + expectBookingRequestedEmails({ booker, organizer, emails }); + + expectBookingRequestedWebhookToHaveBeenFired({ + booker, + organizer, + location: "integrations:daily", + subscriberUrl, + eventType: scenarioData.eventTypes[0], + }); + }, + timeout + ); + }); + + // FIXME: We shouldn't throw error here, the behaviour should be fixed. + test( + `if booking with Cal Video(Daily Video) fails, booking creation fails with uncaught error`, + async ({}) => { + const handleNewBooking = (await import("@calcom/features/bookings/lib/handleNewBooking")).default; + const booker = getBooker({ + email: "booker@example.org", + name: "Booker", + }); + const organizer = TestData.users.example; + + await createBookingScenario({ + eventTypes: [ + { + id: 1, + slotInterval: 45, + length: 45, + users: [ + { + id: 101, }, ], }, @@ -598,83 +1424,6 @@ describe("handleNewBooking", () => { timeout ); - test( - `should create a successful booking with Zoom if used`, - async ({ emails }) => { - const handleNewBooking = (await import("@calcom/features/bookings/lib/handleNewBooking")).default; - const subscriberUrl = "http://my-webhook.example.com"; - 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: [getZoomAppCredential()], - selectedCalendars: [TestData.selectedCalendars.google], - }); - await createBookingScenario( - getScenarioData({ - organizer, - eventTypes: [ - { - id: 1, - slotInterval: 45, - length: 45, - users: [ - { - id: 101, - }, - ], - }, - ], - apps: [TestData.apps["zoomvideo"]], - webhooks: [ - { - userId: organizer.id, - eventTriggers: ["BOOKING_CREATED"], - subscriberUrl, - active: true, - eventTypeId: 1, - appId: null, - }, - ], - }) - ); - mockSuccessfulVideoMeetingCreation({ - metadataLookupKey: "zoomvideo", - }); - - const { req } = createMockNextJsRequest({ - method: "POST", - body: getMockRequestDataForBooking({ - data: { - eventTypeId: 1, - responses: { - email: booker.email, - name: booker.name, - location: { optionValue: "", value: "integrations:zoom" }, - }, - }, - }), - }); - await handleNewBooking(req); - - expectSuccessfulBookingCreationEmails({ booker, organizer, emails }); - - expectBookingCreatedWebhookToHaveBeenFired({ - booker, - organizer, - location: "integrations:zoom", - subscriberUrl, - videoCallUrl: "http://mock-zoomvideo.example.com", - }); - }, - timeout - ); test( `should create a successful booking when location is provided as label of an option(Done for Organizer Address) 1. Should create a booking in the database @@ -895,7 +1644,6 @@ describe("handleNewBooking", () => { const { webhookResponse } = await mockPaymentSuccessWebhookFromStripe({ externalId }); - logger.info("webhookResponse", webhookResponse); expect(webhookResponse?.statusCode).toBe(200); await expectBookingToBeInDatabase({ description: "", @@ -1034,34 +1782,265 @@ describe("handleNewBooking", () => { paymentId: createdBooking.paymentId!, }); - const { webhookResponse } = await mockPaymentSuccessWebhookFromStripe({ externalId }); + const { webhookResponse } = await mockPaymentSuccessWebhookFromStripe({ externalId }); + + expect(webhookResponse?.statusCode).toBe(200); + await expectBookingToBeInDatabase({ + description: "", + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + uid: createdBooking.uid!, + eventTypeId: mockBookingData.eventTypeId, + status: BookingStatus.PENDING, + }); + expectBookingRequestedWebhookToHaveBeenFired({ + booker, + organizer, + location: "integrations:daily", + subscriberUrl, + paidEvent: true, + eventType: scenarioData.eventTypes[0], + }); + }, + timeout + ); + }); + }); + + describe("Team Events", () => { + test.todo("Collective event booking"); + test.todo("Round Robin booking"); + }); + + describe("Team Plus Paid Events", () => { + test.todo("Collective event booking"); + test.todo("Round Robin booking"); + }); + + test.todo("Calendar and video Apps installed on a Team Account"); + + test.todo("Managed Event Type booking"); + + test.todo("Dynamic Group Booking"); + + describe("Booking Limits", () => { + test.todo("Test these cases that were failing earlier https://github.com/calcom/cal.com/pull/10480"); + }); + + describe("Reschedule", () => { + test( + `should rechedule an existing booking successfully with Cal Video(Daily Video) + 1. Should cancel the existing booking + 2. Should create a new booking in the database + 3. Should send emails to the booker as well as organizer + 4. Should trigger BOOKING_RESCHEDULED webhook + `, + async ({ emails }) => { + const handleNewBooking = (await import("@calcom/features/bookings/lib/handleNewBooking")).default; + 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], + }); + + const { dateString: plus1DateString } = getDate({ dateIncrement: 1 }); + const uidOfBookingToBeRescheduled = "n5Wv3eHgconAED2j4gcVhP"; + await createBookingScenario( + getScenarioData({ + webhooks: [ + { + userId: organizer.id, + eventTriggers: ["BOOKING_CREATED"], + subscriberUrl: "http://my-webhook.example.com", + active: true, + eventTypeId: 1, + appId: null, + }, + ], + eventTypes: [ + { + id: 1, + slotInterval: 45, + length: 45, + users: [ + { + id: 101, + }, + ], + }, + ], + bookings: [ + { + uid: uidOfBookingToBeRescheduled, + eventTypeId: 1, + status: BookingStatus.ACCEPTED, + startTime: `${plus1DateString}T05:00:00.000Z`, + endTime: `${plus1DateString}T05:15:00.000Z`, + references: [ + { + type: "daily_video", + uid: "MOCK_ID", + meetingId: "MOCK_ID", + meetingPassword: "MOCK_PASS", + meetingUrl: "http://mock-dailyvideo.example.com", + }, + { + type: "google_calendar", + uid: "MOCK_ID", + meetingId: "MOCK_ID", + meetingPassword: "MOCK_PASSWORD", + meetingUrl: "https://UNUSED_URL", + externalCalendarId: "MOCK_EXTERNAL_CALENDAR_ID", + credentialId: undefined, + }, + ], + }, + ], + organizer, + apps: [TestData.apps["google-calendar"], TestData.apps["daily-video"]], + }) + ); + + const videoMock = mockSuccessfulVideoMeetingCreation({ + metadataLookupKey: "dailyvideo", + }); + + const calendarMock = mockCalendarToHaveNoBusySlots("googlecalendar", { + create: { + uid: "MOCK_ID", + }, + update: { + uid: "UPDATED_MOCK_ID", + }, + }); + + const mockBookingData = getMockRequestDataForBooking({ + data: { + eventTypeId: 1, + rescheduleUid: uidOfBookingToBeRescheduled, + start: `${plus1DateString}T04:00:00.000Z`, + end: `${plus1DateString}T04:15:00.000Z`, + responses: { + email: booker.email, + name: booker.name, + location: { optionValue: "", value: "integrations:daily" }, + }, + }, + }); + + const { req } = createMockNextJsRequest({ + method: "POST", + body: mockBookingData, + }); + + const createdBooking = await handleNewBooking(req); + + const previousBooking = await prismaMock.booking.findUnique({ + where: { + uid: uidOfBookingToBeRescheduled, + }, + }); + + logger.silly({ + previousBooking, + allBookings: await prismaMock.booking.findMany(), + }); + + // Expect previous booking to be cancelled + await expectBookingToBeInDatabase({ + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + uid: uidOfBookingToBeRescheduled, + status: BookingStatus.CANCELLED, + }); + + expect(previousBooking?.status).toBe(BookingStatus.CANCELLED); + /** + * Booking Time should be new time + */ + expect(createdBooking.startTime?.toISOString()).toBe(`${plus1DateString}T04:00:00.000Z`); + expect(createdBooking.endTime?.toISOString()).toBe(`${plus1DateString}T04:15:00.000Z`); - expect(webhookResponse?.statusCode).toBe(200); - await expectBookingToBeInDatabase({ + await expectBookingInDBToBeRescheduledFromTo({ + from: { + uid: uidOfBookingToBeRescheduled, + }, + to: { description: "", // eslint-disable-next-line @typescript-eslint/no-non-null-assertion uid: createdBooking.uid!, eventTypeId: mockBookingData.eventTypeId, - status: BookingStatus.PENDING, - }); - expectBookingRequestedWebhookToHaveBeenFired({ - booker, - organizer, + status: BookingStatus.ACCEPTED, location: "integrations:daily", - subscriberUrl, - paidEvent: true, - eventType: scenarioData.eventTypes[0], - }); - }, - timeout - ); - }); - }); + responses: expect.objectContaining({ + email: booker.email, + name: booker.name, + }), + references: [ + { + type: "daily_video", + uid: "MOCK_ID", + meetingId: "MOCK_ID", + meetingPassword: "MOCK_PASS", + meetingUrl: "http://mock-dailyvideo.example.com", + }, + { + type: "google_calendar", + uid: "MOCK_ID", + meetingId: "MOCK_ID", + meetingPassword: "MOCK_PASSWORD", + meetingUrl: "https://UNUSED_URL", + externalCalendarId: "MOCK_EXTERNAL_CALENDAR_ID", + }, + ], + }, + }); - describe("Reschedule", () => { + expectWorkflowToBeTriggered(); + + expectSuccessfulVideoMeetingUpdationInCalendar(videoMock, { + calEvent: { + location: "http://mock-dailyvideo.example.com", + }, + bookingRef: { + type: "daily_video", + uid: "MOCK_ID", + meetingId: "MOCK_ID", + meetingPassword: "MOCK_PASS", + meetingUrl: "http://mock-dailyvideo.example.com", + }, + }); + + expectSuccessfulCalendarEventUpdationInCalendar(calendarMock, { + externalCalendarId: "MOCK_EXTERNAL_CALENDAR_ID", + calEvent: { + videoCallData: expect.objectContaining({ + url: "http://mock-dailyvideo.example.com", + }), + }, + uid: "MOCK_ID", + }); + + expectSuccessfulBookingRescheduledEmails({ booker, organizer, emails }); + expectBookingRescheduledWebhookToHaveBeenFired({ + booker, + organizer, + location: "integrations:daily", + subscriberUrl: "http://my-webhook.example.com", + videoCallUrl: `${WEBAPP_URL}/video/DYNAMIC_UID`, + }); + }, + timeout + ); test( - `should rechedule a booking successfully with Cal Video(Daily Video) if no explicit location is provided - 1. Should cancel the booking + `should rechedule a booking successfully and update the event in the same externalCalendarId as was used in the booking earlier. + 1. Should cancel the existing booking 2. Should create a new booking in the database 3. Should send emails to the booker as well as organizer 4. Should trigger BOOKING_RESCHEDULED webhook @@ -1106,6 +2085,10 @@ describe("handleNewBooking", () => { id: 101, }, ], + destinationCalendar: { + integration: "google_calendar", + externalId: "event-type-1@example.com", + }, }, ], bookings: [ @@ -1129,7 +2112,7 @@ describe("handleNewBooking", () => { meetingId: "MOCK_ID", meetingPassword: "MOCK_PASSWORD", meetingUrl: "https://UNUSED_URL", - externalCalendarId: "MOCK_EXTERNAL_CALENDAR_ID", + externalCalendarId: "existing-event-type@example.com", credentialId: undefined, }, ], @@ -1174,65 +2157,45 @@ describe("handleNewBooking", () => { const createdBooking = await handleNewBooking(req); - const previousBooking = await prismaMock.booking.findUnique({ - where: { - uid: uidOfBookingToBeRescheduled, - }, - }); - - logger.silly({ - previousBooking, - allBookings: await prismaMock.booking.findMany(), - }); - - // Expect previous booking to be cancelled - await expectBookingToBeInDatabase({ - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - uid: uidOfBookingToBeRescheduled, - status: BookingStatus.CANCELLED, - }); - - expect(previousBooking?.status).toBe(BookingStatus.CANCELLED); /** * Booking Time should be new time */ expect(createdBooking.startTime?.toISOString()).toBe(`${plus1DateString}T04:00:00.000Z`); expect(createdBooking.endTime?.toISOString()).toBe(`${plus1DateString}T04:15:00.000Z`); - expect(createdBooking.responses).toContain({ - email: booker.email, - name: booker.name, - }); - - expect(createdBooking).toContain({ - location: "integrations:daily", - }); - - // Expect new booking to be there. - await expectBookingToBeInDatabase({ - description: "", - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - uid: createdBooking.uid!, - eventTypeId: mockBookingData.eventTypeId, - status: BookingStatus.ACCEPTED, - references: [ - { - type: "daily_video", - uid: "MOCK_ID", - meetingId: "MOCK_ID", - meetingPassword: "MOCK_PASS", - meetingUrl: "http://mock-dailyvideo.example.com", - }, - { - type: "google_calendar", - // IssueToBeFiled-Hariom: It isn' UPDATED_MOCK_ID as references are due to some reason intentionally kept the same after reschedule. See https://github.com/calcom/cal.com/blob/57b48b0a90e13b9eefc1a93abc0044633561b515/packages/core/EventManager.ts#L317 - uid: "MOCK_ID", - meetingId: "MOCK_ID", - meetingPassword: "MOCK_PASSWORD", - meetingUrl: "https://UNUSED_URL", - externalCalendarId: "MOCK_EXTERNAL_CALENDAR_ID", - }, - ], + await expectBookingInDBToBeRescheduledFromTo({ + from: { + uid: uidOfBookingToBeRescheduled, + }, + to: { + description: "", + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + uid: createdBooking.uid!, + eventTypeId: mockBookingData.eventTypeId, + status: BookingStatus.ACCEPTED, + location: "integrations:daily", + responses: expect.objectContaining({ + email: booker.email, + name: booker.name, + }), + references: [ + { + type: "daily_video", + uid: "MOCK_ID", + meetingId: "MOCK_ID", + meetingPassword: "MOCK_PASS", + meetingUrl: "http://mock-dailyvideo.example.com", + }, + { + type: "google_calendar", + uid: "MOCK_ID", + meetingId: "MOCK_ID", + meetingPassword: "MOCK_PASSWORD", + meetingUrl: "https://UNUSED_URL", + externalCalendarId: "existing-event-type@example.com", + }, + ], + }, }); expectWorkflowToBeTriggered(); @@ -1250,8 +2213,10 @@ describe("handleNewBooking", () => { }, }); + // updateEvent uses existing booking's externalCalendarId to update the event in calendar. + // and not the event-type's organizer's which is event-type-1@example.com expectSuccessfulCalendarEventUpdationInCalendar(calendarMock, { - externalCalendarId: "MOCK_EXTERNAL_CALENDAR_ID", + externalCalendarId: "existing-event-type@example.com", calEvent: { location: "http://mock-dailyvideo.example.com", }, @@ -1269,6 +2234,150 @@ describe("handleNewBooking", () => { }, timeout ); + + test( + `an error in updating a calendar event should not stop the rescheduling - Current behaviour is wrong as the booking is resheduled but no-one is notified of it`, + async ({ emails }) => { + const handleNewBooking = (await import("@calcom/features/bookings/lib/handleNewBooking")).default; + 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 uidOfBookingToBeRescheduled = "n5Wv3eHgconAED2j4gcVhP"; + const { dateString: plus1DateString } = getDate({ dateIncrement: 1 }); + + await createBookingScenario( + getScenarioData({ + webhooks: [ + { + userId: organizer.id, + eventTriggers: ["BOOKING_CREATED"], + subscriberUrl: "http://my-webhook.example.com", + active: true, + eventTypeId: 1, + appId: null, + }, + ], + eventTypes: [ + { + id: 1, + slotInterval: 45, + length: 45, + users: [ + { + id: 101, + }, + ], + }, + ], + bookings: [ + { + uid: uidOfBookingToBeRescheduled, + eventTypeId: 1, + status: BookingStatus.ACCEPTED, + startTime: `${plus1DateString}T05:00:00.000Z`, + endTime: `${plus1DateString}T05:15:00.000Z`, + references: [ + { + type: "daily_video", + uid: "MOCK_ID", + meetingId: "MOCK_ID", + meetingPassword: "MOCK_PASS", + meetingUrl: "http://mock-dailyvideo.example.com", + }, + { + type: "google_calendar", + uid: "ORIGINAL_BOOKING_UID", + meetingId: "ORIGINAL_MEETING_ID", + meetingPassword: "ORIGINAL_MEETING_PASSWORD", + meetingUrl: "https://ORIGINAL_MEETING_URL", + externalCalendarId: "existing-event-type@example.com", + credentialId: undefined, + }, + ], + }, + ], + organizer, + apps: [TestData.apps["google-calendar"], TestData.apps["daily-video"]], + }) + ); + + const calendarMock = mockCalendarToCrashOnUpdateEvent("googlecalendar"); + + const mockBookingData = getMockRequestDataForBooking({ + data: { + eventTypeId: 1, + rescheduleUid: uidOfBookingToBeRescheduled, + responses: { + email: booker.email, + name: booker.name, + location: { optionValue: "", value: "New York" }, + }, + }, + }); + + const { req } = createMockNextJsRequest({ + method: "POST", + body: mockBookingData, + }); + + const createdBooking = await handleNewBooking(req); + + await expectBookingInDBToBeRescheduledFromTo({ + from: { + uid: uidOfBookingToBeRescheduled, + }, + to: { + description: "", + location: "New York", + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + uid: createdBooking.uid!, + eventTypeId: mockBookingData.eventTypeId, + status: BookingStatus.ACCEPTED, + responses: expect.objectContaining({ + email: booker.email, + name: booker.name, + }), + references: [ + { + type: "google_calendar", + // A reference is still created in case of event creation failure, with nullish values. Not sure what's the purpose for this. + uid: "ORIGINAL_BOOKING_UID", + meetingId: "ORIGINAL_MEETING_ID", + meetingPassword: "ORIGINAL_MEETING_PASSWORD", + meetingUrl: "https://ORIGINAL_MEETING_URL", + }, + ], + }, + }); + + expectWorkflowToBeTriggered(); + + // FIXME: We should send Broken Integration emails on calendar event updation failure + // expectBrokenIntegrationEmails({ booker, organizer, emails }); + + expectBookingRescheduledWebhookToHaveBeenFired({ + booker, + organizer, + location: "New York", + subscriberUrl: "http://my-webhook.example.com", + }); + }, + timeout + ); }); }); diff --git a/packages/features/bookings/lib/handleNewBooking.ts b/packages/features/bookings/lib/handleNewBooking.ts index 7e97c121ba3210..b7c413c9878777 100644 --- a/packages/features/bookings/lib/handleNewBooking.ts +++ b/packages/features/bookings/lib/handleNewBooking.ts @@ -60,6 +60,7 @@ import { HttpError } from "@calcom/lib/http-error"; import isOutOfBounds, { BookingDateInPastError } from "@calcom/lib/isOutOfBounds"; import logger from "@calcom/lib/logger"; import { handlePayment } from "@calcom/lib/payment/handlePayment"; +import { safeStringify } from "@calcom/lib/safeStringify"; import { checkBookingLimits, checkDurationLimits, getLuckyUser } from "@calcom/lib/server"; import { getBookerUrl } from "@calcom/lib/server/getBookerUrl"; import { getTranslation } from "@calcom/lib/server/i18n"; @@ -213,7 +214,7 @@ function checkForConflicts(busyTimes: BufferedBusyTimes, time: dayjs.ConfigType, // Check if time is between start and end times if (dayjs(time).isBetween(startTime, endTime, null, "[)")) { log.error( - `NAUF: start between a busy time slot ${JSON.stringify({ + `NAUF: start between a busy time slot ${safeStringify({ ...busyTime, time: dayjs(time).format(), })}` @@ -223,7 +224,7 @@ function checkForConflicts(busyTimes: BufferedBusyTimes, time: dayjs.ConfigType, // Check if slot end time is between start and end time if (dayjs(time).add(length, "minutes").isBetween(startTime, endTime)) { log.error( - `NAUF: Ends between a busy time slot ${JSON.stringify({ + `NAUF: Ends between a busy time slot ${safeStringify({ ...busyTime, time: dayjs(time).add(length, "minutes").format(), })}` @@ -2123,10 +2124,7 @@ async function handler( message: "Booking Rescheduling failed", }; - loggerWithEventDetails.error( - `Booking ${organizerUser.name} failed`, - JSON.stringify({ error, results }) - ); + loggerWithEventDetails.error(`Booking ${organizerUser.name} failed`, safeStringify({ error, results })); } else { const metadata: AdditionalInformation = {}; const calendarResult = results.find((result) => result.type.includes("_calendar")); @@ -2180,7 +2178,7 @@ async function handler( loggerWithEventDetails.error( `Booking ${organizerUser.username} failed`, - JSON.stringify({ error, results }) + safeStringify({ error, results }) ); } else { const metadata: AdditionalInformation = {}; diff --git a/packages/lib/piiFreeData.ts b/packages/lib/piiFreeData.ts index 075fdcfc3fbd73..c5e614a93ff201 100644 --- a/packages/lib/piiFreeData.ts +++ b/packages/lib/piiFreeData.ts @@ -1,3 +1,5 @@ +import type { Credential, SelectedCalendar } from "@prisma/client"; + import type { CalendarEvent } from "@calcom/types/Calendar"; export function getPiiFreeCalendarEvent(calEvent: CalendarEvent) { @@ -44,3 +46,30 @@ export function getPiiFreeBooking(booking: { // .... Add all other props here that we don't want to be logged. It prevents those properties from being logged accidentally }; } + +export function getPiiFreeCredential(credential: Partial) { + return { + id: credential.id, + invalid: credential.invalid, + appId: credential.appId, + userId: credential.userId, + type: credential.type, + teamId: credential.teamId, + /** + * Let's just get a boolean value for PII sensitive fields so that we atleast know if it's present or not + */ + key: !!credential.key, + }; +} + +export function getPiiFreeSelectedCalendar(selectedCalendar: Partial) { + return { + integration: selectedCalendar.integration, + userId: selectedCalendar.userId, + /** + * Let's just get a boolean value for PII sensitive fields so that we atleast know if it's present or not + */ + externalId: !!selectedCalendar.externalId, + credentialId: !!selectedCalendar.credentialId, + }; +} diff --git a/packages/lib/safeStringify.ts b/packages/lib/safeStringify.ts new file mode 100644 index 00000000000000..b41ff3e2c4ec07 --- /dev/null +++ b/packages/lib/safeStringify.ts @@ -0,0 +1,8 @@ +export function safeStringify(obj: unknown) { + try { + // Avoid crashing on circular references + return JSON.stringify(obj); + } catch (e) { + return obj; + } +}