From 2faf24fb986c7a323ee9aa1ff9387bcc14033439 Mon Sep 17 00:00:00 2001 From: Hariom Balhara Date: Tue, 10 Oct 2023 09:46:04 +0530 Subject: [PATCH] test: Add collective scheduling tests (#11670) --- .../utils/bookingScenario/bookingScenario.ts | 297 +++-- .../web/test/utils/bookingScenario/expects.ts | 69 +- packages/app-store/appStoreMetaData.ts | 2 +- .../app-store/getNormalizedAppMetadata.ts | 2 +- packages/app-store/utils.ts | 12 +- packages/core/EventManager.ts | 27 +- packages/core/getUserAvailability.ts | 24 +- packages/core/videoClient.ts | 6 +- .../features/bookings/lib/handleNewBooking.ts | 3 +- .../test/booking-limits.test.ts | 7 + .../test/dynamic-group-booking.test.ts | 10 + .../test/fresh-booking.test.ts} | 887 +++----------- .../test/lib/createMockNextJsRequest.ts | 7 + .../test/lib/getMockRequestDataForBooking.ts | 34 + .../test/lib/setupAndTeardown.ts | 29 + .../test/managed-event-type-booking.test.ts | 11 + .../handleNewBooking/test/reschedule.test.ts | 608 +++++++++ .../collective-scheduling.test.ts | 1086 +++++++++++++++++ packages/lib/piiFreeData.ts | 17 +- vitest.config.ts | 3 + 20 files changed, 2329 insertions(+), 812 deletions(-) create mode 100644 packages/features/bookings/lib/handleNewBooking/test/booking-limits.test.ts create mode 100644 packages/features/bookings/lib/handleNewBooking/test/dynamic-group-booking.test.ts rename packages/features/bookings/lib/{handleNewBooking.test.ts => handleNewBooking/test/fresh-booking.test.ts} (71%) create mode 100644 packages/features/bookings/lib/handleNewBooking/test/lib/createMockNextJsRequest.ts create mode 100644 packages/features/bookings/lib/handleNewBooking/test/lib/getMockRequestDataForBooking.ts create mode 100644 packages/features/bookings/lib/handleNewBooking/test/lib/setupAndTeardown.ts create mode 100644 packages/features/bookings/lib/handleNewBooking/test/managed-event-type-booking.test.ts create mode 100644 packages/features/bookings/lib/handleNewBooking/test/reschedule.test.ts create mode 100644 packages/features/bookings/lib/handleNewBooking/test/team-bookings/collective-scheduling.test.ts diff --git a/apps/web/test/utils/bookingScenario/bookingScenario.ts b/apps/web/test/utils/bookingScenario/bookingScenario.ts index ba6b393824..1d65ff77ea 100644 --- a/apps/web/test/utils/bookingScenario/bookingScenario.ts +++ b/apps/web/test/utils/bookingScenario/bookingScenario.ts @@ -9,12 +9,14 @@ import { v4 as uuidv4 } from "uuid"; import "vitest-fetch-mock"; import { appStoreMetadata } from "@calcom/app-store/appStoreMetaData"; +import type { getMockRequestDataForBooking } from "@calcom/features/bookings/lib/handleNewBooking/test/lib/getMockRequestDataForBooking"; import { handleStripePaymentSuccess } from "@calcom/features/ee/payments/api/webhook"; import type { HttpError } from "@calcom/lib/http-error"; import logger from "@calcom/lib/logger"; import { safeStringify } from "@calcom/lib/safeStringify"; import type { SchedulingType } from "@calcom/prisma/enums"; import type { BookingStatus } from "@calcom/prisma/enums"; +import type { AppMeta } from "@calcom/types/App"; import type { NewCalendarEventType } from "@calcom/types/Calendar"; import type { EventBusyDate } from "@calcom/types/Calendar"; @@ -22,10 +24,6 @@ import { getMockPaymentService } from "./MockPaymentService"; logger.setSettings({ minLevel: "silly" }); const log = logger.getChildLogger({ prefix: ["[bookingScenario]"] }); -type App = { - slug: string; - dirName: string; -}; type InputWebhook = { appId: string | null; @@ -52,24 +50,27 @@ type ScenarioData = { /** * Prisma would return these apps */ - apps?: App[]; + apps?: Partial[]; bookings?: InputBooking[]; webhooks?: InputWebhook[]; }; -type InputCredential = typeof TestData.credentials.google; +type InputCredential = typeof TestData.credentials.google & { + id?: number; +}; type InputSelectedCalendar = typeof TestData.selectedCalendars.google; -type InputUser = typeof TestData.users.example & { id: number } & { +type InputUser = Omit & { + id: number; + defaultScheduleId?: number | null; credentials?: InputCredential[]; selectedCalendars?: InputSelectedCalendar[]; schedules: { - id: number; + // Allows giving id in the input directly so that it can be referenced somewhere else as well + id?: number; name: string; availability: { - userId: number | null; - eventTypeId: number | null; days: number[]; startTime: Date; endTime: Date; @@ -97,7 +98,8 @@ export type InputEventType = { afterEventBuffer?: number; requiresConfirmation?: boolean; destinationCalendar?: Prisma.DestinationCalendarCreateInput; -} & Partial>; + schedule?: InputUser["schedules"][number]; +} & Partial>; type InputBooking = { id?: number; @@ -122,37 +124,75 @@ type InputBooking = { }[]; }; -const Timezones = { +export const Timezones = { "+5:30": "Asia/Kolkata", "+6:00": "Asia/Dhaka", }; async function addEventTypesToDb( - eventTypes: (Omit & { + eventTypes: (Omit< + Prisma.EventTypeCreateInput, + "users" | "worflows" | "destinationCalendar" | "schedule" + > & { // 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; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + schedule?: any; })[] ) { log.silly("TestData: Add EventTypes to DB", JSON.stringify(eventTypes)); await prismock.eventType.createMany({ data: eventTypes, }); + const allEventTypes = await prismock.eventType.findMany({ + include: { + users: true, + workflows: true, + destinationCalendar: true, + schedule: true, + }, + }); + + /** + * This is a hack to get the relationship of schedule to be established with eventType. Looks like a prismock bug that creating eventType along with schedule.create doesn't establish the relationship. + * HACK STARTS + */ + log.silly("Fixed possible prismock bug by creating schedule separately"); + for (let i = 0; i < eventTypes.length; i++) { + const eventType = eventTypes[i]; + const createdEventType = allEventTypes[i]; + + if (eventType.schedule) { + log.silly("TestData: Creating Schedule for EventType", JSON.stringify(eventType)); + await prismock.schedule.create({ + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + //@ts-ignore + data: { + ...eventType.schedule.create, + eventType: { + connect: { + id: createdEventType.id, + }, + }, + }, + }); + } + } + /*** + * HACK ENDS + */ + log.silly( "TestData: All EventTypes in DB are", JSON.stringify({ - eventTypes: await prismock.eventType.findMany({ - include: { - users: true, - workflows: true, - destinationCalendar: true, - }, - }), + eventTypes: allEventTypes, }) ); + return allEventTypes; } async function addEventTypes(eventTypes: InputEventType[], usersStore: InputUser[]) { @@ -197,10 +237,22 @@ async function addEventTypes(eventTypes: InputEventType[], usersStore: InputUser create: eventType.destinationCalendar, } : eventType.destinationCalendar, + schedule: eventType.schedule + ? { + create: { + ...eventType.schedule, + availability: { + createMany: { + data: eventType.schedule.availability, + }, + }, + }, + } + : eventType.schedule, }; }); log.silly("TestData: Creating EventType", JSON.stringify(eventTypesWithUsers)); - await addEventTypesToDb(eventTypesWithUsers); + return await addEventTypesToDb(eventTypesWithUsers); } function addBookingReferencesToDB(bookingReferences: Prisma.BookingReferenceCreateManyInput[]) { @@ -289,10 +341,21 @@ async function addUsersToDb(users: (Prisma.UserCreateInput & { schedules: Prisma await prismock.user.createMany({ data: users, }); + log.silly( "Added users to Db", safeStringify({ - allUsers: await prismock.user.findMany(), + allUsers: await prismock.user.findMany({ + include: { + credentials: true, + schedules: { + include: { + availability: true, + }, + }, + destinationCalendar: true, + }, + }), }) ); } @@ -343,16 +406,28 @@ async function addUsers(users: InputUser[]) { await addUsersToDb(prismaUsersCreate); } +// eslint-disable-next-line @typescript-eslint/no-explicit-any +async function addAppsToDb(apps: any[]) { + log.silly("TestData: Creating Apps", JSON.stringify({ apps })); + await prismock.app.createMany({ + data: apps, + }); + const allApps = await prismock.app.findMany(); + log.silly("TestData: Apps as in DB", JSON.stringify({ apps: allApps })); +} export async function createBookingScenario(data: ScenarioData) { log.silly("TestData: Creating Scenario", JSON.stringify({ data })); await addUsers(data.users); - - const eventType = await addEventTypes(data.eventTypes, data.users); if (data.apps) { - prismock.app.createMany({ - data: data.apps, - }); + await addAppsToDb( + data.apps.map((app) => { + // Enable the app by default + return { enabled: true, ...app }; + }) + ); } + const eventTypes = await addEventTypes(data.eventTypes, data.users); + data.bookings = data.bookings || []; // allowSuccessfulBookingCreation(); await addBookings(data.bookings); @@ -360,7 +435,7 @@ export async function createBookingScenario(data: ScenarioData) { await addWebhooks(data.webhooks || []); // addPaymentMock(); return { - eventType, + eventTypes, }; } @@ -483,12 +558,11 @@ export const TestData = { }, schedules: { IstWorkHours: { - id: 1, name: "9:30AM to 6PM in India - 4:00AM to 12:30PM in GMT", availability: [ { - userId: null, - eventTypeId: null, + // userId: null, + // eventTypeId: null, days: [0, 1, 2, 3, 4, 5, 6], startTime: new Date("1970-01-01T09:30:00.000Z"), endTime: new Date("1970-01-01T18:00:00.000Z"), @@ -497,21 +571,50 @@ export const TestData = { ], timeZone: Timezones["+5:30"], }, + /** + * Has an overlap with IstEveningShift from 5PM to 6PM IST(11:30AM to 12:30PM GMT) + */ + IstMorningShift: { + name: "9:30AM to 6PM in India - 4:00AM to 12:30PM in GMT", + availability: [ + { + // userId: null, + // eventTypeId: null, + days: [0, 1, 2, 3, 4, 5, 6], + startTime: new Date("1970-01-01T09:30:00.000Z"), + endTime: new Date("1970-01-01T18:00:00.000Z"), + date: null, + }, + ], + timeZone: Timezones["+5:30"], + }, + /** + * Has an overlap with IstMorningShift from 5PM to 6PM IST(11:30AM to 12:30PM GMT) + */ + IstEveningShift: { + name: "5:00PM to 10PM in India - 11:30AM to 16:30PM in GMT", + availability: [ + { + // userId: null, + // eventTypeId: null, + days: [0, 1, 2, 3, 4, 5, 6], + startTime: new Date("1970-01-01T17:00:00.000Z"), + endTime: new Date("1970-01-01T22:00:00.000Z"), + date: null, + }, + ], + timeZone: Timezones["+5:30"], + }, IstWorkHoursWithDateOverride: (dateString: string) => ({ - id: 1, name: "9:30AM to 6PM in India - 4:00AM to 12:30PM in GMT but with a Date Override for 2PM to 6PM IST(in GST time it is 8:30AM to 12:30PM)", availability: [ { - userId: null, - eventTypeId: null, days: [0, 1, 2, 3, 4, 5, 6], startTime: new Date("1970-01-01T09:30:00.000Z"), endTime: new Date("1970-01-01T18:00:00.000Z"), date: null, }, { - userId: null, - eventTypeId: null, days: [0, 1, 2, 3, 4, 5, 6], startTime: new Date(`1970-01-01T14:00:00.000Z`), endTime: new Date(`1970-01-01T18:00:00.000Z`), @@ -532,9 +635,7 @@ export const TestData = { }, apps: { "google-calendar": { - slug: "google-calendar", - enabled: true, - dirName: "whatever", + ...appStoreMetadata.googlecalendar, // eslint-disable-next-line @typescript-eslint/ban-ts-comment //@ts-ignore keys: { @@ -545,9 +646,7 @@ export const TestData = { }, }, "daily-video": { - slug: "daily-video", - dirName: "whatever", - enabled: true, + ...appStoreMetadata.dailyvideo, // eslint-disable-next-line @typescript-eslint/ban-ts-comment //@ts-ignore keys: { @@ -560,9 +659,7 @@ export const TestData = { }, }, zoomvideo: { - slug: "zoom", - enabled: true, - dirName: "whatever", + ...appStoreMetadata.zoomvideo, // eslint-disable-next-line @typescript-eslint/ban-ts-comment //@ts-ignore keys: { @@ -575,10 +672,7 @@ export const TestData = { }, }, "stripe-payment": { - //TODO: Read from appStoreMeta - slug: "stripe", - enabled: true, - dirName: "stripepayment", + ...appStoreMetadata.stripepayment, // eslint-disable-next-line @typescript-eslint/ban-ts-comment //@ts-ignore keys: { @@ -608,6 +702,7 @@ export function getOrganizer({ credentials, selectedCalendars, destinationCalendar, + defaultScheduleId, }: { name: string; email: string; @@ -615,6 +710,7 @@ export function getOrganizer({ schedules: InputUser["schedules"]; credentials?: InputCredential[]; selectedCalendars?: InputSelectedCalendar[]; + defaultScheduleId?: number | null; destinationCalendar?: Prisma.DestinationCalendarCreateInput; }) { return { @@ -626,6 +722,7 @@ export function getOrganizer({ credentials, selectedCalendars, destinationCalendar, + defaultScheduleId, }; } @@ -856,7 +953,9 @@ export function mockVideoApp({ url: `http://mock-${metadataLookupKey}.example.com`, }; log.silly("mockSuccessfulVideoMeetingCreation", JSON.stringify({ metadataLookupKey, appStoreLookupKey })); + // eslint-disable-next-line @typescript-eslint/no-explicit-any const createMeetingCalls: any[] = []; + // eslint-disable-next-line @typescript-eslint/no-explicit-any const updateMeetingCalls: any[] = []; // eslint-disable-next-line @typescript-eslint/ban-ts-comment //@ts-ignore @@ -866,42 +965,50 @@ export function mockVideoApp({ lib: { // eslint-disable-next-line @typescript-eslint/ban-ts-comment //@ts-ignore - 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); + VideoApiAdapter: (credential) => { + return { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + createMeeting: (...rest: any[]) => { + if (creationCrash) { + throw new Error("MockVideoApiAdapter.createMeeting fake error"); + } + createMeetingCalls.push({ + credential, + args: rest, + }); - return Promise.resolve({ - type: appStoreMetadata[metadataLookupKey as keyof typeof appStoreMetadata].type, - ...videoMeetingData, - }); - }, - // 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) { - throw new Error("bookingRef.type is not defined"); - } - if (!calEvent.organizer) { - throw new Error("calEvent.organizer is not defined"); - } - log.silly( - "mockSuccessfulVideoMeetingCreation.updateMeeting", - JSON.stringify({ bookingRef, calEvent }) - ); - return Promise.resolve({ - type: appStoreMetadata[metadataLookupKey as keyof typeof appStoreMetadata].type, - ...videoMeetingData, - }); - }, - }), + return Promise.resolve({ + type: appStoreMetadata[metadataLookupKey as keyof typeof appStoreMetadata].type, + ...videoMeetingData, + }); + }, + // 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({ + credential, + args: rest, + }); + if (!bookingRef.type) { + throw new Error("bookingRef.type is not defined"); + } + if (!calEvent.organizer) { + throw new Error("calEvent.organizer is not defined"); + } + log.silly( + "mockSuccessfulVideoMeetingCreation.updateMeeting", + JSON.stringify({ bookingRef, calEvent }) + ); + return Promise.resolve({ + type: appStoreMetadata[metadataLookupKey as keyof typeof appStoreMetadata].type, + ...videoMeetingData, + }); + }, + }; + }, }, }); }); @@ -1029,3 +1136,25 @@ export async function mockPaymentSuccessWebhookFromStripe({ externalId }: { exte } return { webhookResponse }; } + +export function getExpectedCalEventForBookingRequest({ + bookingRequest, + eventType, +}: { + bookingRequest: ReturnType; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + eventType: any; +}) { + return { + // keep adding more fields as needed, so that they can be verified in all scenarios + type: eventType.title, + // Not sure why, but milliseconds are missing in cal Event. + startTime: bookingRequest.start.replace(".000Z", "Z"), + endTime: bookingRequest.end.replace(".000Z", "Z"), + }; +} + +export const enum BookingLocations { + CalVideo = "integrations:daily", + ZoomVideo = "integrations:zoom", +} diff --git a/apps/web/test/utils/bookingScenario/expects.ts b/apps/web/test/utils/bookingScenario/expects.ts index e988017b9b..3ad22136ca 100644 --- a/apps/web/test/utils/bookingScenario/expects.ts +++ b/apps/web/test/utils/bookingScenario/expects.ts @@ -1,6 +1,6 @@ import prismaMock from "../../../../../tests/libs/__mocks__/prisma"; -import type { WebhookTriggerEvents, Booking, BookingReference } from "@prisma/client"; +import type { WebhookTriggerEvents, Booking, BookingReference, DestinationCalendar } from "@prisma/client"; import ical from "node-ical"; import { expect } from "vitest"; import "vitest-fetch-mock"; @@ -182,11 +182,15 @@ export function expectSuccessfulBookingCreationEmails({ emails, organizer, booker, + guests, + otherTeamMembers, iCalUID, }: { emails: Fixtures["emails"]; organizer: { email: string; name: string }; booker: { email: string; name: string }; + guests?: { email: string; name: string }[]; + otherTeamMembers?: { email: string; name: string }[]; iCalUID: string; }) { expect(emails).toHaveEmail( @@ -212,6 +216,39 @@ export function expectSuccessfulBookingCreationEmails({ }, `${booker.name} <${booker.email}>` ); + + if (otherTeamMembers) { + otherTeamMembers.forEach((otherTeamMember) => { + expect(emails).toHaveEmail( + { + htmlToContain: "confirmed_event_type_subject", + // Don't know why but organizer and team members of the eventType don'thave their name here like Booker + to: `${otherTeamMember.email}`, + ics: { + filename: "event.ics", + iCalUID: iCalUID, + }, + }, + `${otherTeamMember.email}` + ); + }); + } + + if (guests) { + guests.forEach((guest) => { + expect(emails).toHaveEmail( + { + htmlToContain: "confirmed_event_type_subject", + to: `${guest.email}`, + ics: { + filename: "event.ics", + iCalUID: iCalUID, + }, + }, + `${guest.name} <${guest.email}` + ); + }); + } } export function expectBrokenIntegrationEmails({ @@ -537,8 +574,9 @@ export function expectSuccessfulCalendarEventCreationInCalendar( updateEventCalls: any[]; }, expected: { - calendarId: string | null; + calendarId?: string | null; videoCallUrl: string; + destinationCalendars: Partial[]; } ) { expect(calendarMock.createEventCalls.length).toBe(1); @@ -553,6 +591,8 @@ export function expectSuccessfulCalendarEventCreationInCalendar( externalId: expected.calendarId, }), ] + : expected.destinationCalendars + ? expect.arrayContaining(expected.destinationCalendars.map((cal) => expect.objectContaining(cal))) : null, videoCallData: expect.objectContaining({ url: expected.videoCallUrl, @@ -584,7 +624,7 @@ export function expectSuccessfulCalendarEventUpdationInCalendar( expect(externalId).toBe(expected.externalCalendarId); } -export function expectSuccessfulVideoMeetingCreationInCalendar( +export function expectSuccessfulVideoMeetingCreation( videoMock: { // eslint-disable-next-line @typescript-eslint/no-explicit-any createMeetingCalls: any[]; @@ -592,19 +632,20 @@ export function expectSuccessfulVideoMeetingCreationInCalendar( updateMeetingCalls: any[]; }, expected: { - externalCalendarId: string; - calEvent: Partial; - uid: string; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + credential: any; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + calEvent: any; } ) { expect(videoMock.createMeetingCalls.length).toBe(1); const call = videoMock.createMeetingCalls[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 callArgs = call.args; + const calEvent = callArgs[0]; + const credential = call.credential; + + expect(credential).toEqual(expected.credential); + expect(calEvent).toEqual(expected.calEvent); } export function expectSuccessfulVideoMeetingUpdationInCalendar( @@ -622,8 +663,8 @@ export function expectSuccessfulVideoMeetingUpdationInCalendar( ) { expect(videoMock.updateMeetingCalls.length).toBe(1); const call = videoMock.updateMeetingCalls[0]; - const bookingRef = call[0]; - const calendarEvent = call[1]; + const bookingRef = call.args[0]; + const calendarEvent = call.args[1]; expect(bookingRef).toEqual(expect.objectContaining(expected.bookingRef)); expect(calendarEvent).toEqual(expect.objectContaining(expected.calEvent)); } diff --git a/packages/app-store/appStoreMetaData.ts b/packages/app-store/appStoreMetaData.ts index 74f6fdb95d..72502226eb 100644 --- a/packages/app-store/appStoreMetaData.ts +++ b/packages/app-store/appStoreMetaData.ts @@ -5,7 +5,7 @@ import { getNormalizedAppMetadata } from "./getNormalizedAppMetadata"; type RawAppStoreMetaData = typeof rawAppStoreMetadata; type AppStoreMetaData = { - [key in keyof RawAppStoreMetaData]: AppMeta; + [key in keyof RawAppStoreMetaData]: Omit & { dirName: string }; }; export const appStoreMetadata = {} as AppStoreMetaData; diff --git a/packages/app-store/getNormalizedAppMetadata.ts b/packages/app-store/getNormalizedAppMetadata.ts index b3dec5fe78..de9c6ce6a7 100644 --- a/packages/app-store/getNormalizedAppMetadata.ts +++ b/packages/app-store/getNormalizedAppMetadata.ts @@ -19,7 +19,7 @@ export const getNormalizedAppMetadata = (appMeta: RawAppStoreMetaData[keyof RawA dirName, __template: "", ...appMeta, - } as AppStoreMetaData[keyof AppStoreMetaData]; + } as Omit & { dirName: string }; metadata.logo = getAppAssetFullPath(metadata.logo, { dirName, isTemplate: metadata.isTemplate, diff --git a/packages/app-store/utils.ts b/packages/app-store/utils.ts index 4ceeb6aae3..aaacb56292 100644 --- a/packages/app-store/utils.ts +++ b/packages/app-store/utils.ts @@ -4,6 +4,9 @@ import type { AppCategories } from "@prisma/client"; // import appStore from "./index"; import { appStoreMetadata } from "@calcom/app-store/appStoreMetaData"; import type { EventLocationType } from "@calcom/app-store/locations"; +import logger from "@calcom/lib/logger"; +import { getPiiFreeCredential } from "@calcom/lib/piiFreeData"; +import { safeStringify } from "@calcom/lib/safeStringify"; import type { App, AppMeta } from "@calcom/types/App"; import type { CredentialPayload } from "@calcom/types/Credential"; @@ -52,7 +55,7 @@ function getApps(credentials: CredentialDataWithTeamName[], filterOnCredentials? /** If the app is a globally installed one, let's inject it's key */ if (appMeta.isGlobal) { - appCredentials.push({ + const credential = { id: 0, type: appMeta.type, // eslint-disable-next-line @typescript-eslint/no-non-null-assertion @@ -65,7 +68,12 @@ function getApps(credentials: CredentialDataWithTeamName[], filterOnCredentials? team: { name: "Global", }, - }); + }; + logger.debug( + `${appMeta.type} is a global app, injecting credential`, + safeStringify(getPiiFreeCredential(credential)) + ); + appCredentials.push(credential); } /** Check if app has location option AND add it if user has credentials for it */ diff --git a/packages/core/EventManager.ts b/packages/core/EventManager.ts index dba0812c07..35be0141df 100644 --- a/packages/core/EventManager.ts +++ b/packages/core/EventManager.ts @@ -460,16 +460,23 @@ export default class EventManager { /** @fixme potential bug since Google Meet are saved as `integrations:google:meet` and there are no `google:meet` type in our DB */ const integrationName = event.location.replace("integrations:", ""); - - let videoCredential = event.conferenceCredentialId - ? this.videoCredentials.find((credential) => credential.id === event.conferenceCredentialId) - : this.videoCredentials - // Whenever a new video connection is added, latest credentials are added with the highest ID. - // Because you can't rely on having them in the highest first order here, ensure this by sorting in DESC order - .sort((a, b) => { - return b.id - a.id; - }) - .find((credential: CredentialPayload) => credential.type.includes(integrationName)); + let videoCredential; + if (event.conferenceCredentialId) { + videoCredential = this.videoCredentials.find( + (credential) => credential.id === event.conferenceCredentialId + ); + } else { + videoCredential = this.videoCredentials + // Whenever a new video connection is added, latest credentials are added with the highest ID. + // Because you can't rely on having them in the highest first order here, ensure this by sorting in DESC order + .sort((a, b) => { + return b.id - a.id; + }) + .find((credential: CredentialPayload) => credential.type.includes(integrationName)); + log.warn( + `Could not find conferenceCredentialId for event with location: ${event.location}, trying to use last added video credential` + ); + } /** * This might happen if someone tries to use a location with a missing credential, so we fallback to Cal Video. diff --git a/packages/core/getUserAvailability.ts b/packages/core/getUserAvailability.ts index 98178d4b55..d2078b0fd7 100644 --- a/packages/core/getUserAvailability.ts +++ b/packages/core/getUserAvailability.ts @@ -9,6 +9,7 @@ import { buildDateRanges, subtract } from "@calcom/lib/date-ranges"; import { HttpError } from "@calcom/lib/http-error"; import { descendingLimitKeys, intervalLimitKeyToUnit } from "@calcom/lib/intervalLimit"; import logger from "@calcom/lib/logger"; +import { safeStringify } from "@calcom/lib/safeStringify"; import { checkBookingLimit } from "@calcom/lib/server"; import { performance } from "@calcom/lib/server/perfObserver"; import { getTotalBookingDuration } from "@calcom/lib/server/queries"; @@ -25,6 +26,7 @@ import type { import { getBusyTimes, getBusyTimesForLimitChecks } from "./getBusyTimes"; +const log = logger.getChildLogger({ prefix: ["getUserAvailability"] }); const availabilitySchema = z .object({ dateFrom: stringToDayjs, @@ -161,7 +163,12 @@ export const getUserAvailability = async function getUsersWorkingHoursLifeTheUni if (userId) where.id = userId; const user = initialData?.user || (await getUser(where)); + if (!user) throw new HttpError({ statusCode: 404, message: "No user found" }); + log.debug( + "getUserAvailability for user", + safeStringify({ user: { id: user.id }, slot: { dateFrom, dateTo } }) + ); let eventType: EventType | null = initialData?.eventType || null; if (!eventType && eventTypeId) eventType = await getEventType(eventTypeId); @@ -225,10 +232,17 @@ export const getUserAvailability = async function getUsersWorkingHoursLifeTheUni (schedule) => !user?.defaultScheduleId || schedule.id === user?.defaultScheduleId )[0]; - const schedule = - !eventType?.metadata?.config?.useHostSchedulesForTeamEvent && eventType?.schedule - ? eventType.schedule - : userSchedule; + const useHostSchedulesForTeamEvent = eventType?.metadata?.config?.useHostSchedulesForTeamEvent; + const schedule = !useHostSchedulesForTeamEvent && eventType?.schedule ? eventType.schedule : userSchedule; + log.debug( + "Using schedule:", + safeStringify({ + chosenSchedule: schedule, + eventTypeSchedule: eventType?.schedule, + userSchedule: userSchedule, + useHostSchedulesForTeamEvent: eventType?.metadata?.config?.useHostSchedulesForTeamEvent, + }) + ); const startGetWorkingHours = performance.now(); @@ -270,7 +284,7 @@ export const getUserAvailability = async function getUsersWorkingHoursLifeTheUni const dateRangesInWhichUserIsAvailable = subtract(dateRanges, formattedBusyTimes); - logger.debug( + log.debug( `getWorkingHours took ${endGetWorkingHours - startGetWorkingHours}ms for userId ${userId}`, JSON.stringify({ workingHoursInUtc: workingHours, diff --git a/packages/core/videoClient.ts b/packages/core/videoClient.ts index 6d7be5535e..9d6281f1b1 100644 --- a/packages/core/videoClient.ts +++ b/packages/core/videoClient.ts @@ -55,7 +55,7 @@ const getBusyVideoTimes = async (withCredentials: CredentialPayload[]) => const createMeeting = async (credential: CredentialPayload, calEvent: CalendarEvent) => { const uid: string = getUid(calEvent); - log.silly( + log.debug( "createMeeting", safeStringify({ credential: getPiiFreeCredential(credential), @@ -100,11 +100,13 @@ const createMeeting = async (credential: CredentialPayload, calEvent: CalendarEv }, }); - if (!enabledApp?.enabled) throw "Current location app is not enabled"; + if (!enabledApp?.enabled) + throw `Location app ${credential.appId} is either disabled or not seeded at all`; createdMeeting = await firstVideoAdapter?.createMeeting(calEvent); returnObject = { ...returnObject, createdEvent: createdMeeting, success: true }; + log.debug("created Meeting", safeStringify(returnObject)); } catch (err) { await sendBrokenIntegrationEmail(calEvent, "video"); log.error("createMeeting failed", safeStringify({ err, calEvent: getPiiFreeCalendarEvent(calEvent) })); diff --git a/packages/features/bookings/lib/handleNewBooking.ts b/packages/features/bookings/lib/handleNewBooking.ts index 5475445fa2..ee229bb434 100644 --- a/packages/features/bookings/lib/handleNewBooking.ts +++ b/packages/features/bookings/lib/handleNewBooking.ts @@ -379,7 +379,6 @@ async function ensureAvailableUsers( ) : undefined; - log.debug("getUserAvailability for users", JSON.stringify({ users: eventType.users.map((u) => u.id) })); /** Let's start checking for availability */ for (const user of eventType.users) { const { dateRanges, busy: bufferedBusyTimes } = await getUserAvailability( @@ -968,7 +967,7 @@ async function handler( if ( availableUsers.filter((user) => user.isFixed).length !== users.filter((user) => user.isFixed).length ) { - throw new Error("Some users are unavailable for booking."); + throw new Error("Some of the hosts are unavailable for booking."); } // Pushing fixed user before the luckyUser guarantees the (first) fixed user as the organizer. users = [...availableUsers.filter((user) => user.isFixed), ...luckyUsers]; diff --git a/packages/features/bookings/lib/handleNewBooking/test/booking-limits.test.ts b/packages/features/bookings/lib/handleNewBooking/test/booking-limits.test.ts new file mode 100644 index 0000000000..fcfcef7975 --- /dev/null +++ b/packages/features/bookings/lib/handleNewBooking/test/booking-limits.test.ts @@ -0,0 +1,7 @@ +import { describe } from "vitest"; + +import { test } from "@calcom/web/test/fixtures/fixtures"; + +describe("Booking Limits", () => { + test.todo("Test these cases that were failing earlier https://github.com/calcom/cal.com/pull/10480"); +}); diff --git a/packages/features/bookings/lib/handleNewBooking/test/dynamic-group-booking.test.ts b/packages/features/bookings/lib/handleNewBooking/test/dynamic-group-booking.test.ts new file mode 100644 index 0000000000..a22dd59679 --- /dev/null +++ b/packages/features/bookings/lib/handleNewBooking/test/dynamic-group-booking.test.ts @@ -0,0 +1,10 @@ +import { describe } from "vitest"; + +import { test } from "@calcom/web/test/fixtures/fixtures"; + +import { setupAndTeardown } from "./lib/setupAndTeardown"; + +describe("handleNewBooking", () => { + setupAndTeardown(); + test.todo("Dynamic Group Booking"); +}); diff --git a/packages/features/bookings/lib/handleNewBooking.test.ts b/packages/features/bookings/lib/handleNewBooking/test/fresh-booking.test.ts similarity index 71% rename from packages/features/bookings/lib/handleNewBooking.test.ts rename to packages/features/bookings/lib/handleNewBooking/test/fresh-booking.test.ts index 9299fb01e6..8f3a35f22b 100644 --- a/packages/features/bookings/lib/handleNewBooking.test.ts +++ b/packages/features/bookings/lib/handleNewBooking/test/fresh-booking.test.ts @@ -7,15 +7,12 @@ * * 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"; - import type { Request, Response } from "express"; import type { NextApiRequest, NextApiResponse } from "next"; -import { createMocks } from "node-mocks-http"; -import { describe, expect, beforeEach } from "vitest"; +import { describe, expect } from "vitest"; +import { appStoreMetadata } from "@calcom/app-store/appStoreMetaData"; import { WEBAPP_URL } from "@calcom/lib/constants"; -import logger from "@calcom/lib/logger"; import { BookingStatus } from "@calcom/prisma/enums"; import { test } from "@calcom/web/test/fixtures/fixtures"; import { @@ -27,8 +24,6 @@ import { getBooker, getScenarioData, getZoomAppCredential, - enableEmailFeature, - mockNoTranslations, mockErrorOnVideoMeetingCreation, mockSuccessfulVideoMeetingCreation, mockCalendarToHaveNoBusySlots, @@ -39,7 +34,7 @@ import { mockCalendar, mockCalendarToCrashOnCreateEvent, mockVideoAppToCrashOnCreateMeeting, - mockCalendarToCrashOnUpdateEvent, + BookingLocations, } from "@calcom/web/test/utils/bookingScenario/bookingScenario"; import { expectWorkflowToBeTriggered, @@ -50,33 +45,23 @@ import { expectBookingRequestedWebhookToHaveBeenFired, expectBookingCreatedWebhookToHaveBeenFired, expectBookingPaymentIntiatedWebhookToHaveBeenFired, - expectBookingRescheduledWebhookToHaveBeenFired, - expectSuccessfulBookingRescheduledEmails, - expectSuccessfulCalendarEventUpdationInCalendar, - expectSuccessfulVideoMeetingUpdationInCalendar, expectBrokenIntegrationEmails, expectSuccessfulCalendarEventCreationInCalendar, - expectBookingInDBToBeRescheduledFromTo, } from "@calcom/web/test/utils/bookingScenario/expects"; -type CustomNextApiRequest = NextApiRequest & Request; +import { createMockNextJsRequest } from "./lib/createMockNextJsRequest"; +import { getMockRequestDataForBooking } from "./lib/getMockRequestDataForBooking"; +import { setupAndTeardown } from "./lib/setupAndTeardown"; -type CustomNextApiResponse = NextApiResponse & Response; +export type CustomNextApiRequest = NextApiRequest & Request; + +export type CustomNextApiResponse = NextApiResponse & Response; // Local test runs sometime gets too slow const timeout = process.env.CI ? 5000 : 20000; describe("handleNewBooking", () => { - beforeEach(() => { - // Required to able to generate token in email in some cases - process.env.CALENDSO_ENCRYPTION_KEY = "abcdefghjnmkljhjklmnhjklkmnbhjui"; - process.env.STRIPE_WEBHOOK_SECRET = "MOCK_STRIPE_WEBHOOK_SECRET"; - mockNoTranslations(); - // mockEnableEmailFeature(); - enableEmailFeature(); - globalThis.testEmails = []; - fetchMock.resetMocks(); - }); + setupAndTeardown(); - describe("Fresh Booking:", () => { + describe("Fresh/New Booking:", () => { test( `should create a successful booking with Cal Video(Daily Video) if no explicit location is provided 1. Should create a booking in the database @@ -158,7 +143,7 @@ describe("handleNewBooking", () => { responses: { email: booker.email, name: booker.name, - location: { optionValue: "", value: "integrations:daily" }, + location: { optionValue: "", value: BookingLocations.CalVideo }, }, }, }); @@ -175,7 +160,7 @@ describe("handleNewBooking", () => { }); expect(createdBooking).toContain({ - location: "integrations:daily", + location: BookingLocations.CalVideo, }); await expectBookingToBeInDatabase({ @@ -186,14 +171,14 @@ describe("handleNewBooking", () => { status: BookingStatus.ACCEPTED, references: [ { - type: "daily_video", + type: appStoreMetadata.dailyvideo.type, uid: "MOCK_ID", meetingId: "MOCK_ID", meetingPassword: "MOCK_PASS", meetingUrl: "http://mock-dailyvideo.example.com/meeting-1", }, { - type: "google_calendar", + type: appStoreMetadata.googlecalendar.type, uid: "MOCKED_GOOGLE_CALENDAR_EVENT_ID", meetingId: "MOCKED_GOOGLE_CALENDAR_EVENT_ID", meetingPassword: "MOCK_PASSWORD", @@ -218,7 +203,7 @@ describe("handleNewBooking", () => { expectBookingCreatedWebhookToHaveBeenFired({ booker, organizer, - location: "integrations:daily", + location: BookingLocations.CalVideo, subscriberUrl: "http://my-webhook.example.com", videoCallUrl: `${WEBAPP_URL}/video/DYNAMIC_UID`, }); @@ -303,7 +288,7 @@ describe("handleNewBooking", () => { responses: { email: booker.email, name: booker.name, - location: { optionValue: "", value: "integrations:daily" }, + location: { optionValue: "", value: BookingLocations.CalVideo }, }, }, }); @@ -320,7 +305,7 @@ describe("handleNewBooking", () => { }); expect(createdBooking).toContain({ - location: "integrations:daily", + location: BookingLocations.CalVideo, }); await expectBookingToBeInDatabase({ @@ -331,14 +316,14 @@ describe("handleNewBooking", () => { status: BookingStatus.ACCEPTED, references: [ { - type: "daily_video", + type: appStoreMetadata.dailyvideo.type, uid: "MOCK_ID", meetingId: "MOCK_ID", meetingPassword: "MOCK_PASS", meetingUrl: "http://mock-dailyvideo.example.com/meeting-1", }, { - type: "google_calendar", + type: appStoreMetadata.googlecalendar.type, uid: "GOOGLE_CALENDAR_EVENT_ID", meetingId: "GOOGLE_CALENDAR_EVENT_ID", meetingPassword: "MOCK_PASSWORD", @@ -365,7 +350,7 @@ describe("handleNewBooking", () => { expectBookingCreatedWebhookToHaveBeenFired({ booker, organizer, - location: "integrations:daily", + location: BookingLocations.CalVideo, subscriberUrl: "http://my-webhook.example.com", videoCallUrl: `${WEBAPP_URL}/video/DYNAMIC_UID`, }); @@ -451,7 +436,7 @@ describe("handleNewBooking", () => { responses: { email: booker.email, name: booker.name, - location: { optionValue: "", value: "integrations:daily" }, + location: { optionValue: "", value: BookingLocations.CalVideo }, }, }, }); @@ -468,7 +453,7 @@ describe("handleNewBooking", () => { }); expect(createdBooking).toContain({ - location: "integrations:daily", + location: BookingLocations.CalVideo, }); await expectBookingToBeInDatabase({ @@ -479,14 +464,14 @@ describe("handleNewBooking", () => { status: BookingStatus.ACCEPTED, references: [ { - type: "daily_video", + type: appStoreMetadata.dailyvideo.type, uid: "MOCK_ID", meetingId: "MOCK_ID", meetingPassword: "MOCK_PASS", meetingUrl: "http://mock-dailyvideo.example.com/meeting-1", }, { - type: "google_calendar", + type: appStoreMetadata.googlecalendar.type, uid: "GOOGLE_CALENDAR_EVENT_ID", meetingId: "GOOGLE_CALENDAR_EVENT_ID", meetingPassword: "MOCK_PASSWORD", @@ -511,7 +496,7 @@ describe("handleNewBooking", () => { expectBookingCreatedWebhookToHaveBeenFired({ booker, organizer, - location: "integrations:daily", + location: BookingLocations.CalVideo, subscriberUrl: "http://my-webhook.example.com", videoCallUrl: `${WEBAPP_URL}/video/DYNAMIC_UID`, }); @@ -605,7 +590,7 @@ describe("handleNewBooking", () => { status: BookingStatus.ACCEPTED, references: [ { - type: "google_calendar", + type: appStoreMetadata.googlecalendar.type, // 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, @@ -629,6 +614,156 @@ describe("handleNewBooking", () => { }, timeout ); + + test( + "If destination calendar has no credential ID due to some reason, it should create the event in first connected calendar instead", + 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"]], + }) + ); + + // await prismaMock.destinationCalendar.update({ + // where: { + // userId: organizer.id, + // }, + // data: { + // credentialId: null, + // }, + // }); + 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", + id: "GOOGLE_CALENDAR_EVENT_ID", + iCalUID: "MOCKED_GOOGLE_CALENDAR_ICS_ID", + }, + }); + + const mockBookingData = getMockRequestDataForBooking({ + data: { + eventTypeId: 1, + responses: { + email: booker.email, + name: booker.name, + location: { optionValue: "", value: BookingLocations.CalVideo }, + }, + }, + }); + + 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: BookingLocations.CalVideo, + }); + + await expectBookingToBeInDatabase({ + description: "", + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + uid: createdBooking.uid!, + eventTypeId: mockBookingData.eventTypeId, + status: BookingStatus.ACCEPTED, + references: [ + { + type: appStoreMetadata.dailyvideo.type, + uid: "MOCK_ID", + meetingId: "MOCK_ID", + meetingPassword: "MOCK_PASS", + meetingUrl: "http://mock-dailyvideo.example.com/meeting-1", + }, + { + type: appStoreMetadata.googlecalendar.type, + uid: "GOOGLE_CALENDAR_EVENT_ID", + meetingId: "GOOGLE_CALENDAR_EVENT_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, + iCalUID: "MOCKED_GOOGLE_CALENDAR_ICS_ID", + }); + + expectBookingCreatedWebhookToHaveBeenFired({ + booker, + organizer, + location: BookingLocations.CalVideo, + subscriberUrl: "http://my-webhook.example.com", + videoCallUrl: `${WEBAPP_URL}/video/DYNAMIC_UID`, + }); + }, + timeout + ); }); describe("Video Meeting Creation", () => { @@ -690,7 +825,7 @@ describe("handleNewBooking", () => { responses: { email: booker.email, name: booker.name, - location: { optionValue: "", value: "integrations:zoom" }, + location: { optionValue: "", value: BookingLocations.ZoomVideo }, }, }, }), @@ -708,7 +843,7 @@ describe("handleNewBooking", () => { expectBookingCreatedWebhookToHaveBeenFired({ booker, organizer, - location: "integrations:zoom", + location: BookingLocations.ZoomVideo, subscriberUrl, videoCallUrl: "http://mock-zoomvideo.example.com", }); @@ -775,7 +910,7 @@ describe("handleNewBooking", () => { responses: { email: booker.email, name: booker.name, - location: { optionValue: "", value: "integrations:zoom" }, + location: { optionValue: "", value: BookingLocations.ZoomVideo }, }, }, }), @@ -787,7 +922,7 @@ describe("handleNewBooking", () => { expectBookingCreatedWebhookToHaveBeenFired({ booker, organizer, - location: "integrations:zoom", + location: BookingLocations.ZoomVideo, subscriberUrl, videoCallUrl: null, }); @@ -1031,7 +1166,7 @@ describe("handleNewBooking", () => { responses: { email: booker.email, name: booker.name, - location: { optionValue: "", value: "integrations:daily" }, + location: { optionValue: "", value: BookingLocations.CalVideo }, }, }, }); @@ -1048,7 +1183,7 @@ describe("handleNewBooking", () => { }); expect(createdBooking).toContain({ - location: "integrations:daily", + location: BookingLocations.CalVideo, }); await expectBookingToBeInDatabase({ @@ -1070,7 +1205,7 @@ describe("handleNewBooking", () => { expectBookingRequestedWebhookToHaveBeenFired({ booker, organizer, - location: "integrations:daily", + location: BookingLocations.CalVideo, subscriberUrl, eventType: scenarioData.eventTypes[0], }); @@ -1153,7 +1288,7 @@ describe("handleNewBooking", () => { responses: { email: booker.email, name: booker.name, - location: { optionValue: "", value: "integrations:daily" }, + location: { optionValue: "", value: BookingLocations.CalVideo }, }, }, }); @@ -1170,7 +1305,7 @@ describe("handleNewBooking", () => { }); expect(createdBooking).toContain({ - location: "integrations:daily", + location: BookingLocations.CalVideo, }); await expectBookingToBeInDatabase({ @@ -1193,7 +1328,7 @@ describe("handleNewBooking", () => { expectBookingCreatedWebhookToHaveBeenFired({ booker, organizer, - location: "integrations:daily", + location: BookingLocations.CalVideo, subscriberUrl, videoCallUrl: `${WEBAPP_URL}/video/DYNAMIC_UID`, }); @@ -1275,7 +1410,7 @@ describe("handleNewBooking", () => { responses: { email: booker.email, name: booker.name, - location: { optionValue: "", value: "integrations:daily" }, + location: { optionValue: "", value: BookingLocations.CalVideo }, }, }, }); @@ -1292,7 +1427,7 @@ describe("handleNewBooking", () => { }); expect(createdBooking).toContain({ - location: "integrations:daily", + location: BookingLocations.CalVideo, }); await expectBookingToBeInDatabase({ @@ -1310,7 +1445,7 @@ describe("handleNewBooking", () => { expectBookingRequestedWebhookToHaveBeenFired({ booker, organizer, - location: "integrations:daily", + location: BookingLocations.CalVideo, subscriberUrl, eventType: scenarioData.eventTypes[0], }); @@ -1369,7 +1504,7 @@ describe("handleNewBooking", () => { responses: { email: booker.email, name: booker.name, - location: { optionValue: "", value: "integrations:daily" }, + location: { optionValue: "", value: BookingLocations.CalVideo }, }, }, }), @@ -1574,7 +1709,7 @@ describe("handleNewBooking", () => { responses: { email: booker.email, name: booker.name, - location: { optionValue: "", value: "integrations:daily" }, + location: { optionValue: "", value: BookingLocations.CalVideo }, }, }, }); @@ -1590,7 +1725,7 @@ describe("handleNewBooking", () => { name: booker.name, }); expect(createdBooking).toContain({ - location: "integrations:daily", + location: BookingLocations.CalVideo, paymentUid: paymentUid, }); await expectBookingToBeInDatabase({ @@ -1606,7 +1741,7 @@ describe("handleNewBooking", () => { expectBookingPaymentIntiatedWebhookToHaveBeenFired({ booker, organizer, - location: "integrations:daily", + location: BookingLocations.CalVideo, subscriberUrl: "http://my-webhook.example.com", // eslint-disable-next-line @typescript-eslint/no-non-null-assertion paymentId: createdBooking.paymentId!, @@ -1626,7 +1761,7 @@ describe("handleNewBooking", () => { expectBookingCreatedWebhookToHaveBeenFired({ booker, organizer, - location: "integrations:daily", + location: BookingLocations.CalVideo, subscriberUrl: "http://my-webhook.example.com", videoCallUrl: `${WEBAPP_URL}/video/DYNAMIC_UID`, paidEvent: true, @@ -1716,7 +1851,7 @@ describe("handleNewBooking", () => { responses: { email: booker.email, name: booker.name, - location: { optionValue: "", value: "integrations:daily" }, + location: { optionValue: "", value: BookingLocations.CalVideo }, }, }, }); @@ -1731,7 +1866,7 @@ describe("handleNewBooking", () => { name: booker.name, }); expect(createdBooking).toContain({ - location: "integrations:daily", + location: BookingLocations.CalVideo, paymentUid: paymentUid, }); await expectBookingToBeInDatabase({ @@ -1746,7 +1881,7 @@ describe("handleNewBooking", () => { expectBookingPaymentIntiatedWebhookToHaveBeenFired({ booker, organizer, - location: "integrations:daily", + location: BookingLocations.CalVideo, subscriberUrl: "http://my-webhook.example.com", // eslint-disable-next-line @typescript-eslint/no-non-null-assertion paymentId: createdBooking.paymentId!, @@ -1765,7 +1900,7 @@ describe("handleNewBooking", () => { expectBookingRequestedWebhookToHaveBeenFired({ booker, organizer, - location: "integrations:daily", + location: BookingLocations.CalVideo, subscriberUrl, paidEvent: true, eventType: scenarioData.eventTypes[0], @@ -1776,627 +1911,5 @@ 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 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", - iCalUID: "MOCKED_GOOGLE_CALENDAR_ICS_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`); - - 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(); - - 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, - iCalUID: "MOCKED_GOOGLE_CALENDAR_ICS_ID", - }); - 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: { - iCalUID: "MOCKED_GOOGLE_CALENDAR_ICS_ID", - 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", - }, - uid: "MOCK_ID", - }); - - expectSuccessfulBookingRescheduledEmails({ - booker, - organizer, - emails, - iCalUID: "MOCKED_GOOGLE_CALENDAR_ICS_ID", - }); - expectBookingRescheduledWebhookToHaveBeenFired({ - booker, - organizer, - location: "integrations:daily", - subscriberUrl: "http://my-webhook.example.com", - videoCallUrl: `${WEBAPP_URL}/video/DYNAMIC_UID`, - }); - }, - 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 ({}) => { - 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 - ); - }); + test.todo("CRM calendar events creation verification"); }); - -function createMockNextJsRequest(...args: Parameters) { - return createMocks(...args); -} - -function getBasicMockRequestDataForBooking() { - return { - start: `${getDate({ dateIncrement: 1 }).dateString}T04:00:00.000Z`, - end: `${getDate({ dateIncrement: 1 }).dateString}T04:30:00.000Z`, - eventTypeSlug: "no-confirmation", - timeZone: "Asia/Calcutta", - language: "en", - user: "teampro", - metadata: {}, - hasHashedBookingLink: false, - hashedLink: null, - }; -} - -function getMockRequestDataForBooking({ - data, -}: { - data: Partial> & { - eventTypeId: number; - rescheduleUid?: string; - bookingUid?: string; - responses: { - email: string; - name: string; - location: { optionValue: ""; value: string }; - }; - }; -}) { - return { - ...getBasicMockRequestDataForBooking(), - ...data, - }; -} diff --git a/packages/features/bookings/lib/handleNewBooking/test/lib/createMockNextJsRequest.ts b/packages/features/bookings/lib/handleNewBooking/test/lib/createMockNextJsRequest.ts new file mode 100644 index 0000000000..d9d321544f --- /dev/null +++ b/packages/features/bookings/lib/handleNewBooking/test/lib/createMockNextJsRequest.ts @@ -0,0 +1,7 @@ +import { createMocks } from "node-mocks-http"; + +import type { CustomNextApiRequest, CustomNextApiResponse } from "../fresh-booking.test"; + +export function createMockNextJsRequest(...args: Parameters) { + return createMocks(...args); +} diff --git a/packages/features/bookings/lib/handleNewBooking/test/lib/getMockRequestDataForBooking.ts b/packages/features/bookings/lib/handleNewBooking/test/lib/getMockRequestDataForBooking.ts new file mode 100644 index 0000000000..57ea353ee8 --- /dev/null +++ b/packages/features/bookings/lib/handleNewBooking/test/lib/getMockRequestDataForBooking.ts @@ -0,0 +1,34 @@ +import { getDate } from "@calcom/web/test/utils/bookingScenario/bookingScenario"; + +export function getBasicMockRequestDataForBooking() { + return { + start: `${getDate({ dateIncrement: 1 }).dateString}T04:00:00.000Z`, + end: `${getDate({ dateIncrement: 1 }).dateString}T04:30:00.000Z`, + eventTypeSlug: "no-confirmation", + timeZone: "Asia/Calcutta", + language: "en", + user: "teampro", + metadata: {}, + hasHashedBookingLink: false, + hashedLink: null, + }; +} +export function getMockRequestDataForBooking({ + data, +}: { + data: Partial> & { + eventTypeId: number; + rescheduleUid?: string; + bookingUid?: string; + responses: { + email: string; + name: string; + location: { optionValue: ""; value: string }; + }; + }; +}) { + return { + ...getBasicMockRequestDataForBooking(), + ...data, + }; +} diff --git a/packages/features/bookings/lib/handleNewBooking/test/lib/setupAndTeardown.ts b/packages/features/bookings/lib/handleNewBooking/test/lib/setupAndTeardown.ts new file mode 100644 index 0000000000..d910f33918 --- /dev/null +++ b/packages/features/bookings/lib/handleNewBooking/test/lib/setupAndTeardown.ts @@ -0,0 +1,29 @@ +import { beforeEach, afterEach } from "vitest"; + +import { + enableEmailFeature, + mockNoTranslations, +} from "@calcom/web/test/utils/bookingScenario/bookingScenario"; + +export function setupAndTeardown() { + beforeEach(() => { + // Required to able to generate token in email in some cases + process.env.CALENDSO_ENCRYPTION_KEY = "abcdefghjnmkljhjklmnhjklkmnbhjui"; + process.env.STRIPE_WEBHOOK_SECRET = "MOCK_STRIPE_WEBHOOK_SECRET"; + // We are setting it in vitest.config.ts because otherwise it's too late to set it. + // process.env.DAILY_API_KEY = "MOCK_DAILY_API_KEY"; + mockNoTranslations(); + // mockEnableEmailFeature(); + enableEmailFeature(); + globalThis.testEmails = []; + fetchMock.resetMocks(); + }); + afterEach(() => { + delete process.env.CALENDSO_ENCRYPTION_KEY; + delete process.env.STRIPE_WEBHOOK_SECRET; + delete process.env.DAILY_API_KEY; + globalThis.testEmails = []; + fetchMock.resetMocks(); + // process.env.DAILY_API_KEY = "MOCK_DAILY_API_KEY"; + }); +} diff --git a/packages/features/bookings/lib/handleNewBooking/test/managed-event-type-booking.test.ts b/packages/features/bookings/lib/handleNewBooking/test/managed-event-type-booking.test.ts new file mode 100644 index 0000000000..81a10098aa --- /dev/null +++ b/packages/features/bookings/lib/handleNewBooking/test/managed-event-type-booking.test.ts @@ -0,0 +1,11 @@ +import { describe } from "vitest"; + +import { test } from "@calcom/web/test/fixtures/fixtures"; + +import { setupAndTeardown } from "./lib/setupAndTeardown"; + +describe("handleNewBooking", () => { + setupAndTeardown(); + + test.todo("Managed Event Type booking"); +}); diff --git a/packages/features/bookings/lib/handleNewBooking/test/reschedule.test.ts b/packages/features/bookings/lib/handleNewBooking/test/reschedule.test.ts new file mode 100644 index 0000000000..9a739b0385 --- /dev/null +++ b/packages/features/bookings/lib/handleNewBooking/test/reschedule.test.ts @@ -0,0 +1,608 @@ +import prismaMock from "../../../../../../tests/libs/__mocks__/prisma"; + +import { describe, expect } from "vitest"; + +import { appStoreMetadata } from "@calcom/app-store/apps.metadata.generated"; +import { WEBAPP_URL } from "@calcom/lib/constants"; +import logger from "@calcom/lib/logger"; +import { BookingStatus } from "@calcom/prisma/enums"; +import { test } from "@calcom/web/test/fixtures/fixtures"; +import { + createBookingScenario, + getDate, + getGoogleCalendarCredential, + TestData, + getOrganizer, + getBooker, + getScenarioData, + mockSuccessfulVideoMeetingCreation, + mockCalendarToHaveNoBusySlots, + mockCalendarToCrashOnUpdateEvent, + BookingLocations, +} from "@calcom/web/test/utils/bookingScenario/bookingScenario"; +import { + expectWorkflowToBeTriggered, + expectBookingToBeInDatabase, + expectBookingRescheduledWebhookToHaveBeenFired, + expectSuccessfulBookingRescheduledEmails, + expectSuccessfulCalendarEventUpdationInCalendar, + expectSuccessfulVideoMeetingUpdationInCalendar, + expectBookingInDBToBeRescheduledFromTo, +} from "@calcom/web/test/utils/bookingScenario/expects"; + +import { createMockNextJsRequest } from "./lib/createMockNextJsRequest"; +import { getMockRequestDataForBooking } from "./lib/getMockRequestDataForBooking"; +import { setupAndTeardown } from "./lib/setupAndTeardown"; + +// Local test runs sometime gets too slow +const timeout = process.env.CI ? 5000 : 20000; + +describe("handleNewBooking", () => { + setupAndTeardown(); + + 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: appStoreMetadata.dailyvideo.type, + uid: "MOCK_ID", + meetingId: "MOCK_ID", + meetingPassword: "MOCK_PASS", + meetingUrl: "http://mock-dailyvideo.example.com", + }, + { + type: appStoreMetadata.googlecalendar.type, + 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", + iCalUID: "MOCKED_GOOGLE_CALENDAR_ICS_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: BookingLocations.CalVideo }, + }, + }, + }); + + 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`); + + 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: BookingLocations.CalVideo, + responses: expect.objectContaining({ + email: booker.email, + name: booker.name, + }), + references: [ + { + type: appStoreMetadata.dailyvideo.type, + uid: "MOCK_ID", + meetingId: "MOCK_ID", + meetingPassword: "MOCK_PASS", + meetingUrl: "http://mock-dailyvideo.example.com", + }, + { + type: appStoreMetadata.googlecalendar.type, + uid: "MOCK_ID", + meetingId: "MOCK_ID", + meetingPassword: "MOCK_PASSWORD", + meetingUrl: "https://UNUSED_URL", + externalCalendarId: "MOCK_EXTERNAL_CALENDAR_ID", + }, + ], + }, + }); + + expectWorkflowToBeTriggered(); + + expectSuccessfulVideoMeetingUpdationInCalendar(videoMock, { + calEvent: { + location: "http://mock-dailyvideo.example.com", + }, + bookingRef: { + type: appStoreMetadata.dailyvideo.type, + 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, + iCalUID: "MOCKED_GOOGLE_CALENDAR_ICS_ID", + }); + expectBookingRescheduledWebhookToHaveBeenFired({ + booker, + organizer, + location: BookingLocations.CalVideo, + 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: appStoreMetadata.dailyvideo.type, + uid: "MOCK_ID", + meetingId: "MOCK_ID", + meetingPassword: "MOCK_PASS", + meetingUrl: "http://mock-dailyvideo.example.com", + }, + { + type: appStoreMetadata.googlecalendar.type, + 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: { + iCalUID: "MOCKED_GOOGLE_CALENDAR_ICS_ID", + 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: BookingLocations.CalVideo }, + }, + }, + }); + + 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: BookingLocations.CalVideo, + responses: expect.objectContaining({ + email: booker.email, + name: booker.name, + }), + references: [ + { + type: appStoreMetadata.dailyvideo.type, + uid: "MOCK_ID", + meetingId: "MOCK_ID", + meetingPassword: "MOCK_PASS", + meetingUrl: "http://mock-dailyvideo.example.com", + }, + { + type: appStoreMetadata.googlecalendar.type, + 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: appStoreMetadata.dailyvideo.type, + 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", + }, + uid: "MOCK_ID", + }); + + expectSuccessfulBookingRescheduledEmails({ + booker, + organizer, + emails, + iCalUID: "MOCKED_GOOGLE_CALENDAR_ICS_ID", + }); + expectBookingRescheduledWebhookToHaveBeenFired({ + booker, + organizer, + location: BookingLocations.CalVideo, + subscriberUrl: "http://my-webhook.example.com", + videoCallUrl: `${WEBAPP_URL}/video/DYNAMIC_UID`, + }); + }, + 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 ({}) => { + 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: appStoreMetadata.dailyvideo.type, + uid: "MOCK_ID", + meetingId: "MOCK_ID", + meetingPassword: "MOCK_PASS", + meetingUrl: "http://mock-dailyvideo.example.com", + }, + { + type: appStoreMetadata.googlecalendar.type, + 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: appStoreMetadata.googlecalendar.type, + // 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/test/team-bookings/collective-scheduling.test.ts b/packages/features/bookings/lib/handleNewBooking/test/team-bookings/collective-scheduling.test.ts new file mode 100644 index 0000000000..09e98d14dd --- /dev/null +++ b/packages/features/bookings/lib/handleNewBooking/test/team-bookings/collective-scheduling.test.ts @@ -0,0 +1,1086 @@ +import type { Request, Response } from "express"; +import type { NextApiRequest, NextApiResponse } from "next"; +import { describe, expect } from "vitest"; + +import { appStoreMetadata } from "@calcom/app-store/appStoreMetaData"; +import { WEBAPP_URL } from "@calcom/lib/constants"; +import { SchedulingType } from "@calcom/prisma/enums"; +import { BookingStatus } from "@calcom/prisma/enums"; +import { test } from "@calcom/web/test/fixtures/fixtures"; +import { + createBookingScenario, + getGoogleCalendarCredential, + TestData, + getOrganizer, + getBooker, + getScenarioData, + mockSuccessfulVideoMeetingCreation, + mockCalendarToHaveNoBusySlots, + Timezones, + getDate, + getExpectedCalEventForBookingRequest, + BookingLocations, + getZoomAppCredential, +} from "@calcom/web/test/utils/bookingScenario/bookingScenario"; +import { + expectWorkflowToBeTriggered, + expectSuccessfulBookingCreationEmails, + expectBookingToBeInDatabase, + expectBookingCreatedWebhookToHaveBeenFired, + expectSuccessfulCalendarEventCreationInCalendar, + expectSuccessfulVideoMeetingCreation, +} from "@calcom/web/test/utils/bookingScenario/expects"; + +import { createMockNextJsRequest } from "../lib/createMockNextJsRequest"; +import { getMockRequestDataForBooking } from "../lib/getMockRequestDataForBooking"; +import { setupAndTeardown } from "../lib/setupAndTeardown"; + +export type CustomNextApiRequest = NextApiRequest & Request; + +export type CustomNextApiResponse = NextApiResponse & Response; +// Local test runs sometime gets too slow +const timeout = process.env.CI ? 5000 : 20000; +describe("handleNewBooking", () => { + setupAndTeardown(); + + describe("Team Events", () => { + describe("Collective Assignment", () => { + describe("When there is no schedule set on eventType - Hosts schedules would be used", () => { + test( + `succesfully creates a booking when all the hosts are free as per their schedules + - Destination calendars for event-type and non-first hosts are used to create calendar events + `, + async ({ emails }) => { + const handleNewBooking = (await import("@calcom/features/bookings/lib/handleNewBooking")).default; + const booker = getBooker({ + email: "booker@example.com", + name: "Booker", + }); + + const otherTeamMembers = [ + { + name: "Other Team Member 1", + username: "other-team-member-1", + timeZone: Timezones["+5:30"], + // So, that it picks the first schedule from the list + defaultScheduleId: null, + email: "other-team-member-1@example.com", + id: 102, + // Has Evening shift + schedules: [TestData.schedules.IstEveningShift], + credentials: [getGoogleCalendarCredential()], + selectedCalendars: [TestData.selectedCalendars.google], + destinationCalendar: { + integration: TestData.apps["google-calendar"].type, + externalId: "other-team-member-1@google-calendar.com", + }, + }, + ]; + + const organizer = getOrganizer({ + name: "Organizer", + email: "organizer@example.com", + id: 101, + // So, that it picks the first schedule from the list + defaultScheduleId: null, + // Has morning shift with some overlap with morning shift + schedules: [TestData.schedules.IstMorningShift], + credentials: [getGoogleCalendarCredential()], + selectedCalendars: [TestData.selectedCalendars.google], + destinationCalendar: { + integration: TestData.apps["google-calendar"].type, + 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, + schedulingType: SchedulingType.COLLECTIVE, + length: 45, + users: [ + { + id: 101, + }, + { + id: 102, + }, + ], + destinationCalendar: { + integration: TestData.apps["google-calendar"].type, + externalId: "event-type-1@google-calendar.com", + }, + }, + ], + organizer, + usersApartFromOrganizer: otherTeamMembers, + apps: [TestData.apps["google-calendar"], TestData.apps["daily-video"]], + }) + ); + + mockSuccessfulVideoMeetingCreation({ + metadataLookupKey: appStoreMetadata.dailyvideo.dirName, + videoMeetingData: { + id: "MOCK_ID", + password: "MOCK_PASS", + url: `http://mock-dailyvideo.example.com/meeting-1`, + }, + }); + + const calendarMock = mockCalendarToHaveNoBusySlots("googlecalendar", { + create: { + id: "MOCKED_GOOGLE_CALENDAR_EVENT_ID", + iCalUID: "MOCKED_GOOGLE_CALENDAR_ICS_ID", + }, + }); + + const mockBookingData = getMockRequestDataForBooking({ + data: { + // Try booking the first available free timeslot in both the users' schedules + start: `${getDate({ dateIncrement: 1 }).dateString}T11:30:00.000Z`, + end: `${getDate({ dateIncrement: 1 }).dateString}T11:45:00.000Z`, + eventTypeId: 1, + responses: { + email: booker.email, + name: booker.name, + location: { optionValue: "", value: BookingLocations.CalVideo }, + }, + }, + }); + + const { req } = createMockNextJsRequest({ + method: "POST", + body: mockBookingData, + }); + + const createdBooking = await handleNewBooking(req); + + await expectBookingToBeInDatabase({ + description: "", + location: BookingLocations.CalVideo, + responses: expect.objectContaining({ + email: booker.email, + name: booker.name, + }), + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + uid: createdBooking.uid!, + eventTypeId: mockBookingData.eventTypeId, + status: BookingStatus.ACCEPTED, + references: [ + { + type: appStoreMetadata.dailyvideo.type, + uid: "MOCK_ID", + meetingId: "MOCK_ID", + meetingPassword: "MOCK_PASS", + meetingUrl: "http://mock-dailyvideo.example.com/meeting-1", + }, + { + type: TestData.apps["google-calendar"].type, + uid: "MOCKED_GOOGLE_CALENDAR_EVENT_ID", + meetingId: "MOCKED_GOOGLE_CALENDAR_EVENT_ID", + meetingPassword: "MOCK_PASSWORD", + meetingUrl: "https://UNUSED_URL", + }, + ], + }); + + expectWorkflowToBeTriggered(); + expectSuccessfulCalendarEventCreationInCalendar(calendarMock, { + destinationCalendars: [ + { + integration: TestData.apps["google-calendar"].type, + externalId: "event-type-1@google-calendar.com", + }, + { + integration: TestData.apps["google-calendar"].type, + externalId: "other-team-member-1@google-calendar.com", + }, + ], + videoCallUrl: "http://mock-dailyvideo.example.com/meeting-1", + }); + + expectSuccessfulBookingCreationEmails({ + booker, + organizer, + otherTeamMembers, + emails, + iCalUID: "MOCKED_GOOGLE_CALENDAR_ICS_ID", + }); + + expectBookingCreatedWebhookToHaveBeenFired({ + booker, + organizer, + location: BookingLocations.CalVideo, + subscriberUrl: "http://my-webhook.example.com", + videoCallUrl: `${WEBAPP_URL}/video/DYNAMIC_UID`, + }); + }, + timeout + ); + + test( + `rejects a booking when even one of the hosts is busy`, + async ({}) => { + const handleNewBooking = (await import("@calcom/features/bookings/lib/handleNewBooking")).default; + const booker = getBooker({ + email: "booker@example.com", + name: "Booker", + }); + + const otherTeamMembers = [ + { + name: "Other Team Member 1", + username: "other-team-member-1", + timeZone: Timezones["+5:30"], + // So, that it picks the first schedule from the list + defaultScheduleId: null, + email: "other-team-member-1@example.com", + id: 102, + // Has Evening shift + schedules: [TestData.schedules.IstEveningShift], + credentials: [getGoogleCalendarCredential()], + selectedCalendars: [TestData.selectedCalendars.google], + destinationCalendar: { + integration: TestData.apps["google-calendar"].type, + externalId: "other-team-member-1@google-calendar.com", + }, + }, + ]; + + const organizer = getOrganizer({ + name: "Organizer", + email: "organizer@example.com", + id: 101, + // So, that it picks the first schedule from the list + defaultScheduleId: null, + // Has morning shift with some overlap with morning shift + schedules: [TestData.schedules.IstMorningShift], + credentials: [getGoogleCalendarCredential()], + selectedCalendars: [TestData.selectedCalendars.google], + destinationCalendar: { + integration: TestData.apps["google-calendar"].type, + 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, + schedulingType: SchedulingType.COLLECTIVE, + length: 45, + users: [ + { + id: 101, + }, + { + id: 102, + }, + ], + destinationCalendar: { + integration: TestData.apps["google-calendar"].type, + externalId: "event-type-1@google-calendar.com", + }, + }, + ], + organizer, + usersApartFromOrganizer: otherTeamMembers, + apps: [TestData.apps["google-calendar"], TestData.apps["daily-video"]], + }) + ); + + mockSuccessfulVideoMeetingCreation({ + metadataLookupKey: appStoreMetadata.dailyvideo.dirName, + videoMeetingData: { + id: "MOCK_ID", + password: "MOCK_PASS", + url: `http://mock-dailyvideo.example.com/meeting-1`, + }, + }); + + const calendarMock = mockCalendarToHaveNoBusySlots("googlecalendar", { + create: { + id: "MOCKED_GOOGLE_CALENDAR_EVENT_ID", + iCalUID: "MOCKED_GOOGLE_CALENDAR_ICS_ID", + }, + }); + + const mockBookingData = getMockRequestDataForBooking({ + data: { + // Try booking the first available free timeslot in both the users' schedules + start: `${getDate({ dateIncrement: 1 }).dateString}T09:00:00.000Z`, + end: `${getDate({ dateIncrement: 1 }).dateString}T09:15:00.000Z`, + eventTypeId: 1, + responses: { + email: booker.email, + name: booker.name, + location: { optionValue: "", value: BookingLocations.CalVideo }, + }, + }, + }); + + const { req } = createMockNextJsRequest({ + method: "POST", + body: mockBookingData, + }); + + await expect(async () => { + await handleNewBooking(req); + }).rejects.toThrowError("Some of the hosts are unavailable for booking"); + }, + timeout + ); + }); + + describe("When there is a schedule set on eventType - Event Type common schedule would be used", () => { + test( + `succesfully creates a booking when the users are available as per the common schedule selected in the event-type + - Destination calendars for event-type and non-first hosts are used to create calendar events + `, + async ({ emails }) => { + const handleNewBooking = (await import("@calcom/features/bookings/lib/handleNewBooking")).default; + const booker = getBooker({ + email: "booker@example.com", + name: "Booker", + }); + + const otherTeamMembers = [ + { + name: "Other Team Member 1", + username: "other-team-member-1", + timeZone: Timezones["+5:30"], + defaultScheduleId: null, + email: "other-team-member-1@example.com", + id: 102, + // No user schedules are here + schedules: [], + credentials: [getGoogleCalendarCredential()], + selectedCalendars: [TestData.selectedCalendars.google], + destinationCalendar: { + integration: TestData.apps["google-calendar"].type, + externalId: "other-team-member-1@google-calendar.com", + }, + }, + ]; + + const organizer = getOrganizer({ + name: "Organizer", + email: "organizer@example.com", + id: 101, + defaultScheduleId: null, + // No user schedules are here + schedules: [], + credentials: [getGoogleCalendarCredential()], + selectedCalendars: [TestData.selectedCalendars.google], + destinationCalendar: { + integration: TestData.apps["google-calendar"].type, + 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, + schedulingType: SchedulingType.COLLECTIVE, + length: 45, + users: [ + { + id: 101, + }, + { + id: 102, + }, + ], + // Common schedule is the morning shift + schedule: TestData.schedules.IstMorningShift, + destinationCalendar: { + integration: TestData.apps["google-calendar"].type, + externalId: "event-type-1@google-calendar.com", + }, + }, + ], + organizer, + usersApartFromOrganizer: otherTeamMembers, + apps: [TestData.apps["google-calendar"], TestData.apps["daily-video"]], + }) + ); + + mockSuccessfulVideoMeetingCreation({ + metadataLookupKey: appStoreMetadata.dailyvideo.dirName, + videoMeetingData: { + id: "MOCK_ID", + password: "MOCK_PASS", + url: `http://mock-dailyvideo.example.com/meeting-1`, + }, + }); + + const calendarMock = mockCalendarToHaveNoBusySlots("googlecalendar", { + create: { + id: "MOCKED_GOOGLE_CALENDAR_EVENT_ID", + iCalUID: "MOCKED_GOOGLE_CALENDAR_ICS_ID", + }, + }); + + const mockBookingData = getMockRequestDataForBooking({ + data: { + // Try booking the first available free timeslot in both the users' schedules + start: `${getDate({ dateIncrement: 1 }).dateString}T11:30:00.000Z`, + end: `${getDate({ dateIncrement: 1 }).dateString}T11:45:00.000Z`, + eventTypeId: 1, + responses: { + email: booker.email, + name: booker.name, + location: { optionValue: "", value: BookingLocations.CalVideo }, + }, + }, + }); + + const { req } = createMockNextJsRequest({ + method: "POST", + body: mockBookingData, + }); + + const createdBooking = await handleNewBooking(req); + + await expectBookingToBeInDatabase({ + description: "", + location: BookingLocations.CalVideo, + responses: expect.objectContaining({ + email: booker.email, + name: booker.name, + }), + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + uid: createdBooking.uid!, + eventTypeId: mockBookingData.eventTypeId, + status: BookingStatus.ACCEPTED, + references: [ + { + type: appStoreMetadata.dailyvideo.type, + uid: "MOCK_ID", + meetingId: "MOCK_ID", + meetingPassword: "MOCK_PASS", + meetingUrl: "http://mock-dailyvideo.example.com/meeting-1", + }, + { + type: TestData.apps["google-calendar"].type, + uid: "MOCKED_GOOGLE_CALENDAR_EVENT_ID", + meetingId: "MOCKED_GOOGLE_CALENDAR_EVENT_ID", + meetingPassword: "MOCK_PASSWORD", + meetingUrl: "https://UNUSED_URL", + }, + ], + }); + + expectWorkflowToBeTriggered(); + expectSuccessfulCalendarEventCreationInCalendar(calendarMock, { + destinationCalendars: [ + { + integration: TestData.apps["google-calendar"].type, + externalId: "event-type-1@google-calendar.com", + }, + { + integration: TestData.apps["google-calendar"].type, + externalId: "other-team-member-1@google-calendar.com", + }, + ], + videoCallUrl: "http://mock-dailyvideo.example.com/meeting-1", + }); + + expectSuccessfulBookingCreationEmails({ + booker, + organizer, + otherTeamMembers, + emails, + iCalUID: "MOCKED_GOOGLE_CALENDAR_ICS_ID", + }); + + expectBookingCreatedWebhookToHaveBeenFired({ + booker, + organizer, + location: BookingLocations.CalVideo, + subscriberUrl: "http://my-webhook.example.com", + videoCallUrl: `${WEBAPP_URL}/video/DYNAMIC_UID`, + }); + }, + timeout + ); + + test( + `rejects a booking when the timeslot isn't within the common schedule`, + async ({}) => { + const handleNewBooking = (await import("@calcom/features/bookings/lib/handleNewBooking")).default; + const booker = getBooker({ + email: "booker@example.com", + name: "Booker", + }); + + const otherTeamMembers = [ + { + name: "Other Team Member 1", + username: "other-team-member-1", + timeZone: Timezones["+5:30"], + // So, that it picks the first schedule from the list + defaultScheduleId: null, + email: "other-team-member-1@example.com", + id: 102, + schedules: [], + credentials: [getGoogleCalendarCredential()], + selectedCalendars: [TestData.selectedCalendars.google], + destinationCalendar: { + integration: TestData.apps["google-calendar"].type, + externalId: "other-team-member-1@google-calendar.com", + }, + }, + ]; + + const organizer = getOrganizer({ + name: "Organizer", + email: "organizer@example.com", + id: 101, + // So, that it picks the first schedule from the list + defaultScheduleId: null, + schedules: [], + credentials: [getGoogleCalendarCredential()], + selectedCalendars: [TestData.selectedCalendars.google], + destinationCalendar: { + integration: TestData.apps["google-calendar"].type, + 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, + schedulingType: SchedulingType.COLLECTIVE, + length: 45, + schedule: TestData.schedules.IstMorningShift, + users: [ + { + id: 101, + }, + { + id: 102, + }, + ], + destinationCalendar: { + integration: TestData.apps["google-calendar"].type, + externalId: "event-type-1@google-calendar.com", + }, + }, + ], + organizer, + usersApartFromOrganizer: otherTeamMembers, + apps: [TestData.apps["google-calendar"], TestData.apps["daily-video"]], + }) + ); + + mockSuccessfulVideoMeetingCreation({ + metadataLookupKey: appStoreMetadata.dailyvideo.dirName, + videoMeetingData: { + id: "MOCK_ID", + password: "MOCK_PASS", + url: `http://mock-dailyvideo.example.com/meeting-1`, + }, + }); + + mockCalendarToHaveNoBusySlots("googlecalendar", { + create: { + id: "MOCKED_GOOGLE_CALENDAR_EVENT_ID", + iCalUID: "MOCKED_GOOGLE_CALENDAR_ICS_ID", + }, + }); + + const mockBookingData = getMockRequestDataForBooking({ + data: { + start: `${getDate({ dateIncrement: 1 }).dateString}T03:30:00.000Z`, + end: `${getDate({ dateIncrement: 1 }).dateString}T03:45:00.000Z`, + eventTypeId: 1, + responses: { + email: booker.email, + name: booker.name, + location: { optionValue: "", value: BookingLocations.CalVideo }, + }, + }, + }); + + const { req } = createMockNextJsRequest({ + method: "POST", + body: mockBookingData, + }); + + await expect(async () => { + await handleNewBooking(req); + }).rejects.toThrowError("No available users found."); + }, + timeout + ); + }); + + test( + `When Cal Video is the location, it uses global instance credentials and createMeeting is called for it`, + async ({ emails }) => { + const handleNewBooking = (await import("@calcom/features/bookings/lib/handleNewBooking")).default; + const booker = getBooker({ + email: "booker@example.com", + name: "Booker", + }); + + const otherTeamMembers = [ + { + name: "Other Team Member 1", + username: "other-team-member-1", + timeZone: Timezones["+5:30"], + defaultScheduleId: 1001, + email: "other-team-member-1@example.com", + id: 102, + schedules: [{ ...TestData.schedules.IstWorkHours, id: 1001 }], + credentials: [getGoogleCalendarCredential()], + selectedCalendars: [TestData.selectedCalendars.google], + destinationCalendar: { + integration: TestData.apps["google-calendar"].type, + externalId: "other-team-member-1@google-calendar.com", + }, + }, + ]; + + const organizer = getOrganizer({ + name: "Organizer", + email: "organizer@example.com", + id: 101, + schedules: [TestData.schedules.IstWorkHours], + // Even though Daily Video credential isn't here, it would still work because it's a globally installed app and credentials are available on instance level + credentials: [getGoogleCalendarCredential()], + selectedCalendars: [TestData.selectedCalendars.google], + destinationCalendar: { + integration: TestData.apps["google-calendar"].type, + externalId: "organizer@google-calendar.com", + }, + }); + + const { eventTypes } = 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, + schedulingType: SchedulingType.COLLECTIVE, + length: 45, + users: [ + { + id: 101, + }, + { + id: 102, + }, + ], + destinationCalendar: { + integration: TestData.apps["google-calendar"].type, + externalId: "event-type-1@google-calendar.com", + }, + }, + ], + organizer, + usersApartFromOrganizer: otherTeamMembers, + apps: [TestData.apps["google-calendar"], TestData.apps["daily-video"]], + }) + ); + + const videoMock = mockSuccessfulVideoMeetingCreation({ + metadataLookupKey: appStoreMetadata.dailyvideo.dirName, + videoMeetingData: { + id: "MOCK_ID", + password: "MOCK_PASS", + url: `http://mock-dailyvideo.example.com/meeting-1`, + }, + }); + + const calendarMock = mockCalendarToHaveNoBusySlots("googlecalendar", { + create: { + id: "MOCKED_GOOGLE_CALENDAR_EVENT_ID", + iCalUID: "MOCKED_GOOGLE_CALENDAR_ICS_ID", + }, + }); + + const mockBookingData = getMockRequestDataForBooking({ + data: { + start: `${getDate({ dateIncrement: 1 }).dateString}T05:00:00.000Z`, + end: `${getDate({ dateIncrement: 1 }).dateString}T05:30:00.000Z`, + eventTypeId: 1, + responses: { + email: booker.email, + name: booker.name, + location: { optionValue: "", value: BookingLocations.CalVideo }, + }, + }, + }); + + const { req } = createMockNextJsRequest({ + method: "POST", + body: mockBookingData, + }); + + const createdBooking = await handleNewBooking(req); + + await expectBookingToBeInDatabase({ + description: "", + location: BookingLocations.CalVideo, + responses: expect.objectContaining({ + email: booker.email, + name: booker.name, + }), + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + uid: createdBooking.uid!, + eventTypeId: mockBookingData.eventTypeId, + status: BookingStatus.ACCEPTED, + references: [ + { + type: appStoreMetadata.dailyvideo.type, + uid: "MOCK_ID", + meetingId: "MOCK_ID", + meetingPassword: "MOCK_PASS", + meetingUrl: "http://mock-dailyvideo.example.com/meeting-1", + }, + { + type: appStoreMetadata.googlecalendar.type, + uid: "MOCKED_GOOGLE_CALENDAR_EVENT_ID", + meetingId: "MOCKED_GOOGLE_CALENDAR_EVENT_ID", + meetingPassword: "MOCK_PASSWORD", + meetingUrl: "https://UNUSED_URL", + }, + ], + }); + + expectWorkflowToBeTriggered(); + expectSuccessfulCalendarEventCreationInCalendar(calendarMock, { + destinationCalendars: [ + { + integration: TestData.apps["google-calendar"].type, + externalId: "event-type-1@google-calendar.com", + }, + { + integration: TestData.apps["google-calendar"].type, + externalId: "other-team-member-1@google-calendar.com", + }, + ], + videoCallUrl: "http://mock-dailyvideo.example.com/meeting-1", + }); + + expectSuccessfulVideoMeetingCreation(videoMock, { + credential: expect.objectContaining({ + appId: "daily-video", + key: { + apikey: "MOCK_DAILY_API_KEY", + }, + }), + calEvent: expect.objectContaining( + getExpectedCalEventForBookingRequest({ + bookingRequest: mockBookingData, + eventType: eventTypes[0], + }) + ), + }); + + expectSuccessfulBookingCreationEmails({ + booker, + organizer, + otherTeamMembers, + emails, + iCalUID: "MOCKED_GOOGLE_CALENDAR_ICS_ID", + }); + + expectBookingCreatedWebhookToHaveBeenFired({ + booker, + organizer, + location: BookingLocations.CalVideo, + subscriberUrl: "http://my-webhook.example.com", + videoCallUrl: `${WEBAPP_URL}/video/DYNAMIC_UID`, + }); + }, + timeout + ); + + test( + `When Zoom is the location, it uses credentials of the first host and createMeeting is called for it.`, + async ({ emails }) => { + const handleNewBooking = (await import("@calcom/features/bookings/lib/handleNewBooking")).default; + const booker = getBooker({ + email: "booker@example.com", + name: "Booker", + }); + + const otherTeamMembers = [ + { + name: "Other Team Member 1", + username: "other-team-member-1", + timeZone: Timezones["+5:30"], + defaultScheduleId: 1001, + email: "other-team-member-1@example.com", + id: 102, + schedules: [ + { + ...TestData.schedules.IstWorkHours, + // Specify an ID directly here because we want to be able to use that ID in defaultScheduleId above. + id: 1001, + }, + ], + credentials: [getGoogleCalendarCredential()], + selectedCalendars: [TestData.selectedCalendars.google], + destinationCalendar: { + integration: TestData.apps["google-calendar"].type, + externalId: "other-team-member-1@google-calendar.com", + }, + }, + ]; + + const organizer = getOrganizer({ + name: "Organizer", + email: "organizer@example.com", + id: 101, + schedules: [TestData.schedules.IstWorkHours], + credentials: [ + { + id: 2, + ...getGoogleCalendarCredential(), + }, + { + id: 1, + ...getZoomAppCredential(), + }, + ], + selectedCalendars: [TestData.selectedCalendars.google], + destinationCalendar: { + integration: TestData.apps["google-calendar"].type, + externalId: "organizer@google-calendar.com", + }, + }); + + const { eventTypes } = 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, + schedulingType: SchedulingType.COLLECTIVE, + length: 45, + users: [ + { + id: 101, + }, + { + id: 102, + }, + ], + locations: [ + { + type: BookingLocations.ZoomVideo, + credentialId: 1, + }, + ], + destinationCalendar: { + integration: TestData.apps["google-calendar"].type, + externalId: "event-type-1@google-calendar.com", + }, + }, + ], + organizer, + usersApartFromOrganizer: otherTeamMembers, + apps: [TestData.apps["google-calendar"], TestData.apps["zoomvideo"]], + }) + ); + + const videoMock = mockSuccessfulVideoMeetingCreation({ + metadataLookupKey: "zoomvideo", + videoMeetingData: { + id: "MOCK_ID", + password: "MOCK_PASS", + url: `http://mock-zoomvideo.example.com/meeting-1`, + }, + }); + + const calendarMock = mockCalendarToHaveNoBusySlots("googlecalendar", { + create: { + id: "MOCKED_GOOGLE_CALENDAR_EVENT_ID", + iCalUID: "MOCKED_GOOGLE_CALENDAR_ICS_ID", + }, + }); + + const mockBookingData = getMockRequestDataForBooking({ + data: { + start: `${getDate({ dateIncrement: 1 }).dateString}T05:00:00.000Z`, + end: `${getDate({ dateIncrement: 1 }).dateString}T05:30:00.000Z`, + eventTypeId: 1, + responses: { + email: booker.email, + name: booker.name, + location: { optionValue: "", value: BookingLocations.ZoomVideo }, + }, + }, + }); + + const { req } = createMockNextJsRequest({ + method: "POST", + body: mockBookingData, + }); + + const createdBooking = await handleNewBooking(req); + + await expectBookingToBeInDatabase({ + description: "", + location: BookingLocations.ZoomVideo, + responses: expect.objectContaining({ + email: booker.email, + name: booker.name, + }), + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + uid: createdBooking.uid!, + eventTypeId: mockBookingData.eventTypeId, + status: BookingStatus.ACCEPTED, + references: [ + { + type: TestData.apps.zoomvideo.type, + meetingId: "MOCK_ID", + meetingPassword: "MOCK_PASS", + meetingUrl: "http://mock-zoomvideo.example.com/meeting-1", + }, + { + type: TestData.apps["google-calendar"].type, + uid: "MOCKED_GOOGLE_CALENDAR_EVENT_ID", + meetingId: "MOCKED_GOOGLE_CALENDAR_EVENT_ID", + meetingPassword: "MOCK_PASSWORD", + meetingUrl: "https://UNUSED_URL", + }, + ], + }); + + expectWorkflowToBeTriggered(); + expectSuccessfulCalendarEventCreationInCalendar(calendarMock, { + destinationCalendars: [ + { + integration: TestData.apps["google-calendar"].type, + externalId: "event-type-1@google-calendar.com", + }, + { + integration: TestData.apps["google-calendar"].type, + externalId: "other-team-member-1@google-calendar.com", + }, + ], + videoCallUrl: "http://mock-zoomvideo.example.com/meeting-1", + }); + + expectSuccessfulVideoMeetingCreation(videoMock, { + credential: expect.objectContaining({ + appId: TestData.apps.zoomvideo.slug, + key: expect.objectContaining({ + access_token: "ACCESS_TOKEN", + refresh_token: "REFRESH_TOKEN", + token_type: "Bearer", + }), + }), + calEvent: expect.objectContaining( + getExpectedCalEventForBookingRequest({ + bookingRequest: mockBookingData, + eventType: eventTypes[0], + }) + ), + }); + + expectSuccessfulBookingCreationEmails({ + booker, + organizer, + otherTeamMembers, + emails, + iCalUID: "MOCKED_GOOGLE_CALENDAR_ICS_ID", + }); + + expectBookingCreatedWebhookToHaveBeenFired({ + booker, + organizer, + location: BookingLocations.ZoomVideo, + subscriberUrl: "http://my-webhook.example.com", + videoCallUrl: `http://mock-zoomvideo.example.com/meeting-1`, + }); + }, + timeout + ); + }); + + 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"); +}); diff --git a/packages/lib/piiFreeData.ts b/packages/lib/piiFreeData.ts index 7e8f838676..1df51ed8b9 100644 --- a/packages/lib/piiFreeData.ts +++ b/packages/lib/piiFreeData.ts @@ -3,6 +3,14 @@ import type { Credential, SelectedCalendar, DestinationCalendar } from "@prisma/ import type { EventType } from "@calcom/prisma/client"; import type { CalendarEvent } from "@calcom/types/Calendar"; +function getBooleanStatus(val: unknown) { + if (process.env.NODE_ENV === "production") { + return `PiiFree:${!!val}`; + } else { + return val; + } +} + export function getPiiFreeCalendarEvent(calEvent: CalendarEvent) { return { eventTypeId: calEvent.eventTypeId, @@ -16,12 +24,13 @@ export function getPiiFreeCalendarEvent(calEvent: CalendarEvent) { recurrence: calEvent.recurrence, requiresConfirmation: calEvent.requiresConfirmation, uid: calEvent.uid, + conferenceCredentialId: calEvent.conferenceCredentialId, iCalUID: calEvent.iCalUID, /** * Let's just get a boolean value for PII sensitive fields so that we atleast know if it's present or not */ // Not okay to have title which can have Booker and Organizer names - title: !!calEvent.title, + title: getBooleanStatus(calEvent.title), // .... Add all other props here that we don't want to be logged. It prevents those properties from being logged accidentally }; } @@ -44,7 +53,7 @@ export function getPiiFreeBooking(booking: { * Let's just get a boolean value for PII sensitive fields so that we atleast know if it's present or not */ // Not okay to have title which can have Booker and Organizer names - title: !!booking.title, + title: getBooleanStatus(booking.title), // .... Add all other props here that we don't want to be logged. It prevents those properties from being logged accidentally }; } @@ -60,7 +69,7 @@ export function getPiiFreeCredential(credential: Partial) { /** * 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, + key: getBooleanStatus(credential.key), }; } @@ -82,7 +91,7 @@ export function getPiiFreeDestinationCalendar(destinationCalendar: Partial