* fix: resolve duplicate new-team-btn selector in teams E2E test Remove TeamsCTA from the loading skeleton to prevent duplicate data-testid='new-team-btn' elements in the DOM during Next.js streaming SSR. The skeleton's Suspense fallback and the resolved page content can briefly coexist, causing Playwright's strict mode to fail when both contain the same test ID. Also use .first() in the E2E test as a defensive measure. Co-Authored-By: romitgabani1 <romitgabani1.work@gmail.com> * fix: assert single new-team-btn element instead of using .first() Address Cubic AI review feedback: use toHaveCount(1) assertion to ensure exactly one new-team-btn exists, rather than .first() which could mask duplicate-element regressions. Co-Authored-By: romitgabani1 <romitgabani1.work@gmail.com> * fix: revert test changes, only remove CTA from skeleton Per user feedback: only change needed is removing TeamsCTA from the loading skeleton to prevent users without a team plan from bypassing the upgrade banner via the skeleton CTA. Co-Authored-By: romitgabani1 <romitgabani1.work@gmail.com> --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
426 lines
14 KiB
TypeScript
426 lines
14 KiB
TypeScript
import { IS_TEAM_BILLING_ENABLED } from "@calcom/lib/constants";
|
|
import { prisma } from "@calcom/prisma";
|
|
import { SchedulingType } from "@calcom/prisma/enums";
|
|
import { expect } from "@playwright/test";
|
|
import { test, todo } from "./lib/fixtures";
|
|
import {
|
|
bookTimeSlot,
|
|
confirmReschedule,
|
|
fillStripeTestCheckout,
|
|
selectFirstAvailableTimeSlotNextMonth,
|
|
submitAndWaitForResponse,
|
|
testName,
|
|
} from "./lib/testUtils";
|
|
|
|
test.describe.configure({ mode: "parallel" });
|
|
|
|
test.describe("Teams tests", () => {
|
|
test("should render the /teams page", async ({ page, users, context }) => {
|
|
const user = await users.create(undefined, { hasTeam: true });
|
|
|
|
await user.apiLogin();
|
|
|
|
await page.goto("/teams");
|
|
|
|
await page.waitForLoadState();
|
|
|
|
const locator = page.getByRole("heading", { name: "Teams", exact: true });
|
|
|
|
await expect(locator).toBeVisible();
|
|
});
|
|
});
|
|
|
|
test.describe("Teams - NonOrg", () => {
|
|
test.afterEach(({ users }) => users.deleteAll());
|
|
|
|
test("Can create a booking for Collective 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.COLLECTIVE,
|
|
}
|
|
);
|
|
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 title of the booking
|
|
const BookingTitle = `${teamEventTitle} between ${team.name} and ${testName}`;
|
|
await expect(page.locator("[data-testid=booking-title]")).toHaveText(BookingTitle);
|
|
// The booker should be in the attendee list
|
|
await expect(page.locator(`[data-testid="attendee-name-${testName}"]`)).toHaveText(testName);
|
|
|
|
// All the teammates should be in the booking
|
|
for (const teammate of teamMatesObj) {
|
|
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.locator(`[data-testid="attendee-name-${testName}"]`)).toHaveText(testName);
|
|
|
|
// The title of the booking
|
|
const bookingTitle = await page.getByTestId("booking-title").textContent();
|
|
expect(
|
|
teamMatesObj.concat([{ name: owner.name! }]).some((teamMate) => {
|
|
const BookingTitle = `${teamEventTitle} between ${teamMate.name} and ${testName}`;
|
|
return BookingTitle === bookingTitle;
|
|
})
|
|
).toBe(true);
|
|
|
|
// 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("Non admin team members cannot create team in org", async ({ page, users }) => {
|
|
const teamMateName = "teammate-1";
|
|
|
|
const owner = await users.create(undefined, {
|
|
hasTeam: true,
|
|
isOrg: true,
|
|
hasSubteam: true,
|
|
teammates: [{ name: teamMateName }],
|
|
});
|
|
|
|
const allUsers = await users.get();
|
|
const memberUser = allUsers.find((user) => user.name === teamMateName);
|
|
|
|
// eslint-disable-next-line playwright/no-conditional-in-test
|
|
if (memberUser) {
|
|
// Add teammate to the sub-team so they have a team membership and see teams list
|
|
const org = await owner.getOrgMembership();
|
|
const subTeam = await prisma.team.findFirst({
|
|
where: { parentId: org.teamId },
|
|
select: { id: true },
|
|
});
|
|
if (!subTeam) throw new Error("Expected a sub-team under the org, but none was found");
|
|
await prisma.membership.create({
|
|
data: {
|
|
teamId: subTeam.id,
|
|
userId: memberUser.id,
|
|
role: "MEMBER",
|
|
accepted: true,
|
|
},
|
|
});
|
|
|
|
await memberUser.apiLogin();
|
|
|
|
await page.goto("/teams");
|
|
await expect(page.locator("[data-testid=new-team-btn]")).toBeHidden();
|
|
|
|
const uniqueName = "test-unique-team-name";
|
|
|
|
// Go directly to the create team page (new onboarding-v3 style flow)
|
|
await page.goto("/settings/teams/new");
|
|
await page.waitForLoadState("networkidle");
|
|
// Fill team name input
|
|
await page.locator('[data-testid="team-name-input"]').fill(uniqueName);
|
|
await page.click("[type=submit]");
|
|
|
|
// cleanup
|
|
await prisma.team.delete({ where: { id: org.teamId } });
|
|
}
|
|
});
|
|
|
|
test("Can create team with same name as user", async ({ page, users }) => {
|
|
const user = await users.create(undefined, { hasTeam: true });
|
|
// Name to be used for both user and team
|
|
const uniqueName = user.username!;
|
|
await user.apiLogin();
|
|
await page.goto("/teams");
|
|
|
|
await test.step("Can create team with same name", async () => {
|
|
await page.locator("[data-testid=new-team-btn]").click();
|
|
await page.waitForLoadState("networkidle");
|
|
// Fill team name input (new onboarding-v3 style flow)
|
|
await page.locator('[data-testid="team-name-input"]').fill(uniqueName);
|
|
// Click 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);
|
|
// Wait for the invite email page
|
|
await expect(page).toHaveURL(/\/settings\/teams\/new\/invite\/email.*$/i);
|
|
await page.waitForLoadState("networkidle");
|
|
// Skip the invite step
|
|
const skipButton = page.locator("[data-testid=skip-invite-button]");
|
|
await skipButton.waitFor({ state: "visible", timeout: 10000 });
|
|
await skipButton.click();
|
|
// Wait for redirect to teams page
|
|
await page.waitForURL(/\/teams$/i);
|
|
});
|
|
|
|
await test.step("Can access user and team with same slug", async () => {
|
|
// Go to team page and confirm name
|
|
const teamUrl = `/team/${uniqueName}`;
|
|
await page.goto(teamUrl);
|
|
await page.waitForURL(teamUrl);
|
|
await expect(page.locator("[data-testid=team-name]")).toHaveText(uniqueName);
|
|
|
|
// Go to user page and confirm name
|
|
const userUrl = `/${uniqueName}`;
|
|
await page.goto(userUrl);
|
|
await page.waitForURL(userUrl);
|
|
await expect(page.locator("[data-testid=name-title]")).toHaveText(uniqueName);
|
|
|
|
// cleanup team
|
|
await prisma.team.deleteMany({ where: { slug: uniqueName } });
|
|
});
|
|
});
|
|
|
|
test("Can create a private team", 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.COLLECTIVE,
|
|
}
|
|
);
|
|
|
|
await owner.apiLogin();
|
|
const { team } = await owner.getFirstTeamMembership();
|
|
|
|
// Mark team as private
|
|
await page.goto(`/settings/teams/${team.id}/settings`);
|
|
await Promise.all([
|
|
page.click("[data-testid=make-team-private-check]"),
|
|
expect(page.locator(`[data-testid=make-team-private-check][data-state="checked"]`)).toBeVisible(),
|
|
// according to switch implementation, checked state can be set before mutation is resolved
|
|
// so we need to await for req to resolve
|
|
page.waitForResponse((res) => res.url().includes("/api/trpc/teams/update")),
|
|
]);
|
|
|
|
// Go to Team's page
|
|
await page.goto(`/team/${team.slug}`);
|
|
await expect(page.locator('[data-testid="book-a-team-member-btn"]')).toBeHidden();
|
|
|
|
// Go to members page
|
|
await page.goto(`/team/${team.slug}?members=1`);
|
|
await expect(page.locator('[data-testid="you-cannot-see-team-members"]')).toBeVisible();
|
|
await expect(page.locator('[data-testid="team-members-container"]')).toBeHidden();
|
|
});
|
|
test("Email Embeds slots are loading for team event types", 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.COLLECTIVE,
|
|
}
|
|
);
|
|
|
|
await owner.apiLogin();
|
|
const { team } = await owner.getFirstTeamMembership();
|
|
const {
|
|
title: teamEventTitle,
|
|
slug: teamEventSlug,
|
|
id: teamEventId,
|
|
} = await owner.getFirstTeamEvent(team.id);
|
|
|
|
await page.goto(`/event-types?teamId=${team.id}`);
|
|
|
|
await page.getByTestId(`event-type-options-${teamEventId}`).first().click();
|
|
await page.getByTestId("embed").click();
|
|
await page.getByTestId("email").click();
|
|
await page.getByTestId("incrementMonth").click();
|
|
|
|
await expect(page.getByTestId("no-slots-available")).toBeHidden();
|
|
|
|
// Check Team Url
|
|
const availableTimesUrl = await page.getByTestId("see_all_available_times").getAttribute("href");
|
|
await expect(availableTimesUrl).toContain(`/team/${team.slug}/${teamEventSlug}`);
|
|
});
|
|
|
|
todo("Create a Round Robin with different leastRecentlyBooked hosts");
|
|
test("Reschedule a Collective EventType booking", async ({ users, page, bookings }) => {
|
|
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.COLLECTIVE,
|
|
}
|
|
);
|
|
|
|
const { team } = await owner.getFirstTeamMembership();
|
|
const eventType = await owner.getFirstTeamEvent(team.id);
|
|
|
|
const booking = await bookings.create(owner.id, owner.username, eventType.id);
|
|
await page.goto(`/reschedule/${booking.uid}`);
|
|
await selectFirstAvailableTimeSlotNextMonth(page);
|
|
await confirmReschedule(page);
|
|
await expect(page.locator("[data-testid=success-page]")).toBeVisible();
|
|
});
|
|
todo("Reschedule a Round Robin EventType booking");
|
|
});
|
|
|
|
test.describe("Team Slug Validation", () => {
|
|
test.afterEach(({ users, orgs }) => {
|
|
users.deleteAll();
|
|
orgs.deleteAll();
|
|
});
|
|
|
|
test("Teams in different organizations can have the same slug", async ({ page, users, orgs }) => {
|
|
const org1 = await orgs.create({ name: "Organization 1" });
|
|
const org2 = await orgs.create({ name: "Organization 2" });
|
|
|
|
const owner1 = await users.create(
|
|
{
|
|
organizationId: org1.id,
|
|
roleInOrganization: "OWNER",
|
|
},
|
|
{
|
|
hasTeam: true,
|
|
teamSlug: "cal",
|
|
teamRole: "OWNER",
|
|
}
|
|
);
|
|
|
|
const owner2 = await users.create(
|
|
{
|
|
organizationId: org2.id,
|
|
roleInOrganization: "OWNER",
|
|
},
|
|
{
|
|
hasTeam: true,
|
|
teamSlug: "calCom",
|
|
teamRole: "OWNER",
|
|
}
|
|
);
|
|
const { team: team1 } = await owner1.getFirstTeamMembership();
|
|
|
|
await owner1.apiLogin();
|
|
await page.goto(`/settings/teams/${team1.id}/profile`);
|
|
await page.locator('input[name="slug"]').fill("calCom");
|
|
await submitAndWaitForResponse(page, "/api/trpc/teams/update?batch=1", {
|
|
action: () => page.locator("[data-testid=update-team-profile]").click(),
|
|
});
|
|
});
|
|
|
|
test("Teams within same organization cannot have duplicate slugs", async ({ page, users, orgs }) => {
|
|
const org = await orgs.create({ name: "Organization 1" });
|
|
|
|
const owner = await users.create(
|
|
{
|
|
organizationId: org.id,
|
|
roleInOrganization: "OWNER",
|
|
},
|
|
{
|
|
hasTeam: true,
|
|
numberOfTeams: 2,
|
|
teamRole: "OWNER",
|
|
}
|
|
);
|
|
|
|
const teams = await owner.getAllTeamMembership();
|
|
await owner.apiLogin();
|
|
await page.goto(`/settings/teams/${teams[0].team.id}/profile`);
|
|
if (!teams[1].team.slug) throw new Error("Slug not found for team 2");
|
|
await page.locator('input[name="slug"]').fill(teams[1].team.slug);
|
|
await submitAndWaitForResponse(page, "/api/trpc/teams/update?batch=1", {
|
|
action: () => page.locator("[data-testid=update-team-profile]").click(),
|
|
expectedStatusCode: 409,
|
|
});
|
|
});
|
|
|
|
test("Teams without organization can have same slug as teams in organizations", async ({
|
|
page,
|
|
users,
|
|
orgs,
|
|
}) => {
|
|
const org = await orgs.create({ name: "Organization 1" });
|
|
|
|
const orgOwner = await users.create(
|
|
{
|
|
organizationId: org.id,
|
|
roleInOrganization: "OWNER",
|
|
},
|
|
{
|
|
hasTeam: true,
|
|
teamSlug: "calCom",
|
|
teamRole: "OWNER",
|
|
}
|
|
);
|
|
|
|
const teamOwner = await users.create(
|
|
{ username: "pro-user", name: "pro-user" },
|
|
{
|
|
hasTeam: true,
|
|
}
|
|
);
|
|
|
|
const { team } = await teamOwner.getFirstTeamMembership();
|
|
await teamOwner.apiLogin();
|
|
await page.goto(`/settings/teams/${team.id}/profile`);
|
|
await page.locator('input[name="slug"]').fill("calCom");
|
|
await submitAndWaitForResponse(page, "/api/trpc/teams/update?batch=1", {
|
|
action: () => page.locator("[data-testid=update-team-profile]").click(),
|
|
});
|
|
});
|
|
});
|