From 1293588ac413f3ebd6c7fbe92d67ed85ac1c0518 Mon Sep 17 00:00:00 2001 From: Hariom Balhara Date: Thu, 8 Feb 2024 21:50:00 +0530 Subject: [PATCH] test: Bookings: Add more automated tests for organization (#13576) * Avoid selecting unused props * Add automated tests * Add existing user invite and booking --------- Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com> --- __checks__/organization.spec.ts | 40 +- apps/web/playwright/fixtures/emails.ts | 2 + apps/web/playwright/fixtures/orgs.ts | 9 +- apps/web/playwright/fixtures/users.ts | 4 + apps/web/playwright/lib/testUtils.ts | 8 +- .../playwright/organization/booking.e2e.ts | 488 ++++++++++++++++++ .../playwright/organization/lib/inviteUser.ts | 57 ++ .../organization-invitation.e2e.ts | 14 +- .../organization/team-management.e2e.ts | 80 +++ apps/web/playwright/team/expects.ts | 16 +- apps/web/playwright/teams.e2e.ts | 211 +------- .../features/eventtypes/lib/getPublicEvent.ts | 1 - 12 files changed, 702 insertions(+), 228 deletions(-) create mode 100644 apps/web/playwright/organization/booking.e2e.ts create mode 100644 apps/web/playwright/organization/lib/inviteUser.ts create mode 100644 apps/web/playwright/organization/team-management.e2e.ts diff --git a/__checks__/organization.spec.ts b/__checks__/organization.spec.ts index 4c6a047341..e510f53582 100644 --- a/__checks__/organization.spec.ts +++ b/__checks__/organization.spec.ts @@ -8,14 +8,14 @@ test.describe("Org", () => { const response = await page.goto("https://i.cal.com/embed"); expect(response?.status()).toBe(200); await page.screenshot({ path: "screenshot.jpg" }); - await expectPageToBeServerSideRendered(page); + await expectPageToBeRenderedWithEmbedSsr(page); }); test("Org User(Peer) Page should be embeddable", async ({ page }) => { const response = await page.goto("https://i.cal.com/peer/embed"); expect(response?.status()).toBe(200); await expect(page.locator("text=Peer Richelsen")).toBeVisible(); - await expectPageToBeServerSideRendered(page); + await expectPageToBeRenderedWithEmbedSsr(page); }); test("Org User Event(peer/meet) Page should be embeddable", async ({ page }) => { @@ -23,14 +23,14 @@ test.describe("Org", () => { expect(response?.status()).toBe(200); await expect(page.locator('[data-testid="decrementMonth"]')).toBeVisible(); await expect(page.locator('[data-testid="incrementMonth"]')).toBeVisible(); - await expectPageToBeServerSideRendered(page); + await expectPageToBeRenderedWithEmbedSsr(page); }); test("Org Team Profile(/sales) page should be embeddable", async ({ page }) => { const response = await page.goto("https://i.cal.com/sales/embed"); expect(response?.status()).toBe(200); await expect(page.locator("text=Cal.com Sales")).toBeVisible(); - await expectPageToBeServerSideRendered(page); + await expectPageToBeRenderedWithEmbedSsr(page); }); test("Org Team Event page(/sales/hippa) should be embeddable", async ({ page }) => { @@ -38,9 +38,10 @@ test.describe("Org", () => { expect(response?.status()).toBe(200); await expect(page.locator('[data-testid="decrementMonth"]')).toBeVisible(); await expect(page.locator('[data-testid="incrementMonth"]')).toBeVisible(); - await expectPageToBeServerSideRendered(page); + await expectPageToBeRenderedWithEmbedSsr(page); }); }); + test.describe("Dynamic Group Booking", () => { test("Dynamic Group booking link should load", async ({ page }) => { const users = [ @@ -63,12 +64,39 @@ test.describe("Org", () => { expect((await page.locator('[data-testid="event-meta"] [data-testid="avatar"]').all()).length).toBe(3); }); }); + + test("Organization Homepage - Has Engineering and Marketing Teams", async ({ page }) => { + const response = await page.goto("https://i.cal.com"); + expect(response?.status()).toBe(200); + await expect(page.locator("text=Cal.com")).toBeVisible(); + await expect(page.locator("text=Engineering")).toBeVisible(); + await expect(page.locator("text=Marketing")).toBeVisible(); + }); + + test.describe("Browse the Engineering Team", async () => { + test("By User Navigation", async ({ page }) => { + await page.goto("https://i.cal.com"); + await page.click('text="Engineering"'); + await expect(page.locator("text=Cal.com Engineering")).toBeVisible(); + }); + + test("By /team/engineering", async ({ page }) => { + await page.goto("https://i.cal.com/team/engineering"); + await expect(page.locator("text=Cal.com Engineering")).toBeVisible(); + }); + + test("By /engineering", async ({ page }) => { + await page.goto("https://i.cal.com/engineering"); + await expect(page.locator("text=Cal.com Engineering")).toBeVisible(); + }); + }); }); // This ensures that the route is actually mapped to a page that is using withEmbedSsr -async function expectPageToBeServerSideRendered(page: Page) { +async function expectPageToBeRenderedWithEmbedSsr(page: Page) { expect( await page.evaluate(() => { + //@ts-expect-error - __NEXT_DATA__ is a global variable defined by Next.js return window.__NEXT_DATA__.props.pageProps.isEmbed; }) ).toBe(true); diff --git a/apps/web/playwright/fixtures/emails.ts b/apps/web/playwright/fixtures/emails.ts index 8d02dacb2f..412f20af63 100644 --- a/apps/web/playwright/fixtures/emails.ts +++ b/apps/web/playwright/fixtures/emails.ts @@ -13,6 +13,7 @@ export const createEmailsFixture = () => { if (IS_MAILHOG_ENABLED) { const mailhogAPI = mailhog(); return { + messages: mailhogAPI.messages.bind(mailhogAPI), search: (query: string, kind?: string, start?: number, limit?: number) => { if (kind === "from" || kind === "to") { if (!hasUUID(query)) { @@ -27,6 +28,7 @@ export const createEmailsFixture = () => { }; } else { return { + messages: unimplemented, search: unimplemented, deleteMessage: unimplemented, }; diff --git a/apps/web/playwright/fixtures/orgs.ts b/apps/web/playwright/fixtures/orgs.ts index d3b7e41c77..7cc48c0276 100644 --- a/apps/web/playwright/fixtures/orgs.ts +++ b/apps/web/playwright/fixtures/orgs.ts @@ -2,6 +2,7 @@ import type { Page } from "@playwright/test"; import type { Team } from "@prisma/client"; import { prisma } from "@calcom/prisma"; +import { teamMetadataSchema } from "@calcom/prisma/zod-utils"; const getRandomSlug = () => `org-${Math.random().toString(36).substring(7)}`; @@ -15,8 +16,12 @@ export const createOrgsFixture = (page: Page) => { slug: opts.slug || getRandomSlug(), requestedSlug: opts.requestedSlug, }); - store.orgs.push(org); - return org; + const orgWithMetadata = { + ...org, + metadata: teamMetadataSchema.parse(org.metadata), + }; + store.orgs.push(orgWithMetadata); + return orgWithMetadata; }, get: () => store.orgs, deleteAll: async () => { diff --git a/apps/web/playwright/fixtures/users.ts b/apps/web/playwright/fixtures/users.ts index 6bd4609a9f..c55ab2f9e4 100644 --- a/apps/web/playwright/fixtures/users.ts +++ b/apps/web/playwright/fixtures/users.ts @@ -196,6 +196,9 @@ export const createUsersFixture = ( password: opts?.password ?? uname, }; }, + /** + * In case organizationId is passed, it simulates a scenario where a nonexistent user is added to an organization. + */ create: async ( opts?: | (CustomUserOpts & { @@ -726,6 +729,7 @@ const createUser = ( throw new Error("Missing role for user in organization"); } return { + organizationId, profiles: { create: { uid: ProfileRepository.generateProfileUid(), diff --git a/apps/web/playwright/lib/testUtils.ts b/apps/web/playwright/lib/testUtils.ts index 7289b8178d..4c2440aa48 100644 --- a/apps/web/playwright/lib/testUtils.ts +++ b/apps/web/playwright/lib/testUtils.ts @@ -225,7 +225,10 @@ export async function getEmailsReceivedByUser({ if (!emails) return null; const matchingEmails = await emails.search(userEmail, "to"); if (!matchingEmails?.total) { - console.log(`No emails received by ${userEmail}`); + console.log( + `No emails received by ${userEmail}. All emails sent to:`, + (await emails.messages())?.items.map((e) => e.to) + ); } return matchingEmails; } @@ -358,6 +361,9 @@ export async function doOnOrgDomain( "x-cal-force-slug": orgSlug, }); await callback({ page }); + await page.setExtraHTTPHeaders({ + "x-cal-force-slug": "", + }); } // When App directory is there, this is the 404 page text. We should work on fixing the 404 page as it changed due to app directory. diff --git a/apps/web/playwright/organization/booking.e2e.ts b/apps/web/playwright/organization/booking.e2e.ts new file mode 100644 index 0000000000..a56b77a1fa --- /dev/null +++ b/apps/web/playwright/organization/booking.e2e.ts @@ -0,0 +1,488 @@ +import type { Page } from "@playwright/test"; +import { expect } from "@playwright/test"; + +import { getOrgUsernameFromEmail } from "@calcom/features/auth/signup/utils/getOrgUsernameFromEmail"; +import { getOrgFullOrigin } from "@calcom/features/ee/organizations/lib/orgDomains"; +import { MembershipRole, SchedulingType } from "@calcom/prisma/enums"; + +import { test } from "../lib/fixtures"; +import { + bookTimeSlot, + doOnOrgDomain, + NotFoundPageTextAppDir, + selectFirstAvailableTimeSlotNextMonth, + testName, +} from "../lib/testUtils"; +import { expectExistingUserToBeInvitedToOrganization } from "../team/expects"; +import { acceptTeamOrOrgInvite, inviteExistingUserToOrganization } from "./lib/inviteUser"; + +test.describe("Bookings", () => { + test.afterEach(({ orgs, users }) => { + orgs.deleteAll(); + users.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 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); + + await expectPageToBeNotFound({ page, url: `/team/${team.slug}/${teamEvent.slug}` }); + + await doOnOrgDomain( + { + orgSlug: org.slug, + page, + }, + async () => { + await bookTeamEvent({ page, team, event: teamEvent }); + + // 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 page.goto(`/${user.username}/${event.slug}`); + + // Shouldn't be servable on the non-org domain + await expect(page.locator(`text=${NotFoundPageTextAppDir}`)).toBeVisible(); + + 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.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, + 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", + 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, + 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.metadata?.orgAutoAcceptEmail ?? null + ); + + const usernameOutsideOrg = userOutsideOrganization.username; + // Before invite is accepted the booking page isn't available + await expectPageToBeNotFound({ page, url: `/${usernameInOrg}` }); + await userOutsideOrganization.apiLogin(); + await acceptTeamOrOrgInvite(page); + 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 expectRedirectToOrgDomain({ + page, + org, + eventSlug: `/${usernameOutsideOrg}/${event.slug}`, + expectedEventSlug: `/${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); +} + +async function bookTeamEvent({ + page, + team, + event, +}: { + page: Page; + team: { + slug: string | null; + name: string | null; + }; + event: { slug: string; title: string }; +}) { + // Note that even though the default way to access a team booking in an organization is to not use /team in the URL, but it isn't testable with playwright as the rewrite is taken care of by Next.js config which can't handle on the fly org slug's handling + // So, we are using /team in the URL to access the team booking + // There are separate tests to verify that the next.config.js rewrites are working + // Also there are additional checkly tests that verify absolute e2e flow. They are in __checks__/organization.spec.ts + await page.goto(`/team/${team.slug}/${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 ${team.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); +} + +async function expectPageToBeNotFound({ page, url }: { page: Page; url: string }) { + await page.goto(`${url}`); + await expect(page.locator(`text=${NotFoundPageTextAppDir}`)).toBeVisible(); +} + +async function expectRedirectToOrgDomain({ + page, + org, + eventSlug, + expectedEventSlug, +}: { + page: Page; + org: { slug: string | null }; + eventSlug: string; + expectedEventSlug: string; +}) { + if (!org.slug) { + throw new Error("Org slug is not defined"); + } + page.goto(eventSlug).catch((e) => { + console.log("Expected navigation error to happen"); + }); + + const orgSlug = org.slug; + + const orgRedirectUrl = await new Promise(async (resolve) => { + page.on("request", (request) => { + if (request.isNavigationRequest()) { + const requestedUrl = request.url(); + console.log("Requested navigation to", requestedUrl); + // Resolve on redirection to org domain + if (requestedUrl.includes(orgSlug)) { + resolve(requestedUrl); + } + } + }); + }); + + expect(orgRedirectUrl).toContain(`${getOrgFullOrigin(org.slug)}${expectedEventSlug}`); +} diff --git a/apps/web/playwright/organization/lib/inviteUser.ts b/apps/web/playwright/organization/lib/inviteUser.ts new file mode 100644 index 0000000000..214862ed7f --- /dev/null +++ b/apps/web/playwright/organization/lib/inviteUser.ts @@ -0,0 +1,57 @@ +import type { Page } from "@playwright/test"; +import type { createUsersFixture } from "playwright/fixtures/users"; + +export const inviteUserToOrganization = async ({ + page, + organizationId, + email, + usersFixture, +}: { + page: Page; + organizationId: number; + email: string; + usersFixture: ReturnType; +}) => { + await page.goto("/settings/organizations/members"); + await page.waitForLoadState("networkidle"); + const invitedUserEmail = usersFixture.trackEmail({ + username: email.split("@")[0], + domain: email.split("@")[1], + }); + await inviteAnEmail(page, invitedUserEmail); + return { invitedUserEmail }; +}; + +export const inviteExistingUserToOrganization = async ({ + page, + organizationId, + user, + usersFixture, +}: { + page: Page; + organizationId: number; + user: { + email: string; + }; + usersFixture: ReturnType; +}) => { + await page.goto("/settings/organizations/members"); + await page.waitForLoadState("networkidle"); + + await inviteAnEmail(page, user.email); + await page.waitForSelector('[data-testid="toast-success"]'); + return { invitedUserEmail: user.email }; +}; + +export async function acceptTeamOrOrgInvite(page: Page) { + await page.goto("/settings/teams"); + await page.click('[data-testid^="accept-invitation"]'); + await page.waitForLoadState("networkidle"); +} + +async function inviteAnEmail(page: Page, invitedUserEmail: string) { + await page.locator('button:text("Add")').click(); + await page.locator('input[name="inviteUser"]').fill(invitedUserEmail); + await page.locator('button:text("Send invite")').click(); + await page.waitForLoadState("networkidle"); +} diff --git a/apps/web/playwright/organization/organization-invitation.e2e.ts b/apps/web/playwright/organization/organization-invitation.e2e.ts index b61d044f4b..1b3eac6949 100644 --- a/apps/web/playwright/organization/organization-invitation.e2e.ts +++ b/apps/web/playwright/organization/organization-invitation.e2e.ts @@ -377,7 +377,7 @@ async function signupFromInviteLink({ return { email }; } -async function signupFromEmailInviteLink({ +export async function signupFromEmailInviteLink({ browser, inviteLink, expectedUsername, @@ -385,8 +385,8 @@ async function signupFromEmailInviteLink({ }: { browser: Browser; inviteLink: string; - expectedUsername: string; - expectedEmail: string; + expectedUsername?: string; + expectedEmail?: string; }) { // Follow invite link in new window const context = await browser.newContext(); @@ -396,10 +396,14 @@ async function signupFromEmailInviteLink({ await signupPage.locator(`[data-testid="signup-usernamefield"]`).waitFor({ state: "visible" }); await expect(signupPage.locator(`[data-testid="signup-usernamefield"]`)).toBeDisabled(); // await for value. initial value is "" - await expect(signupPage.locator(`[data-testid="signup-usernamefield"]`)).toHaveValue(expectedUsername); + if (expectedUsername) { + await expect(signupPage.locator(`[data-testid="signup-usernamefield"]`)).toHaveValue(expectedUsername); + } await expect(signupPage.locator(`[data-testid="signup-emailfield"]`)).toBeDisabled(); - await expect(signupPage.locator(`[data-testid="signup-emailfield"]`)).toHaveValue(expectedEmail); + if (expectedEmail) { + await expect(signupPage.locator(`[data-testid="signup-emailfield"]`)).toHaveValue(expectedEmail); + } await signupPage.waitForLoadState("networkidle"); // Check required fields diff --git a/apps/web/playwright/organization/team-management.e2e.ts b/apps/web/playwright/organization/team-management.e2e.ts new file mode 100644 index 0000000000..90ffae5029 --- /dev/null +++ b/apps/web/playwright/organization/team-management.e2e.ts @@ -0,0 +1,80 @@ +import { expect } from "@playwright/test"; + +import { IS_TEAM_BILLING_ENABLED } from "@calcom/lib/constants"; +import { prisma } from "@calcom/prisma"; +import { MembershipRole } from "@calcom/prisma/enums"; + +import { test } from "../lib/fixtures"; +import { fillStripeTestCheckout } from "../lib/testUtils"; + +test.describe("Teams", () => { + test.afterEach(({ orgs, users }) => { + orgs.deleteAll(); + users.deleteAll(); + }); + + test("Can create teams via Wizard", async ({ page, users, orgs }) => { + const org = await orgs.create({ + name: "TestOrg", + }); + const user = await users.create({ + organizationId: org.id, + roleInOrganization: MembershipRole.ADMIN, + }); + const inviteeEmail = `${user.username}+invitee@example.com`; + await user.apiLogin(); + await page.goto("/teams"); + + await test.step("Can create team", async () => { + // Click text=Create Team + await page.locator("text=Create a new Team").click(); + await page.waitForURL((url) => url.pathname === "/settings/teams/new"); + // Fill input[name="name"] + await page.locator('input[name="name"]').fill(`${user.username}'s Team`); + // Click text=Continue + await page.click("[type=submit]"); + // TODO: Figure out a way to make this more reliable + // eslint-disable-next-line playwright/no-conditional-in-test + if (IS_TEAM_BILLING_ENABLED) await fillStripeTestCheckout(page); + await expect(page).toHaveURL(/\/settings\/teams\/(\d+)\/onboard-members.*$/i); + await page.waitForSelector('[data-testid="pending-member-list"]'); + expect(await page.getByTestId("pending-member-item").count()).toBe(1); + }); + + await test.step("Can add members", async () => { + await page.getByTestId("new-member-button").click(); + await page.locator('[placeholder="email\\@example\\.com"]').fill(inviteeEmail); + await page.getByTestId("invite-new-member-button").click(); + await expect(page.locator(`li:has-text("${inviteeEmail}")`)).toBeVisible(); + + // locator.count() does not await for the expected number of elements + // https://github.com/microsoft/playwright/issues/14278 + // using toHaveCount() is more reliable + await expect(page.getByTestId("pending-member-item")).toHaveCount(2); + }); + + await test.step("Can remove members", async () => { + await expect(page.getByTestId("pending-member-item")).toHaveCount(2); + const lastRemoveMemberButton = page.getByTestId("remove-member-button").last(); + await lastRemoveMemberButton.click(); + await page.waitForLoadState("networkidle"); + await expect(page.getByTestId("pending-member-item")).toHaveCount(1); + + // Cleanup here since this user is created without our fixtures. + await prisma.user.delete({ where: { email: inviteeEmail } }); + }); + + await test.step("Can finish team creation", async () => { + await page.getByTestId("publish-button").click(); + await expect(page).toHaveURL(/\/settings\/teams\/(\d+)\/profile$/i); + }); + + await test.step("Can disband team", async () => { + await page.waitForURL(/\/settings\/teams\/(\d+)\/profile$/i); + await page.getByTestId("disband-team-button").click(); + await page.getByTestId("dialog-confirmation").click(); + await page.waitForURL("/teams"); + expect(await page.locator(`text=${user.username}'s Team`).count()).toEqual(0); + }); + }); +}); diff --git a/apps/web/playwright/team/expects.ts b/apps/web/playwright/team/expects.ts index 4579a1e613..214803bd99 100644 --- a/apps/web/playwright/team/expects.ts +++ b/apps/web/playwright/team/expects.ts @@ -10,7 +10,7 @@ export async function expectInvitationEmailToBeReceived( page: Page, emails: ReturnType, userEmail: string, - subject: string, + subject?: string | null, returnLink?: string ) { if (!emails) return null; @@ -21,10 +21,20 @@ export async function expectInvitationEmailToBeReceived( expect(receivedEmails?.total).toBe(1); const [firstReceivedEmail] = (receivedEmails as Messages).items; - - expect(firstReceivedEmail.subject).toBe(subject); + if (subject) { + expect(firstReceivedEmail.subject).toBe(subject); + } if (!returnLink) return; const dom = new JSDOM(firstReceivedEmail.html); const anchor = dom.window.document.querySelector(`a[href*="${returnLink}"]`); return anchor?.getAttribute("href"); } + +export async function expectExistingUserToBeInvitedToOrganization( + page: Page, + emails: ReturnType, + userEmail: string, + subject?: string | null +) { + return expectInvitationEmailToBeReceived(page, emails, userEmail, subject, "settings/team"); +} diff --git a/apps/web/playwright/teams.e2e.ts b/apps/web/playwright/teams.e2e.ts index a044d9ee96..0a636e7aac 100644 --- a/apps/web/playwright/teams.e2e.ts +++ b/apps/web/playwright/teams.e2e.ts @@ -2,15 +2,13 @@ import { expect } from "@playwright/test"; import { IS_TEAM_BILLING_ENABLED } from "@calcom/lib/constants"; import { prisma } from "@calcom/prisma"; -import { MembershipRole, SchedulingType } from "@calcom/prisma/enums"; +import { SchedulingType } from "@calcom/prisma/enums"; import { test } from "./lib/fixtures"; import { testBothFutureAndLegacyRoutes } from "./lib/future-legacy-routes"; import { bookTimeSlot, - doOnOrgDomain, fillStripeTestCheckout, - NotFoundPageTextAppDir, selectFirstAvailableTimeSlotNextMonth, testName, todo, @@ -282,210 +280,3 @@ testBothFutureAndLegacyRoutes.describe("Teams - NonOrg", (routeVariant) => { todo("Reschedule a Collective EventType booking"); todo("Reschedule a Round Robin EventType booking"); }); - -test.describe("Teams - Org", () => { - test.afterEach(({ orgs, users }) => { - orgs.deleteAll(); - users.deleteAll(); - }); - - test("Can create teams via Wizard", async ({ page, users, orgs }) => { - const org = await orgs.create({ - name: "TestOrg", - }); - const user = await users.create({ - organizationId: org.id, - roleInOrganization: MembershipRole.ADMIN, - }); - const inviteeEmail = `${user.username}+invitee@example.com`; - await user.apiLogin(); - await page.goto("/teams"); - - await test.step("Can create team", async () => { - // Click text=Create Team - await page.locator("text=Create a new Team").click(); - await page.waitForURL((url) => url.pathname === "/settings/teams/new"); - // Fill input[name="name"] - await page.locator('input[name="name"]').fill(`${user.username}'s Team`); - // Click text=Continue - await page.click("[type=submit]"); - // TODO: Figure out a way to make this more reliable - // eslint-disable-next-line playwright/no-conditional-in-test - if (IS_TEAM_BILLING_ENABLED) await fillStripeTestCheckout(page); - await expect(page).toHaveURL(/\/settings\/teams\/(\d+)\/onboard-members.*$/i); - await page.waitForSelector('[data-testid="pending-member-list"]'); - expect(await page.getByTestId("pending-member-item").count()).toBe(1); - }); - - await test.step("Can add members", async () => { - await page.getByTestId("new-member-button").click(); - await page.locator('[placeholder="email\\@example\\.com"]').fill(inviteeEmail); - await page.getByTestId("invite-new-member-button").click(); - await expect(page.locator(`li:has-text("${inviteeEmail}")`)).toBeVisible(); - - // locator.count() does not await for the expected number of elements - // https://github.com/microsoft/playwright/issues/14278 - // using toHaveCount() is more reliable - await expect(page.getByTestId("pending-member-item")).toHaveCount(2); - }); - - await test.step("Can remove members", async () => { - await expect(page.getByTestId("pending-member-item")).toHaveCount(2); - const lastRemoveMemberButton = page.getByTestId("remove-member-button").last(); - await lastRemoveMemberButton.click(); - await page.waitForLoadState("networkidle"); - await expect(page.getByTestId("pending-member-item")).toHaveCount(1); - - // Cleanup here since this user is created without our fixtures. - await prisma.user.delete({ where: { email: inviteeEmail } }); - }); - - await test.step("Can finish team creation", async () => { - await page.getByTestId("publish-button").click(); - await expect(page).toHaveURL(/\/settings\/teams\/(\d+)\/profile$/i); - }); - - await test.step("Can disband team", async () => { - await page.waitForURL(/\/settings\/teams\/(\d+)\/profile$/i); - await page.getByTestId("disband-team-button").click(); - await page.getByTestId("dialog-confirmation").click(); - await page.waitForURL("/teams"); - expect(await page.locator(`text=${user.username}'s Team`).count()).toEqual(0); - }); - }); - - 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 { title: teamEventTitle, slug: teamEventSlug } = await owner.getFirstTeamEvent(team.id); - - await page.goto(`/team/${team.slug}/${teamEventSlug}`); - - await expect(page.locator(`text=${NotFoundPageTextAppDir}`)).toBeVisible(); - await doOnOrgDomain( - { - orgSlug: org.slug, - page, - }, - async () => { - await page.goto(`/team/${team.slug}/${teamEventSlug}`); - await selectFirstAvailableTimeSlotNextMonth(page); - await bookTimeSlot(page); - await expect(page.getByTestId("success-page")).toBeVisible(); - - // The title of the booking - const BookingTitle = `${teamEventTitle} between ${team.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); - - // 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 }) => { - 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" }, - { - hasTeam: true, - teammates: teamMatesObj, - schedulingType: SchedulingType.ROUND_ROBIN, - } - ); - - const { team } = await owner.getFirstTeamMembership(); - const { title: teamEventTitle, slug: teamEventSlug } = await owner.getFirstTeamEvent(team.id); - - await page.goto(`/team/${team.slug}/${teamEventSlug}`); - await selectFirstAvailableTimeSlotNextMonth(page); - await bookTimeSlot(page); - await expect(page.locator("[data-testid=success-page]")).toBeVisible(); - - // The person who booked the meeting should be in the attendee list - await expect(page.getByTestId(`attendee-name-${testName}`)).toHaveText(testName); - - // The title of the booking - const BookingTitle = `${teamEventTitle} between ${team.name} and ${testName}`; - await expect(page.getByTestId("booking-title")).toHaveText(BookingTitle); - - // 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(); - 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]"); - }); -}); diff --git a/packages/features/eventtypes/lib/getPublicEvent.ts b/packages/features/eventtypes/lib/getPublicEvent.ts index 783a5fa63c..3d3b835749 100644 --- a/packages/features/eventtypes/lib/getPublicEvent.ts +++ b/packages/features/eventtypes/lib/getPublicEvent.ts @@ -123,7 +123,6 @@ export const getPublicEvent = async ( usernameList, orgSlug: org, }); - console.log("getPublicEvent - dynamic", usersInOrgContext); const users = usersInOrgContext; const defaultEvent = getDefaultEvent(eventSlug);