/** * 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/intervalLimits/intervalLimit"; import prisma from "@calcom/prisma"; import { BookingStatus } from "@calcom/prisma/enums"; import { entries } from "@calcom/prisma/zod-utils"; import { test } from "./lib/fixtures"; import { bookTimeSlot, confirmReschedule, createUserWithLimits, expectSlotNotAllowedToBook, } 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 separately 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); // Find the first occurrence of the same weekday in the next month // Note: .day() sets the day of the week for the CURRENT week, which can // return a date in the previous month. Instead, we calculate the correct date. const targetDay = date.day(); const startOfNextMonth = date.add(1, "month").startOf("month"); const firstDayOfMonth = startOfNextMonth.day(); let daysToAdd = targetDay - firstDayOfMonth; if (daysToAdd < 0) daysToAdd += 7; return startOfNextMonth.add(daysToAdd, "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.fixme(`Per ${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(); let latestRescheduleUrl: string | null = null; 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(); latestRescheduleUrl = await page .locator('span[data-testid="reschedule-link"] > a') .getAttribute("href"); 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 expectSlotNotAllowedToBook(page); }); await test.step("but can reschedule", async () => { const bookingId = latestRescheduleUrl?.split("/").pop(); const rescheduledBooking = await prisma.booking.findFirstOrThrow({ where: { uid: bookingId } }); const year = rescheduledBooking.startTime.getFullYear(); const month = String(rescheduledBooking.startTime.getMonth() + 1).padStart(2, "0"); const day = String(rescheduledBooking.startTime.getDate()).padStart(2, "0"); await page.goto( `/${user.username}/${ user.eventTypes.at(-1)?.slug }?rescheduleUid=${bookingId}&date=${year}-${month}-${day}&month=${year}-${month}` ); const formerDay = availableDays.getByText(rescheduledBooking.startTime.getDate().toString(), { exact: true, }); await expect(formerDay).toBeVisible(); const formerTimeElement = page.locator('[data-testid="former_time_p"]'); await expect(formerTimeElement).toBeVisible(); await page.locator('[data-testid="time"]').nth(0).click(); await expect(page.locator('[name="name"]')).toBeDisabled(); await expect(page.locator('[name="email"]')).toBeDisabled(); await confirmReschedule(page); await expect(page.locator("[data-testid=success-page]")).toBeVisible(); const newBooking = await prisma.booking.findFirstOrThrow({ where: { fromReschedule: bookingId } }); expect(newBooking).not.toBeNull(); const updatedRescheduledBooking = await prisma.booking.findFirstOrThrow({ where: { uid: bookingId }, }); expect(updatedRescheduledBooking.status).toBe(BookingStatus.CANCELLED); await prisma.booking.deleteMany({ where: { id: { in: [newBooking.id, rescheduledBooking.id], }, }, }); }); 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.describe("multiple limits", () => { test("day limit with multiple limits set", async ({ page, users }) => { const slug = "booking-limit-multiple-day"; const user = await createUserWithLimits({ users, slug, length: EVENT_LENGTH, bookingLimits: BOOKING_LIMITS_MULTIPLE, }); const bookingDate = firstMondayInBookingMonth; const limitValue = BOOKING_LIMITS_MULTIPLE.PER_DAY; 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(); let slotUrl = ""; await test.step("can book up to day limit", async () => { for (let i = 0; i < limitValue; 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); } }); 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(-1); // try to book directly via form page await page.goto(slotUrl); await expectSlotNotAllowedToBook(page); }); }); test("week limit with multiple limits set", async ({ page, users, bookings }) => { const slug = "booking-limit-multiple-week"; const user = await createUserWithLimits({ users, slug, length: EVENT_LENGTH, bookingLimits: BOOKING_LIMITS_MULTIPLE, }); const baseBookingDate = firstMondayInBookingMonth; const eventTypeId = user.eventTypes.at(-1)?.id; if (!eventTypeId) throw new Error("Event type not found"); // Pre-create 1 booking on Monday (same week as UI booking) // This satisfies the daily limit for Monday and counts toward the weekly limit const preBookingDate = baseBookingDate.hour(10).minute(0); await bookings.create(user.id, user.username, eventTypeId, { startTime: preBookingDate.toDate(), endTime: preBookingDate.add(EVENT_LENGTH, "minutes").toDate(), }); // Test week limit on Tuesday (same week, different day to avoid daily limit conflict) // Need to book 1 more to hit weekly limit of 2 (1 already exists from pre-booking) const bookingDate = baseBookingDate.add(1, "day"); const weekLimitValue = BOOKING_LIMITS_MULTIPLE.PER_WEEK; const bookingsToMake = weekLimitValue - 1; 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(); let slotUrl = ""; await test.step("can book up to week limit", async () => { for (let i = 0; i < bookingsToMake; 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); } }); 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(); // After hitting weekly limit, all remaining days in the week should be blocked // Monday was already blocked by daily limit, Tuesday is now blocked // The remaining weekdays (Wed-Fri) should also be blocked = 3 more days // Total blocked: 1 (Tuesday we just booked) + 3 (Wed-Fri) = 4 days // But Monday was already blocked before we started, so delta is -4 expect(availableDaysAfter && availableDaysAfter - availableDaysBefore).toBe(-4); // try to book directly via form page await page.goto(slotUrl); await expectSlotNotAllowedToBook(page); }); }); test("month limit with multiple limits set", async ({ page, users, bookings }) => { const slug = "booking-limit-multiple-month"; const user = await createUserWithLimits({ users, slug, length: EVENT_LENGTH, bookingLimits: BOOKING_LIMITS_MULTIPLE, }); // Pre-create bookings for day (1) and week (2 total, so 1 more) limits const baseBookingDate = firstMondayInBookingMonth; const eventTypeId = user.eventTypes.at(-1)?.id; if (!eventTypeId) throw new Error("Event type not found"); // Create 2 bookings (day limit: 1, week limit: 2 total) // First booking on Monday at 10:00, second on Tuesday at 10:00 for (let i = 0; i < 2; i++) { const date = baseBookingDate.add(i, "day").hour(10).minute(0); await bookings.create(user.id, user.username, eventTypeId, { startTime: date.toDate(), endTime: date.add(EVENT_LENGTH, "minutes").toDate(), }); } // Test month limit - need to book 1 more (total 3, but 2 already exist) // Move to next week for month limit test (similar to week limit test) const bookingDate = baseBookingDate.add(1, "week"); const monthLimitValue = BOOKING_LIMITS_MULTIPLE.PER_MONTH; const bookingsToMake = monthLimitValue - 2; // 2 already exist from previous limits 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(); let slotUrl = ""; await test.step("can book up to month limit", async () => { for (let i = 0; i < bookingsToMake; 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); } }); 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(0); // try to book directly via form page await page.goto(slotUrl); await expectSlotNotAllowedToBook(page); }); }); test("year limit with multiple limits set", async ({ page, users, bookings }) => { const slug = "booking-limit-multiple-year"; const user = await createUserWithLimits({ users, slug, length: EVENT_LENGTH, bookingLimits: BOOKING_LIMITS_MULTIPLE, }); // Pre-create bookings for day (1), week (2 total), and month (3 total) limits const baseBookingDate = firstMondayInBookingMonth; const eventTypeId = user.eventTypes.at(-1)?.id; if (!eventTypeId) throw new Error("Event type not found"); // Create 3 bookings (day: 1, week: 2, month: 3 total) // Spread across different days to satisfy day/week/month limits // Monday, Tuesday, and next Monday (to span a week) const dates = [ baseBookingDate.hour(10).minute(0), baseBookingDate.add(1, "day").hour(10).minute(0), baseBookingDate.add(1, "week").hour(10).minute(0), ]; for (const date of dates) { await bookings.create(user.id, user.username, eventTypeId, { startTime: date.toDate(), endTime: date.add(EVENT_LENGTH, "minutes").toDate(), }); } // Test year limit - need to book 1 more (total 4, but 3 already exist) const yearLimitValue = BOOKING_LIMITS_MULTIPLE.PER_YEAR; const bookingsToMake = yearLimitValue - 3; // 3 already exist from previous limits // Move to next month for year limit test const bookingDate = incrementDate(baseBookingDate, "month"); 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(); let slotUrl = ""; await test.step("can book up to year limit", async () => { for (let i = 0; i < bookingsToMake; 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); } }); 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(0); // try to book directly via form page await page.goto(slotUrl); await expectSlotNotAllowedToBook(page); }); }); }); });