test: Add tests for Booker component (#19051)

* feat: Don't allow unavailable slot's booking form to be visible - Send back to slots listing

* Improvements

* feat: Add env variables and strategies to prevent booking failures

- Added new environment variables for configuring slot reservation and availability checks
- Implemented strategies to handle concurrent bookings and slot availability
- Updated Booker component and related files to support new reservation and slot checking mechanisms
- Added README with detailed explanation of booking prevention strategies
- Configured dynamic intervals for slot and reservation queries

* docs: Update Booker README with detailed slot reservation and availability explanation

- Clarified slot reservation behavior when multiple users access the same booking page
- Added details about `getSchedule` refetching strategies and timing
- Explained how slot availability is detected and communicated to users

* Support for skip confirmation form flow

* feat: Enhance slot availability and reservation mechanism

- Refactored slot availability checking to support multiple slots
- Added tentative selected timeslots to improve booking UX
- Updated trpc handler to check availability for multiple slots
- Introduced new store methods for managing tentative slot selections
- Improved slot reservation and availability status tracking

* refactor: Improve slot availability quick check mechanism

- Renamed and restructured slot availability check parameters
- Extracted quick availability checks into a separate hook
- Updated type definitions for slot status and availability checks
- Simplified slot availability logic in Booker and related components
- Maintained cached state for quick availability checks

* Add tests booker

* Support minimu booking check too

* change data-testid

* Rneame

* Fix race condition with uid cookie not set

* fix tests

* Remove unsded variable

* fixup! Merge branch 'main' into dont-allow-booking-form-with-unavaialble-slot

* Fix the unavailability bug when only seconds differ

* Handle column view that doesnt have selected date and also add tests for isTimeSlotAavailable

* Fi ts and unit tests

---------

Co-authored-by: Hariom Balhara <hariombalhara@gmgmail.com>
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
Co-authored-by: Morgan Vernay <morgan@cal.com>
This commit is contained in:
Hariom Balhara
2025-03-18 13:02:20 +01:00
committed by GitHub
co-authored by Hariom Balhara Udit Takkar Morgan Morgan Vernay
parent 192238f4bf
commit 07c38f1f33
20 changed files with 508 additions and 7 deletions
+15
View File
@@ -0,0 +1,15 @@
import timezone from "dayjs/plugin/timezone";
import utc from "dayjs/plugin/utc";
import dayjs from "@calcom/dayjs";
dayjs.extend(utc);
dayjs.extend(timezone);
const mockDayjs = vi.fn((date) => dayjs(date));
mockDayjs.utc = vi.fn((date) => dayjs.utc(date));
mockDayjs.tz = vi.fn();
mockDayjs.extend = vi.fn();
export default mockDayjs;
@@ -0,0 +1,7 @@
vi.mock("@calcom/features/auth/Turnstile", () => ({
default: ({ onVerify }: { onVerify: (token: string) => void }) => (
<div data-testid="turnstile-captcha" onClick={() => onVerify("test-token")}>
Mock Captcha
</div>
),
}));
@@ -0,0 +1,5 @@
vi.mock("../config", () => ({
useBookerResizeAnimation: () => ({ current: document.createElement("div") }),
getBookerSizeClassNames: () => [],
fadeInLeft: {},
}));
@@ -0,0 +1,37 @@
import { vi } from "vitest";
const mockStore = {
state: {
username: "",
eventSlug: "",
eventId: undefined,
layout: "month_view",
month: undefined,
bookingUid: null,
isTeamEvent: false,
bookingData: null,
verifiedEmail: null,
rescheduleUid: null,
rescheduledBy: null,
seatReferenceUid: undefined,
durationConfig: null,
org: null,
isInstantMeeting: false,
timezone: null,
teamMemberEmail: null,
crmOwnerRecordType: null,
crmAppSlug: null,
},
setSelectedDate: vi.fn(),
setMonth: vi.fn(),
setTimeFormat: vi.fn(),
setLayout: vi.fn(),
setVerifiedEmail: vi.fn(),
setState: vi.fn(),
};
vi.mock("../store", () => ({
useBookerStore: vi.fn(() => mockStore),
}));
export default mockStore;
@@ -0,0 +1,265 @@
import "@calcom/features/bookings/Booker/__mocks__/config";
import "@calcom/features/bookings/Booker/components/OverlayCalendar/__mocks__/OverlayCalendar";
import "@calcom/features/bookings/Booker/components/__mocks__/AvailableTimeSlots";
import "@calcom/features/bookings/Booker/components/__mocks__/DatePicker";
import "@calcom/features/bookings/Booker/components/__mocks__/DryRunMessage";
import "@calcom/features/bookings/Booker/components/__mocks__/EventMeta";
import "@calcom/features/bookings/Booker/components/__mocks__/Header";
import "@calcom/features/bookings/Booker/components/__mocks__/LargeCalendar";
import "@calcom/features/bookings/Booker/components/__mocks__/Section";
import { constantsScenarios } from "@calcom/lib/__mocks__/constants";
import "@calcom/lib/__mocks__/logger";
import { render, screen } from "@testing-library/react";
import { vi } from "vitest";
import "@calcom/dayjs/__mocks__";
import "@calcom/features/auth/Turnstile";
import { Booker } from "../Booker";
import { useBookerStore } from "../store";
import type { BookerState } from "../types";
// Mock components that we don't want to test
vi.mock("../components/BookEventForm", () => ({
BookEventForm: ({
isTimeslotUnavailable,
onCancel,
}: {
isTimeslotUnavailable: boolean;
onCancel: () => void;
}) => {
console.log("BookEventForm Called", { isTimeslotUnavailable, onCancel });
return (
<div data-testid="book-event-form" data-unavailable={isTimeslotUnavailable}>
Mock Book Event Form
<button onClick={onCancel}>cancel</button>
</div>
);
},
}));
vi.mock("../components/BookEventForm/BookFormAsModal", () => ({
BookFormAsModal: () => {
return <div data-testid="book-form-as-modal">Mock Book Form As Modal</div>;
},
}));
const mockEvent = {
data: {
id: 1,
title: "Test Event",
seatsPerTimeSlot: 1,
seatsShowAvailabilityCount: true,
},
isSuccess: true,
isPending: false,
};
const mockSchedule = {
data: {
slots: {
"2024-01-01": [{ time: "2024-01-01T10:00:00Z" }, { time: "2024-01-01T11:00:00Z" }],
},
},
isPending: false,
invalidate: vi.fn(),
};
vi.mock("@calcom/atoms/hooks/useIsPlatformBookerEmbed", () => ({
useIsPlatformBookerEmbed: () => false,
}));
vi.mock("@calcom/atoms/hooks/useIsPlatform", () => ({
useIsPlatform: () => false,
}));
// Update mockStoreState to include all required state
const mockStoreState = {
state: "booking" as BookerState,
setState: vi.fn(),
selectedDate: "2024-01-01",
seatedEventData: {},
setSeatedEventData: vi.fn(),
tentativeSelectedTimeslots: [],
setTentativeSelectedTimeslots: vi.fn(),
dayCount: 7,
setDayCount: vi.fn(),
setSelectedTimeslot: vi.fn(),
selectedTimeslot: null,
formStep: 0,
setFormStep: vi.fn(),
bookerState: "booking",
setBookerState: vi.fn(),
layout: "default",
setLayout: vi.fn(),
};
// Update defaultProps to include missing required props
const defaultProps = {
username: "testuser",
eventSlug: "test-event",
entity: { considerUnpublished: false },
event: mockEvent,
schedule: mockSchedule,
slots: {
selectedTimeslot: "2024-01-01T10:00:00Z",
setSelectedTimeslot: vi.fn(),
allSelectedTimeslots: ["2024-01-01T10:00:00Z"],
quickAvailabilityChecks: [],
},
bookerForm: {
key: "form-key",
bookerFormErrorRef: { current: null },
formEmail: "",
bookingForm: {
watch: vi.fn(),
setValue: vi.fn(),
getValues: vi.fn().mockReturnValue({ responses: {} }),
},
errors: {},
},
bookings: {
handleBookEvent: vi.fn(),
errors: {},
loadingStates: {},
expiryTime: 0,
instantVideoMeetingUrl: "",
},
verifyEmail: {
isEmailVerificationModalVisible: false,
setEmailVerificationModalVisible: vi.fn(),
handleVerifyEmail: vi.fn(),
renderConfirmNotVerifyEmailButtonCond: true,
isVerificationCodeSending: false,
},
calendars: {
overlayBusyDates: [],
isOverlayCalendarEnabled: false,
connectedCalendars: [],
loadingConnectedCalendar: false,
onToggleCalendar: vi.fn(),
},
bookerLayout: {
shouldShowFormInDialog: false,
extraDays: 7,
columnViewExtraDays: { current: 7 },
isMobile: false,
layout: "MONTH_VIEW",
hideEventTypeDetails: false,
isEmbed: false,
bookerLayouts: { enabledLayouts: [] },
hasDarkBackground: false,
},
verifyCode: null,
isPlatform: false,
orgBannerUrl: null,
customClassNames: {},
areInstantMeetingParametersSet: false,
userLocale: "en",
hasValidLicense: true,
isBookingDryRun: false,
renderCaptcha: false,
onConnectNowInstantMeeting: vi.fn(),
onGoBackInstantMeeting: vi.fn(),
onOverlayClickNoCalendar: vi.fn(),
onClickOverlayContinue: vi.fn(),
onOverlaySwitchStateChange: vi.fn(),
extraOptions: {},
sessionUsername: null,
rescheduleUid: null,
hasSession: false,
isInstantMeeting: false,
};
describe("Booker", () => {
beforeEach(() => {
constantsScenarios.set({
PUBLIC_QUICK_AVAILABILITY_ROLLOUT: 100,
});
vi.clearAllMocks();
});
it("should render null when in loading state", () => {
useBookerStore.setState({
...mockStoreState,
state: "loading",
});
const { container } = render(<Booker {...defaultProps} />);
expect(container).toBeEmptyDOMElement();
});
it("should render DryRunMessage when in dry run mode", () => {
useBookerStore.setState({
...mockStoreState,
state: "selecting_time",
selectedDate: "2024-01-01",
selectedTimeslot: "2024-01-01T10:00:00Z",
tentativeSelectedTimeslots: ["2024-01-01T10:00:00Z"],
});
const propsWithDryRun = {
...defaultProps,
isBookingDryRun: true,
event: {
...mockEvent,
data: {
...mockEvent.data,
isDynamic: false,
},
},
};
render(<Booker {...propsWithDryRun} />);
expect(screen.getByTestId("dry-run-message")).toBeInTheDocument();
});
it("should invalidate schedule when cancelling booking form", () => {
const mockInvalidate = vi.fn();
constantsScenarios.set({
PUBLIC_INVALIDATE_AVAILABLE_SLOTS_ON_BOOKING_FORM: "true",
});
const propsWithInvalidate = {
...defaultProps,
schedule: {
...mockSchedule,
invalidate: mockInvalidate,
},
};
useBookerStore.setState({
...mockStoreState,
state: "booking",
});
render(<Booker {...propsWithInvalidate} />);
screen.logTestingPlaygroundURL();
// Trigger form cancel
const cancelButton = screen.getByRole("button", { name: /cancel/i });
cancelButton.click();
expect(mockInvalidate).toHaveBeenCalled();
expect(defaultProps.slots.setSelectedTimeslot).toHaveBeenCalledWith(null);
});
describe("Schedule and Availability Check", () => {
it("should mark timeslot as unavailable based on quick availability checks", async () => {
const propsWithQuickChecks = {
...defaultProps,
slots: {
...defaultProps.slots,
quickAvailabilityChecks: [{ utcStartIso: "2024-01-01T10:00:00Z", status: "unavailable" }],
},
};
useBookerStore.setState({
...mockStoreState,
state: "booking",
});
render(<Booker {...propsWithQuickChecks} />);
const bookEventForm = screen.getByTestId("book-event-form");
await expect(bookEventForm).toHaveAttribute("data-unavailable", "true");
});
});
});
@@ -0,0 +1,21 @@
import { vi } from "vitest";
const mockOverlayCalendar = vi.fn(() => {
return {
connectedCalendars: [],
overlayBusyDates: [],
onToggleCalendar: vi.fn(),
isOverlayCalendarEnabled: false,
loadingConnectedCalendar: false,
handleClickNoCalendar: vi.fn(),
handleSwitchStateChange: vi.fn(),
handleClickContinue: vi.fn(),
hasSession: false,
};
});
vi.mock("../OverlayCalendar", () => ({
OverlayCalendar: mockOverlayCalendar,
}));
export default mockOverlayCalendar;
@@ -0,0 +1,5 @@
vi.mock("../AvailableTimeSlots", () => ({
AvailableTimeSlots: ({ children }: { children: React.ReactNode }) => (
<div data-testid="available-time-slots">{children}</div>
),
}));
@@ -0,0 +1,3 @@
vi.mock("../components/DatePicker", () => ({
DatePicker: () => <div data-testid="date-picker">Mock Date Picker</div>,
}));
@@ -0,0 +1,5 @@
vi.mock("../DryRunMessage", () => ({
DryRunMessage: ({ isEmbed }: { isEmbed: boolean }) => (
<div data-testid="dry-run-message">Mock Dry Run Message</div>
),
}));
@@ -0,0 +1,16 @@
import { vi } from "vitest";
vi.mock("../EventMeta", () => ({
EventMeta: () => {
return (
<div>
<h2>Test Event</h2>
<p>Test Description</p>
<div>
<span>30 minutes</span>
<span>Free</span>
</div>
</div>
);
},
}));
@@ -0,0 +1,4 @@
// Mock layout components
vi.mock("../Header", () => ({
Header: ({ children }: { children: React.ReactNode }) => <div data-testid="header">{children}</div>,
}));
@@ -0,0 +1,13 @@
import { vi } from "vitest";
vi.mock("../LargeCalendar", () => ({
LargeCalendar: ({ extraDays, schedule }: { extraDays: number; schedule: any }) => {
return (
<div>
<h2>Test Large Calendar</h2>
<p>Extra Days: {extraDays}</p>
<p>Schedule: {JSON.stringify(schedule)}</p>
</div>
);
},
}));
@@ -0,0 +1,22 @@
vi.mock("../Section", () => {
const BookerSection = ({
children,
className = "",
area,
}: {
children: React.ReactNode;
className?: string;
area?: string | { default: string; month_view: string };
}) => {
console.log("BookerSection", { type: children.type, children, className, area });
return (
<div
data-testid="booker-section"
className={className}
data-area={typeof area === "string" ? area : area?.default}>
{children}
</div>
);
};
return { BookerSection };
});
@@ -0,0 +1,3 @@
vi.mock("../components/Unavailable", () => ({
NotFound: () => <div data-testid="not-found">Mock Not Found</div>,
}));
+21
View File
@@ -6,6 +6,20 @@ const mockedConstants = {
IS_PRODUCTION: false,
IS_TEAM_BILLING_ENABLED: false,
WEBSITE_URL: "",
PUBLIC_INVALIDATE_AVAILABLE_SLOTS_ON_BOOKING_FORM: true,
CLOUDFLARE_SITE_ID: "test-site-id",
CLOUDFLARE_USE_TURNSTILE_IN_BOOKER: "1",
DEFAULT_LIGHT_BRAND_COLOR: "#292929",
DEFAULT_DARK_BRAND_COLOR: "#fafafa",
CALCOM_VERSION: "0.0.0",
IS_SELF_HOSTED: false,
SEO_IMG_DEFAULT: "https://cal.com/og-image.png",
SEO_IMG_OGIMG: "https://cal.com/og-image-wide.png",
SEO_IMG_LOGO: "https://cal.com/logo.png",
CURRENT_TIMEZONE: "Europe/London",
APP_NAME: "Cal.com",
BOOKER_NUMBER_OF_DAYS_TO_LOAD: 14,
PUBLIC_QUICK_AVAILABILITY_ROLLOUT: 100,
} as typeof constants;
vi.mock("@calcom/lib/constants", () => {
@@ -31,4 +45,11 @@ export const constantsScenarios = {
// @ts-ignore
mockedConstants.WEBSITE_URL = url;
},
set: (envVariables: Record<string, string>) => {
Object.entries(envVariables).forEach(([key, value]) => {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
mockedConstants[key] = value;
});
},
};
+16
View File
@@ -0,0 +1,16 @@
import { vi } from "vitest";
const mockLogger = {
debug: vi.fn(),
error: vi.fn(),
info: vi.fn(),
log: vi.fn(),
warn: vi.fn(),
getSubLogger: vi.fn(),
};
vi.mock("@calcom/lib/logger", () => ({
default: mockLogger,
}));
export default mockLogger;
+17
View File
@@ -0,0 +1,17 @@
vi.mock("@calcom/lib/next-seo.config", () => ({
default: {
headSeo: {
siteName: "Cal.com",
},
defaultNextSeo: {
title: "Cal.com",
description: "Scheduling infrastructure for everyone.",
},
},
seoConfig: {
headSeo: {
siteName: "Cal.com",
},
},
buildSeoMeta: vi.fn().mockReturnValue({}),
}));
@@ -0,0 +1,5 @@
import { vi } from "vitest";
vi.mock("../unpublished-entity/UnpublishedEntity", () => ({
UnpublishedEntity: () => <div data-testid="unpublished-entity">Mock Unpublished Entity</div>,
}));
@@ -6,12 +6,33 @@ import { afterEach, expect, vi } from "vitest";
// For next.js webapp compponent that use "preserve" for jsx in tsconfig.json
global.React = React;
vi.mock("framer-motion", () => ({
AnimatePresence: ({ children }: { children: React.ReactNode }) => <>{children}</>,
LazyMotion: ({ children }: { children: React.ReactNode }) => <>{children}</>,
m: {
span: ({ children }: { children: React.ReactNode }) => <span>{children}</span>,
div: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
},
useReducedMotion: () => false,
useAnimate: () => [null, vi.fn()],
}));
// Add new mocks
vi.mock("next-seo", () => ({
NextSeo: () => null,
LogoJsonLd: () => null,
}));
vi.mock("@calcom/features/ee/organizations/hooks", () => ({
useOrgBrandingValues() {
return {};
},
}));
vi.mock("react-sticky-box", () => ({
default: ({ children }: { children: React.ReactNode }) => <div data-testid="sticky-box">{children}</div>,
}));
vi.mock("@calcom/features/ee/organizations/context/provider", () => ({
useOrgBranding() {
return {};
+7 -7
View File
@@ -110,7 +110,7 @@ const workspaces = packagedEmbedTestsOnly
include: ["packages/features/**/*.{test,spec}.tsx"],
exclude: ["packages/features/form-builder/**/*", "packages/features/bookings/**/*"],
environment: "jsdom",
setupFiles: ["setupVitest.ts", "packages/ui/components/test-setup.ts"],
setupFiles: ["setupVitest.ts", "packages/ui/components/test-setup.tsx"],
},
},
@@ -128,7 +128,7 @@ const workspaces = packagedEmbedTestsOnly
name: "@calcom/app-store-core",
include: ["packages/app-store/*.{test,spec}.[jt]s?(x)"],
environment: "jsdom",
setupFiles: ["packages/ui/components/test-setup.ts"],
setupFiles: ["packages/ui/components/test-setup.tsx"],
},
},
{
@@ -137,7 +137,7 @@ const workspaces = packagedEmbedTestsOnly
name: "@calcom/routing-forms",
include: ["packages/app-store/routing-forms/**/*.test.tsx"],
environment: "jsdom",
setupFiles: ["packages/ui/components/test-setup.ts"],
setupFiles: ["packages/ui/components/test-setup.tsx"],
},
},
{
@@ -146,7 +146,7 @@ const workspaces = packagedEmbedTestsOnly
name: "@calcom/ui",
include: ["packages/ui/components/**/*.{test,spec}.[jt]s?(x)"],
environment: "jsdom",
setupFiles: ["packages/ui/components/test-setup.ts"],
setupFiles: ["packages/ui/components/test-setup.tsx"],
},
},
{
@@ -155,7 +155,7 @@ const workspaces = packagedEmbedTestsOnly
name: "@calcom/features/form-builder",
include: ["packages/features/form-builder/**/*.{test,spec}.[jt]sx"],
environment: "jsdom",
setupFiles: ["packages/ui/components/test-setup.ts"],
setupFiles: ["packages/ui/components/test-setup.tsx"],
},
},
{
@@ -164,7 +164,7 @@ const workspaces = packagedEmbedTestsOnly
name: "@calcom/features/bookings",
include: ["packages/features/bookings/**/*.{test,spec}.[jt]sx"],
environment: "jsdom",
setupFiles: ["packages/ui/components/test-setup.ts"],
setupFiles: ["packages/ui/components/test-setup.tsx"],
},
},
{
@@ -173,7 +173,7 @@ const workspaces = packagedEmbedTestsOnly
name: "@calcom/web/components",
include: ["apps/web/components/**/*.{test,spec}.[jt]sx"],
environment: "jsdom",
setupFiles: ["packages/ui/components/test-setup.ts"],
setupFiles: ["packages/ui/components/test-setup.tsx"],
},
},
{