Files
calendar/packages/features/routing-forms/lib/handleResponse.test.ts
T
Joe Au-YeungGitHubClaude Opus 4.5joe@cal.com <j.auyeung419@gmail.com>joe@cal.com <j.auyeung419@gmail.com>joe@cal.com <j.auyeung419@gmail.com>joe@cal.com <j.auyeung419@gmail.com>Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>Hariom Balhara
6089f47d24 feat: add fallback action options to routing form fallback section (#27449)
* feat: add no users found fallback action for routing forms

- Add NoUsersFoundFallbackActionType enum with CustomPageMessage and ExternalRedirectUrl options
- Add noUsersFoundFallbackAction field to zodNonRouterRoute schema
- Add NoUsersFoundFallback UI component in RouteBuilder for configuring fallback action
- Update handleResponse to return noUsersFoundFallbackAction when no team members match
- Add i18n translation keys for new UI strings

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* refactor: change fallback to use same action options as main route

- Replace noUsersFoundFallbackAction with fallbackAction that has same structure as main action
- Add action dropdown and input fields to fallback attributes query builder section
- Support Event redirect, External URL, and Custom page options in fallback
- Remove separate NoUsersFoundFallback toggle component
- Update handleResponse to return fallbackAction

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* fix: ensure fallbackAction type is always defined in onChange handlers

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* fix: add guards for route.fallbackAction in onChange handlers

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* fix: only return fallbackAction when no users are found

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* refactor: restructure fallback section to mirror main route structure

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* fix: ensure backwards compatibility for existing routes with fallbackAttributesQueryValue

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* fix: initialize fallbackAction with main event type for backwards compatibility

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* fix: include fallbackAction in getRoutesToSave to persist changes

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* fix: use fallbackAction in getRoutedUrl when no team members found

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* fix: use i18n for fallback label and add tests for fallback action

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* fix: do not trigger fallback action when CRM contact owner is found

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* fix: prioritize fallbackAction over fallbackAttributesQueryValue

When a route has a fallbackAction configured, skip the
fallbackAttributesQueryValue to ensure the fallbackAction is triggered
when no team members are found. This maintains backwards compatibility
by only using fallbackAttributesQueryValue when fallbackAction is not set.

Also treats teamMemberIdsMatchingAttributeLogic being null (routing couldn't
run, e.g., missing orgId) the same as an empty array for the purpose of
triggering the fallbackAction.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* refactor: extract RouteActionSelector shared component for action selection UI

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* fix: add i18n string for default custom page message

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* feat: add webhook trigger when routing form fallback route is hit

- Add ROUTING_FORM_FALLBACK_HIT to WebhookTriggerEvents enum in Prisma schema
- Add new trigger to routing-forms webhook triggers in constants
- Add translation string for the new webhook trigger
- Implement triggerFallbackWebhook function in formSubmissionUtils.ts
- Call webhook trigger from handleResponse.ts when fallback action is used

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* fix: remove webhook object from error log to avoid exposing secrets

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* revert: remove webhook trigger changes (to be moved to separate PR)

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* fix: only show fallback query builder when fallbackAction is explicitly set

The fallback attributes query builder was being shown for new routes
when fallbackAction was not set, causing an extra 'Add rule' button
to appear. This broke e2e tests that relied on the button index.

Now the fallback query builder is only shown when fallbackAction is
explicitly set to EventTypeRedirectUrl, which happens after the route
is saved and reloaded (via auto-initialization in useRoutes).

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* fix: use data-testid for attribute query builder selectors in e2e tests

- Add data-testid='attributes-query-builder' to the main attributes query builder div
- Update addAttributeRoutingRule and addAttributeRoutingRuleWithOperator to use the data-testid selector instead of nth() indices
- This makes the tests more robust and independent of DOM structure changes

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* fix: initialize fallbackAction for new routes in useCreateRoute hook

- New routes created via useCreateRoute now initialize fallbackAction
- This ensures new routes have a valid fallbackAction when saved
- Matches the initialization logic for existing routes in useRoutes

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* fix: initialize fallbackAction at save time when action is EventTypeRedirectUrl

- When saving routes, if fallbackAction is undefined but action is EventTypeRedirectUrl,
  initialize fallbackAction from the main action
- This handles the case where user creates a new route and changes action to an event type
- Fixes e2e test failures where form save was failing due to undefined fallbackAction

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* fix: initialize fallbackAction when main action changes to EventTypeRedirectUrl

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* fix: only show fallback section when main action has valid event type selected

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* fix: check for valid fallbackAction event type before preserving existing value

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* Give more structure to main and fallback routes

* Update comment

* Rename fallbackAttributesQueryBuilder to matchingMembersFallbackRoute

* Display custom event redirect input

* fix: address Cubic AI review feedback for routing form fallback

- Simplify getFallbackAction logic to use checkedFallback flag instead of
  manually parsing attributesQueryValue (confidence 9/10)
- Add i18n translation for 'And connect with specific team members' string
  using t('connect_with_specific_team_members') (confidence 9/10)

Co-Authored-By: unknown <>

* fix: update getFallbackAction to use checkedFallback and handle missing orgId case

- Use checkedFallback flag as primary indicator for triggering fallback action
- Also handle edge case where attribute routing was configured but couldn't run
  due to missing orgId (teamMemberIdsMatchingAttributeLogic is null)
- Update test to mock checkedFallback: true when expecting fallback action

Co-Authored-By: unknown <>

* Address UI comment

* fix: Use fallbackAttributesQueryValue when fallback action is event redirect

Only use fallbackAttributesQueryValue when:
1. fallbackAction is EventTypeRedirectUrl (attribute routing applies)
2. fallbackAction is not set (backwards compatibility)

Skip it for CustomPageMessage and ExternalRedirectUrl fallback actions
since attribute routing doesn't apply to those action types.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: correct JSX syntax error in matchingMembersFallbackRoute

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* refactor: extract getCustomSlug helper to deduplicate custom event type slug logic

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* fix: only show fallback attribute filtering for teams under orgs

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* fix: show fallback action for all team forms, gate attribute filtering on isOrganization

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: Hariom Balhara <hariombalhara@gmail.com>
2026-02-11 09:15:16 -05:00

676 lines
23 KiB
TypeScript

import "@calcom/lib/__mocks__/logger";
import { prisma } from "@calcom/prisma/__mocks__/prisma";
import { beforeEach, describe, expect, it, vi } from "vitest";
import routerGetCrmContactOwnerEmail from "@calcom/app-store/routing-forms/lib/crmRouting/routerGetCrmContactOwnerEmail";
import type { TargetRoutingFormForResponse } from "@calcom/app-store/routing-forms/lib/formSubmissionUtils";
import { onSubmissionOfFormResponse } from "@calcom/app-store/routing-forms/lib/formSubmissionUtils";
import isRouter from "@calcom/app-store/routing-forms/lib/isRouter";
import { RoutingFormResponseRepository } from "@calcom/features/routing-forms/repositories/RoutingFormResponseRepository";
import { findTeamMembersMatchingAttributeLogic } from "./findTeamMembersMatchingAttributeLogic";
import { handleResponse } from "./handleResponse";
vi.mock("./findTeamMembersMatchingAttributeLogic", () => ({
findTeamMembersMatchingAttributeLogic: vi.fn(),
}));
vi.mock("@calcom/features/routing-forms/repositories/RoutingFormResponseRepository");
const mockRoutingFormResponseRepository = {
recordQueuedFormResponse: vi.fn(),
recordFormResponse: vi.fn(),
};
vi.mock("@calcom/app-store/routing-forms/lib/crmRouting/routerGetCrmContactOwnerEmail", () => ({
default: vi.fn(),
}));
vi.mock("@calcom/app-store/routing-forms/lib/formSubmissionUtils", () => ({
onSubmissionOfFormResponse: vi.fn(),
}));
vi.mock("@calcom/app-store/routing-forms/lib/isRouter", () => ({
default: vi.fn(),
}));
vi.mock("@calcom/lib/sentryWrapper", () => ({
withReporting: (fn: unknown) => fn,
}));
vi.mock("@calcom/prisma", () => ({
prisma,
}));
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",
timeFormat: null,
locale: null,
},
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: Record<
string,
{
value: (string | number | string[]) & (string | number | string[] | undefined);
label: string;
identifier?: string | undefined;
}
> = {
name: { value: "John Doe", label: "Name" },
email: { value: "john.doe@example.com", label: "Email" },
};
describe("handleResponse", () => {
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(RoutingFormResponseRepository).mockImplementation(function () {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return mockRoutingFormResponseRepository as any;
});
});
it("should throw an Error for missing required fields", async () => {
await expect(
handleResponse({
response: { email: { value: "test@test.com", label: "Email" } }, // Name is missing
form: mockForm,
identifierKeyedResponse: null,
formFillerId: "user1",
chosenRouteId: null,
isPreview: false,
})
).rejects.toThrow(/Missing required fields Name/);
});
it("should throw an Error for invalid email", async () => {
await expect(
handleResponse({
response: {
name: { value: "John Doe", label: "Name" },
email: { value: "invalid-email", label: "Email" },
},
form: mockForm,
identifierKeyedResponse: null,
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,
uuid: "d8b4b7d2-3f45-4f67-9aa1-98c4b49cf283",
formId: mockForm.id,
response: mockResponse,
chosenRouteId: null,
formFillerId: "user1",
routedToBookingUid: null,
createdAt: new Date(),
updatedAt: new Date(),
};
vi.mocked(mockRoutingFormResponseRepository.recordFormResponse).mockResolvedValue(dbFormResponse);
const result = await handleResponse({
response: mockResponse,
form: mockForm,
identifierKeyedResponse: null,
formFillerId: "user1",
chosenRouteId: null,
isPreview: false,
});
expect(mockRoutingFormResponseRepository.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(mockRoutingFormResponseRepository.recordQueuedFormResponse).mockResolvedValue(queuedResponse);
const result = await handleResponse({
response: mockResponse,
form: mockForm,
identifierKeyedResponse: null,
formFillerId: "user1",
chosenRouteId: null,
isPreview: false,
queueFormResponse: true,
});
expect(mockRoutingFormResponseRepository.recordQueuedFormResponse).toHaveBeenCalledWith({
formId: mockForm.id,
response: mockResponse,
chosenRouteId: null,
});
expect(mockRoutingFormResponseRepository.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,
identifierKeyedResponse: null,
formFillerId: "user1",
chosenRouteId: null,
isPreview: true,
});
expect(mockRoutingFormResponseRepository.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,
identifierKeyedResponse: null,
formFillerId: "user1",
chosenRouteId: null,
isPreview: true,
queueFormResponse: true,
});
expect(mockRoutingFormResponseRepository.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",
// eslint-disable-next-line @typescript-eslint/no-explicit-any
queryValue: { type: "group", children1: {} } as any,
action: {
type: "customPageMessage" as const,
value: "Thank you for your submission!",
},
attributeRoutingConfig: null,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
attributesQueryValue: { type: "group", children1: {} } as any,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
fallbackAttributesQueryValue: { type: "group", children1: {} } as any,
isFallback: false,
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const formWithRoute: TargetRoutingFormForResponse = { ...mockForm, routes: [chosenRoute as any] };
vi.mocked(routerGetCrmContactOwnerEmail).mockResolvedValue({
email: "owner@example.com",
recordType: "contact",
crmAppSlug: "hubspot",
recordId: "123",
});
vi.mocked(findTeamMembersMatchingAttributeLogic).mockResolvedValue({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
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,
identifierKeyedResponse: {
name: "John Doe",
email: "john.doe@example.com",
},
fetchCrm: true,
});
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,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
routes: [chosenRoute as any],
};
await expect(
handleResponse({
response: mockResponse,
form: formWithRoute,
identifierKeyedResponse: null,
formFillerId: "user1",
chosenRouteId: "route1",
isPreview: false,
})
).rejects.toThrow(/Chosen route is a router/);
});
describe("Fallback action", () => {
// Mock attributesQueryValue with actual rules
const mockAttributesQueryValueWithRules = {
type: "group",
children1: {
"rule-1": {
type: "rule",
properties: {
field: "attr-1",
operator: "select_equals",
value: ["option-1"],
},
},
},
};
it("should return fallbackAction when no team members match attribute logic", async () => {
const fallbackAction = {
type: "externalRedirectUrl" as const,
value: "https://example.com/fallback",
};
const chosenRoute = {
id: "route1",
// eslint-disable-next-line @typescript-eslint/no-explicit-any
queryValue: { type: "group", children1: {} } as any,
action: {
type: "eventTypeRedirectUrl" as const,
value: "team/30min",
},
attributeRoutingConfig: null,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
attributesQueryValue: mockAttributesQueryValueWithRules as any,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
fallbackAttributesQueryValue: { type: "group", children1: {} } as any,
fallbackAction,
isFallback: false,
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const formWithRoute: TargetRoutingFormForResponse = { ...mockForm, routes: [chosenRoute as any] };
vi.mocked(findTeamMembersMatchingAttributeLogic).mockResolvedValue({
teamMembersMatchingAttributeLogic: [], // Empty array = no users found
checkedFallback: true, // Fallback was checked and triggered
fallbackAttributeLogicBuildingWarnings: [],
mainAttributeLogicBuildingWarnings: [],
timeTaken: {},
});
const result = await handleResponse({
response: mockResponse,
form: formWithRoute,
formFillerId: "user1",
chosenRouteId: "route1",
isPreview: true,
identifierKeyedResponse: {
name: "John Doe",
email: "john.doe@example.com",
},
});
expect(result.teamMembersMatchingAttributeLogic).toEqual([]);
expect(result.fallbackAction).toEqual(fallbackAction);
// Verify that fallbackAttributesQueryValue is NOT passed when fallbackAction is configured
expect(findTeamMembersMatchingAttributeLogic).toHaveBeenCalledWith(
expect.objectContaining({
fallbackAttributesQueryValue: undefined,
}),
expect.anything()
);
});
it("should return null fallbackAction when team members are found", async () => {
const fallbackAction = {
type: "externalRedirectUrl" as const,
value: "https://example.com/fallback",
};
const chosenRoute = {
id: "route1",
// eslint-disable-next-line @typescript-eslint/no-explicit-any
queryValue: { type: "group", children1: {} } as any,
action: {
type: "eventTypeRedirectUrl" as const,
value: "team/30min",
},
attributeRoutingConfig: null,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
attributesQueryValue: mockAttributesQueryValueWithRules as any,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
fallbackAttributesQueryValue: { type: "group", children1: {} } as any,
fallbackAction,
isFallback: false,
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const formWithRoute: TargetRoutingFormForResponse = { ...mockForm, routes: [chosenRoute as any] };
vi.mocked(findTeamMembersMatchingAttributeLogic).mockResolvedValue({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
teamMembersMatchingAttributeLogic: [{ userId: 123, result: "MATCH" as any }],
checkedFallback: false,
fallbackAttributeLogicBuildingWarnings: [],
mainAttributeLogicBuildingWarnings: [],
timeTaken: {},
});
const result = await handleResponse({
response: mockResponse,
form: formWithRoute,
formFillerId: "user1",
chosenRouteId: "route1",
isPreview: true,
identifierKeyedResponse: {
name: "John Doe",
email: "john.doe@example.com",
},
});
expect(result.teamMembersMatchingAttributeLogic).toEqual([123]);
expect(result.fallbackAction).toBeNull();
});
it("should return null fallbackAction when route has no fallbackAction configured", async () => {
const chosenRoute = {
id: "route1",
// eslint-disable-next-line @typescript-eslint/no-explicit-any
queryValue: { type: "group", children1: {} } as any,
action: {
type: "eventTypeRedirectUrl" as const,
value: "team/30min",
},
attributeRoutingConfig: null,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
attributesQueryValue: { type: "group", children1: {} } as any,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
fallbackAttributesQueryValue: { type: "group", children1: {} } as any,
// No fallbackAction configured
isFallback: false,
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const formWithRoute: TargetRoutingFormForResponse = { ...mockForm, routes: [chosenRoute as any] };
vi.mocked(findTeamMembersMatchingAttributeLogic).mockResolvedValue({
teamMembersMatchingAttributeLogic: [], // Empty array = no users found
checkedFallback: false,
fallbackAttributeLogicBuildingWarnings: [],
mainAttributeLogicBuildingWarnings: [],
timeTaken: {},
});
const result = await handleResponse({
response: mockResponse,
form: formWithRoute,
formFillerId: "user1",
chosenRouteId: "route1",
isPreview: true,
identifierKeyedResponse: {
name: "John Doe",
email: "john.doe@example.com",
},
});
expect(result.teamMembersMatchingAttributeLogic).toEqual([]);
expect(result.fallbackAction).toBeNull();
// Verify that fallbackAttributesQueryValue IS passed when fallbackAction is NOT configured (backwards compatibility)
expect(findTeamMembersMatchingAttributeLogic).toHaveBeenCalledWith(
expect.objectContaining({
fallbackAttributesQueryValue: { type: "group", children1: {} },
}),
expect.anything()
);
});
it("should return null fallbackAction when teamMembersMatchingAttributeLogic is null (non-team form)", async () => {
const fallbackAction = {
type: "externalRedirectUrl" as const,
value: "https://example.com/fallback",
};
const chosenRoute = {
id: "route1",
// eslint-disable-next-line @typescript-eslint/no-explicit-any
queryValue: { type: "group", children1: {} } as any,
action: {
type: "eventTypeRedirectUrl" as const,
value: "team/30min",
},
attributeRoutingConfig: null,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
attributesQueryValue: { type: "group", children1: {} } as any,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
fallbackAttributesQueryValue: { type: "group", children1: {} } as any,
fallbackAction,
isFallback: false,
};
// Non-team form (no teamId or orgId)
const nonTeamForm: TargetRoutingFormForResponse = {
...mockForm,
teamId: null,
team: null,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
routes: [chosenRoute as any],
};
const result = await handleResponse({
response: mockResponse,
form: nonTeamForm,
formFillerId: "user1",
chosenRouteId: "route1",
isPreview: true,
identifierKeyedResponse: {
name: "John Doe",
email: "john.doe@example.com",
},
});
// teamMembersMatchingAttributeLogic is null for non-team forms
expect(result.teamMembersMatchingAttributeLogic).toBeNull();
// fallbackAction should be null because attribute routing has no rules configured (empty children1)
expect(result.fallbackAction).toBeNull();
});
it("should return fallbackAction when team has no orgId but attribute routing is configured", async () => {
const fallbackAction = {
type: "externalRedirectUrl" as const,
value: "https://example.com/fallback",
};
const chosenRoute = {
id: "route1",
// eslint-disable-next-line @typescript-eslint/no-explicit-any
queryValue: { type: "group", children1: {} } as any,
action: {
type: "eventTypeRedirectUrl" as const,
value: "team/30min",
},
attributeRoutingConfig: null,
// Route has attribute routing rules configured
// eslint-disable-next-line @typescript-eslint/no-explicit-any
attributesQueryValue: mockAttributesQueryValueWithRules as any,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
fallbackAttributesQueryValue: { type: "group", children1: {} } as any,
fallbackAction,
isFallback: false,
};
// Team form but team has no parentId (not part of an org)
const teamWithoutOrgForm: TargetRoutingFormForResponse = {
...mockForm,
teamId: 123,
team: { parentId: null }, // No org
// eslint-disable-next-line @typescript-eslint/no-explicit-any
routes: [chosenRoute as any],
};
const result = await handleResponse({
response: mockResponse,
form: teamWithoutOrgForm,
formFillerId: "user1",
chosenRouteId: "route1",
isPreview: true,
identifierKeyedResponse: {
name: "John Doe",
email: "john.doe@example.com",
},
});
// teamMembersMatchingAttributeLogic is null because orgId is missing (attribute routing couldn't run)
expect(result.teamMembersMatchingAttributeLogic).toBeNull();
// fallbackAction SHOULD be returned because attribute routing was configured but couldn't run
expect(result.fallbackAction).toEqual(fallbackAction);
});
it("should return null fallbackAction when no team members found but CRM contact owner exists", async () => {
const fallbackAction = {
type: "externalRedirectUrl" as const,
value: "https://example.com/fallback",
};
const chosenRoute = {
id: "route1",
// eslint-disable-next-line @typescript-eslint/no-explicit-any
queryValue: { type: "group", children1: {} } as any,
action: {
type: "eventTypeRedirectUrl" as const,
value: "team/30min",
},
attributeRoutingConfig: null,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
attributesQueryValue: mockAttributesQueryValueWithRules as any,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
fallbackAttributesQueryValue: { type: "group", children1: {} } as any,
fallbackAction,
isFallback: false,
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const formWithRoute: TargetRoutingFormForResponse = { ...mockForm, routes: [chosenRoute as any] };
vi.mocked(findTeamMembersMatchingAttributeLogic).mockResolvedValue({
teamMembersMatchingAttributeLogic: [], // Empty array = no users found
checkedFallback: false,
fallbackAttributeLogicBuildingWarnings: [],
mainAttributeLogicBuildingWarnings: [],
timeTaken: {},
});
// Mock CRM contact owner found
vi.mocked(routerGetCrmContactOwnerEmail).mockResolvedValue({
email: "crm-owner@example.com",
recordType: "contact",
crmAppSlug: "hubspot",
recordId: "123",
});
const result = await handleResponse({
response: mockResponse,
form: formWithRoute,
formFillerId: "user1",
chosenRouteId: "route1",
isPreview: true,
fetchCrm: true,
identifierKeyedResponse: {
name: "John Doe",
email: "john.doe@example.com",
},
});
expect(result.teamMembersMatchingAttributeLogic).toEqual([]);
expect(result.crmContactOwnerEmail).toEqual("crm-owner@example.com");
// fallbackAction should be null because CRM contact owner was found
expect(result.fallbackAction).toBeNull();
});
});
});