diff --git a/packages/app-store/routing-forms/lib/handleResponse.test.ts b/packages/app-store/routing-forms/lib/handleResponse.test.ts new file mode 100644 index 0000000000..3eb8dde502 --- /dev/null +++ b/packages/app-store/routing-forms/lib/handleResponse.test.ts @@ -0,0 +1,309 @@ +import "@calcom/lib/__mocks__/logger"; + +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { z } from "zod"; + +import { findTeamMembersMatchingAttributeLogic } from "@calcom/lib/raqb/findTeamMembersMatchingAttributeLogic"; +import { RoutingFormResponseRepository } from "@calcom/lib/server/repository/formResponse"; +import type { ZResponseInputSchema } from "@calcom/trpc/server/routers/viewer/routing-forms/response.schema"; + +import isRouter from "../lib/isRouter"; +import routerGetCrmContactOwnerEmail from "./crmRouting/routerGetCrmContactOwnerEmail"; +import type { TargetRoutingFormForResponse } from "./formSubmissionUtils"; +import { onSubmissionOfFormResponse } from "./formSubmissionUtils"; +import { handleResponse } from "./handleResponse"; + +vi.mock("@calcom/lib/raqb/findTeamMembersMatchingAttributeLogic", () => ({ + findTeamMembersMatchingAttributeLogic: vi.fn(), +})); + +vi.mock("@calcom/lib/server/repository/formResponse", () => ({ + RoutingFormResponseRepository: { + recordQueuedFormResponse: vi.fn(), + recordFormResponse: vi.fn(), + }, +})); + +vi.mock("./crmRouting/routerGetCrmContactOwnerEmail", () => ({ + default: vi.fn(), +})); + +vi.mock("./formSubmissionUtils", () => ({ + onSubmissionOfFormResponse: vi.fn(), +})); + +vi.mock("../lib/isRouter", () => ({ + default: vi.fn(), +})); + +vi.mock("@calcom/lib/sentryWrapper", () => ({ + withReporting: (fn: unknown) => fn, +})); + +const mockForm: TargetRoutingFormForResponse = { + id: "form-id", + name: "Test Form", + fields: [ + { + id: "name", + label: "Name", + type: "text" as const, + required: true, + identifier: "name", + placeholder: undefined, + selectText: undefined, + deleted: false, + }, + { + id: "email", + label: "Email", + type: "email" as const, + required: true, + identifier: "email", + placeholder: undefined, + selectText: undefined, + deleted: false, + }, + { + id: "guests", + label: "Guests", + type: "text" as const, + required: false, + identifier: "guests", + placeholder: undefined, + selectText: undefined, + deleted: false, + }, + ], + routes: [], + userId: 1, + teamId: 1, + user: { + id: 1, + email: "test@example.com", + }, + team: { + parentId: 2, + }, + createdAt: "2023-01-01T00:00:00.000Z", + updatedAt: "2023-01-01T00:00:00.000Z", + description: null, + disabled: false, + settings: { + emailOwnerOnSubmission: false, + }, + connectedForms: [], + routers: [], + teamMembers: [], + position: 0, + updatedById: null, +}; + +const mockResponse: z.infer["response"] = { + name: { value: "John Doe", label: "Name" }, + email: { value: "john.doe@example.com", label: "Email" }, +}; + +describe("handleResponse", () => { + beforeEach(() => { + vi.resetAllMocks(); + }); + + it("should throw a TRPCError for missing required fields", async () => { + await expect( + handleResponse({ + response: { email: { value: "test@test.com", label: "Email" } }, // Name is missing + form: mockForm, + formFillerId: "user1", + chosenRouteId: null, + isPreview: false, + }) + ).rejects.toThrow(/Missing required fields Name/); + }); + + it("should throw a TRPCError for invalid email", async () => { + await expect( + handleResponse({ + response: { + name: { value: "John Doe", label: "Name" }, + email: { value: "invalid-email", label: "Email" }, + }, + form: mockForm, + formFillerId: "user1", + chosenRouteId: null, + isPreview: false, + }) + ).rejects.toThrow(/Invalid value for fields 'Email' with value 'invalid-email' should be valid email/); + }); + + it("should record form response when not in preview and not queued", async () => { + const dbFormResponse = { + id: 1, + formId: mockForm.id, + response: mockResponse, + chosenRouteId: null, + formFillerId: "user1", + routedToBookingUid: null, + createdAt: new Date(), + updatedAt: new Date(), + }; + vi.mocked(RoutingFormResponseRepository.recordFormResponse).mockResolvedValue(dbFormResponse); + + const result = await handleResponse({ + response: mockResponse, + form: mockForm, + formFillerId: "user1", + chosenRouteId: null, + isPreview: false, + }); + + expect(RoutingFormResponseRepository.recordFormResponse).toHaveBeenCalledWith({ + formId: mockForm.id, + response: mockResponse, + chosenRouteId: null, + }); + expect(onSubmissionOfFormResponse).toHaveBeenCalledWith({ + form: mockForm, + formResponseInDb: dbFormResponse, + chosenRouteAction: null, + }); + expect(result.formResponse).toEqual(dbFormResponse); + expect(result.queuedFormResponse).toBeNull(); + }); + + it("should queue form response when queueFormResponse is true", async () => { + const queuedResponse = { + id: "queued-id", + formId: mockForm.id, + response: mockResponse, + chosenRouteId: null, + actualResponseId: null, + createdAt: new Date(), + updatedAt: new Date(), + }; + vi.mocked(RoutingFormResponseRepository.recordQueuedFormResponse).mockResolvedValue(queuedResponse); + + const result = await handleResponse({ + response: mockResponse, + form: mockForm, + formFillerId: "user1", + chosenRouteId: null, + isPreview: false, + queueFormResponse: true, + }); + + expect(RoutingFormResponseRepository.recordQueuedFormResponse).toHaveBeenCalledWith({ + formId: mockForm.id, + response: mockResponse, + chosenRouteId: null, + }); + expect(RoutingFormResponseRepository.recordFormResponse).not.toHaveBeenCalled(); + expect(onSubmissionOfFormResponse).not.toHaveBeenCalled(); + expect(result.queuedFormResponse).toEqual(queuedResponse); + expect(result.formResponse).toBeNull(); + }); + + describe("Preview mode", () => { + it("should send formResponse with id=0 and quueueFormResponse isn't true", async () => { + const result = await handleResponse({ + response: mockResponse, + form: mockForm, + formFillerId: "user1", + chosenRouteId: null, + isPreview: true, + }); + + expect(RoutingFormResponseRepository.recordFormResponse).not.toHaveBeenCalled(); + expect(onSubmissionOfFormResponse).not.toHaveBeenCalled(); + expect(result.isPreview).toBe(true); + expect(result.formResponse).toBeDefined(); + expect(result.formResponse?.id).toBe(0); + }); + + it("should send queuedFormResponse with id=00000000-0000-0000-0000-000000000000 when queueFormResponse is true", async () => { + const result = await handleResponse({ + response: mockResponse, + form: mockForm, + formFillerId: "user1", + chosenRouteId: null, + isPreview: true, + queueFormResponse: true, + }); + + expect(RoutingFormResponseRepository.recordQueuedFormResponse).not.toHaveBeenCalled(); + expect(result.isPreview).toBe(true); + expect(result.queuedFormResponse).toBeDefined(); + expect(result.queuedFormResponse?.id).toBe("00000000-0000-0000-0000-000000000000"); + expect(result.formResponse).toBeUndefined(); + }); + }); + + it("should handle chosen route and call routing logic", async () => { + const chosenRoute = { + id: "route1", + queryValue: { type: "group", children1: {} } as any, + action: { + type: "customPageMessage" as const, + value: "Thank you for your submission!", + }, + attributeRoutingConfig: null, + attributesQueryValue: { type: "group", children1: {} } as any, + fallbackAttributesQueryValue: { type: "group", children1: {} } as any, + isFallback: false, + }; + const formWithRoute: TargetRoutingFormForResponse = { ...mockForm, routes: [chosenRoute as any] }; + + vi.mocked(routerGetCrmContactOwnerEmail).mockResolvedValue({ + email: "owner@example.com", + recordType: "contact", + crmAppSlug: "hubspot", + }); + vi.mocked(findTeamMembersMatchingAttributeLogic).mockResolvedValue({ + teamMembersMatchingAttributeLogic: [{ userId: 123, result: "MATCH" as any }], + checkedFallback: false, + fallbackAttributeLogicBuildingWarnings: [], + mainAttributeLogicBuildingWarnings: [], + timeTaken: { + ttGetAttributesForLogic: 50, + }, + }); + + const result = await handleResponse({ + response: mockResponse, + form: formWithRoute, + formFillerId: "user1", + chosenRouteId: "route1", + isPreview: false, + }); + + expect(routerGetCrmContactOwnerEmail).toHaveBeenCalled(); + expect(findTeamMembersMatchingAttributeLogic).toHaveBeenCalled(); + expect(result.crmContactOwnerEmail).toBe("owner@example.com"); + expect(result.teamMembersMatchingAttributeLogic).toEqual([123]); + }); + + it("should throw error if chosen route is a router", async () => { + const chosenRoute = { + id: "route1", + name: "A router", + description: "Router description", + isRouter: true as const, + routes: [], + }; + vi.mocked(isRouter).mockReturnValue(true); + const formWithRoute: TargetRoutingFormForResponse = { + ...mockForm, + routes: [chosenRoute as any], + }; + + await expect( + handleResponse({ + response: mockResponse, + form: formWithRoute, + formFillerId: "user1", + chosenRouteId: "route1", + isPreview: false, + }) + ).rejects.toThrow(/Chosen route is a router/); + }); +}); diff --git a/packages/app-store/routing-forms/lib/substituteVariables.ts b/packages/app-store/routing-forms/lib/substituteVariables.ts index 6c5901b657..3dbc40f030 100644 --- a/packages/app-store/routing-forms/lib/substituteVariables.ts +++ b/packages/app-store/routing-forms/lib/substituteVariables.ts @@ -3,6 +3,15 @@ import slugify from "@calcom/lib/slugify"; import type { FormResponse, NonRouterRoute, Field } from "../types/types"; import getFieldIdentifier from "./getFieldIdentifier"; +/** + * Substitues variables in the target URL identified by routeValue with values from response + * e.g. {firstName} is replaced with value of the field with identifier firstName + * + * @param routeValue - The target URL with variables to be substituted + * @param response - The form response containing the values to be substituted + * @param fields - The fields of the form + * @returns The URL with variables substituted + */ export const substituteVariables = ( routeValue: NonRouterRoute["action"]["value"], response: FormResponse, diff --git a/packages/app-store/routing-forms/trpc/onFormSubmission.test.ts b/packages/app-store/routing-forms/trpc/onFormSubmission.test.ts new file mode 100644 index 0000000000..7d02313e4a --- /dev/null +++ b/packages/app-store/routing-forms/trpc/onFormSubmission.test.ts @@ -0,0 +1,168 @@ +import "@calcom/lib/__mocks__/logger"; + +import { describe, it, vi, expect, beforeEach, afterEach } from "vitest"; + +import getWebhooks from "@calcom/features/webhooks/lib/getWebhooks"; +import { sendGenericWebhookPayload } from "@calcom/features/webhooks/lib/sendPayload"; +import { WebhookTriggerEvents } from "@calcom/prisma/client"; + +import { _onFormSubmission } from "./utils"; + +// Mock dependencies +vi.mock("@calcom/lib/getOrgIdFromMemberOrTeamId", () => ({ + default: vi.fn(() => Promise.resolve(1)), +})); +vi.mock("@calcom/features/webhooks/lib/getWebhooks", () => ({ + default: vi.fn(() => Promise.resolve([])), +})); +vi.mock("@calcom/features/webhooks/lib/sendPayload", () => ({ + sendGenericWebhookPayload: vi.fn(() => Promise.resolve()), +})); +vi.mock("@calcom/features/tasker", () => { + const tasker = { + create: vi.fn(() => Promise.resolve()), + }; + return { default: Promise.resolve(tasker) }; +}); + +const mockSendEmail = vi.fn(() => Promise.resolve()); +const mockResponseEmailConstructor = vi.fn(); +vi.mock("../emails/templates/response-email", () => ({ + default: class MockResponseEmail { + sendEmail = mockSendEmail; + constructor(...args: unknown[]) { + mockResponseEmailConstructor(...args); + } + }, +})); + +describe("_onFormSubmission", () => { + const mockForm = { + id: "form-1", + name: "Test Form", + fields: [ + { id: "field-1", identifier: "email", label: "Email", type: "email" }, + { id: "field-2", identifier: "name", label: "Name", type: "text" }, + ], + user: { id: 1, email: "test@example.com" }, + teamId: null, + settings: { emailOwnerOnSubmission: true }, + }; + + const mockResponse = { + "field-1": { label: "Email", value: "test@response.com" }, + "field-2": { label: "Name", value: "Test Name" }, + }; + + const responseId = 123; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe("Webhooks", () => { + it("should call FORM_SUBMITTED webhooks", async () => { + vi.mocked(getWebhooks).mockResolvedValueOnce([{ id: "wh-1", secret: "secret" } as any]); + + await _onFormSubmission(mockForm as any, mockResponse, responseId); + + expect(getWebhooks).toHaveBeenCalledWith({ + userId: 1, + teamId: null, + orgId: 1, + triggerEvent: WebhookTriggerEvents.FORM_SUBMITTED, + }); + expect(sendGenericWebhookPayload).toHaveBeenCalledTimes(1); + }); + + it("should schedule FORM_SUBMITTED_NO_EVENT webhooks via tasker", async () => { + const tasker = await (await import("@calcom/features/tasker")).default; + const mockWebhook = { id: "wh-no-event-1", secret: "secret" }; + const chosenAction = { type: "customPageMessage" as const, value: "test" }; + + vi.mocked(getWebhooks).mockImplementation(async (options) => { + if (options.triggerEvent === WebhookTriggerEvents.FORM_SUBMITTED_NO_EVENT) { + return [mockWebhook as any]; + } + return []; + }); + + await _onFormSubmission(mockForm as any, mockResponse, responseId, chosenAction); + + expect(getWebhooks).toHaveBeenCalledWith( + expect.objectContaining({ + triggerEvent: WebhookTriggerEvents.FORM_SUBMITTED_NO_EVENT, + }) + ); + expect(tasker.create).toHaveBeenCalledWith( + "triggerFormSubmittedNoEventWebhook", + { + responseId, + form: mockForm, + responses: { + email: { + value: "test@response.com", + response: "test@response.com", + }, + name: { value: "Test Name", response: "Test Name" }, + }, + redirect: chosenAction, + webhook: mockWebhook, + }, + { scheduledAt: expect.any(Date) } + ); + }); + }); + + describe("Response Email", () => { + it("should send response email to team members for a team form", async () => { + const teamForm = { + ...mockForm, + teamId: 1, + userWithEmails: ["team-member1@example.com", "team-member2@example.com"], + user: { id: 1, email: "test@example.com" }, + }; + + await _onFormSubmission(teamForm as any, mockResponse, responseId); + + expect(mockResponseEmailConstructor).toHaveBeenCalledWith({ + form: teamForm, + toAddresses: ["team-member1@example.com", "team-member2@example.com"], + orderedResponses: [mockResponse["field-1"], mockResponse["field-2"]], + }); + expect(mockSendEmail).toHaveBeenCalled(); + }); + + it("should send response email to owner when enabled", async () => { + const ownerForm = { + ...mockForm, + settings: { emailOwnerOnSubmission: true }, + }; + + await _onFormSubmission(ownerForm as any, mockResponse, responseId); + + expect(mockResponseEmailConstructor).toHaveBeenCalledWith({ + form: ownerForm, + toAddresses: [ownerForm.user.email], + orderedResponses: [mockResponse["field-1"], mockResponse["field-2"]], + }); + expect(mockSendEmail).toHaveBeenCalled(); + }); + + it("should not send response email to owner when disabled", async () => { + const ownerForm = { + ...mockForm, + settings: { emailOwnerOnSubmission: false }, + }; + + await _onFormSubmission(ownerForm as any, mockResponse, responseId); + + expect(mockResponseEmailConstructor).not.toHaveBeenCalled(); + expect(mockSendEmail).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/app-store/routing-forms/trpc/utils.ts b/packages/app-store/routing-forms/trpc/utils.ts index f46922159b..828efd6e75 100644 --- a/packages/app-store/routing-forms/trpc/utils.ts +++ b/packages/app-store/routing-forms/trpc/utils.ts @@ -85,7 +85,7 @@ export function getFieldResponse({ * Not called in preview mode or dry run mode * It takes care of sending webhooks and emails for form submissions */ -async function _onFormSubmission( +export async function _onFormSubmission( form: Ensure< SerializableForm & { user: Pick; userWithEmails?: string[] }, "fields" diff --git a/packages/lib/server/getRoutedUrl.test.ts b/packages/lib/server/getRoutedUrl.test.ts new file mode 100644 index 0000000000..862b036b1a --- /dev/null +++ b/packages/lib/server/getRoutedUrl.test.ts @@ -0,0 +1,280 @@ +import "@calcom/lib/__mocks__/logger"; + +import type { GetServerSidePropsContext } from "next"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { getAbsoluteEventTypeRedirectUrlWithEmbedSupport } from "@calcom/app-store/routing-forms/getEventTypeRedirectUrl"; +import { getResponseToStore } from "@calcom/app-store/routing-forms/lib/getResponseToStore"; +import { getSerializableForm } from "@calcom/app-store/routing-forms/lib/getSerializableForm"; +import { handleResponse } from "@calcom/app-store/routing-forms/lib/handleResponse"; +import { findMatchingRoute } from "@calcom/app-store/routing-forms/lib/processRoute"; +import { substituteVariables } from "@calcom/app-store/routing-forms/lib/substituteVariables"; +import { getUrlSearchParamsToForward } from "@calcom/app-store/routing-forms/pages/routing-link/getUrlSearchParamsToForward"; +import { orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains"; +import { isAuthorizedToViewFormOnOrgDomain } from "@calcom/features/routing-forms/lib/isAuthorizedToViewForm"; +import { RoutingFormRepository } from "@calcom/lib/server/repository/routingForm"; +import { UserRepository } from "@calcom/lib/server/repository/user"; + +import { getRoutedUrl } from "./getRoutedUrl"; + +// Mock dependencies +vi.mock("@calcom/app-store/routing-forms/lib/handleResponse"); +vi.mock("@calcom/lib/server/repository/routingForm"); +vi.mock("@calcom/lib/server/repository/user"); +vi.mock("@calcom/features/ee/organizations/lib/orgDomains"); +vi.mock("@calcom/features/routing-forms/lib/isAuthorizedToViewForm"); +vi.mock("@calcom/app-store/routing-forms/lib/getSerializableForm"); +vi.mock("@calcom/app-store/routing-forms/lib/getResponseToStore"); +vi.mock("@calcom/app-store/routing-forms/lib/processRoute"); +vi.mock("@calcom/app-store/routing-forms/lib/substituteVariables"); +vi.mock("@calcom/app-store/routing-forms/pages/routing-link/getUrlSearchParamsToForward"); +vi.mock("@calcom/app-store/routing-forms/getEventTypeRedirectUrl"); +vi.mock("@calcom/app-store/routing-forms/enrichFormWithMigrationData", () => ({ + enrichFormWithMigrationData: vi.fn((form) => form), +})); +vi.mock("@calcom/lib/sentryWrapper", () => ({ + withReporting: (fn: unknown) => fn, +})); + +const mockForm = { + id: "form-id", + user: { id: 1, name: "Test User" }, + team: null, + name: "Test Form", + fields: [], + routes: [], + userId: 1, + teamId: null, + createdAt: new Date(), + updatedAt: new Date(), + description: null, + enabled: true, + isDefault: false, + fieldsOrder: [], + position: 0, + slug: "test-form", +}; + +const mockSerializableForm = { + id: "form-id", + fields: [{ id: "email", type: "email", label: "Email", identifier: "email" }], + routes: [], + user: { id: 1 }, +}; + +const mockContext = ( + query: Record, + url = "/link/form-id" +): Pick => ({ + query: { form: "form-id", ...query }, + req: { url }, +}); + +describe("getRoutedUrl", () => { + beforeEach(() => { + vi.resetAllMocks(); + // Provide default mock implementations + vi.mocked(orgDomainConfig).mockReturnValue({ currentOrgDomain: null }); + vi.mocked(RoutingFormRepository.findFormByIdIncludeUserTeamAndOrg).mockResolvedValue(null); + vi.mocked(UserRepository.enrichUserWithItsProfile).mockImplementation(async ({ user }) => user); + vi.mocked(isAuthorizedToViewFormOnOrgDomain).mockReturnValue(true); + vi.mocked(getSerializableForm).mockResolvedValue(mockSerializableForm as never); + vi.mocked(findMatchingRoute).mockReturnValue(null); + vi.mocked(handleResponse).mockResolvedValue({ + teamMembersMatchingAttributeLogic: null, + formResponse: { id: 1 }, + queuedFormResponse: null, + attributeRoutingConfig: null, + timeTaken: {}, + crmContactOwnerEmail: null, + crmContactOwnerRecordType: null, + crmAppSlug: null, + isPreview: false, + }); + vi.mocked(substituteVariables).mockImplementation((value) => value); + vi.mocked(getUrlSearchParamsToForward).mockReturnValue(new URLSearchParams()); + vi.mocked(getAbsoluteEventTypeRedirectUrlWithEmbedSupport).mockImplementation( + ({ eventTypeRedirectUrl }) => eventTypeRedirectUrl + ); + vi.mocked(getResponseToStore).mockReturnValue({ email: "test@cal.com" }); + }); + + it("should return notFound if query params are invalid", async () => { + const context = { + query: { wrong_param: "value" }, // 'form' is missing + req: { url: "/link/form-id" }, + }; + // @ts-expect-error we are testing invalid input + const result = await getRoutedUrl(context); + expect(result).toEqual({ notFound: true }); + }); + + it("should return notFound if form is not found", async () => { + vi.mocked(RoutingFormRepository.findFormByIdIncludeUserTeamAndOrg).mockResolvedValue(null); + const context = mockContext({}); + const result = await getRoutedUrl(context); + expect(result).toEqual({ notFound: true }); + expect(RoutingFormRepository.findFormByIdIncludeUserTeamAndOrg).toHaveBeenCalledWith("form-id"); + }); + + it("should return notFound if user is not authorized", async () => { + vi.mocked(RoutingFormRepository.findFormByIdIncludeUserTeamAndOrg).mockResolvedValue(mockForm as never); + vi.mocked(isAuthorizedToViewFormOnOrgDomain).mockReturnValue(false); + + const context = mockContext({}); + const result = await getRoutedUrl(context); + + expect(result).toEqual({ notFound: true }); + expect(isAuthorizedToViewFormOnOrgDomain).toHaveBeenCalledWith({ + user: mockForm.user, + team: mockForm.team, + currentOrgDomain: null, + }); + }); + + it("should throw an error if no matching route is found", async () => { + vi.mocked(RoutingFormRepository.findFormByIdIncludeUserTeamAndOrg).mockResolvedValue(mockForm as never); + vi.mocked(getSerializableForm).mockResolvedValue(mockSerializableForm as never); + vi.mocked(findMatchingRoute).mockReturnValue(null); + + const context = mockContext({}); + await expect(getRoutedUrl(context)).rejects.toThrow("No matching route could be found"); + expect(findMatchingRoute).toHaveBeenCalledWith( + expect.objectContaining({ + form: mockSerializableForm, + }) + ); + }); + + it("should return props with a message for customPageMessage action", async () => { + vi.mocked(RoutingFormRepository.findFormByIdIncludeUserTeamAndOrg).mockResolvedValue(mockForm as never); + const message = "This is a custom message"; + const mockRoute = { id: "route1", action: { type: "customPageMessage", value: message } }; + vi.mocked(findMatchingRoute).mockReturnValue(mockRoute as never); + + const context = mockContext({}); + const result = await getRoutedUrl(context); + + expect(result).toEqual({ + props: { + isEmbed: false, + form: mockSerializableForm, + message: message, + errorMessage: null, + }, + }); + expect(handleResponse).toHaveBeenCalledWith( + expect.objectContaining({ + form: mockSerializableForm, + }) + ); + expect(findMatchingRoute).toHaveBeenCalledWith( + expect.objectContaining({ + form: mockSerializableForm, + }) + ); + }); + + it("should return a redirect for eventTypeRedirectUrl action and substitute variables", async () => { + vi.mocked(RoutingFormRepository.findFormByIdIncludeUserTeamAndOrg).mockResolvedValue(mockForm as never); + const redirectUrl = "test-user/30min/{email}"; + const mockRoute = { id: "route1", action: { type: "eventTypeRedirectUrl", value: redirectUrl } }; + vi.mocked(findMatchingRoute).mockReturnValue(mockRoute as never); + const substitutedUrl = "test-user/30min/test@cal.com"; + vi.mocked(substituteVariables).mockReturnValue(substitutedUrl); + vi.mocked(getAbsoluteEventTypeRedirectUrlWithEmbedSupport).mockReturnValue(`/${substitutedUrl}`); + + const context = mockContext({ email: "test@cal.com" }); + const result = await getRoutedUrl(context); + + expect(substituteVariables).toHaveBeenCalledWith( + redirectUrl, + { email: "test@cal.com" }, + mockSerializableForm.fields + ); + expect(getUrlSearchParamsToForward).toHaveBeenCalledWith( + expect.objectContaining({ + searchParams: expect.any(URLSearchParams), + formResponse: { email: "test@cal.com" }, + fields: mockSerializableForm.fields, + }) + ); + expect(getAbsoluteEventTypeRedirectUrlWithEmbedSupport).toHaveBeenCalledWith( + expect.objectContaining({ + eventTypeRedirectUrl: substitutedUrl, + }) + ); + expect(result).toEqual({ + redirect: { + destination: `/${substitutedUrl}`, + permanent: false, + }, + }); + }); + + it("should return a redirect for externalRedirectUrl action", async () => { + vi.mocked(RoutingFormRepository.findFormByIdIncludeUserTeamAndOrg).mockResolvedValue(mockForm as never); + const redirectUrl = "https://example.com"; + const mockRoute = { id: "route1", action: { type: "externalRedirectUrl", value: redirectUrl } }; + vi.mocked(findMatchingRoute).mockReturnValue(mockRoute as never); + + const context = mockContext({ email: "test@cal.com" }); + const result = await getRoutedUrl(context); + + const searchParams = new URLSearchParams({ + ...(context.query as Record), + "cal.action": "externalRedirectUrl", + }); + expect(result).toEqual({ + redirect: { + destination: `${redirectUrl}?${searchParams.toString()}`, + permanent: false, + }, + }); + expect(findMatchingRoute).toHaveBeenCalledWith( + expect.objectContaining({ + form: mockSerializableForm, + }) + ); + }); + + it("should call handleResponse with correct response prop obtained from getResponseToStore", async () => { + vi.mocked(RoutingFormRepository.findFormByIdIncludeUserTeamAndOrg).mockResolvedValue(mockForm as never); + const redirectUrl = "test-user/30min"; + const mockRoute = { id: "route1", action: { type: "eventTypeRedirectUrl", value: redirectUrl } }; + vi.mocked(findMatchingRoute).mockReturnValue(mockRoute as never); + vi.mocked(getAbsoluteEventTypeRedirectUrlWithEmbedSupport).mockReturnValue(`/${redirectUrl}`); + vi.mocked(getResponseToStore).mockReturnValue({ "xxx-xxx": { value: "test@cal.com" } } as never); + + const context = mockContext({ email: "test@cal.com" }); + await getRoutedUrl(context); + + expect(getResponseToStore).toHaveBeenCalledWith({ + formFields: mockSerializableForm.fields, + fieldsResponses: { email: "test@cal.com" }, + }); + + expect(handleResponse).toHaveBeenCalledWith( + expect.objectContaining({ response: { "xxx-xxx": { value: "test@cal.com" } } }) + ); + }); + + describe("Dry Run", () => { + it("should call handleResponse with isPreview:true", async () => { + vi.mocked(RoutingFormRepository.findFormByIdIncludeUserTeamAndOrg).mockResolvedValue(mockForm as never); + const redirectUrl = "test-user/30min"; + const mockRoute = { id: "route1", action: { type: "eventTypeRedirectUrl", value: redirectUrl } }; + vi.mocked(findMatchingRoute).mockReturnValue(mockRoute as never); + vi.mocked(getAbsoluteEventTypeRedirectUrlWithEmbedSupport).mockReturnValue(`/${redirectUrl}`); + + const context = mockContext({ "cal.isBookingDryRun": "true" }); + await getRoutedUrl(context); + + expect(handleResponse).toHaveBeenCalledWith( + expect.objectContaining({ + isPreview: true, + }) + ); + }); + }); +});