test: Add e2e for prerendering headless router with attribute routing (#22080)

* test: Add e2e for prerender

* wip

* fix tests

* conflict resolution

* Fix unit tests and e2e failure

* chore: Reorganize
This commit is contained in:
Hariom Balhara authored and GitHub committed 2025-07-14 22:47:12 -03:00
1 parent 750e6315dc
commit f667417d84
13 files changed
+1935 -273

No files matched your search

@@ -621,7 +621,7 @@ export default function Success(props: PageProps) {
<Badge variant="blue">{t("Host")}</Badge>
</div>
{!bookingInfo.eventType?.hideOrganizerEmail && (
<p className="text-default">
<p className="text-default" data-testid="booking-host-email">
{bookingInfo?.userPrimaryEmail ?? bookingInfo.user.email}
</p>
)}
+91 -188
View File
@@ -17,6 +17,7 @@ import { MembershipRole, SchedulingType, TimeUnit, WorkflowTriggerEvents } from
import { teamMetadataSchema } from "@calcom/prisma/zod-utils";
import type { Schedule } from "@calcom/types/schedule";
import { createRoutingForm } from "../lib/test-helpers/routingFormHelpers";
import { selectFirstAvailableTimeSlotNextMonth, teamEventSlug, teamEventTitle } from "../lib/testUtils";
import type { createEmailsFixture } from "./emails";
import { TimeZoneEnum } from "./types";
@@ -274,6 +275,7 @@ export const createUsersFixture = (
| null,
scenario: {
seedRoutingForms?: boolean;
seedRoutingFormWithAttributeRouting?: boolean;
hasTeam?: true;
numberOfTeams?: number;
teamRole?: MembershipRole;
@@ -341,192 +343,6 @@ export const createUsersFixture = (
});
}
if (scenario.seedRoutingForms) {
const multiSelectOption2Uuid = "d1302635-9f12-17b1-9153-c3a854649182";
const multiSelectOption1Uuid = "d1292635-9f12-17b1-9153-c3a854649182";
const selectOption1Uuid = "d0292635-9f12-17b1-9153-c3a854649182";
const selectOption2Uuid = "d0302635-9f12-17b1-9153-c3a854649182";
const multiSelectLegacyFieldUuid = "d4292635-9f12-17b1-9153-c3a854649182";
const multiSelectFieldUuid = "d9892635-9f12-17b1-9153-c3a854649182";
const selectFieldUuid = "d1302635-9f12-17b1-9153-c3a854649182";
const legacySelectFieldUuid = "f0292635-9f12-17b1-9153-c3a854649182";
await prisma.app_RoutingForms_Form.create({
data: {
routes: [
{
id: "8a898988-89ab-4cde-b012-31823f708642",
action: { type: "eventTypeRedirectUrl", value: "pro/30min" },
queryValue: {
id: "8a898988-89ab-4cde-b012-31823f708642",
type: "group",
children1: {
"8988bbb8-0123-4456-b89a-b1823f70c5ff": {
type: "rule",
properties: {
field: "c4296635-9f12-47b1-8153-c3a854649182",
value: ["event-routing"],
operator: "equal",
valueSrc: ["value"],
valueType: ["text"],
},
},
},
},
},
{
id: "aa8aaba9-cdef-4012-b456-71823f70f7ef",
action: { type: "customPageMessage", value: "Custom Page Result" },
queryValue: {
id: "aa8aaba9-cdef-4012-b456-71823f70f7ef",
type: "group",
children1: {
"b99b8a89-89ab-4cde-b012-31823f718ff5": {
type: "rule",
properties: {
field: "c4296635-9f12-47b1-8153-c3a854649182",
value: ["custom-page"],
operator: "equal",
valueSrc: ["value"],
valueType: ["text"],
},
},
},
},
},
{
id: "a8ba9aab-4567-489a-bcde-f1823f71b4ad",
action: { type: "externalRedirectUrl", value: `${WEBAPP_URL}/pro` },
queryValue: {
id: "a8ba9aab-4567-489a-bcde-f1823f71b4ad",
type: "group",
children1: {
"998b9b9a-0123-4456-b89a-b1823f7232b9": {
type: "rule",
properties: {
field: "c4296635-9f12-47b1-8153-c3a854649182",
value: ["external-redirect"],
operator: "equal",
valueSrc: ["value"],
valueType: ["text"],
},
},
},
},
},
{
id: "aa8ba8b9-0123-4456-b89a-b182623406d8",
action: { type: "customPageMessage", value: "Multiselect(Legacy) chosen" },
queryValue: {
id: "aa8ba8b9-0123-4456-b89a-b182623406d8",
type: "group",
children1: {
"b98a8abb-cdef-4012-b456-718262343d27": {
type: "rule",
properties: {
field: multiSelectLegacyFieldUuid,
value: [["Option-2"]],
operator: "multiselect_equals",
valueSrc: ["value"],
valueType: ["multiselect"],
},
},
},
},
},
{
id: "bb9ea8b9-0123-4456-b89a-b182623406d8",
action: { type: "customPageMessage", value: "Multiselect chosen" },
queryValue: {
id: "aa8ba8b9-0123-4456-b89a-b182623406d8",
type: "group",
children1: {
"b98a8abb-cdef-4012-b456-718262343d27": {
type: "rule",
properties: {
field: multiSelectFieldUuid,
value: [[multiSelectOption2Uuid]],
operator: "multiselect_equals",
valueSrc: ["value"],
valueType: ["multiselect"],
},
},
},
},
},
{
id: "898899aa-4567-489a-bcde-f1823f708646",
action: { type: "customPageMessage", value: "Fallback Message" },
isFallback: true,
queryValue: { id: "898899aa-4567-489a-bcde-f1823f708646", type: "group" },
},
],
fields: [
{
id: "c4296635-9f12-47b1-8153-c3a854649182",
type: "text",
label: "Test field",
required: true,
},
{
id: multiSelectLegacyFieldUuid,
type: "multiselect",
label: "Multi Select(with Legacy `selectText`)",
identifier: "multi",
selectText: "Option-1\nOption-2",
required: false,
},
{
id: multiSelectFieldUuid,
type: "multiselect",
label: "Multi Select",
identifier: "multi-new-format",
options: [
{
id: multiSelectOption1Uuid,
label: "Option-1",
},
{
id: multiSelectOption2Uuid,
label: "Option-2",
},
],
required: false,
},
{
id: legacySelectFieldUuid,
type: "select",
label: "Legacy Select",
identifier: "test-select",
selectText: "Option-1\nOption-2",
required: false,
},
{
id: selectFieldUuid,
type: "select",
label: "Select",
identifier: "test-select-new-format",
options: [
{
id: selectOption1Uuid,
label: "Option-1",
},
{
id: selectOption2Uuid,
label: "Option-2",
},
],
required: false,
},
],
user: {
connect: {
id: _user.id,
},
},
name: seededForm.name,
},
});
}
const user = await prisma.user.findUniqueOrThrow({
where: { id: _user.id },
include: userIncludes,
@@ -664,7 +480,94 @@ export const createUsersFixture = (
}
}
}
const userFixture = createUserFixture(user, store.page);
if (scenario.seedRoutingForms) {
const firstTeamMembership = await prisma.membership.findFirstOrThrow({
where: {
userId: _user.id,
team: {
isOrganization: false,
},
},
});
if (!firstTeamMembership) {
throw new Error("No sub-team created");
}
await createRoutingForm({
userId: _user.id,
teamId: firstTeamMembership.teamId,
formType: scenario.seedRoutingFormWithAttributeRouting ? "attributeRouting" : "default",
...(scenario.seedRoutingFormWithAttributeRouting && {
attributeRouting: {
attributes: [
{
name: "Department",
type: "SINGLE_SELECT" as const,
options: ["Engineering", "Sales", "Marketing", "Product", "Design"],
},
{
name: "Location",
type: "SINGLE_SELECT" as const,
options: ["New York", "London", "Tokyo", "Berlin", "Remote"],
},
{
name: "Skills",
type: "MULTI_SELECT" as const,
options: ["JavaScript", "React", "Node.js", "Python", "Design", "Sales"],
},
{
name: "Years of Experience",
type: "NUMBER" as const,
},
{
name: "Bio",
type: "TEXT" as const,
},
],
assignments: [
{
memberIndex: 0,
attributeValues: {
Location: ["New York"],
Skills: ["JavaScript"],
},
},
{
memberIndex: 1,
attributeValues: {
Location: ["London"],
Skills: ["React", "JavaScript"],
},
},
],
teamEvents: [
{
title: "Team Sales",
slug: "team-sales",
schedulingType: "ROUND_ROBIN",
assignAllTeamMembers: true,
length: 60,
description: "Team Sales",
},
{
title: "Team Javascript",
slug: "team-javascript",
schedulingType: "ROUND_ROBIN",
assignAllTeamMembers: true,
length: 60,
description: "Team Javascript",
},
],
},
}),
});
}
const finalUser = await prisma.user.findUniqueOrThrow({
where: { id: user.id },
include: userIncludes,
});
const userFixture = createUserFixture(finalUser, store.page);
store.users.push(userFixture);
return userFixture;
},
@@ -781,7 +684,7 @@ const createUserFixture = (user: UserWithIncludes, page: Page) => {
getFirstTeamMembership: async () => {
const memberships = await prisma.membership.findMany({
where: { userId: user.id },
include: { team: true },
include: { team: true, user: true },
});
const membership = memberships
@@ -0,0 +1,334 @@
import prismock from "../../../../../../tests/libs/__mocks__/prisma";
import { expect, describe, it, beforeEach, afterEach, vi } from "vitest";
import { createAttributes } from "../organizationHelpers";
// Mock console.log to avoid noise in tests
const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {
// Intentionally empty to suppress console output in tests
});
// Default mock attributes matching the original hardcoded values
const mockAttributes = [
{
name: "Department",
type: "SINGLE_SELECT" as const,
options: ["Engineering", "Sales", "Marketing", "Product", "Design"],
},
{
name: "Location",
type: "SINGLE_SELECT" as const,
options: ["New York", "London", "Tokyo", "Berlin", "Remote"],
},
{
name: "Skills",
type: "MULTI_SELECT" as const,
options: ["JavaScript", "React", "Node.js", "Python", "Design", "Sales"],
},
{
name: "Years of Experience",
type: "NUMBER" as const,
},
{
name: "Bio",
type: "TEXT" as const,
},
];
// Default assignments matching the original hardcoded behavior
const mockAssignments = [
{
memberIndex: 0,
attributeValues: {
Location: ["New York"],
Skills: ["JavaScript"],
},
},
{
memberIndex: 1,
attributeValues: {
Location: ["London"],
Skills: ["React", "JavaScript"],
},
},
];
describe("createAttributes", () => {
beforeEach(async () => {
// Reset prismock state between tests
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await (prismock as any).reset();
consoleSpy.mockClear();
});
afterEach(async () => {
consoleSpy.mockRestore();
});
it("should create all 5 mock attributes for an organization", async () => {
const orgId = 1;
// Create a membership for the organization
await prismock.membership.create({
data: {
id: 1,
userId: 1,
teamId: orgId,
role: "OWNER",
accepted: true,
},
});
const result = await createAttributes({
orgId,
attributes: mockAttributes,
assignments: mockAssignments,
});
// Verify all attributes were created
const attributes = await prismock.attribute.findMany({
where: { teamId: orgId },
include: { options: true },
});
expect(attributes).toHaveLength(5);
expect(result).toHaveLength(5);
// Check specific attributes
const departmentAttr = attributes.find((attr) => attr.name === "Department");
const locationAttr = attributes.find((attr) => attr.name === "Location");
const skillsAttr = attributes.find((attr) => attr.name === "Skills");
const experienceAttr = attributes.find((attr) => attr.name === "Years of Experience");
const bioAttr = attributes.find((attr) => attr.name === "Bio");
expect(departmentAttr).toBeDefined();
expect(departmentAttr?.type).toBe("SINGLE_SELECT");
expect(departmentAttr?.options).toHaveLength(5);
expect(locationAttr).toBeDefined();
expect(locationAttr?.type).toBe("SINGLE_SELECT");
expect(locationAttr?.options).toHaveLength(5);
expect(skillsAttr).toBeDefined();
expect(skillsAttr?.type).toBe("MULTI_SELECT");
expect(skillsAttr?.options).toHaveLength(6);
expect(experienceAttr).toBeDefined();
expect(experienceAttr?.type).toBe("NUMBER");
expect(experienceAttr?.options).toHaveLength(0); // No options created for NUMBER type
expect(bioAttr).toBeDefined();
expect(bioAttr?.type).toBe("TEXT");
expect(bioAttr?.options).toHaveLength(0); // No options created for TEXT type
});
it("should assign attribute values to all organization members", async () => {
const orgId = 1;
// Create multiple memberships
await prismock.membership.createMany({
data: [
{ id: 1, userId: 1, teamId: orgId, role: "OWNER", accepted: true },
{ id: 2, userId: 2, teamId: orgId, role: "MEMBER", accepted: true },
{ id: 3, userId: 3, teamId: orgId, role: "MEMBER", accepted: true },
],
});
await createAttributes({
orgId,
attributes: mockAttributes,
assignments: mockAssignments,
});
// Check that attribute assignments were created
const attributeAssignments = await prismock.attributeToUser.findMany();
const attributeOptions = await prismock.attributeOption.findMany({
include: { assignedUsers: true },
});
// Should have assignments only for specific first two members
// Member 1: Location (New York) + Skills (JavaScript) = 2 assignments
// Member 2: Location (London) + Skills (React + JavaScript) = 3 assignments
// Total = 5 assignments (no random assignments for other members/attributes)
expect(attributeAssignments.length).toBe(5);
// Should have 4 options with assigned users:
// Location: New York (assigned to user 1) + London (assigned to user 2) = 2 options
// Skills: JavaScript (assigned to user 1) + React & JavaScript (assigned to user 2) = 2 unique options
const customOptions = attributeOptions.filter((opt) => opt.assignedUsers.length > 0);
expect(customOptions.length).toBe(4);
});
it("should skip seeding if attributes already exist", async () => {
const orgId = 1;
// Create a membership
await prismock.membership.create({
data: {
id: 1,
userId: 1,
teamId: orgId,
role: "OWNER",
accepted: true,
},
});
// Pre-create one of the attributes
await prismock.attribute.create({
data: {
id: "attr-1",
name: "Department",
slug: "department",
type: "SINGLE_SELECT",
teamId: orgId,
enabled: true,
},
});
const result = await createAttributes({
orgId,
attributes: mockAttributes,
assignments: mockAssignments,
});
// Should return early and not create more attributes
expect(result).toBeUndefined();
const attributes = await prismock.attribute.findMany({
where: { teamId: orgId },
});
// Should still only have the 1 pre-existing attribute
expect(attributes).toHaveLength(1);
});
it("should handle organization with no members gracefully", async () => {
const orgId = 1;
// Don't create any memberships
const result = await createAttributes({
orgId,
attributes: mockAttributes,
assignments: mockAssignments,
});
// Should still create attributes
const attributes = await prismock.attribute.findMany({
where: { teamId: orgId },
});
expect(attributes).toHaveLength(5);
expect(result).toHaveLength(5);
// But no attribute assignments should be created
const attributeAssignments = await prismock.attributeToUser.findMany();
expect(attributeAssignments).toHaveLength(0);
});
it("should assign specific skills and locations to first two members", async () => {
const orgId = 1;
// Create exactly two memberships to test specific assignments
await prismock.membership.createMany({
data: [
{ id: 1, userId: 1, teamId: orgId, role: "OWNER", accepted: true },
{ id: 2, userId: 2, teamId: orgId, role: "MEMBER", accepted: true },
],
});
await createAttributes({
orgId,
attributes: mockAttributes,
assignments: mockAssignments,
});
// Get the attributes and their options
const skillsAttr = await prismock.attribute.findFirst({
where: { teamId: orgId, name: "Skills" },
include: { options: true },
});
const locationAttr = await prismock.attribute.findFirst({
where: { teamId: orgId, name: "Location" },
include: { options: true },
});
expect(skillsAttr).toBeDefined();
expect(locationAttr).toBeDefined();
// Check assignments for first member (should have JavaScript skill and New York location)
const firstMemberAssignments = await prismock.attributeToUser.findMany({
where: { memberId: 1 },
include: { attributeOption: true },
});
const firstMemberSkills = firstMemberAssignments
.filter((a) => a.attributeOption.attributeId === skillsAttr!.id)
.map((a) => a.attributeOption.value);
const firstMemberLocation = firstMemberAssignments
.filter((a) => a.attributeOption.attributeId === locationAttr!.id)
.map((a) => a.attributeOption.value);
expect(firstMemberSkills).toContain("JavaScript");
expect(firstMemberLocation).toContain("New York");
// Check assignments for second member (should have React and JavaScript skills, London location)
const secondMemberAssignments = await prismock.attributeToUser.findMany({
where: { memberId: 2 },
include: { attributeOption: true },
});
const secondMemberSkills = secondMemberAssignments
.filter((a) => a.attributeOption.attributeId === skillsAttr!.id)
.map((a) => a.attributeOption.value);
const secondMemberLocation = secondMemberAssignments
.filter((a) => a.attributeOption.attributeId === locationAttr!.id)
.map((a) => a.attributeOption.value);
expect(secondMemberSkills).toContain("JavaScript");
expect(secondMemberSkills).toContain("React");
expect(secondMemberLocation).toContain("London");
});
it("should generate correct slugs and enable all attributes", async () => {
const orgId = 123;
// Create a membership
await prismock.membership.create({
data: {
id: 1,
userId: 1,
teamId: orgId,
role: "OWNER",
accepted: true,
},
});
await createAttributes({
orgId,
attributes: mockAttributes,
assignments: mockAssignments,
});
const attributes = await prismock.attribute.findMany({
where: { teamId: orgId },
});
attributes.forEach((attr) => {
// All should be enabled
expect(attr.enabled).toBe(true);
// Should have correct slug format
expect(attr.slug).toMatch(/^org:123-/);
// Specific slug checks
if (attr.name === "Years of Experience") {
expect(attr.slug).toBe("org:123-years-of-experience");
}
});
});
});
@@ -0,0 +1,377 @@
import prismock from "../../../../../../tests/libs/__mocks__/prisma";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { createRoutingForm } from "../routingFormHelpers";
// Mock console.log to avoid noise in tests
const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {
// Intentionally empty to suppress console output during tests
});
describe("createRoutingForm", () => {
beforeEach(async () => {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
await prismock.reset();
consoleSpy.mockClear();
});
// Common attribute routing config for tests
const defaultAttributeRoutingConfig = {
attributes: [
{
name: "Department",
type: "SINGLE_SELECT" as const,
options: ["Engineering", "Sales", "Marketing", "Product", "Design"],
},
{
name: "Location",
type: "SINGLE_SELECT" as const,
options: ["New York", "London", "Tokyo", "Berlin", "Remote"],
},
{
name: "Skills",
type: "MULTI_SELECT" as const,
options: ["JavaScript", "React", "Node.js", "Python", "Design", "Sales"],
},
{
name: "Years of Experience",
type: "NUMBER" as const,
},
{
name: "Bio",
type: "TEXT" as const,
},
],
assignments: [
{
memberIndex: 0,
attributeValues: {
Location: ["New York"],
Skills: ["JavaScript"],
},
},
{
memberIndex: 1,
attributeValues: {
Location: ["London"],
Skills: ["React", "JavaScript"],
},
},
],
teamEvents: [
{
title: "Team Sales",
slug: "team-sales",
schedulingType: "ROUND_ROBIN" as const,
assignAllTeamMembers: true,
length: 60,
description: "Team Sales",
},
{
title: "Team Javascript",
slug: "team-javascript",
schedulingType: "ROUND_ROBIN" as const,
assignAllTeamMembers: true,
length: 60,
description: "Team Javascript",
},
],
};
describe("Default routing form", () => {
beforeEach(async () => {
// Create a test user for default form tests
await prismock.user.create({
data: {
id: 1,
username: "test-user",
email: "test@example.com",
name: "Test User",
},
});
});
it("should create a basic routing form", async () => {
const userId = 1;
const teamId = 1;
const form = await createRoutingForm({
userId,
teamId,
formType: "default",
});
expect(form).toBeDefined();
expect(form.name).toBe("Fixture Routing Form");
expect(form.userId).toBe(userId);
});
it("should create form with multiple route types", async () => {
const userId = 1;
const teamId = 1;
const form = await createRoutingForm({
userId,
teamId,
formType: "default",
});
expect(form.routes).toBeDefined();
const routes = form.routes as any[];
// Should have event redirect, custom page, external redirect, and multiselect routes
expect(routes.length).toBeGreaterThan(4);
const eventRedirectRoute = routes.find((r) => r.action?.type === "eventTypeRedirectUrl");
const customPageRoute = routes.find((r) => r.action?.type === "customPageMessage");
const externalRedirectRoute = routes.find((r) => r.action?.type === "externalRedirectUrl");
const fallbackRoute = routes.find((r) => r.isFallback === true);
expect(eventRedirectRoute).toBeDefined();
expect(customPageRoute).toBeDefined();
expect(externalRedirectRoute).toBeDefined();
expect(fallbackRoute).toBeDefined();
});
it("should create form with various field types", async () => {
const userId = 1;
const teamId = 1;
const form = await createRoutingForm({
userId,
teamId,
formType: "default",
});
const fields = form.fields as any[];
expect(fields).toBeDefined();
expect(fields.length).toBeGreaterThan(3);
const textField = fields.find((f) => f.type === "text");
const multiselectField = fields.find((f) => f.type === "multiselect");
const selectField = fields.find((f) => f.type === "select");
expect(textField).toBeDefined();
expect(multiselectField).toBeDefined();
expect(selectField).toBeDefined();
});
});
describe("Attribute routing form", () => {
beforeEach(async () => {
// Create a test user
await prismock.user.create({
data: {
id: 1,
username: "test-user",
email: "test@example.com",
name: "Test User",
},
});
// Setup organization and team structure for attribute routing
await prismock.team.createMany({
data: [
{ id: 100, name: "Test Org", isOrganization: true, slug: "test-org" },
{ id: 1, name: "Test Team", isOrganization: false, slug: "test-team", parentId: 100 },
],
});
await prismock.membership.createMany({
data: [
{ id: 1, userId: 1, teamId: 100, role: "OWNER", accepted: true }, // Org membership
{ id: 2, userId: 1, teamId: 1, role: "OWNER", accepted: true }, // Team membership
],
});
});
it("should create routing form with attribute routing", async () => {
const userId = 1;
const teamId = 1;
const result = await createRoutingForm({
userId,
teamId,
formType: "attributeRouting",
attributeRouting: defaultAttributeRoutingConfig,
});
expect(result).toBeDefined();
expect(result!.name).toBe("Form with Attribute Routing");
expect(result!.userId).toBe(userId);
expect(result!.teamId).toBe(teamId);
});
it("should create team event types for attribute routing", async () => {
const userId = 1;
const teamId = 1;
await createRoutingForm({
userId,
teamId,
formType: "attributeRouting",
attributeRouting: defaultAttributeRoutingConfig,
});
// Check that team event types were created
const salesEvent = await prismock.eventType.findFirst({
where: { slug: "team-sales", teamId },
});
const javascriptEvent = await prismock.eventType.findFirst({
where: { slug: "team-javascript", teamId },
});
expect(salesEvent).toBeDefined();
expect(salesEvent?.title).toBe("Team Sales");
expect(salesEvent?.schedulingType).toBe("ROUND_ROBIN");
expect(javascriptEvent).toBeDefined();
expect(javascriptEvent?.title).toBe("Team Javascript");
expect(javascriptEvent?.schedulingType).toBe("ROUND_ROBIN");
});
it("should create attributes for the organization", async () => {
const userId = 1;
const teamId = 1;
await createRoutingForm({
userId,
teamId,
formType: "attributeRouting",
attributeRouting: defaultAttributeRoutingConfig,
});
// Check that attributes were created for the organization
const attributes = await prismock.attribute.findMany({
where: { teamId: 100 }, // Organization ID
include: { options: true },
});
expect(attributes.length).toBeGreaterThan(0);
const skillsAttribute = attributes.find((attr) => attr.name === "Skills");
const locationAttribute = attributes.find((attr) => attr.name === "Location");
expect(skillsAttribute).toBeDefined();
expect(locationAttribute).toBeDefined();
});
it("should create routes with attribute-based routing logic", async () => {
const userId = 1;
const teamId = 1;
const result = await createRoutingForm({
userId,
teamId,
formType: "attributeRouting",
attributeRouting: defaultAttributeRoutingConfig,
});
const routes = result!.routes as any[];
expect(routes).toBeDefined();
expect(routes.length).toBeGreaterThanOrEqual(3);
// Should have routes with attributesQueryValue
const attributeRoutes = routes.filter((route) => route.attributesQueryValue);
expect(attributeRoutes.length).toBeGreaterThanOrEqual(2);
// Should have fallback route
const fallbackRoute = routes.find((route) => route.isFallback === true);
expect(fallbackRoute).toBeDefined();
});
it("should create form fields matching attributes", async () => {
const userId = 1;
const teamId = 1;
const result = await createRoutingForm({
userId,
teamId,
formType: "attributeRouting",
attributeRouting: defaultAttributeRoutingConfig,
});
const fields = result!.fields as any[];
expect(fields).toBeDefined();
expect(fields.length).toBe(5);
const locationField = fields.find((f) => f.label === "Location");
const skillsField = fields.find((f) => f.label === "skills");
const emailField = fields.find((f) => f.label === "Email");
const nameField = fields.find((f) => f.label === "Name");
const ratingField = fields.find((f) => f.label === "Rating");
expect(locationField?.type).toBe("select");
expect(skillsField?.type).toBe("multiselect");
expect(emailField?.type).toBe("email");
expect(nameField?.type).toBe("text");
expect(ratingField?.type).toBe("number");
// Check required fields
expect(locationField?.required).toBe(true);
expect(skillsField?.required).toBe(true);
expect(emailField?.required).toBe(true);
expect(nameField?.required).toBe(false);
expect(ratingField?.required).toBe(false);
});
it("should handle missing attributeRouting config", async () => {
const userId = 1;
const teamId = 1;
await expect(
createRoutingForm({
userId,
teamId,
formType: "attributeRouting",
})
).rejects.toThrow("attributeRouting config is required when formType is 'attributeRouting'");
});
it("should handle missing organization membership", async () => {
const userId = 2; // User without org membership
const teamId = 1;
// Create a test user without org membership
await prismock.user.create({
data: {
id: 2,
username: "test-user-2",
email: "test2@example.com",
name: "Test User 2",
},
});
// Only create team membership, not org membership
await prismock.membership.create({
data: { id: 3, userId: 2, teamId: 1, role: "MEMBER", accepted: true },
});
await expect(
createRoutingForm({
userId,
teamId,
formType: "attributeRouting",
attributeRouting: defaultAttributeRoutingConfig,
})
).rejects.toThrow();
});
it("should handle missing team", async () => {
const userId = 1;
const teamId = 999; // Non-existent team
await expect(
createRoutingForm({
userId,
teamId,
formType: "attributeRouting",
attributeRouting: defaultAttributeRoutingConfig,
})
).rejects.toThrow();
});
});
});
@@ -0,0 +1,177 @@
import { prisma } from "@calcom/prisma";
/**
* Example usage:
*
* // Basic usage with default assignments
* await createAttributes({
* orgId: 1,
* attributes: [
* {
* name: "Department",
* type: "SINGLE_SELECT",
* options: ["Engineering", "Sales", "Marketing"]
* }
* ],
* assignments: [
* {
* memberIndex: 0,
* attributeValues: {
* "Department": ["Engineering"]
* }
* }
* ]
* });
*
* // Create attributes without assignments
* await createAttributes({
* orgId: 1,
* attributes: [
* { name: "Bio", type: "TEXT" },
* { name: "Years of Experience", type: "NUMBER" }
* ]
* });
*/
interface AttributeConfig {
name: string;
type: "SINGLE_SELECT" | "MULTI_SELECT" | "TEXT" | "NUMBER";
options?: string[];
}
interface MemberAssignment {
memberIndex: number;
attributeValues: Record<string, string[]>; // attribute name -> selected values
}
interface SeedAttributesConfig {
orgId: number;
attributes: AttributeConfig[];
assignments?: MemberAssignment[];
}
/**
* Seeds attributes for an organization with configurable types, values, and assignments
* @param config - Configuration object containing orgId, attributes, and optional assignments
* @returns Array of created attributes with their options
*/
export async function createAttributes(config: SeedAttributesConfig) {
const { orgId, attributes: attributeConfigs, assignments = [] } = config;
console.log(`🎯 Seeding attributes for org ${orgId}`);
// Check if attributes already exist
const existingAttributes = await prisma.attribute.findMany({
where: {
teamId: orgId,
name: {
in: attributeConfigs.map((attr) => attr.name),
},
},
});
if (existingAttributes.length > 0) {
console.log(`Skipping attributes seed, attributes already exist`);
return;
}
const memberships = await prisma.membership.findMany({
where: {
teamId: orgId,
},
select: {
id: true,
userId: true,
},
});
console.log(`🎯 Creating attributes for org ${orgId}`);
const attributes: { id: string; name: string; options: { id: string; value: string }[] }[] = [];
for (const attr of attributeConfigs) {
const attribute = await prisma.attribute.create({
data: {
name: attr.name,
slug: `org:${orgId}-${attr.name.toLowerCase().replace(/ /g, "-")}`,
type: attr.type as "TEXT" | "NUMBER" | "SINGLE_SELECT" | "MULTI_SELECT",
teamId: orgId,
enabled: true,
options: attr.options
? {
create: attr.options.map((opt) => ({
value: opt,
slug: opt.toLowerCase().replace(/ /g, "-"),
})),
}
: undefined,
},
include: {
options: true,
},
});
attributes.push({
id: attribute.id,
name: attribute.name,
options: attribute.options.map((opt) => ({
id: opt.id,
value: opt.value,
})),
});
console.log(`\t📝 Created attribute: ${attr.name}`);
// Process assignments for this attribute
for (const assignment of assignments) {
const { memberIndex, attributeValues } = assignment;
// Check if this assignment has values for the current attribute
const valuesForAttribute = attributeValues[attr.name];
if (!valuesForAttribute || valuesForAttribute.length === 0) {
console.log(
`\t\t⏭️ Skipped ${attr.name} assignment for member ${memberIndex + 1} - no values specified`
);
continue;
}
// Get the member at the specified index
const member = memberships[memberIndex];
if (!member) {
console.log(`\t\t⚠️ Skipped ${attr.name} assignment - member at index ${memberIndex} not found`);
continue;
}
// Find the options for the specified values
const selectedOptions = attribute.options.filter((opt) => valuesForAttribute.includes(opt.value));
if (selectedOptions.length === 0) {
console.log(
`\t\t⚠️ Skipped ${attr.name} assignment for user ${
member.userId
} - no matching options found for values: ${valuesForAttribute.join(", ")}`
);
continue;
}
// Create assignments for each selected option
for (const option of selectedOptions) {
await prisma.attributeToUser.create({
data: {
memberId: member.id,
attributeOptionId: option.id,
},
});
}
const assignedValues = selectedOptions.map((opt) => opt.value);
console.log(
`\t\t✅ Assigned ${attr.name} [${assignedValues.map((v) => `"${v}"`).join(", ")}] to user ${
member.userId
} (member ${memberIndex + 1})`
);
}
}
return attributes;
}
@@ -0,0 +1,559 @@
import { uuid } from "short-uuid";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { prisma } from "@calcom/prisma";
import { createAttributes } from "./organizationHelpers";
import { createRoundRobinTeamEventType } from "./teamHelpers";
/**
* Configuration for form fields
*/
interface FieldConfig {
id?: string;
type: "text" | "email" | "select" | "multiselect" | "number";
label: string;
identifier?: string;
required?: boolean;
options?: { id: string; label: string }[];
selectText?: string;
}
/**
* Configuration for form routes
*/
interface RouteConfig {
id?: string;
action: {
type: "eventTypeRedirectUrl" | "customPageMessage" | "externalRedirectUrl";
value: string;
};
isFallback?: boolean;
queryValue?: any;
attributesQueryValue?: any;
fallbackAttributesQueryValue?: any;
}
/**
* Configuration for team events in attribute routing
*/
interface TeamEventConfig {
title: string;
slug: string;
schedulingType?: "ROUND_ROBIN" | "COLLECTIVE";
assignAllTeamMembers?: boolean;
length?: number;
description?: string;
}
/**
* Configuration for creating test routing forms
*/
interface CreateRoutingFormConfig {
userId: number;
teamId: number;
/**
* Whether to setup with attribute routing or not
*/
formType: "default" | "attributeRouting";
// Basic form settings
form?: {
name?: string;
description?: string;
fields?: FieldConfig[];
routes?: RouteConfig[];
};
// For attribute routing (required when formType is "attributeRouting")
attributeRouting?: {
orgId?: number; // If not provided, will find from userId
attributes: {
name: string;
type: "SINGLE_SELECT" | "MULTI_SELECT" | "TEXT" | "NUMBER";
options?: string[];
}[];
assignments: {
memberIndex: number;
attributeValues: Record<string, string[]>;
}[];
teamEvents: TeamEventConfig[];
};
}
/**
* Creates a test routing form with configurable options
*
* The routes are not configurable at the moment
* The hardcoded routes are:
* - Route-1: Skills:Javascript routes to team/team-javascript selecting only those team members who have JavaScript in their skills
* - Route-2: Skills:Sales routes to team/team-sales selecting only those team members who have Sales in their skills
* - Route-3: Fallback Message
*
* The hardcoded attributes are:
* - Location
* - Skills
* @param config - Configuration object containing all routing form settings
* @returns The created routing form
*/
export async function createRoutingForm(config: CreateRoutingFormConfig) {
const { userId, teamId, formType } = config;
if (formType === "attributeRouting") {
// Validate required config
if (!config.attributeRouting) {
throw new Error("attributeRouting config is required when formType is 'attributeRouting'");
}
// Find org membership
const orgMembership = await prisma.membership.findFirstOrThrow({
where: {
userId,
team: {
isOrganization: true,
},
},
});
if (!orgMembership) {
throw new Error("Organization membership not found");
}
const orgId = config.attributeRouting.orgId || orgMembership.teamId;
const team = await prisma.team.findUniqueOrThrow({
where: {
id: teamId,
},
});
// Use provided team events configuration
const teamEventsConfig = config.attributeRouting.teamEvents;
// Create team events
const teamEvents: { [key: string]: any } = {};
for (const eventConfig of teamEventsConfig) {
const eventType = await createRoundRobinTeamEventType({
teamId,
eventType: { ...eventConfig, length: eventConfig.length || 60 },
});
teamEvents[eventConfig.slug] = eventType;
}
// Use provided attributes and assignments configuration
const attributes = await createAttributes({
orgId,
attributes: config.attributeRouting.attributes,
assignments: config.attributeRouting.assignments,
});
if (!attributes) {
throw new Error("Attributes not found");
}
const skillAttribute = attributes.find((attr) => attr.name === "Skills")!;
const locationAttribute = attributes.find((attr) => attr.name === "Location")!;
console.log("Attributes", JSON.stringify(attributes, null, 2));
const javascriptEventRoute = {
id: "8a898988-89ab-4cde-b012-31823f708642",
value: `team/${team.slug}/team-javascript`,
};
const salesEventRoute = {
id: "8b2224b2-89ab-4cde-b012-31823f708642",
value: `team/${team.slug}/team-sales`,
};
const form = {
name: config.form?.name || "Form with Attribute Routing",
routes: [javascriptEventRoute, salesEventRoute],
formFieldLocation: {
id: "674c169a-e40a-492c-b4bb-6f5213873bd6",
},
formFieldSkills: {
id: "83316968-45bf-4c9d-b5d4-5368a8d2d2a8",
},
formFieldEmail: {
id: "dd28ffcf-7029-401e-bddb-ce2e7496a1c1",
},
formFieldName: {
id: "57734f65-8bbb-4065-9e71-fb7f0b7485f8",
},
formFieldRating: {
id: "f4e9fa6c-5c5d-4d8e-b15c-7f37e9d0c729",
},
};
const attributeRaw = {
location: {
id: "location-id",
options: [
{ id: "Location-London-uuid", value: "London" },
{ id: "Location-New-York-uuid", value: "New York" },
],
},
skills: {
id: "skills-id",
options: [
{ id: "Skills-JavaScript-uuid", value: "JavaScript" },
{ id: "Skills-Sales-uuid", value: "Sales" },
],
},
};
const formFieldSkillsOptions = attributeRaw.skills.options.map((opt) => ({
id: opt.id,
label: opt.value,
}));
const formFieldLocationOptions = attributeRaw.location.options.map((opt) => ({
id: opt.id,
label: opt.value,
}));
const createdForm = await prisma.app_RoutingForms_Form.create({
data: {
id: uuid(),
routes: [
{
id: javascriptEventRoute.id,
action: {
type: "eventTypeRedirectUrl",
value: javascriptEventRoute.value,
},
queryValue: {
id: "aaba9988-cdef-4012-b456-719300f53ef8",
type: "group",
children1: {
"b98b98a8-0123-4456-b89a-b19300f55277": {
type: "rule",
properties: {
field: form.formFieldSkills.id,
value: [
formFieldSkillsOptions.filter((opt) => opt.label === "JavaScript").map((opt) => opt.id),
],
operator: "multiselect_equals",
valueSrc: ["value"],
valueType: ["multiselect"],
valueError: [null],
},
},
},
},
attributesQueryValue: {
id: "ab99bbb9-89ab-4cde-b012-319300f53ef8",
type: "group",
children1: {
"b98b98a8-0123-4456-b89a-b19300f55277": {
type: "rule",
properties: {
field: skillAttribute.id,
value: [
skillAttribute.options.filter((opt) => opt.value === "JavaScript").map((opt) => opt.id),
],
operator: "multiselect_some_in",
valueSrc: ["value"],
valueType: ["multiselect"],
valueError: [null],
},
},
},
},
},
{
id: salesEventRoute.id,
action: {
type: "eventTypeRedirectUrl",
value: salesEventRoute.value,
},
queryValue: {
id: "aaba9948-cdef-4012-b456-719300f53ef8",
type: "group",
children1: {
"c98b98a8-1123-4456-e89a-a19300f55277": {
type: "rule",
properties: {
field: form.formFieldSkills.id,
value: [
formFieldSkillsOptions.filter((opt) => opt.label === "Sales").map((opt) => opt.id),
],
operator: "multiselect_equals",
valueSrc: ["value"],
valueType: ["multiselect"],
valueError: [null],
},
},
},
},
attributesQueryValue: {
id: "ab988888-89ab-4cde-b012-319300f53ef8",
type: "group",
children1: {
"b98b98a12-0123-4456-b89a-b19300f55277": {
type: "rule",
properties: {
field: skillAttribute.id,
value: [
skillAttribute.options.filter((opt) => opt.value === "Sales").map((opt) => opt.id),
],
operator: "multiselect_some_in",
valueSrc: ["value"],
valueType: ["multiselect"],
valueError: [null],
},
},
},
},
fallbackAttributesQueryValue: {
id: "a9888488-4567-489a-bcde-f19300f53ef8",
type: "group",
},
},
{
id: "148899aa-4567-489a-bcde-f1823f708646",
action: { type: "customPageMessage", value: "Fallback Message" },
isFallback: true,
queryValue: { id: "814899aa-4567-489a-bcde-f1823f708646", type: "group" },
},
],
fields: config.form?.fields || [
{
id: form.formFieldLocation.id,
type: "select",
label: "Location",
options: formFieldLocationOptions,
required: true,
},
{
id: form.formFieldSkills.id,
type: "multiselect",
label: "skills",
options: formFieldSkillsOptions,
required: true,
},
{
id: form.formFieldEmail.id,
type: "email",
label: "Email",
required: true,
},
{
id: form.formFieldName.id,
identifier: "name",
type: "text",
label: "Name",
required: false,
},
{
id: form.formFieldRating.id,
type: "number",
label: "Rating",
required: false,
},
],
team: {
connect: {
id: teamId,
},
},
user: {
connect: {
id: userId,
},
},
name: form.name,
},
});
console.log(`🎯 Created form ${createdForm.id}`, JSON.stringify(createdForm, null, 2));
return createdForm;
} else {
// Default routing form
const multiSelectOption2Uuid = "d1302635-9f12-17b1-9153-c3a854649182";
const multiSelectOption1Uuid = "d1292635-9f12-17b1-9153-c3a854649182";
const selectOption1Uuid = "d0292635-9f12-17b1-9153-c3a854649182";
const selectOption2Uuid = "d0302635-9f12-17b1-9153-c3a854649182";
const multiSelectLegacyFieldUuid = "d4292635-9f12-17b1-9153-c3a854649182";
const multiSelectFieldUuid = "d9892635-9f12-17b1-9153-c3a854649182";
const selectFieldUuid = "d1302635-9f12-17b1-9153-c3a854649182";
const legacySelectFieldUuid = "f0292635-9f12-17b1-9153-c3a854649182";
const form = await prisma.app_RoutingForms_Form.create({
data: {
routes: config.form?.routes || [
{
id: "8a898988-89ab-4cde-b012-31823f708642",
action: { type: "eventTypeRedirectUrl", value: "pro/30min" },
queryValue: {
id: "8a898988-89ab-4cde-b012-31823f708642",
type: "group",
children1: {
"8988bbb8-0123-4456-b89a-b1823f70c5ff": {
type: "rule",
properties: {
field: "c4296635-9f12-47b1-8153-c3a854649182",
value: ["event-routing"],
operator: "equal",
valueSrc: ["value"],
valueType: ["text"],
},
},
},
},
},
{
id: "aa8aaba9-cdef-4012-b456-71823f70f7ef",
action: { type: "customPageMessage", value: "Custom Page Result" },
queryValue: {
id: "aa8aaba9-cdef-4012-b456-71823f70f7ef",
type: "group",
children1: {
"b99b8a89-89ab-4cde-b012-31823f718ff5": {
type: "rule",
properties: {
field: "c4296635-9f12-47b1-8153-c3a854649182",
value: ["custom-page"],
operator: "equal",
valueSrc: ["value"],
valueType: ["text"],
},
},
},
},
},
{
id: "a8ba9aab-4567-489a-bcde-f1823f71b4ad",
action: { type: "externalRedirectUrl", value: `${WEBAPP_URL}/pro` },
queryValue: {
id: "a8ba9aab-4567-489a-bcde-f1823f71b4ad",
type: "group",
children1: {
"998b9b9a-0123-4456-b89a-b1823f7232b9": {
type: "rule",
properties: {
field: "c4296635-9f12-47b1-8153-c3a854649182",
value: ["external-redirect"],
operator: "equal",
valueSrc: ["value"],
valueType: ["text"],
},
},
},
},
},
{
id: "aa8ba8b9-0123-4456-b89a-b182623406d8",
action: { type: "customPageMessage", value: "Multiselect(Legacy) chosen" },
queryValue: {
id: "aa8ba8b9-0123-4456-b89a-b182623406d8",
type: "group",
children1: {
"b98a8abb-cdef-4012-b456-718262343d27": {
type: "rule",
properties: {
field: multiSelectLegacyFieldUuid,
value: [["Option-2"]],
operator: "multiselect_equals",
valueSrc: ["value"],
valueType: ["multiselect"],
},
},
},
},
},
{
id: "bb9ea8b9-0123-4456-b89a-b182623406d8",
action: { type: "customPageMessage", value: "Multiselect chosen" },
queryValue: {
id: "aa8ba8b9-0123-4456-b89a-b182623406d8",
type: "group",
children1: {
"b98a8abb-cdef-4012-b456-718262343d27": {
type: "rule",
properties: {
field: multiSelectFieldUuid,
value: [[multiSelectOption2Uuid]],
operator: "multiselect_equals",
valueSrc: ["value"],
valueType: ["multiselect"],
},
},
},
},
},
{
id: "898899aa-4567-489a-bcde-f1823f708646",
action: { type: "customPageMessage", value: "Fallback Message" },
isFallback: true,
queryValue: { id: "898899aa-4567-489a-bcde-f1823f708646", type: "group" },
},
],
fields: config.form?.fields || [
{
id: "c4296635-9f12-47b1-8153-c3a854649182",
type: "text",
label: "Test field",
required: true,
},
{
id: multiSelectLegacyFieldUuid,
type: "multiselect",
label: "Multi Select(with Legacy `selectText`)",
identifier: "multi",
selectText: "Option-1\nOption-2",
required: false,
},
{
id: multiSelectFieldUuid,
type: "multiselect",
label: "Multi Select",
identifier: "multi-new-format",
options: [
{
id: multiSelectOption1Uuid,
label: "Option-1",
},
{
id: multiSelectOption2Uuid,
label: "Option-2",
},
],
required: false,
},
{
id: legacySelectFieldUuid,
type: "select",
label: "Legacy Select",
identifier: "test-select",
selectText: "Option-1\nOption-2",
required: false,
},
{
id: selectFieldUuid,
type: "select",
label: "Select",
identifier: "test-select-new-format",
options: [
{
id: selectOption1Uuid,
label: "Option-1",
},
{
id: selectOption2Uuid,
label: "Option-2",
},
],
required: false,
},
],
user: {
connect: {
id: userId,
},
},
name: config.form?.name || "Fixture Routing Form",
},
});
return form;
}
}
@@ -0,0 +1,50 @@
import type { Prisma as PrismaType } from "@prisma/client";
import { prisma } from "@calcom/prisma";
/**
* Creates a team event type and assigns all team members as hosts
* @param teamId - The team ID
* @param eventType - Event type data to create
* @returns The created event type
*/
export async function createRoundRobinTeamEventType({
teamId,
eventType,
}: {
teamId: number;
eventType: PrismaType.EventTypeCreateInput;
}) {
const createdEventType = await prisma.eventType.create({
data: {
...eventType,
team: {
connect: {
id: teamId,
},
},
},
include: {
team: true,
},
});
const teamMemberships = await prisma.membership.findMany({
where: {
teamId,
},
});
// Add all team members as hosts (excluding the current user who's already added)
for (const membership of teamMemberships) {
await prisma.host.create({
data: {
userId: membership.userId,
eventTypeId: createdEventType.id,
isFixed: false, // ROUND_ROBIN scheduling type
},
});
}
return createdEventType;
}
@@ -88,7 +88,12 @@ export function getAbsoluteEventTypeRedirectUrl({
}
}
const origin = teamSlugInRedirectUrl ? form.teamOrigin : form.userOrigin;
// We want origin to be the WEBAPP_URL as in E2E the org domain won't exist. E2E fake request from org domain using doOnOrgDomain
const origin = process.env.NEXT_PUBLIC_IS_E2E
? WEBAPP_URL
: teamSlugInRedirectUrl
? form.teamOrigin
: form.userOrigin;
return `${origin}/${eventTypeRedirectUrl}?${allURLSearchParams}`;
}
@@ -0,0 +1,16 @@
import type { Frame } from "@playwright/test";
import { expect } from "@playwright/test";
export async function expectHostsToBe({ hosts, frame }: { hosts: { email: string }[]; frame: Frame }) {
try {
await frame.waitForSelector(`[data-testid="booking-host-email"]`, { timeout: 5000 });
} catch (error) {
console.error("Failed to find booking-host-email element:", error.message);
throw new Error(`booking-host-email element not found. `);
}
const hostEmailsOnPage = await frame.locator(`[data-testid="booking-host-email"]`).allTextContents();
const expectedEmails = hosts.map((host) => host.email);
expect(hostEmailsOnPage).toEqual(expect.arrayContaining(expectedEmails));
}
@@ -3,6 +3,39 @@ import { expect } from "@playwright/test";
import prisma from "@calcom/prisma";
export async function getQueuedFormResponse(queuedFormResponseId: string) {
return prisma.app_RoutingForms_QueuedFormResponse.findFirst({
where: {
id: queuedFormResponseId,
},
include: {
actualResponse: true,
},
});
}
export async function getAllFormResponses(formId: string) {
return prisma.app_RoutingForms_FormResponse.findMany({
where: {
formId: formId,
},
orderBy: {
createdAt: "desc",
},
});
}
export async function getLatestQueuedFormResponse({ formId }: { formId: string }) {
return prisma.app_RoutingForms_QueuedFormResponse.findFirst({
where: {
formId: formId,
},
orderBy: {
createdAt: "desc",
},
});
}
export const deleteAllBookingsByEmail = async (email: string) =>
await prisma.booking.deleteMany({
where: {
@@ -115,6 +148,10 @@ export async function bookFirstEvent(username: string, frame: Frame, page: Page)
await frame.waitForTimeout(1000);
// expect(await page.screenshot()).toMatchSnapshot("availability-page-1.png");
// Remove /embed from the end if present.
return bookEvent({ frame, page });
}
export async function bookEvent({ frame, page }: { frame: Frame; page: Page }) {
const eventSlug = new URL(frame.url()).pathname.replace(/\/embed$/, "");
await selectFirstAvailableTimeSlotNextMonth(frame, page);
// expect(await page.screenshot()).toMatchSnapshot("booking-page.png");
@@ -158,3 +195,40 @@ export async function assertNoRequestIsBlocked(page: Page) {
}
});
}
export async function expectEmbedIFrameToBeVisible({
calNamespace,
page,
}: {
calNamespace: string;
page: Page;
}) {
const iframe = page.locator(`[name="cal-embed=${calNamespace}"]`);
await expect(iframe).toBeVisible();
}
export async function expectActualFormResponseConnectedToQueuedFormResponse({
queuedFormResponse,
page,
numberOfExpectedSetFieldValues,
}: {
queuedFormResponse: { id: string };
page: Page;
}) {
const responsePromise = page.waitForResponse("**/queued-response");
const response = await responsePromise;
expect(response.status()).toBe(200);
const queuedFormResponseFromDb = await getQueuedFormResponse(queuedFormResponse.id);
expect(queuedFormResponseFromDb?.actualResponse?.id).toBeDefined();
const responseFromDb = queuedFormResponseFromDb?.actualResponse?.response;
expect(responseFromDb).toBeDefined();
const valuesFromResponse = Object.values(responseFromDb).map((item) => item.value);
const valuesSetInResponse = valuesFromResponse.filter((value) => !!value);
// We are unable to verify the exact response values because we don't directly have the form field identifiers in responseFromDB and would require correlating that with the actual Form from DB
// So, for now we just verify the number of values set
// There are 5 values that are submitted when CTA is clicked.
// TODO: We should be able to verify the exact response values by correlating the form field identifiers with the actual Form from DB
expect(valuesSetInResponse.length).toBe(numberOfExpectedSetFieldValues);
}
@@ -0,0 +1,170 @@
import type { Page } from "@playwright/test";
import { expect } from "@playwright/test";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { SchedulingType } from "@calcom/prisma/enums";
import { test } from "@calcom/web/playwright/lib/fixtures";
import type { Fixtures } from "@calcom/web/playwright/lib/fixtures";
import { doOnOrgDomain } from "@calcom/web/playwright/lib/testUtils";
import { expectHostsToBe } from "../lib/pages/bookingSuccessPage";
import {
getEmbedIframe,
bookEvent,
expectEmbedIFrameToBeVisible,
getAllFormResponses,
expectActualFormResponseConnectedToQueuedFormResponse,
getLatestQueuedFormResponse,
} from "../lib/testUtils";
// in parallel mode sometimes handleNewBooking endpoint throws "No available users found" error, this never happens in serial mode.
test.describe.configure({ mode: "serial" });
async function fillRoutingRequiredAttributes(
page: Page,
{ skills, location, email }: { skills: string; location: string; email: string }
) {
await page.fill("#cal-booking-place-routingFormFullPrerender-input-email", email);
await page.selectOption("#cal-booking-place-routingFormFullPrerender-select-skills", skills);
await page.selectOption("#cal-booking-place-routingFormFullPrerender-select-location", location);
return {
skills,
location,
email,
};
}
async function fillRestOfTheFormAndSubmit(page: Page) {
const name = "John Doe";
await page.fill("#cal-booking-place-routingFormFullPrerender-input-name", name);
await page.click("#cta-routingFormFullPrerender");
return {
name,
};
}
//TODO: Change these tests to use a user/eventType per embed type atleast. This is so that we can test different themes,layouts configured in App or per EventType
test.describe("Prerender Headless Router", () => {
test.afterEach(async ({ users }) => {
await users.deleteAll();
});
test("should prerender when routing-required fields are filled and route to Javascript team as Javascript skills is selected and then doing the actual booking should show the routed team members as hosts", async ({
page,
embeds,
users,
}) => {
const userFixture = await users.create(
{ username: "routing-forms" },
{
hasTeam: true,
isOrg: true,
hasSubteam: true,
schedulingType: SchedulingType.ROUND_ROBIN,
seedRoutingFormWithAttributeRouting: true,
seedRoutingForms: true,
}
);
const orgMembership = await userFixture.getOrgMembership();
const teamMembership = await userFixture.getFirstTeamMembership();
const routingForm = userFixture.routingForms[0];
const calNamespace = "routingFormFullPrerender";
await embeds.gotoPlayground({
calNamespace,
url: `/embed/routing-playground.html?only=ns:${calNamespace}&param.formId=${routingForm.id}&calOrigin=${WEBAPP_URL}&cal.embed.logging=1`,
});
await doOnOrgDomain({ orgSlug: orgMembership.team.slug, page }, async ({ page }) => {
const { skills, location, email } = await fillRoutingRequiredAttributes(page, {
skills: "JavaScript",
location: "London",
email: "embed-user@example.com", // This is the email that will be used to book the event
});
const expectedRoutedTeamMembers = [teamMembership.user];
// Routes to the team booking page with params cal.routingFormResponseId, cal.routedTeamMemberIds
// Sample URL: http://app.cal.local:3000/team/team-0-1751270109394/team-javascript/embed?cal.teamId=103&cal.orgId=104&Rating=5&embed=routingFormFullPrerender&debug=true&only=ns%3AroutingFormFullPrerender&param.formId=bed2a37b-8bf7-4eb9-9b9e-4adf33bd9470&calOrigin=http%3A%2F%2Fapp.cal.remote%3A3000&cal.embed.logging=1&cal.embed.pageType=team.event.booking.slots&cal.action=eventTypeRedirectUrl&cal.queuedFormResponseId=cmcit0aos0006j5o4qctc2oyy&cal.embed.connectVersion=0&form=bed2a37b-8bf7-4eb9-9b9e-4adf33bd9470&skills=JavaScript&Location=London&name=John+Doe&Email=undefined&Manager=John+Doe&embedType=modal&guest=undefined&cal.routingFormResponseId=14&month=2025-07&date=2025-07-02&slot=2025-07-02T08%3A00%3A00.000Z
const { prerenderedIframe, queuedFormResponseId } = await expectPrerenderedRoutedTeamBookingPage({
page,
calNamespace,
calLink: `/team/${teamMembership.team.slug}/team-javascript`,
embeds,
routedTeamMemberIds: expectedRoutedTeamMembers.map((user) => user.id),
routingForm,
});
const { name } = await fillRestOfTheFormAndSubmit(page);
await Promise.all([
expectEmbedIFrameToBeVisible({ calNamespace, page }),
expectActualFormResponseConnectedToQueuedFormResponse({
queuedFormResponse: { id: queuedFormResponseId },
numberOfExpectedSetFieldValues: 5,
page,
}),
]);
await bookEvent({ frame: prerenderedIframe, page });
await expectHostsToBe({
hosts: expectedRoutedTeamMembers.map((user) => ({ email: user.email })),
frame: prerenderedIframe,
});
});
});
});
async function expectPrerenderedRoutedTeamBookingPage({
page,
calNamespace,
calLink,
embeds,
routingForm,
routedTeamMemberIds,
}: {
page: Page;
calNamespace: string;
calLink: string;
embeds: Fixtures["embeds"];
routingForm: {
id: string;
};
routedTeamMemberIds: string[];
}) {
const prerenderedIframe = await getEmbedIframe({ calNamespace, page, pathname: calLink });
if (!prerenderedIframe) {
throw new Error("Prerendered iframe not found");
}
// As Routing Form is unique to the test, we can expect responses to be provided by the test itself.
// So, the only response should be the one that is provided by the test itself.
const queuedResponse = await getLatestQueuedFormResponse({ formId: routingForm.id });
const routingFormResponses = await getAllFormResponses(routingForm.id);
if (!queuedResponse) {
throw new Error("Couldn't find the queued response");
}
if (routingFormResponses.length > 0) {
throw new Error(
`Routing form response shouldn't have been recorded. Found one with id: ${routingFormResponses[0].id}`
);
}
await expect(prerenderedIframe).toBeEmbedCalLink(
calNamespace,
embeds.getActionFiredDetails,
{
pathname: calLink,
searchParams: {
"cal.queuedFormResponseId": queuedResponse.id,
"cal.routedTeamMemberIds": routedTeamMemberIds.join(","),
},
},
true
);
return { prerenderedIframe, queuedFormResponseId: queuedResponse.id };
}
@@ -12,7 +12,7 @@
const newSearchParams = new URLSearchParams(location.search);
let paramsChanged = false;
if (!newSearchParams.has("only")) {
newSearchParams.set("only", "ns:routingFormPrerender");
newSearchParams.set("only", "ns:routingFormFullPrerender");
paramsChanged = true;
}
@@ -116,56 +116,15 @@
const email = form.querySelector("input[name='email']").value;
const skills = form.querySelector("select[name='skills']").value;
if (name && email) {
document.getElementById("cal-booking-place-routingFormWithoutPrerender-submit").setAttribute("data-cal-link", `router?form=${seededFormAcmeId}&email=${email}&name=${name}&Location=London&Department=Engineering&Manager=John Doe&Rating=5&skills=${skills}&Email=${email}`);
document.getElementById("cal-booking-place-routingFormWithoutPrerender-submit").setAttribute("data-cal-link", `router?form=${seededFormAcmeId}&email=${email}&name=${name}&Location=London&Department=Engineering&Rating=5&skills=${skills}&Email=${email}`);
}
requestAnimationFrame(updateSubmitButtonLink);
});
</script>
</div>
<div id="cal-booking-place-routingFormPrerender">
<div id="cal-booking-routingFormFullPrerender">
<hr/>
<a href="?only=ns:routingFormPrerender">Routing Form Prerender Team Booking Page - Demo</a>
<p>1. As soon as you start typing in the form, the form will prerender the booking page(where redirect is supposed to happen)</p>
<p>2. Clicking on submit will open the prerendered/being prerendered iframe in the modal, sending fresh request for headless router, followed by fetching the slots</p>
<p>3. Changing the form response and clicking submit, will re-submit the response and reopen the modal with the updated data(update slots call, updated prefill fields)</p>
<p>4. Clicking submit again without changing the form response will just re-show the hidden modal(as long as slotsStaleTimeMs threshold is not breached). But, in case of an error it iframe always tries to rerender. Trying adding an invalid email to see the error</p>
<form id="cal-booking-place-routingFormPrerender-form">
<input type="text" name="name" placeholder="John Doe" />
<input type="email" name="email" placeholder="john@example.com" />
<select name="skills" placeholder="JavaScript, Node.js">
<option value="JavaScript">JavaScript</option>
<option value="Sales">Sales</option>
</select>
</form>
<button id="cal-booking-place-routingFormPrerender-submit" data-cal-namespace="routingFormPrerender" data-cal-config='{"cal.embed.pageType":"team.event.booking.slots", "guests":["guest1@example.com", "guest2@example.com"]}'>Submit</button>
<script>
document.getElementById("cal-booking-place-routingFormPrerender-form").addEventListener("input", function prerender(e) {
if (prerender.initiated) return;
prerender.initiated = true;
console.log("Playground: prerender initiated");
Cal.ns.routingFormPrerender("prerender", {
calLink: "team/insights-team/team-javascript",
type: "modal",
pageType: "user.event.booking.slots",
});
});
requestAnimationFrame(function updateSubmitButtonLink() {
const seededFormAcmeId = "948ae412-d995-4865-885a-48302588de03";
const form = document.getElementById("cal-booking-place-routingFormPrerender-form");
const name = form.querySelector("input[name='name']").value;
const email = form.querySelector("input[name='email']").value;
const skills = form.querySelector("select[name='skills']").value;
if (name && email) {
document.getElementById("cal-booking-place-routingFormPrerender-submit").setAttribute("data-cal-link", `router?form=${seededFormAcmeId}&email=${email}&name=${name}&Location=London&Department=Engineering&Manager=John Doe&Rating=5&skills=${skills}&Email=${email}`);
}
requestAnimationFrame(updateSubmitButtonLink);
});
</script>
</div>
<div id="cal-booking-routing-full-prerender">
<hr/>
<a href="?only=ns:routing-full-prerender&debug=1&param.email=test@example.com">Routing Form - Prerender Headless Router itself queuing the form response</a>
<a href="?only=ns:routingFormFullPrerender&debug=1&param.email=test@example.com">Routing Form - Prerender Headless Router itself queuing the form response</a>
<p>1. As page is loading, we prerender the headless router for the email passed as param.email</p>
<p>2. Whenever email changes and onblur happens, the prerender is triggered for the new email and previous prerendered modal is removed</p>
<p>3. The prerender of the headless router queues the form response and doesn't really record it. When the actual booking is made, the form response is recorded from the queue</p>
@@ -174,36 +133,73 @@
<p>6. slotsStaleTimeMs is set to 10 seconds(default is 1 minute) and iframeForceReloadThresholdMs is set to 30 seconds(default is 15 minutes) and they are considered from the time when the prerender/modal call was made</p>
<p>7. To avoid reaching the iframeForceReloadThresholdMs, user could prerender the router again and again judiciously</p>
</p>
<script>
window.buildRouterUrl = ({email, formId}) => {
return `router?form=${formId}&email=${email}`;
}
</script>
<div class="inline-embed-container">
<div id="cal-booking-place-routing-full-prerender">
<div class="place"></div>
<label for="cal-booking-place-routing-full-prerender-input-email">Email</label>
<script>
document.write(`<input style="width: 400px" type="email" id="cal-booking-place-routing-full-prerender-input-email" value="${window.params.email ?? ''}" />`)
</script>
</div>
</div>
<h3><script>
document.write(`<button id="cta-routing-full-prerender" data-cal-namespace="routingFormFullPrerender" data-cal-origin="${window.calOrigin}" data-cal-link="${window.buildRouterUrl({email:document.getElementById('cal-booking-place-routing-full-prerender-input-email').value, formId:window.params.formId})}" data-cal-config='{"cal.embed.pageType":"team.event.booking.slots"}'>Demo</button>`)
</script></h3>
<script>
(()=>{
if (window.only !== "ns:routing-full-prerender") {
<script>
(()=>{
if (window.only !== "ns:routingFormFullPrerender") {
return;
}
Cal("init", "routingFormFullPrerender", {
debug: true,
calOrigin: window.calOrigin,
});
const prerender = ({email, formId}) => {
window.routingFormFullPrerender = {
buildRouterUrl: ({skills, location, name, email, formId}) => {
return `router?form=${formId}&skills=${skills}&Location=${location}&name=${name}&Email=${email}&Rating=5`;
},
onFormSubmit: (e) => {
e.preventDefault();
const skills = document.getElementById('cal-booking-place-routingFormFullPrerender-select-skills').value;
const location = document.getElementById('cal-booking-place-routingFormFullPrerender-select-location').value;
const name = document.getElementById('cal-booking-place-routingFormFullPrerender-input-name').value;
const email = document.getElementById('cal-booking-place-routingFormFullPrerender-input-email').value;
const routerUrl = window.routingFormFullPrerender.buildRouterUrl({skills, location, name, email, formId: window.params.formId});
Cal.ns.routingFormFullPrerender("modal", {
calLink: routerUrl,
calOrigin: window.calOrigin,
});
}
}
})();
</script>
<div id="cal-booking-place-routingFormFullPrerender">
<div class="place"></div>
<form id="cal-booking-place-routingFormFullPrerender-form" onsubmit="window.routingFormFullPrerender.onFormSubmit(event)">
<label for="cal-booking-place-routingFormFullPrerender-input-name">Name</label>
<input required style="width: 400px" type="text" id="cal-booking-place-routingFormFullPrerender-input-name" placeholder="John Doe" />
<label for="cal-booking-place-routingFormFullPrerender-input-email">Email</label>
<input required style="width: 400px" type="email" id="cal-booking-place-routingFormFullPrerender-input-email" placeholder="john@example.com" />
<label for="cal-booking-place-routingFormFullPrerender-select-skills">Skills</label>
<select required style="width: 400px" id="cal-booking-place-routingFormFullPrerender-select-skills">
<option value="">Select a skill</option>
<option value="JavaScript">JavaScript</option>
<option value="React">React</option>
<option value="Node.js">Node.js</option>
<option value="Python">Python</option>
<option value="Sales">Sales</option>
</select>
<label for="cal-booking-place-routingFormFullPrerender-select-location">Location</label>
<select required style="width: 400px" id="cal-booking-place-routingFormFullPrerender-select-location">
<option value="">Select a location</option>
<option value="New York">New York</option>
<option value="London">London</option>
<option value="Tokyo">Tokyo</option>
<option value="Berlin">Berlin</option>
<option value="Remote">Remote</option>
</select>
<button type="submit" id="cta-routingFormFullPrerender">Submit</button>
</form>
</div>
</div>
<script>
(()=>{
const prerender = ({skills, location, email, formId}) => {
Cal.ns.routingFormFullPrerender("prerender", {
// Email will be automatically passed as query param
calLink: window.buildRouterUrl({email, formId}),
calLink: window.routingFormFullPrerender.buildRouterUrl({skills, location, email, formId}),
type: "modal",
pageType: "team.event.booking.slots",
options: {
@@ -212,26 +208,23 @@
},
});
}
document.getElementById('cal-booking-place-routing-full-prerender-input-email').onchange = function onEmailChange(e) {
const element = e.target;
const email = element.validity.valid ? element.value : null;
if (!email) {
function onSelectChange(e) {
const skills = document.getElementById('cal-booking-place-routingFormFullPrerender-select-skills').value;
const location = document.getElementById('cal-booking-place-routingFormFullPrerender-select-location').value;
const email = document.getElementById('cal-booking-place-routingFormFullPrerender-input-email').value;
function areFieldRequiredbyRoutingFormAvailable() {
return skills && location;
}
if (!areFieldRequiredbyRoutingFormAvailable()) {
return;
}
function updateCtaLink(email) {
const element = document.getElementById('cta-routing-full-prerender');
const currentLink = element.getAttribute('data-cal-link');
const currentLinkObject = new URL(currentLink, 'https://example.com');
currentLinkObject.searchParams.set('email', email);
element.setAttribute('data-cal-link', currentLinkObject.pathname + currentLinkObject.search);
}
updateCtaLink(email);
prerender({email, formId:window.params.formId});
prerender({skills, location, email, formId: window.params.formId});
}
if (!window.params.email || !window.params.formId) {
alert('Pass param.email and param.formId query params')
}
prerender({email:window.params.email, formId:window.params.formId});
document.getElementById('cal-booking-place-routingFormFullPrerender-select-skills').onchange = onSelectChange;
document.getElementById('cal-booking-place-routingFormFullPrerender-select-location').onchange = onSelectChange;
})();
</script>
</div>
+4
View File
@@ -19,6 +19,7 @@ import { orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomain
import { isAuthorizedToViewFormOnOrgDomain } from "@calcom/features/routing-forms/lib/isAuthorizedToViewForm";
import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowError";
import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
import { withReporting } from "@calcom/lib/sentryWrapper";
import { RoutingFormRepository } from "@calcom/lib/server/repository/routingForm";
import { UserRepository } from "@calcom/lib/server/repository/user";
@@ -179,6 +180,9 @@ const _getRoutedUrl = async (context: Pick<GetServerSidePropsContext, "query" |
},
};
}
log.error("Error handling the response", safeStringify(e));
throw new Error("Error handling the response");
}
// TODO: To be done using sentry tracing