529 lines
17 KiB
TypeScript
529 lines
17 KiB
TypeScript
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();
|
|
}
|