cal.pub0.org/apps/web/test/lib/getSchedule.test.ts

1395 lines
41 KiB
TypeScript
Raw Normal View History

import type {
EventType as PrismaEventType,
User as PrismaUser,
Booking as PrismaBooking,
App as PrismaApp,
} from "@prisma/client";
import CalendarManagerMock from "../../../../tests/libs/__mocks__/CalendarManager";
import prismaMock from "../../../../tests/libs/__mocks__/prisma";
import { diff } from "jest-diff";
import { v4 as uuidv4 } from "uuid";
import { describe, expect, vi, beforeEach, afterEach, test } from "vitest";
import logger from "@calcom/lib/logger";
import prisma from "@calcom/prisma";
import type { SchedulingType } from "@calcom/prisma/enums";
import type { BookingStatus } from "@calcom/prisma/enums";
import type { Slot } from "@calcom/trpc/server/routers/viewer/slots/types";
import { getSchedule } from "@calcom/trpc/server/routers/viewer/slots/util";
// TODO: Mock properly
prismaMock.eventType.findUnique.mockResolvedValue(null);
prismaMock.user.findMany.mockResolvedValue([]);
vi.mock("@calcom/lib/constants", () => ({
IS_PRODUCTION: true,
WEBAPP_URL: "http://localhost:3000"
}));
declare global {
// eslint-disable-next-line @typescript-eslint/no-namespace
namespace jest {
interface Matchers<R> {
toHaveTimeSlots(expectedSlots: string[], date: { dateString: string }): R;
}
}
}
expect.extend({
toHaveTimeSlots(
schedule: { slots: Record<string, Slot[]> },
expectedSlots: string[],
{ dateString }: { dateString: string }
) {
if (!schedule.slots[`${dateString}`]) {
return {
pass: false,
message: () => `has no timeslots for ${dateString}`,
};
}
if (
!schedule.slots[`${dateString}`]
.map((slot) => slot.time)
.every((actualSlotTime, index) => {
return `${dateString}T${expectedSlots[index]}` === actualSlotTime;
})
) {
return {
pass: false,
message: () =>
`has incorrect timeslots for ${dateString}.\n\r ${diff(
expectedSlots.map((expectedSlot) => `${dateString}T${expectedSlot}`),
schedule.slots[`${dateString}`].map((slot) => slot.time)
)}`,
};
}
return {
pass: true,
message: () => "has correct timeslots ",
};
},
});
const Timezones = {
"+5:30": "Asia/Kolkata",
"+6:00": "Asia/Dhaka",
};
2022-07-28 10:37:00 +00:00
const TestData = {
selectedCalendars: {
google: {
integration: "google_calendar",
externalId: "john@example.com",
},
},
credentials: {
google: getGoogleCalendarCredential(),
},
schedules: {
IstWorkHours: {
id: 1,
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"],
},
IstWorkHoursWithDateOverride: (dateString: string) => ({
id: 1,
name: "9:30AM to 6PM in India - 4:00AM to 12:30PM in GMT but with a Date Override for 2PM to 6PM IST(in GST time it is 8:30AM to 12:30PM)",
availability: [
{
userId: null,
eventTypeId: null,
days: [0, 1, 2, 3, 4, 5, 6],
startTime: new Date("1970-01-01T09:30:00.000Z"),
endTime: new Date("1970-01-01T18:00:00.000Z"),
date: null,
},
{
userId: null,
eventTypeId: null,
days: [0, 1, 2, 3, 4, 5, 6],
startTime: new Date("1970-01-01T14:00:00.000Z"),
endTime: new Date("1970-01-01T18:00:00.000Z"),
date: dateString,
},
],
timeZone: Timezones["+5:30"],
}),
},
users: {
example: {
username: "example",
defaultScheduleId: 1,
email: "example@example.com",
timeZone: Timezones["+5:30"],
},
},
apps: {
googleCalendar: {
slug: "google-calendar",
dirName: "whatever",
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore
keys: {
expiry_date: Infinity,
client_id: "client_id",
client_secret: "client_secret",
redirect_uris: ["http://localhost:3000/auth/callback"],
},
},
},
};
type App = {
slug: string;
dirName: string;
};
type InputCredential = typeof TestData.credentials.google;
type InputSelectedCalendar = typeof TestData.selectedCalendars.google;
type InputUser = typeof TestData.users.example & { id: number } & {
credentials?: InputCredential[];
selectedCalendars?: InputSelectedCalendar[];
schedules: {
id: number;
name: string;
availability: {
userId: number | null;
eventTypeId: number | null;
days: number[];
startTime: Date;
endTime: Date;
date: string | null;
}[];
timeZone: string;
}[];
};
type InputEventType = {
id: number;
title?: string;
length?: number;
offsetStart?: number;
slotInterval?: number;
minimumBookingNotice?: number;
users?: { id: number }[];
hosts?: { id: number }[];
schedulingType?: SchedulingType;
beforeEventBuffer?: number;
afterEventBuffer?: number;
};
type InputBooking = {
userId?: number;
eventTypeId: number;
startTime: string;
endTime: string;
title?: string;
status: BookingStatus;
attendees?: { email: string }[];
};
type InputHost = {
id: number;
userId: number;
eventTypeId: number;
isFixed: boolean;
};
const cleanup = async () => {
await prisma.eventType.deleteMany();
await prisma.user.deleteMany();
await prisma.schedule.deleteMany();
await prisma.selectedCalendar.deleteMany();
await prisma.credential.deleteMany();
await prisma.booking.deleteMany();
await prisma.app.deleteMany();
};
beforeEach(async () => {
await cleanup();
});
afterEach(async () => {
await cleanup();
});
describe("getSchedule", () => {
2023-01-11 13:24:02 +00:00
describe("Calendar event", () => {
test("correctly identifies unavailable slots from calendar", async () => {
const { dateString: plus1DateString } = getDate({ dateIncrement: 1 });
const { dateString: plus2DateString } = getDate({ dateIncrement: 2 });
CalendarManagerMock.getBusyCalendarTimes.mockResolvedValue([
{
start: `${plus2DateString}T04:45:00.000Z`,
end: `${plus2DateString}T23:00:00.000Z`,
},
]);
2023-01-11 13:24:02 +00:00
const scenarioData = {
hosts: [],
2023-01-11 13:24:02 +00:00
eventTypes: [
{
id: 1,
slotInterval: 45,
length: 45,
2023-01-11 13:24:02 +00:00
users: [
{
id: 101,
},
],
},
],
users: [
{
...TestData.users.example,
id: 101,
schedules: [TestData.schedules.IstWorkHours],
credentials: [getGoogleCalendarCredential()],
selectedCalendars: [TestData.selectedCalendars.google],
},
],
apps: [TestData.apps.googleCalendar],
};
// An event with one accepted booking
createBookingScenario(scenarioData);
const scheduleForDayWithAGoogleCalendarBooking = await getSchedule({
eventTypeId: 1,
eventTypeSlug: "",
startTime: `${plus1DateString}T18:30:00.000Z`,
endTime: `${plus2DateString}T18:29:59.999Z`,
timeZone: Timezones["+5:30"],
});
2023-01-11 13:24:02 +00:00
// As per Google Calendar Availability, only 4PM(4-4:45PM) GMT slot would be available
2023-01-11 13:24:02 +00:00
expect(scheduleForDayWithAGoogleCalendarBooking).toHaveTimeSlots([`04:00:00.000Z`], {
dateString: plus2DateString,
});
});
});
describe("User Event", () => {
test("correctly identifies unavailable slots from Cal Bookings in different status", async () => {
const { dateString: plus1DateString } = getDate({ dateIncrement: 1 });
const { dateString: plus2DateString } = getDate({ dateIncrement: 2 });
const { dateString: plus3DateString } = getDate({ dateIncrement: 3 });
// An event with one accepted booking
createBookingScenario({
// An event with length 30 minutes, slotInterval 45 minutes, and minimumBookingNotice 1440 minutes (24 hours)
eventTypes: [
{
id: 1,
// If `slotInterval` is set, it supersedes `length`
slotInterval: 45,
length: 45,
users: [
{
id: 101,
},
],
},
],
users: [
{
...TestData.users.example,
id: 101,
schedules: [TestData.schedules.IstWorkHours],
},
],
bookings: [
// That event has one accepted booking from 4:00 to 4:15 in GMT on Day + 3 which is 9:30 to 9:45 in IST
{
eventTypeId: 1,
userId: 101,
status: "ACCEPTED",
// Booking Time is stored in GMT in DB. So, provide entry in GMT only.
startTime: `${plus3DateString}T04:00:00.000Z`,
endTime: `${plus3DateString}T04:15:00.000Z`,
},
{
eventTypeId: 1,
userId: 101,
status: "REJECTED",
// Booking Time is stored in GMT in DB. So, provide entry in GMT only.
startTime: `${plus2DateString}T04:00:00.000Z`,
endTime: `${plus2DateString}T04:15:00.000Z`,
},
{
eventTypeId: 1,
userId: 101,
status: "CANCELLED",
// Booking Time is stored in GMT in DB. So, provide entry in GMT only.
startTime: `${plus2DateString}T05:00:00.000Z`,
endTime: `${plus2DateString}T05:15:00.000Z`,
},
{
eventTypeId: 1,
userId: 101,
status: "PENDING",
// Booking Time is stored in GMT in DB. So, provide entry in GMT only.
startTime: `${plus2DateString}T06:00:00.000Z`,
endTime: `${plus2DateString}T06:15:00.000Z`,
},
],
hosts: [],
});
// Day Plus 2 is completely free - It only has non accepted bookings
const scheduleOnCompletelyFreeDay = await getSchedule({
eventTypeId: 1,
// EventTypeSlug doesn't matter for non-dynamic events
eventTypeSlug: "",
startTime: `${plus1DateString}T18:30:00.000Z`,
endTime: `${plus2DateString}T18:29:59.999Z`,
timeZone: Timezones["+5:30"],
});
// getSchedule returns timeslots in GMT
expect(scheduleOnCompletelyFreeDay).toHaveTimeSlots(
[
"04:00:00.000Z",
"04:45:00.000Z",
"05:30:00.000Z",
"06:15:00.000Z",
"07:00:00.000Z",
"07:45:00.000Z",
"08:30:00.000Z",
"09:15:00.000Z",
"10:00:00.000Z",
"10:45:00.000Z",
"11:30:00.000Z",
],
{
dateString: plus2DateString,
}
);
// Day plus 3
const scheduleForDayWithOneBooking = await getSchedule({
eventTypeId: 1,
eventTypeSlug: "",
startTime: `${plus2DateString}T18:30:00.000Z`,
endTime: `${plus3DateString}T18:29:59.999Z`,
timeZone: Timezones["+5:30"],
});
expect(scheduleForDayWithOneBooking).toHaveTimeSlots(
[
// "04:00:00.000Z", - This slot is unavailable because of the booking from 4:00 to 4:15
`04:15:00.000Z`,
`05:00:00.000Z`,
`05:45:00.000Z`,
`06:30:00.000Z`,
`07:15:00.000Z`,
`08:00:00.000Z`,
`08:45:00.000Z`,
`09:30:00.000Z`,
`10:15:00.000Z`,
`11:00:00.000Z`,
`11:45:00.000Z`,
],
{
dateString: plus3DateString,
}
);
});
test("slots are available as per `length`, `slotInterval` of the event", async () => {
createBookingScenario({
eventTypes: [
{
id: 1,
length: 30,
users: [
{
id: 101,
},
],
},
{
id: 2,
length: 30,
slotInterval: 120,
users: [
{
id: 101,
},
],
},
],
users: [
{
...TestData.users.example,
id: 101,
schedules: [TestData.schedules.IstWorkHours],
},
],
hosts: [],
});
const { dateString: plus1DateString } = getDate({ dateIncrement: 1 });
const { dateString: plus2DateString } = getDate({ dateIncrement: 2 });
const scheduleForEventWith30Length = await getSchedule({
eventTypeId: 1,
eventTypeSlug: "",
startTime: `${plus1DateString}T18:30:00.000Z`,
endTime: `${plus2DateString}T18:29:59.999Z`,
timeZone: Timezones["+5:30"],
});
expect(scheduleForEventWith30Length).toHaveTimeSlots(
[
`04:00:00.000Z`,
`04:30:00.000Z`,
`05:00:00.000Z`,
`05:30:00.000Z`,
`06:00:00.000Z`,
`06:30:00.000Z`,
`07:00:00.000Z`,
`07:30:00.000Z`,
`08:00:00.000Z`,
`08:30:00.000Z`,
`09:00:00.000Z`,
`09:30:00.000Z`,
`10:00:00.000Z`,
`10:30:00.000Z`,
`11:00:00.000Z`,
`11:30:00.000Z`,
`12:00:00.000Z`,
],
{
dateString: plus2DateString,
}
);
const scheduleForEventWith30minsLengthAndSlotInterval2hrs = await getSchedule({
eventTypeId: 2,
eventTypeSlug: "",
startTime: `${plus1DateString}T18:30:00.000Z`,
endTime: `${plus2DateString}T18:29:59.999Z`,
timeZone: Timezones["+5:30"],
});
// `slotInterval` takes precedence over `length`
// 4:30 is utc so it is 10:00 in IST
expect(scheduleForEventWith30minsLengthAndSlotInterval2hrs).toHaveTimeSlots(
[`04:30:00.000Z`, `06:30:00.000Z`, `08:30:00.000Z`, `10:30:00.000Z`, `12:30:00.000Z`],
{
dateString: plus2DateString,
}
);
});
// FIXME: Fix minimumBookingNotice is respected test
// eslint-disable-next-line playwright/no-skipped-test
test.skip("minimumBookingNotice is respected", async () => {
vi.useFakeTimers().setSystemTime(
(() => {
const today = new Date();
// Beginning of the day in current timezone of the system
return new Date(today.getFullYear(), today.getMonth(), today.getDate());
})()
);
createBookingScenario({
eventTypes: [
{
id: 1,
length: 120,
minimumBookingNotice: 13 * 60, // Would take the minimum bookable time to be 18:30UTC+13 = 7:30AM UTC
users: [
{
id: 101,
},
],
},
{
id: 2,
length: 120,
minimumBookingNotice: 10 * 60, // Would take the minimum bookable time to be 18:30UTC+10 = 4:30AM UTC
users: [
{
id: 101,
},
],
},
],
users: [
{
...TestData.users.example,
id: 101,
schedules: [TestData.schedules.IstWorkHours],
},
],
hosts: [],
});
const { dateString: todayDateString } = getDate();
const { dateString: minus1DateString } = getDate({ dateIncrement: -1 });
const scheduleForEventWithBookingNotice13Hrs = await getSchedule({
eventTypeId: 1,
eventTypeSlug: "",
startTime: `${minus1DateString}T18:30:00.000Z`,
endTime: `${todayDateString}T18:29:59.999Z`,
timeZone: Timezones["+5:30"],
});
expect(scheduleForEventWithBookingNotice13Hrs).toHaveTimeSlots(
[
/*`04:00:00.000Z`, `06:00:00.000Z`, - Minimum time slot is 07:30 UTC*/ `08:00:00.000Z`,
`10:00:00.000Z`,
`12:00:00.000Z`,
],
{
dateString: todayDateString,
}
);
const scheduleForEventWithBookingNotice10Hrs = await getSchedule({
eventTypeId: 2,
eventTypeSlug: "",
startTime: `${minus1DateString}T18:30:00.000Z`,
endTime: `${todayDateString}T18:29:59.999Z`,
timeZone: Timezones["+5:30"],
});
expect(scheduleForEventWithBookingNotice10Hrs).toHaveTimeSlots(
[
/*`04:00:00.000Z`, - Minimum bookable time slot is 04:30 UTC but next available is 06:00*/
`06:00:00.000Z`,
`08:00:00.000Z`,
`10:00:00.000Z`,
`12:00:00.000Z`,
],
{
dateString: todayDateString,
}
);
vi.useRealTimers();
});
test("afterBuffer and beforeBuffer tests - Non Cal Busy Time", async () => {
const { dateString: plus2DateString } = getDate({ dateIncrement: 2 });
const { dateString: plus3DateString } = getDate({ dateIncrement: 3 });
CalendarManagerMock.getBusyCalendarTimes.mockResolvedValue([
{
start: `${plus3DateString}T04:00:00.000Z`,
end: `${plus3DateString}T05:59:59.000Z`,
},
]);
const scenarioData = {
eventTypes: [
{
id: 1,
length: 120,
beforeEventBuffer: 120,
afterEventBuffer: 120,
users: [
{
id: 101,
},
],
},
],
users: [
{
...TestData.users.example,
id: 101,
schedules: [TestData.schedules.IstWorkHours],
credentials: [getGoogleCalendarCredential()],
selectedCalendars: [TestData.selectedCalendars.google],
},
],
hosts: [],
apps: [TestData.apps.googleCalendar],
};
createBookingScenario(scenarioData);
const scheduleForEventOnADayWithNonCalBooking = await getSchedule({
eventTypeId: 1,
eventTypeSlug: "",
startTime: `${plus2DateString}T18:30:00.000Z`,
endTime: `${plus3DateString}T18:29:59.999Z`,
timeZone: Timezones["+5:30"],
});
expect(scheduleForEventOnADayWithNonCalBooking).toHaveTimeSlots(
[
// `04:00:00.000Z`, // - 4 AM is booked
// `06:00:00.000Z`, // - 6 AM is not available because 08:00AM slot has a `beforeEventBuffer`
`08:00:00.000Z`, // - 8 AM is available because of availability of 06:00 - 07:59
`10:00:00.000Z`,
`12:00:00.000Z`,
],
{
dateString: plus3DateString,
}
);
});
test("afterBuffer and beforeBuffer tests - Cal Busy Time", async () => {
const { dateString: plus1DateString } = getDate({ dateIncrement: 1 });
const { dateString: plus2DateString } = getDate({ dateIncrement: 2 });
const { dateString: plus3DateString } = getDate({ dateIncrement: 3 });
CalendarManagerMock.getBusyCalendarTimes.mockResolvedValue([
{
start: `${plus3DateString}T04:00:00.000Z`,
end: `${plus3DateString}T05:59:59.000Z`,
},
]);
const scenarioData = {
eventTypes: [
{
id: 1,
length: 120,
beforeEventBuffer: 120,
afterEventBuffer: 120,
users: [
{
id: 101,
},
],
},
],
users: [
{
...TestData.users.example,
id: 101,
schedules: [TestData.schedules.IstWorkHours],
credentials: [getGoogleCalendarCredential()],
selectedCalendars: [TestData.selectedCalendars.google],
},
],
bookings: [
{
userId: 101,
eventTypeId: 1,
startTime: `${plus2DateString}T04:00:00.000Z`,
endTime: `${plus2DateString}T05:59:59.000Z`,
status: "ACCEPTED" as BookingStatus,
},
],
apps: [TestData.apps.googleCalendar],
hosts: [],
};
createBookingScenario(scenarioData);
const scheduleForEventOnADayWithCalBooking = await getSchedule({
eventTypeId: 1,
eventTypeSlug: "",
startTime: `${plus1DateString}T18:30:00.000Z`,
endTime: `${plus2DateString}T18:29:59.999Z`,
timeZone: Timezones["+5:30"],
});
expect(scheduleForEventOnADayWithCalBooking).toHaveTimeSlots(
[
// `04:00:00.000Z`, // - 4 AM is booked
// `06:00:00.000Z`, // - 6 AM is not available because of afterBuffer(120 mins) of the existing booking(4-5:59AM slot)
// `08:00:00.000Z`, // - 8 AM is not available because of beforeBuffer(120mins) of possible booking at 08:00
`10:00:00.000Z`,
`12:00:00.000Z`,
],
{
dateString: plus2DateString,
}
);
});
test("Start times are offset (offsetStart)", async () => {
const { dateString: plus1DateString } = getDate({ dateIncrement: 1 });
const { dateString: plus2DateString } = getDate({ dateIncrement: 2 });
CalendarManagerMock.getBusyCalendarTimes.mockResolvedValue([]);
const scenarioData = {
eventTypes: [
{
id: 1,
length: 25,
offsetStart: 5,
users: [
{
id: 101,
},
],
},
],
users: [
{
...TestData.users.example,
id: 101,
schedules: [TestData.schedules.IstWorkHours],
credentials: [getGoogleCalendarCredential()],
selectedCalendars: [TestData.selectedCalendars.google],
},
],
hosts: [],
apps: [TestData.apps.googleCalendar],
};
createBookingScenario(scenarioData);
const schedule = await getSchedule({
eventTypeId: 1,
eventTypeSlug: "",
startTime: `${plus1DateString}T18:30:00.000Z`,
endTime: `${plus2DateString}T18:29:59.999Z`,
timeZone: Timezones["+5:30"],
});
expect(schedule).toHaveTimeSlots(
[
`04:05:00.000Z`,
`04:35:00.000Z`,
`05:05:00.000Z`,
`05:35:00.000Z`,
`06:05:00.000Z`,
`06:35:00.000Z`,
`07:05:00.000Z`,
`07:35:00.000Z`,
`08:05:00.000Z`,
`08:35:00.000Z`,
`09:05:00.000Z`,
`09:35:00.000Z`,
`10:05:00.000Z`,
`10:35:00.000Z`,
`11:05:00.000Z`,
`11:35:00.000Z`,
`12:05:00.000Z`,
],
{
dateString: plus2DateString,
}
);
});
test("Check for Date overrides", async () => {
const { dateString: plus1DateString } = getDate({ dateIncrement: 1 });
const { dateString: plus2DateString } = getDate({ dateIncrement: 2 });
const scenarioData = {
eventTypes: [
{
id: 1,
length: 60,
users: [
{
id: 101,
},
],
},
],
users: [
{
...TestData.users.example,
id: 101,
schedules: [TestData.schedules.IstWorkHoursWithDateOverride(plus2DateString)],
},
],
hosts: [],
};
createBookingScenario(scenarioData);
const scheduleForEventOnADayWithDateOverride = await getSchedule({
eventTypeId: 1,
eventTypeSlug: "",
startTime: `${plus1DateString}T18:30:00.000Z`,
endTime: `${plus2DateString}T18:29:59.999Z`,
timeZone: Timezones["+5:30"],
});
expect(scheduleForEventOnADayWithDateOverride).toHaveTimeSlots(
["08:30:00.000Z", "09:30:00.000Z", "10:30:00.000Z", "11:30:00.000Z"],
{
dateString: plus2DateString,
}
);
});
test("that a user is considered busy when there's a booking they host", async () => {
const { dateString: plus1DateString } = getDate({ dateIncrement: 1 });
const { dateString: plus2DateString } = getDate({ dateIncrement: 2 });
createBookingScenario({
eventTypes: [
// A Collective Event Type hosted by this user
{
id: 1,
slotInterval: 45,
schedulingType: "COLLECTIVE",
hosts: [
{
id: 101,
},
{
id: 102,
},
],
},
// A default Event Type which this user owns
{
id: 2,
length: 15,
slotInterval: 45,
users: [{ id: 101 }],
},
],
users: [
{
...TestData.users.example,
id: 101,
schedules: [TestData.schedules.IstWorkHours],
},
{
...TestData.users.example,
id: 102,
schedules: [TestData.schedules.IstWorkHours],
},
],
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:15:00.000Z`,
},
],
hosts: [
// This user is a host of our Collective event
{
id: 1,
eventTypeId: 1,
userId: 101,
isFixed: true,
},
],
});
// Requesting this user's availability for their
// individual Event Type
const thisUserAvailability = await getSchedule({
eventTypeId: 2,
eventTypeSlug: "",
startTime: `${plus1DateString}T18:30:00.000Z`,
endTime: `${plus2DateString}T18:29:59.999Z`,
timeZone: Timezones["+5:30"],
});
expect(thisUserAvailability).toHaveTimeSlots(
[
// `04:00:00.000Z`, // <- This slot should be occupied by the Collective Event
`04:15:00.000Z`,
`05:00:00.000Z`,
`05:45:00.000Z`,
`06:30:00.000Z`,
`07:15:00.000Z`,
`08:00:00.000Z`,
`08:45:00.000Z`,
`09:30:00.000Z`,
`10:15:00.000Z`,
`11:00:00.000Z`,
`11:45:00.000Z`,
],
{
dateString: plus2DateString,
}
);
});
});
describe("Team Event", () => {
test("correctly identifies unavailable slots from calendar for all users in collective scheduling, considers bookings of users in other events as well", async () => {
const { dateString: todayDateString } = getDate();
const { dateString: plus1DateString } = getDate({ dateIncrement: 1 });
const { dateString: plus2DateString } = getDate({ dateIncrement: 2 });
createBookingScenario({
eventTypes: [
// An event having two users with one accepted booking
{
id: 1,
slotInterval: 45,
schedulingType: "COLLECTIVE",
length: 45,
users: [
{
id: 101,
},
{
id: 102,
},
],
},
{
id: 2,
slotInterval: 45,
length: 45,
users: [
{
id: 102,
},
],
},
],
users: [
{
...TestData.users.example,
id: 101,
schedules: [TestData.schedules.IstWorkHours],
},
{
...TestData.users.example,
id: 102,
schedules: [TestData.schedules.IstWorkHours],
},
],
bookings: [
{
userId: 101,
eventTypeId: 1,
status: "ACCEPTED",
startTime: `${plus2DateString}T04:00:00.000Z`,
endTime: `${plus2DateString}T04:15:00.000Z`,
},
{
userId: 102,
eventTypeId: 2,
status: "ACCEPTED",
startTime: `${plus2DateString}T05:30:00.000Z`,
endTime: `${plus2DateString}T05:45:00.000Z`,
},
],
hosts: [],
});
const scheduleForTeamEventOnADayWithNoBooking = await getSchedule({
eventTypeId: 1,
eventTypeSlug: "",
startTime: `${todayDateString}T18:30:00.000Z`,
endTime: `${plus1DateString}T18:29:59.999Z`,
timeZone: Timezones["+5:30"],
});
expect(scheduleForTeamEventOnADayWithNoBooking).toHaveTimeSlots(
[
`04:00:00.000Z`,
`04:45:00.000Z`,
`05:30:00.000Z`,
`06:15:00.000Z`,
`07:00:00.000Z`,
`07:45:00.000Z`,
`08:30:00.000Z`,
`09:15:00.000Z`,
`10:00:00.000Z`,
`10:45:00.000Z`,
`11:30:00.000Z`,
],
{
dateString: plus1DateString,
}
);
const scheduleForTeamEventOnADayWithOneBookingForEachUser = await getSchedule({
eventTypeId: 1,
eventTypeSlug: "",
startTime: `${plus1DateString}T18:30:00.000Z`,
endTime: `${plus2DateString}T18:29:59.999Z`,
timeZone: Timezones["+5:30"],
});
// A user with blocked time in another event, still affects Team Event availability
// It's a collective availability, so both user 101 and 102 are considered for timeslots
expect(scheduleForTeamEventOnADayWithOneBookingForEachUser).toHaveTimeSlots(
[
//`04:00:00.000Z`, - Blocked with User 101
`04:15:00.000Z`,
fix: better slot starting times ## What does this PR do? Currently, we start the first slot always at the nearest 15 minutes. This is not ideal as for some duration other slot starting time make more sense. So with this PR the starting times are defined as follow: - Frequency is exact hours (60, 120, 180, ...), slot start time is a full hour - Frequency is half hours (30, 90, ...), slot start time is half or full hours (8:00, 8:30, ...) - Same with 20-minute events (20, 40, ...) and 10-minute events - Everything else will start at the nearest 15 min slot It also fixes that slot times are shifted when there is a busy slot with a different duration. Here is a before and after of a 30-min event with a 5-minute busy slot at 1:00 pm Before: ![Screenshot 2023-07-07 at 13 31 45](https://github.com/calcom/cal.com/assets/30310907/b92d4ff4-49f1-48f4-a973-99266f61d919) After ![Screenshot 2023-07-07 at 13 34 01](https://github.com/calcom/cal.com/assets/30310907/042c7ef7-8c2a-4cd9-b663-183bc07b5864) #### 30 Minute events, availability starting at 7:15 Before: ![Screenshot 2023-07-06 at 12 40 00](https://github.com/calcom/cal.com/assets/30310907/752ed978-83cf-4ee9-a38d-b5795df6daec) After: ![Screenshot 2023-07-06 at 12 40 42](https://github.com/calcom/cal.com/assets/30310907/5d51ec15-5be8-4f3b-b374-46dad35216b8) ## Type of change - Bug fix (non-breaking change which fixes an issue) ## How should this be tested? - Check if slot times are shown as described - Test with different intervals/durations - Test with busy times - Test with different availabilities ## Mandatory Tasks - [x] Make sure you have self-reviewed the code. A decent size PR without self-review might be rejected.
2023-07-10 22:32:26 +00:00
//`05:00:00.000Z`, - Blocked with User 102 in event 2
`05:45:00.000Z`,
`06:30:00.000Z`,
`07:15:00.000Z`,
`08:00:00.000Z`,
`08:45:00.000Z`,
`09:30:00.000Z`,
`10:15:00.000Z`,
`11:00:00.000Z`,
`11:45:00.000Z`,
],
{ dateString: plus2DateString }
);
});
test("correctly identifies unavailable slots from calendar for all users in Round Robin scheduling, considers bookings of users in other events as well", async () => {
const { dateString: plus1DateString } = getDate({ dateIncrement: 1 });
const { dateString: plus2DateString } = getDate({ dateIncrement: 2 });
const { dateString: plus3DateString } = getDate({ dateIncrement: 3 });
createBookingScenario({
eventTypes: [
// An event having two users with one accepted booking
{
id: 1,
slotInterval: 45,
length: 45,
users: [
{
id: 101,
},
{
id: 102,
},
],
schedulingType: "ROUND_ROBIN",
},
{
id: 2,
slotInterval: 45,
length: 45,
users: [
{
id: 102,
},
],
},
],
users: [
{
...TestData.users.example,
id: 101,
schedules: [TestData.schedules.IstWorkHours],
},
{
...TestData.users.example,
id: 102,
schedules: [TestData.schedules.IstWorkHours],
},
],
bookings: [
{
userId: 101,
eventTypeId: 1,
status: "ACCEPTED",
startTime: `${plus2DateString}T04:00:00.000Z`,
endTime: `${plus2DateString}T04:15:00.000Z`,
},
{
userId: 102,
eventTypeId: 2,
status: "ACCEPTED",
startTime: `${plus2DateString}T05:30:00.000Z`,
endTime: `${plus2DateString}T05:45:00.000Z`,
},
{
userId: 101,
eventTypeId: 1,
status: "ACCEPTED",
startTime: `${plus3DateString}T04:00:00.000Z`,
endTime: `${plus3DateString}T04:15:00.000Z`,
},
{
userId: 102,
eventTypeId: 2,
status: "ACCEPTED",
startTime: `${plus3DateString}T04:00:00.000Z`,
endTime: `${plus3DateString}T04:15:00.000Z`,
},
],
hosts: [],
});
const scheduleForTeamEventOnADayWithOneBookingForEachUserButOnDifferentTimeslots = await getSchedule({
eventTypeId: 1,
eventTypeSlug: "",
startTime: `${plus1DateString}T18:30:00.000Z`,
endTime: `${plus2DateString}T18:29:59.999Z`,
timeZone: Timezones["+5:30"],
});
// A user with blocked time in another event, still affects Team Event availability
expect(scheduleForTeamEventOnADayWithOneBookingForEachUserButOnDifferentTimeslots).toHaveTimeSlots(
[
`04:00:00.000Z`, // - Blocked with User 101 but free with User 102. Being RoundRobin it is still bookable
`04:45:00.000Z`,
`05:30:00.000Z`, // - Blocked with User 102 but free with User 101. Being RoundRobin it is still bookable
`06:15:00.000Z`,
`07:00:00.000Z`,
`07:45:00.000Z`,
`08:30:00.000Z`,
`09:15:00.000Z`,
`10:00:00.000Z`,
`10:45:00.000Z`,
`11:30:00.000Z`,
],
{ dateString: plus2DateString }
);
const scheduleForTeamEventOnADayWithOneBookingForEachUserOnSameTimeSlot = await getSchedule({
eventTypeId: 1,
eventTypeSlug: "",
startTime: `${plus2DateString}T18:30:00.000Z`,
endTime: `${plus3DateString}T18:29:59.999Z`,
timeZone: Timezones["+5:30"],
});
// A user with blocked time in another event, still affects Team Event availability
expect(scheduleForTeamEventOnADayWithOneBookingForEachUserOnSameTimeSlot).toHaveTimeSlots(
[
//`04:00:00.000Z`, // - Blocked with User 101 as well as User 102, so not available in Round Robin
`04:15:00.000Z`,
`05:00:00.000Z`,
`05:45:00.000Z`,
`06:30:00.000Z`,
`07:15:00.000Z`,
`08:00:00.000Z`,
`08:45:00.000Z`,
`09:30:00.000Z`,
`10:15:00.000Z`,
`11:00:00.000Z`,
`11:45:00.000Z`,
],
{ dateString: plus3DateString }
);
});
});
});
function getGoogleCalendarCredential() {
return {
type: "google_calendar",
key: {
scope:
"https://www.googleapis.com/auth/calendar.events https://www.googleapis.com/auth/calendar.readonly",
token_type: "Bearer",
expiry_date: 1656999025367,
access_token: "ACCESS_TOKEN",
refresh_token: "REFRESH_TOKEN",
},
};
}
function addEventTypes(eventTypes: InputEventType[], usersStore: InputUser[]) {
const baseEventType = {
title: "Base EventType Title",
slug: "base-event-type-slug",
timeZone: null,
beforeEventBuffer: 0,
afterEventBuffer: 0,
schedulingType: null,
//TODO: What is the purpose of periodStartDate and periodEndDate? Test these?
periodStartDate: new Date("2022-01-21T09:03:48.000Z"),
periodEndDate: new Date("2022-01-21T09:03:48.000Z"),
periodCountCalendarDays: false,
periodDays: 30,
seatsPerTimeSlot: null,
metadata: {},
minimumBookingNotice: 0,
offsetStart: 0,
};
const foundEvents: Record<number, boolean> = {};
const eventTypesWithUsers = eventTypes.map((eventType) => {
if (!eventType.slotInterval && !eventType.length) {
throw new Error("eventTypes[number]: slotInterval or length must be defined");
}
if (foundEvents[eventType.id]) {
throw new Error(`eventTypes[number]: id ${eventType.id} is not unique`);
}
foundEvents[eventType.id] = true;
const users =
eventType.users?.map((userWithJustId) => {
return usersStore.find((user) => user.id === userWithJustId.id);
}) || [];
return {
...baseEventType,
...eventType,
users,
};
});
logger.silly("TestData: Creating EventType", eventTypes);
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
prismaMock.eventType.findUnique.mockImplementation(({ where }) => {
return new Promise((resolve) => {
const eventType = eventTypesWithUsers.find((e) => e.id === where.id) as unknown as PrismaEventType & {
users: PrismaUser[];
};
resolve(eventType);
});
});
}
async function addBookings(bookings: InputBooking[], eventTypes: InputEventType[]) {
logger.silly("TestData: Creating Bookings", bookings);
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
prismaMock.booking.findMany.mockImplementation((findManyArg) => {
const where = findManyArg?.where || {};
return new Promise((resolve) => {
resolve(
bookings
// We can improve this filter to support the entire where clause but that isn't necessary yet. So, handle what we know we pass to `findMany` and is needed
.filter((booking) => {
/**
* A user is considered busy within a given time period if there
* is a booking they own OR host. This function mocks some of the logic
* for each condition. For details see the following ticket:
* https://github.com/calcom/cal.com/issues/6374
*/
// ~~ FIRST CONDITION ensures that this booking is owned by this user
// and that the status is what we want
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
const statusIn = where.OR[0].status?.in || [];
const firstConditionMatches =
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
statusIn.includes(booking.status) && booking.userId === where.OR[0].userId;
// We return this booking if either condition is met
return firstConditionMatches;
})
.map((booking) => ({
uid: uuidv4(),
title: "Test Booking Title",
...booking,
eventType: eventTypes.find((eventType) => eventType.id === booking.eventTypeId),
})) as unknown as PrismaBooking[]
);
});
});
}
function addUsers(users: InputUser[]) {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
prismaMock.user.findUniqueOrThrow.mockImplementation((findUniqueArgs) => {
return new Promise((resolve) => {
resolve({
email: `IntegrationTestUser${findUniqueArgs?.where.id}@example.com`,
} as unknown as PrismaUser);
});
});
prismaMock.user.findMany.mockResolvedValue(
users.map((user) => {
return {
...user,
username: `IntegrationTestUser${user.id}`,
email: `IntegrationTestUser${user.id}@example.com`,
};
}) as unknown as PrismaUser[]
);
}
type ScenarioData = {
// TODO: Support multiple bookings and add tests with that.
bookings?: InputBooking[];
users: InputUser[];
hosts: InputHost[];
credentials?: InputCredential[];
apps?: App[];
selectedCalendars?: InputSelectedCalendar[];
eventTypes: InputEventType[];
calendarBusyTimes?: {
start: string;
end: string;
}[];
};
function createBookingScenario(data: ScenarioData) {
logger.silly("TestData: Creating Scenario", data);
addUsers(data.users);
const eventType = addEventTypes(data.eventTypes, data.users);
if (data.apps) {
prismaMock.app.findMany.mockResolvedValue(data.apps as PrismaApp[]);
// FIXME: How do we know which app to return?
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
prismaMock.app.findUnique.mockImplementation(({ where: { slug: whereSlug } }) => {
return new Promise((resolve) => {
if (!data.apps) {
resolve(null);
return;
}
resolve((data.apps.find(({ slug }) => slug == whereSlug) as PrismaApp) || null);
});
});
}
data.bookings = data.bookings || [];
addBookings(data.bookings, data.eventTypes);
return {
eventType,
};
}
/**
* This fn indents to dynamically compute day, month, year for the purpose of testing.
* We are not using DayJS because that's actually being tested by this code.
* - `dateIncrement` adds the increment to current day
* - `monthIncrement` adds the increment to current month
* - `yearIncrement` adds the increment to current year
*/
const getDate = (param: { dateIncrement?: number; monthIncrement?: number; yearIncrement?: number } = {}) => {
let { dateIncrement, monthIncrement, yearIncrement } = param;
dateIncrement = dateIncrement || 0;
monthIncrement = monthIncrement || 0;
yearIncrement = yearIncrement || 0;
let _date = new Date().getDate() + dateIncrement;
let year = new Date().getFullYear() + yearIncrement;
// Make it start with 1 to match with DayJS requiremet
let _month = new Date().getMonth() + monthIncrement + 1;
// If last day of the month(As _month is plus 1 already it is going to be the 0th day of next month which is the last day of current month)
const lastDayOfMonth = new Date(year, _month, 0).getDate();
const numberOfDaysForNextMonth = +_date - +lastDayOfMonth;
if (numberOfDaysForNextMonth > 0) {
_date = numberOfDaysForNextMonth;
_month = _month + 1;
}
if (_month === 13) {
_month = 1;
year = year + 1;
}
const date = _date < 10 ? "0" + _date : _date;
const month = _month < 10 ? "0" + _month : _month;
return {
date,
month,
year,
dateString: `${year}-${month}-${date}`,
};
};