diff --git a/apps/web/playwright/booking-limits.e2e.ts b/apps/web/playwright/booking-limits.e2e.ts new file mode 100644 index 0000000000..ff5ad02356 --- /dev/null +++ b/apps/web/playwright/booking-limits.e2e.ts @@ -0,0 +1,418 @@ +/** + * These e2e tests only aim to cover standard cases + * Edge cases are currently handled in integration tests only + */ +import { expect } from "@playwright/test"; + +import type { Dayjs } from "@calcom/dayjs"; +import dayjs from "@calcom/dayjs"; +import { intervalLimitKeyToUnit } from "@calcom/lib/intervalLimit"; +import { entries } from "@calcom/prisma/zod-utils"; +import type { IntervalLimit } from "@calcom/types/Calendar"; + +import { test } from "./lib/fixtures"; +import { bookTimeSlot, createUserWithLimits } from "./lib/testUtils"; + +test.describe.configure({ mode: "parallel" }); +test.afterEach(async ({ users }) => { + await users.deleteAll(); +}); + +// used as a multiplier for duration limits +const EVENT_LENGTH = 30; + +// limits used when testing each limit seperately +const BOOKING_LIMITS_SINGLE = { + PER_DAY: 2, + PER_WEEK: 2, + PER_MONTH: 2, + PER_YEAR: 2, +}; + +// limits used when testing multiple limits together +const BOOKING_LIMITS_MULTIPLE = { + PER_DAY: 1, + PER_WEEK: 2, + PER_MONTH: 3, + PER_YEAR: 4, +}; + +// prevent tests from crossing year boundaries - if currently in Oct or later, start booking in Jan instead of Nov +// (we increment months twice when checking multiple limits) +const firstDayInBookingMonth = + dayjs().month() >= 9 ? dayjs().add(1, "year").month(0).date(1) : dayjs().add(1, "month").date(1); + +// avoid weekly edge cases +const firstMondayInBookingMonth = firstDayInBookingMonth.day( + firstDayInBookingMonth.date() === firstDayInBookingMonth.startOf("week").date() ? 1 : 8 +); + +// ensure we land on the same weekday when incrementing month +const incrementDate = (date: Dayjs, unit: dayjs.ManipulateType) => { + if (unit !== "month") return date.add(1, unit); + return date.add(1, "month").day(date.day()); +}; + +const getLastEventUrlWithMonth = (user: Awaited>, date: Dayjs) => { + return `/${user.username}/${user.eventTypes.at(-1)?.slug}?month=${date.format("YYYY-MM")}`; +}; + +test.describe("Booking limits", () => { + entries(BOOKING_LIMITS_SINGLE).forEach(([limitKey, bookingLimit]) => { + const limitUnit = intervalLimitKeyToUnit(limitKey); + + // test one limit at a time + test(limitUnit, async ({ page, users }) => { + const slug = `booking-limit-${limitUnit}`; + const singleLimit = { [limitKey]: bookingLimit }; + + const user = await createUserWithLimits({ + users, + slug, + length: EVENT_LENGTH, + bookingLimits: singleLimit, + }); + + let slotUrl = ""; + + const monthUrl = getLastEventUrlWithMonth(user, firstMondayInBookingMonth); + await page.goto(monthUrl); + + const availableDays = page.locator('[data-testid="day"][data-disabled="false"]'); + const bookingDay = availableDays.getByText(firstMondayInBookingMonth.date().toString(), { + exact: true, + }); + + // finish rendering days before counting + await expect(bookingDay).toBeVisible({ timeout: 10_000 }); + const availableDaysBefore = await availableDays.count(); + + await test.step("can book up to limit", async () => { + for (let i = 0; i < bookingLimit; i++) { + await bookingDay.click(); + + await page.getByTestId("time").nth(0).click(); + await bookTimeSlot(page); + + slotUrl = page.url(); + + await expect(page.getByTestId("success-page")).toBeVisible(); + + await page.goto(monthUrl); + } + }); + + const expectedAvailableDays = { + day: -1, + week: -5, + month: 0, + year: 0, + }; + + await test.step("but not over", async () => { + // should already have navigated to monthUrl - just ensure days are rendered + await expect(page.getByTestId("day").nth(0)).toBeVisible(); + + // ensure the day we just booked is now blocked + await expect(bookingDay).toBeHidden({ timeout: 10_000 }); + + const availableDaysAfter = await availableDays.count(); + + // equals 0 if no available days, otherwise signed difference + expect(availableDaysAfter && availableDaysAfter - availableDaysBefore).toBe( + expectedAvailableDays[limitUnit] + ); + + // try to book directly via form page + await page.goto(slotUrl); + await bookTimeSlot(page); + + await expect(page.getByTestId("booking-fail")).toBeVisible({ timeout: 1000 }); + }); + + await test.step(`month after booking`, async () => { + await page.goto(getLastEventUrlWithMonth(user, firstMondayInBookingMonth.add(1, "month"))); + + // finish rendering days before counting + await expect(page.getByTestId("day").nth(0)).toBeVisible({ timeout: 10_000 }); + + // the month after we made bookings should have availability unless we hit a yearly limit + await expect((await availableDays.count()) === 0).toBe(limitUnit === "year"); + }); + }); + }); + + test("multiple", async ({ page, users }) => { + const slug = "booking-limit-multiple"; + + const user = await createUserWithLimits({ + users, + slug, + length: EVENT_LENGTH, + bookingLimits: BOOKING_LIMITS_MULTIPLE, + }); + + let slotUrl = ""; + + let bookingDate = firstMondayInBookingMonth; + + // keep track of total bookings across multiple limits + let bookingCount = 0; + + for (const [limitKey, limitValue] of entries(BOOKING_LIMITS_MULTIPLE)) { + const limitUnit = intervalLimitKeyToUnit(limitKey); + + const monthUrl = getLastEventUrlWithMonth(user, bookingDate); + await page.goto(monthUrl); + + const availableDays = page.locator('[data-testid="day"][data-disabled="false"]'); + const bookingDay = availableDays.getByText(bookingDate.date().toString(), { exact: true }); + + // finish rendering days before counting + await expect(bookingDay).toBeVisible({ timeout: 10_000 }); + + const availableDaysBefore = await availableDays.count(); + + await test.step(`can book up ${limitUnit} to limit`, async () => { + for (let i = 0; i + bookingCount < limitValue; i++) { + await bookingDay.click(); + + await page.getByTestId("time").nth(0).click(); + await bookTimeSlot(page); + bookingCount++; + + slotUrl = page.url(); + + await expect(page.getByTestId("success-page")).toBeVisible(); + + await page.goto(monthUrl); + } + }); + + const expectedAvailableDays = { + day: -1, + week: -4, // one day will already be blocked by daily limit + month: 0, + year: 0, + }; + + await test.step("but not over", async () => { + // should already have navigated to monthUrl - just ensure days are rendered + await expect(page.getByTestId("day").nth(0)).toBeVisible(); + + // ensure the day we just booked is now blocked + await expect(bookingDay).toBeHidden({ timeout: 10_000 }); + + const availableDaysAfter = await availableDays.count(); + + // equals 0 if no available days, otherwise signed difference + expect(availableDaysAfter && availableDaysAfter - availableDaysBefore).toBe( + expectedAvailableDays[limitUnit] + ); + + // try to book directly via form page + await page.goto(slotUrl); + await bookTimeSlot(page); + + await expect(page.getByTestId("booking-fail")).toBeVisible({ timeout: 1000 }); + }); + + await test.step(`month after booking`, async () => { + await page.goto(getLastEventUrlWithMonth(user, bookingDate.add(1, "month"))); + + // finish rendering days before counting + await expect(page.getByTestId("day").nth(0)).toBeVisible({ timeout: 10_000 }); + + // the month after we made bookings should have availability unless we hit a yearly limit + await expect((await availableDays.count()) === 0).toBe(limitUnit === "year"); + }); + + // increment date by unit after hitting each limit + bookingDate = incrementDate(bookingDate, limitUnit); + } + }); +}); + +test.describe("Duration limits", () => { + entries(BOOKING_LIMITS_SINGLE).forEach(([limitKey, bookingLimit]) => { + const limitUnit = intervalLimitKeyToUnit(limitKey); + + // test one limit at a time + test(limitUnit, async ({ page, users }) => { + const slug = `duration-limit-${limitUnit}`; + const singleLimit = { [limitKey]: bookingLimit * EVENT_LENGTH }; + + const user = await createUserWithLimits({ + users, + slug, + length: EVENT_LENGTH, + durationLimits: singleLimit, + }); + + let slotUrl = ""; + + const monthUrl = getLastEventUrlWithMonth(user, firstMondayInBookingMonth); + await page.goto(monthUrl); + + const availableDays = page.locator('[data-testid="day"][data-disabled="false"]'); + const bookingDay = availableDays.getByText(firstMondayInBookingMonth.date().toString(), { + exact: true, + }); + + // finish rendering days before counting + await expect(bookingDay).toBeVisible({ timeout: 10_000 }); + const availableDaysBefore = await availableDays.count(); + + await test.step("can book up to limit", async () => { + for (let i = 0; i < bookingLimit; i++) { + await bookingDay.click(); + + await page.getByTestId("time").nth(0).click(); + await bookTimeSlot(page); + + slotUrl = page.url(); + + await expect(page.getByTestId("success-page")).toBeVisible(); + + await page.goto(monthUrl); + } + }); + + const expectedAvailableDays = { + day: -1, + week: -5, + month: 0, + year: 0, + }; + + await test.step("but not over", async () => { + // should already have navigated to monthUrl - just ensure days are rendered + await expect(page.getByTestId("day").nth(0)).toBeVisible(); + + // ensure the day we just booked is now blocked + await expect(bookingDay).toBeHidden({ timeout: 10_000 }); + + const availableDaysAfter = await availableDays.count(); + + // equals 0 if no available days, otherwise signed difference + expect(availableDaysAfter && availableDaysAfter - availableDaysBefore).toBe( + expectedAvailableDays[limitUnit] + ); + + // try to book directly via form page + await page.goto(slotUrl); + await bookTimeSlot(page); + + await expect(page.getByTestId("booking-fail")).toBeVisible({ timeout: 1000 }); + }); + + await test.step(`month after booking`, async () => { + await page.goto(getLastEventUrlWithMonth(user, firstMondayInBookingMonth.add(1, "month"))); + + // finish rendering days before counting + await expect(page.getByTestId("day").nth(0)).toBeVisible({ timeout: 10_000 }); + + // the month after we made bookings should have availability unless we hit a yearly limit + await expect((await availableDays.count()) === 0).toBe(limitUnit === "year"); + }); + }); + }); + + test("multiple", async ({ page, users }) => { + const slug = "duration-limit-multiple"; + + // multiply all booking limits by EVENT_LENGTH + const durationLimits = entries(BOOKING_LIMITS_MULTIPLE).reduce((limits, [limitKey, bookingLimit]) => { + return { + ...limits, + [limitKey]: bookingLimit * EVENT_LENGTH, + }; + }, {} as Record); + + const user = await createUserWithLimits({ + users, + slug, + length: EVENT_LENGTH, + durationLimits, + }); + + let slotUrl = ""; + + let bookingDate = firstMondayInBookingMonth; + + // keep track of total bookings across multiple limits + let bookingCount = 0; + + for (const [limitKey, limitValue] of entries(BOOKING_LIMITS_MULTIPLE)) { + const limitUnit = intervalLimitKeyToUnit(limitKey); + + const monthUrl = getLastEventUrlWithMonth(user, bookingDate); + await page.goto(monthUrl); + + const availableDays = page.locator('[data-testid="day"][data-disabled="false"]'); + const bookingDay = availableDays.getByText(bookingDate.date().toString(), { exact: true }); + + // finish rendering days before counting + await expect(bookingDay).toBeVisible({ timeout: 10_000 }); + + const availableDaysBefore = await availableDays.count(); + + await test.step(`can book up ${limitUnit} to limit`, async () => { + for (let i = 0; i + bookingCount < limitValue; i++) { + await bookingDay.click(); + + await page.getByTestId("time").nth(0).click(); + await bookTimeSlot(page); + bookingCount++; + + slotUrl = page.url(); + + await expect(page.getByTestId("success-page")).toBeVisible(); + + await page.goto(monthUrl); + } + }); + + const expectedAvailableDays = { + day: -1, + week: -4, // one day will already be blocked by daily limit + month: 0, + year: 0, + }; + + await test.step("but not over", async () => { + // should already have navigated to monthUrl - just ensure days are rendered + await expect(page.getByTestId("day").nth(0)).toBeVisible(); + + // ensure the day we just booked is now blocked + await expect(bookingDay).toBeHidden({ timeout: 10_000 }); + + const availableDaysAfter = await availableDays.count(); + + // equals 0 if no available days, otherwise signed difference + expect(availableDaysAfter && availableDaysAfter - availableDaysBefore).toBe( + expectedAvailableDays[limitUnit] + ); + + // try to book directly via form page + await page.goto(slotUrl); + await bookTimeSlot(page); + + await expect(page.getByTestId("booking-fail")).toBeVisible({ timeout: 1000 }); + }); + + await test.step(`month after booking`, async () => { + await page.goto(getLastEventUrlWithMonth(user, bookingDate.add(1, "month"))); + + // finish rendering days before counting + await expect(page.getByTestId("day").nth(0)).toBeVisible({ timeout: 10_000 }); + + // the month after we made bookings should have availability unless we hit a yearly limit + await expect((await availableDays.count()) === 0).toBe(limitUnit === "year"); + }); + + // increment date by unit after hitting each limit + bookingDate = incrementDate(bookingDate, limitUnit); + } + }); +}); diff --git a/apps/web/playwright/lib/testUtils.ts b/apps/web/playwright/lib/testUtils.ts index 5bb711d71a..c334eb5d05 100644 --- a/apps/web/playwright/lib/testUtils.ts +++ b/apps/web/playwright/lib/testUtils.ts @@ -11,6 +11,7 @@ import { totp } from "otplib"; import type { Prisma } from "@calcom/prisma/client"; import { BookingStatus } from "@calcom/prisma/enums"; +import type { IntervalLimit } from "@calcom/types/Calendar"; import type { Fixtures } from "./fixtures"; import { test } from "./fixtures"; @@ -246,6 +247,38 @@ export async function expectEmailsToHaveSubject({ expect(bookerFirstEmail.subject).toBe(emailSubject); } +export const createUserWithLimits = ({ + users, + slug, + title, + length, + bookingLimits, + durationLimits, +}: { + users: Fixtures["users"]; + slug: string; + title?: string; + length?: number; + bookingLimits?: IntervalLimit; + durationLimits?: IntervalLimit; +}) => { + if (!bookingLimits && !durationLimits) { + throw new Error("Need to supply at least one of bookingLimits or durationLimits"); + } + + return users.create({ + eventTypes: [ + { + slug, + title: title ?? slug, + length: length ?? 30, + bookingLimits, + durationLimits, + }, + ], + }); +}; + // this method is not used anywhere else // but I'm keeping it here in case we need in the future async function createUserWithSeatedEvent(users: Fixtures["users"]) { diff --git a/apps/web/test/utils/bookingScenario/bookingScenario.ts b/apps/web/test/utils/bookingScenario/bookingScenario.ts index af7717219e..63ab59c9df 100644 --- a/apps/web/test/utils/bookingScenario/bookingScenario.ts +++ b/apps/web/test/utils/bookingScenario/bookingScenario.ts @@ -12,6 +12,7 @@ import "vitest-fetch-mock"; import { appStoreMetadata } from "@calcom/app-store/appStoreMetaData"; import { handleStripePaymentSuccess } from "@calcom/features/ee/payments/api/webhook"; +import { weekdayToWeekIndex, type WeekDays } from "@calcom/lib/date-fns"; import type { HttpError } from "@calcom/lib/http-error"; import logger from "@calcom/lib/logger"; import { safeStringify } from "@calcom/lib/safeStringify"; @@ -19,7 +20,7 @@ import type { SchedulingType } from "@calcom/prisma/enums"; import type { BookingStatus } from "@calcom/prisma/enums"; import type { AppMeta } from "@calcom/types/App"; import type { NewCalendarEventType } from "@calcom/types/Calendar"; -import type { EventBusyDate } from "@calcom/types/Calendar"; +import type { EventBusyDate, IntervalLimit } from "@calcom/types/Calendar"; import { getMockPaymentService } from "./MockPaymentService"; @@ -89,6 +90,7 @@ type InputUser = Omit & { timeZone: string; }[]; destinationCalendar?: Prisma.DestinationCalendarCreateInput; + weekStart?: string; }; export type InputEventType = { @@ -110,10 +112,9 @@ export type InputEventType = { requiresConfirmation?: boolean; destinationCalendar?: Prisma.DestinationCalendarCreateInput; schedule?: InputUser["schedules"][number]; - bookingLimits?: { - PER_DAY?: number; - }; -} & Partial>; + bookingLimits?: IntervalLimit; + durationLimits?: IntervalLimit; +} & Partial>; type WhiteListedBookingProps = { id?: number; @@ -536,20 +537,32 @@ export async function createOrganization(orgData: { name: string; slug: string } * - `dateIncrement` adds the increment to current day * - `monthIncrement` adds the increment to current month * - `yearIncrement` adds the increment to current year + * - `fromDate` starts incrementing from this date (default: today) */ export const getDate = ( - param: { dateIncrement?: number; monthIncrement?: number; yearIncrement?: number } = {} + param: { + dateIncrement?: number; + monthIncrement?: number; + yearIncrement?: number; + fromDate?: Date; + } = {} ) => { - let { dateIncrement, monthIncrement, yearIncrement } = param; + let { dateIncrement, monthIncrement, yearIncrement, fromDate } = param; + dateIncrement = dateIncrement || 0; monthIncrement = monthIncrement || 0; yearIncrement = yearIncrement || 0; + fromDate = fromDate || new Date(); - let _date = new Date().getDate() + dateIncrement; - let year = new Date().getFullYear() + yearIncrement; + fromDate.setDate(fromDate.getDate() + dateIncrement); + fromDate.setMonth(fromDate.getMonth() + monthIncrement); + fromDate.setFullYear(fromDate.getFullYear() + yearIncrement); + + let _date = fromDate.getDate(); + let year = fromDate.getFullYear(); // Make it start with 1 to match with DayJS requiremet - let _month = new Date().getMonth() + monthIncrement + 1; + let _month = fromDate.getMonth() + 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(); @@ -568,13 +581,35 @@ export const getDate = ( const month = _month < 10 ? `0${_month}` : _month; return { - date, - month, - year, + date: String(date), + month: String(month), + year: String(year), dateString: `${year}-${month}-${date}`, }; }; +const isWeekStart = (date: Date, weekStart: WeekDays) => { + return date.getDay() === weekdayToWeekIndex(weekStart); +}; + +export const getNextMonthNotStartingOnWeekStart = (weekStart: WeekDays, from?: Date) => { + const date = from ?? new Date(); + + const incrementMonth = (date: Date) => { + date.setMonth(date.getMonth() + 1); + }; + + // start searching from the 1st day of next month + incrementMonth(date); + date.setDate(1); + + while (isWeekStart(date, weekStart)) { + incrementMonth(date); + } + + return getDate({ fromDate: date }); +}; + export function getMockedCredential({ metadataLookupKey, key, @@ -798,6 +833,7 @@ export function getOrganizer({ selectedCalendars, destinationCalendar, defaultScheduleId, + weekStart = "Sunday", teams, }: { name: string; @@ -808,6 +844,7 @@ export function getOrganizer({ selectedCalendars?: InputSelectedCalendar[]; defaultScheduleId?: number | null; destinationCalendar?: Prisma.DestinationCalendarCreateInput; + weekStart?: WeekDays; teams?: InputUser["teams"]; }) { return { @@ -821,6 +858,7 @@ export function getOrganizer({ selectedCalendars, destinationCalendar, defaultScheduleId, + weekStart, teams, }; } diff --git a/packages/embeds/embed-react/tsconfig.json b/packages/embeds/embed-react/tsconfig.json index 4a82423a1e..ccbd96a5d8 100644 --- a/packages/embeds/embed-react/tsconfig.json +++ b/packages/embeds/embed-react/tsconfig.json @@ -13,7 +13,7 @@ "@calcom/embed-snippet": ["../embed-snippet/src"] } }, - "include": ["**/*.ts", "**/*.tsx", "env.d.ts"], + "include": ["src/**/*.ts", "src/**/*.tsx", "env.d.ts"], // Exclude "test" because that has `api.test.ts` which imports @calcom/embed-react which needs it to be built using this tsconfig.json first. Excluding it here prevents type-check from validating test folder "exclude": ["node_modules", "test"] } diff --git a/packages/features/bookings/lib/handleNewBooking/test/booking-limits.test.ts b/packages/features/bookings/lib/handleNewBooking/test/booking-limits.test.ts index fcfcef7975..f79531e8cf 100644 --- a/packages/features/bookings/lib/handleNewBooking/test/booking-limits.test.ts +++ b/packages/features/bookings/lib/handleNewBooking/test/booking-limits.test.ts @@ -1,7 +1,465 @@ -import { describe } from "vitest"; +/** + * These integration tests aim to cover difficult-to-test edge cases + * Standard cases are currently handled in e2e tests only + * + * see: https://github.com/calcom/cal.com/pull/10480 + * https://github.com/calcom/cal.com/pull/10968 + */ +import prismock from "../../../../../../tests/libs/__mocks__/prisma"; +import { describe, expect, vi } from "vitest"; + +import { BookingStatus } from "@calcom/prisma/enums"; import { test } from "@calcom/web/test/fixtures/fixtures"; +import { + TestData, + createBookingScenario, + getBooker, + getDate, + getNextMonthNotStartingOnWeekStart, + getOrganizer, + getScenarioData, +} from "@calcom/web/test/utils/bookingScenario/bookingScenario"; +import { createMockNextJsRequest } from "@calcom/web/test/utils/bookingScenario/createMockNextJsRequest"; +import { expectBookingToBeInDatabase } from "@calcom/web/test/utils/bookingScenario/expects"; +import { getMockRequestDataForBooking } from "@calcom/web/test/utils/bookingScenario/getMockRequestDataForBooking"; +import { setupAndTeardown } from "@calcom/web/test/utils/bookingScenario/setupAndTeardown"; -describe("Booking Limits", () => { - test.todo("Test these cases that were failing earlier https://github.com/calcom/cal.com/pull/10480"); +// Local test runs sometime gets too slow +const timeout = process.env.CI ? 5000 : 20000; + +const eventLength = 30; + +describe("handleNewBooking", () => { + setupAndTeardown(); + + describe( + "Booking Limits", + () => { + // This test fails on CI as handleNewBooking throws no_available_users_found_error error + // eslint-disable-next-line playwright/no-skipped-test + test.skip( + `should fail a booking if yearly booking limits are already reached + 1. year with limits reached: should fail to book + 2. following year without bookings: should create a booking in the database + `, + async ({}) => { + const handleNewBooking = (await import("@calcom/features/bookings/lib/handleNewBooking")).default; + + const booker = getBooker({ + email: "booker@example.com", + name: "Booker", + }); + + const organizer = getOrganizer({ + name: "Organizer", + email: "organizer@example.com", + id: 101, + schedules: [TestData.schedules.IstWorkHours], + }); + + const { dateString: nextYearDateString } = getDate({ yearIncrement: 1 }); + + await createBookingScenario( + getScenarioData({ + eventTypes: [ + { + id: 1, + slotInterval: eventLength, + length: eventLength, + users: [ + { + id: 101, + }, + ], + bookingLimits: { + PER_YEAR: 2, + }, + }, + ], + bookings: [ + { + eventTypeId: 1, + userId: 101, + status: BookingStatus.ACCEPTED, + startTime: `${nextYearDateString}T04:00:00.000Z`, + endTime: `${nextYearDateString}T04:30:00.000Z`, + }, + { + eventTypeId: 1, + userId: 101, + status: BookingStatus.ACCEPTED, + startTime: `${nextYearDateString}T04:30:00.000Z`, + endTime: `${nextYearDateString}T05:00:00.000Z`, + }, + ], + organizer, + }) + ); + + const mockBookingData = getMockRequestDataForBooking({ + data: { + start: `${nextYearDateString}T05:00:00.000Z`, + end: `${nextYearDateString}T05:30:00.000Z`, + eventTypeId: 1, + responses: { + email: booker.email, + name: booker.name, + location: { optionValue: "", value: "New York" }, + }, + }, + }); + + const { req } = createMockNextJsRequest({ + method: "POST", + body: mockBookingData, + }); + + await expect(async () => await handleNewBooking(req)).rejects.toThrowError("booking_limit_reached"); + + const { dateString: yearWithoutBookingsDateString } = getDate({ yearIncrement: 2 }); + + const mockBookingDataFollowingYear = getMockRequestDataForBooking({ + data: { + start: `${yearWithoutBookingsDateString}T05:00:00.000Z`, + end: `${yearWithoutBookingsDateString}T05:30:00.000Z`, + eventTypeId: 1, + responses: { + email: booker.email, + name: booker.name, + location: { optionValue: "", value: "New York" }, + }, + }, + }); + + const { req: reqFollowingYear } = createMockNextJsRequest({ + method: "POST", + body: mockBookingDataFollowingYear, + }); + + const createdBooking = await handleNewBooking(reqFollowingYear); + + expect(createdBooking.responses).toContain({ + email: booker.email, + name: booker.name, + }); + + expect(createdBooking).toContain({ + location: "New York", + }); + + await expectBookingToBeInDatabase({ + description: "", + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + uid: createdBooking.uid!, + eventTypeId: mockBookingDataFollowingYear.eventTypeId, + status: BookingStatus.ACCEPTED, + }); + }, + timeout + ); + + test( + `should fail a booking if yearly duration limits are already reached + 1. year with limits reached: should fail to book + 2. following year without bookings: should create a booking in the database + `, + async ({}) => { + const handleNewBooking = (await import("@calcom/features/bookings/lib/handleNewBooking")).default; + + const booker = getBooker({ + email: "booker@example.com", + name: "Booker", + }); + + const organizer = getOrganizer({ + name: "Organizer", + email: "organizer@example.com", + id: 101, + schedules: [TestData.schedules.IstWorkHours], + }); + + const yearlyDurationLimit = 2 * eventLength; + + const { dateString: nextYearDateString } = getDate({ yearIncrement: 1 }); + + await createBookingScenario( + getScenarioData({ + eventTypes: [ + { + id: 1, + slotInterval: eventLength, + length: eventLength, + users: [ + { + id: 101, + }, + ], + durationLimits: { + PER_YEAR: yearlyDurationLimit, + }, + }, + ], + bookings: [ + { + eventTypeId: 1, + userId: 101, + status: BookingStatus.ACCEPTED, + startTime: `${nextYearDateString}T04:00:00.000Z`, + endTime: `${nextYearDateString}T04:30:00.000Z`, + }, + { + eventTypeId: 1, + userId: 101, + status: BookingStatus.ACCEPTED, + startTime: `${nextYearDateString}T04:30:00.000Z`, + endTime: `${nextYearDateString}T05:00:00.000Z`, + }, + ], + organizer, + }) + ); + + const mockBookingData = getMockRequestDataForBooking({ + data: { + start: `${nextYearDateString}T05:00:00.000Z`, + end: `${nextYearDateString}T05:30:00.000Z`, + eventTypeId: 1, + responses: { + email: booker.email, + name: booker.name, + location: { optionValue: "", value: "New York" }, + }, + }, + }); + + const { req } = createMockNextJsRequest({ + method: "POST", + body: mockBookingData, + }); + + vi.spyOn(prismock, "$queryRaw").mockResolvedValue([{ totalMinutes: yearlyDurationLimit }]); + + await expect(async () => await handleNewBooking(req)).rejects.toThrowError( + "duration_limit_reached" + ); + + const { dateString: yearWithoutBookingsDateString } = getDate({ yearIncrement: 2 }); + + const mockBookingDataFollowingYear = getMockRequestDataForBooking({ + data: { + start: `${yearWithoutBookingsDateString}T05:00:00.000Z`, + end: `${yearWithoutBookingsDateString}T05:30:00.000Z`, + eventTypeId: 1, + responses: { + email: booker.email, + name: booker.name, + location: { optionValue: "", value: "New York" }, + }, + }, + }); + + const { req: reqFollowingYear } = createMockNextJsRequest({ + method: "POST", + body: mockBookingDataFollowingYear, + }); + + vi.spyOn(prismock, "$queryRaw").mockResolvedValue([{ totalMinutes: 0 }]); + + const createdBooking = await handleNewBooking(reqFollowingYear); + + expect(createdBooking.responses).toContain({ + email: booker.email, + name: booker.name, + }); + + expect(createdBooking).toContain({ + location: "New York", + }); + + await expectBookingToBeInDatabase({ + description: "", + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + uid: createdBooking.uid!, + eventTypeId: mockBookingDataFollowingYear.eventTypeId, + status: BookingStatus.ACCEPTED, + }); + }, + timeout + ); + + const { date: todayDate } = getDate(); + const { date: tomorrowDate } = getDate({ dateIncrement: 1 }); + + // today or tomorrow can't be the 1st day of month for this test to succeed + test.skipIf([todayDate, tomorrowDate].includes("01"))( + `should fail a booking if exceeds booking limits with bookings in the past`, + async ({}) => { + const handleNewBooking = (await import("@calcom/features/bookings/lib/handleNewBooking")).default; + + const booker = getBooker({ + email: "booker@example.com", + name: "Booker", + }); + + const organizer = getOrganizer({ + name: "Organizer", + email: "organizer@example.com", + id: 101, + schedules: [TestData.schedules.IstWorkHours], + }); + + const { dateString: yesterdayDateString } = getDate({ dateIncrement: -1 }); + + await createBookingScenario( + getScenarioData({ + eventTypes: [ + { + id: 1, + slotInterval: eventLength, + length: eventLength, + users: [ + { + id: 101, + }, + ], + bookingLimits: { + PER_MONTH: 2, + }, + }, + ], + bookings: [ + { + eventTypeId: 1, + userId: 101, + status: BookingStatus.ACCEPTED, + startTime: `${yesterdayDateString}T04:00:00.000Z`, + endTime: `${yesterdayDateString}T04:30:00.000Z`, + }, + { + eventTypeId: 1, + userId: 101, + status: BookingStatus.ACCEPTED, + startTime: `${yesterdayDateString}T04:30:00.000Z`, + endTime: `${yesterdayDateString}T05:00:00.000Z`, + }, + ], + organizer, + }) + ); + + const { dateString: tomorrowDateString } = getDate({ dateIncrement: 1 }); + + const mockBookingData = getMockRequestDataForBooking({ + data: { + start: `${tomorrowDateString}T05:00:00.000Z`, + end: `${tomorrowDateString}T05:30:00.000Z`, + eventTypeId: 1, + responses: { + email: booker.email, + name: booker.name, + location: { optionValue: "", value: "New York" }, + }, + }, + }); + + const { req } = createMockNextJsRequest({ + method: "POST", + body: mockBookingData, + }); + + await expect(async () => await handleNewBooking(req)).rejects.toThrowError("booking_limit_reached"); + }, + timeout + ); + + test( + `should fail a booking if exceeds booking limits with bookings in week across two months`, + async ({}) => { + const handleNewBooking = (await import("@calcom/features/bookings/lib/handleNewBooking")).default; + + const booker = getBooker({ + email: "booker@example.com", + name: "Booker", + }); + + const organizer = getOrganizer({ + name: "Organizer", + email: "organizer@example.com", + id: 101, + schedules: [TestData.schedules.IstWorkHours], + }); + + const firstDayOfMonthStartingWithPartialWeek = getNextMonthNotStartingOnWeekStart( + organizer.weekStart, + new Date(getDate({ monthIncrement: 1 }).dateString) + ); + + const lastDayOfMonthEndingWithPartialWeek = getDate({ + fromDate: new Date(firstDayOfMonthStartingWithPartialWeek.dateString), + dateIncrement: -1, + }); + + await createBookingScenario( + getScenarioData({ + eventTypes: [ + { + id: 1, + slotInterval: eventLength, + length: eventLength, + users: [ + { + id: 101, + }, + ], + bookingLimits: { + PER_WEEK: 2, + }, + }, + ], + bookings: [ + { + eventTypeId: 1, + userId: 101, + status: BookingStatus.ACCEPTED, + startTime: `${firstDayOfMonthStartingWithPartialWeek.dateString}T04:00:00.000Z`, + endTime: `${firstDayOfMonthStartingWithPartialWeek.dateString}T04:30:00.000Z`, + }, + { + eventTypeId: 1, + userId: 101, + status: BookingStatus.ACCEPTED, + startTime: `${firstDayOfMonthStartingWithPartialWeek.dateString}T04:30:00.000Z`, + endTime: `${firstDayOfMonthStartingWithPartialWeek.dateString}T05:00:00.000Z`, + }, + ], + organizer, + }) + ); + + const mockBookingData = getMockRequestDataForBooking({ + data: { + start: `${lastDayOfMonthEndingWithPartialWeek.dateString}T05:00:00.000Z`, + end: `${lastDayOfMonthEndingWithPartialWeek.dateString}T05:30:00.000Z`, + eventTypeId: 1, + responses: { + email: booker.email, + name: booker.name, + location: { optionValue: "", value: "New York" }, + }, + }, + }); + + const { req } = createMockNextJsRequest({ + method: "POST", + body: mockBookingData, + }); + + await expect(async () => await handleNewBooking(req)).rejects.toThrowError("booking_limit_reached"); + }, + timeout + ); + }, + timeout + ); }); diff --git a/packages/lib/date-fns/index.ts b/packages/lib/date-fns/index.ts index c89969bea8..947c731f18 100644 --- a/packages/lib/date-fns/index.ts +++ b/packages/lib/date-fns/index.ts @@ -147,7 +147,7 @@ export const isNextDayInTimezone = (time: string, timezoneA: string, timezoneB: }; const weekDays = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"] as const; -type WeekDays = (typeof weekDays)[number]; +export type WeekDays = (typeof weekDays)[number]; type WeekDayIndex = 0 | 1 | 2 | 3 | 4 | 5 | 6; /**