From 471420c1d4f76953f72b1ca82017a7bd3c7bbcec Mon Sep 17 00:00:00 2001 From: Hariom Balhara Date: Thu, 21 Jul 2022 22:14:23 +0530 Subject: [PATCH] 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 --- apps/web/jest.config.ts | 2 +- apps/web/package.json | 4 +- .../playwright/embed-code-generator.test.ts | 10 +- apps/web/playwright/event-types.test.ts | 10 +- apps/web/server/routers/viewer/slots.tsx | 338 +++++----- apps/web/test/.env.test.example | 3 + apps/web/test/README.md | 12 + apps/web/test/docker-compose.yml | 15 + apps/web/test/lib/getSchedule.test.ts | 579 ++++++++++++++++++ package.json | 2 +- packages/core/CalendarManager.ts | 13 +- packages/core/getBusyTimes.ts | 10 +- packages/lib/availability.ts | 2 +- packages/lib/logger.ts | 1 - packages/prisma/package.json | 3 +- packages/prisma/seed.ts | 3 +- turbo.json | 15 +- yarn.lock | 34 +- 18 files changed, 870 insertions(+), 186 deletions(-) create mode 100644 apps/web/test/.env.test.example create mode 100644 apps/web/test/README.md create mode 100644 apps/web/test/docker-compose.yml create mode 100644 apps/web/test/lib/getSchedule.test.ts diff --git a/apps/web/jest.config.ts b/apps/web/jest.config.ts index 2f719b82db..92fe6767d7 100644 --- a/apps/web/jest.config.ts +++ b/apps/web/jest.config.ts @@ -3,7 +3,7 @@ import type { Config } from "@jest/types"; const config: Config.InitialOptions = { verbose: true, roots: [""], - testMatch: ["**/tests/**/*.+(ts|tsx|js)", "**/?(*.)+(spec|test).+(ts|tsx|js)"], + testMatch: ["**/test/lib/**/*.(spec|test).(ts|tsx|js)"], testPathIgnorePatterns: ["/.next", "/playwright/"], transform: { "^.+\\.(js|jsx|ts|tsx)$": ["babel-jest", { presets: ["next/babel"] }], diff --git a/apps/web/package.json b/apps/web/package.json index b54cbbf5e6..5721dc1988 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -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", diff --git a/apps/web/playwright/embed-code-generator.test.ts b/apps/web/playwright/embed-code-generator.test.ts index 4950a706ed..f0582e3a1e 100644 --- a/apps/web/playwright/embed-code-generator.test.ts +++ b/apps/web/playwright/embed-code-generator.test.ts @@ -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", }); }); }); diff --git a/apps/web/playwright/event-types.test.ts b/apps/web/playwright/event-types.test.ts index bfd39ab2c1..db24426031 100644 --- a/apps/web/playwright/event-types.test.ts +++ b/apps/web/playwright/event-types.test.ts @@ -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); diff --git a/apps/web/server/routers/viewer/slots.tsx b/apps/web/server/routers/viewer/slots.tsx index 58fcef5064..3ee1a7c11d 100644 --- a/apps/web/server/routers/viewer/slots.tsx +++ b/apps/web/server/routers/viewer/slots.tsx @@ -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 = {}; + const availabilityCheckProps = { + eventLength: eventType.length, + beforeBufferTime: eventType.beforeEventBuffer, + currentSeats, + }; + const isWithinBounds = (_time: Parameters[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 = {}; - const availabilityCheckProps = { - eventLength: eventType.length, - beforeBufferTime: eventType.beforeEventBuffer, - currentSeats, - }; - const isWithinBounds = (_time: Parameters[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, + }; +} diff --git a/apps/web/test/.env.test.example b/apps/web/test/.env.test.example new file mode 100644 index 0000000000..ee9ed1cf0e --- /dev/null +++ b/apps/web/test/.env.test.example @@ -0,0 +1,3 @@ +DATABASE_URL="postgresql://prisma:prisma@localhost:5433/tests" +NEXT_PUBLIC_WEBAPP_URL="http://localhost:3000" +INTEGRATION_TEST_MODE=true \ No newline at end of file diff --git a/apps/web/test/README.md b/apps/web/test/README.md new file mode 100644 index 0000000000..18cf96aa3b --- /dev/null +++ b/apps/web/test/README.md @@ -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 \ No newline at end of file diff --git a/apps/web/test/docker-compose.yml b/apps/web/test/docker-compose.yml new file mode 100644 index 0000000000..769adab097 --- /dev/null +++ b/apps/web/test/docker-compose.yml @@ -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 \ No newline at end of file diff --git a/apps/web/test/lib/getSchedule.test.ts b/apps/web/test/lib/getSchedule.test.ts new file mode 100644 index 0000000000..bf1d590f69 --- /dev/null +++ b/apps/web/test/lib/getSchedule.test.ts @@ -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 { + 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; + 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 } + ); + }); + }); +}); diff --git a/package.json b/package.json index 70d5b0141b..3b2dc17ce1 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/core/CalendarManager.ts b/packages/core/CalendarManager.ts index a4192f1295..ce24f0883b 100644 --- a/packages/core/CalendarManager.ts +++ b/packages/core/CalendarManager.ts @@ -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); diff --git a/packages/core/getBusyTimes.ts b/packages/core/getBusyTimes.ts index 30397f85ac..08dfaea5f9 100644 --- a/packages/core/getBusyTimes.ts +++ b/packages/core/getBusyTimes.ts @@ -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; } diff --git a/packages/lib/availability.ts b/packages/lib/availability.ts index 6a472c54a4..6cf59161fa 100644 --- a/packages/lib/availability.ts +++ b/packages/lib/availability.ts @@ -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, diff --git a/packages/lib/logger.ts b/packages/lib/logger.ts index b6ebce421e..9cbcc05088 100644 --- a/packages/lib/logger.ts +++ b/packages/lib/logger.ts @@ -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", diff --git a/packages/prisma/package.json b/packages/prisma/package.json index c0fd7502bc..54592967f3 100644 --- a/packages/prisma/package.json +++ b/packages/prisma/package.json @@ -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", diff --git a/packages/prisma/seed.ts b/packages/prisma/seed.ts index c9eb8436b9..30c7991f1f 100644 --- a/packages/prisma/seed.ts +++ b/packages/prisma/seed.ts @@ -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({ diff --git a/turbo.json b/turbo.json index 8008d03643..0ca5e68b52 100644 --- a/turbo.json +++ b/turbo.json @@ -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"] } diff --git a/yarn.lock b/yarn.lock index 6385afb5ab..95e7332f1e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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"