test: Add collective scheduling tests (#11670)

This commit is contained in:
Hariom Balhara 2023-10-10 09:46:04 +05:30 committed by GitHub
parent 1456e2d4d5
commit 2faf24fb98
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
20 changed files with 2329 additions and 812 deletions

View File

@ -9,12 +9,14 @@ import { v4 as uuidv4 } from "uuid";
import "vitest-fetch-mock"; import "vitest-fetch-mock";
import { appStoreMetadata } from "@calcom/app-store/appStoreMetaData"; 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 { handleStripePaymentSuccess } from "@calcom/features/ee/payments/api/webhook";
import type { HttpError } from "@calcom/lib/http-error"; import type { HttpError } from "@calcom/lib/http-error";
import logger from "@calcom/lib/logger"; import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify"; import { safeStringify } from "@calcom/lib/safeStringify";
import type { SchedulingType } from "@calcom/prisma/enums"; import type { SchedulingType } from "@calcom/prisma/enums";
import type { BookingStatus } 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 { NewCalendarEventType } from "@calcom/types/Calendar";
import type { EventBusyDate } from "@calcom/types/Calendar"; import type { EventBusyDate } from "@calcom/types/Calendar";
@ -22,10 +24,6 @@ import { getMockPaymentService } from "./MockPaymentService";
logger.setSettings({ minLevel: "silly" }); logger.setSettings({ minLevel: "silly" });
const log = logger.getChildLogger({ prefix: ["[bookingScenario]"] }); const log = logger.getChildLogger({ prefix: ["[bookingScenario]"] });
type App = {
slug: string;
dirName: string;
};
type InputWebhook = { type InputWebhook = {
appId: string | null; appId: string | null;
@ -52,24 +50,27 @@ type ScenarioData = {
/** /**
* Prisma would return these apps * Prisma would return these apps
*/ */
apps?: App[]; apps?: Partial<AppMeta>[];
bookings?: InputBooking[]; bookings?: InputBooking[];
webhooks?: InputWebhook[]; webhooks?: InputWebhook[];
}; };
type InputCredential = typeof TestData.credentials.google; type InputCredential = typeof TestData.credentials.google & {
id?: number;
};
type InputSelectedCalendar = typeof TestData.selectedCalendars.google; type InputSelectedCalendar = typeof TestData.selectedCalendars.google;
type InputUser = typeof TestData.users.example & { id: number } & { type InputUser = Omit<typeof TestData.users.example, "defaultScheduleId"> & {
id: number;
defaultScheduleId?: number | null;
credentials?: InputCredential[]; credentials?: InputCredential[];
selectedCalendars?: InputSelectedCalendar[]; selectedCalendars?: InputSelectedCalendar[];
schedules: { schedules: {
id: number; // Allows giving id in the input directly so that it can be referenced somewhere else as well
id?: number;
name: string; name: string;
availability: { availability: {
userId: number | null;
eventTypeId: number | null;
days: number[]; days: number[];
startTime: Date; startTime: Date;
endTime: Date; endTime: Date;
@ -97,7 +98,8 @@ export type InputEventType = {
afterEventBuffer?: number; afterEventBuffer?: number;
requiresConfirmation?: boolean; requiresConfirmation?: boolean;
destinationCalendar?: Prisma.DestinationCalendarCreateInput; destinationCalendar?: Prisma.DestinationCalendarCreateInput;
} & Partial<Omit<Prisma.EventTypeCreateInput, "users">>; schedule?: InputUser["schedules"][number];
} & Partial<Omit<Prisma.EventTypeCreateInput, "users" | "schedule">>;
type InputBooking = { type InputBooking = {
id?: number; id?: number;
@ -122,37 +124,75 @@ type InputBooking = {
}[]; }[];
}; };
const Timezones = { export const Timezones = {
"+5:30": "Asia/Kolkata", "+5:30": "Asia/Kolkata",
"+6:00": "Asia/Dhaka", "+6:00": "Asia/Dhaka",
}; };
async function addEventTypesToDb( async function addEventTypesToDb(
eventTypes: (Omit<Prisma.EventTypeCreateInput, "users" | "worflows" | "destinationCalendar"> & { eventTypes: (Omit<
Prisma.EventTypeCreateInput,
"users" | "worflows" | "destinationCalendar" | "schedule"
> & {
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
users?: any[]; users?: any[];
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
workflows?: any[]; workflows?: any[];
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
destinationCalendar?: any; destinationCalendar?: any;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
schedule?: any;
})[] })[]
) { ) {
log.silly("TestData: Add EventTypes to DB", JSON.stringify(eventTypes)); log.silly("TestData: Add EventTypes to DB", JSON.stringify(eventTypes));
await prismock.eventType.createMany({ await prismock.eventType.createMany({
data: eventTypes, 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( log.silly(
"TestData: All EventTypes in DB are", "TestData: All EventTypes in DB are",
JSON.stringify({ JSON.stringify({
eventTypes: await prismock.eventType.findMany({ eventTypes: allEventTypes,
include: {
users: true,
workflows: true,
destinationCalendar: true,
},
}),
}) })
); );
return allEventTypes;
} }
async function addEventTypes(eventTypes: InputEventType[], usersStore: InputUser[]) { async function addEventTypes(eventTypes: InputEventType[], usersStore: InputUser[]) {
@ -197,10 +237,22 @@ async function addEventTypes(eventTypes: InputEventType[], usersStore: InputUser
create: eventType.destinationCalendar, create: eventType.destinationCalendar,
} }
: 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)); log.silly("TestData: Creating EventType", JSON.stringify(eventTypesWithUsers));
await addEventTypesToDb(eventTypesWithUsers); return await addEventTypesToDb(eventTypesWithUsers);
} }
function addBookingReferencesToDB(bookingReferences: Prisma.BookingReferenceCreateManyInput[]) { function addBookingReferencesToDB(bookingReferences: Prisma.BookingReferenceCreateManyInput[]) {
@ -289,10 +341,21 @@ async function addUsersToDb(users: (Prisma.UserCreateInput & { schedules: Prisma
await prismock.user.createMany({ await prismock.user.createMany({
data: users, data: users,
}); });
log.silly( log.silly(
"Added users to Db", "Added users to Db",
safeStringify({ 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); 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) { export async function createBookingScenario(data: ScenarioData) {
log.silly("TestData: Creating Scenario", JSON.stringify({ data })); log.silly("TestData: Creating Scenario", JSON.stringify({ data }));
await addUsers(data.users); await addUsers(data.users);
const eventType = await addEventTypes(data.eventTypes, data.users);
if (data.apps) { if (data.apps) {
prismock.app.createMany({ await addAppsToDb(
data: data.apps, 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 || []; data.bookings = data.bookings || [];
// allowSuccessfulBookingCreation(); // allowSuccessfulBookingCreation();
await addBookings(data.bookings); await addBookings(data.bookings);
@ -360,7 +435,7 @@ export async function createBookingScenario(data: ScenarioData) {
await addWebhooks(data.webhooks || []); await addWebhooks(data.webhooks || []);
// addPaymentMock(); // addPaymentMock();
return { return {
eventType, eventTypes,
}; };
} }
@ -483,12 +558,11 @@ export const TestData = {
}, },
schedules: { schedules: {
IstWorkHours: { IstWorkHours: {
id: 1,
name: "9:30AM to 6PM in India - 4:00AM to 12:30PM in GMT", name: "9:30AM to 6PM in India - 4:00AM to 12:30PM in GMT",
availability: [ availability: [
{ {
userId: null, // userId: null,
eventTypeId: null, // eventTypeId: null,
days: [0, 1, 2, 3, 4, 5, 6], days: [0, 1, 2, 3, 4, 5, 6],
startTime: new Date("1970-01-01T09:30:00.000Z"), startTime: new Date("1970-01-01T09:30:00.000Z"),
endTime: new Date("1970-01-01T18:00:00.000Z"), endTime: new Date("1970-01-01T18:00:00.000Z"),
@ -497,21 +571,50 @@ export const TestData = {
], ],
timeZone: Timezones["+5:30"], 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) => ({ 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)", 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: [ availability: [
{ {
userId: null,
eventTypeId: null,
days: [0, 1, 2, 3, 4, 5, 6], days: [0, 1, 2, 3, 4, 5, 6],
startTime: new Date("1970-01-01T09:30:00.000Z"), startTime: new Date("1970-01-01T09:30:00.000Z"),
endTime: new Date("1970-01-01T18:00:00.000Z"), endTime: new Date("1970-01-01T18:00:00.000Z"),
date: null, date: null,
}, },
{ {
userId: null,
eventTypeId: null,
days: [0, 1, 2, 3, 4, 5, 6], days: [0, 1, 2, 3, 4, 5, 6],
startTime: new Date(`1970-01-01T14:00:00.000Z`), startTime: new Date(`1970-01-01T14:00:00.000Z`),
endTime: new Date(`1970-01-01T18:00:00.000Z`), endTime: new Date(`1970-01-01T18:00:00.000Z`),
@ -532,9 +635,7 @@ export const TestData = {
}, },
apps: { apps: {
"google-calendar": { "google-calendar": {
slug: "google-calendar", ...appStoreMetadata.googlecalendar,
enabled: true,
dirName: "whatever",
// eslint-disable-next-line @typescript-eslint/ban-ts-comment // eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore //@ts-ignore
keys: { keys: {
@ -545,9 +646,7 @@ export const TestData = {
}, },
}, },
"daily-video": { "daily-video": {
slug: "daily-video", ...appStoreMetadata.dailyvideo,
dirName: "whatever",
enabled: true,
// eslint-disable-next-line @typescript-eslint/ban-ts-comment // eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore //@ts-ignore
keys: { keys: {
@ -560,9 +659,7 @@ export const TestData = {
}, },
}, },
zoomvideo: { zoomvideo: {
slug: "zoom", ...appStoreMetadata.zoomvideo,
enabled: true,
dirName: "whatever",
// eslint-disable-next-line @typescript-eslint/ban-ts-comment // eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore //@ts-ignore
keys: { keys: {
@ -575,10 +672,7 @@ export const TestData = {
}, },
}, },
"stripe-payment": { "stripe-payment": {
//TODO: Read from appStoreMeta ...appStoreMetadata.stripepayment,
slug: "stripe",
enabled: true,
dirName: "stripepayment",
// eslint-disable-next-line @typescript-eslint/ban-ts-comment // eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore //@ts-ignore
keys: { keys: {
@ -608,6 +702,7 @@ export function getOrganizer({
credentials, credentials,
selectedCalendars, selectedCalendars,
destinationCalendar, destinationCalendar,
defaultScheduleId,
}: { }: {
name: string; name: string;
email: string; email: string;
@ -615,6 +710,7 @@ export function getOrganizer({
schedules: InputUser["schedules"]; schedules: InputUser["schedules"];
credentials?: InputCredential[]; credentials?: InputCredential[];
selectedCalendars?: InputSelectedCalendar[]; selectedCalendars?: InputSelectedCalendar[];
defaultScheduleId?: number | null;
destinationCalendar?: Prisma.DestinationCalendarCreateInput; destinationCalendar?: Prisma.DestinationCalendarCreateInput;
}) { }) {
return { return {
@ -626,6 +722,7 @@ export function getOrganizer({
credentials, credentials,
selectedCalendars, selectedCalendars,
destinationCalendar, destinationCalendar,
defaultScheduleId,
}; };
} }
@ -856,7 +953,9 @@ export function mockVideoApp({
url: `http://mock-${metadataLookupKey}.example.com`, url: `http://mock-${metadataLookupKey}.example.com`,
}; };
log.silly("mockSuccessfulVideoMeetingCreation", JSON.stringify({ metadataLookupKey, appStoreLookupKey })); log.silly("mockSuccessfulVideoMeetingCreation", JSON.stringify({ metadataLookupKey, appStoreLookupKey }));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const createMeetingCalls: any[] = []; const createMeetingCalls: any[] = [];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const updateMeetingCalls: any[] = []; const updateMeetingCalls: any[] = [];
// eslint-disable-next-line @typescript-eslint/ban-ts-comment // eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore //@ts-ignore
@ -866,42 +965,50 @@ export function mockVideoApp({
lib: { lib: {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment // eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore //@ts-ignore
VideoApiAdapter: () => ({ VideoApiAdapter: (credential) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any return {
createMeeting: (...rest: any[]) => { // eslint-disable-next-line @typescript-eslint/no-explicit-any
if (creationCrash) { createMeeting: (...rest: any[]) => {
throw new Error("MockVideoApiAdapter.createMeeting fake error"); if (creationCrash) {
} throw new Error("MockVideoApiAdapter.createMeeting fake error");
createMeetingCalls.push(rest); }
createMeetingCalls.push({
credential,
args: rest,
});
return Promise.resolve({ return Promise.resolve({
type: appStoreMetadata[metadataLookupKey as keyof typeof appStoreMetadata].type, type: appStoreMetadata[metadataLookupKey as keyof typeof appStoreMetadata].type,
...videoMeetingData, ...videoMeetingData,
}); });
}, },
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
updateMeeting: async (...rest: any[]) => { updateMeeting: async (...rest: any[]) => {
if (updationCrash) { if (updationCrash) {
throw new Error("MockVideoApiAdapter.updateMeeting fake error"); throw new Error("MockVideoApiAdapter.updateMeeting fake error");
} }
const [bookingRef, calEvent] = rest; const [bookingRef, calEvent] = rest;
updateMeetingCalls.push(rest); updateMeetingCalls.push({
if (!bookingRef.type) { credential,
throw new Error("bookingRef.type is not defined"); args: rest,
} });
if (!calEvent.organizer) { if (!bookingRef.type) {
throw new Error("calEvent.organizer is not defined"); throw new Error("bookingRef.type is not defined");
} }
log.silly( if (!calEvent.organizer) {
"mockSuccessfulVideoMeetingCreation.updateMeeting", throw new Error("calEvent.organizer is not defined");
JSON.stringify({ bookingRef, calEvent }) }
); log.silly(
return Promise.resolve({ "mockSuccessfulVideoMeetingCreation.updateMeeting",
type: appStoreMetadata[metadataLookupKey as keyof typeof appStoreMetadata].type, JSON.stringify({ bookingRef, calEvent })
...videoMeetingData, );
}); return Promise.resolve({
}, type: appStoreMetadata[metadataLookupKey as keyof typeof appStoreMetadata].type,
}), ...videoMeetingData,
});
},
};
},
}, },
}); });
}); });
@ -1029,3 +1136,25 @@ export async function mockPaymentSuccessWebhookFromStripe({ externalId }: { exte
} }
return { webhookResponse }; return { webhookResponse };
} }
export function getExpectedCalEventForBookingRequest({
bookingRequest,
eventType,
}: {
bookingRequest: ReturnType<typeof getMockRequestDataForBooking>;
// 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",
}

View File

@ -1,6 +1,6 @@
import prismaMock from "../../../../../tests/libs/__mocks__/prisma"; 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 ical from "node-ical";
import { expect } from "vitest"; import { expect } from "vitest";
import "vitest-fetch-mock"; import "vitest-fetch-mock";
@ -182,11 +182,15 @@ export function expectSuccessfulBookingCreationEmails({
emails, emails,
organizer, organizer,
booker, booker,
guests,
otherTeamMembers,
iCalUID, iCalUID,
}: { }: {
emails: Fixtures["emails"]; emails: Fixtures["emails"];
organizer: { email: string; name: string }; organizer: { email: string; name: string };
booker: { email: string; name: string }; booker: { email: string; name: string };
guests?: { email: string; name: string }[];
otherTeamMembers?: { email: string; name: string }[];
iCalUID: string; iCalUID: string;
}) { }) {
expect(emails).toHaveEmail( expect(emails).toHaveEmail(
@ -212,6 +216,39 @@ export function expectSuccessfulBookingCreationEmails({
}, },
`${booker.name} <${booker.email}>` `${booker.name} <${booker.email}>`
); );
if (otherTeamMembers) {
otherTeamMembers.forEach((otherTeamMember) => {
expect(emails).toHaveEmail(
{
htmlToContain: "<title>confirmed_event_type_subject</title>",
// 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: "<title>confirmed_event_type_subject</title>",
to: `${guest.email}`,
ics: {
filename: "event.ics",
iCalUID: iCalUID,
},
},
`${guest.name} <${guest.email}`
);
});
}
} }
export function expectBrokenIntegrationEmails({ export function expectBrokenIntegrationEmails({
@ -537,8 +574,9 @@ export function expectSuccessfulCalendarEventCreationInCalendar(
updateEventCalls: any[]; updateEventCalls: any[];
}, },
expected: { expected: {
calendarId: string | null; calendarId?: string | null;
videoCallUrl: string; videoCallUrl: string;
destinationCalendars: Partial<DestinationCalendar>[];
} }
) { ) {
expect(calendarMock.createEventCalls.length).toBe(1); expect(calendarMock.createEventCalls.length).toBe(1);
@ -553,6 +591,8 @@ export function expectSuccessfulCalendarEventCreationInCalendar(
externalId: expected.calendarId, externalId: expected.calendarId,
}), }),
] ]
: expected.destinationCalendars
? expect.arrayContaining(expected.destinationCalendars.map((cal) => expect.objectContaining(cal)))
: null, : null,
videoCallData: expect.objectContaining({ videoCallData: expect.objectContaining({
url: expected.videoCallUrl, url: expected.videoCallUrl,
@ -584,7 +624,7 @@ export function expectSuccessfulCalendarEventUpdationInCalendar(
expect(externalId).toBe(expected.externalCalendarId); expect(externalId).toBe(expected.externalCalendarId);
} }
export function expectSuccessfulVideoMeetingCreationInCalendar( export function expectSuccessfulVideoMeetingCreation(
videoMock: { videoMock: {
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
createMeetingCalls: any[]; createMeetingCalls: any[];
@ -592,19 +632,20 @@ export function expectSuccessfulVideoMeetingCreationInCalendar(
updateMeetingCalls: any[]; updateMeetingCalls: any[];
}, },
expected: { expected: {
externalCalendarId: string; // eslint-disable-next-line @typescript-eslint/no-explicit-any
calEvent: Partial<CalendarEvent>; credential: any;
uid: string; // eslint-disable-next-line @typescript-eslint/no-explicit-any
calEvent: any;
} }
) { ) {
expect(videoMock.createMeetingCalls.length).toBe(1); expect(videoMock.createMeetingCalls.length).toBe(1);
const call = videoMock.createMeetingCalls[0]; const call = videoMock.createMeetingCalls[0];
const uid = call[0]; const callArgs = call.args;
const calendarEvent = call[1]; const calEvent = callArgs[0];
const externalId = call[2]; const credential = call.credential;
expect(uid).toBe(expected.uid);
expect(calendarEvent).toEqual(expect.objectContaining(expected.calEvent)); expect(credential).toEqual(expected.credential);
expect(externalId).toBe(expected.externalCalendarId); expect(calEvent).toEqual(expected.calEvent);
} }
export function expectSuccessfulVideoMeetingUpdationInCalendar( export function expectSuccessfulVideoMeetingUpdationInCalendar(
@ -622,8 +663,8 @@ export function expectSuccessfulVideoMeetingUpdationInCalendar(
) { ) {
expect(videoMock.updateMeetingCalls.length).toBe(1); expect(videoMock.updateMeetingCalls.length).toBe(1);
const call = videoMock.updateMeetingCalls[0]; const call = videoMock.updateMeetingCalls[0];
const bookingRef = call[0]; const bookingRef = call.args[0];
const calendarEvent = call[1]; const calendarEvent = call.args[1];
expect(bookingRef).toEqual(expect.objectContaining(expected.bookingRef)); expect(bookingRef).toEqual(expect.objectContaining(expected.bookingRef));
expect(calendarEvent).toEqual(expect.objectContaining(expected.calEvent)); expect(calendarEvent).toEqual(expect.objectContaining(expected.calEvent));
} }

View File

@ -5,7 +5,7 @@ import { getNormalizedAppMetadata } from "./getNormalizedAppMetadata";
type RawAppStoreMetaData = typeof rawAppStoreMetadata; type RawAppStoreMetaData = typeof rawAppStoreMetadata;
type AppStoreMetaData = { type AppStoreMetaData = {
[key in keyof RawAppStoreMetaData]: AppMeta; [key in keyof RawAppStoreMetaData]: Omit<AppMeta, "dirName"> & { dirName: string };
}; };
export const appStoreMetadata = {} as AppStoreMetaData; export const appStoreMetadata = {} as AppStoreMetaData;

View File

@ -19,7 +19,7 @@ export const getNormalizedAppMetadata = (appMeta: RawAppStoreMetaData[keyof RawA
dirName, dirName,
__template: "", __template: "",
...appMeta, ...appMeta,
} as AppStoreMetaData[keyof AppStoreMetaData]; } as Omit<AppStoreMetaData[keyof AppStoreMetaData], "dirName"> & { dirName: string };
metadata.logo = getAppAssetFullPath(metadata.logo, { metadata.logo = getAppAssetFullPath(metadata.logo, {
dirName, dirName,
isTemplate: metadata.isTemplate, isTemplate: metadata.isTemplate,

View File

@ -4,6 +4,9 @@ import type { AppCategories } from "@prisma/client";
// import appStore from "./index"; // import appStore from "./index";
import { appStoreMetadata } from "@calcom/app-store/appStoreMetaData"; import { appStoreMetadata } from "@calcom/app-store/appStoreMetaData";
import type { EventLocationType } from "@calcom/app-store/locations"; 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 { App, AppMeta } from "@calcom/types/App";
import type { CredentialPayload } from "@calcom/types/Credential"; 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 the app is a globally installed one, let's inject it's key */
if (appMeta.isGlobal) { if (appMeta.isGlobal) {
appCredentials.push({ const credential = {
id: 0, id: 0,
type: appMeta.type, type: appMeta.type,
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
@ -65,7 +68,12 @@ function getApps(credentials: CredentialDataWithTeamName[], filterOnCredentials?
team: { team: {
name: "Global", 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 */ /** Check if app has location option AND add it if user has credentials for it */

View File

@ -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 */ /** @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:", ""); const integrationName = event.location.replace("integrations:", "");
let videoCredential;
let videoCredential = event.conferenceCredentialId if (event.conferenceCredentialId) {
? this.videoCredentials.find((credential) => credential.id === event.conferenceCredentialId) videoCredential = this.videoCredentials.find(
: this.videoCredentials (credential) => credential.id === event.conferenceCredentialId
// 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 } else {
.sort((a, b) => { videoCredential = this.videoCredentials
return b.id - a.id; // 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
.find((credential: CredentialPayload) => credential.type.includes(integrationName)); .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. * This might happen if someone tries to use a location with a missing credential, so we fallback to Cal Video.

View File

@ -9,6 +9,7 @@ import { buildDateRanges, subtract } from "@calcom/lib/date-ranges";
import { HttpError } from "@calcom/lib/http-error"; import { HttpError } from "@calcom/lib/http-error";
import { descendingLimitKeys, intervalLimitKeyToUnit } from "@calcom/lib/intervalLimit"; import { descendingLimitKeys, intervalLimitKeyToUnit } from "@calcom/lib/intervalLimit";
import logger from "@calcom/lib/logger"; import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
import { checkBookingLimit } from "@calcom/lib/server"; import { checkBookingLimit } from "@calcom/lib/server";
import { performance } from "@calcom/lib/server/perfObserver"; import { performance } from "@calcom/lib/server/perfObserver";
import { getTotalBookingDuration } from "@calcom/lib/server/queries"; import { getTotalBookingDuration } from "@calcom/lib/server/queries";
@ -25,6 +26,7 @@ import type {
import { getBusyTimes, getBusyTimesForLimitChecks } from "./getBusyTimes"; import { getBusyTimes, getBusyTimesForLimitChecks } from "./getBusyTimes";
const log = logger.getChildLogger({ prefix: ["getUserAvailability"] });
const availabilitySchema = z const availabilitySchema = z
.object({ .object({
dateFrom: stringToDayjs, dateFrom: stringToDayjs,
@ -161,7 +163,12 @@ export const getUserAvailability = async function getUsersWorkingHoursLifeTheUni
if (userId) where.id = userId; if (userId) where.id = userId;
const user = initialData?.user || (await getUser(where)); const user = initialData?.user || (await getUser(where));
if (!user) throw new HttpError({ statusCode: 404, message: "No user found" }); 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; let eventType: EventType | null = initialData?.eventType || null;
if (!eventType && eventTypeId) eventType = await getEventType(eventTypeId); if (!eventType && eventTypeId) eventType = await getEventType(eventTypeId);
@ -225,10 +232,17 @@ export const getUserAvailability = async function getUsersWorkingHoursLifeTheUni
(schedule) => !user?.defaultScheduleId || schedule.id === user?.defaultScheduleId (schedule) => !user?.defaultScheduleId || schedule.id === user?.defaultScheduleId
)[0]; )[0];
const schedule = const useHostSchedulesForTeamEvent = eventType?.metadata?.config?.useHostSchedulesForTeamEvent;
!eventType?.metadata?.config?.useHostSchedulesForTeamEvent && eventType?.schedule const schedule = !useHostSchedulesForTeamEvent && eventType?.schedule ? eventType.schedule : userSchedule;
? eventType.schedule log.debug(
: userSchedule; "Using schedule:",
safeStringify({
chosenSchedule: schedule,
eventTypeSchedule: eventType?.schedule,
userSchedule: userSchedule,
useHostSchedulesForTeamEvent: eventType?.metadata?.config?.useHostSchedulesForTeamEvent,
})
);
const startGetWorkingHours = performance.now(); const startGetWorkingHours = performance.now();
@ -270,7 +284,7 @@ export const getUserAvailability = async function getUsersWorkingHoursLifeTheUni
const dateRangesInWhichUserIsAvailable = subtract(dateRanges, formattedBusyTimes); const dateRangesInWhichUserIsAvailable = subtract(dateRanges, formattedBusyTimes);
logger.debug( log.debug(
`getWorkingHours took ${endGetWorkingHours - startGetWorkingHours}ms for userId ${userId}`, `getWorkingHours took ${endGetWorkingHours - startGetWorkingHours}ms for userId ${userId}`,
JSON.stringify({ JSON.stringify({
workingHoursInUtc: workingHours, workingHoursInUtc: workingHours,

View File

@ -55,7 +55,7 @@ const getBusyVideoTimes = async (withCredentials: CredentialPayload[]) =>
const createMeeting = async (credential: CredentialPayload, calEvent: CalendarEvent) => { const createMeeting = async (credential: CredentialPayload, calEvent: CalendarEvent) => {
const uid: string = getUid(calEvent); const uid: string = getUid(calEvent);
log.silly( log.debug(
"createMeeting", "createMeeting",
safeStringify({ safeStringify({
credential: getPiiFreeCredential(credential), 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); createdMeeting = await firstVideoAdapter?.createMeeting(calEvent);
returnObject = { ...returnObject, createdEvent: createdMeeting, success: true }; returnObject = { ...returnObject, createdEvent: createdMeeting, success: true };
log.debug("created Meeting", safeStringify(returnObject));
} catch (err) { } catch (err) {
await sendBrokenIntegrationEmail(calEvent, "video"); await sendBrokenIntegrationEmail(calEvent, "video");
log.error("createMeeting failed", safeStringify({ err, calEvent: getPiiFreeCalendarEvent(calEvent) })); log.error("createMeeting failed", safeStringify({ err, calEvent: getPiiFreeCalendarEvent(calEvent) }));

View File

@ -379,7 +379,6 @@ async function ensureAvailableUsers(
) )
: undefined; : undefined;
log.debug("getUserAvailability for users", JSON.stringify({ users: eventType.users.map((u) => u.id) }));
/** Let's start checking for availability */ /** Let's start checking for availability */
for (const user of eventType.users) { for (const user of eventType.users) {
const { dateRanges, busy: bufferedBusyTimes } = await getUserAvailability( const { dateRanges, busy: bufferedBusyTimes } = await getUserAvailability(
@ -968,7 +967,7 @@ async function handler(
if ( if (
availableUsers.filter((user) => user.isFixed).length !== users.filter((user) => user.isFixed).length 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. // Pushing fixed user before the luckyUser guarantees the (first) fixed user as the organizer.
users = [...availableUsers.filter((user) => user.isFixed), ...luckyUsers]; users = [...availableUsers.filter((user) => user.isFixed), ...luckyUsers];

View File

@ -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");
});

View File

@ -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");
});

View File

@ -0,0 +1,7 @@
import { createMocks } from "node-mocks-http";
import type { CustomNextApiRequest, CustomNextApiResponse } from "../fresh-booking.test";
export function createMockNextJsRequest(...args: Parameters<typeof createMocks>) {
return createMocks<CustomNextApiRequest, CustomNextApiResponse>(...args);
}

View File

@ -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<ReturnType<typeof getBasicMockRequestDataForBooking>> & {
eventTypeId: number;
rescheduleUid?: string;
bookingUid?: string;
responses: {
email: string;
name: string;
location: { optionValue: ""; value: string };
};
};
}) {
return {
...getBasicMockRequestDataForBooking(),
...data,
};
}

View File

@ -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";
});
}

View File

@ -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");
});

View File

@ -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
);
});
});

View File

@ -3,6 +3,14 @@ import type { Credential, SelectedCalendar, DestinationCalendar } from "@prisma/
import type { EventType } from "@calcom/prisma/client"; import type { EventType } from "@calcom/prisma/client";
import type { CalendarEvent } from "@calcom/types/Calendar"; 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) { export function getPiiFreeCalendarEvent(calEvent: CalendarEvent) {
return { return {
eventTypeId: calEvent.eventTypeId, eventTypeId: calEvent.eventTypeId,
@ -16,12 +24,13 @@ export function getPiiFreeCalendarEvent(calEvent: CalendarEvent) {
recurrence: calEvent.recurrence, recurrence: calEvent.recurrence,
requiresConfirmation: calEvent.requiresConfirmation, requiresConfirmation: calEvent.requiresConfirmation,
uid: calEvent.uid, uid: calEvent.uid,
conferenceCredentialId: calEvent.conferenceCredentialId,
iCalUID: calEvent.iCalUID, 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 * 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 // 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 // .... 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 * 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 // 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 // .... 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<Credential>) {
/** /**
* Let's just get a boolean value for PII sensitive fields so that we atleast know if it's present or not * 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<Desti
/** /**
* Let's just get a boolean value for PII sensitive fields so that we atleast know if it's present or not * Let's just get a boolean value for PII sensitive fields so that we atleast know if it's present or not
*/ */
externalId: !!destinationCalendar.externalId, externalId: getBooleanStatus(destinationCalendar.externalId),
}; };
} }

View File

@ -2,6 +2,9 @@ import { defineConfig } from "vitest/config";
process.env.INTEGRATION_TEST_MODE = "true"; process.env.INTEGRATION_TEST_MODE = "true";
// We can't set it during tests because it is used as soon as _metadata.ts is imported which happens before tests start running
process.env.DAILY_API_KEY = "MOCK_DAILY_API_KEY";
export default defineConfig({ export default defineConfig({
test: { test: {
coverage: { coverage: {