import type { Page } from "@playwright/test"; import { expect } from "@playwright/test"; import { JSDOM } from "jsdom"; import { uuid } from "short-uuid"; import { getOrgUsernameFromEmail } from "@calcom/features/auth/signup/utils/getOrgUsernameFromEmail"; import { WEBAPP_URL } from "@calcom/lib/constants"; import { MembershipRole, SchedulingType } from "@calcom/prisma/enums"; import { test } from "../lib/fixtures"; import { bookTeamEvent, bookTimeSlot, doOnOrgDomain, expectPageToBeNotFound, selectFirstAvailableTimeSlotNextMonth, submitAndWaitForResponse, testName, } from "../lib/testUtils"; import { expectExistingUserToBeInvitedToOrganization } from "../team/expects"; import { gotoPathAndExpectRedirectToOrgDomain } from "./lib/gotoPathAndExpectRedirectToOrgDomain"; import { acceptTeamOrOrgInvite, inviteExistingUserToOrganization } from "./lib/inviteUser"; function getOrgOrigin(orgSlug: string | null) { if (!orgSlug) { throw new Error("orgSlug is required"); } let orgOrigin = WEBAPP_URL.replace("://app", `://${orgSlug}`); orgOrigin = orgOrigin.includes(orgSlug) ? orgOrigin : WEBAPP_URL.replace("://", `://${orgSlug}.`); return orgOrigin; } test.describe.configure({ mode: "parallel" }); test.describe("Bookings", () => { test.afterEach(async ({ orgs, users, page }) => { await users.deleteAll(); await orgs.deleteAll(); }); test.describe("Team Event", () => { test("Can create a booking for Collective EventType", async ({ page, users, orgs }) => { const org = await orgs.create({ name: "TestOrg", }); const teamMatesObj = [ { name: "teammate-1" }, { name: "teammate-2" }, { name: "teammate-3" }, { name: "teammate-4" }, ]; const owner = await users.create( { username: "pro-user", name: "pro-user", organizationId: org.id, roleInOrganization: MembershipRole.MEMBER, }, { hasTeam: true, teammates: teamMatesObj, schedulingType: SchedulingType.COLLECTIVE, } ); const { team } = await owner.getFirstTeamMembership(); const teamEvent = await owner.getFirstTeamEvent(team.id); await expectPageToBeNotFound({ page, url: `/team/${team.slug}/${teamEvent.slug}` }); await doOnOrgDomain( { orgSlug: org.slug, page, }, async () => { await bookTeamEvent({ page, team, event: teamEvent }); // All the teammates should be in the booking for (const teammate of teamMatesObj.concat([{ name: owner.name || "" }])) { await expect(page.getByText(teammate.name, { exact: true })).toBeVisible(); } } ); // TODO: Assert whether the user received an email }); test("Can create a booking for Collective EventType with only phone number", async ({ page, users, orgs, }) => { const org = await orgs.create({ name: "TestOrg", }); const teamMatesObj = [ { name: "teammate-1" }, { name: "teammate-2" }, { name: "teammate-3" }, { name: "teammate-4" }, ]; const owner = await users.create( { username: "pro-user", name: "pro-user", organizationId: org.id, roleInOrganization: MembershipRole.MEMBER, }, { hasTeam: true, teammates: teamMatesObj, schedulingType: SchedulingType.COLLECTIVE, } ); const { team } = await owner.getFirstTeamMembership(); const teamEvent = await owner.getFirstTeamEvent(team.id); await owner.apiLogin(); await markPhoneNumberAsRequiredAndEmailAsOptional(page, teamEvent.id); await expectPageToBeNotFound({ page, url: `/team/${team.slug}/${teamEvent.slug}` }); await doOnOrgDomain( { orgSlug: org.slug, page, }, async () => { await bookTeamEvent({ page, team, event: teamEvent, opts: { attendeePhoneNumber: "+918888888888" }, }); // All the teammates should be in the booking for (const teammate of teamMatesObj.concat([{ name: owner.name || "" }])) { await expect(page.getByText(teammate.name, { exact: true })).toBeVisible(); } } ); // TODO: Assert whether the user received an email }); test("Can create a booking for Round Robin EventType", async ({ page, users, orgs }) => { const org = await orgs.create({ name: "TestOrg", }); const teamMatesObj = [ { name: "teammate-1" }, { name: "teammate-2" }, { name: "teammate-3" }, { name: "teammate-4" }, ]; const owner = await users.create( { username: "pro-user", name: "pro-user", organizationId: org.id, roleInOrganization: MembershipRole.MEMBER, }, { hasTeam: true, teammates: teamMatesObj, schedulingType: SchedulingType.ROUND_ROBIN, } ); const { team } = await owner.getFirstTeamMembership(); const teamEvent = await owner.getFirstTeamEvent(team.id); const eventHostsObj = [...teamMatesObj, { name: "pro-user" }]; await expectPageToBeNotFound({ page, url: `/team/${team.slug}/${teamEvent.slug}` }); await doOnOrgDomain( { orgSlug: org.slug, page, }, async () => { await bookTeamEvent({ page, team, event: teamEvent, teamMatesObj: eventHostsObj }); // Since all the users have the same leastRecentlyBooked value // Anyone of the teammates could be the Host of the booking. const chosenUser = await page.getByTestId("booking-host-name").textContent(); expect(chosenUser).not.toBeNull(); // eslint-disable-next-line @typescript-eslint/no-non-null-assertion expect(teamMatesObj.concat([{ name: owner.name! }]).some(({ name }) => name === chosenUser)).toBe( true ); } ); // TODO: Assert whether the user received an email }); test("Can create a booking for Round Robin EventType with both phone number and email required", async ({ page, users, orgs, }) => { const org = await orgs.create({ name: "TestOrg", }); const teamMatesObj = [ { name: "teammate-1" }, { name: "teammate-2" }, { name: "teammate-3" }, { name: "teammate-4" }, ]; const owner = await users.create( { username: "pro-user", name: "pro-user", organizationId: org.id, roleInOrganization: MembershipRole.MEMBER, }, { hasTeam: true, teammates: teamMatesObj, schedulingType: SchedulingType.ROUND_ROBIN, } ); const { team } = await owner.getFirstTeamMembership(); const teamEvent = await owner.getFirstTeamEvent(team.id); const eventHostsObj = [...teamMatesObj, { name: "pro-user" }]; await owner.apiLogin(); await markPhoneNumberAsRequiredField(page, teamEvent.id); await expectPageToBeNotFound({ page, url: `/team/${team.slug}/${teamEvent.slug}` }); await doOnOrgDomain( { orgSlug: org.slug, page, }, async () => { await bookTeamEvent({ page, team, event: teamEvent, teamMatesObj: eventHostsObj, opts: { attendeePhoneNumber: "+918888888888" }, }); // Since all the users have the same leastRecentlyBooked value // Anyone of the teammates could be the Host of the booking. const chosenUser = await page.getByTestId("booking-host-name").textContent(); expect(chosenUser).not.toBeNull(); // eslint-disable-next-line @typescript-eslint/no-non-null-assertion expect(teamMatesObj.concat([{ name: owner.name! }]).some(({ name }) => name === chosenUser)).toBe( true ); } ); // TODO: Assert whether the user received an email }); test("Can access booking page with event slug and team page in lowercase/uppercase/mixedcase", async ({ page, orgs, users, }) => { const org = await orgs.create({ name: "TestOrg", }); const teamMatesObj = [ { name: "teammate-1" }, { name: "teammate-2" }, { name: "teammate-3" }, { name: "teammate-4" }, ]; const owner = await users.create( { username: "pro-user", name: "pro-user", organizationId: org.id, roleInOrganization: MembershipRole.MEMBER, }, { hasTeam: true, teammates: teamMatesObj, schedulingType: SchedulingType.COLLECTIVE, } ); const { team } = await owner.getFirstTeamMembership(); const { slug: teamEventSlug } = await owner.getFirstTeamEvent(team.id); const teamSlugUpperCase = team.slug?.toUpperCase(); const teamEventSlugUpperCase = teamEventSlug.toUpperCase(); // This is the most closest to the actual user flow as org1.cal.com maps to /org/orgSlug await page.goto(`/org/${org.slug}/${teamSlugUpperCase}/${teamEventSlugUpperCase}`); await page.waitForSelector("[data-testid=day]"); }); }); test.describe("User Event", () => { test("Can create a booking", async ({ page, users, orgs }) => { const org = await orgs.create({ name: "TestOrg", }); const user = await users.create({ username: "pro-user", name: "pro-user", organizationId: org.id, roleInOrganization: MembershipRole.MEMBER, }); const event = await user.getFirstEventAsOwner(); await expectPageToBeNotFound({ page, url: `/${user.username}/${event.slug}` }); await doOnOrgDomain( { orgSlug: org.slug, page, }, async () => { await bookUserEvent({ page, user, event }); } ); }); test.describe("User Event with same slug as another user's", () => { test("booking is created for first user when first user is booked", async ({ page, users, orgs }) => { const org = await orgs.create({ name: "TestOrg", }); const user1 = await users.create({ username: "user1", name: "User 1", organizationId: org.id, roleInOrganization: MembershipRole.MEMBER, }); const user2 = await users.create({ username: "user2", name: "User2", organizationId: org.id, roleInOrganization: MembershipRole.MEMBER, }); const user1Event = await user1.getFirstEventAsOwner(); await doOnOrgDomain( { orgSlug: org.slug, page, }, async () => { await bookUserEvent({ page, user: user1, event: user1Event }); } ); }); test("booking is created for second user when second user is booked", async ({ page, users, orgs }) => { const org = await orgs.create({ name: "TestOrg", }); const user1 = await users.create({ username: "user1", name: "User 1", organizationId: org.id, roleInOrganization: MembershipRole.MEMBER, }); const user2 = await users.create({ username: "user2", name: "User2", organizationId: org.id, roleInOrganization: MembershipRole.MEMBER, }); const user2Event = await user2.getFirstEventAsOwner(); await doOnOrgDomain( { orgSlug: org.slug, page, }, async () => { await bookUserEvent({ page, user: user2, event: user2Event }); } ); }); }); test("check SSR and OG", async ({ page, users, orgs }) => { const name = "Test User"; const org = await orgs.create({ name: "TestOrg", }); const user = await users.create({ name, organizationId: org.id, roleInOrganization: MembershipRole.MEMBER, }); const firstEventType = await user.getFirstEventAsOwner(); const calLink = `/${user.username}/${firstEventType.slug}`; await doOnOrgDomain( { orgSlug: org.slug, page, }, async () => { const [response] = await Promise.all([ // This promise resolves to the main resource response page.waitForResponse( (response) => response.url().includes(`${calLink}`) && response.status() === 200 ), // Trigger the page navigation page.goto(`${calLink}`), ]); const ssrResponse = await response.text(); const document = new JSDOM(ssrResponse).window.document; const orgOrigin = getOrgOrigin(org.slug); const titleText = document.querySelector("title")?.textContent; const ogImage = document.querySelector('meta[property="og:image"]')?.getAttribute("content"); const ogUrl = document.querySelector('meta[property="og:url"]')?.getAttribute("content"); const canonicalLink = document.querySelector('link[rel="canonical"]')?.getAttribute("href"); expect(titleText).toContain(name); expect(ogUrl).toEqual(`${orgOrigin}${calLink}`); expect(canonicalLink).toEqual(`${orgOrigin}${calLink}`); // Verify that there is correct URL that would generate the awesome OG image expect(ogImage).toContain( "/_next/image?w=1200&q=100&url=%2Fapi%2Fsocial%2Fog%2Fimage%3Ftype%3Dmeeting%26title%3D" ); // Verify Organizer Name in the URL expect(ogImage).toContain("meetingProfileName%3DTest%2BUser"); } ); }); }); test.describe("Scenario with same username in and outside organization", () => { test("Can create a booking for user with same username in and outside organization", async ({ page, users, orgs, }) => { const org = await orgs.create({ name: "TestOrg", }); const username = "john"; const userInsideOrganization = await users.create({ username, useExactUsername: true, email: `john-inside-${uuid()}@example.com`, name: "John Inside Organization", organizationId: org.id, roleInOrganization: MembershipRole.MEMBER, eventTypes: [ { title: "John Inside Org's Meeting", slug: "john-inside-org-meeting", length: 15, }, ], }); const userOutsideOrganization = await users.create({ username, name: "John Outside Organization", email: `john-outside-${uuid()}@example.com`, useExactUsername: true, eventTypes: [ { title: "John Outside Org's Meeting", slug: "john-outside-org-meeting", length: 15, }, ], }); const eventForUserInsideOrganization = await userInsideOrganization.getFirstEventAsOwner(); const eventForUserOutsideOrganization = await userOutsideOrganization.getFirstEventAsOwner(); // John Inside Org's meeting can't be accessed on userOutsideOrganization's namespace await expectPageToBeNotFound({ page, url: `/${userOutsideOrganization.username}/john-inside-org-meeting`, }); await bookUserEvent({ page, user: userOutsideOrganization, event: eventForUserOutsideOrganization }); await doOnOrgDomain( { orgSlug: org.slug, page, }, async () => { // John Outside Org's meeting can't be accessed on userInsideOrganization's namespaces await expectPageToBeNotFound({ page, url: `/${userInsideOrganization.username}/john-outside-org-meeting`, }); await bookUserEvent({ page, user: userInsideOrganization, event: eventForUserInsideOrganization }); } ); }); }); test.describe("Inviting an existing user and then", () => { test("create a booking on new link", async ({ page, browser, users, orgs, emails }) => { const org = await orgs.create({ name: "TestOrg", }); const owner = await users.create({ username: "owner", name: "owner", organizationId: org.id, roleInOrganization: MembershipRole.OWNER, }); const userOutsideOrganization = await users.create({ username: "john", name: "John Outside Organization", }); await owner.apiLogin(); const { invitedUserEmail } = await inviteExistingUserToOrganization({ page, organizationId: org.id, organizationSlug: org.slug, user: userOutsideOrganization, usersFixture: users, }); const inviteLink = await expectExistingUserToBeInvitedToOrganization(page, emails, invitedUserEmail); if (!inviteLink) { throw new Error("Invite link not found"); } const usernameInOrg = getOrgUsernameFromEmail( invitedUserEmail, org.organizationSettings?.orgAutoAcceptEmail ?? null ); const usernameOutsideOrg = userOutsideOrganization.username; // Before invite is accepted the booking page isn't available await expectPageToBeNotFound({ page, url: `/${usernameInOrg}` }); const [newContext, newPage] = await userOutsideOrganization.apiLoginOnNewBrowser(browser); await acceptTeamOrOrgInvite(newPage); await newContext.close(); await test.step("Book through new link", async () => { await doOnOrgDomain( { orgSlug: org.slug, page, }, async () => { await bookUserEvent({ page, user: { username: usernameInOrg, name: userOutsideOrganization.name, }, event: await userOutsideOrganization.getFirstEventAsOwner(), }); } ); }); await test.step("Booking through old link redirects to new link on org domain", async () => { const event = await userOutsideOrganization.getFirstEventAsOwner(); await gotoPathAndExpectRedirectToOrgDomain({ page, org, path: `/${usernameOutsideOrg}/${event.slug}`, expectedPath: `/${usernameInOrg}/${event.slug}`, }); // As the redirection correctly happens, the booking would work too which we have verified in previous step. But we can't test that with org domain as that domain doesn't exist. }); }); }); }); async function bookUserEvent({ page, user, event, }: { page: Page; user: { username: string | null; name: string | null; }; event: { slug: string; title: string }; }) { await page.goto(`/${user.username}/${event.slug}`); await selectFirstAvailableTimeSlotNextMonth(page); await bookTimeSlot(page); await expect(page.getByTestId("success-page")).toBeVisible(); // The title of the booking const BookingTitle = `${event.title} between ${user.name} and ${testName}`; await expect(page.getByTestId("booking-title")).toHaveText(BookingTitle); // The booker should be in the attendee list await expect(page.getByTestId(`attendee-name-${testName}`)).toHaveText(testName); } const markPhoneNumberAsRequiredAndEmailAsOptional = async (page: Page, eventId: number) => { // Make phone as required await markPhoneNumberAsRequiredField(page, eventId); // Make email as not required await page.locator('[data-testid="field-email"] [data-testid="edit-field-action"]').click(); const emailRequiredFiled = await page.locator('[data-testid="field-required"]'); await emailRequiredFiled.locator("> :nth-child(2)").click(); await page.getByTestId("field-add-save").click(); await submitAndWaitForResponse(page, "/api/trpc/eventTypes/update?batch=1", { action: () => page.locator("[data-testid=update-eventtype]").click(), }); }; const markPhoneNumberAsRequiredField = async (page: Page, eventId: number) => { await page.goto(`/event-types/${eventId}?tabName=advanced`); await page.locator('[data-testid="field-attendeePhoneNumber"] [data-testid="toggle-field"]').click(); await page.locator('[data-testid="field-attendeePhoneNumber"] [data-testid="edit-field-action"]').click(); const phoneRequiredFiled = await page.locator('[data-testid="field-required"]'); await phoneRequiredFiled.locator("> :nth-child(1)").click(); await page.getByTestId("field-add-save").click(); await submitAndWaitForResponse(page, "/api/trpc/eventTypes/update?batch=1", { action: () => page.locator("[data-testid=update-eventtype]").click(), }); };