Add getSchedule tests (#3233)

* Add getSchedule tests

* Add first integration test

* Update turbo.json

* Make sure unit tests run asap

* Worker threads

* Improve team event test

* Remove unrelated changes

* Improve tests readability

* Update CalendarManager.ts

* Add README

* Debug tests

* Temporarily disabled build errors

* Fix failing tests

* Remove unncessary logs

Co-authored-by: zomars <zomars@me.com>
This commit is contained in:
Hariom Balhara 2022-07-21 22:14:23 +05:30 committed by GitHub
parent 2eedf74eb7
commit 471420c1d4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 870 additions and 186 deletions

View File

@ -3,7 +3,7 @@ import type { Config } from "@jest/types";
const config: Config.InitialOptions = {
verbose: true,
roots: ["<rootDir>"],
testMatch: ["**/tests/**/*.+(ts|tsx|js)", "**/?(*.)+(spec|test).+(ts|tsx|js)"],
testMatch: ["**/test/lib/**/*.(spec|test).(ts|tsx|js)"],
testPathIgnorePatterns: ["<rootDir>/.next", "<rootDir>/playwright/"],
transform: {
"^.+\\.(js|jsx|ts|tsx)$": ["babel-jest", { presets: ["next/babel"] }],

View File

@ -9,7 +9,8 @@
"clean": "rm -rf .turbo && rm -rf node_modules && rm -rf .next",
"dev": "next dev",
"dx": "yarn dev",
"test": "jest",
"test": "dotenv -e ./test/.env.test -- jest",
"db-setup-tests": "dotenv -e ./test/.env.test -- yarn workspace @calcom/prisma prisma migrate deploy",
"test-e2e": "cd ../.. && yarn playwright test --config=tests/config/playwright.config.ts --project=chromium",
"playwright-report": "playwright show-report playwright/reports/playwright-html-report",
"test-codegen": "yarn playwright codegen http://localhost:3000",
@ -89,6 +90,7 @@
"next-mdx-remote": "^4.0.3",
"next-seo": "^4.26.0",
"next-transpile-modules": "^9.0.0",
"nock": "^13.2.8",
"nodemailer": "^6.7.5",
"otplib": "^12.0.1",
"qrcode": "^1.5.0",

View File

@ -158,8 +158,10 @@ test.describe("Embed Code Generator Tests", () => {
});
test.describe("Event Type Edit Page", () => {
//TODO: Instead of hardcoding, browse through actual events, as this ID might change in future
const sixtyMinProEventId = "6";
test.beforeEach(async ({ page }) => {
await page.goto("/event-types/3");
await page.goto(`/event-types/${sixtyMinProEventId}`);
});
test("open Embed Dialog for the Event Type", async ({ page }) => {
@ -167,14 +169,14 @@ test.describe("Embed Code Generator Tests", () => {
await expectToBeNavigatingToEmbedTypesDialog(page, {
eventTypeId,
basePage: "/event-types/3",
basePage: `/event-types/${sixtyMinProEventId}`,
});
chooseEmbedType(page, "inline");
await expectToBeNavigatingToEmbedCodeAndPreviewDialog(page, {
eventTypeId,
basePage: "/event-types/3",
basePage: `/event-types/${sixtyMinProEventId}`,
embedType: "inline",
});
@ -186,7 +188,7 @@ test.describe("Embed Code Generator Tests", () => {
await expectToContainValidPreviewIframe(page, {
embedType: "inline",
calLink: "pro/30min",
calLink: "pro/60min",
});
});
});

View File

@ -91,12 +91,14 @@ test.describe("Event Types tests", () => {
});
test("can duplicate an existing event type", async ({ page }) => {
const firstTitle = await page.locator("[data-testid=event-type-title-3]").innerText();
const firstFullSlug = await page.locator("[data-testid=event-type-slug-3]").innerText();
// TODO: Locate the actual EventType available in list. This ID might change in future
const eventTypeId = "6";
const firstTitle = await page.locator(`[data-testid=event-type-title-${eventTypeId}]`).innerText();
const firstFullSlug = await page.locator(`[data-testid=event-type-slug-${eventTypeId}]`).innerText();
const firstSlug = firstFullSlug.split("/")[2];
await page.click("[data-testid=event-type-options-3]");
await page.click("[data-testid=event-type-duplicate-3]");
await page.click(`[data-testid=event-type-options-${eventTypeId}]`);
await page.click(`[data-testid=event-type-duplicate-${eventTypeId}]`);
const url = page.url();
const params = new URLSearchParams(url);

View File

@ -5,6 +5,7 @@ import type { CurrentSeats } from "@calcom/core/getUserAvailability";
import { getUserAvailability } from "@calcom/core/getUserAvailability";
import dayjs, { Dayjs } from "@calcom/dayjs";
import logger from "@calcom/lib/logger";
import { prisma } from "@calcom/prisma";
import { availabilityUserSelect } from "@calcom/prisma";
import { TimeRange } from "@calcom/types/schedule";
@ -94,171 +95,192 @@ const checkForAvailability = ({
export const slotsRouter = createRouter().query("getSchedule", {
input: getScheduleSchema,
async resolve({ input, ctx }) {
if (input.debug === true) {
logger.setSettings({ minLevel: "debug" });
}
const startPrismaEventTypeGet = performance.now();
const eventType = await ctx.prisma.eventType.findUnique({
where: {
id: input.eventTypeId,
},
select: {
id: true,
minimumBookingNotice: true,
length: true,
seatsPerTimeSlot: true,
timeZone: true,
slotInterval: true,
beforeEventBuffer: true,
afterEventBuffer: true,
schedulingType: true,
periodType: true,
periodStartDate: true,
periodEndDate: true,
periodCountCalendarDays: true,
periodDays: true,
schedule: {
select: {
availability: true,
timeZone: true,
},
},
availability: {
select: {
startTime: true,
endTime: true,
days: true,
},
},
users: {
select: {
username: true,
...availabilityUserSelect,
},
return await getSchedule(input, ctx);
},
});
export async function getSchedule(
input: {
timeZone?: string | undefined;
eventTypeId?: number | undefined;
usernameList?: string[] | undefined;
debug?: boolean | undefined;
startTime: string;
endTime: string;
},
ctx: { prisma: typeof prisma }
) {
if (input.debug === true) {
logger.setSettings({ minLevel: "debug" });
}
if (process.env.INTEGRATION_TEST_MODE === "true") {
logger.setSettings({ minLevel: "silly" });
}
const startPrismaEventTypeGet = performance.now();
const eventType = await ctx.prisma.eventType.findUnique({
where: {
id: input.eventTypeId,
},
select: {
id: true,
minimumBookingNotice: true,
length: true,
seatsPerTimeSlot: true,
timeZone: true,
slotInterval: true,
beforeEventBuffer: true,
afterEventBuffer: true,
schedulingType: true,
periodType: true,
periodStartDate: true,
periodEndDate: true,
periodCountCalendarDays: true,
periodDays: true,
schedule: {
select: {
availability: true,
timeZone: true,
},
},
availability: {
select: {
startTime: true,
endTime: true,
days: true,
},
},
users: {
select: {
username: true,
...availabilityUserSelect,
},
},
},
});
const endPrismaEventTypeGet = performance.now();
logger.debug(
`Prisma eventType get took ${endPrismaEventTypeGet - startPrismaEventTypeGet}ms for event:${
input.eventTypeId
}`
);
if (!eventType) {
throw new TRPCError({ code: "NOT_FOUND" });
}
const startTime =
input.timeZone === "Etc/GMT"
? dayjs.utc(input.startTime)
: dayjs(input.startTime).utc().tz(input.timeZone);
const endTime =
input.timeZone === "Etc/GMT" ? dayjs.utc(input.endTime) : dayjs(input.endTime).utc().tz(input.timeZone);
if (!startTime.isValid() || !endTime.isValid()) {
throw new TRPCError({ message: "Invalid time range given.", code: "BAD_REQUEST" });
}
let currentSeats: CurrentSeats | undefined = undefined;
const userSchedules = await Promise.all(
eventType.users.map(async (currentUser) => {
const {
busy,
workingHours,
currentSeats: _currentSeats,
} = await getUserAvailability(
{
userId: currentUser.id,
dateFrom: startTime.format(),
dateTo: endTime.format(),
eventTypeId: input.eventTypeId,
afterEventBuffer: eventType.afterEventBuffer,
},
{ user: currentUser, eventType, currentSeats }
);
if (!currentSeats && _currentSeats) currentSeats = _currentSeats;
return {
workingHours,
busy,
};
})
);
const workingHours = userSchedules.flatMap((s) => s.workingHours);
const slots: Record<string, Slot[]> = {};
const availabilityCheckProps = {
eventLength: eventType.length,
beforeBufferTime: eventType.beforeEventBuffer,
currentSeats,
};
const isWithinBounds = (_time: Parameters<typeof isOutOfBounds>[0]) =>
!isOutOfBounds(_time, {
periodType: eventType.periodType,
periodStartDate: eventType.periodStartDate,
periodEndDate: eventType.periodEndDate,
periodCountCalendarDays: eventType.periodCountCalendarDays,
periodDays: eventType.periodDays,
});
const endPrismaEventTypeGet = performance.now();
logger.debug(`Prisma eventType get took ${endPrismaEventTypeGet - startPrismaEventTypeGet}ms`);
if (!eventType) {
throw new TRPCError({ code: "NOT_FOUND" });
}
const startTime =
input.timeZone === "Etc/GMT"
? dayjs.utc(input.startTime)
: dayjs(input.startTime).utc().tz(input.timeZone);
const endTime =
input.timeZone === "Etc/GMT" ? dayjs.utc(input.endTime) : dayjs(input.endTime).utc().tz(input.timeZone);
let time = startTime;
let getSlotsTime = 0;
let checkForAvailabilityTime = 0;
let getSlotsCount = 0;
let checkForAvailabilityCount = 0;
do {
const startGetSlots = performance.now();
// get slots retrieves the available times for a given day
const times = getSlots({
inviteeDate: time,
eventLength: eventType.length,
workingHours,
minimumBookingNotice: eventType.minimumBookingNotice,
frequency: eventType.slotInterval || eventType.length,
});
const endGetSlots = performance.now();
getSlotsTime += endGetSlots - startGetSlots;
getSlotsCount++;
// if ROUND_ROBIN - slots stay available on some() - if normal / COLLECTIVE - slots only stay available on every()
const filterStrategy =
!eventType.schedulingType || eventType.schedulingType === SchedulingType.COLLECTIVE
? ("every" as const)
: ("some" as const);
if (!startTime.isValid() || !endTime.isValid()) {
throw new TRPCError({ message: "Invalid time range given.", code: "BAD_REQUEST" });
}
let currentSeats: CurrentSeats | undefined = undefined;
const userSchedules = await Promise.all(
eventType.users.map(async (currentUser) => {
const {
busy,
workingHours,
currentSeats: _currentSeats,
} = await getUserAvailability(
{
userId: currentUser.id,
dateFrom: startTime.format(),
dateTo: endTime.format(),
eventTypeId: input.eventTypeId,
afterEventBuffer: eventType.afterEventBuffer,
},
{ user: currentUser, eventType, currentSeats }
);
if (!currentSeats && _currentSeats) currentSeats = _currentSeats;
return {
workingHours,
busy,
};
const filteredTimes = times.filter(isWithinBounds).filter((time) =>
userSchedules[filterStrategy]((schedule) => {
const startCheckForAvailability = performance.now();
const result = checkForAvailability({ time, ...schedule, ...availabilityCheckProps });
const endCheckForAvailability = performance.now();
checkForAvailabilityCount++;
checkForAvailabilityTime += endCheckForAvailability - startCheckForAvailability;
return result;
})
);
const workingHours = userSchedules.flatMap((s) => s.workingHours);
slots[time.format("YYYY-MM-DD")] = filteredTimes.map((time) => ({
time: time.toISOString(),
users: eventType.users.map((user) => user.username || ""),
// Conditionally add the attendees and booking id to slots object if there is already a booking during that time
...(currentSeats?.some((booking) => booking.startTime.toISOString() === time.toISOString()) && {
attendees:
currentSeats[
currentSeats.findIndex((booking) => booking.startTime.toISOString() === time.toISOString())
]._count.attendees,
bookingUid:
currentSeats[
currentSeats.findIndex((booking) => booking.startTime.toISOString() === time.toISOString())
].uid,
}),
}));
time = time.add(1, "day");
} while (time.isBefore(endTime));
const slots: Record<string, Slot[]> = {};
const availabilityCheckProps = {
eventLength: eventType.length,
beforeBufferTime: eventType.beforeEventBuffer,
currentSeats,
};
const isWithinBounds = (_time: Parameters<typeof isOutOfBounds>[0]) =>
!isOutOfBounds(_time, {
periodType: eventType.periodType,
periodStartDate: eventType.periodStartDate,
periodEndDate: eventType.periodEndDate,
periodCountCalendarDays: eventType.periodCountCalendarDays,
periodDays: eventType.periodDays,
});
logger.debug(`getSlots took ${getSlotsTime}ms and executed ${getSlotsCount} times`);
let time = startTime;
let getSlotsTime = 0;
let checkForAvailabilityTime = 0;
let getSlotsCount = 0;
let checkForAvailabilityCount = 0;
do {
const startGetSlots = performance.now();
// get slots retrieves the available times for a given day
const times = getSlots({
inviteeDate: time,
eventLength: eventType.length,
workingHours,
minimumBookingNotice: eventType.minimumBookingNotice,
frequency: eventType.slotInterval || eventType.length,
});
const endGetSlots = performance.now();
getSlotsTime += endGetSlots - startGetSlots;
getSlotsCount++;
// if ROUND_ROBIN - slots stay available on some() - if normal / COLLECTIVE - slots only stay available on every()
const filterStrategy =
!eventType.schedulingType || eventType.schedulingType === SchedulingType.COLLECTIVE
? ("every" as const)
: ("some" as const);
const filteredTimes = times.filter(isWithinBounds).filter((time) =>
userSchedules[filterStrategy]((schedule) => {
const startCheckForAvailability = performance.now();
const result = checkForAvailability({ time, ...schedule, ...availabilityCheckProps });
const endCheckForAvailability = performance.now();
checkForAvailabilityCount++;
checkForAvailabilityTime += endCheckForAvailability - startCheckForAvailability;
return result;
})
);
slots[time.format("YYYY-MM-DD")] = filteredTimes.map((time) => ({
time: time.toISOString(),
users: eventType.users.map((user) => user.username || ""),
// Conditionally add the attendees and booking id to slots object if there is already a booking during that time
...(currentSeats?.some((booking) => booking.startTime.toISOString() === time.toISOString()) && {
attendees:
currentSeats[
currentSeats.findIndex((booking) => booking.startTime.toISOString() === time.toISOString())
]._count.attendees,
bookingUid:
currentSeats[
currentSeats.findIndex((booking) => booking.startTime.toISOString() === time.toISOString())
].uid,
}),
}));
time = time.add(1, "day");
} while (time.isBefore(endTime));
logger.debug(`getSlots took ${getSlotsTime}ms and executed ${getSlotsCount} times`);
logger.debug(
`checkForAvailability took ${checkForAvailabilityTime}ms and executed ${checkForAvailabilityCount} times`
);
return {
slots,
};
},
});
logger.debug(
`checkForAvailability took ${checkForAvailabilityTime}ms and executed ${checkForAvailabilityCount} times`
);
logger.silly(`Available slots: ${JSON.stringify(slots)}`);
return {
slots,
};
}

View File

@ -0,0 +1,3 @@
DATABASE_URL="postgresql://prisma:prisma@localhost:5433/tests"
NEXT_PUBLIC_WEBAPP_URL="http://localhost:3000"
INTEGRATION_TEST_MODE=true

12
apps/web/test/README.md Normal file
View File

@ -0,0 +1,12 @@
# Unit and Integration Tests
Make sure you have copied .env.test.example to .env.test
You can run all jest tests as
`yarn test`
You can run tests matching specific description by following command
`yarn test -t getSchedule`
Tip: Use `--watchAll` flag to run tests on every change

View File

@ -0,0 +1,15 @@
# Set the version of docker compose to use
version: '3.9'
# The containers that compose the project
services:
db:
image: postgres:13
restart: always
container_name: integration-tests-prisma
ports:
- '5433:5432'
environment:
POSTGRES_USER: prisma
POSTGRES_PASSWORD: prisma
POSTGRES_DB: tests

View File

@ -0,0 +1,579 @@
import { Prisma } from "@prisma/client";
import nock from "nock";
import { v4 as uuidv4 } from "uuid";
import logger from "@calcom/lib/logger";
import prisma from "@calcom/prisma";
import { BookingStatus, PeriodType } from "@calcom/prisma/client";
import { getSchedule } from "../../server/routers/viewer/slots";
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, expectedSlots: string[], { dateString }: { dateString: string }) {
expect(schedule.slots[`${dateString}`]).toBeDefined();
expect(schedule.slots[`${dateString}`].map((slot: { time: string }) => slot.time)).toEqual(
expectedSlots.map((slotTime) => `${dateString}T${slotTime}`)
);
return {
pass: true,
message: () => "has correct timeslots ",
};
},
});
/**
* 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.
*/
const getDate = (param: { dateIncrement?: number; monthIncrement?: number; yearIncrement?: number } = {}) => {
let { dateIncrement, monthIncrement, yearIncrement } = param;
dateIncrement = dateIncrement || 0;
monthIncrement = monthIncrement || 0;
yearIncrement = yearIncrement || 0;
const year = new Date().getFullYear() + yearIncrement;
// Make it start with 1 to match with DayJS requiremet
let _month = new Date().getMonth() + monthIncrement + 1;
if (_month === 13) {
_month = 1;
}
const month = _month < 10 ? "0" + _month : _month;
let _date = new Date().getDate() + dateIncrement;
// 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)
if (_date === new Date(year, _month, 0).getDate()) {
_date = 1;
}
const date = _date < 10 ? "0" + _date : _date;
// console.log("Date, month, year:", date, month, year);
return {
date,
month,
year,
dateString: `${year}-${month}-${date}`,
};
};
const ctx = {
prisma,
};
type App = {
slug: string;
dirName: string;
};
type User = {
credentials?: Credential[];
selectedCalendars?: SelectedCalendar[];
};
type Credential = { key: any; type: string };
type SelectedCalendar = {
integration: string;
externalId: string;
};
type EventType = {
id?: number;
title?: string;
length: number;
periodType: PeriodType;
slotInterval: number;
minimumBookingNotice: number;
seatsPerTimeSlot?: number | null;
};
type Booking = {
userId: number;
eventTypeId: number;
startTime: string;
endTime: string;
title?: string;
status: BookingStatus;
};
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",
},
};
}
async function addEventTypeToDB(data: {
eventType: EventType;
selectedCalendars?: SelectedCalendar[];
credentials?: Credential[];
users?: User[];
usersConnectedToTheEvent?: { id: number }[];
numUsers?: number;
}) {
data.selectedCalendars = data.selectedCalendars || [];
data.credentials = data.credentials || [];
const userCreate = {
id: 100,
username: "hariom",
email: "hariombalhara@gmail.com",
schedules: {
create: {
name: "Schedule1",
availability: {
create: {
userId: null,
eventTypeId: null,
days: [0, 1, 2, 3, 4, 5, 6],
startTime: "1970-01-01T09:30:00.000Z",
endTime: "1970-01-01T18:00:00.000Z",
date: null,
},
},
timeZone: "Asia/Kolkata",
},
},
};
const usersCreate: typeof userCreate[] = [];
if (!data.users && !data.numUsers && !data.usersConnectedToTheEvent) {
throw new Error("Either users, numUsers or usersConnectedToTheEvent must be provided");
}
if (!data.users && data.numUsers) {
data.users = [];
for (let i = 0; i < data.numUsers; i++) {
data.users.push({
credentials: undefined,
selectedCalendars: undefined,
});
}
}
if (data.users?.length) {
data.users.forEach((user, index) => {
const newUserCreate = {
...userCreate,
...user,
credentials: { create: user.credentials },
selectedCalendars: { create: user.selectedCalendars },
};
newUserCreate.id = index + 1;
newUserCreate.username = `IntegrationTestUser${newUserCreate.id}`;
newUserCreate.email = `IntegrationTestUser${newUserCreate.id}@example.com`;
usersCreate.push(newUserCreate);
});
} else {
usersCreate.push({ ...userCreate });
}
const prismaData: Prisma.EventTypeCreateArgs["data"] = {
title: "Test EventType Title",
slug: "testslug",
timeZone: null,
beforeEventBuffer: 0,
afterEventBuffer: 0,
schedulingType: null,
periodStartDate: "2022-01-21T09:03:48.000Z",
periodEndDate: "2022-01-21T09:03:48.000Z",
periodCountCalendarDays: false,
periodDays: 30,
users: {
create: usersCreate,
connect: data.usersConnectedToTheEvent,
},
...data.eventType,
};
logger.silly("TestData: Creating EventType", prismaData);
return await prisma.eventType.create({
data: prismaData,
select: {
id: true,
users: true,
},
});
}
async function addBookingToDB(data: { booking: Booking }) {
const prismaData = {
uid: uuidv4(),
title: "Test Booking Title",
...data.booking,
};
logger.silly("TestData: Creating Booking", prismaData);
return await prisma.booking.create({
data: prismaData,
});
}
async function createBookingScenario(data: {
booking?: Omit<Booking, "eventTypeId" | "userId">;
users?: User[];
numUsers?: number;
credentials?: Credential[];
apps?: App[];
selectedCalendars?: SelectedCalendar[];
eventType: EventType;
/**
* User must already be existing
* */
usersConnectedToTheEvent?: { id: number }[];
}) {
// if (!data.eventType.userId) {
// data.eventType.userId =
// (data.users ? data.users[0]?.id : null) || data.usersConnect ? data.usersConnect[0]?.id : null;
// }
const eventType = await addEventTypeToDB(data);
if (data.apps) {
await prisma.app.createMany({
data: data.apps,
});
}
if (data.booking) {
// TODO: What about if there are multiple users of the eventType?
const userId = eventType.users[0].id;
const eventTypeId = eventType.id;
await addBookingToDB({ ...data, booking: { ...data.booking, userId, eventTypeId } });
}
return {
eventType,
};
}
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", () => {
describe("User Event", () => {
test("correctly identifies unavailable slots from Cal Bookings", async () => {
// const { dateString: todayDateString } = getDate();
const { dateString: plus1DateString } = getDate({ dateIncrement: 1 });
const { dateString: plus2DateString } = getDate({ dateIncrement: 2 });
const { dateString: plus3DateString } = getDate({ dateIncrement: 3 });
// An event with one accepted booking
const { eventType } = await createBookingScenario({
eventType: {
minimumBookingNotice: 1440,
length: 30,
slotInterval: 45,
periodType: "UNLIMITED" as PeriodType,
},
numUsers: 1,
booking: {
status: "ACCEPTED",
startTime: `${plus3DateString}T04:00:00.000Z`,
endTime: `${plus3DateString}T04:15:00.000Z`,
},
});
// const scheduleLyingWithinMinBookingNotice = await getSchedule(
// {
// eventTypeId: eventType.id,
// startTime: `${todayDateString}T18:30:00.000Z`,
// endTime: `${plus1DateString}T18:29:59.999Z`,
// timeZone: "Asia/Kolkata",
// },
// ctx
// );
// expect(scheduleLyingWithinMinBookingNotice).toHaveTimeSlots([], {
// dateString: plus1DateString,
// });
const scheduleOnCompletelyFreeDay = await getSchedule(
{
eventTypeId: eventType.id,
startTime: `${plus1DateString}T18:30:00.000Z`,
endTime: `${plus2DateString}T18:29:59.999Z`,
timeZone: "Asia/Kolkata",
},
ctx
);
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,
}
);
const scheduleForDayWithOneBooking = await getSchedule(
{
eventTypeId: eventType.id,
startTime: `${plus2DateString}T18:30:00.000Z`,
endTime: `${plus3DateString}T18:29:59.999Z`,
timeZone: "Asia/Kolkata", // GMT+5:30
},
ctx
);
expect(scheduleForDayWithOneBooking).toHaveTimeSlots(
[
"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: plus3DateString,
}
);
});
test("correctly identifies unavailable slots from calendar", async () => {
const { dateString: plus1DateString } = getDate({ dateIncrement: 1 });
const { dateString: plus2DateString } = getDate({ dateIncrement: 2 });
// An event with one accepted booking
const { eventType } = await createBookingScenario({
eventType: {
minimumBookingNotice: 1440,
length: 30,
slotInterval: 45,
periodType: "UNLIMITED" as PeriodType,
seatsPerTimeSlot: null,
},
users: [
{
credentials: [getGoogleCalendarCredential()],
selectedCalendars: [
{
integration: "google_calendar",
externalId: "john@example.com",
},
],
},
],
apps: [
{
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"],
},
},
],
});
nock("https://oauth2.googleapis.com").post("/token").reply(200, {
access_token: "access_token",
expiry_date: Infinity,
});
// Google Calendar with 11th July having many events
nock("https://www.googleapis.com")
.post("/calendar/v3/freeBusy")
.reply(200, {
calendars: [
{
busy: [
{
start: `${plus2DateString}T04:30:00.000Z`,
end: `${plus2DateString}T23:00:00.000Z`,
},
],
},
],
});
const scheduleForDayWithAGoogleCalendarBooking = await getSchedule(
{
eventTypeId: eventType.id,
startTime: `${plus1DateString}T18:30:00.000Z`,
endTime: `${plus2DateString}T18:29:59.999Z`,
timeZone: "Asia/Kolkata",
},
ctx
);
// As per Google Calendar Availability, only 4PM GMT slot would be available
expect(scheduleForDayWithAGoogleCalendarBooking).toHaveTimeSlots([`04:00:00.000Z`], {
dateString: plus2DateString,
});
});
});
describe("Team Event", () => {
test("correctly identifies unavailable slots from calendar", async () => {
const { dateString: todayDateString } = getDate();
const { dateString: plus1DateString } = getDate({ dateIncrement: 1 });
const { dateString: plus2DateString } = getDate({ dateIncrement: 2 });
// An event having two users with one accepted booking
const { eventType: teamEventType } = await createBookingScenario({
eventType: {
id: 1,
minimumBookingNotice: 0,
length: 30,
slotInterval: 45,
periodType: "UNLIMITED" as PeriodType,
seatsPerTimeSlot: null,
},
numUsers: 2,
booking: {
status: "ACCEPTED",
startTime: `${plus2DateString}T04:00:00.000Z`,
endTime: `${plus2DateString}T04:15:00.000Z`,
},
});
const scheduleForTeamEventOnADayWithNoBooking = await getSchedule(
{
eventTypeId: 1,
startTime: `${todayDateString}T18:30:00.000Z`,
endTime: `${plus1DateString}T18:29:59.999Z`,
timeZone: "Asia/Kolkata",
},
ctx
);
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 scheduleForTeamEventOnADayWithOneBooking = await getSchedule(
{
eventTypeId: 1,
startTime: `${plus1DateString}T18:30:00.000Z`,
endTime: `${plus2DateString}T18:29:59.999Z`,
timeZone: "Asia/Kolkata",
},
ctx
);
expect(scheduleForTeamEventOnADayWithOneBooking).toHaveTimeSlots(
[
`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 }
);
// An event with user 2 of team event
await createBookingScenario({
eventType: {
id: 2,
minimumBookingNotice: 0,
length: 30,
slotInterval: 45,
periodType: "UNLIMITED" as PeriodType,
seatsPerTimeSlot: null,
},
usersConnectedToTheEvent: [
{
id: teamEventType.users[1].id,
},
],
booking: {
status: "ACCEPTED",
startTime: `${plus2DateString}T05:30:00.000Z`,
endTime: `${plus2DateString}T05:45:00.000Z`,
},
});
const scheduleOfTeamEventHavingAUserWithBlockedTimeInAnotherEvent = await getSchedule(
{
eventTypeId: 1,
startTime: `${plus1DateString}T18:30:00.000Z`,
endTime: `${plus2DateString}T18:29:59.999Z`,
timeZone: "Asia/Kolkata",
},
ctx
);
// A user with blocked time in another event, doesn't impact Team Event availability
expect(scheduleOfTeamEventHavingAUserWithBlockedTimeInAnotherEvent).toHaveTimeSlots(
[
`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 }
);
});
});
});

View File

@ -42,7 +42,7 @@
"test-playwright": "yarn playwright test --config=tests/config/playwright.config.ts",
"embed-tests-quick": "turbo run embed-tests-quick",
"embed-tests": "turbo run embed-tests",
"test-e2e": "turbo run test-e2e --scope=\"@calcom/web\" --concurrency=1",
"test-e2e": "turbo run test --scope=\"@calcom/web\" && yarn turbo run test-e2e --scope=\"@calcom/web\" --concurrency=1",
"type-check": "turbo run type-check",
"app-store": "yarn workspace @calcom/app-store-cli cli",
"app-store:build": "yarn workspace @calcom/app-store-cli build",

View File

@ -101,20 +101,19 @@ const getCachedResults = async (
/** We extract external Ids so we don't cache too much */
const selectedCalendarIds = passedSelectedCalendars.map((sc) => sc.externalId);
/** We create a unque hash key based on the input data */
const cacheKey = createHash("md5")
.update(JSON.stringify({ id, selectedCalendarIds, dateFrom, dateTo }))
.digest("hex");
const cacheKey = JSON.stringify({ id, selectedCalendarIds, dateFrom, dateTo });
const cacheHashedKey = createHash("md5").update(cacheKey).digest("hex");
/** Check if we already have cached data and return */
const cachedAvailability = cache.get(cacheKey);
const cachedAvailability = cache.get(cacheHashedKey);
if (cachedAvailability) {
log.debug(`Cache HIT: Calendar Availability for key`, { id, selectedCalendarIds, dateFrom, dateTo });
log.debug(`Cache HIT: Calendar Availability for key: ${cacheKey}`);
return cachedAvailability;
}
log.debug(`Cache MISS: Calendar Availability for key`, { id, selectedCalendarIds, dateFrom, dateTo });
log.debug(`Cache MISS: Calendar Availability for key ${cacheKey}`);
/** If we don't then we actually fetch external calendars (which can be very slow) */
const availability = await c.getAvailability(dateFrom, dateTo, passedSelectedCalendars);
/** We save the availability to a few seconds so recurrent calls are nearly instant */
cache.put(cacheKey, availability, CACHING_TIME);
cache.put(cacheHashedKey, availability, CACHING_TIME);
return availability;
});
const awaitedResults = await Promise.all(results);

View File

@ -16,6 +16,13 @@ export async function getBusyTimes(params: {
selectedCalendars: SelectedCalendar[];
}) {
const { credentials, userId, eventTypeId, startTime, endTime, selectedCalendars } = params;
logger.silly(
`Checking Busy time from Cal Bookings in range ${startTime} to ${endTime} for input ${JSON.stringify({
userId,
eventTypeId,
status: BookingStatus.ACCEPTED,
})}`
);
const startPrismaBookingGet = performance.now();
const busyTimes: EventBusyDate[] = await prisma.booking
.findMany({
@ -29,11 +36,13 @@ export async function getBusyTimes(params: {
},
},
select: {
id: true,
startTime: true,
endTime: true,
},
})
.then((bookings) => bookings.map(({ startTime, endTime }) => ({ end: endTime, start: startTime })));
logger.silly(`Busy Time from Cal Bookings ${JSON.stringify(busyTimes)}`);
const endPrismaBookingGet = performance.now();
logger.debug(`prisma booking get took ${endPrismaBookingGet - startPrismaBookingGet}ms`);
if (credentials.length > 0) {
@ -46,7 +55,6 @@ export async function getBusyTimes(params: {
busyTimes.push(...videoBusyTimes);
*/
}
return busyTimes;
}

View File

@ -87,10 +87,10 @@ export function getWorkingHours(
utcOffset;
const endTime =
dayjs.utc(schedule.endTime).get("hour") * 60 + dayjs.utc(schedule.endTime).get("minute") - utcOffset;
// add to working hours, keeping startTime and endTimes between bounds (0-1439)
const sameDayStartTime = Math.max(MINUTES_DAY_START, Math.min(MINUTES_DAY_END, startTime));
const sameDayEndTime = Math.max(MINUTES_DAY_START, Math.min(MINUTES_DAY_END, endTime));
if (sameDayStartTime !== sameDayEndTime) {
workingHours.push({
days: schedule.days,

View File

@ -3,7 +3,6 @@ import { Logger } from "tslog";
import { IS_PRODUCTION } from "./constants";
const logger = new Logger({
minLevel: "info",
dateTimePattern: "hour:minute:second.millisecond timeZoneName",
displayFunctionName: false,
displayFilePath: "hidden",

View File

@ -28,7 +28,8 @@
},
"dependencies": {
"@calcom/lib": "*",
"@prisma/client": "^4.1.0"
"@prisma/client": "^4.1.0",
"dotenv-cli": "^6.0.0"
},
"main": "index.ts",
"types": "index.d.ts",

View File

@ -9,7 +9,6 @@ import prisma from ".";
import "./seed-app-store";
require("dotenv").config({ path: "../../.env" });
async function createUserAndEventType(opts: {
user: {
email: string;
@ -87,7 +86,7 @@ async function createUserAndEventType(opts: {
});
console.log(
`\t📆 Event type ${eventTypeData.slug}, length ${eventTypeData.length}min - ${process.env.NEXT_PUBLIC_WEBAPP_URL}/${user.username}/${eventTypeData.slug}`
`\t📆 Event type ${eventTypeData.slug} with id ${id}, length ${eventTypeData.length}min - ${process.env.NEXT_PUBLIC_WEBAPP_URL}/${user.username}/${eventTypeData.slug}`
);
for (const bookingInput of bookingInputs) {
await prisma.booking.create({

View File

@ -126,14 +126,24 @@
"test": {
"dependsOn": ["^test"]
},
"@calcom/web#db-setup-tests": {
"cache": false
},
"@calcom/web#test": {
"cache": false,
"dependsOn": ["@calcom/web#db-setup-tests"]
},
"test-e2e": {
"cache": false,
"dependsOn": ["@calcom/prisma#db-seed", "@calcom/web#test", "@calcom/web#build"]
"dependsOn": ["@calcom/prisma#db-seed", "@calcom/web#build"]
},
"type-check": {
"cache": false,
"outputs": []
},
"@calcom/prisma#db-reset": {
"cache": false
},
"@calcom/app-store-cli#build": {
"cache": false,
"inputs": ["../../app-store/**/**"],
@ -168,5 +178,6 @@
"inputs": ["./.env.appStore.example", "./.env.appStore"],
"outputs": ["./.env.appStore"]
}
}
},
"globalDependencies": ["yarn.lock"]
}

View File

@ -6744,12 +6744,27 @@ dotenv-checker@^1.1.5:
gradient-string "2.0.0"
inquirer "8.2.1"
dotenv-cli@^6.0.0:
version "6.0.0"
resolved "https://registry.yarnpkg.com/dotenv-cli/-/dotenv-cli-6.0.0.tgz#8a30cbc59d0a8aaa166b2fee0a9a55e23a1223ab"
integrity sha512-qXlCOi3UMDhCWFKe0yq5sg3X+pJAz+RQDiFN38AMSbUrnY3uZshSfDJUAge951OS7J9gwLZGfsBlWRSOYz/TRg==
dependencies:
cross-spawn "^7.0.3"
dotenv "^16.0.0"
dotenv-expand "^8.0.1"
minimist "^1.2.5"
dotenv-expand@^8.0.1:
version "8.0.3"
resolved "https://registry.yarnpkg.com/dotenv-expand/-/dotenv-expand-8.0.3.tgz#29016757455bcc748469c83a19b36aaf2b83dd6e"
integrity sha512-SErOMvge0ZUyWd5B0NXMQlDkN+8r+HhVUsxgOO7IoPDOdDRD2JjExpN6y3KnFR66jsJMwSn1pqIivhU5rcJiNg==
dotenv@^10.0.0:
version "10.0.0"
resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-10.0.0.tgz#3d4227b8fb95f81096cdd2b66653fb2c7085ba81"
integrity sha512-rlBi9d8jpv9Sf1klPjNfFAuWDjKLwTIJJ/VxtoTwIR6hnZxcEOQCZg2oIL3MWBYw5GpUDKOEnND7LXTbIpQ03Q==
dotenv@^16.0.1:
dotenv@^16.0.0, dotenv@^16.0.1:
version "16.0.1"
resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-16.0.1.tgz#8f8f9d94876c35dac989876a5d3a82a267fdce1d"
integrity sha512-1K6hR6wtk2FviQ4kEiSjFiH5rpzEVi8WW0x96aztHVMhEspNpc4DVOUTEHtEva5VThQ8IaBX1Pe4gSzpVVUsKQ==
@ -10966,7 +10981,7 @@ json-stable-stringify-without-jsonify@^1.0.1:
resolved "https://registry.yarnpkg.com/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz#9db7b59496ad3f3cfef30a75142d2d930ad72651"
integrity sha1-nbe1lJatPzz+8wp1FC0tkwrXJlE=
json-stringify-safe@~5.0.1:
json-stringify-safe@^5.0.1, json-stringify-safe@~5.0.1:
version "5.0.1"
resolved "https://registry.yarnpkg.com/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz#1296a2d58fd45f19a0f6ce01d65701e2c735b6eb"
integrity sha1-Epai1Y/UXxmg9s4B1lcB4sc1tus=
@ -12820,6 +12835,16 @@ nice-try@^1.0.4:
resolved "https://registry.yarnpkg.com/nice-try/-/nice-try-1.0.5.tgz#a3378a7696ce7d223e88fc9b764bd7ef1089e366"
integrity sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==
nock@^13.2.8:
version "13.2.8"
resolved "https://registry.yarnpkg.com/nock/-/nock-13.2.8.tgz#e2043ccaa8e285508274575e090a7fe1e46b90f1"
integrity sha512-JT42FrXfQRpfyL4cnbBEJdf4nmBpVP0yoCcSBr+xkT8Q1y3pgtaCKHGAAOIFcEJ3O3t0QbVAmid0S0f2bj3Wpg==
dependencies:
debug "^4.1.0"
json-stringify-safe "^5.0.1"
lodash "^4.17.21"
propagate "^2.0.0"
node-addon-api@^2.0.0:
version "2.0.2"
resolved "https://registry.yarnpkg.com/node-addon-api/-/node-addon-api-2.0.2.tgz#432cfa82962ce494b132e9d72a15b29f71ff5d32"
@ -14037,6 +14062,11 @@ prop-types@^15.0.0, prop-types@^15.5.8, prop-types@^15.6.0, prop-types@^15.6.2,
object-assign "^4.1.1"
react-is "^16.13.1"
propagate@^2.0.0:
version "2.0.1"
resolved "https://registry.yarnpkg.com/propagate/-/propagate-2.0.1.tgz#40cdedab18085c792334e64f0ac17256d38f9a45"
integrity sha512-vGrhOavPSTz4QVNuBNdcNXePNdNMaO1xj9yBeH1ScQPjk/rhg9sSlCXPhMkFuaNNW/syTvYqsnbIJxMBfRbbag==
property-expr@^2.0.4:
version "2.0.5"
resolved "https://registry.yarnpkg.com/property-expr/-/property-expr-2.0.5.tgz#278bdb15308ae16af3e3b9640024524f4dc02cb4"