first commit
This commit is contained in:
@@ -0,0 +1,100 @@
|
||||
import { expect } from "@playwright/test";
|
||||
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
import { test } from "../../lib/fixtures";
|
||||
|
||||
test.afterAll(({ users }) => {
|
||||
users.deleteAll();
|
||||
});
|
||||
|
||||
test.describe("user1NotMemberOfOrg1 is part of team1MemberOfOrg1", () => {
|
||||
test("Team1 profile should show correct domain if logged in as User1", async ({ page, users, orgs }) => {
|
||||
const org = await orgs.create({
|
||||
name: "TestOrg",
|
||||
});
|
||||
|
||||
const user1NotMemberOfOrg1 = await users.create(undefined, {
|
||||
hasTeam: true,
|
||||
});
|
||||
|
||||
const { team: team1MemberOfOrg1 } = await user1NotMemberOfOrg1.getFirstTeamMembership();
|
||||
await moveTeamToOrg({ team: team1MemberOfOrg1, org });
|
||||
|
||||
await user1NotMemberOfOrg1.apiLogin();
|
||||
|
||||
await page.goto(`/settings/teams/${team1MemberOfOrg1.id}/profile`);
|
||||
const domain = await page.locator(".testid-leading-text-team-url").textContent();
|
||||
expect(domain).toContain(org.slug);
|
||||
});
|
||||
|
||||
test("EventTypes listing should show correct link for user events and team1MemberOfOrg1's events", async ({
|
||||
page,
|
||||
users,
|
||||
orgs,
|
||||
}) => {
|
||||
const org = await orgs.create({
|
||||
name: "TestOrg",
|
||||
});
|
||||
|
||||
const user1NotMemberOfOrg1 = await users.create(undefined, {
|
||||
hasTeam: true,
|
||||
});
|
||||
|
||||
const { team: team1MemberOfOrg1 } = await user1NotMemberOfOrg1.getFirstTeamMembership();
|
||||
await moveTeamToOrg({ team: team1MemberOfOrg1, org });
|
||||
|
||||
await user1NotMemberOfOrg1.apiLogin();
|
||||
await page.goto("/event-types");
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
const userEventLinksLocators = await page
|
||||
.locator(`[data-testid=slug-${user1NotMemberOfOrg1.username}] [data-testid="preview-link-button"]`)
|
||||
.all();
|
||||
|
||||
expect(userEventLinksLocators.length).toBeGreaterThan(0);
|
||||
|
||||
for (const userEventLinkLocator of userEventLinksLocators) {
|
||||
const href = await userEventLinkLocator.getAttribute("href");
|
||||
expect(href).toContain(WEBAPP_URL);
|
||||
}
|
||||
|
||||
const teamEventLinksLocators = await page
|
||||
.locator(`[data-testid=slug-${team1MemberOfOrg1.slug}] [data-testid="preview-link-button"]`)
|
||||
.all();
|
||||
|
||||
expect(teamEventLinksLocators.length).toBeGreaterThan(0);
|
||||
|
||||
for (const teamEventLinksLocator of teamEventLinksLocators) {
|
||||
const href = await teamEventLinksLocator.getAttribute("href");
|
||||
expect(href).not.toContain(WEBAPP_URL);
|
||||
expect(href).toContain(org.slug);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
async function moveTeamToOrg({
|
||||
team,
|
||||
org,
|
||||
}: {
|
||||
team: {
|
||||
id: number;
|
||||
};
|
||||
org: {
|
||||
id: number;
|
||||
};
|
||||
}) {
|
||||
await prisma.team.update({
|
||||
where: {
|
||||
id: team.id,
|
||||
},
|
||||
data: {
|
||||
parent: {
|
||||
connect: {
|
||||
id: org.id,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
528
calcom/apps/web/playwright/organization/booking.e2e.ts
Normal file
528
calcom/apps/web/playwright/organization/booking.e2e.ts
Normal file
@@ -0,0 +1,528 @@
|
||||
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 {
|
||||
bookTimeSlot,
|
||||
doOnOrgDomain,
|
||||
selectFirstAvailableTimeSlotNextMonth,
|
||||
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("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 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, teamMatesObj });
|
||||
|
||||
// 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%2520User%26");
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
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,
|
||||
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}` });
|
||||
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 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);
|
||||
}
|
||||
|
||||
async function bookTeamEvent({
|
||||
page,
|
||||
team,
|
||||
event,
|
||||
teamMatesObj,
|
||||
}: {
|
||||
page: Page;
|
||||
team: {
|
||||
slug: string | null;
|
||||
name: string | null;
|
||||
};
|
||||
event: { slug: string; title: string; schedulingType: SchedulingType | null };
|
||||
teamMatesObj?: { name: 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
|
||||
if (event.schedulingType === SchedulingType.ROUND_ROBIN) {
|
||||
const bookingTitle = await page.getByTestId("booking-title").textContent();
|
||||
|
||||
expect(
|
||||
teamMatesObj?.some((teamMate) => {
|
||||
const BookingTitle = `${event.title} between ${teamMate.name} and ${testName}`;
|
||||
return BookingTitle === bookingTitle;
|
||||
})
|
||||
).toBe(true);
|
||||
} else {
|
||||
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.getByTestId(`404-page`)).toBeVisible();
|
||||
}
|
||||
29
calcom/apps/web/playwright/organization/expects.ts
Normal file
29
calcom/apps/web/playwright/organization/expects.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
import { expect } from "@playwright/test";
|
||||
import { JSDOM } from "jsdom";
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import type { Messages } from "mailhog";
|
||||
import type { createEmailsFixture } from "playwright/fixtures/emails";
|
||||
|
||||
import { getEmailsReceivedByUser } from "../lib/testUtils";
|
||||
|
||||
export async function expectInvitationEmailToBeReceived(
|
||||
page: Page,
|
||||
emails: ReturnType<typeof createEmailsFixture>,
|
||||
userEmail: string,
|
||||
subject: string,
|
||||
returnLink?: string
|
||||
) {
|
||||
if (!emails) return null;
|
||||
// We need to wait for the email to go through, otherwise it will fail
|
||||
// eslint-disable-next-line playwright/no-wait-for-timeout
|
||||
await page.waitForTimeout(2000);
|
||||
const receivedEmails = await getEmailsReceivedByUser({ emails, userEmail });
|
||||
expect(receivedEmails?.total).toBe(1);
|
||||
const [firstReceivedEmail] = (receivedEmails as Messages).items;
|
||||
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");
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
import { expect } from "@playwright/test";
|
||||
|
||||
import { getOrgFullOrigin } from "@calcom/features/ee/organizations/lib/orgDomains";
|
||||
|
||||
export async function gotoPathAndExpectRedirectToOrgDomain({
|
||||
page,
|
||||
org,
|
||||
path,
|
||||
expectedPath,
|
||||
}: {
|
||||
page: Page;
|
||||
org: { slug: string | null };
|
||||
path: string;
|
||||
expectedPath: string;
|
||||
}) {
|
||||
if (!org.slug) {
|
||||
throw new Error("Org slug is not defined");
|
||||
}
|
||||
page.goto(path).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)}${expectedPath}`);
|
||||
}
|
||||
57
calcom/apps/web/playwright/organization/lib/inviteUser.ts
Normal file
57
calcom/apps/web/playwright/organization/lib/inviteUser.ts
Normal file
@@ -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<typeof createUsersFixture>;
|
||||
}) => {
|
||||
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<typeof createUsersFixture>;
|
||||
}) => {
|
||||
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");
|
||||
}
|
||||
@@ -0,0 +1,588 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
import { expect } from "@playwright/test";
|
||||
import { JSDOM } from "jsdom";
|
||||
import type { Messages } from "mailhog";
|
||||
import path from "path";
|
||||
import { uuid } from "short-uuid";
|
||||
|
||||
import { IS_TEAM_BILLING_ENABLED } from "@calcom/lib/constants";
|
||||
|
||||
import type { createEmailsFixture } from "../fixtures/emails";
|
||||
import { test } from "../lib/fixtures";
|
||||
import { fillStripeTestCheckout } from "../lib/testUtils";
|
||||
import { getEmailsReceivedByUser } from "../lib/testUtils";
|
||||
import { gotoPathAndExpectRedirectToOrgDomain } from "./lib/gotoPathAndExpectRedirectToOrgDomain";
|
||||
|
||||
async function expectEmailWithSubject(
|
||||
page: Page,
|
||||
emails: ReturnType<typeof createEmailsFixture>,
|
||||
userEmail: string,
|
||||
subject: string
|
||||
) {
|
||||
if (!emails) return null;
|
||||
|
||||
// eslint-disable-next-line playwright/no-wait-for-timeout
|
||||
await page.waitForTimeout(2000);
|
||||
const receivedEmails = await getEmailsReceivedByUser({ emails, userEmail });
|
||||
|
||||
const allEmails = (receivedEmails as Messages).items;
|
||||
const email = allEmails.find((email) => email.subject === subject);
|
||||
if (!email) {
|
||||
throw new Error(`Email with subject ${subject} not found`);
|
||||
}
|
||||
const dom = new JSDOM(email.html);
|
||||
return dom;
|
||||
}
|
||||
|
||||
export async function expectOrganizationCreationEmailToBeSent({
|
||||
page,
|
||||
emails,
|
||||
userEmail,
|
||||
orgSlug,
|
||||
}: {
|
||||
page: Page;
|
||||
emails: ReturnType<typeof createEmailsFixture>;
|
||||
userEmail: string;
|
||||
orgSlug: string;
|
||||
}) {
|
||||
const dom = await expectEmailWithSubject(page, emails, userEmail, "Your organization has been created");
|
||||
const document = dom?.window?.document;
|
||||
expect(document?.querySelector(`[href*=${orgSlug}]`)).toBeTruthy();
|
||||
return dom;
|
||||
}
|
||||
|
||||
async function expectOrganizationCreationEmailToBeSentWithLinks({
|
||||
page,
|
||||
emails,
|
||||
userEmail,
|
||||
oldUsername,
|
||||
newUsername,
|
||||
orgSlug,
|
||||
}: {
|
||||
page: Page;
|
||||
emails: ReturnType<typeof createEmailsFixture>;
|
||||
userEmail: string;
|
||||
oldUsername: string;
|
||||
newUsername: string;
|
||||
orgSlug: string;
|
||||
}) {
|
||||
const dom = await expectOrganizationCreationEmailToBeSent({
|
||||
page,
|
||||
emails,
|
||||
userEmail,
|
||||
orgSlug,
|
||||
});
|
||||
const document = dom?.window.document;
|
||||
const links = document?.querySelectorAll(`[data-testid="organization-link-info"] [href]`);
|
||||
if (!links) {
|
||||
throw new Error(`data-testid="organization-link-info doesn't have links`);
|
||||
}
|
||||
expect((links[0] as unknown as HTMLAnchorElement).href).toContain(oldUsername);
|
||||
expect((links[1] as unknown as HTMLAnchorElement).href).toContain(newUsername);
|
||||
}
|
||||
|
||||
export async function expectEmailVerificationEmailToBeSent(
|
||||
page: Page,
|
||||
emails: ReturnType<typeof createEmailsFixture>,
|
||||
userEmail: string
|
||||
) {
|
||||
const subject = "Cal.com: Verify your account";
|
||||
return expectEmailWithSubject(page, emails, userEmail, subject);
|
||||
}
|
||||
|
||||
test.afterAll(({ users, orgs }) => {
|
||||
users.deleteAll();
|
||||
orgs.deleteAll();
|
||||
});
|
||||
|
||||
function capitalize(text: string) {
|
||||
if (!text) {
|
||||
return text;
|
||||
}
|
||||
return text.charAt(0).toUpperCase() + text.slice(1);
|
||||
}
|
||||
|
||||
test.describe("Organization", () => {
|
||||
test("Admin should be able to create an org where an existing user is made an owner", async ({
|
||||
page,
|
||||
users,
|
||||
emails,
|
||||
}) => {
|
||||
const appLevelAdmin = await users.create({
|
||||
role: "ADMIN",
|
||||
});
|
||||
await appLevelAdmin.apiLogin();
|
||||
|
||||
const orgOwnerUsernamePrefix = "owner";
|
||||
|
||||
const orgOwnerEmail = users.trackEmail({
|
||||
username: orgOwnerUsernamePrefix,
|
||||
domain: `example.com`,
|
||||
});
|
||||
|
||||
const orgOwnerUser = await users.create({
|
||||
username: orgOwnerUsernamePrefix,
|
||||
email: orgOwnerEmail,
|
||||
role: "ADMIN",
|
||||
});
|
||||
|
||||
const orgOwnerUsernameOutsideOrg = orgOwnerUser.username;
|
||||
const orgOwnerUsernameInOrg = orgOwnerEmail.split("@")[0];
|
||||
const orgName = capitalize(`${orgOwnerUser.username}`);
|
||||
const orgSlug = `myOrg-${uuid()}`.toLowerCase();
|
||||
await page.goto("/settings/organizations/new");
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
await test.step("Basic info", async () => {
|
||||
// Check required fields
|
||||
await page.locator("button[type=submit]").click();
|
||||
await expect(page.locator(".text-red-700")).toHaveCount(3);
|
||||
|
||||
// Happy path
|
||||
await fillAndSubmitFirstStepAsAdmin(page, orgOwnerEmail, orgName, orgSlug);
|
||||
});
|
||||
|
||||
await expectOrganizationCreationEmailToBeSentWithLinks({
|
||||
page,
|
||||
emails,
|
||||
userEmail: orgOwnerEmail,
|
||||
oldUsername: orgOwnerUsernameOutsideOrg || "",
|
||||
newUsername: orgOwnerUsernameInOrg,
|
||||
orgSlug,
|
||||
});
|
||||
|
||||
await test.step("About the organization", async () => {
|
||||
// Choosing an avatar
|
||||
await page.locator('button:text("Upload")').click();
|
||||
const fileChooserPromise = page.waitForEvent("filechooser");
|
||||
await page.getByText("Choose a file...").click();
|
||||
const fileChooser = await fileChooserPromise;
|
||||
await fileChooser.setFiles(path.join(__dirname, "../../public/apple-touch-icon.png"));
|
||||
await page.locator('button:text("Save")').click();
|
||||
|
||||
// About text
|
||||
await page.locator('textarea[name="about"]').fill("This is a testing org");
|
||||
await page.locator("button[type=submit]").click();
|
||||
|
||||
// Waiting to be in next step URL
|
||||
await page.waitForURL("/settings/organizations/*/onboard-members");
|
||||
});
|
||||
|
||||
await test.step("On-board administrators", async () => {
|
||||
await page.waitForSelector('[data-testid="pending-member-list"]');
|
||||
expect(await page.getByTestId("pending-member-item").count()).toBe(1);
|
||||
|
||||
const adminEmail = users.trackEmail({ username: "rick", domain: `example.com` });
|
||||
|
||||
//can add members
|
||||
await page.getByTestId("new-member-button").click();
|
||||
await page.locator('[placeholder="email\\@example\\.com"]').fill(adminEmail);
|
||||
await page.getByTestId("invite-new-member-button").click();
|
||||
await expect(page.locator(`li:has-text("${adminEmail}")`)).toBeVisible();
|
||||
// TODO: Check if invited admin received the invitation email
|
||||
// await expectInvitationEmailToBeReceived(
|
||||
// page,
|
||||
// emails,
|
||||
// adminEmail,
|
||||
// `${orgName}'s admin invited you to join the organization ${orgName} on Cal.com`
|
||||
// );
|
||||
await expect(page.getByTestId("pending-member-item")).toHaveCount(2);
|
||||
|
||||
// can remove members
|
||||
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);
|
||||
await page.getByTestId("publish-button").click();
|
||||
// Waiting to be in next step URL
|
||||
await page.waitForURL("/settings/organizations/*/add-teams");
|
||||
});
|
||||
|
||||
await test.step("Create teams", async () => {
|
||||
// Filling one team
|
||||
await page.locator('input[name="teams.0.name"]').fill("Marketing");
|
||||
|
||||
// Adding another team
|
||||
await page.locator('button:text("Add a team")').click();
|
||||
await page.locator('input[name="teams.1.name"]').fill("Sales");
|
||||
|
||||
// Finishing the creation wizard
|
||||
await page.getByTestId("continue_or_checkout").click();
|
||||
await page.waitForURL("/event-types");
|
||||
});
|
||||
|
||||
await test.step("Login as org owner and pay", async () => {
|
||||
// eslint-disable-next-line playwright/no-skipped-test
|
||||
test.skip(!IS_TEAM_BILLING_ENABLED, "Skipping paying for org as stripe is disabled");
|
||||
|
||||
await orgOwnerUser.apiLogin();
|
||||
await page.goto("/event-types");
|
||||
const upgradeButton = await page.getByTestId("upgrade_org_banner_button");
|
||||
|
||||
await expect(upgradeButton).toBeVisible();
|
||||
await upgradeButton.click();
|
||||
// Check that stripe checkout is present
|
||||
const expectedUrl = "https://checkout.stripe.com";
|
||||
|
||||
await page.waitForURL((url) => url.href.startsWith(expectedUrl));
|
||||
const url = page.url();
|
||||
|
||||
// Check that the URL matches the expected URL
|
||||
expect(url).toContain(expectedUrl);
|
||||
|
||||
await fillStripeTestCheckout(page);
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
const upgradeButtonHidden = await page.getByTestId("upgrade_org_banner_button");
|
||||
|
||||
await expect(upgradeButtonHidden).toBeHidden();
|
||||
});
|
||||
|
||||
// Verify that the owner's old username redirect is properly set
|
||||
await gotoPathAndExpectRedirectToOrgDomain({
|
||||
page,
|
||||
org: {
|
||||
slug: orgSlug,
|
||||
},
|
||||
path: `/${orgOwnerUsernameOutsideOrg}`,
|
||||
expectedPath: `/${orgOwnerUsernameInOrg}`,
|
||||
});
|
||||
});
|
||||
|
||||
test("Admin should be able to create an org where the owner doesn't exist yet", async ({
|
||||
page,
|
||||
users,
|
||||
emails,
|
||||
}) => {
|
||||
const appLevelAdmin = await users.create({
|
||||
role: "ADMIN",
|
||||
});
|
||||
await appLevelAdmin.apiLogin();
|
||||
const orgOwnerUsername = `owner`;
|
||||
const orgName = capitalize(`${orgOwnerUsername}`);
|
||||
const orgSlug = `myOrg-${uuid()}`.toLowerCase();
|
||||
const orgOwnerEmail = users.trackEmail({
|
||||
username: orgOwnerUsername,
|
||||
domain: `example.com`,
|
||||
});
|
||||
|
||||
await page.goto("/settings/organizations/new");
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
await test.step("Basic info", async () => {
|
||||
// Check required fields
|
||||
await page.locator("button[type=submit]").click();
|
||||
await expect(page.locator(".text-red-700")).toHaveCount(3);
|
||||
|
||||
// Happy path
|
||||
await fillAndSubmitFirstStepAsAdmin(page, orgOwnerEmail, orgName, orgSlug);
|
||||
});
|
||||
|
||||
const dom = await expectOrganizationCreationEmailToBeSent({
|
||||
page,
|
||||
emails,
|
||||
userEmail: orgOwnerEmail,
|
||||
orgSlug,
|
||||
});
|
||||
expect(dom?.window.document.querySelector(`[href*=${orgSlug}]`)).toBeTruthy();
|
||||
await expectEmailVerificationEmailToBeSent(page, emails, orgOwnerEmail);
|
||||
// Rest of the steps remain same as org creation with existing user as owner. So skipping them
|
||||
});
|
||||
|
||||
test("User can create and upgrade a org", async ({ page, users, emails }) => {
|
||||
// eslint-disable-next-line playwright/no-skipped-test
|
||||
test.skip(process.env.NEXT_PUBLIC_ORG_SELF_SERVE_ENABLED !== "1", "Org self serve is not enabled");
|
||||
const stringUUID = uuid();
|
||||
|
||||
const orgOwnerUsername = `owner-${stringUUID}`;
|
||||
|
||||
const targetOrgEmail = users.trackEmail({
|
||||
username: orgOwnerUsername,
|
||||
domain: `example.com`,
|
||||
});
|
||||
const orgOwnerUser = await users.create({
|
||||
username: orgOwnerUsername,
|
||||
email: targetOrgEmail,
|
||||
});
|
||||
|
||||
await orgOwnerUser.apiLogin();
|
||||
const orgName = capitalize(`${orgOwnerUsername}`);
|
||||
await page.goto("/settings/organizations/new");
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
await test.step("Basic info", async () => {
|
||||
// These values are infered due to an existing user being signed
|
||||
expect(await page.locator("input[name=name]").inputValue()).toBe("Example");
|
||||
expect(await page.locator("input[name=slug]").inputValue()).toBe("example");
|
||||
|
||||
await page.locator("input[name=name]").fill(orgName);
|
||||
await page.locator("input[name=slug]").fill(orgOwnerUsername);
|
||||
|
||||
await page.locator("button[type=submit]").click();
|
||||
await page.waitForLoadState("networkidle");
|
||||
});
|
||||
|
||||
await test.step("About the organization", async () => {
|
||||
// Choosing an avatar
|
||||
await page.locator('button:text("Upload")').click();
|
||||
const fileChooserPromise = page.waitForEvent("filechooser");
|
||||
await page.getByText("Choose a file...").click();
|
||||
const fileChooser = await fileChooserPromise;
|
||||
await fileChooser.setFiles(path.join(__dirname, "../../public/apple-touch-icon.png"));
|
||||
await page.locator('button:text("Save")').click();
|
||||
|
||||
// About text
|
||||
await page.locator('textarea[name="about"]').fill("This is a testing org");
|
||||
await page.locator("button[type=submit]").click();
|
||||
|
||||
// Waiting to be in next step URL
|
||||
await page.waitForURL("/settings/organizations/*/onboard-members");
|
||||
});
|
||||
|
||||
await test.step("On-board administrators", async () => {
|
||||
await page.waitForSelector('[data-testid="pending-member-list"]');
|
||||
expect(await page.getByTestId("pending-member-item").count()).toBe(1);
|
||||
|
||||
const adminEmail = users.trackEmail({ username: "rick", domain: `example.com` });
|
||||
|
||||
//can add members
|
||||
await page.getByTestId("new-member-button").click();
|
||||
await page.locator('[placeholder="email\\@example\\.com"]').fill(adminEmail);
|
||||
await page.getByTestId("invite-new-member-button").click();
|
||||
await expect(page.locator(`li:has-text("${adminEmail}")`)).toBeVisible();
|
||||
// TODO: Check if invited admin received the invitation email
|
||||
// await expectInvitationEmailToBeReceived(
|
||||
// page,
|
||||
// emails,
|
||||
// adminEmail,
|
||||
// `${orgName}'s admin invited you to join the organization ${orgName} on Cal.com`
|
||||
// );
|
||||
await expect(page.getByTestId("pending-member-item")).toHaveCount(2);
|
||||
|
||||
// can remove members
|
||||
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);
|
||||
await page.getByTestId("publish-button").click();
|
||||
// Waiting to be in next step URL
|
||||
await page.waitForURL("/settings/organizations/*/add-teams");
|
||||
});
|
||||
|
||||
await test.step("Create teams", async () => {
|
||||
// Filling one team
|
||||
await page.locator('input[name="teams.0.name"]').fill("Marketing");
|
||||
|
||||
// Adding another team
|
||||
await page.locator('button:text("Add a team")').click();
|
||||
await page.locator('input[name="teams.1.name"]').fill("Sales");
|
||||
|
||||
// Finishing the creation wizard
|
||||
await page.getByTestId("continue_or_checkout").click();
|
||||
});
|
||||
|
||||
await test.step("Login as org owner and pay", async () => {
|
||||
// eslint-disable-next-line playwright/no-skipped-test
|
||||
test.skip(!IS_TEAM_BILLING_ENABLED, "Skipping paying for org as stripe is disabled");
|
||||
await orgOwnerUser.apiLogin();
|
||||
await page.goto("/event-types");
|
||||
const upgradeButton = await page.getByTestId("upgrade_org_banner_button");
|
||||
|
||||
await expect(upgradeButton).toBeVisible();
|
||||
await upgradeButton.click();
|
||||
// Check that stripe checkout is present
|
||||
const expectedUrl = "https://checkout.stripe.com";
|
||||
|
||||
await page.waitForURL((url) => url.href.startsWith(expectedUrl));
|
||||
const url = page.url();
|
||||
|
||||
// Check that the URL matches the expected URL
|
||||
expect(url).toContain(expectedUrl);
|
||||
|
||||
await fillStripeTestCheckout(page);
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
const upgradeButtonHidden = await page.getByTestId("upgrade_org_banner_button");
|
||||
|
||||
await expect(upgradeButtonHidden).toBeHidden();
|
||||
});
|
||||
});
|
||||
|
||||
test("User gets prompted with >=3 teams to upgrade & can transfer existing teams to org", async ({
|
||||
page,
|
||||
users,
|
||||
}) => {
|
||||
// eslint-disable-next-line playwright/no-skipped-test
|
||||
test.skip(process.env.NEXT_PUBLIC_ORG_SELF_SERVE_ENABLED !== "1", "Org self serve is not enabled");
|
||||
const numberOfTeams = 3;
|
||||
const stringUUID = uuid();
|
||||
|
||||
const orgOwnerUsername = `owner-${stringUUID}`;
|
||||
|
||||
const targetOrgEmail = users.trackEmail({
|
||||
username: orgOwnerUsername,
|
||||
domain: `example.com`,
|
||||
});
|
||||
const orgOwnerUser = await users.create(
|
||||
{
|
||||
username: orgOwnerUsername,
|
||||
email: targetOrgEmail,
|
||||
},
|
||||
{ hasTeam: true, numberOfTeams }
|
||||
);
|
||||
|
||||
await orgOwnerUser.apiLogin();
|
||||
|
||||
await page.goto("/teams");
|
||||
|
||||
await test.step("Has org self serve banner", async () => {
|
||||
// These values are infered due to an existing user being signed
|
||||
const selfServeButtonLocator = await page.getByTestId("setup_your_org_action_button");
|
||||
await expect(selfServeButtonLocator).toBeVisible();
|
||||
|
||||
await selfServeButtonLocator.click();
|
||||
await page.waitForURL("/settings/organizations/new");
|
||||
});
|
||||
|
||||
await test.step("Basic info", async () => {
|
||||
// These values are infered due to an existing user being signed
|
||||
const slugLocator = await page.locator("input[name=slug]");
|
||||
expect(await page.locator("input[name=name]").inputValue()).toBe("Example");
|
||||
expect(await slugLocator.inputValue()).toBe("example");
|
||||
|
||||
await slugLocator.fill(`example-${stringUUID}`);
|
||||
|
||||
await page.locator("button[type=submit]").click();
|
||||
await page.waitForLoadState("networkidle");
|
||||
});
|
||||
|
||||
await test.step("About the organization", async () => {
|
||||
// Choosing an avatar
|
||||
await page.locator('button:text("Upload")').click();
|
||||
const fileChooserPromise = page.waitForEvent("filechooser");
|
||||
await page.getByText("Choose a file...").click();
|
||||
const fileChooser = await fileChooserPromise;
|
||||
await fileChooser.setFiles(path.join(__dirname, "../../public/apple-touch-icon.png"));
|
||||
await page.locator('button:text("Save")').click();
|
||||
|
||||
// About text
|
||||
await page.locator('textarea[name="about"]').fill("This is a testing org");
|
||||
await page.locator("button[type=submit]").click();
|
||||
|
||||
// Waiting to be in next step URL
|
||||
await page.waitForURL("/settings/organizations/*/onboard-members");
|
||||
});
|
||||
|
||||
await test.step("On-board administrators", async () => {
|
||||
await page.waitForSelector('[data-testid="pending-member-list"]');
|
||||
expect(await page.getByTestId("pending-member-item").count()).toBe(1);
|
||||
|
||||
const adminEmail = users.trackEmail({ username: "rick", domain: `example.com` });
|
||||
|
||||
//can add members
|
||||
await page.getByTestId("new-member-button").click();
|
||||
await page.locator('[placeholder="email\\@example\\.com"]').fill(adminEmail);
|
||||
await page.getByTestId("invite-new-member-button").click();
|
||||
await expect(page.locator(`li:has-text("${adminEmail}")`)).toBeVisible();
|
||||
// TODO: Check if invited admin received the invitation email
|
||||
// await expectInvitationEmailToBeReceived(
|
||||
// page,
|
||||
// emails,
|
||||
// adminEmail,
|
||||
// `${orgName}'s admin invited you to join the organization ${orgName} on Cal.com`
|
||||
// );
|
||||
await expect(page.getByTestId("pending-member-item")).toHaveCount(2);
|
||||
|
||||
// can remove members
|
||||
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);
|
||||
await page.getByTestId("publish-button").click();
|
||||
// Waiting to be in next step URL
|
||||
await page.waitForURL("/settings/organizations/*/add-teams");
|
||||
});
|
||||
|
||||
await test.step("Move existing teams to org", async () => {
|
||||
// No easy way to get all team checkboxes so we fill all checkboxes on the page in
|
||||
const foundCheckboxes = page.locator('input[type="checkbox"]');
|
||||
const count = await foundCheckboxes.count();
|
||||
for (let i = 0; i < count; i++) {
|
||||
const checkbox = foundCheckboxes.nth(i);
|
||||
await checkbox.click();
|
||||
}
|
||||
});
|
||||
|
||||
await test.step("Create teams", async () => {
|
||||
// Filling one team
|
||||
await page.locator('input[name="teams.0.name"]').fill("Marketing");
|
||||
|
||||
// Adding another team
|
||||
await page.locator('button:text("Add a team")').click();
|
||||
await page.locator('input[name="teams.1.name"]').fill("Sales");
|
||||
|
||||
// Finishing the creation wizard
|
||||
await page.getByTestId("continue_or_checkout").click();
|
||||
});
|
||||
|
||||
await test.step("Login as org owner and pay", async () => {
|
||||
// eslint-disable-next-line playwright/no-skipped-test
|
||||
test.skip(!IS_TEAM_BILLING_ENABLED, "Skipping paying for org as stripe is disabled");
|
||||
await orgOwnerUser.apiLogin();
|
||||
await page.goto("/event-types");
|
||||
const upgradeButton = await page.getByTestId("upgrade_org_banner_button");
|
||||
|
||||
await expect(upgradeButton).toBeVisible();
|
||||
await upgradeButton.click();
|
||||
// Check that stripe checkout is present
|
||||
const expectedUrl = "https://checkout.stripe.com";
|
||||
|
||||
await page.waitForURL((url) => url.href.startsWith(expectedUrl));
|
||||
const url = page.url();
|
||||
|
||||
// Check that the URL matches the expected URL
|
||||
expect(url).toContain(expectedUrl);
|
||||
|
||||
await fillStripeTestCheckout(page);
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
const upgradeButtonHidden = await page.getByTestId("upgrade_org_banner_button");
|
||||
|
||||
await expect(upgradeButtonHidden).toBeHidden();
|
||||
});
|
||||
|
||||
await test.step("Ensure correctnumberOfTeams are migrated", async () => {
|
||||
// eslint-disable-next-line playwright/no-skipped-test
|
||||
await page.goto("/teams");
|
||||
await page.waitForLoadState("networkidle");
|
||||
const teamListItems = await page.getByTestId("team-list-item-link").all();
|
||||
|
||||
// Number of teams migrated + the two created in the create teams step
|
||||
expect(teamListItems.length).toBe(numberOfTeams + 2);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
async function fillAndSubmitFirstStepAsAdmin(
|
||||
page: Page,
|
||||
targetOrgEmail: string,
|
||||
orgName: string,
|
||||
orgSlug: string
|
||||
) {
|
||||
await page.locator("input[name=orgOwnerEmail]").fill(targetOrgEmail);
|
||||
// Since we are admin fill in this infomation instead of deriving it
|
||||
await page.locator("input[name=name]").fill(orgName);
|
||||
await page.locator("input[name=slug]").fill(orgSlug);
|
||||
|
||||
// Fill in seat infomation
|
||||
await page.locator("input[name=seats]").fill("30");
|
||||
await page.locator("input[name=pricePerSeat]").fill("30");
|
||||
|
||||
await Promise.all([
|
||||
page.waitForResponse("**/api/trpc/organizations/create**"),
|
||||
page.locator("button[type=submit]").click(),
|
||||
]);
|
||||
}
|
||||
@@ -0,0 +1,559 @@
|
||||
import type { Browser, Page } from "@playwright/test";
|
||||
import { expect } from "@playwright/test";
|
||||
|
||||
import prisma from "@calcom/prisma";
|
||||
import { MembershipRole } from "@calcom/prisma/client";
|
||||
|
||||
import { moveUserToOrg } from "@lib/orgMigration";
|
||||
|
||||
import { test } from "../lib/fixtures";
|
||||
import { getInviteLink } from "../lib/testUtils";
|
||||
import { expectInvitationEmailToBeReceived } from "./expects";
|
||||
|
||||
test.describe.configure({ mode: "parallel" });
|
||||
|
||||
test.afterEach(async ({ users, orgs }) => {
|
||||
await users.deleteAll();
|
||||
await orgs.deleteAll();
|
||||
});
|
||||
|
||||
test.describe("Organization", () => {
|
||||
test.describe("Email not matching orgAutoAcceptEmail", () => {
|
||||
test("nonexisting user invited to an organization", async ({ browser, page, users, emails }) => {
|
||||
const orgOwner = await users.create(undefined, { hasTeam: true, isOrg: true });
|
||||
const { team: org } = await orgOwner.getOrgMembership();
|
||||
await orgOwner.apiLogin();
|
||||
await page.goto("/settings/organizations/members");
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
await test.step("By email", async () => {
|
||||
const invitedUserEmail = users.trackEmail({ username: "rick", domain: "domain.com" });
|
||||
// '-domain' because the email doesn't match orgAutoAcceptEmail
|
||||
const usernameDerivedFromEmail = `${invitedUserEmail.split("@")[0]}-domain`;
|
||||
|
||||
await inviteAnEmail(page, invitedUserEmail);
|
||||
const inviteLink = await expectInvitationEmailToBeReceived(
|
||||
page,
|
||||
emails,
|
||||
invitedUserEmail,
|
||||
`${org.name}'s admin invited you to join the organization ${org.name} on Cal.com`,
|
||||
"signup?token"
|
||||
);
|
||||
|
||||
await expectUserToBeAMemberOfOrganization({
|
||||
page,
|
||||
username: usernameDerivedFromEmail,
|
||||
role: "member",
|
||||
isMemberShipAccepted: false,
|
||||
email: invitedUserEmail,
|
||||
});
|
||||
|
||||
assertInviteLink(inviteLink);
|
||||
await signupFromEmailInviteLink({
|
||||
browser,
|
||||
inviteLink,
|
||||
expectedEmail: invitedUserEmail,
|
||||
expectedUsername: usernameDerivedFromEmail,
|
||||
});
|
||||
|
||||
const dbUser = await prisma.user.findUnique({ where: { email: invitedUserEmail } });
|
||||
expect(dbUser?.username).toBe(usernameDerivedFromEmail);
|
||||
|
||||
await expectUserToBeAMemberOfOrganization({
|
||||
page,
|
||||
username: usernameDerivedFromEmail,
|
||||
role: "member",
|
||||
isMemberShipAccepted: true,
|
||||
email: invitedUserEmail,
|
||||
});
|
||||
});
|
||||
|
||||
await test.step("By invite link", async () => {
|
||||
const inviteLink = await copyInviteLink(page);
|
||||
const email = users.trackEmail({ username: "rick", domain: "domain.com" });
|
||||
// '-domain' because the email doesn't match orgAutoAcceptEmail
|
||||
const usernameDerivedFromEmail = `${email.split("@")[0]}-domain`;
|
||||
await signupFromInviteLink({ browser, inviteLink, email });
|
||||
const dbUser = await prisma.user.findUnique({ where: { email } });
|
||||
expect(dbUser?.username).toBe(usernameDerivedFromEmail);
|
||||
await expectUserToBeAMemberOfOrganization({
|
||||
page,
|
||||
username: usernameDerivedFromEmail,
|
||||
role: "member",
|
||||
isMemberShipAccepted: true,
|
||||
email,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// This test is already covered by booking.e2e.ts where existing user is invited and his booking links are tested.
|
||||
// We can re-test here when we want to test some more scenarios.
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
test("existing user invited to an organization", () => {});
|
||||
|
||||
test("nonexisting user invited to a Team inside organization", async ({
|
||||
browser,
|
||||
page,
|
||||
users,
|
||||
emails,
|
||||
}) => {
|
||||
const orgOwner = await users.create(undefined, { hasTeam: true, isOrg: true, hasSubteam: true });
|
||||
await orgOwner.apiLogin();
|
||||
const { team } = await orgOwner.getFirstTeamMembership();
|
||||
const { team: org } = await orgOwner.getOrgMembership();
|
||||
|
||||
await test.step("By email", async () => {
|
||||
await page.goto(`/settings/teams/${team.id}/members`);
|
||||
await page.waitForLoadState("networkidle");
|
||||
const invitedUserEmail = users.trackEmail({ username: "rick", domain: "domain.com" });
|
||||
// '-domain' because the email doesn't match orgAutoAcceptEmail
|
||||
const usernameDerivedFromEmail = `${invitedUserEmail.split("@")[0]}-domain`;
|
||||
await inviteAnEmail(page, invitedUserEmail);
|
||||
await expectUserToBeAMemberOfTeam({
|
||||
page,
|
||||
teamId: team.id,
|
||||
username: usernameDerivedFromEmail,
|
||||
role: "member",
|
||||
isMemberShipAccepted: false,
|
||||
email: invitedUserEmail,
|
||||
});
|
||||
|
||||
await expectUserToBeAMemberOfOrganization({
|
||||
page,
|
||||
username: usernameDerivedFromEmail,
|
||||
role: "member",
|
||||
isMemberShipAccepted: false,
|
||||
email: invitedUserEmail,
|
||||
});
|
||||
|
||||
await page.waitForLoadState("networkidle");
|
||||
const inviteLink = await expectInvitationEmailToBeReceived(
|
||||
page,
|
||||
emails,
|
||||
invitedUserEmail,
|
||||
`${team.name}'s admin invited you to join the team ${team.name} of organization ${org.name} on Cal.com`,
|
||||
"signup?token"
|
||||
);
|
||||
|
||||
assertInviteLink(inviteLink);
|
||||
|
||||
await signupFromEmailInviteLink({
|
||||
browser,
|
||||
inviteLink,
|
||||
expectedEmail: invitedUserEmail,
|
||||
expectedUsername: usernameDerivedFromEmail,
|
||||
});
|
||||
|
||||
const dbUser = await prisma.user.findUnique({ where: { email: invitedUserEmail } });
|
||||
expect(dbUser?.username).toBe(usernameDerivedFromEmail);
|
||||
|
||||
await expectUserToBeAMemberOfTeam({
|
||||
page,
|
||||
teamId: team.id,
|
||||
username: usernameDerivedFromEmail,
|
||||
role: "member",
|
||||
isMemberShipAccepted: true,
|
||||
email: invitedUserEmail,
|
||||
});
|
||||
|
||||
await expectUserToBeAMemberOfOrganization({
|
||||
page,
|
||||
username: usernameDerivedFromEmail,
|
||||
role: "member",
|
||||
isMemberShipAccepted: true,
|
||||
email: invitedUserEmail,
|
||||
});
|
||||
});
|
||||
|
||||
await test.step("By invite link", async () => {
|
||||
await page.goto(`/settings/teams/${team.id}/members`);
|
||||
const inviteLink = await copyInviteLink(page);
|
||||
const email = users.trackEmail({ username: "rick", domain: "domain.com" });
|
||||
// '-domain' because the email doesn't match orgAutoAcceptEmail
|
||||
const usernameDerivedFromEmail = `${email.split("@")[0]}-domain`;
|
||||
await signupFromInviteLink({ browser, inviteLink, email });
|
||||
|
||||
const dbUser = await prisma.user.findUnique({ where: { email } });
|
||||
expect(dbUser?.username).toBe(usernameDerivedFromEmail);
|
||||
await expectUserToBeAMemberOfTeam({
|
||||
teamId: team.id,
|
||||
page,
|
||||
username: usernameDerivedFromEmail,
|
||||
role: "member",
|
||||
isMemberShipAccepted: true,
|
||||
email: email,
|
||||
});
|
||||
|
||||
await expectUserToBeAMemberOfOrganization({
|
||||
page,
|
||||
username: usernameDerivedFromEmail,
|
||||
role: "member",
|
||||
isMemberShipAccepted: true,
|
||||
email: email,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Email matching orgAutoAcceptEmail and a Verified Organization with DNS Setup Done", () => {
|
||||
test("nonexisting user is invited to Org", async ({ browser, page, users, emails }) => {
|
||||
const orgOwner = await users.create(undefined, {
|
||||
hasTeam: true,
|
||||
isOrg: true,
|
||||
isOrgVerified: true,
|
||||
isDnsSetup: true,
|
||||
});
|
||||
const { team: org } = await orgOwner.getOrgMembership();
|
||||
await orgOwner.apiLogin();
|
||||
await page.goto("/settings/organizations/members");
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
await test.step("By email", async () => {
|
||||
const invitedUserEmail = users.trackEmail({ username: "rick", domain: "example.com" });
|
||||
const usernameDerivedFromEmail = invitedUserEmail.split("@")[0];
|
||||
await inviteAnEmail(page, invitedUserEmail);
|
||||
const inviteLink = await expectInvitationEmailToBeReceived(
|
||||
page,
|
||||
emails,
|
||||
invitedUserEmail,
|
||||
`${org.name}'s admin invited you to join the organization ${org.name} on Cal.com`,
|
||||
"signup?token"
|
||||
);
|
||||
|
||||
await expectUserToBeAMemberOfOrganization({
|
||||
page,
|
||||
username: usernameDerivedFromEmail,
|
||||
role: "member",
|
||||
isMemberShipAccepted: true,
|
||||
email: invitedUserEmail,
|
||||
});
|
||||
|
||||
assertInviteLink(inviteLink);
|
||||
await signupFromEmailInviteLink({
|
||||
browser,
|
||||
inviteLink,
|
||||
expectedEmail: invitedUserEmail,
|
||||
expectedUsername: usernameDerivedFromEmail,
|
||||
});
|
||||
|
||||
const dbUser = await prisma.user.findUnique({ where: { email: invitedUserEmail } });
|
||||
expect(dbUser?.username).toBe(usernameDerivedFromEmail);
|
||||
|
||||
await expectUserToBeAMemberOfOrganization({
|
||||
page,
|
||||
username: usernameDerivedFromEmail,
|
||||
role: "member",
|
||||
isMemberShipAccepted: true,
|
||||
email: invitedUserEmail,
|
||||
});
|
||||
});
|
||||
|
||||
await test.step("By invite link", async () => {
|
||||
const inviteLink = await copyInviteLink(page);
|
||||
const email = users.trackEmail({ username: "rick", domain: "example.com" });
|
||||
const usernameDerivedFromEmail = email.split("@")[0];
|
||||
await signupFromInviteLink({ browser, inviteLink, email });
|
||||
|
||||
const dbUser = await prisma.user.findUnique({ where: { email } });
|
||||
expect(dbUser?.username).toBe(usernameDerivedFromEmail);
|
||||
await expectUserToBeAMemberOfOrganization({
|
||||
page,
|
||||
username: usernameDerivedFromEmail,
|
||||
role: "member",
|
||||
isMemberShipAccepted: true,
|
||||
email,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Such a user has user.username changed directly in addition to having the new username in the profile.username
|
||||
test("existing user migrated to an organization", async ({ users, page, emails }) => {
|
||||
const orgOwner = await users.create(undefined, {
|
||||
hasTeam: true,
|
||||
isOrg: true,
|
||||
isOrgVerified: true,
|
||||
isDnsSetup: true,
|
||||
});
|
||||
const { team: org } = await orgOwner.getOrgMembership();
|
||||
await orgOwner.apiLogin();
|
||||
const { existingUser } = await test.step("Invite an existing user to an organization", async () => {
|
||||
const existingUser = await users.create({
|
||||
username: "john",
|
||||
emailDomain: org.organizationSettings?.orgAutoAcceptEmail ?? "",
|
||||
name: "John Outside Organization",
|
||||
});
|
||||
|
||||
await moveUserToOrg({
|
||||
user: existingUser,
|
||||
targetOrg: {
|
||||
username: `${existingUser.username}-org`,
|
||||
id: org.id,
|
||||
membership: {
|
||||
role: MembershipRole.MEMBER,
|
||||
accepted: true,
|
||||
},
|
||||
},
|
||||
shouldMoveTeams: false,
|
||||
});
|
||||
return { existingUser };
|
||||
});
|
||||
|
||||
await test.step("Signing up with the previous username of the migrated user - shouldn't be allowed", async () => {
|
||||
await page.goto("/signup");
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
await page.locator('input[name="username"]').fill(existingUser.username!);
|
||||
await page
|
||||
.locator('input[name="email"]')
|
||||
.fill(`${existingUser.username}-differnet-email@example.com`);
|
||||
await page.locator('input[name="password"]').fill("Password99!");
|
||||
await page.waitForLoadState("networkidle");
|
||||
await expect(page.locator('button[type="submit"]')).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
test("nonexisting user is invited to a team inside organization", async ({
|
||||
browser,
|
||||
page,
|
||||
users,
|
||||
emails,
|
||||
}) => {
|
||||
const orgOwner = await users.create(undefined, {
|
||||
hasTeam: true,
|
||||
isOrg: true,
|
||||
hasSubteam: true,
|
||||
isOrgVerified: true,
|
||||
isDnsSetup: true,
|
||||
});
|
||||
const { team: org } = await orgOwner.getOrgMembership();
|
||||
const { team } = await orgOwner.getFirstTeamMembership();
|
||||
|
||||
await orgOwner.apiLogin();
|
||||
|
||||
await test.step("By email", async () => {
|
||||
await page.goto(`/settings/teams/${team.id}/members`);
|
||||
await page.waitForLoadState("networkidle");
|
||||
const invitedUserEmail = users.trackEmail({ username: "rick", domain: "example.com" });
|
||||
const usernameDerivedFromEmail = invitedUserEmail.split("@")[0];
|
||||
await inviteAnEmail(page, invitedUserEmail);
|
||||
await expectUserToBeAMemberOfTeam({
|
||||
page,
|
||||
teamId: team.id,
|
||||
username: usernameDerivedFromEmail,
|
||||
role: "member",
|
||||
isMemberShipAccepted: true,
|
||||
email: invitedUserEmail,
|
||||
});
|
||||
|
||||
await expectUserToBeAMemberOfOrganization({
|
||||
page,
|
||||
username: usernameDerivedFromEmail,
|
||||
role: "member",
|
||||
isMemberShipAccepted: true,
|
||||
email: invitedUserEmail,
|
||||
});
|
||||
const inviteLink = await expectInvitationEmailToBeReceived(
|
||||
page,
|
||||
emails,
|
||||
invitedUserEmail,
|
||||
`${team.name}'s admin invited you to join the team ${team.name} of organization ${org.name} on Cal.com`,
|
||||
"signup?token"
|
||||
);
|
||||
|
||||
assertInviteLink(inviteLink);
|
||||
|
||||
await signupFromEmailInviteLink({
|
||||
browser,
|
||||
inviteLink,
|
||||
expectedEmail: invitedUserEmail,
|
||||
expectedUsername: usernameDerivedFromEmail,
|
||||
});
|
||||
|
||||
const dbUser = await prisma.user.findUnique({ where: { email: invitedUserEmail } });
|
||||
expect(dbUser?.username).toBe(usernameDerivedFromEmail);
|
||||
|
||||
await expectUserToBeAMemberOfTeam({
|
||||
page,
|
||||
teamId: team.id,
|
||||
username: usernameDerivedFromEmail,
|
||||
role: "member",
|
||||
isMemberShipAccepted: true,
|
||||
email: invitedUserEmail,
|
||||
});
|
||||
|
||||
await expectUserToBeAMemberOfOrganization({
|
||||
page,
|
||||
username: usernameDerivedFromEmail,
|
||||
role: "member",
|
||||
isMemberShipAccepted: true,
|
||||
email: invitedUserEmail,
|
||||
});
|
||||
});
|
||||
|
||||
await test.step("By invite link", async () => {
|
||||
await page.goto(`/settings/teams/${team.id}/members`);
|
||||
|
||||
const inviteLink = await copyInviteLink(page);
|
||||
const email = users.trackEmail({ username: "rick", domain: "example.com" });
|
||||
// '-domain' because the email doesn't match orgAutoAcceptEmail
|
||||
const usernameDerivedFromEmail = `${email.split("@")[0]}`;
|
||||
|
||||
await signupFromInviteLink({ browser, inviteLink, email });
|
||||
|
||||
const dbUser = await prisma.user.findUnique({ where: { email } });
|
||||
expect(dbUser?.username).toBe(usernameDerivedFromEmail);
|
||||
await expectUserToBeAMemberOfTeam({
|
||||
teamId: team.id,
|
||||
page,
|
||||
username: usernameDerivedFromEmail,
|
||||
role: "member",
|
||||
isMemberShipAccepted: true,
|
||||
email: email,
|
||||
});
|
||||
await expectUserToBeAMemberOfOrganization({
|
||||
page,
|
||||
username: usernameDerivedFromEmail,
|
||||
role: "member",
|
||||
isMemberShipAccepted: true,
|
||||
email: email,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
async function signupFromInviteLink({
|
||||
browser,
|
||||
inviteLink,
|
||||
email,
|
||||
}: {
|
||||
browser: Browser;
|
||||
inviteLink: string;
|
||||
email: string;
|
||||
}) {
|
||||
const context = await browser.newContext();
|
||||
const inviteLinkPage = await context.newPage();
|
||||
await inviteLinkPage.goto(inviteLink);
|
||||
await inviteLinkPage.waitForLoadState("networkidle");
|
||||
|
||||
// Check required fields
|
||||
const button = inviteLinkPage.locator("button[type=submit][disabled]");
|
||||
await expect(button).toBeVisible(); // email + 3 password hints
|
||||
|
||||
await inviteLinkPage.locator("input[name=email]").fill(email);
|
||||
await inviteLinkPage.locator("input[name=password]").fill(`P4ssw0rd!`);
|
||||
await inviteLinkPage.locator("button[type=submit]").click();
|
||||
await inviteLinkPage.waitForURL("/getting-started");
|
||||
return { email };
|
||||
}
|
||||
|
||||
export async function signupFromEmailInviteLink({
|
||||
browser,
|
||||
inviteLink,
|
||||
expectedUsername,
|
||||
expectedEmail,
|
||||
}: {
|
||||
browser: Browser;
|
||||
inviteLink: string;
|
||||
expectedUsername?: string;
|
||||
expectedEmail?: string;
|
||||
}) {
|
||||
// Follow invite link in new window
|
||||
const context = await browser.newContext();
|
||||
const signupPage = await context.newPage();
|
||||
|
||||
signupPage.goto(inviteLink);
|
||||
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 ""
|
||||
if (expectedUsername) {
|
||||
await expect(signupPage.locator(`[data-testid="signup-usernamefield"]`)).toHaveValue(expectedUsername);
|
||||
}
|
||||
|
||||
await expect(signupPage.locator(`[data-testid="signup-emailfield"]`)).toBeDisabled();
|
||||
if (expectedEmail) {
|
||||
await expect(signupPage.locator(`[data-testid="signup-emailfield"]`)).toHaveValue(expectedEmail);
|
||||
}
|
||||
|
||||
await signupPage.waitForLoadState("networkidle");
|
||||
// Check required fields
|
||||
await signupPage.locator("input[name=password]").fill(`P4ssw0rd!`);
|
||||
await signupPage.locator("button[type=submit]").click();
|
||||
await signupPage.waitForURL("/getting-started?from=signup");
|
||||
await context.close();
|
||||
await signupPage.close();
|
||||
}
|
||||
|
||||
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");
|
||||
}
|
||||
|
||||
async function expectUserToBeAMemberOfOrganization({
|
||||
page,
|
||||
username,
|
||||
email,
|
||||
role,
|
||||
isMemberShipAccepted,
|
||||
}: {
|
||||
page: Page;
|
||||
username: string;
|
||||
role: string;
|
||||
isMemberShipAccepted: boolean;
|
||||
email: string;
|
||||
}) {
|
||||
// Check newly invited member is not pending anymore
|
||||
await page.goto("/settings/organizations/members");
|
||||
expect(await page.locator(`[data-testid="member-${username}-username"]`).textContent()).toBe(username);
|
||||
expect(await page.locator(`[data-testid="member-${username}-email"]`).textContent()).toBe(email);
|
||||
expect((await page.locator(`[data-testid="member-${username}-role"]`).textContent())?.toLowerCase()).toBe(
|
||||
role.toLowerCase()
|
||||
);
|
||||
if (isMemberShipAccepted) {
|
||||
await expect(page.locator(`[data-testid2="member-${username}-pending"]`)).toBeHidden();
|
||||
} else {
|
||||
await expect(page.locator(`[data-testid2="member-${username}-pending"]`)).toBeVisible();
|
||||
}
|
||||
}
|
||||
|
||||
async function expectUserToBeAMemberOfTeam({
|
||||
page,
|
||||
teamId,
|
||||
email,
|
||||
role,
|
||||
username,
|
||||
isMemberShipAccepted,
|
||||
}: {
|
||||
page: Page;
|
||||
username: string;
|
||||
role: string;
|
||||
teamId: number;
|
||||
isMemberShipAccepted: boolean;
|
||||
email: string;
|
||||
}) {
|
||||
// Check newly invited member is not pending anymore
|
||||
await page.goto(`/settings/teams/${teamId}/members`);
|
||||
await page.reload();
|
||||
expect(
|
||||
(
|
||||
await page.locator(`[data-testid="member-${username}"] [data-testid=member-role]`).textContent()
|
||||
)?.toLowerCase()
|
||||
).toBe(role.toLowerCase());
|
||||
if (isMemberShipAccepted) {
|
||||
await expect(page.locator(`[data-testid="email-${email.replace("@", "")}-pending"]`)).toBeHidden();
|
||||
} else {
|
||||
await expect(page.locator(`[data-testid="email-${email.replace("@", "")}-pending"]`)).toBeVisible();
|
||||
}
|
||||
}
|
||||
|
||||
function assertInviteLink(inviteLink: string | null | undefined): asserts inviteLink is string {
|
||||
if (!inviteLink) throw new Error("Invite link not found");
|
||||
}
|
||||
|
||||
async function copyInviteLink(page: Page) {
|
||||
await page.locator('button:text("Add")').click();
|
||||
await page.locator(`[data-testid="copy-invite-link-button"]`).click();
|
||||
const inviteLink = await getInviteLink(page);
|
||||
return inviteLink;
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
import { expect } from "@playwright/test";
|
||||
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
import { test } from "../lib/fixtures";
|
||||
|
||||
test.describe.configure({ mode: "parallel" });
|
||||
|
||||
test.afterEach(async ({ users, orgs }) => {
|
||||
await users.deleteAll();
|
||||
await orgs.deleteAll();
|
||||
});
|
||||
|
||||
test.describe("Organization - Privacy", () => {
|
||||
test(`Private Org \n
|
||||
1) Org Member cannot see members of orgs\n
|
||||
2) Org Owner/Admin can see members`, async ({ page, users, orgs }) => {
|
||||
const org = await orgs.create({
|
||||
name: "TestOrg",
|
||||
isPrivate: true,
|
||||
});
|
||||
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: "OWNER",
|
||||
},
|
||||
{
|
||||
hasTeam: true,
|
||||
teammates: teamMatesObj,
|
||||
}
|
||||
);
|
||||
const memberInOrg = await users.create({
|
||||
username: "org-member-user",
|
||||
name: "org-member-user",
|
||||
organizationId: org.id,
|
||||
roleInOrganization: "MEMBER",
|
||||
});
|
||||
|
||||
await owner.apiLogin();
|
||||
await page.goto("/settings/organizations/members");
|
||||
await page.waitForLoadState("domcontentloaded");
|
||||
|
||||
const tableLocator = await page.getByTestId("user-list-data-table");
|
||||
|
||||
await expect(tableLocator).toBeVisible();
|
||||
|
||||
await memberInOrg.apiLogin();
|
||||
await page.goto("/settings/organizations/members");
|
||||
await page.waitForLoadState("domcontentloaded");
|
||||
const userDataTable = await page.getByTestId("user-list-data-table");
|
||||
const membersPrivacyWarning = await page.getByTestId("members-privacy-warning");
|
||||
await expect(userDataTable).toBeHidden();
|
||||
await expect(membersPrivacyWarning).toBeVisible();
|
||||
});
|
||||
test(`Private Org - Private Team\n
|
||||
1) Team Member cannot see members in team\n
|
||||
2) Team Admin/Owner can see members in team`, async ({ page, users, orgs }) => {
|
||||
const org = await orgs.create({
|
||||
name: "TestOrg",
|
||||
isPrivate: true,
|
||||
});
|
||||
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: "OWNER",
|
||||
},
|
||||
{
|
||||
hasTeam: true,
|
||||
teammates: teamMatesObj,
|
||||
}
|
||||
);
|
||||
|
||||
await owner.apiLogin();
|
||||
const membership = await owner.getFirstTeamMembership();
|
||||
const teamId = membership.team.id;
|
||||
|
||||
// Update team to be private
|
||||
await page.goto(`/settings/teams/${teamId}/members`);
|
||||
await page.waitForLoadState("domcontentloaded");
|
||||
const togglePrivateSwitch = await page.getByTestId("make-team-private-check");
|
||||
await togglePrivateSwitch.click();
|
||||
|
||||
// As admin/owner we can see the user list
|
||||
const tableLocator = await page.getByTestId("team-member-list-container");
|
||||
await expect(tableLocator).toBeVisible();
|
||||
|
||||
const memberUser = await prisma.membership.findFirst({
|
||||
where: {
|
||||
teamId,
|
||||
role: "MEMBER",
|
||||
},
|
||||
select: {
|
||||
user: {
|
||||
select: {
|
||||
email: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(memberUser?.user.email).toBeDefined();
|
||||
// @ts-expect-error expect doesnt assert on a type level
|
||||
const memberOfTeam = await users.set(memberUser?.user.email);
|
||||
await memberOfTeam.apiLogin();
|
||||
|
||||
await page.goto(`/settings/teams/${teamId}/members`);
|
||||
await page.waitForLoadState("domcontentloaded");
|
||||
|
||||
// As a user we can not see the user list when a team is private
|
||||
const hiddenTableLocator = await page.getByTestId("team-member-list-container");
|
||||
await expect(hiddenTableLocator).toBeHidden();
|
||||
});
|
||||
test(`Private Org - Public Team\n
|
||||
1) All team members can see members in team \n
|
||||
2) Team Admin/Owner can see members in team`, 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: "OWNER",
|
||||
},
|
||||
{
|
||||
hasTeam: true,
|
||||
teammates: teamMatesObj,
|
||||
}
|
||||
);
|
||||
|
||||
await owner.apiLogin();
|
||||
const membership = await owner.getFirstTeamMembership();
|
||||
const teamId = membership.team.id;
|
||||
|
||||
// Update team to be private
|
||||
await page.goto(`/settings/teams/${teamId}/members`);
|
||||
await page.waitForLoadState("domcontentloaded");
|
||||
|
||||
// As admin/owner we can see the user list
|
||||
const tableLocator = await page.getByTestId("team-member-list-container");
|
||||
await expect(tableLocator).toBeVisible();
|
||||
|
||||
const memberUser = await prisma.membership.findFirst({
|
||||
where: {
|
||||
teamId,
|
||||
role: "MEMBER",
|
||||
},
|
||||
select: {
|
||||
user: {
|
||||
select: {
|
||||
email: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(memberUser?.user.email).toBeDefined();
|
||||
// @ts-expect-error expect doesnt assert on a type level
|
||||
const memberOfTeam = await users.set(memberUser?.user.email);
|
||||
await memberOfTeam.apiLogin();
|
||||
|
||||
await page.goto(`/settings/teams/${teamId}/members`);
|
||||
await page.waitForLoadState("domcontentloaded");
|
||||
|
||||
// As a user we can not see the user list when a team is private
|
||||
const hiddenTableLocator = await page.getByTestId("team-member-list-container");
|
||||
await expect(hiddenTableLocator).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user