* fix: remove @calcom/web imports from packages/features to eliminate circular dependency - Migrate UserTableUser and MemberPermissions types to packages/features/users/types/user-table.ts - Migrate useGeo hook to packages/features/geo/GeoContext.tsx - Migrate buildLegacyRequest to packages/lib/buildLegacyCtx.ts - Migrate Calendar component to packages/features/calendars/weeklyview/components/ - Move test utilities (bookingScenario, fixtures) to packages/features/test/ - Update all imports in packages/features to use new locations - Add re-exports in apps/web for backward compatibility Co-Authored-By: benny@cal.com <sldisek783@gmail.com> * fix: delete original implementation files and fix type issues - Delete original calendar component files in apps/web (keep only re-export stubs) - Migrate OutOfOfficeInSlots to packages/features/bookings/components - Convert apps/web OutOfOfficeInSlots to re-export stub - Fix className vs class issue in Calendar.tsx - Fix @calcom/trpc import violation in user-table.ts by using structural type Co-Authored-By: benny@cal.com <sldisek783@gmail.com> * fix: add missing isGroup and contains fields to UserTableUser attributes type Co-Authored-By: benny@cal.com <sldisek783@gmail.com> * fix: update customRole type to match actual Prisma Role model Co-Authored-By: benny@cal.com <sldisek783@gmail.com> * fix build * fix * fix * cleanup weeklyview * fix * refactor to mv MemberPermissions to types package * add types dependency to features * fix * fix * fix * fix * fix * fix * rename * rename * migrate * migrate * migrate * fix * fix * fix * refactor: move test utilities from packages/features/test to tests/libs - Move bookingScenario utilities to tests/libs/bookingScenario - Move fixtures to tests/libs/fixtures - Update all imports in packages/features test files to use new location - Update all imports in apps/web test files to use new location - Eliminates duplication of test utilities between packages/features and apps/web Co-Authored-By: benny@cal.com <sldisek783@gmail.com> * fix: correct relative import paths for tests/libs in test files Co-Authored-By: benny@cal.com <sldisek783@gmail.com> * refactor: replace test utility implementations with re-exports to tests/libs Co-Authored-By: benny@cal.com <sldisek783@gmail.com> * fix: fix test import paths and move signup handler tests to apps/web Co-Authored-By: benny@cal.com <sldisek783@gmail.com> * fix: move buildLegacyCtx to packages/lib and restore handlers to packages/features - Move buildLegacyCtx from apps/web/lib to packages/lib to break circular dependency - Update apps/web/lib/buildLegacyCtx.ts to re-export from @calcom/lib - Restore signup handlers and tests to packages/features/auth/signup/handlers - Update handler imports to use @calcom/lib/buildLegacyCtx instead of @calcom/web/lib/buildLegacyCtx Co-Authored-By: benny@cal.com <sldisek783@gmail.com> * fix: update recurring-event.test.ts imports to use tests/libs path Co-Authored-By: benny@cal.com <sldisek783@gmail.com> * refactor: delete test re-export files and update imports to use tests/libs directly Co-Authored-By: benny@cal.com <sldisek783@gmail.com> * fix: update remaining test imports to use tests/libs directly Co-Authored-By: benny@cal.com <sldisek783@gmail.com> * fix: update handleRecurringEventBooking calls to match function signature (1 arg) Co-Authored-By: benny@cal.com <sldisek783@gmail.com> * remove * migrate tests * migrate tests * refactor: update test mock imports by removing and using async for mock creators. * fix type errors * fix: add type assertion for MockUser in p2002.test-suite.ts Co-Authored-By: benny@cal.com <sldisek783@gmail.com> * fix: restore locale import in compareReminderBodyToTemplate.test.ts using relative path Co-Authored-By: benny@cal.com <sldisek783@gmail.com> * feat: create @calcom/testing package and migrate tests from /tests directory - Created new @calcom/testing package in /packages/testing - Moved all files from /tests to /packages/testing - Updated all imports across the codebase to use @calcom/testing alias - Removed /tests directory at root level This allows other packages like @calcom/features and @calcom/web to import testing utilities using the @calcom/testing alias instead of relative paths. Co-Authored-By: benny@cal.com <sldisek783@gmail.com> * fix * fix * fix: add missing useBookings export to @calcom/atoms package Co-Authored-By: benny@cal.com <sldisek783@gmail.com> * fix: add missing useCalendarsBusyTimes and useConnectedCalendars exports to @calcom/atoms Co-Authored-By: benny@cal.com <sldisek783@gmail.com> * chore: add @calcom/testing as explicit devDependency to packages that use it Co-Authored-By: benny@cal.com <sldisek783@gmail.com> * refactor: move setupVitest.ts into @calcom/testing package Co-Authored-By: benny@cal.com <sldisek783@gmail.com> * fix * chore: add biome rules to restrict @calcom/testing imports Co-Authored-By: benny@cal.com <sldisek783@gmail.com> * fix * rename libs to lib * rename libs to lib * add rule * add rule * refactor: remove @calcom/features imports from @calcom/testing - Move mockPaymentSuccessWebhookFromStripe to fresh-booking.test.ts - Replace ProfileRepository.generateProfileUid() with uuidv4() - Clone Tracking type into @calcom/testing/src/lib/types.ts - Update imports in expects.ts and getMockRequestDataForBooking.ts - Move source files into src/ folder - Move CalendarManager mock to @calcom/features Co-Authored-By: benny@cal.com <sldisek783@gmail.com> * fix: add explicit exports for nested paths in @calcom/testing Co-Authored-By: benny@cal.com <sldisek783@gmail.com> * improve * improve * fix * fix * fix type checks * fix type checks * fix type checks * fix tests --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
1420 lines
51 KiB
TypeScript
1420 lines
51 KiB
TypeScript
import {
|
|
createBookingScenario,
|
|
getBooker,
|
|
getDate,
|
|
getGoogleCalendarCredential,
|
|
getOrganizer,
|
|
getScenarioData,
|
|
mockCalendarToHaveNoBusySlots,
|
|
mockSuccessfulVideoMeetingCreation,
|
|
TestData,
|
|
Timezones,
|
|
} from "@calcom/testing/lib/bookingScenario/bookingScenario";
|
|
import { createMockNextJsRequest } from "@calcom/testing/lib/bookingScenario/createMockNextJsRequest";
|
|
import {
|
|
expectBookingCreatedWebhookToHaveBeenFired,
|
|
expectBookingToBeInDatabase, // expectWorkflowToBeTriggered,
|
|
expectSuccessfulBookingCreationEmails,
|
|
expectSuccessfulCalendarEventCreationInCalendar,
|
|
} from "@calcom/testing/lib/bookingScenario/expects";
|
|
import { getMockRequestDataForBooking } from "@calcom/testing/lib/bookingScenario/getMockRequestDataForBooking";
|
|
import { setupAndTeardown } from "@calcom/testing/lib/bookingScenario/setupAndTeardown";
|
|
|
|
import { v4 as uuidv4 } from "uuid";
|
|
import { describe, expect } from "vitest";
|
|
|
|
import { WEBAPP_URL, WEBSITE_URL } from "@calcom/lib/constants";
|
|
import { ErrorCode } from "@calcom/lib/errorCodes";
|
|
import logger from "@calcom/lib/logger";
|
|
import { BookingStatus, SchedulingType } from "@calcom/prisma/enums";
|
|
import { test } from "@calcom/testing/lib/fixtures/fixtures";
|
|
|
|
const DAY_IN_MS = 1000 * 60 * 60 * 24;
|
|
|
|
function getPlusDayDate(date: string, days: number) {
|
|
return new Date(new Date(date).getTime() + days * DAY_IN_MS);
|
|
}
|
|
|
|
// Local test runs sometime gets too slow
|
|
const timeout = process.env.CI ? 5000 : 20000;
|
|
describe("handleNewBooking", () => {
|
|
setupAndTeardown();
|
|
|
|
describe("Recurring EventType:", () => {
|
|
describe("User event type:", () => {
|
|
test(
|
|
`should create successful bookings for the number of slots requested
|
|
1. Should create the same number of bookings as requested slots in the database
|
|
2. Should send emails for the first booking only to the booker as well as organizer
|
|
3. Should create a calendar event for every booking in the destination calendar
|
|
3. Should trigger BOOKING_CREATED webhook for every booking
|
|
`,
|
|
async ({ emails }) => {
|
|
const handleRecurringEventBooking = (await import("@calcom/web/pages/api/book/recurring-event"))
|
|
.handleRecurringEventBooking;
|
|
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 recurrence = getRecurrence({
|
|
type: "weekly",
|
|
numberOfOccurrences: 3,
|
|
});
|
|
const plus1DateString = getDate({ dateIncrement: 1 }).dateString;
|
|
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: 30,
|
|
length: 30,
|
|
recurringEvent: recurrence,
|
|
users: [
|
|
{
|
|
id: 101,
|
|
},
|
|
],
|
|
destinationCalendar: {
|
|
integration: "google_calendar",
|
|
externalId: "event-type-1@google-calendar.com",
|
|
},
|
|
},
|
|
],
|
|
|
|
organizer,
|
|
apps: [TestData.apps["google-calendar"], TestData.apps["daily-video"]],
|
|
})
|
|
);
|
|
|
|
mockSuccessfulVideoMeetingCreation({
|
|
metadataLookupKey: "dailyvideo",
|
|
videoMeetingData: {
|
|
id: "MOCK_ID",
|
|
password: "MOCK_PASS",
|
|
url: `http://mock-dailyvideo.example.com/meeting-1`,
|
|
},
|
|
});
|
|
|
|
const calendarMock = await mockCalendarToHaveNoBusySlots("googlecalendar", {
|
|
create: {
|
|
id: "MOCKED_GOOGLE_CALENDAR_EVENT_ID",
|
|
iCalUID: "MOCKED_GOOGLE_CALENDAR_ICS_ID",
|
|
},
|
|
});
|
|
|
|
const recurringCountInRequest = 2;
|
|
const mockBookingData = getMockRequestDataForBooking({
|
|
data: {
|
|
eventTypeId: 1,
|
|
start: `${plus1DateString}T04:00:00.000Z`,
|
|
end: `${plus1DateString}T04:30:00.000Z`,
|
|
recurringEventId: uuidv4(),
|
|
recurringCount: recurringCountInRequest,
|
|
responses: {
|
|
email: booker.email,
|
|
name: booker.name,
|
|
location: { optionValue: "", value: "integrations:daily" },
|
|
},
|
|
},
|
|
});
|
|
|
|
const numOfSlotsToBeBooked = 4;
|
|
const { req, res } = createMockNextJsRequest({
|
|
method: "POST",
|
|
body: Array(numOfSlotsToBeBooked)
|
|
.fill(mockBookingData)
|
|
.map((mockBookingData, index) => {
|
|
return {
|
|
...mockBookingData,
|
|
start: getPlusDayDate(mockBookingData.start, index).toISOString(),
|
|
end: getPlusDayDate(mockBookingData.end, index).toISOString(),
|
|
};
|
|
}),
|
|
});
|
|
|
|
const createdBookings = await handleRecurringEventBooking(req);
|
|
expect(createdBookings.length).toBe(numOfSlotsToBeBooked);
|
|
for (const [index, createdBooking] of Object.entries(createdBookings)) {
|
|
logger.debug("Assertion for Booking with index:", index, { createdBooking });
|
|
expect(createdBooking.responses).toEqual(
|
|
expect.objectContaining({
|
|
email: booker.email,
|
|
name: booker.name,
|
|
})
|
|
);
|
|
|
|
expect(createdBooking).toEqual(
|
|
expect.objectContaining({
|
|
location: "integrations:daily",
|
|
})
|
|
);
|
|
|
|
await expectBookingToBeInDatabase({
|
|
description: "",
|
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
uid: createdBooking.uid!,
|
|
eventTypeId: mockBookingData.eventTypeId,
|
|
status: BookingStatus.ACCEPTED,
|
|
recurringEventId: mockBookingData.recurringEventId,
|
|
references: [
|
|
{
|
|
type: "daily_video",
|
|
uid: "MOCK_ID",
|
|
meetingId: "MOCK_ID",
|
|
meetingPassword: "MOCK_PASS",
|
|
meetingUrl: "http://mock-dailyvideo.example.com/meeting-1",
|
|
},
|
|
{
|
|
type: "google_calendar",
|
|
uid: "MOCKED_GOOGLE_CALENDAR_EVENT_ID",
|
|
meetingId: "MOCKED_GOOGLE_CALENDAR_EVENT_ID",
|
|
meetingPassword: "MOCK_PASSWORD",
|
|
},
|
|
],
|
|
});
|
|
|
|
expectBookingCreatedWebhookToHaveBeenFired({
|
|
booker,
|
|
organizer,
|
|
location: "integrations:daily",
|
|
subscriberUrl: "http://my-webhook.example.com",
|
|
//FIXME: All recurring bookings seem to have the same URL. https://github.com/calcom/cal.com/issues/11955
|
|
videoCallUrl: `${WEBAPP_URL}/video/${createdBookings[0].uid}`,
|
|
});
|
|
}
|
|
|
|
// expectWorkflowToBeTriggered();
|
|
|
|
expectSuccessfulBookingCreationEmails({
|
|
booker,
|
|
booking: {
|
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
uid: createdBookings[0].uid!,
|
|
urlOrigin: WEBSITE_URL,
|
|
},
|
|
organizer,
|
|
emails,
|
|
bookingTimeRange: {
|
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
start: createdBookings[0].startTime!,
|
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
end: createdBookings[0].endTime!,
|
|
},
|
|
iCalUID: "MOCKED_GOOGLE_CALENDAR_ICS_ID",
|
|
recurrence: {
|
|
...recurrence,
|
|
count: recurringCountInRequest,
|
|
},
|
|
});
|
|
|
|
expect(emails.get().length).toBe(2);
|
|
|
|
expectSuccessfulCalendarEventCreationInCalendar(calendarMock, [
|
|
{
|
|
calendarId: "event-type-1@google-calendar.com",
|
|
videoCallUrl: "http://mock-dailyvideo.example.com/meeting-1",
|
|
},
|
|
{
|
|
calendarId: "event-type-1@google-calendar.com",
|
|
videoCallUrl: "http://mock-dailyvideo.example.com/meeting-1",
|
|
},
|
|
{
|
|
calendarId: "event-type-1@google-calendar.com",
|
|
videoCallUrl: "http://mock-dailyvideo.example.com/meeting-1",
|
|
},
|
|
{
|
|
calendarId: "event-type-1@google-calendar.com",
|
|
videoCallUrl: "http://mock-dailyvideo.example.com/meeting-1",
|
|
},
|
|
]);
|
|
},
|
|
timeout
|
|
);
|
|
|
|
test.skip(
|
|
`should fail recurring booking if second slot is already booked`,
|
|
async ({}) => {
|
|
const handleRecurringEventBooking = (await import("@calcom/web/pages/api/book/recurring-event"))
|
|
.handleRecurringEventBooking;
|
|
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 recurrence = getRecurrence({
|
|
type: "weekly",
|
|
numberOfOccurrences: 3,
|
|
});
|
|
const plus1DateString = getDate({ dateIncrement: 1 }).dateString;
|
|
const plus2DateString = getDate({ dateIncrement: 2 }).dateString;
|
|
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: 30,
|
|
length: 30,
|
|
recurringEvent: recurrence,
|
|
users: [
|
|
{
|
|
id: 101,
|
|
},
|
|
],
|
|
destinationCalendar: {
|
|
integration: "google_calendar",
|
|
externalId: "event-type-1@google-calendar.com",
|
|
},
|
|
},
|
|
],
|
|
bookings: [
|
|
{
|
|
uid: "booking-1-uid",
|
|
eventTypeId: 1,
|
|
userId: organizer.id,
|
|
status: BookingStatus.ACCEPTED,
|
|
startTime: `${plus2DateString}T04:00:00.000Z`,
|
|
endTime: `${plus2DateString}T04:30:00.000Z`,
|
|
},
|
|
],
|
|
organizer,
|
|
apps: [TestData.apps["google-calendar"], TestData.apps["daily-video"]],
|
|
})
|
|
);
|
|
|
|
mockSuccessfulVideoMeetingCreation({
|
|
metadataLookupKey: "dailyvideo",
|
|
videoMeetingData: {
|
|
id: "MOCK_ID",
|
|
password: "MOCK_PASS",
|
|
url: `http://mock-dailyvideo.example.com/meeting-1`,
|
|
},
|
|
});
|
|
|
|
await mockCalendarToHaveNoBusySlots("googlecalendar", {
|
|
create: {
|
|
id: "MOCKED_GOOGLE_CALENDAR_EVENT_ID",
|
|
iCalUID: "MOCKED_GOOGLE_CALENDAR_ICS_ID",
|
|
},
|
|
});
|
|
|
|
const recurringCountInRequest = 2;
|
|
const mockBookingData = getMockRequestDataForBooking({
|
|
data: {
|
|
eventTypeId: 1,
|
|
start: `${plus1DateString}T04:00:00.000Z`,
|
|
end: `${plus1DateString}T04:30:00.000Z`,
|
|
recurringEventId: uuidv4(),
|
|
recurringCount: recurringCountInRequest,
|
|
responses: {
|
|
email: booker.email,
|
|
name: booker.name,
|
|
location: { optionValue: "", value: "integrations:daily" },
|
|
},
|
|
},
|
|
});
|
|
|
|
const numOfSlotsToBeBooked = 4;
|
|
const { req, res } = createMockNextJsRequest({
|
|
method: "POST",
|
|
body: Array(numOfSlotsToBeBooked)
|
|
.fill(mockBookingData)
|
|
.map((mockBookingData, index) => {
|
|
return {
|
|
...mockBookingData,
|
|
start: getPlusDayDate(mockBookingData.start, index).toISOString(),
|
|
end: getPlusDayDate(mockBookingData.end, index).toISOString(),
|
|
};
|
|
}),
|
|
});
|
|
|
|
await expect(handleRecurringEventBooking(req)).rejects.toThrow(
|
|
ErrorCode.NoAvailableUsersFound
|
|
);
|
|
// Actually the first booking goes through in this case but the status is still a failure. We should do a dry run to check if booking is possible for the 2 slots and if yes, then only go for the actual booking otherwise fail the recurring bookign
|
|
},
|
|
timeout
|
|
);
|
|
|
|
test(
|
|
`should create successful bookings for the number of slots requested even if the third slot is already booked as long as first two slots are free
|
|
1. Should create the same number of bookings as requested slots in the database
|
|
2. Should send emails for the first booking only to the booker as well as organizer
|
|
3. Should create a calendar event for every booking in the destination calendar
|
|
3. Should trigger BOOKING_CREATED webhook for every booking
|
|
`,
|
|
async ({ emails }) => {
|
|
const recurringCountInRequest = 4;
|
|
|
|
const handleRecurringEventBooking = (await import("@calcom/web/pages/api/book/recurring-event"))
|
|
.handleRecurringEventBooking;
|
|
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 recurrence = getRecurrence({
|
|
type: "weekly",
|
|
numberOfOccurrences: 3,
|
|
});
|
|
const plus1DateString = getDate({ dateIncrement: 1 }).dateString;
|
|
const plus3DateString = getDate({ dateIncrement: 3 }).dateString;
|
|
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: 30,
|
|
length: 30,
|
|
recurringEvent: recurrence,
|
|
users: [
|
|
{
|
|
id: 101,
|
|
},
|
|
],
|
|
destinationCalendar: {
|
|
integration: "google_calendar",
|
|
externalId: "event-type-1@google-calendar.com",
|
|
},
|
|
},
|
|
],
|
|
bookings: [
|
|
{
|
|
uid: "booking-1-uid",
|
|
eventTypeId: 1,
|
|
userId: organizer.id,
|
|
status: BookingStatus.ACCEPTED,
|
|
startTime: `${plus3DateString}T04:00:00.000Z`,
|
|
endTime: `${plus3DateString}T04:30:00.000Z`,
|
|
},
|
|
],
|
|
organizer,
|
|
apps: [TestData.apps["google-calendar"], TestData.apps["daily-video"]],
|
|
})
|
|
);
|
|
|
|
mockSuccessfulVideoMeetingCreation({
|
|
metadataLookupKey: "dailyvideo",
|
|
videoMeetingData: {
|
|
id: "MOCK_ID",
|
|
password: "MOCK_PASS",
|
|
url: `http://mock-dailyvideo.example.com/meeting-1`,
|
|
},
|
|
});
|
|
|
|
const calendarMock = await mockCalendarToHaveNoBusySlots("googlecalendar", {
|
|
create: {
|
|
id: "MOCKED_GOOGLE_CALENDAR_EVENT_ID",
|
|
iCalUID: "MOCKED_GOOGLE_CALENDAR_ICS_ID",
|
|
},
|
|
});
|
|
|
|
const mockBookingData = getMockRequestDataForBooking({
|
|
data: {
|
|
eventTypeId: 1,
|
|
start: `${plus1DateString}T04:00:00.000Z`,
|
|
end: `${plus1DateString}T04:30:00.000Z`,
|
|
recurringEventId: uuidv4(),
|
|
recurringCount: recurringCountInRequest,
|
|
responses: {
|
|
email: booker.email,
|
|
name: booker.name,
|
|
location: { optionValue: "", value: "integrations:daily" },
|
|
},
|
|
},
|
|
});
|
|
|
|
const numOfSlotsToBeBooked = 4;
|
|
const { req, res } = createMockNextJsRequest({
|
|
method: "POST",
|
|
body: Array(numOfSlotsToBeBooked)
|
|
.fill(mockBookingData)
|
|
.map((mockBookingData, index) => {
|
|
return {
|
|
...mockBookingData,
|
|
start: getPlusDayDate(mockBookingData.start, index).toISOString(),
|
|
end: getPlusDayDate(mockBookingData.end, index).toISOString(),
|
|
};
|
|
}),
|
|
});
|
|
|
|
const createdBookings = await handleRecurringEventBooking(req);
|
|
expect(createdBookings.length).toBe(numOfSlotsToBeBooked);
|
|
for (const [index, createdBooking] of Object.entries(createdBookings)) {
|
|
logger.debug("Assertion for Booking with index:", index, { createdBooking });
|
|
expect(createdBooking.responses).toEqual(
|
|
expect.objectContaining({
|
|
email: booker.email,
|
|
name: booker.name,
|
|
})
|
|
);
|
|
|
|
expect(createdBooking).toEqual(
|
|
expect.objectContaining({
|
|
location: "integrations:daily",
|
|
})
|
|
);
|
|
|
|
await expectBookingToBeInDatabase({
|
|
description: "",
|
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
uid: createdBooking.uid!,
|
|
eventTypeId: mockBookingData.eventTypeId,
|
|
status: BookingStatus.ACCEPTED,
|
|
recurringEventId: mockBookingData.recurringEventId,
|
|
references: [
|
|
{
|
|
type: "daily_video",
|
|
uid: "MOCK_ID",
|
|
meetingId: "MOCK_ID",
|
|
meetingPassword: "MOCK_PASS",
|
|
meetingUrl: "http://mock-dailyvideo.example.com/meeting-1",
|
|
},
|
|
{
|
|
type: "google_calendar",
|
|
uid: "MOCKED_GOOGLE_CALENDAR_EVENT_ID",
|
|
meetingId: "MOCKED_GOOGLE_CALENDAR_EVENT_ID",
|
|
meetingPassword: "MOCK_PASSWORD",
|
|
},
|
|
],
|
|
});
|
|
|
|
expectBookingCreatedWebhookToHaveBeenFired({
|
|
booker,
|
|
organizer,
|
|
location: "integrations:daily",
|
|
subscriberUrl: "http://my-webhook.example.com",
|
|
//FIXME: File a bug - All recurring bookings seem to have the same URL. They should have same CalVideo URL which could mean that future recurring meetings would have already expired by the time they are needed.
|
|
videoCallUrl: `${WEBAPP_URL}/video/${createdBookings[0].uid}`,
|
|
});
|
|
}
|
|
|
|
// expectWorkflowToBeTriggered();
|
|
|
|
expectSuccessfulBookingCreationEmails({
|
|
booker,
|
|
booking: {
|
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
uid: createdBookings[0].uid!,
|
|
urlOrigin: WEBSITE_URL,
|
|
},
|
|
organizer,
|
|
emails,
|
|
iCalUID: "MOCKED_GOOGLE_CALENDAR_ICS_ID",
|
|
recurrence: {
|
|
...recurrence,
|
|
count: recurringCountInRequest,
|
|
},
|
|
});
|
|
|
|
expect(emails.get().length).toBe(2);
|
|
|
|
expectSuccessfulCalendarEventCreationInCalendar(calendarMock, [
|
|
{
|
|
calendarId: "event-type-1@google-calendar.com",
|
|
videoCallUrl: "http://mock-dailyvideo.example.com/meeting-1",
|
|
},
|
|
{
|
|
calendarId: "event-type-1@google-calendar.com",
|
|
videoCallUrl: "http://mock-dailyvideo.example.com/meeting-1",
|
|
},
|
|
{
|
|
calendarId: "event-type-1@google-calendar.com",
|
|
videoCallUrl: "http://mock-dailyvideo.example.com/meeting-1",
|
|
},
|
|
{
|
|
calendarId: "event-type-1@google-calendar.com",
|
|
videoCallUrl: "http://mock-dailyvideo.example.com/meeting-1",
|
|
},
|
|
]);
|
|
},
|
|
timeout
|
|
);
|
|
|
|
test(
|
|
`should create successful bookings for the number of slots requested even if the last slot is already booked as long as first two slots are free
|
|
1. Should create the same number of bookings as requested slots in the database
|
|
2. Should send emails for the first booking only to the booker as well as organizer
|
|
3. Should create a calendar event for every booking in the destination calendar
|
|
3. Should trigger BOOKING_CREATED webhook for every booking
|
|
`,
|
|
async ({ emails }) => {
|
|
const recurringCountInRequest = 4;
|
|
|
|
const handleRecurringEventBooking = (await import("@calcom/web/pages/api/book/recurring-event"))
|
|
.handleRecurringEventBooking;
|
|
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 recurrence = getRecurrence({
|
|
type: "weekly",
|
|
numberOfOccurrences: 3,
|
|
});
|
|
const plus1DateString = getDate({ dateIncrement: 1 }).dateString;
|
|
const plus4DateString = getDate({ dateIncrement: 4 }).dateString;
|
|
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: 30,
|
|
length: 30,
|
|
recurringEvent: recurrence,
|
|
users: [
|
|
{
|
|
id: 101,
|
|
},
|
|
],
|
|
destinationCalendar: {
|
|
integration: "google_calendar",
|
|
externalId: "event-type-1@google-calendar.com",
|
|
},
|
|
},
|
|
],
|
|
bookings: [
|
|
{
|
|
uid: "booking-1-uid",
|
|
eventTypeId: 1,
|
|
userId: organizer.id,
|
|
status: BookingStatus.ACCEPTED,
|
|
startTime: `${plus4DateString}T04:00:00.000Z`,
|
|
endTime: `${plus4DateString}T04:30:00.000Z`,
|
|
},
|
|
],
|
|
organizer,
|
|
apps: [TestData.apps["google-calendar"], TestData.apps["daily-video"]],
|
|
})
|
|
);
|
|
|
|
mockSuccessfulVideoMeetingCreation({
|
|
metadataLookupKey: "dailyvideo",
|
|
videoMeetingData: {
|
|
id: "MOCK_ID",
|
|
password: "MOCK_PASS",
|
|
url: `http://mock-dailyvideo.example.com/meeting-1`,
|
|
},
|
|
});
|
|
|
|
const calendarMock = await mockCalendarToHaveNoBusySlots("googlecalendar", {
|
|
create: {
|
|
id: "MOCKED_GOOGLE_CALENDAR_EVENT_ID",
|
|
iCalUID: "MOCKED_GOOGLE_CALENDAR_ICS_ID",
|
|
},
|
|
});
|
|
|
|
const mockBookingData = getMockRequestDataForBooking({
|
|
data: {
|
|
eventTypeId: 1,
|
|
start: `${plus1DateString}T04:00:00.000Z`,
|
|
end: `${plus1DateString}T04:30:00.000Z`,
|
|
recurringEventId: uuidv4(),
|
|
recurringCount: recurringCountInRequest,
|
|
responses: {
|
|
email: booker.email,
|
|
name: booker.name,
|
|
location: { optionValue: "", value: "integrations:daily" },
|
|
},
|
|
},
|
|
});
|
|
|
|
const numOfSlotsToBeBooked = 4;
|
|
const { req, res } = createMockNextJsRequest({
|
|
method: "POST",
|
|
body: Array(numOfSlotsToBeBooked)
|
|
.fill(mockBookingData)
|
|
.map((mockBookingData, index) => {
|
|
return {
|
|
...mockBookingData,
|
|
start: getPlusDayDate(mockBookingData.start, index).toISOString(),
|
|
end: getPlusDayDate(mockBookingData.end, index).toISOString(),
|
|
};
|
|
}),
|
|
});
|
|
|
|
const createdBookings = await handleRecurringEventBooking(req);
|
|
expect(createdBookings.length).toBe(numOfSlotsToBeBooked);
|
|
for (const [index, createdBooking] of Object.entries(createdBookings)) {
|
|
logger.debug("Assertion for Booking with index:", index, { createdBooking });
|
|
expect(createdBooking.responses).toEqual(
|
|
expect.objectContaining({
|
|
email: booker.email,
|
|
name: booker.name,
|
|
})
|
|
);
|
|
|
|
expect(createdBooking).toEqual(
|
|
expect.objectContaining({
|
|
location: "integrations:daily",
|
|
})
|
|
);
|
|
|
|
await expectBookingToBeInDatabase({
|
|
description: "",
|
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
uid: createdBooking.uid!,
|
|
eventTypeId: mockBookingData.eventTypeId,
|
|
status: BookingStatus.ACCEPTED,
|
|
recurringEventId: mockBookingData.recurringEventId,
|
|
references: [
|
|
{
|
|
type: "daily_video",
|
|
uid: "MOCK_ID",
|
|
meetingId: "MOCK_ID",
|
|
meetingPassword: "MOCK_PASS",
|
|
meetingUrl: "http://mock-dailyvideo.example.com/meeting-1",
|
|
},
|
|
{
|
|
type: "google_calendar",
|
|
uid: "MOCKED_GOOGLE_CALENDAR_EVENT_ID",
|
|
meetingId: "MOCKED_GOOGLE_CALENDAR_EVENT_ID",
|
|
meetingPassword: "MOCK_PASSWORD",
|
|
},
|
|
],
|
|
});
|
|
|
|
expectBookingCreatedWebhookToHaveBeenFired({
|
|
booker,
|
|
organizer,
|
|
location: "integrations:daily",
|
|
subscriberUrl: "http://my-webhook.example.com",
|
|
//FIXME: File a bug - All recurring bookings seem to have the same URL. They should have same CalVideo URL which could mean that future recurring meetings would have already expired by the time they are needed.
|
|
videoCallUrl: `${WEBAPP_URL}/video/${createdBookings[0].uid}`,
|
|
});
|
|
}
|
|
|
|
// expectWorkflowToBeTriggered();
|
|
|
|
expectSuccessfulBookingCreationEmails({
|
|
booking: {
|
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
uid: createdBookings[0].uid!,
|
|
urlOrigin: WEBSITE_URL,
|
|
},
|
|
booker,
|
|
organizer,
|
|
emails,
|
|
iCalUID: "MOCKED_GOOGLE_CALENDAR_ICS_ID",
|
|
recurrence: {
|
|
...recurrence,
|
|
count: recurringCountInRequest,
|
|
},
|
|
});
|
|
|
|
expect(emails.get().length).toBe(2);
|
|
|
|
expectSuccessfulCalendarEventCreationInCalendar(calendarMock, [
|
|
{
|
|
calendarId: "event-type-1@google-calendar.com",
|
|
videoCallUrl: "http://mock-dailyvideo.example.com/meeting-1",
|
|
},
|
|
{
|
|
calendarId: "event-type-1@google-calendar.com",
|
|
videoCallUrl: "http://mock-dailyvideo.example.com/meeting-1",
|
|
},
|
|
{
|
|
calendarId: "event-type-1@google-calendar.com",
|
|
videoCallUrl: "http://mock-dailyvideo.example.com/meeting-1",
|
|
},
|
|
{
|
|
calendarId: "event-type-1@google-calendar.com",
|
|
videoCallUrl: "http://mock-dailyvideo.example.com/meeting-1",
|
|
},
|
|
]);
|
|
},
|
|
timeout
|
|
);
|
|
});
|
|
describe("Round robin event type:", () => {
|
|
test.skip("should when when a fixed host is not available on the second slot", async () => {
|
|
const handleRecurringEventBooking = (await import("@calcom/web/pages/api/book/recurring-event"))
|
|
.handleRecurringEventBooking;
|
|
const booker = getBooker({
|
|
email: "booker@example.com",
|
|
name: "Booker",
|
|
});
|
|
|
|
const organizer = getOrganizer({
|
|
name: "Organizer",
|
|
email: "organizer@example.com",
|
|
id: 101,
|
|
// So, that it picks the first schedule from the list
|
|
defaultScheduleId: null,
|
|
teams: [
|
|
{
|
|
membership: {
|
|
accepted: true,
|
|
},
|
|
team: {
|
|
id: 1,
|
|
name: "Team 1",
|
|
slug: "team-1",
|
|
},
|
|
},
|
|
],
|
|
// 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",
|
|
},
|
|
});
|
|
|
|
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.IstMorningShift],
|
|
credentials: [getGoogleCalendarCredential()],
|
|
selectedCalendars: [TestData.selectedCalendars.google],
|
|
destinationCalendar: {
|
|
integration: TestData.apps["google-calendar"].type,
|
|
externalId: "other-team-member-1@google-calendar.com",
|
|
},
|
|
},
|
|
];
|
|
|
|
const recurrence = getRecurrence({
|
|
type: "weekly",
|
|
numberOfOccurrences: 3,
|
|
});
|
|
const plus1DateString = getDate({ dateIncrement: 1 }).dateString;
|
|
const plus2DateString = getDate({ dateIncrement: 2 }).dateString;
|
|
|
|
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: 30,
|
|
schedulingType: SchedulingType.ROUND_ROBIN,
|
|
length: 30,
|
|
recurringEvent: recurrence,
|
|
hosts: [
|
|
{
|
|
userId: 101,
|
|
isFixed: false,
|
|
},
|
|
{
|
|
userId: 102,
|
|
isFixed: true,
|
|
},
|
|
],
|
|
destinationCalendar: {
|
|
integration: "google_calendar",
|
|
externalId: "event-type-1@google-calendar.com",
|
|
},
|
|
},
|
|
],
|
|
bookings: [
|
|
{
|
|
userId: 102,
|
|
attendees: [
|
|
{
|
|
email: "IntegrationTestUser102@example.com",
|
|
},
|
|
],
|
|
eventTypeId: 1,
|
|
status: "ACCEPTED",
|
|
startTime: `${plus2DateString}T04:00:00.000Z`,
|
|
endTime: `${plus2DateString}T04:30:00.000Z`,
|
|
},
|
|
],
|
|
organizer,
|
|
usersApartFromOrganizer: otherTeamMembers,
|
|
apps: [TestData.apps["google-calendar"], TestData.apps["daily-video"]],
|
|
})
|
|
);
|
|
|
|
mockSuccessfulVideoMeetingCreation({
|
|
metadataLookupKey: "dailyvideo",
|
|
videoMeetingData: {
|
|
id: "MOCK_ID",
|
|
password: "MOCK_PASS",
|
|
url: `http://mock-dailyvideo.example.com/meeting-1`,
|
|
},
|
|
});
|
|
|
|
const calendarMock = await mockCalendarToHaveNoBusySlots("googlecalendar", {
|
|
create: {
|
|
id: "MOCKED_GOOGLE_CALENDAR_EVENT_ID",
|
|
iCalUID: "MOCKED_GOOGLE_CALENDAR_ICS_ID",
|
|
},
|
|
});
|
|
|
|
const recurringCountInRequest = 4;
|
|
const mockBookingData1 = getMockRequestDataForBooking({
|
|
data: {
|
|
eventTypeId: 1,
|
|
start: `${plus1DateString}T04:00:00.000Z`,
|
|
end: `${plus1DateString}T04:30:00.000Z`,
|
|
recurringEventId: uuidv4(),
|
|
recurringCount: recurringCountInRequest,
|
|
responses: {
|
|
email: booker.email,
|
|
name: booker.name,
|
|
location: { optionValue: "", value: "integrations:daily" },
|
|
},
|
|
},
|
|
});
|
|
|
|
const numOfSlotsToBeBooked = 4;
|
|
const { req, res } = createMockNextJsRequest({
|
|
method: "POST",
|
|
body: Array(numOfSlotsToBeBooked)
|
|
.fill(mockBookingData1)
|
|
.map((mockBookingData, index) => {
|
|
return {
|
|
...mockBookingData,
|
|
schedulingType: SchedulingType.ROUND_ROBIN,
|
|
start: getPlusDayDate(mockBookingData.start, index).toISOString(),
|
|
end: getPlusDayDate(mockBookingData.end, index).toISOString(),
|
|
};
|
|
}),
|
|
});
|
|
let error = { message: "" };
|
|
try {
|
|
await handleRecurringEventBooking(req);
|
|
} catch (e) {
|
|
error = e as Error;
|
|
}
|
|
expect(error.message).toBe(ErrorCode.NoAvailableUsersFound);
|
|
});
|
|
|
|
test("should create successfully bookings that are all assigned to the next available least recently booked user", async () => {
|
|
const handleRecurringEventBooking = (await import("@calcom/web/pages/api/book/recurring-event"))
|
|
.handleRecurringEventBooking;
|
|
const booker = getBooker({
|
|
email: "booker@example.com",
|
|
name: "Booker",
|
|
});
|
|
|
|
const organizer = getOrganizer({
|
|
name: "Organizer",
|
|
email: "organizer@example.com",
|
|
id: 101,
|
|
// So, that it picks the first schedule from the list
|
|
defaultScheduleId: null,
|
|
teams: [
|
|
{
|
|
membership: {
|
|
accepted: true,
|
|
},
|
|
team: {
|
|
id: 1,
|
|
name: "Team 1",
|
|
slug: "team-1",
|
|
},
|
|
},
|
|
],
|
|
// 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",
|
|
},
|
|
});
|
|
|
|
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.IstMorningShift],
|
|
credentials: [getGoogleCalendarCredential()],
|
|
selectedCalendars: [TestData.selectedCalendars.google],
|
|
destinationCalendar: {
|
|
integration: TestData.apps["google-calendar"].type,
|
|
externalId: "other-team-member-1@google-calendar.com",
|
|
},
|
|
},
|
|
];
|
|
|
|
const recurrence = getRecurrence({
|
|
type: "weekly",
|
|
numberOfOccurrences: 3,
|
|
});
|
|
const plus1DateString = getDate({ dateIncrement: 1 }).dateString;
|
|
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: 30,
|
|
schedulingType: SchedulingType.ROUND_ROBIN,
|
|
length: 30,
|
|
recurringEvent: recurrence,
|
|
users: [
|
|
{
|
|
id: 101,
|
|
},
|
|
{
|
|
id: 102,
|
|
},
|
|
],
|
|
destinationCalendar: {
|
|
integration: "google_calendar",
|
|
externalId: "event-type-1@google-calendar.com",
|
|
},
|
|
},
|
|
],
|
|
organizer,
|
|
usersApartFromOrganizer: otherTeamMembers,
|
|
apps: [TestData.apps["google-calendar"], TestData.apps["daily-video"]],
|
|
})
|
|
);
|
|
|
|
mockSuccessfulVideoMeetingCreation({
|
|
metadataLookupKey: "dailyvideo",
|
|
videoMeetingData: {
|
|
id: "MOCK_ID",
|
|
password: "MOCK_PASS",
|
|
url: `http://mock-dailyvideo.example.com/meeting-1`,
|
|
},
|
|
});
|
|
|
|
const calendarMock = await mockCalendarToHaveNoBusySlots("googlecalendar", {
|
|
create: {
|
|
id: "MOCKED_GOOGLE_CALENDAR_EVENT_ID",
|
|
iCalUID: "MOCKED_GOOGLE_CALENDAR_ICS_ID",
|
|
},
|
|
});
|
|
|
|
const recurringCountInRequest = 4;
|
|
const mockBookingData1 = getMockRequestDataForBooking({
|
|
data: {
|
|
eventTypeId: 1,
|
|
start: `${plus1DateString}T04:00:00.000Z`,
|
|
end: `${plus1DateString}T04:30:00.000Z`,
|
|
recurringEventId: uuidv4(),
|
|
recurringCount: recurringCountInRequest,
|
|
responses: {
|
|
email: booker.email,
|
|
name: booker.name,
|
|
location: { optionValue: "", value: "integrations:daily" },
|
|
},
|
|
},
|
|
});
|
|
|
|
const mockBookingData2 = getMockRequestDataForBooking({
|
|
data: {
|
|
eventTypeId: 1,
|
|
start: `${plus1DateString}T07:00:00.000Z`,
|
|
end: `${plus1DateString}T07:30:00.000Z`,
|
|
recurringEventId: uuidv4(),
|
|
recurringCount: recurringCountInRequest,
|
|
responses: {
|
|
email: booker.email,
|
|
name: booker.name,
|
|
location: { optionValue: "", value: "integrations:daily" },
|
|
},
|
|
},
|
|
});
|
|
const numOfSlotsToBeBooked = 4;
|
|
const { req, res } = createMockNextJsRequest({
|
|
method: "POST",
|
|
body: Array(numOfSlotsToBeBooked)
|
|
.fill(mockBookingData1)
|
|
.map((mockBookingData, index) => {
|
|
return {
|
|
...mockBookingData,
|
|
schedulingType: SchedulingType.ROUND_ROBIN,
|
|
start: getPlusDayDate(mockBookingData.start, index).toISOString(),
|
|
end: getPlusDayDate(mockBookingData.end, index).toISOString(),
|
|
};
|
|
}),
|
|
});
|
|
|
|
const { req: req1, res: res1 } = createMockNextJsRequest({
|
|
method: "POST",
|
|
body: Array(numOfSlotsToBeBooked)
|
|
.fill(mockBookingData2)
|
|
.map((mockBookingData, index) => {
|
|
return {
|
|
...mockBookingData,
|
|
schedulingType: SchedulingType.ROUND_ROBIN,
|
|
start: getPlusDayDate(mockBookingData.start, index).toISOString(),
|
|
end: getPlusDayDate(mockBookingData.end, index).toISOString(),
|
|
};
|
|
}),
|
|
});
|
|
|
|
const createdBookings1 = await handleRecurringEventBooking(req);
|
|
|
|
const assignedUserIds1 = createdBookings1.map((booking) => booking.userId);
|
|
|
|
const createdBookings2 = await handleRecurringEventBooking(req1);
|
|
|
|
const assignedUserIds2 = createdBookings2.map((booking) => booking.userId);
|
|
|
|
const allSameHosts = (assignedUserIds: (number | null | undefined)[]) =>
|
|
assignedUserIds.every((id) => id === assignedUserIds[0]);
|
|
|
|
expect(createdBookings1[0].userId).not.toBe(createdBookings2[0].userId);
|
|
expect(allSameHosts(assignedUserIds1)).toBe(true);
|
|
expect(allSameHosts(assignedUserIds2)).toBe(true);
|
|
});
|
|
|
|
test(`should create bookings that are all assigned to the least recently booked user that is available on the first two slots,
|
|
if the least recently booked user is not available on the second slot`, async () => {
|
|
const handleRecurringEventBooking = (await import("@calcom/web/pages/api/book/recurring-event"))
|
|
.handleRecurringEventBooking;
|
|
const booker = getBooker({
|
|
email: "booker@example.com",
|
|
name: "Booker",
|
|
});
|
|
|
|
const organizer = getOrganizer({
|
|
name: "Organizer",
|
|
email: "organizer@example.com",
|
|
id: 101,
|
|
// So, that it picks the first schedule from the list
|
|
defaultScheduleId: null,
|
|
teams: [
|
|
{
|
|
membership: {
|
|
accepted: true,
|
|
},
|
|
team: {
|
|
id: 1,
|
|
name: "Team 1",
|
|
slug: "team-1",
|
|
},
|
|
},
|
|
],
|
|
// 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",
|
|
},
|
|
});
|
|
|
|
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.IstMorningShift],
|
|
credentials: [getGoogleCalendarCredential()],
|
|
selectedCalendars: [TestData.selectedCalendars.google],
|
|
destinationCalendar: {
|
|
integration: TestData.apps["google-calendar"].type,
|
|
externalId: "other-team-member-1@google-calendar.com",
|
|
},
|
|
},
|
|
];
|
|
|
|
const recurrence = getRecurrence({
|
|
type: "weekly",
|
|
numberOfOccurrences: 3,
|
|
});
|
|
const plus1DateString = getDate({ dateIncrement: 1 }).dateString;
|
|
const plus2DateString = getDate({ dateIncrement: 2 }).dateString;
|
|
|
|
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: 30,
|
|
schedulingType: SchedulingType.ROUND_ROBIN,
|
|
length: 30,
|
|
recurringEvent: recurrence,
|
|
users: [
|
|
{
|
|
id: 101,
|
|
},
|
|
{
|
|
id: 102,
|
|
},
|
|
],
|
|
destinationCalendar: {
|
|
integration: "google_calendar",
|
|
externalId: "event-type-1@google-calendar.com",
|
|
},
|
|
},
|
|
],
|
|
bookings: [
|
|
// Create a booking on our Collective Event Type
|
|
{
|
|
userId: 101,
|
|
attendees: [
|
|
{
|
|
email: "IntegrationTestUser102@example.com",
|
|
},
|
|
],
|
|
eventTypeId: 1,
|
|
status: "ACCEPTED",
|
|
startTime: `${plus2DateString}T04:00:00.000Z`,
|
|
endTime: `${plus2DateString}T04:30:00.000Z`,
|
|
},
|
|
{
|
|
userId: 101,
|
|
attendees: [
|
|
{
|
|
email: "IntegrationTestUser102@example.com",
|
|
},
|
|
],
|
|
eventTypeId: 1,
|
|
status: "ACCEPTED",
|
|
startTime: `${plus2DateString}T07:00:00.000Z`,
|
|
endTime: `${plus2DateString}T07:30:00.000Z`,
|
|
},
|
|
],
|
|
organizer,
|
|
usersApartFromOrganizer: otherTeamMembers,
|
|
apps: [TestData.apps["google-calendar"], TestData.apps["daily-video"]],
|
|
})
|
|
);
|
|
|
|
mockSuccessfulVideoMeetingCreation({
|
|
metadataLookupKey: "dailyvideo",
|
|
videoMeetingData: {
|
|
id: "MOCK_ID",
|
|
password: "MOCK_PASS",
|
|
url: `http://mock-dailyvideo.example.com/meeting-1`,
|
|
},
|
|
});
|
|
|
|
const calendarMock = await mockCalendarToHaveNoBusySlots("googlecalendar", {
|
|
create: {
|
|
id: "MOCKED_GOOGLE_CALENDAR_EVENT_ID",
|
|
iCalUID: "MOCKED_GOOGLE_CALENDAR_ICS_ID",
|
|
},
|
|
});
|
|
|
|
const recurringCountInRequest = 4;
|
|
const mockBookingData1 = getMockRequestDataForBooking({
|
|
data: {
|
|
eventTypeId: 1,
|
|
start: `${plus1DateString}T04:00:00.000Z`,
|
|
end: `${plus1DateString}T04:30:00.000Z`,
|
|
recurringEventId: uuidv4(),
|
|
recurringCount: recurringCountInRequest,
|
|
responses: {
|
|
email: booker.email,
|
|
name: booker.name,
|
|
location: { optionValue: "", value: "integrations:daily" },
|
|
},
|
|
},
|
|
});
|
|
|
|
const mockBookingData2 = getMockRequestDataForBooking({
|
|
data: {
|
|
eventTypeId: 1,
|
|
start: `${plus1DateString}T07:00:00.000Z`,
|
|
end: `${plus1DateString}T07:30:00.000Z`,
|
|
recurringEventId: uuidv4(),
|
|
recurringCount: recurringCountInRequest,
|
|
responses: {
|
|
email: booker.email,
|
|
name: booker.name,
|
|
location: { optionValue: "", value: "integrations:daily" },
|
|
},
|
|
},
|
|
});
|
|
const numOfSlotsToBeBooked = 4;
|
|
const { req, res } = createMockNextJsRequest({
|
|
method: "POST",
|
|
body: Array(numOfSlotsToBeBooked)
|
|
.fill(mockBookingData1)
|
|
.map((mockBookingData, index) => {
|
|
return {
|
|
...mockBookingData,
|
|
schedulingType: SchedulingType.ROUND_ROBIN,
|
|
start: getPlusDayDate(mockBookingData.start, index).toISOString(),
|
|
end: getPlusDayDate(mockBookingData.end, index).toISOString(),
|
|
};
|
|
}),
|
|
});
|
|
|
|
const { req: req1, res: res1 } = createMockNextJsRequest({
|
|
method: "POST",
|
|
body: Array(numOfSlotsToBeBooked)
|
|
.fill(mockBookingData2)
|
|
.map((mockBookingData, index) => {
|
|
return {
|
|
...mockBookingData,
|
|
schedulingType: SchedulingType.ROUND_ROBIN,
|
|
start: getPlusDayDate(mockBookingData.start, index).toISOString(),
|
|
end: getPlusDayDate(mockBookingData.end, index).toISOString(),
|
|
};
|
|
}),
|
|
});
|
|
|
|
const createdBookings1 = await handleRecurringEventBooking(req);
|
|
|
|
const assignedUserIds1 = createdBookings1.map((booking) => booking.userId);
|
|
|
|
const createdBookings2 = await handleRecurringEventBooking(req1);
|
|
|
|
const assignedUserIds2 = createdBookings2.map((booking) => booking.userId);
|
|
|
|
const allSameHosts = (assignedUserIds: (number | null | undefined)[]) =>
|
|
assignedUserIds.every((id) => id === assignedUserIds[0]);
|
|
|
|
expect(createdBookings1[0].userId).toBe(102); // user 101 is busy on the second recurring slot
|
|
expect(allSameHosts(assignedUserIds1)).toBe(true);
|
|
expect(allSameHosts(assignedUserIds2)).toBe(true);
|
|
});
|
|
});
|
|
});
|
|
|
|
function getRecurrence({
|
|
type,
|
|
numberOfOccurrences,
|
|
}: {
|
|
type: "weekly" | "monthly" | "yearly";
|
|
numberOfOccurrences: number;
|
|
}) {
|
|
const freq = type === "yearly" ? 0 : type === "monthly" ? 1 : 2;
|
|
return { freq, count: numberOfOccurrences, interval: 1 };
|
|
}
|
|
});
|