diff --git a/apps/web/test/utils/bookingScenario/bookingScenario.ts b/apps/web/test/utils/bookingScenario/bookingScenario.ts index ce11e1b89b..9aebffa764 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 356c9752a3..550b07b5c6 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 0e91f10ea8..094e0f7750 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 6993a50dad..39215f5800 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 21103a27ff..27b371e5e4 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 8d3e44f4d4..52b9194f71 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 7ec4d42591..c497246b77 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,6 +219,806 @@ describe("handleNewBooking", () => { timeout ); + describe("Calendar events should be created in the appropriate calendar", () => { + test( + `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 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], + }); + + 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`, + }, + }); + + const calendarMock = mockCalendarToHaveNoBusySlots("googlecalendar", { + create: { + uid: "MOCK_ID", + }, + }); + + 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, + 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, { + 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, + }); + + expectSuccessfulBookingCreationEmails({ booker, organizer, emails }); + expectBookingCreatedWebhookToHaveBeenFired({ + booker, + organizer, + location: "integrations:daily", + subscriberUrl: "http://my-webhook.example.com", + videoCallUrl: `${WEBAPP_URL}/video/DYNAMIC_UID`, + }); + }, + timeout + ); + + test( + `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; + 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", + }, + }); + 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`, + }, + }); + + const calendarMock = mockCalendarToHaveNoBusySlots("googlecalendar", { + create: { + uid: "MOCK_ID", + }, + }); + + 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, + 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: "http://my-webhook.example.com", + videoCallUrl: `${WEBAPP_URL}/video/DYNAMIC_UID`, + }); + }, + timeout + ); + + test( + `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 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", + }, + }); + 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"]], + }) + ); + + const calendarMock = mockCalendarToCrashOnCreateEvent("googlecalendar"); + + const mockBookingData = getMockRequestDataForBooking({ + data: { + eventTypeId: 1, + responses: { + email: booker.email, + name: booker.name, + location: { optionValue: "", value: "New York" }, + }, + }, + }); + + 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: "New York", + }); + + await expectBookingToBeInDatabase({ + description: "", + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + uid: createdBooking.uid!, + eventTypeId: mockBookingData.eventTypeId, + 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(); + + // FIXME: We should send Broken Integration emails on calendar event creation failure + // expectCalendarEventCreationFailureEmails({ booker, organizer, emails }); + + expectBookingCreatedWebhookToHaveBeenFired({ + booker, + organizer, + location: "New York", + subscriberUrl: "http://my-webhook.example.com", + }); + }, + timeout + ); + }); + + 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", + }); + + 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( + `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 @@ -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: "", @@ -1058,10 +1806,30 @@ describe("handleNewBooking", () => { }); }); + 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 a booking successfully with Cal Video(Daily Video) if no explicit location is provided - 1. Should cancel the booking + `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 @@ -1199,40 +1967,39 @@ describe("handleNewBooking", () => { 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: "MOCK_EXTERNAL_CALENDAR_ID", + }, + ], + }, }); expectWorkflowToBeTriggered(); @@ -1252,6 +2019,204 @@ describe("handleNewBooking", () => { 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 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 + `, + 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, + }, + ], + destinationCalendar: { + integration: "google_calendar", + externalId: "event-type-1@example.com", + }, + }, + ], + 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: "existing-event-type@example.com", + 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); + + /** + * 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`); + + 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(); + + 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", + }, + }); + + // 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: "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 7e97c121ba..b7c413c987 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 075fdcfc3f..c5e614a93f 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 0000000000..b41ff3e2c4 --- /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; + } +}