Compare commits
31 Commits
main
...
e2e-limits
Author | SHA1 | Date | |
---|---|---|---|
27d08d48a4 | |||
98b79e9c93 | |||
530aacdc39 | |||
186bfe8b86 | |||
8729b6f10c | |||
987357ea4c | |||
3a9021d107 | |||
49b8b312f4 | |||
c93bdcce08 | |||
d70de65d15 | |||
a8366e0965 | |||
0bb05b9a86 | |||
b6558a7eca | |||
65cf9889e8 | |||
fcf6a4f7ed | |||
49b201b5ac | |||
b6f836891c | |||
745d6aa992 | |||
88bee53159 | |||
410ae0ecf1 | |||
53a3abd0d2 | |||
5ff52482b3 | |||
72e6df1da0 | |||
9288371a1f | |||
8a952f4814 | |||
473cab631a | |||
b8aabcf7c8 | |||
fd5ddd5e76 | |||
c8362ec984 | |||
d168793a99 | |||
02f2393be5 |
|
@ -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<ReturnType<typeof createUserWithLimits>>, 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<keyof IntervalLimit, number>);
|
||||
|
||||
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);
|
||||
}
|
||||
});
|
||||
});
|
|
@ -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"]) {
|
||||
|
|
|
@ -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<typeof TestData.users.example, "defaultScheduleId"> & {
|
|||
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<Omit<Prisma.EventTypeCreateInput, "users" | "schedule">>;
|
||||
bookingLimits?: IntervalLimit;
|
||||
durationLimits?: IntervalLimit;
|
||||
} & Partial<Omit<Prisma.EventTypeCreateInput, "users" | "schedule" | "bookingLimits" | "durationLimits">>;
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
|
|
@ -1,7 +1,463 @@
|
|||
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",
|
||||
() => {
|
||||
test(
|
||||
`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
|
||||
);
|
||||
});
|
||||
|
|
|
@ -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;
|
||||
|
||||
/**
|
||||
|
|
Loading…
Reference in New Issue
Block a user