diff --git a/apps/web/modules/bookings/views/bookings-single-view.tsx b/apps/web/modules/bookings/views/bookings-single-view.tsx
index 71c18e8792..bf38a499e8 100644
--- a/apps/web/modules/bookings/views/bookings-single-view.tsx
+++ b/apps/web/modules/bookings/views/bookings-single-view.tsx
@@ -621,7 +621,7 @@ export default function Success(props: PageProps) {
+
{bookingInfo?.userPrimaryEmail ?? bookingInfo.user.email}
)} diff --git a/apps/web/playwright/fixtures/users.ts b/apps/web/playwright/fixtures/users.ts index fb224859f1..71a7520d54 100644 --- a/apps/web/playwright/fixtures/users.ts +++ b/apps/web/playwright/fixtures/users.ts @@ -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 diff --git a/apps/web/playwright/lib/test-helpers/__tests__/organizationHelpers.test.ts b/apps/web/playwright/lib/test-helpers/__tests__/organizationHelpers.test.ts new file mode 100644 index 0000000000..dd926c2553 --- /dev/null +++ b/apps/web/playwright/lib/test-helpers/__tests__/organizationHelpers.test.ts @@ -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"); + } + }); + }); +}); diff --git a/apps/web/playwright/lib/test-helpers/__tests__/routingFormHelpers.test.ts b/apps/web/playwright/lib/test-helpers/__tests__/routingFormHelpers.test.ts new file mode 100644 index 0000000000..9005d3a5e1 --- /dev/null +++ b/apps/web/playwright/lib/test-helpers/__tests__/routingFormHelpers.test.ts @@ -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(); + }); + }); +}); diff --git a/apps/web/playwright/lib/test-helpers/organizationHelpers.ts b/apps/web/playwright/lib/test-helpers/organizationHelpers.ts new file mode 100644 index 0000000000..19b60ef474 --- /dev/null +++ b/apps/web/playwright/lib/test-helpers/organizationHelpers.ts @@ -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: Record1. As soon as you start typing in the form, the form will prerender the booking page(where redirect is supposed to happen)
-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
-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)
-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
- - - -1. As page is loading, we prerender the headless router for the email passed as param.email
2. Whenever email changes and onblur happens, the prerender is triggered for the new email and previous prerendered modal is removed
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
@@ -174,36 +133,73 @@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
7. To avoid reaching the iframeForceReloadThresholdMs, user could prerender the router again and again judiciously
- - - +