Files
calendar/packages/features/routing-trace/services/RoutingTraceService.test.ts
T
Joe Au-YeungGitHubunknown <>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>Pedro Castro
620445d85f feat: Use RoutingTraceService to write assignment reasons (#27225)
* 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 <>

* fix: Use CURRENT_TIMESTAMP for createdAt defaults (standard Cal.com pattern)

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

* fix: Use @default(now()) for createdAt in routing trace tables to match migration

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: Pedro Castro <pedro@cal.com>
2026-01-28 12:45:15 -05:00

664 lines
22 KiB
TypeScript

import type { AssignmentReasonRepository } from "@calcom/features/assignment-reason/repositories/AssignmentReasonRepository";
import { AssignmentReasonEnum } from "@calcom/prisma/enums";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type {
IPendingRoutingTraceRepository,
PendingRoutingTraceRecord,
} from "../repositories/PendingRoutingTraceRepository.interface";
import type { IRoutingTraceRepository } from "../repositories/RoutingTraceRepository.interface";
import { RoutingTraceService } from "./RoutingTraceService";
describe("RoutingTraceService", () => {
let service: RoutingTraceService;
let mockPendingRoutingTraceRepository: IPendingRoutingTraceRepository;
let mockRoutingTraceRepository: IRoutingTraceRepository;
let mockAssignmentReasonRepository: Pick<AssignmentReasonRepository, "createAssignmentReason">;
function createMockPendingRoutingTraceRepository(): IPendingRoutingTraceRepository {
return {
create: vi.fn(),
findByFormResponseId: vi.fn(),
findByQueuedFormResponseId: vi.fn(),
};
}
function createMockRoutingTraceRepository(): IRoutingTraceRepository {
return {
create: vi.fn(),
};
}
function createMockAssignmentReasonRepository(): Pick<AssignmentReasonRepository, "createAssignmentReason"> {
return {
createAssignmentReason: vi.fn(),
};
}
beforeEach(() => {
vi.clearAllMocks();
mockPendingRoutingTraceRepository = createMockPendingRoutingTraceRepository();
mockRoutingTraceRepository = createMockRoutingTraceRepository();
mockAssignmentReasonRepository = createMockAssignmentReasonRepository();
service = new RoutingTraceService({
pendingRoutingTraceRepository: mockPendingRoutingTraceRepository,
routingTraceRepository: mockRoutingTraceRepository,
assignmentReasonRepository: mockAssignmentReasonRepository as AssignmentReasonRepository,
});
});
describe("addStep", () => {
it("should add a step to the routing trace", () => {
service.addStep({
domain: "routing_form",
step: "attribute-logic-evaluated",
data: { routeIsFallback: false },
});
expect(service.getStepsCount()).toBe(1);
});
it("should add multiple steps to the routing trace", () => {
service.addStep({
domain: "routing_form",
step: "attribute-logic-evaluated",
data: { routeIsFallback: false },
});
service.addStep({
domain: "salesforce",
step: "salesforce_assignment",
data: { email: "owner@example.com" },
});
expect(service.getStepsCount()).toBe(2);
});
it("should add step with empty data by default", () => {
service.addStep({
domain: "test_domain",
step: "test_step",
});
expect(service.getStepsCount()).toBe(1);
});
});
describe("getStepsCount", () => {
it("should return 0 when no steps have been added", () => {
expect(service.getStepsCount()).toBe(0);
});
it("should return correct count after adding steps", () => {
service.addStep({ domain: "test", step: "step1" });
service.addStep({ domain: "test", step: "step2" });
service.addStep({ domain: "test", step: "step3" });
expect(service.getStepsCount()).toBe(3);
});
});
describe("savePendingRoutingTrace", () => {
it("should save pending trace with formResponseId", async () => {
service.addStep({
domain: "routing_form",
step: "attribute-logic-evaluated",
data: { routeIsFallback: false },
});
await service.savePendingRoutingTrace({ formResponseId: 123 });
expect(mockPendingRoutingTraceRepository.create).toHaveBeenCalledWith({
trace: expect.arrayContaining([
expect.objectContaining({
domain: "routing_form",
step: "attribute-logic-evaluated",
data: { routeIsFallback: false },
}),
]),
formResponseId: 123,
});
});
it("should save pending trace with queuedFormResponseId", async () => {
service.addStep({
domain: "routing_form",
step: "attribute-logic-evaluated",
data: { routeIsFallback: true },
});
await service.savePendingRoutingTrace({ queuedFormResponseId: "queued-123" });
expect(mockPendingRoutingTraceRepository.create).toHaveBeenCalledWith({
trace: expect.arrayContaining([
expect.objectContaining({
domain: "routing_form",
step: "attribute-logic-evaluated",
data: { routeIsFallback: true },
}),
]),
queuedFormResponseId: "queued-123",
});
});
});
describe("processForBooking", () => {
const baseProcessArgs = {
bookingId: 1,
bookingUid: "booking-uid-123",
organizerEmail: "organizer@example.com",
isRerouting: false,
};
it("should return null if no pending trace is found", async () => {
vi.mocked(mockPendingRoutingTraceRepository.findByFormResponseId).mockResolvedValue(null);
const result = await service.processForBooking({
...baseProcessArgs,
formResponseId: 123,
});
expect(result).toBeNull();
});
it("should return null if no pending trace is found for queuedFormResponseId", async () => {
vi.mocked(mockPendingRoutingTraceRepository.findByQueuedFormResponseId).mockResolvedValue(null);
const result = await service.processForBooking({
...baseProcessArgs,
queuedFormResponseId: "queued-123",
});
expect(result).toBeNull();
});
describe("routing form assignment", () => {
it("should extract ROUTING_FORM_ROUTING assignment reason", async () => {
const pendingTrace: PendingRoutingTraceRecord = {
id: "pending-1",
createdAt: new Date(),
trace: [
{
domain: "routing_form",
step: "attribute-logic-evaluated",
timestamp: Date.now(),
data: { routeIsFallback: false },
},
],
formResponseId: 123,
queuedFormResponseId: null,
};
vi.mocked(mockPendingRoutingTraceRepository.findByFormResponseId).mockResolvedValue(pendingTrace);
vi.mocked(mockAssignmentReasonRepository.createAssignmentReason).mockResolvedValue({
id: 1,
bookingId: 1,
reasonEnum: AssignmentReasonEnum.ROUTING_FORM_ROUTING,
reasonString: "",
});
vi.mocked(mockRoutingTraceRepository.create).mockResolvedValue({
id: "trace-1",
createdAt: new Date(),
trace: pendingTrace.trace,
formResponseId: 123,
queuedFormResponseId: null,
bookingUid: "booking-uid-123",
assignmentReasonId: 1,
});
const result = await service.processForBooking({
...baseProcessArgs,
formResponseId: 123,
});
expect(result).toEqual({
assignmentReason: {
reasonEnum: AssignmentReasonEnum.ROUTING_FORM_ROUTING,
reasonString: "",
},
});
expect(mockAssignmentReasonRepository.createAssignmentReason).toHaveBeenCalledWith({
bookingId: 1,
reasonEnum: AssignmentReasonEnum.ROUTING_FORM_ROUTING,
reasonString: "",
});
});
it("should extract ROUTING_FORM_ROUTING_FALLBACK assignment reason when routeIsFallback is true", async () => {
const pendingTrace: PendingRoutingTraceRecord = {
id: "pending-1",
createdAt: new Date(),
trace: [
{
domain: "routing_form",
step: "attribute-logic-evaluated",
timestamp: Date.now(),
data: { routeIsFallback: true },
},
],
formResponseId: 123,
queuedFormResponseId: null,
};
vi.mocked(mockPendingRoutingTraceRepository.findByFormResponseId).mockResolvedValue(pendingTrace);
vi.mocked(mockAssignmentReasonRepository.createAssignmentReason).mockResolvedValue({
id: 1,
bookingId: 1,
reasonEnum: AssignmentReasonEnum.ROUTING_FORM_ROUTING_FALLBACK,
reasonString: "",
});
vi.mocked(mockRoutingTraceRepository.create).mockResolvedValue({
id: "trace-1",
createdAt: new Date(),
trace: pendingTrace.trace,
formResponseId: 123,
queuedFormResponseId: null,
bookingUid: "booking-uid-123",
assignmentReasonId: 1,
});
const result = await service.processForBooking({
...baseProcessArgs,
formResponseId: 123,
});
expect(result).toEqual({
assignmentReason: {
reasonEnum: AssignmentReasonEnum.ROUTING_FORM_ROUTING_FALLBACK,
reasonString: "",
},
});
});
it("should extract REROUTED assignment reason when isRerouting is true", async () => {
const pendingTrace: PendingRoutingTraceRecord = {
id: "pending-1",
createdAt: new Date(),
trace: [
{
domain: "routing_form",
step: "attribute-logic-evaluated",
timestamp: Date.now(),
data: { routeIsFallback: false },
},
],
formResponseId: 123,
queuedFormResponseId: null,
};
vi.mocked(mockPendingRoutingTraceRepository.findByFormResponseId).mockResolvedValue(pendingTrace);
vi.mocked(mockAssignmentReasonRepository.createAssignmentReason).mockResolvedValue({
id: 1,
bookingId: 1,
reasonEnum: AssignmentReasonEnum.REROUTED,
reasonString: "Rerouted by admin@example.com",
});
vi.mocked(mockRoutingTraceRepository.create).mockResolvedValue({
id: "trace-1",
createdAt: new Date(),
trace: pendingTrace.trace,
formResponseId: 123,
queuedFormResponseId: null,
bookingUid: "booking-uid-123",
assignmentReasonId: 1,
});
const result = await service.processForBooking({
...baseProcessArgs,
formResponseId: 123,
isRerouting: true,
reroutedByEmail: "admin@example.com",
});
expect(result).toEqual({
assignmentReason: {
reasonEnum: AssignmentReasonEnum.REROUTED,
reasonString: "Rerouted by admin@example.com",
},
});
});
});
describe("salesforce assignment", () => {
it("should extract SALESFORCE_ASSIGNMENT when organizer matches CRM contact owner", async () => {
const pendingTrace: PendingRoutingTraceRecord = {
id: "pending-1",
createdAt: new Date(),
trace: [
{
domain: "salesforce",
step: "salesforce_assignment",
timestamp: Date.now(),
data: {
email: "organizer@example.com",
recordType: "Contact",
recordId: "003ABC123",
},
},
],
formResponseId: 123,
queuedFormResponseId: null,
};
vi.mocked(mockPendingRoutingTraceRepository.findByFormResponseId).mockResolvedValue(pendingTrace);
vi.mocked(mockAssignmentReasonRepository.createAssignmentReason).mockResolvedValue({
id: 1,
bookingId: 1,
reasonEnum: AssignmentReasonEnum.SALESFORCE_ASSIGNMENT,
reasonString: "Salesforce contact owner: organizer@example.com (Contact ID: 003ABC123)",
});
vi.mocked(mockRoutingTraceRepository.create).mockResolvedValue({
id: "trace-1",
createdAt: new Date(),
trace: pendingTrace.trace,
formResponseId: 123,
queuedFormResponseId: null,
bookingUid: "booking-uid-123",
assignmentReasonId: 1,
});
const result = await service.processForBooking({
...baseProcessArgs,
formResponseId: 123,
});
expect(result).toEqual({
assignmentReason: {
reasonEnum: AssignmentReasonEnum.SALESFORCE_ASSIGNMENT,
reasonString: "Salesforce contact owner: organizer@example.com (Contact ID: 003ABC123)",
},
});
});
it("should use account lookup field reason string when rrSkipToAccountLookupField is true", async () => {
const pendingTrace: PendingRoutingTraceRecord = {
id: "pending-1",
createdAt: new Date(),
trace: [
{
domain: "salesforce",
step: "salesforce_assignment",
timestamp: Date.now(),
data: {
email: "organizer@example.com",
recordType: "Account",
recordId: "001ABC123",
rrSkipToAccountLookupField: true,
rrSKipToAccountLookupFieldName: "Account_Owner__c",
},
},
],
formResponseId: 123,
queuedFormResponseId: null,
};
vi.mocked(mockPendingRoutingTraceRepository.findByFormResponseId).mockResolvedValue(pendingTrace);
vi.mocked(mockAssignmentReasonRepository.createAssignmentReason).mockResolvedValue({
id: 1,
bookingId: 1,
reasonEnum: AssignmentReasonEnum.SALESFORCE_ASSIGNMENT,
reasonString:
"Salesforce account lookup field: Account_Owner__c - organizer@example.com (Account ID: 001ABC123)",
});
vi.mocked(mockRoutingTraceRepository.create).mockResolvedValue({
id: "trace-1",
createdAt: new Date(),
trace: pendingTrace.trace,
formResponseId: 123,
queuedFormResponseId: null,
bookingUid: "booking-uid-123",
assignmentReasonId: 1,
});
const result = await service.processForBooking({
...baseProcessArgs,
formResponseId: 123,
});
expect(result).toEqual({
assignmentReason: {
reasonEnum: AssignmentReasonEnum.SALESFORCE_ASSIGNMENT,
reasonString:
"Salesforce account lookup field: Account_Owner__c - organizer@example.com (Account ID: 001ABC123)",
},
});
});
it("should fall back to routing form assignment when organizer does not match CRM contact owner", async () => {
const pendingTrace: PendingRoutingTraceRecord = {
id: "pending-1",
createdAt: new Date(),
trace: [
{
domain: "salesforce",
step: "salesforce_assignment",
timestamp: Date.now(),
data: {
email: "different-owner@example.com",
recordType: "Contact",
recordId: "003ABC123",
},
},
{
domain: "routing_form",
step: "attribute-logic-evaluated",
timestamp: Date.now(),
data: { routeIsFallback: false },
},
],
formResponseId: 123,
queuedFormResponseId: null,
};
vi.mocked(mockPendingRoutingTraceRepository.findByFormResponseId).mockResolvedValue(pendingTrace);
vi.mocked(mockAssignmentReasonRepository.createAssignmentReason).mockResolvedValue({
id: 1,
bookingId: 1,
reasonEnum: AssignmentReasonEnum.ROUTING_FORM_ROUTING,
reasonString: "",
});
vi.mocked(mockRoutingTraceRepository.create).mockResolvedValue({
id: "trace-1",
createdAt: new Date(),
trace: pendingTrace.trace,
formResponseId: 123,
queuedFormResponseId: null,
bookingUid: "booking-uid-123",
assignmentReasonId: 1,
});
const result = await service.processForBooking({
...baseProcessArgs,
formResponseId: 123,
});
expect(result).toEqual({
assignmentReason: {
reasonEnum: AssignmentReasonEnum.ROUTING_FORM_ROUTING,
reasonString: "",
},
});
});
it("should handle case-insensitive email matching for CRM assignment", async () => {
const pendingTrace: PendingRoutingTraceRecord = {
id: "pending-1",
createdAt: new Date(),
trace: [
{
domain: "salesforce",
step: "salesforce_assignment",
timestamp: Date.now(),
data: {
email: "ORGANIZER@EXAMPLE.COM",
recordType: "Contact",
recordId: "003ABC123",
},
},
],
formResponseId: 123,
queuedFormResponseId: null,
};
vi.mocked(mockPendingRoutingTraceRepository.findByFormResponseId).mockResolvedValue(pendingTrace);
vi.mocked(mockAssignmentReasonRepository.createAssignmentReason).mockResolvedValue({
id: 1,
bookingId: 1,
reasonEnum: AssignmentReasonEnum.SALESFORCE_ASSIGNMENT,
reasonString: "Salesforce contact owner: ORGANIZER@EXAMPLE.COM (Contact ID: 003ABC123)",
});
vi.mocked(mockRoutingTraceRepository.create).mockResolvedValue({
id: "trace-1",
createdAt: new Date(),
trace: pendingTrace.trace,
formResponseId: 123,
queuedFormResponseId: null,
bookingUid: "booking-uid-123",
assignmentReasonId: 1,
});
const result = await service.processForBooking({
...baseProcessArgs,
formResponseId: 123,
});
expect(result?.assignmentReason?.reasonEnum).toBe(AssignmentReasonEnum.SALESFORCE_ASSIGNMENT);
});
});
it("should create permanent routing trace with assignment reason", async () => {
const pendingTrace: PendingRoutingTraceRecord = {
id: "pending-1",
createdAt: new Date(),
trace: [
{
domain: "routing_form",
step: "attribute-logic-evaluated",
timestamp: Date.now(),
data: { routeIsFallback: false },
},
],
formResponseId: 123,
queuedFormResponseId: null,
};
vi.mocked(mockPendingRoutingTraceRepository.findByFormResponseId).mockResolvedValue(pendingTrace);
vi.mocked(mockAssignmentReasonRepository.createAssignmentReason).mockResolvedValue({
id: 42,
bookingId: 1,
reasonEnum: AssignmentReasonEnum.ROUTING_FORM_ROUTING,
reasonString: "",
});
vi.mocked(mockRoutingTraceRepository.create).mockResolvedValue({
id: "trace-1",
createdAt: new Date(),
trace: pendingTrace.trace,
formResponseId: 123,
queuedFormResponseId: null,
bookingUid: "booking-uid-123",
assignmentReasonId: 42,
});
await service.processForBooking({
...baseProcessArgs,
formResponseId: 123,
});
expect(mockRoutingTraceRepository.create).toHaveBeenCalledWith({
trace: pendingTrace.trace,
formResponseId: 123,
queuedFormResponseId: undefined,
bookingUid: "booking-uid-123",
assignmentReasonId: 42,
});
});
it("should handle pending trace without assignment reason data", async () => {
const pendingTrace: PendingRoutingTraceRecord = {
id: "pending-1",
createdAt: new Date(),
trace: [
{
domain: "other_domain",
step: "some_step",
timestamp: Date.now(),
data: {},
},
],
formResponseId: 123,
queuedFormResponseId: null,
};
vi.mocked(mockPendingRoutingTraceRepository.findByFormResponseId).mockResolvedValue(pendingTrace);
vi.mocked(mockRoutingTraceRepository.create).mockResolvedValue({
id: "trace-1",
createdAt: new Date(),
trace: pendingTrace.trace,
formResponseId: 123,
queuedFormResponseId: null,
bookingUid: "booking-uid-123",
assignmentReasonId: null,
});
const result = await service.processForBooking({
...baseProcessArgs,
formResponseId: 123,
});
expect(result).toEqual({
assignmentReason: undefined,
});
expect(mockAssignmentReasonRepository.createAssignmentReason).not.toHaveBeenCalled();
expect(mockRoutingTraceRepository.create).toHaveBeenCalledWith({
trace: pendingTrace.trace,
formResponseId: 123,
queuedFormResponseId: undefined,
bookingUid: "booking-uid-123",
assignmentReasonId: undefined,
});
});
it("should process pending trace found by queuedFormResponseId", async () => {
const pendingTrace: PendingRoutingTraceRecord = {
id: "pending-1",
createdAt: new Date(),
trace: [
{
domain: "routing_form",
step: "attribute-logic-evaluated",
timestamp: Date.now(),
data: { routeIsFallback: false },
},
],
formResponseId: null,
queuedFormResponseId: "queued-123",
};
vi.mocked(mockPendingRoutingTraceRepository.findByQueuedFormResponseId).mockResolvedValue(pendingTrace);
vi.mocked(mockAssignmentReasonRepository.createAssignmentReason).mockResolvedValue({
id: 1,
bookingId: 1,
reasonEnum: AssignmentReasonEnum.ROUTING_FORM_ROUTING,
reasonString: "",
});
vi.mocked(mockRoutingTraceRepository.create).mockResolvedValue({
id: "trace-1",
createdAt: new Date(),
trace: pendingTrace.trace,
formResponseId: null,
queuedFormResponseId: "queued-123",
bookingUid: "booking-uid-123",
assignmentReasonId: 1,
});
const result = await service.processForBooking({
...baseProcessArgs,
queuedFormResponseId: "queued-123",
});
expect(mockPendingRoutingTraceRepository.findByQueuedFormResponseId).toHaveBeenCalledWith("queued-123");
expect(result?.assignmentReason?.reasonEnum).toBe(AssignmentReasonEnum.ROUTING_FORM_ROUTING);
});
});
});