Files
calendar/packages/app-store/routing-forms/lib/processRoute.test.ts
T
Joe Au-YeungGitHubDevin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>Claude Opus 4.5
f536100224 feat: Add RoutingFormTrace (#27316)
* Add DB schema

* Init zod schema

* Init RoutingTrace and PendingRoutingTrace repository interfaces

* Create PrismaPendingRoutingTraceRepository

* Init RoutingTraceService

* Create RoutingTraceService container

* User routing trace service in routing

* Create RoutingTraceRepository and PrismaRoutingTraceRepoistory

* Add findByFormResponseId and findByQueuedFormResponseId to PendingRoutingTraceRepository

* Update DI containers

* RoutingTraceService create process booking method

* Use pending routing trace rather than URL params

* Fix schema

* Fix writing assignment reason for routed booking

* Remove  from  service

* Refactor RoutingTraceService to not rely on async local storage

* Pass RoutingTraceService through routing call

* Add attribute-logic-evaluated to routing trace step

* Add routing trace to trpc endpoint

* Add CRM routing trace step

* Fix extracting routing trace to assignment reason

* Add back CRM params to prevent refetching

* test: Add unit tests for RoutingTraceService and repositories

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

* test: Add missing mock for RoutingTraceService in getRoutedUrl tests

Also fix pre-existing lint issues in the test file:
- Add explicit types to mockForm and mockSerializableForm variables
- Add explicit type to url parameter in mockContext
- Replace 'as any' with 'as unknown as InstanceType<typeof UserRepository>'

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

* Link pending form submission to routing trace

* Clean up

* Add lookup field assignment reasons

* Rename to PendingRoutingTrace

* Add migration file

* fix: Update RoutingTraceService tests to use assignmentReasonRepository mock

- Add getStepsCount() method back to RoutingTraceService
- Add queuedFormResponseId support to processForBooking method
- Update tests to use mockAssignmentReasonRepository instead of prisma mock
- Remove test for missing routingTraceRepository (all deps now required)

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

* fix: Remove PII (organizer email) from log payload in RoutingTraceService

Addresses Cubic AI review feedback with confidence 9/10.
Logging PII violates sensitive information logging rules.

Co-Authored-By: unknown <>

* Write field values at the time of routing

* Write attributes used to route

* fix: Add CHECK constraint to ensure at least one response ID is set in routing trace tables

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

* fix: Address PR review comments for routing trace feature

- Add checkedFallback to routing trace step data (Comment 11)
- Extract hardcoded domain/step strings to constants (Comment 13)
- Use @default(now()) for createdAt in PendingRoutingTrace and RoutingTrace (Comment 15)
- Add DEFAULT CURRENT_TIMESTAMP to migration for createdAt fields

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

* fix: Use UTC-safe timezone expression for createdAt defaults in routing trace tables

Addresses Cubic AI review feedback (confidence 9/10) to prevent timezone
issues by using dbgenerated("timezone('UTC', now())") instead of bare now()
for the createdAt fields in PendingRoutingTrace and RoutingTrace models.

Co-Authored-By: unknown <>

* fix: Align migration SQL with Prisma's expected timezone syntax

Use 'UTC'::text cast in timezone() function to match Prisma's generated SQL.

Co-Authored-By: unknown <>

* fix: Revert migration SQL to match Prisma schema timezone syntax

Remove ::text cast from timezone() function to match what Prisma generates
from the schema definition.

Co-Authored-By: unknown <>

* fix: Use explicit ::text cast in timezone() for PostgreSQL compatibility

PostgreSQL normalizes timezone('UTC', now()) to timezone('UTC'::text, now())
internally. Update both schema and migration to use the explicit cast to
ensure they match and pass the migration check.

Co-Authored-By: unknown <>

* feat: Add RoutingFormTraceService domain class for type-safe routing traces

- Create RoutingFormTraceService class with routeMatched, fallbackRouteUsed,
  attributeLogicEvaluated, and attributeFallbackUsed methods
- Add ROUTING_FORM_STEPS constants
- Add unit tests

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

* feat: Integrate RoutingFormTraceService for routing form tracing

- Add routingFormTraceService parameter to findMatchingRoute
- Record route_matched or fallback_route_used step when route is selected
- Use "Default Route" as name for form-level fallback routes
- Refactor findTeamMembersMatchingAttributeLogic to use RoutingFormTraceService
- Update handleResponse to accept and pass routingFormTraceService
- Create RoutingFormTraceService in getRoutedUrl and pass through the flow

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

* test: Add test coverage for RoutingFormTraceService integration

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

* feat: Integrate RoutingFormTraceService for routing form tracing

- Add routingFormTraceService parameter to findMatchingRoute
- Record route_matched or fallback_route_used step when route is selected
- Use "Default Route" as name for form-level fallback routes
- Refactor findTeamMembersMatchingAttributeLogic to use RoutingFormTraceService
- Update handleResponse to accept and pass routingFormTraceService
- Create RoutingFormTraceService in getRoutedUrl and pass through the flow

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

* fix: Restore schema and migration from main branch

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

* fix: Use correct parameter name routingFormTraceService in tests

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

* fix: Use 'default_route' instead of 'Default Route' for fallback route name

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>
2026-01-28 17:15:22 -03:00

215 lines
6.5 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from "vitest";
import type { RoutingFormTraceService } from "@calcom/features/routing-trace/domains/RoutingFormTraceService";
import { RaqbLogicResult } from "@calcom/lib/raqb/evaluateRaqbLogic";
import type { FormResponse, SerializableForm } from "../types/types";
import { findMatchingRoute } from "./processRoute";
vi.mock("@calcom/lib/raqb/evaluateRaqbLogic", () => ({
evaluateRaqbLogic: vi.fn(),
RaqbLogicResult: {
MATCH: "MATCH",
NO_MATCH: "NO_MATCH",
LOGIC_NOT_FOUND_SO_MATCHED: "LOGIC_NOT_FOUND_SO_MATCHED",
},
}));
vi.mock("./getQueryBuilderConfig", () => ({
getQueryBuilderConfigForFormFields: vi.fn().mockReturnValue({}),
}));
const { evaluateRaqbLogic } = await import("@calcom/lib/raqb/evaluateRaqbLogic");
describe("findMatchingRoute", () => {
let mockRoutingFormTrace: RoutingFormTraceService;
beforeEach(() => {
vi.clearAllMocks();
mockRoutingFormTrace = {
routeMatched: vi.fn(),
fallbackRouteUsed: vi.fn(),
attributeLogicEvaluated: vi.fn(),
attributeFallbackUsed: vi.fn(),
} as unknown as RoutingFormTraceService;
});
const createMockForm = (
routes: Array<{
id: string;
name?: string;
isFallback?: boolean;
queryValue?: unknown;
}>
): Pick<SerializableForm<never>, "routes" | "fields"> => ({
routes: routes.map((route) => ({
id: route.id,
name: route.name,
isFallback: route.isFallback ?? false,
queryValue: route.queryValue ?? { type: "group" },
action: { type: "customPageMessage", value: "test" },
})) as never,
fields: [],
});
const createMockResponse = (): Record<string, Pick<FormResponse[string], "value">> => ({});
it("should throw error if fallback route is missing", () => {
const form = createMockForm([{ id: "route-1", name: "Route 1" }]);
expect(() =>
findMatchingRoute({
form,
response: createMockResponse(),
})
).toThrow("Fallback route is missing");
});
it("should return null if no route matches", () => {
vi.mocked(evaluateRaqbLogic).mockReturnValue(RaqbLogicResult.NO_MATCH);
const form = createMockForm([
{ id: "route-1", name: "Route 1" },
{ id: "fallback", name: "Fallback", isFallback: true },
]);
const result = findMatchingRoute({
form,
response: createMockResponse(),
});
expect(result).toBeNull();
});
describe("tracing", () => {
it("should call routeMatched when a non-fallback route matches", () => {
vi.mocked(evaluateRaqbLogic).mockReturnValue(RaqbLogicResult.MATCH);
const form = createMockForm([
{ id: "route-1", name: "Sales Route" },
{ id: "fallback", name: "Default", isFallback: true },
]);
const result = findMatchingRoute({
form,
response: createMockResponse(),
routingFormTraceService: mockRoutingFormTrace,
});
expect(result).not.toBeNull();
expect(mockRoutingFormTrace.routeMatched).toHaveBeenCalledWith({
routeId: "route-1",
routeName: "Sales Route",
});
expect(mockRoutingFormTrace.fallbackRouteUsed).not.toHaveBeenCalled();
});
it("should call fallbackRouteUsed when fallback route is used", () => {
vi.mocked(evaluateRaqbLogic)
.mockReturnValueOnce(RaqbLogicResult.NO_MATCH)
.mockReturnValueOnce(RaqbLogicResult.MATCH);
const form = createMockForm([
{ id: "route-1", name: "Sales Route" },
{ id: "fallback", name: "Default Route", isFallback: true },
]);
const result = findMatchingRoute({
form,
response: createMockResponse(),
routingFormTraceService: mockRoutingFormTrace,
});
expect(result).not.toBeNull();
expect(mockRoutingFormTrace.fallbackRouteUsed).toHaveBeenCalledWith({
routeId: "fallback",
routeName: "Default Route",
});
expect(mockRoutingFormTrace.routeMatched).not.toHaveBeenCalled();
});
it("should use 'default_route' as name when fallback route has no name", () => {
vi.mocked(evaluateRaqbLogic)
.mockReturnValueOnce(RaqbLogicResult.NO_MATCH)
.mockReturnValueOnce(RaqbLogicResult.MATCH);
const form = createMockForm([
{ id: "route-1", name: "Sales Route" },
{ id: "fallback", isFallback: true },
]);
const result = findMatchingRoute({
form,
response: createMockResponse(),
routingFormTraceService: mockRoutingFormTrace,
});
expect(result).not.toBeNull();
expect(mockRoutingFormTrace.fallbackRouteUsed).toHaveBeenCalledWith({
routeId: "fallback",
routeName: "default_route",
});
});
it("should use route id as name when route has no name", () => {
vi.mocked(evaluateRaqbLogic).mockReturnValue(RaqbLogicResult.MATCH);
const form = createMockForm([
{ id: "route-123" },
{ id: "fallback", isFallback: true },
]);
const result = findMatchingRoute({
form,
response: createMockResponse(),
routingFormTraceService: mockRoutingFormTrace,
});
expect(result).not.toBeNull();
expect(mockRoutingFormTrace.routeMatched).toHaveBeenCalledWith({
routeId: "route-123",
routeName: "route-123",
});
});
it("should not call trace methods when routingFormTrace is not provided", () => {
vi.mocked(evaluateRaqbLogic).mockReturnValue(RaqbLogicResult.MATCH);
const form = createMockForm([
{ id: "route-1", name: "Sales Route" },
{ id: "fallback", isFallback: true },
]);
const result = findMatchingRoute({
form,
response: createMockResponse(),
});
expect(result).not.toBeNull();
expect(mockRoutingFormTrace.routeMatched).not.toHaveBeenCalled();
expect(mockRoutingFormTrace.fallbackRouteUsed).not.toHaveBeenCalled();
});
it("should handle LOGIC_NOT_FOUND_SO_MATCHED as a match", () => {
vi.mocked(evaluateRaqbLogic).mockReturnValue(RaqbLogicResult.LOGIC_NOT_FOUND_SO_MATCHED);
const form = createMockForm([
{ id: "route-1", name: "Auto Match Route" },
{ id: "fallback", isFallback: true },
]);
const result = findMatchingRoute({
form,
response: createMockResponse(),
routingFormTraceService: mockRoutingFormTrace,
});
expect(result).not.toBeNull();
expect(mockRoutingFormTrace.routeMatched).toHaveBeenCalledWith({
routeId: "route-1",
routeName: "Auto Match Route",
});
});
});
});