* 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>
215 lines
6.5 KiB
TypeScript
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",
|
|
});
|
|
});
|
|
});
|
|
});
|