test: Add Attributes Routing e2e and some more unit tests (#17032)

* Initial commit

* routingForm to Booking a particular team member working

* Happy path working

* Fixes

* Fix router query params forwarding

* Add basicConfig within app

* Tests

* More tests

* Update packages/app-store/routing-forms/components/SingleForm.tsx

Co-authored-by: Omar López <zomars@me.com>

* WIP

* Fix lint issues

* Fix tests -TS and add new logic

* Add getEventTypeRedirectUrl tests

* Remove booking title changes code

* More changes

* Add tests for getRoutedUsers

* More tests

* Update testUtils.ts

---------

Co-authored-by: Omar López <zomars@me.com>
Co-authored-by: Keith Williams <keithwillcode@gmail.com>
This commit is contained in:
authored and GitHub committed 2024-10-09 22:47:16 +05:30
1 parent 90bcd711a9
commit 06429e27fb
8 files changed
+423 -9

No files matched your search

+6 -1
View File
@@ -153,6 +153,7 @@ const createTeamAndAddUser = async (
isDnsSetup,
index,
orgRequestedSlug,
schedulingType,
}: {
user: { id: number; email: string; username: string | null; role?: MembershipRole };
isUnpublished?: boolean;
@@ -163,6 +164,7 @@ const createTeamAndAddUser = async (
organizationId?: number | null;
index?: number;
orgRequestedSlug?: string;
schedulingType?: SchedulingType;
},
workerInfo: WorkerInfo
) => {
@@ -189,7 +191,9 @@ const createTeamAndAddUser = async (
data.slug = !isUnpublished ? slug : undefined;
if (isOrg && hasSubteam) {
const team = await createTeamAndAddUser({ user }, workerInfo);
await createTeamEventType(user, team);
await createTeamEventType(user, team, {
schedulingType: schedulingType,
});
await createTeamWorkflow(user, team);
data.children = { connect: [{ id: team.id }] };
}
@@ -532,6 +536,7 @@ export const createUsersFixture = (
hasSubteam: scenario.hasSubteam,
organizationId: opts?.organizationId,
orgRequestedSlug: scenario.orgRequestedSlug,
schedulingType: scenario.schedulingType,
},
workerInfo
);
+2 -1
View File
@@ -13,6 +13,7 @@
- [ ] Warnings are needed with recommendation to create new attribute instead in unsupported scenarios. We need to test for more such changes to Attribute types.
- [ ] When choosing 'Value of Field' option in RAQB, show a warning somewhere if all options of the Form field aren't present in the attribute option.
- So, if Form Field is 'What is Comapny Size' with options Enterprise, Large, Medium, Small and Attribute for which Value of 'What is compnay size' has options Enterprise, Large, Medium. It would show a warning that 'Small' option is not present in the attribute.
- [ ] When logic can't be built(Short text maps to an attribute with given set of options but the short text value doesn't match any of the options), the team member matching route throws error but Test Preview still shows the previous value - It show should the error in there.
### Performance Tests and Improvements
- [ ] Test with ~1000 team members and ~100 attributes.
@@ -30,7 +31,7 @@
- [x] Basic Tests
- [ ] getAttributes
- [ ] Querying Logic Test
- [ ] getRoutedUsers tests
- [x] getRoutedUsers tests
- [ ] getAvailableSlots
- [ ] should use routedTeamMemberIds in availability query
@@ -444,6 +444,7 @@ const Route = ({
<div className="block w-full">
<Select
required
className="data-testid-eventTypeRedirectUrl-select"
isDisabled={disabled}
options={eventTypeRedirectUrlOptions}
onChange={(option) => {
@@ -1,6 +1,8 @@
import type { Page } from "@playwright/test";
import type { Locator, Page } from "@playwright/test";
import { expect } from "@playwright/test";
import { prisma } from "@calcom/prisma";
import { AttributeType, SchedulingType } from "@calcom/prisma/enums";
import type { Fixtures } from "@calcom/web/playwright/lib/fixtures";
import { test } from "@calcom/web/playwright/lib/fixtures";
import { selectInteractions } from "@calcom/web/playwright/lib/pageObject";
@@ -25,6 +27,53 @@ const Identifiers = {
selectNewFormat: "test-select-new-format",
};
async function enableContactOwnerOverride(page: Page) {
await page.click("text=Contact owner will be the Round Robin host");
}
async function selectFirstAttributeOption({ fromLocator }: { fromLocator: Locator }) {
await selectOptionUsingLocator({
locator: fromLocator,
option: 1
});
}
async function selectFirstValueForAttributeValue({ fromLocator, option }: { fromLocator: Locator, option: number }) {
await selectOptionUsingLocator({
locator: fromLocator,
option
});
}
async function addAttributeRoutingRule(page: Page) {
// TODO: Use a better selector maybe?
await page.locator('text="Add rule"').nth(1).click();
const attributeQueryBuilder = page.locator(".group-container").nth(1);
const attributeSelectorFirstRule = attributeQueryBuilder.locator(".rule--field").nth(0);
await selectFirstAttributeOption({
fromLocator: attributeSelectorFirstRule,
});
const attributeValueSelector = attributeQueryBuilder.locator(".rule--value").nth(0);
const numOfOptionsInAttribute = 3;
await selectFirstValueForAttributeValue({
fromLocator: attributeValueSelector,
// Select 'Value of Field Short Text' option
option: numOfOptionsInAttribute + 1
});
}
async function selectFirstEventRedirectOption(page: Page) {
await selectOption({
selector: {
selector: ".data-testid-eventTypeRedirectUrl-select",
nth: 0,
},
option: 2,
page,
});
}
test.describe("Routing Forms", () => {
test.describe("Zero State Routing Forms", () => {
test("should be able to add a new form and view it", async ({ page }) => {
@@ -45,7 +94,7 @@ test.describe("Routing Forms", () => {
await expect(page.getByTestId(`404-page`)).toBeVisible();
});
test("should be able to edit the form", async ({ page }) => {
test("should be able to edit a newly created form", async ({ page }) => {
const formId = await addForm(page);
const description = "Test Description";
@@ -432,6 +481,110 @@ test.describe("Routing Forms", () => {
expect(route).toBe("Fallback Message");
});
});
test.describe("Form with Attribute Routing - Team Form", () => {
test.beforeEach(async ({ page, users }) => {
const userFixture = await users.create(
{ username: "routing-forms" },
{
hasTeam: true,
isOrg: true,
hasSubteam: true,
schedulingType: SchedulingType.ROUND_ROBIN,
}
);
const orgMembership = await userFixture.getOrgMembership();
const createdAttribute = await prisma.attribute.create({
data: {
teamId: orgMembership.teamId,
type: AttributeType.SINGLE_SELECT,
name: "Company Size",
slug: `company-size-orgId-${orgMembership.teamId}`,
options: {
create: [
{
slug: "large",
value: "large",
},
{
slug: "medium",
value: "medium",
},
{
slug: "small",
value: "small",
},
],
},
},
include: {
options: true,
},
});
await prisma.attributeToUser.create({
data: {
member: {
connect: {
userId_teamId: {
userId: userFixture.id,
teamId: orgMembership.teamId,
},
},
},
attributeOption: {
connect: {
id: createdAttribute.options[0].id,
},
},
},
});
await userFixture.apiLogin();
});
test.afterEach(async ({ users }) => {
// This also delete forms on cascade
await users.deleteAll();
});
test("should be able to add attribute routing to a newly created team form", async ({ page }) => {
const formId = await addForm(page, {
forTeam: true,
});
await addShortTextFieldAndSaveForm({
page,
formId,
});
await page.click('[href*="/route-builder/"]');
await selectNewRoute(page);
// This would select Round Robin event that we created above
await selectFirstEventRedirectOption(page);
await enableContactOwnerOverride(page);
await addAttributeRoutingRule(page);
await saveCurrentForm(page);
await (async function testPreviewWhereThereIsMatch() {
await page.click('[data-testid="test-preview"]');
await page.fill('[data-testid="form-field-short-text"]', "large");
await page.click('[data-testid="test-routing"]');
await page.waitForSelector('text=@example.com');
await page.click('[data-testid="dialog-rejection"]');
})();
await (async function testPreviewWhereThereIsNoMatch() {
await page.click('[data-testid="test-preview"]');
await page.fill('[data-testid="form-field-short-text"]', "medium");
await page.click('[data-testid="test-routing"]');
await page.waitForSelector('text=No matching members.');
await page.click('[data-testid="dialog-rejection"]');
})();
});
});
});
async function disableForm(page: Page) {
@@ -569,6 +722,15 @@ async function addAllTypesOfFieldsAndSaveForm(
};
}
async function addShortTextFieldAndSaveForm({ page, formId }: { page: Page; formId: string }) {
await page.goto(`apps/routing-forms/form-edit/${formId}`);
await page.click('[data-testid="add-field"]');
await page.locator(".data-testid-field-type").nth(0).click();
await page.fill(`[name="fields.0.label"]`, "Short Text");
await page.fill(`[name="fields.0.identifier"]`, "short-text");
await saveCurrentForm(page);
}
async function selectOption({
page,
selector,
@@ -589,6 +751,17 @@ async function selectOption({
.click();
}
export async function selectOptionUsingLocator({
locator,
option,
}: {
locator: Locator;
option: number;
}) {
await locator.click();
await locator.locator('[id*="react-select-"][aria-disabled]').nth(option - 1).click();
}
async function verifyFieldOptionsInRule(options: string[], page: Page) {
await verifySelectOptions(
{
@@ -1,11 +1,25 @@
import type { Page } from "@playwright/test";
import { expect } from "@playwright/test";
export async function addForm(page: Page, { name = "Test Form Name" } = {}) {
export async function addForm(
page: Page,
{
name = "Test Form Name",
forTeam,
}: {
name?: string;
forTeam?: boolean;
} = {}
) {
await page.goto(`/routing-forms/forms`);
await page.click('[data-testid="new-routing-form"]');
// Choose to create the Form for the user(which is the first option) and not the team
await page.click('[data-testid="option-0"]');
if (forTeam) {
await page.click('[data-testid="option-team-1"]');
} else {
await page.click('[data-testid="option-0"]');
}
await page.fill("input[name]", name);
await page.click('[data-testid="add-form"]');
await page.waitForSelector('[data-testid="add-field"]');
@@ -0,0 +1,128 @@
import { describe, it, expect } from "vitest";
import {
getRoutedHostsWithContactOwnerAndFixedHosts,
getRoutedUsersWithContactOwnerAndFixedUsers,
} from "./getRoutedUsers";
describe("getRoutedHostsWithContactOwnerAndFixedHosts", () => {
const hosts = [
{ user: { id: 1, email: "user1@example.com" }, isFixed: false },
{ user: { id: 2, email: "user2@example.com" }, isFixed: true },
{ user: { id: 3, email: "user3@example.com" }, isFixed: false },
{ user: { id: 4, email: "owner@example.com" }, isFixed: false },
];
const hostsWithoutFixedHosts = hosts.map((host) => ({ ...host, isFixed: false }));
it("should return all hosts when routedTeamMemberIds is null", () => {
const result = getRoutedHostsWithContactOwnerAndFixedHosts({
routedTeamMemberIds: null,
hosts,
contactOwnerEmail: "owner@example.com",
});
expect(result).toEqual(hosts);
});
it("should return all hosts when routedTeamMemberIds is empty - We don't want to enter a scenario where we have no team members to be booked", () => {
const result = getRoutedHostsWithContactOwnerAndFixedHosts({
routedTeamMemberIds: [],
hosts,
contactOwnerEmail: "owner@example.com",
});
expect(result).toEqual(hosts);
});
it("should filter hosts based on routedTeamMemberIds, isFixed, and contactOwnerEmail", () => {
const result = getRoutedHostsWithContactOwnerAndFixedHosts({
routedTeamMemberIds: [1, 3],
hosts,
contactOwnerEmail: "owner@example.com",
});
expect(result).toEqual([
{ user: { id: 1, email: "user1@example.com" }, isFixed: false },
{ user: { id: 2, email: "user2@example.com" }, isFixed: true },
{ user: { id: 3, email: "user3@example.com" }, isFixed: false },
{ user: { id: 4, email: "owner@example.com" }, isFixed: false },
]);
});
it("should return an empty array when no hosts match the criteria", () => {
const result = getRoutedHostsWithContactOwnerAndFixedHosts({
routedTeamMemberIds: [5],
hosts: hostsWithoutFixedHosts,
contactOwnerEmail: "nonexistent@example.com",
});
expect(result).toEqual([]);
});
it("should return fixed hosts even if routedTeamMemberIds and contactOwnerEmail are invalid ", () => {
const result = getRoutedHostsWithContactOwnerAndFixedHosts({
routedTeamMemberIds: [5],
hosts,
contactOwnerEmail: "nonexistent@example.com",
});
expect(result).toEqual([{ user: { id: 2, email: "user2@example.com" }, isFixed: true }]);
});
});
describe("getRoutedUsersWithContactOwnerAndFixedUsers", () => {
const users = [
{ id: 1, email: "user1@example.com", isFixed: false },
{ id: 2, email: "user2@example.com", isFixed: true },
{ id: 3, email: "user3@example.com", isFixed: false },
{ id: 4, email: "owner@example.com", isFixed: false },
];
const usersWithoutFixedHosts = users.map((user) => ({ ...user, isFixed: false }));
it("should return all users when routedTeamMemberIds is null", () => {
const result = getRoutedUsersWithContactOwnerAndFixedUsers({
routedTeamMemberIds: null,
users,
contactOwnerEmail: "owner@example.com",
});
expect(result).toEqual(users);
});
it("should return all users when routedTeamMemberIds is empty - We don't want to enter a scenario where we have no team members to be booked", () => {
const result = getRoutedUsersWithContactOwnerAndFixedUsers({
routedTeamMemberIds: [],
users,
contactOwnerEmail: "owner@example.com",
});
expect(result).toEqual(users);
});
it("should filter users based on routedTeamMemberIds, isFixed, and contactOwnerEmail", () => {
const result = getRoutedUsersWithContactOwnerAndFixedUsers({
routedTeamMemberIds: [1, 3],
users,
contactOwnerEmail: "owner@example.com",
});
expect(result).toEqual([
{ id: 1, email: "user1@example.com", isFixed: false },
{ id: 2, email: "user2@example.com", isFixed: true },
{ id: 3, email: "user3@example.com", isFixed: false },
{ id: 4, email: "owner@example.com", isFixed: false },
]);
});
it("should return an empty array when neither fixed, nor routedTeamMemberIds, nor contactOwnerEmail match", () => {
const result = getRoutedUsersWithContactOwnerAndFixedUsers({
routedTeamMemberIds: [5],
users: usersWithoutFixedHosts,
contactOwnerEmail: "nonexistent@example.com",
});
expect(result).toEqual([]);
});
it("should return fixed hosts even if routedTeamMemberIds and contactOwnerEmail are invalid ", () => {
const result = getRoutedUsersWithContactOwnerAndFixedUsers({
routedTeamMemberIds: [5],
users,
contactOwnerEmail: "nonexistent@example.com",
});
expect(result).toEqual([{ id: 2, email: "user2@example.com", isFixed: true }]);
});
});
@@ -0,0 +1,90 @@
import { describe, expect, it } from "vitest";
import type { GetAvailabilityUser } from "@calcom/core/getUserAvailability";
import { getUsersWithCredentialsConsideringContactOwner } from "./util";
describe("getUsersWithCredentialsConsideringContactOwner", () => {
const createMockUser = (email: string): GetAvailabilityUser => ({
id: Math.random(),
email,
name: email.split("@")[0],
username: email.split("@")[0],
timeZone: "UTC",
weekStart: "Monday",
defaultScheduleId: null,
bufferTime: 0,
locale: "en",
});
it("should return all hosts when contact owner does not exist", () => {
const hosts = [
{ isFixed: true, user: createMockUser("host1@example.com") },
{ isFixed: false, user: createMockUser("host2@example.com") },
];
const result = getUsersWithCredentialsConsideringContactOwner({
contactOwnerEmail: "nonexistent@example.com",
hosts,
});
expect(result).toHaveLength(2);
expect(result.map((u) => u.email)).toEqual(["host1@example.com", "host2@example.com"]);
});
it("should return all hosts when contact owner is a fixed host", () => {
const hosts = [
{ isFixed: true, user: createMockUser("host1@example.com") },
{ isFixed: false, user: createMockUser("host2@example.com") },
];
const result = getUsersWithCredentialsConsideringContactOwner({
contactOwnerEmail: "host1@example.com",
hosts,
});
expect(result).toHaveLength(2);
expect(result.map((u) => u.email)).toEqual(["host1@example.com", "host2@example.com"]);
});
it("should return contact owner and fixed hosts when contact owner is not fixed", () => {
const hosts = [
{ isFixed: true, user: createMockUser("host1@example.com") },
{ isFixed: false, user: createMockUser("host2@example.com") },
{ isFixed: false, user: createMockUser("contactowner@example.com") },
];
const result = getUsersWithCredentialsConsideringContactOwner({
contactOwnerEmail: "contactowner@example.com",
hosts,
});
expect(result).toHaveLength(2);
expect(result.map((u) => u.email)).toEqual(["host1@example.com", "contactowner@example.com"]);
});
it("should return all hosts when contactOwnerEmail is null", () => {
const hosts = [
{ isFixed: true, user: createMockUser("host1@example.com") },
{ isFixed: false, user: createMockUser("host2@example.com") },
];
const result = getUsersWithCredentialsConsideringContactOwner({
contactOwnerEmail: null,
hosts,
});
expect(result).toHaveLength(2);
expect(result.map((u) => u.email)).toEqual(["host1@example.com", "host2@example.com"]);
});
it("should return only fixed hosts when there's no contact owner", () => {
const hosts = [
{ isFixed: true, user: createMockUser("host1@example.com") },
{ isFixed: false, user: createMockUser("host2@example.com") },
{ isFixed: true, user: createMockUser("host3@example.com") },
];
const result = getUsersWithCredentialsConsideringContactOwner({
contactOwnerEmail: null,
hosts,
});
expect(result).toHaveLength(3);
expect(result.map((u) => u.email)).toEqual([
"host1@example.com",
"host2@example.com",
"host3@example.com",
]);
});
});
@@ -346,7 +346,7 @@ export interface IGetAvailableSlots {
/**
* Returns (Contact Owner plus Fixed Hosts) OR All Hosts
*/
function getUsersWithCredentialsConsideringContactOwner({
export function getUsersWithCredentialsConsideringContactOwner({
contactOwnerEmail,
hosts,
}: {
@@ -481,7 +481,9 @@ export async function getAvailableSlots({ input, ctx }: GetScheduleOptions): Pro
userId: true,
},
});
routedHostsWithContactOwnerAndFixedHosts = routedHostsWithContactOwnerAndFixedHosts.filter((host) => host.user.id === originalRescheduledBooking?.userId || 0);
routedHostsWithContactOwnerAndFixedHosts = routedHostsWithContactOwnerAndFixedHosts.filter(
(host) => host.user.id === originalRescheduledBooking?.userId || 0
);
}
const usersWithCredentials = getUsersWithCredentialsConsideringContactOwner({