Files
calendar/packages/trpc/server/routers/viewer/bookings/getRoutingTrace.handler.test.ts
Joe Au-YeungGitHubClaude Opus 4.5Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
7abbc8c22c feat: Routing trace presenter (#27372)
* feat: Add routing trace presenters

Add domain-specific presenters (SalesforceRoutingTracePresenter,
RoutingFormTracePresenter) that format trace steps into human-readable
strings, and a core RoutingTracePresenter that delegates to them based
on step domain. Includes unit tests for all presenters.

Co-Authored-By: Claude Opus 4.5 <[email protected]>

* feat: Add findByBookingUid to RoutingTraceRepository

Add method to look up a routing trace by booking UID, needed by the
routing trace presenter tRPC endpoint.

Co-Authored-By: Claude Opus 4.5 <[email protected]>

* feat: Add getRoutingTrace tRPC endpoint

Expose routing trace data for a booking via viewer.bookings.getRoutingTrace.
Tries the permanent RoutingTrace first (round robin bookings), then falls
back to PendingRoutingTrace via the booking's form response relation.

Co-Authored-By: Claude Opus 4.5 <[email protected]>

* feat: Add routing trace side sheet to booking list item

Add a route icon button on booking list items that came from routing
forms. Clicking it opens a side sheet displaying the full routing trace
as human-readable steps. Adds RoutingTraceSheet component, store state,
and translation key.

Co-Authored-By: Claude Opus 4.5 <[email protected]>

* refactor: Move routing trace action to dropdown menu

Move the routing trace button from a standalone icon on the booking list
item into the actions dropdown menu alongside "Report wrong assignment".
The RoutingTraceSheet is now rendered from BookingActionsDropdown.

Co-Authored-By: Claude Opus 4.5 <[email protected]>

* fix: Improve routing trace UI and handle unnamed routes

- Redesign RoutingTraceSheet with vertical timeline layout, domain badges,
  skeleton loading state, and millisecond timestamps
- Use Salesforce app-store icon for Salesforce steps
- Fall back to "Unnamed route" when route name is missing or matches route ID
- Fix dropdown menu icon to git-merge (valid icon, unique in menu)

Co-Authored-By: Claude Opus 4.5 <[email protected]>

* test: Add tests for getRoutingTrace handler and findByBookingUid repository method

Co-Authored-By: [email protected] <[email protected]>

* Abstract functions

* Fix build error

* Add permission check

* fix: Update getRoutingTrace tests to include ctx and mock BookingAccessService

Co-Authored-By: [email protected] <[email protected]>

---------

Co-authored-by: Claude Opus 4.5 <[email protected]>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-01-29 15:26:44 -03:00

250 lines
8.5 KiB
TypeScript

import { BookingAccessService } from "@calcom/features/bookings/services/BookingAccessService";
import { RoutingTracePresenter } from "@calcom/features/routing-trace/presenters/RoutingTracePresenter";
import { PrismaRoutingTraceRepository } from "@calcom/features/routing-trace/repositories/PrismaRoutingTraceRepository";
import type { RoutingTrace } from "@calcom/features/routing-trace/repositories/RoutingTraceRepository.interface";
import { prisma } from "@calcom/prisma";
import type { TrpcSessionUser } from "@calcom/trpc/server/types";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { getRoutingTraceHandler } from "./getRoutingTrace.handler";
vi.mock("@calcom/features/routing-trace/repositories/PrismaRoutingTraceRepository");
vi.mock("@calcom/features/routing-trace/presenters/RoutingTracePresenter");
vi.mock("@calcom/features/bookings/services/BookingAccessService");
vi.mock("@calcom/prisma", () => {
const mockPrisma = {
app_RoutingForms_FormResponse: {
findFirst: vi.fn(),
},
};
return {
default: mockPrisma,
prisma: mockPrisma,
};
});
describe("getRoutingTraceHandler", () => {
const mockRepository = {
findByBookingUid: vi.fn(),
};
const mockBookingAccessService = {
doesUserIdHaveAccessToBooking: vi.fn(),
};
const mockUser = {
id: 1,
email: "[email protected]",
} as NonNullable<TrpcSessionUser>;
const createCtx = () => ({
user: mockUser,
});
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(PrismaRoutingTraceRepository).mockImplementation(function () {
return mockRepository;
});
vi.mocked(BookingAccessService).mockImplementation(function () {
return mockBookingAccessService;
});
mockBookingAccessService.doesUserIdHaveAccessToBooking.mockResolvedValue(true);
});
describe("when routing trace exists in RoutingTrace table", () => {
it("should return presented steps from permanent routing trace", async () => {
const trace: RoutingTrace = [
{
domain: "routing_form",
step: "route_matched",
timestamp: 1000,
data: { routeId: "route-1", routeName: "Enterprise" },
},
{
domain: "salesforce",
step: "salesforce_assignment",
timestamp: 2000,
data: { email: "[email protected]", recordType: "Contact", recordId: "003ABC" },
},
];
const mockTraceRecord = {
id: "trace-1",
createdAt: new Date("2025-01-01T10:00:00Z"),
trace,
formResponseId: 123,
queuedFormResponseId: null,
bookingUid: "booking-uid-123",
assignmentReasonId: 42,
};
const presentedSteps = [
{
message: 'Route matched: "Enterprise" (ID: route-1)',
domain: "routing_form",
step: "route_matched",
timestamp: 1000,
},
{
message: "Salesforce assignment: [email protected] via Contact (ID: 003ABC)",
domain: "salesforce",
step: "salesforce_assignment",
timestamp: 2000,
},
];
mockRepository.findByBookingUid.mockResolvedValue(mockTraceRecord);
vi.mocked(RoutingTracePresenter.present).mockReturnValue(presentedSteps);
const result = await getRoutingTraceHandler({
ctx: createCtx(),
input: { bookingUid: "booking-uid-123" },
});
expect(mockBookingAccessService.doesUserIdHaveAccessToBooking).toHaveBeenCalledWith({
userId: mockUser.id,
bookingUid: "booking-uid-123",
});
expect(mockRepository.findByBookingUid).toHaveBeenCalledWith("booking-uid-123");
expect(RoutingTracePresenter.present).toHaveBeenCalledWith(trace);
expect(result).toEqual({ steps: presentedSteps });
expect(prisma.app_RoutingForms_FormResponse.findFirst).not.toHaveBeenCalled();
});
});
describe("when routing trace does not exist in RoutingTrace table", () => {
it("should fall back to PendingRoutingTrace via form response", async () => {
const pendingTrace: RoutingTrace = [
{
domain: "routing_form",
step: "attribute-logic-evaluated",
timestamp: 3000,
data: { routeName: "Sales", routeIsFallback: false },
},
];
const mockFormResponse = {
id: 456,
routedToBookingUid: "booking-uid-456",
pendingRoutingTrace: {
id: "pending-trace-1",
trace: pendingTrace,
},
};
const presentedSteps = [
{
message: 'Attribute logic evaluated: Route: "Sales"',
domain: "routing_form",
step: "attribute-logic-evaluated",
timestamp: 3000,
},
];
mockRepository.findByBookingUid.mockResolvedValue(null);
vi.mocked(prisma.app_RoutingForms_FormResponse.findFirst).mockResolvedValue(mockFormResponse);
vi.mocked(RoutingTracePresenter.present).mockReturnValue(presentedSteps);
const result = await getRoutingTraceHandler({
ctx: createCtx(),
input: { bookingUid: "booking-uid-456" },
});
expect(mockRepository.findByBookingUid).toHaveBeenCalledWith("booking-uid-456");
expect(prisma.app_RoutingForms_FormResponse.findFirst).toHaveBeenCalledWith({
where: { routedToBookingUid: "booking-uid-456" },
include: { pendingRoutingTrace: true },
});
expect(RoutingTracePresenter.present).toHaveBeenCalledWith(pendingTrace);
expect(result).toEqual({ steps: presentedSteps });
});
it("should return empty steps when form response has no pending trace", async () => {
const mockFormResponse = {
id: 789,
routedToBookingUid: "booking-uid-789",
pendingRoutingTrace: null,
};
mockRepository.findByBookingUid.mockResolvedValue(null);
vi.mocked(prisma.app_RoutingForms_FormResponse.findFirst).mockResolvedValue(mockFormResponse);
const result = await getRoutingTraceHandler({
ctx: createCtx(),
input: { bookingUid: "booking-uid-789" },
});
expect(mockRepository.findByBookingUid).toHaveBeenCalledWith("booking-uid-789");
expect(prisma.app_RoutingForms_FormResponse.findFirst).toHaveBeenCalled();
expect(RoutingTracePresenter.present).not.toHaveBeenCalled();
expect(result).toEqual({ steps: [] });
});
it("should return empty steps when form response does not exist", async () => {
mockRepository.findByBookingUid.mockResolvedValue(null);
vi.mocked(prisma.app_RoutingForms_FormResponse.findFirst).mockResolvedValue(null);
const result = await getRoutingTraceHandler({
ctx: createCtx(),
input: { bookingUid: "non-existent-uid" },
});
expect(mockRepository.findByBookingUid).toHaveBeenCalledWith("non-existent-uid");
expect(prisma.app_RoutingForms_FormResponse.findFirst).toHaveBeenCalledWith({
where: { routedToBookingUid: "non-existent-uid" },
include: { pendingRoutingTrace: true },
});
expect(RoutingTracePresenter.present).not.toHaveBeenCalled();
expect(result).toEqual({ steps: [] });
});
it("should return empty steps when pending trace has no trace data", async () => {
const mockFormResponse = {
id: 111,
routedToBookingUid: "booking-uid-111",
pendingRoutingTrace: {
id: "pending-trace-2",
trace: null,
},
};
mockRepository.findByBookingUid.mockResolvedValue(null);
vi.mocked(prisma.app_RoutingForms_FormResponse.findFirst).mockResolvedValue(mockFormResponse);
const result = await getRoutingTraceHandler({
ctx: createCtx(),
input: { bookingUid: "booking-uid-111" },
});
expect(result).toEqual({ steps: [] });
expect(RoutingTracePresenter.present).not.toHaveBeenCalled();
});
});
describe("edge cases", () => {
it("should handle empty trace array from permanent routing trace", async () => {
const mockTraceRecord = {
id: "trace-empty",
createdAt: new Date("2025-01-01T10:00:00Z"),
trace: [],
formResponseId: 999,
queuedFormResponseId: null,
bookingUid: "booking-uid-empty",
assignmentReasonId: null,
};
mockRepository.findByBookingUid.mockResolvedValue(mockTraceRecord);
vi.mocked(RoutingTracePresenter.present).mockReturnValue([]);
const result = await getRoutingTraceHandler({
ctx: createCtx(),
input: { bookingUid: "booking-uid-empty" },
});
expect(RoutingTracePresenter.present).toHaveBeenCalledWith([]);
expect(result).toEqual({ steps: [] });
});
});
});