diff --git a/apps/web/modules/bookings/hooks/useBookings.test.tsx b/apps/web/modules/bookings/hooks/useBookings.test.tsx new file mode 100644 index 0000000000..88bdda50d8 --- /dev/null +++ b/apps/web/modules/bookings/hooks/useBookings.test.tsx @@ -0,0 +1,314 @@ +import React from "react"; +import { renderHook, waitFor } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { describe, it, expect, vi, beforeEach } from "vitest"; + +import { BookerStoreContext } from "@calcom/features/bookings/Booker/BookerStoreProvider"; +import { getQueryParam } from "@calcom/features/bookings/Booker/utils/query-param"; +import { bookingMetadataSchema } from "@calcom/prisma/zod-utils"; + +import { useBookings } from "./useBookings"; + +const mockBookingMetadataSchema = vi.mocked(bookingMetadataSchema); + +// Mock dependencies +vi.mock("@calcom/features/bookings/Booker/utils/query-param", () => ({ + getQueryParam: vi.fn(), + updateQueryParam: vi.fn(), +})); + +vi.mock("@calcom/features/bookings/lib/create-booking", () => ({ + createBooking: vi.fn(), +})); + +vi.mock("@calcom/features/bookings/lib/create-instant-booking", () => ({ + createInstantBooking: vi.fn(), +})); + +vi.mock("@calcom/features/bookings/lib/create-recurring-booking", () => ({ + createRecurringBooking: vi.fn(), +})); + +vi.mock("@calcom/features/bookings/lib/bookingSuccessRedirect", () => ({ + useBookingSuccessRedirect: () => vi.fn(), +})); + +vi.mock("@calcom/lib/hooks/useLocale", () => ({ + useLocale: () => ({ + t: (text: string) => text, + }), +})); + +vi.mock("@calcom/ui/components/toast", () => ({ + showToast: vi.fn(), +})); + +vi.mock("@calcom/atoms/hooks/bookings/useHandleBookEvent", () => ({ + useHandleBookEvent: () => ({ + handleBookEvent: vi.fn(), + }), +})); + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ + push: vi.fn(), + replace: vi.fn(), + back: vi.fn(), + }), +})); + +vi.mock("@calcom/features/bookings/Booker/hooks/useBookingForm", () => ({ + useBookingForm: () => ({ + bookingForm: { + watch: vi.fn(), + setValue: vi.fn(), + getValues: vi.fn().mockReturnValue({ responses: {} }), + }, + formEmail: "", + bookerFormErrorRef: { current: null }, + key: "test-key", + }), +})); + +vi.mock("@calcom/lib/webstorage", () => ({ + localStorage: { + getItem: vi.fn(), + setItem: vi.fn(), + removeItem: vi.fn(), + }, +})); + +vi.mock("@calcom/embed-core/embed-iframe", () => ({ + sdkActionManager: { + fire: vi.fn(), + }, + useIsEmbed: () => false, +})); + +vi.mock("@calcom/lib/hooks/useCompatSearchParams", () => ({ + useCompatSearchParams: () => new URLSearchParams(), +})); + +vi.mock("@calcom/features/bookings/lib/client/decoyBookingStore", () => ({ + storeDecoyBooking: vi.fn(), +})); + +vi.mock("@calcom/app-store/stripepayment/lib/client", () => ({ + createPaymentLink: vi.fn(), +})); + +// Create mock function that can be accessed after vi.mock hoisting +const mockUseQuery = vi.fn(); +vi.mock("@calcom/trpc/react", () => { + const mockUseQueryFn = vi.fn(); + // Store reference globally so we can access it in tests + (globalThis as any).__mockUseQuery = mockUseQueryFn; + return { + trpc: { + viewer: { + bookings: { + getInstantBookingLocation: { + useQuery: mockUseQueryFn, + }, + }, + }, + }, + }; +}); + +// Get the mock function reference after module is loaded +const getMockUseQuery = () => (globalThis as any).__mockUseQuery as ReturnType; + +// Mock bookingMetadataSchema.parse +vi.mock("@calcom/prisma/zod-utils", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + bookingMetadataSchema: { + ...actual.bookingMetadataSchema, + parse: vi.fn(), + }, + }; +}); + +const createMockStore = (isInstantMeeting: boolean) => { + const state = { + eventSlug: "test-event", + eventId: 1, + isInstantMeeting, + rescheduleUid: null, + rescheduledBy: null, + bookingData: null, + selectedTimeslot: null, + selectedDuration: null, + setRescheduleUid: vi.fn(), + setBookingData: vi.fn(), + }; + + return { + getState: () => state, + setState: vi.fn(), + subscribe: vi.fn(), + destroy: vi.fn(), + }; +}; + +const mockEvent = { + data: { + id: 1, + slug: "test-event", + length: 30, + requiresConfirmation: false, + recurringEvent: null, + schedulingType: null, + metadata: {}, + successRedirectUrl: null, + forwardParamsSuccessRedirect: false, + subsetOfHosts: [], + isDynamic: false, + subsetOfUsers: [], + }, + isSuccess: true, + isPending: false, +}; + +const createTestWrapper = (mockStore: ReturnType) => { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }); + + return ({ children }: { children: React.ReactNode }) => ( + + {children} + + ); +}; + +describe("useBookings - Instant Booking Query", () => { + beforeEach(() => { + vi.clearAllMocks(); + const mockUseQueryFn = getMockUseQuery(); + mockUseQueryFn.mockReturnValue({ + data: undefined, + isPending: false, + }); + }); + + it("should NOT enable instant booking query when bookingUid exists but isInstantMeeting is false (seated event scenario - THE BUG)", () => { + vi.mocked(getQueryParam).mockReturnValue("test-booking-uid"); + + const mockStore = createMockStore(false); + + renderHook(() => useBookings({ event: mockEvent, bookingForm: {} as any, metadata: {} }), { + wrapper: createTestWrapper(mockStore), + }); + + const mockUseQueryFn = getMockUseQuery(); + expect(mockUseQueryFn).toHaveBeenCalledWith( + { + bookingUid: "test-booking-uid", + }, + expect.objectContaining({ + enabled: false, // FIX: Should be false when isInstantMeeting is false + }) + ); + }); + + it("should enable instant booking query when bookingUid exists AND isInstantMeeting is true", () => { + vi.mocked(getQueryParam).mockReturnValue("test-booking-uid"); + + const mockStore = createMockStore(true); + + renderHook(() => useBookings({ event: mockEvent, bookingForm: {} as any, metadata: {} }), { + wrapper: createTestWrapper(mockStore), + }); + + const mockUseQueryFn = getMockUseQuery(); + expect(mockUseQueryFn).toHaveBeenCalledWith( + { + bookingUid: "test-booking-uid", + }, + expect.objectContaining({ + enabled: true, // Should be true when isInstantMeeting is true + }) + ); + }); + + it("should NOT set instantVideoMeetingUrl when query returns data but isInstantMeeting is false (prevents redirect bug)", async () => { + vi.mocked(getQueryParam).mockReturnValue("test-booking-uid"); + + const mockBookingData = { + booking: { + uid: "test-booking-uid", + metadata: { + videoCallUrl: "http://localhost:3000/video/test-booking-uid", + }, + }, + }; + + const mockUseQueryFn = getMockUseQuery(); + mockUseQueryFn.mockReturnValue({ + data: mockBookingData, + isPending: false, + }); + + mockBookingMetadataSchema.parse.mockReturnValue({ + videoCallUrl: "http://localhost:3000/video/test-booking-uid", + }); + + const mockStore = createMockStore(false); + + const { result } = renderHook( + () => useBookings({ event: mockEvent, bookingForm: {} as any, metadata: {} }), + { + wrapper: createTestWrapper(mockStore), + } + ); + + await waitFor(() => { + // FIX: instantVideoMeetingUrl should remain undefined when isInstantMeeting is false + // This prevents the redirect to /video/[uid] for seated events + expect(result.current.instantVideoMeetingUrl).toBeUndefined(); + }); + }); + + it("should set instantVideoMeetingUrl when query returns data AND isInstantMeeting is true", async () => { + vi.mocked(getQueryParam).mockReturnValue("test-booking-uid"); + + const mockBookingData = { + booking: { + uid: "test-booking-uid", + metadata: { + videoCallUrl: "http://localhost:3000/video/test-booking-uid", + }, + }, + }; + + const mockUseQueryFn = getMockUseQuery(); + mockUseQueryFn.mockReturnValue({ + data: mockBookingData, + isPending: false, + }); + + mockBookingMetadataSchema.parse.mockReturnValue({ + videoCallUrl: "http://localhost:3000/video/test-booking-uid", + }); + + const mockStore = createMockStore(true); + + const { result } = renderHook( + () => useBookings({ event: mockEvent, bookingForm: {} as any, metadata: {} }), + { + wrapper: createTestWrapper(mockStore), + } + ); + + await waitFor(() => { + // instantVideoMeetingUrl should be set when isInstantMeeting is true + expect(result.current.instantVideoMeetingUrl).toBe("http://localhost:3000/video/test-booking-uid"); + }); + }); +}); diff --git a/apps/web/modules/bookings/hooks/useBookings.ts b/apps/web/modules/bookings/hooks/useBookings.ts index a55433f15b..fba49b3520 100644 --- a/apps/web/modules/bookings/hooks/useBookings.ts +++ b/apps/web/modules/bookings/hooks/useBookings.ts @@ -225,7 +225,7 @@ export const useBookings = ({ event, hashedLink, bookingForm, metadata, isBookin bookingUid: bookingUid, }, { - enabled: !!bookingUid, + enabled: !!bookingUid && isInstantMeeting, refetchInterval: 2000, refetchIntervalInBackground: true, } @@ -234,7 +234,7 @@ export const useBookings = ({ event, hashedLink, bookingForm, metadata, isBookin function refactorMeWithoutEffect() { const data = _instantBooking.data; - if (!data || !data.booking) return; + if (!data || !data.booking || !isInstantMeeting) return; try { const locationVideoCallUrl: string | undefined = bookingMetadataSchema.parse( data.booking?.metadata || {} @@ -249,7 +249,7 @@ export const useBookings = ({ event, hashedLink, bookingForm, metadata, isBookin showToast(t("something_went_wrong_on_our_end"), "error"); } }, - [_instantBooking.data] + [_instantBooking.data, isInstantMeeting] ); const createBookingMutation = useMutation({