Files
calendar/packages/lib/bookings/findQualifiedHosts.test.ts
T
649faf4ff6 fix: Fairness when routing (#18550)
* Partial push of new filters

* Let's always return the passed host array when a filter doesn't need to be applied

* fix: isFixed typing in same round robin host check

* Add main bulk of rewritten logic for routed fairness support

* Remove console.log

* Add getAttributeWithEnabledWeights to AttributeRepository

* Add OOO entry repository

* Use getOrderedListOfLuckyUsers in lead threshold filtering

* Shortfall already includes weight

* test against minShortfall

* first attempt to fix fallbacks

* Added testcase updates

* Partially fix filterHosts test

* Some test updates

* Removed email field from normalization

* fix no available users found error

* fix unit test

* remove fallback from same host reschedule return

* fixes and improvements for findQualifiedHosts

* include fallback logic in handleNewBooking

* use fallback in handleNewBooking

* fix for contact owner in handleNewBooking

* WIP - comment

* pass routingFormResponseId

* fix type error

* pass routingFormResponse

* fixes in treshold logic

* remove reason

* merge qualified and fallback availabilities

* fix type error

* add and fix tests

* clean up test file

* fix filterHostsBySameRoundRobinHost tests

* fix findQualifiedHosts.test.ts

* fix getRoutedUsers.test.ts

* fix tests

* fix isRerouting check

* fix start time for two weeks check

* fix fallback hosts

* fix test

* fallback hosts should also include qualified hosts

* fix type error

* fix typos

* add contact owner to fallbacks

* fix typo

* fix qualifiedRRUsers

* set fixed users when no hosts

* add tets + code clean up

* add test for reschedule with same host

* fix typo

* add more tests for findQualifiedHosts

* remove skip from test

* add comment

* remove unused code

* fix fallback users in loadAndValidateUsers

* improve naming for fallback

* rename missed fallback variable

* fix reschedule with same rr host in handleNewBooking

* fixes for fallback slots

* add more tests + small fixes for contact owner

* fix tests and type errors

* add logs for fallback

* add type:json to logger

* Fix small merge issue

* fix end time if two weeks are cut off

* add more tests for contact owner availability check

* add new logic for two weeks fallback

* fix reschedule same RR host test

---------

Co-authored-by: CarinaWolli <wollencarina@gmail.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
Co-authored-by: Carina Wollendorfer <30310907+CarinaWolli@users.noreply.github.com>
2025-02-18 14:59:48 +00:00

671 lines
16 KiB
TypeScript

import prismaMock from "../../../tests/libs/__mocks__/prismaMock";
import { vi, it, describe, expect, afterEach } from "vitest";
import type { Mock } from "vitest";
import { SchedulingType } from "@calcom/prisma/enums";
import { filterHostsByLeadThreshold } from "./filterHostsByLeadThreshold";
import { findQualifiedHosts } from "./findQualifiedHosts";
import * as getRoutedUsers from "./getRoutedUsers";
// Mock the filterHostsByLeadThreshold function
vi.mock("./filterHostsByLeadThreshold", () => {
return {
filterHostsByLeadThreshold: vi.fn(),
};
});
// Clear call history after each test
afterEach(() => {
(filterHostsByLeadThreshold as Mock).mockClear();
});
describe("findQualifiedHosts", async () => {
it("should return qualified hosts based on mock of filterHostsByLeadThreshold", async () => {
const hosts = [
{
isFixed: true,
createdAt: new Date(),
user: {
id: 2,
email: "hellouser2@email.com",
credentials: [],
userLevelSelectedCalendars: [],
},
priority: undefined,
weight: undefined,
},
{
isFixed: false,
createdAt: new Date(),
user: {
id: 1,
email: "hellouser@email.com",
credentials: [],
userLevelSelectedCalendars: [],
},
priority: undefined,
weight: undefined,
},
{
isFixed: false,
createdAt: new Date(),
user: {
id: 3,
email: "hellouser3@email.com",
credentials: [],
userLevelSelectedCalendars: [],
},
priority: undefined,
weight: undefined,
},
];
const rrHosts = hosts.filter((host) => !host.isFixed);
const fixedHosts = hosts.filter((host) => host.isFixed);
const rrHostsAfterFairness = [rrHosts[2]];
// Configure the mock return value
(filterHostsByLeadThreshold as Mock).mockResolvedValue(rrHostsAfterFairness);
// Define the input for the test
const eventType = {
id: 1,
hosts,
users: [],
schedulingType: SchedulingType.ROUND_ROBIN,
maxLeadThreshold: null,
rescheduleWithSameRoundRobinHost: true,
assignAllTeamMembers: true,
assignRRMembersUsingSegment: false,
rrSegmentQueryValue: null,
isRRWeightsEnabled: false,
team: {
id: 1,
parentId: null,
},
};
// Call the function under test
const result = await findQualifiedHosts({
eventType,
routedTeamMemberIds: [],
rescheduleUid: null,
contactOwnerEmail: null,
routingFormResponse: null,
});
// Verify the result
expect(result).toStrictEqual({
qualifiedRRHosts: rrHostsAfterFairness,
fixedHosts,
allFallbackRRHosts: rrHosts,
});
});
it("should return hosts after valid input with users", async () => {
const users = [
{
email: "hello@gmail.com",
id: 1,
credentials: [],
userLevelSelectedCalendars: [],
},
{
email: "hello2@gmail.com",
id: 2,
credentials: [],
userLevelSelectedCalendars: [],
},
];
// Define the input for the test
const eventType = {
id: 1,
hosts: [],
users,
schedulingType: null,
maxLeadThreshold: null,
rescheduleWithSameRoundRobinHost: true,
assignAllTeamMembers: true,
assignRRMembersUsingSegment: false,
rrSegmentQueryValue: null,
isRRWeightsEnabled: false,
team: {
id: 1,
parentId: null,
},
};
// Call the function under test
const result = await findQualifiedHosts({
eventType,
routedTeamMemberIds: [],
rescheduleUid: null,
contactOwnerEmail: null,
routingFormResponse: null,
});
// Verify the result
expect(result).toEqual({
qualifiedRRHosts: [],
fixedHosts: users.map((user) => ({
user: user,
isFixed: true,
email: user.email,
createdAt: null,
})),
});
expect(filterHostsByLeadThreshold).not.toHaveBeenCalled();
});
it("should return only the crm contact owner match & other users + contact owner as fallback ", async () => {
const oneYearAgo = new Date();
oneYearAgo.setFullYear(oneYearAgo.getFullYear() - 1);
const hosts = [
{
weight: 100,
priority: 2,
createdAt: oneYearAgo,
isFixed: false,
user: {
email: "hello1@gmail.com",
id: 1,
credentials: [],
userLevelSelectedCalendars: [],
},
},
{
weight: 100,
priority: 2,
createdAt: oneYearAgo,
isFixed: false,
user: {
email: "hello2@gmail.com",
id: 2,
credentials: [],
userLevelSelectedCalendars: [],
},
},
{
weight: 100,
priority: 2,
createdAt: oneYearAgo,
isFixed: false,
user: {
email: "hello3@gmail.com",
id: 3,
credentials: [],
userLevelSelectedCalendars: [],
},
},
];
(filterHostsByLeadThreshold as Mock).mockResolvedValue(hosts);
// Define the input for the test
const eventType = {
id: 1,
hosts,
users: [],
schedulingType: SchedulingType.ROUND_ROBIN,
maxLeadThreshold: null,
rescheduleWithSameRoundRobinHost: true,
assignAllTeamMembers: true,
assignRRMembersUsingSegment: false,
rrSegmentQueryValue: null,
isRRWeightsEnabled: false,
team: {
id: 1,
parentId: null,
},
};
// Call the function under test
const result = await findQualifiedHosts({
eventType,
routedTeamMemberIds: [],
rescheduleUid: null,
contactOwnerEmail: "hello1@gmail.com",
routingFormResponse: null,
});
// Verify the result
expect(result).toEqual({
qualifiedRRHosts: [
{
...hosts[0],
},
],
allFallbackRRHosts: hosts,
fixedHosts: [],
});
});
// it("should return only the crm contact owner match & other users + contact owner as fallback (with routing and segment filtering)", async () => {
// });
it("should return only routed members + contact owner as fallback for crm contact owner match", async () => {
const oneYearAgo = new Date();
oneYearAgo.setFullYear(oneYearAgo.getFullYear() - 1);
const hosts = [
{
isFixed: false,
createdAt: oneYearAgo,
weight: undefined,
priority: undefined,
user: {
email: "hello1@gmail.com",
id: 1,
credentials: [],
userLevelSelectedCalendars: [],
},
},
{
isFixed: false,
createdAt: oneYearAgo,
weight: undefined,
priority: undefined,
user: {
email: "hello2@gmail.com",
id: 2,
credentials: [],
userLevelSelectedCalendars: [],
},
},
{
isFixed: false,
createdAt: oneYearAgo,
weight: undefined,
priority: undefined,
user: {
email: "hello3@gmail.com",
id: 3,
credentials: [],
userLevelSelectedCalendars: [],
},
},
];
(filterHostsByLeadThreshold as Mock).mockResolvedValue([hosts[0], hosts[1]]);
// Define the input for the test
const eventType = {
id: 1,
hosts,
users: [],
schedulingType: SchedulingType.ROUND_ROBIN,
maxLeadThreshold: null,
rescheduleWithSameRoundRobinHost: true,
assignAllTeamMembers: true,
assignRRMembersUsingSegment: false,
rrSegmentQueryValue: null,
isRRWeightsEnabled: false,
team: {
id: 1,
parentId: null,
},
};
// Call the function under test
const result = await findQualifiedHosts({
eventType,
routedTeamMemberIds: [1],
rescheduleUid: null,
contactOwnerEmail: "hello3@gmail.com",
routingFormResponse: null,
});
// Verify the result
expect(result).toEqual({
qualifiedRRHosts: [hosts[2]],
allFallbackRRHosts: [hosts[0], hosts[2]],
fixedHosts: [],
});
});
it("if it's a reschedule with same host, it should only return this host and the fixed hosts", async () => {
const oneYearAgo = new Date();
oneYearAgo.setFullYear(oneYearAgo.getFullYear() - 1);
const hosts = [
{
isFixed: false,
createdAt: oneYearAgo,
weight: undefined,
priority: undefined,
user: {
email: "hello1@gmail.com",
id: 1,
credentials: [],
userLevelSelectedCalendars: [],
},
},
{
isFixed: false,
createdAt: oneYearAgo,
weight: undefined,
priority: undefined,
user: {
email: "hello2@gmail.com",
id: 2,
credentials: [],
userLevelSelectedCalendars: [],
},
},
{
isFixed: true,
createdAt: oneYearAgo,
weight: undefined,
priority: undefined,
user: {
email: "hello3@gmail.com",
id: 3,
credentials: [],
userLevelSelectedCalendars: [],
},
},
];
const eventType = {
id: 1,
hosts,
users: [],
schedulingType: SchedulingType.ROUND_ROBIN,
maxLeadThreshold: null,
rescheduleWithSameRoundRobinHost: true,
assignAllTeamMembers: true,
assignRRMembersUsingSegment: false,
rrSegmentQueryValue: null,
isRRWeightsEnabled: false,
team: {
id: 1,
parentId: null,
},
};
prismaMock.booking.findFirst.mockResolvedValue({ userId: 2 });
const result = await findQualifiedHosts({
eventType,
routedTeamMemberIds: [],
rescheduleUid: "recheduleUid",
contactOwnerEmail: null,
routingFormResponse: null,
});
expect(result).toEqual({
qualifiedRRHosts: [hosts[1]],
fixedHosts: [hosts[2]],
});
});
it("should return early if segment matching results in only one host", async () => {
const oneYearAgo = new Date();
oneYearAgo.setFullYear(oneYearAgo.getFullYear() - 1);
const hosts = [
{
isFixed: false,
createdAt: oneYearAgo,
weight: undefined,
priority: undefined,
user: {
email: "hello1@gmail.com",
id: 1,
credentials: [],
userLevelSelectedCalendars: [],
},
},
{
isFixed: false,
createdAt: oneYearAgo,
weight: undefined,
priority: undefined,
user: {
email: "hello2@gmail.com",
id: 2,
credentials: [],
userLevelSelectedCalendars: [],
},
},
{
isFixed: false,
createdAt: oneYearAgo,
weight: undefined,
priority: undefined,
user: {
email: "hello3@gmail.com",
id: 3,
credentials: [],
userLevelSelectedCalendars: [],
},
},
];
const eventType = {
id: 1,
hosts,
users: [],
schedulingType: SchedulingType.ROUND_ROBIN,
maxLeadThreshold: null,
rescheduleWithSameRoundRobinHost: true,
assignAllTeamMembers: true,
assignRRMembersUsingSegment: false,
rrSegmentQueryValue: null,
isRRWeightsEnabled: false,
team: {
id: 1,
parentId: null,
},
};
vi.spyOn(getRoutedUsers, "findMatchingHostsWithEventSegment").mockImplementation(async () => [hosts[0]]);
const filterSpy = vi.spyOn(Array.prototype, "filter");
// Call the function under test
const result = await findQualifiedHosts({
eventType,
routedTeamMemberIds: [0, 1, 2],
rescheduleUid: null,
contactOwnerEmail: null,
routingFormResponse: null,
});
// check if we returned early after segment matching only returned one host
expect(filterSpy).toHaveBeenCalledTimes(2);
// Verify the result
expect(result).toEqual({
qualifiedRRHosts: [hosts[0]],
fixedHosts: [],
});
});
it("should filter for segment matching and routed team member ids", async () => {
const oneYearAgo = new Date();
oneYearAgo.setFullYear(oneYearAgo.getFullYear() - 1);
const hosts = [
{
isFixed: false,
createdAt: oneYearAgo,
weight: undefined,
priority: undefined,
user: {
email: "hello1@gmail.com",
id: 1,
credentials: [],
userLevelSelectedCalendars: [],
},
},
{
isFixed: false,
createdAt: oneYearAgo,
weight: undefined,
priority: undefined,
user: {
email: "hello2@gmail.com",
id: 2,
credentials: [],
userLevelSelectedCalendars: [],
},
},
{
isFixed: false,
createdAt: oneYearAgo,
weight: undefined,
priority: undefined,
user: {
email: "hello3@gmail.com",
id: 3,
credentials: [],
userLevelSelectedCalendars: [],
},
},
];
const eventType = {
id: 1,
hosts,
users: [],
schedulingType: SchedulingType.ROUND_ROBIN,
maxLeadThreshold: null,
rescheduleWithSameRoundRobinHost: true,
assignAllTeamMembers: true,
assignRRMembersUsingSegment: false,
rrSegmentQueryValue: null,
isRRWeightsEnabled: false,
team: {
id: 1,
parentId: null,
},
};
vi.spyOn(getRoutedUsers, "findMatchingHostsWithEventSegment").mockImplementation(async () => [
hosts[0],
hosts[1],
]);
// Call the function under test
const result = await findQualifiedHosts({
eventType,
routedTeamMemberIds: [2, 3],
rescheduleUid: null,
contactOwnerEmail: null,
routingFormResponse: null,
});
// Verify the result
expect(result).toEqual({
qualifiedRRHosts: [hosts[1]],
fixedHosts: [],
});
});
it("should filter for fairness and return fallback with segment filtering and routed team member ids", async () => {
const oneYearAgo = new Date();
oneYearAgo.setFullYear(oneYearAgo.getFullYear() - 1);
const hosts = [
{
isFixed: false,
createdAt: oneYearAgo,
weight: undefined,
priority: undefined,
user: {
email: "hello1@gmail.com",
id: 1,
credentials: [],
userLevelSelectedCalendars: [],
},
},
{
isFixed: false,
createdAt: oneYearAgo,
weight: undefined,
priority: undefined,
user: {
email: "hello2@gmail.com",
id: 2,
credentials: [],
userLevelSelectedCalendars: [],
},
},
{
isFixed: false,
createdAt: oneYearAgo,
weight: undefined,
priority: undefined,
user: {
email: "hello3@gmail.com",
id: 3,
credentials: [],
userLevelSelectedCalendars: [],
},
},
{
isFixed: false,
createdAt: oneYearAgo,
weight: undefined,
priority: undefined,
user: {
email: "hello3@gmail.com",
id: 3,
credentials: [],
userLevelSelectedCalendars: [],
},
},
];
const eventType = {
id: 1,
hosts,
users: [],
schedulingType: SchedulingType.ROUND_ROBIN,
maxLeadThreshold: null,
rescheduleWithSameRoundRobinHost: true,
assignAllTeamMembers: true,
assignRRMembersUsingSegment: false,
rrSegmentQueryValue: null,
isRRWeightsEnabled: false,
team: {
id: 1,
parentId: null,
},
};
vi.spyOn(getRoutedUsers, "findMatchingHostsWithEventSegment").mockImplementation(async () => [
hosts[0],
hosts[1],
hosts[2],
]);
const rrHostsAfterFairness = [hosts[2]];
// Configure the mock return value
(filterHostsByLeadThreshold as Mock).mockResolvedValue(rrHostsAfterFairness);
// Call the function under test
const result = await findQualifiedHosts({
eventType,
routedTeamMemberIds: [2, 3],
rescheduleUid: null,
contactOwnerEmail: null,
routingFormResponse: null,
});
// Verify the result
expect(result).toEqual({
qualifiedRRHosts: [hosts[2]],
allFallbackRRHosts: [hosts[1], hosts[2]],
fixedHosts: [],
});
});
});