test: E2E for Orgs - The beginning (#12095)
parent
09ecd445bb
commit
426d31712e
|
@ -3,7 +3,10 @@ import Link from "next/link";
|
|||
import { usePathname } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { orgDomainConfig, subdomainSuffix } from "@calcom/features/ee/organizations/lib/orgDomains";
|
||||
import {
|
||||
getOrgDomainConfigFromHostname,
|
||||
subdomainSuffix,
|
||||
} from "@calcom/features/ee/organizations/lib/orgDomains";
|
||||
import { DOCS_URL, IS_CALCOM, JOIN_DISCORD, WEBSITE_URL } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { HeadSeo } from "@calcom/ui";
|
||||
|
@ -50,7 +53,10 @@ export default function Custom404() {
|
|||
|
||||
const [url, setUrl] = useState(`${WEBSITE_URL}/signup`);
|
||||
useEffect(() => {
|
||||
const { isValidOrgDomain, currentOrgDomain } = orgDomainConfig(window.location.host);
|
||||
const { isValidOrgDomain, currentOrgDomain } = getOrgDomainConfigFromHostname({
|
||||
hostname: window.location.host,
|
||||
});
|
||||
|
||||
const [routerUsername] = pathname?.replace("%20", "-").split(/[?#]/) ?? [];
|
||||
if (routerUsername && (!isValidOrgDomain || !currentOrgDomain)) {
|
||||
const splitPath = routerUsername.split("/");
|
||||
|
|
|
@ -275,10 +275,7 @@ export type UserPageProps = {
|
|||
|
||||
export const getServerSideProps: GetServerSideProps<UserPageProps> = async (context) => {
|
||||
const ssr = await ssrInit(context);
|
||||
const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(
|
||||
context.req.headers.host ?? "",
|
||||
context.params?.orgSlug
|
||||
);
|
||||
const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(context.req, context.params?.orgSlug);
|
||||
const usernameList = getUsernameList(context.query.user as string);
|
||||
const isOrgContext = isValidOrgDomain && currentOrgDomain;
|
||||
const dataFetchStart = Date.now();
|
||||
|
|
|
@ -72,10 +72,7 @@ async function getDynamicGroupPageProps(context: GetServerSidePropsContext) {
|
|||
|
||||
const { ssrInit } = await import("@server/lib/ssr");
|
||||
const ssr = await ssrInit(context);
|
||||
const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(
|
||||
context.req.headers.host ?? "",
|
||||
context.params?.orgSlug
|
||||
);
|
||||
const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(context.req, context.params?.orgSlug);
|
||||
|
||||
const users = await prisma.user.findMany({
|
||||
where: {
|
||||
|
@ -148,10 +145,7 @@ async function getUserPageProps(context: GetServerSidePropsContext) {
|
|||
const { user: usernames, type: slug } = paramsSchema.parse(context.params);
|
||||
const username = usernames[0];
|
||||
const { rescheduleUid, bookingUid, duration: queryDuration } = context.query;
|
||||
const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(
|
||||
context.req.headers.host ?? "",
|
||||
context.params?.orgSlug
|
||||
);
|
||||
const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(context.req, context.params?.orgSlug);
|
||||
|
||||
const isOrgContext = currentOrgDomain && isValidOrgDomain;
|
||||
|
||||
|
|
|
@ -154,7 +154,7 @@ async function getTeamLogos(subdomain: string, isValidOrgDomain: boolean) {
|
|||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
const { query } = req;
|
||||
const parsedQuery = logoApiSchema.parse(query);
|
||||
const { isValidOrgDomain } = orgDomainConfig(req.headers.host ?? "");
|
||||
const { isValidOrgDomain } = orgDomainConfig(req);
|
||||
|
||||
const hostname = req?.headers["host"];
|
||||
if (!hostname) throw new Error("No hostname");
|
||||
|
|
|
@ -29,7 +29,7 @@ const querySchema = z
|
|||
|
||||
async function getIdentityData(req: NextApiRequest) {
|
||||
const { username, teamname, orgId, orgSlug } = querySchema.parse(req.query);
|
||||
const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(req.headers.host ?? "");
|
||||
const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(req);
|
||||
|
||||
const org = isValidOrgDomain ? currentOrgDomain : null;
|
||||
|
||||
|
|
|
@ -9,7 +9,7 @@ type Response = {
|
|||
};
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse<Response>): Promise<void> {
|
||||
const { currentOrgDomain } = orgDomainConfig(req.headers.host ?? "");
|
||||
const { currentOrgDomain } = orgDomainConfig(req);
|
||||
const result = await checkUsername(req.body.username, currentOrgDomain);
|
||||
return res.status(200).json(result);
|
||||
}
|
||||
|
|
|
@ -65,7 +65,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
|||
|
||||
const session = await getServerSession({ req, res });
|
||||
const ssr = await ssrInit(context);
|
||||
const { currentOrgDomain } = orgDomainConfig(context.req.headers.host ?? "");
|
||||
const { currentOrgDomain } = orgDomainConfig(context.req);
|
||||
|
||||
if (session) {
|
||||
// Validating if username is Premium, while this is true an email its required for stripe user confirmation
|
||||
|
|
|
@ -61,7 +61,7 @@ async function getUserPageProps(context: GetServerSidePropsContext) {
|
|||
const session = await getServerSession(context);
|
||||
const { link, slug } = paramsSchema.parse(context.params);
|
||||
const { rescheduleUid, duration: queryDuration } = context.query;
|
||||
const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(context.req.headers.host ?? "");
|
||||
const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(context.req);
|
||||
const org = isValidOrgDomain ? currentOrgDomain : null;
|
||||
|
||||
const { ssrInit } = await import("@server/lib/ssr");
|
||||
|
|
|
@ -269,10 +269,7 @@ function TeamPage({
|
|||
|
||||
export const getServerSideProps = async (context: GetServerSidePropsContext) => {
|
||||
const slug = Array.isArray(context.query?.slug) ? context.query.slug.pop() : context.query.slug;
|
||||
const { isValidOrgDomain, currentOrgDomain } = orgDomainConfig(
|
||||
context.req.headers.host ?? "",
|
||||
context.params?.orgSlug
|
||||
);
|
||||
const { isValidOrgDomain, currentOrgDomain } = orgDomainConfig(context.req, context.params?.orgSlug);
|
||||
const isOrgContext = isValidOrgDomain && currentOrgDomain;
|
||||
|
||||
// Provided by Rewrite from next.config.js
|
||||
|
|
|
@ -74,10 +74,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
|||
const { rescheduleUid, duration: queryDuration } = context.query;
|
||||
const { ssrInit } = await import("@server/lib/ssr");
|
||||
const ssr = await ssrInit(context);
|
||||
const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(
|
||||
context.req.headers.host ?? "",
|
||||
context.params?.orgSlug
|
||||
);
|
||||
const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(context.req, context.params?.orgSlug);
|
||||
const isOrgContext = currentOrgDomain && isValidOrgDomain;
|
||||
|
||||
if (!isOrgContext) {
|
||||
|
|
|
@ -0,0 +1,56 @@
|
|||
import type { Page } from "@playwright/test";
|
||||
import type { Team } from "@prisma/client";
|
||||
|
||||
import { prisma } from "@calcom/prisma";
|
||||
|
||||
const getRandomSlug = () => `org-${Math.random().toString(36).substring(7)}`;
|
||||
|
||||
// creates a user fixture instance and stores the collection
|
||||
export const createOrgsFixture = (page: Page) => {
|
||||
const store = { orgs: [], page } as { orgs: Team[]; page: typeof page };
|
||||
return {
|
||||
create: async (opts: { name: string; slug?: string; requestedSlug?: string }) => {
|
||||
const org = await createOrgInDb({
|
||||
name: opts.name,
|
||||
slug: opts.slug || getRandomSlug(),
|
||||
requestedSlug: opts.requestedSlug,
|
||||
});
|
||||
store.orgs.push(org);
|
||||
return org;
|
||||
},
|
||||
get: () => store.orgs,
|
||||
deleteAll: async () => {
|
||||
await prisma.team.deleteMany({ where: { id: { in: store.orgs.map((org) => org.id) } } });
|
||||
store.orgs = [];
|
||||
},
|
||||
delete: async (id: number) => {
|
||||
await prisma.team.delete({ where: { id } });
|
||||
store.orgs = store.orgs.filter((b) => b.id !== id);
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
async function createOrgInDb({
|
||||
name,
|
||||
slug,
|
||||
requestedSlug,
|
||||
}: {
|
||||
name: string;
|
||||
slug: string | null;
|
||||
requestedSlug?: string;
|
||||
}) {
|
||||
return await prisma.team.create({
|
||||
data: {
|
||||
name: name,
|
||||
slug: slug,
|
||||
metadata: {
|
||||
isOrganization: true,
|
||||
...(requestedSlug
|
||||
? {
|
||||
requestedSlug,
|
||||
}
|
||||
: null),
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
|
@ -9,6 +9,7 @@ import { DEFAULT_SCHEDULE, getAvailabilityFromSchedule } from "@calcom/lib/avail
|
|||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
import { MembershipRole, SchedulingType } from "@calcom/prisma/enums";
|
||||
import { teamMetadataSchema } from "@calcom/prisma/zod-utils";
|
||||
|
||||
import { selectFirstAvailableTimeSlotNextMonth, teamEventSlug, teamEventTitle } from "../lib/testUtils";
|
||||
import { TimeZoneEnum } from "./types";
|
||||
|
@ -78,11 +79,13 @@ const createTeamAndAddUser = async (
|
|||
isUnpublished,
|
||||
isOrg,
|
||||
hasSubteam,
|
||||
organizationId,
|
||||
}: {
|
||||
user: { id: number; username: string | null; role?: MembershipRole };
|
||||
isUnpublished?: boolean;
|
||||
isOrg?: boolean;
|
||||
hasSubteam?: true;
|
||||
organizationId?: number | null;
|
||||
},
|
||||
workerInfo: WorkerInfo
|
||||
) => {
|
||||
|
@ -101,6 +104,7 @@ const createTeamAndAddUser = async (
|
|||
data.children = { connect: [{ id: team.id }] };
|
||||
}
|
||||
data.orgUsers = isOrg ? { connect: [{ id: user.id }] } : undefined;
|
||||
data.parent = organizationId ? { connect: { id: organizationId } } : undefined;
|
||||
const team = await prisma.team.create({
|
||||
data,
|
||||
});
|
||||
|
@ -114,6 +118,7 @@ const createTeamAndAddUser = async (
|
|||
accepted: true,
|
||||
},
|
||||
});
|
||||
|
||||
return team;
|
||||
};
|
||||
|
||||
|
@ -282,6 +287,7 @@ export const createUsersFixture = (page: Page, emails: API | undefined, workerIn
|
|||
isUnpublished: scenario.isUnpublished,
|
||||
isOrg: scenario.isOrg,
|
||||
hasSubteam: scenario.hasSubteam,
|
||||
organizationId: opts?.organizationId,
|
||||
},
|
||||
workerInfo
|
||||
);
|
||||
|
@ -399,11 +405,27 @@ const createUserFixture = (user: UserWithIncludes, page: Page) => {
|
|||
logout: async () => {
|
||||
await page.goto("/auth/logout");
|
||||
},
|
||||
getTeam: async () => {
|
||||
return prisma.membership.findFirstOrThrow({
|
||||
getFirstTeam: async () => {
|
||||
const memberships = await prisma.membership.findMany({
|
||||
where: { userId: user.id },
|
||||
include: { team: true },
|
||||
});
|
||||
|
||||
const membership = memberships
|
||||
.map((membership) => {
|
||||
return {
|
||||
...membership,
|
||||
team: {
|
||||
...membership.team,
|
||||
metadata: teamMetadataSchema.parse(membership.team.metadata),
|
||||
},
|
||||
};
|
||||
})
|
||||
.find((membership) => !membership.team?.metadata?.isOrganization);
|
||||
if (!membership) {
|
||||
throw new Error("No team found for user");
|
||||
}
|
||||
return membership;
|
||||
},
|
||||
getOrg: async () => {
|
||||
return prisma.membership.findFirstOrThrow({
|
||||
|
@ -453,16 +475,27 @@ const createUserFixture = (user: UserWithIncludes, page: Page) => {
|
|||
type SupportedTestEventTypes = PrismaType.EventTypeCreateInput & {
|
||||
_bookings?: PrismaType.BookingCreateInput[];
|
||||
};
|
||||
type CustomUserOptsKeys = "username" | "password" | "completedOnboarding" | "locale" | "name" | "email";
|
||||
type CustomUserOptsKeys =
|
||||
| "username"
|
||||
| "password"
|
||||
| "completedOnboarding"
|
||||
| "locale"
|
||||
| "name"
|
||||
| "email"
|
||||
| "organizationId";
|
||||
type CustomUserOpts = Partial<Pick<Prisma.User, CustomUserOptsKeys>> & {
|
||||
timeZone?: TimeZoneEnum;
|
||||
eventTypes?: SupportedTestEventTypes[];
|
||||
// ignores adding the worker-index after username
|
||||
useExactUsername?: boolean;
|
||||
roleInOrganization?: MembershipRole;
|
||||
};
|
||||
|
||||
// creates the actual user in the db.
|
||||
const createUser = (workerInfo: WorkerInfo, opts?: CustomUserOpts | null): PrismaType.UserCreateInput => {
|
||||
const createUser = (
|
||||
workerInfo: WorkerInfo,
|
||||
opts?: CustomUserOpts | null
|
||||
): PrismaType.UserUncheckedCreateInput => {
|
||||
// build a unique name for our user
|
||||
const uname =
|
||||
opts?.useExactUsername && opts?.username
|
||||
|
@ -478,6 +511,7 @@ const createUser = (workerInfo: WorkerInfo, opts?: CustomUserOpts | null): Prism
|
|||
completedOnboarding: opts?.completedOnboarding ?? true,
|
||||
timeZone: opts?.timeZone ?? TimeZoneEnum.UK,
|
||||
locale: opts?.locale ?? "en",
|
||||
...getOrganizationRelatedProps({ organizationId: opts?.organizationId, role: opts?.roleInOrganization }),
|
||||
schedules:
|
||||
opts?.completedOnboarding ?? true
|
||||
? {
|
||||
|
@ -493,6 +527,42 @@ const createUser = (workerInfo: WorkerInfo, opts?: CustomUserOpts | null): Prism
|
|||
}
|
||||
: undefined,
|
||||
};
|
||||
|
||||
function getOrganizationRelatedProps({
|
||||
organizationId,
|
||||
role,
|
||||
}: {
|
||||
organizationId: number | null | undefined;
|
||||
role: MembershipRole | undefined;
|
||||
}) {
|
||||
if (!organizationId) {
|
||||
return null;
|
||||
}
|
||||
if (!role) {
|
||||
throw new Error("Missing role for user in organization");
|
||||
}
|
||||
return {
|
||||
organizationId: organizationId || null,
|
||||
...(organizationId
|
||||
? {
|
||||
teams: {
|
||||
// Create membership
|
||||
create: [
|
||||
{
|
||||
team: {
|
||||
connect: {
|
||||
id: organizationId,
|
||||
},
|
||||
},
|
||||
accepted: true,
|
||||
role: MembershipRole.ADMIN,
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
: null),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
async function confirmPendingPayment(page: Page) {
|
||||
|
|
|
@ -9,6 +9,7 @@ import prisma from "@calcom/prisma";
|
|||
import type { ExpectedUrlDetails } from "../../../../playwright.config";
|
||||
import { createBookingsFixture } from "../fixtures/bookings";
|
||||
import { createEmbedsFixture } from "../fixtures/embeds";
|
||||
import { createOrgsFixture } from "../fixtures/orgs";
|
||||
import { createPaymentsFixture } from "../fixtures/payments";
|
||||
import { createBookingPageFixture } from "../fixtures/regularBookings";
|
||||
import { createRoutingFormsFixture } from "../fixtures/routingForms";
|
||||
|
@ -17,6 +18,7 @@ import { createUsersFixture } from "../fixtures/users";
|
|||
|
||||
export interface Fixtures {
|
||||
page: Page;
|
||||
orgs: ReturnType<typeof createOrgsFixture>;
|
||||
users: ReturnType<typeof createUsersFixture>;
|
||||
bookings: ReturnType<typeof createBookingsFixture>;
|
||||
payments: ReturnType<typeof createPaymentsFixture>;
|
||||
|
@ -48,6 +50,10 @@ declare global {
|
|||
* @see https://playwright.dev/docs/test-fixtures
|
||||
*/
|
||||
export const test = base.extend<Fixtures>({
|
||||
orgs: async ({ page }, use) => {
|
||||
const orgsFixture = createOrgsFixture(page);
|
||||
await use(orgsFixture);
|
||||
},
|
||||
users: async ({ page, context, emails }, use, workerInfo) => {
|
||||
const usersFixture = createUsersFixture(page, emails, workerInfo);
|
||||
await use(usersFixture);
|
||||
|
|
|
@ -1,16 +1,16 @@
|
|||
import type { Page } from "@playwright/test";
|
||||
import { expect } from "@playwright/test";
|
||||
|
||||
import { prisma } from "@calcom/prisma";
|
||||
import { SchedulingType } from "@calcom/prisma/enums";
|
||||
import { MembershipRole, SchedulingType } from "@calcom/prisma/enums";
|
||||
|
||||
import { test } from "./lib/fixtures";
|
||||
import { bookTimeSlot, selectFirstAvailableTimeSlotNextMonth, testName, todo } from "./lib/testUtils";
|
||||
|
||||
test.describe.configure({ mode: "parallel" });
|
||||
|
||||
test.afterEach(({ users }) => users.deleteAll());
|
||||
|
||||
test.describe("Teams", () => {
|
||||
test.describe("Teams - NonOrg", () => {
|
||||
test.afterEach(({ users }) => users.deleteAll());
|
||||
test("Can create teams via Wizard", async ({ page, users }) => {
|
||||
const user = await users.create();
|
||||
const inviteeEmail = `${user.username}+invitee@example.com`;
|
||||
|
@ -64,6 +64,7 @@ test.describe("Teams", () => {
|
|||
// await expect(page.locator('[data-testid="empty-screen"]')).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test("Can create a booking for Collective EventType", async ({ page, users }) => {
|
||||
const ownerObj = { username: "pro-user", name: "pro-user" };
|
||||
const teamMatesObj = [
|
||||
|
@ -78,7 +79,7 @@ test.describe("Teams", () => {
|
|||
teammates: teamMatesObj,
|
||||
schedulingType: SchedulingType.COLLECTIVE,
|
||||
});
|
||||
const { team } = await owner.getTeam();
|
||||
const { team } = await owner.getFirstTeam();
|
||||
const { title: teamEventTitle, slug: teamEventSlug } = await owner.getFirstTeamEvent(team.id);
|
||||
|
||||
await page.goto(`/team/${team.slug}/${teamEventSlug}`);
|
||||
|
@ -99,6 +100,7 @@ test.describe("Teams", () => {
|
|||
|
||||
// TODO: Assert whether the user received an email
|
||||
});
|
||||
|
||||
test("Can create a booking for Round Robin EventType", async ({ page, users }) => {
|
||||
const ownerObj = { username: "pro-user", name: "pro-user" };
|
||||
const teamMatesObj = [
|
||||
|
@ -113,7 +115,7 @@ test.describe("Teams", () => {
|
|||
schedulingType: SchedulingType.ROUND_ROBIN,
|
||||
});
|
||||
|
||||
const { team } = await owner.getTeam();
|
||||
const { team } = await owner.getFirstTeam();
|
||||
const { title: teamEventTitle, slug: teamEventSlug } = await owner.getFirstTeamEvent(team.id);
|
||||
|
||||
await page.goto(`/team/${team.slug}/${teamEventSlug}`);
|
||||
|
@ -135,6 +137,7 @@ test.describe("Teams", () => {
|
|||
expect(teamMatesObj.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";
|
||||
|
||||
|
@ -169,6 +172,7 @@ test.describe("Teams", () => {
|
|||
await prisma.team.delete({ where: { id: org.teamId } });
|
||||
}
|
||||
});
|
||||
|
||||
test("Can create team with same name as user", async ({ page, users }) => {
|
||||
// Name to be used for both user and team
|
||||
const uniqueName = "test-unique-name";
|
||||
|
@ -210,6 +214,7 @@ test.describe("Teams", () => {
|
|||
await prisma.team.delete({ where: { id: team?.id } });
|
||||
});
|
||||
});
|
||||
|
||||
test("Can create a private team", async ({ page, users }) => {
|
||||
const ownerObj = { username: "pro-user", name: "pro-user" };
|
||||
const teamMatesObj = [
|
||||
|
@ -226,7 +231,7 @@ test.describe("Teams", () => {
|
|||
});
|
||||
|
||||
await owner.apiLogin();
|
||||
const { team } = await owner.getTeam();
|
||||
const { team } = await owner.getFirstTeam();
|
||||
|
||||
// Mark team as private
|
||||
await page.goto(`/settings/teams/${team.id}/members`);
|
||||
|
@ -247,3 +252,180 @@ test.describe("Teams", () => {
|
|||
todo("Reschedule a Collective EventType booking");
|
||||
todo("Reschedule a Round Robin EventType booking");
|
||||
});
|
||||
|
||||
test.describe("Teams - Org", () => {
|
||||
test.afterEach(({ orgs, users }) => {
|
||||
orgs.deleteAll();
|
||||
users.deleteAll();
|
||||
});
|
||||
|
||||
test("Can create teams via Wizard", async ({ page, users, orgs }) => {
|
||||
const org = await orgs.create({
|
||||
name: "TestOrg",
|
||||
});
|
||||
const user = await users.create({
|
||||
organizationId: org.id,
|
||||
roleInOrganization: MembershipRole.ADMIN,
|
||||
});
|
||||
const inviteeEmail = `${user.username}+invitee@example.com`;
|
||||
await user.apiLogin();
|
||||
await page.goto("/teams");
|
||||
|
||||
await test.step("Can create team", async () => {
|
||||
// Click text=Create Team
|
||||
await page.locator("text=Create a new Team").click();
|
||||
await page.waitForURL((url) => url.pathname === "/settings/teams/new");
|
||||
// Fill input[name="name"]
|
||||
await page.locator('input[name="name"]').fill(`${user.username}'s Team`);
|
||||
// Click text=Continue
|
||||
await page.locator("text=Continue").click();
|
||||
await page.waitForURL(/\/settings\/teams\/(\d+)\/onboard-members$/i);
|
||||
await page.waitForSelector('[data-testid="pending-member-list"]');
|
||||
expect(await page.locator('[data-testid="pending-member-item"]').count()).toBe(1);
|
||||
});
|
||||
|
||||
await test.step("Can add members", async () => {
|
||||
// Click [data-testid="new-member-button"]
|
||||
await page.locator('[data-testid="new-member-button"]').click();
|
||||
// Fill [placeholder="email\@example\.com"]
|
||||
await page.locator('[placeholder="email\\@example\\.com"]').fill(inviteeEmail);
|
||||
// Click [data-testid="invite-new-member-button"]
|
||||
await page.locator('[data-testid="invite-new-member-button"]').click();
|
||||
await expect(page.locator(`li:has-text("${inviteeEmail}")`)).toBeVisible();
|
||||
expect(await page.locator('[data-testid="pending-member-item"]').count()).toBe(2);
|
||||
});
|
||||
|
||||
await test.step("Can remove members", async () => {
|
||||
expect(await page.locator('[data-testid="pending-member-item"]').count()).toBe(2);
|
||||
|
||||
const lastRemoveMemberButton = page.locator('[data-testid="remove-member-button"]').last();
|
||||
await lastRemoveMemberButton.click();
|
||||
await page.waitForLoadState("networkidle");
|
||||
expect(await page.locator('[data-testid="pending-member-item"]').count()).toBe(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.locator("text=Finish").click();
|
||||
await page.waitForURL("/settings/teams");
|
||||
});
|
||||
|
||||
await test.step("Can disband team", async () => {
|
||||
await page.locator('[data-testid="team-list-item-link"]').click();
|
||||
await page.waitForURL(/\/settings\/teams\/(\d+)\/profile$/i);
|
||||
await page.locator("text=Disband Team").click();
|
||||
await page.locator("text=Yes, disband team").click();
|
||||
await page.waitForURL("/teams");
|
||||
expect(await page.locator(`text=${user.username}'s Team`).count()).toEqual(0);
|
||||
});
|
||||
});
|
||||
|
||||
test("Can create a booking for Collective EventType", async ({ page, users, orgs }) => {
|
||||
const org = await orgs.create({
|
||||
name: "TestOrg",
|
||||
});
|
||||
const teamMatesObj = [
|
||||
{ name: "teammate-1" },
|
||||
{ name: "teammate-2" },
|
||||
{ name: "teammate-3" },
|
||||
{ name: "teammate-4" },
|
||||
];
|
||||
|
||||
const owner = await users.create(
|
||||
{
|
||||
username: "pro-user",
|
||||
name: "pro-user",
|
||||
organizationId: org.id,
|
||||
roleInOrganization: MembershipRole.MEMBER,
|
||||
},
|
||||
{
|
||||
hasTeam: true,
|
||||
teammates: teamMatesObj,
|
||||
schedulingType: SchedulingType.COLLECTIVE,
|
||||
}
|
||||
);
|
||||
const { team } = await owner.getFirstTeam();
|
||||
const { title: teamEventTitle, slug: teamEventSlug } = await owner.getFirstTeamEvent(team.id);
|
||||
|
||||
await page.goto(`/team/${team.slug}/${teamEventSlug}`);
|
||||
|
||||
await expect(page.locator('[data-testid="404-page"]')).toBeVisible();
|
||||
await doOnOrgDomain(
|
||||
{
|
||||
orgSlug: org.slug,
|
||||
page,
|
||||
},
|
||||
async () => {
|
||||
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 ownerObj = { username: "pro-user", name: "pro-user" };
|
||||
const teamMatesObj = [
|
||||
{ name: "teammate-1" },
|
||||
{ name: "teammate-2" },
|
||||
{ name: "teammate-3" },
|
||||
{ name: "teammate-4" },
|
||||
];
|
||||
const owner = await users.create(ownerObj, {
|
||||
hasTeam: true,
|
||||
teammates: teamMatesObj,
|
||||
schedulingType: SchedulingType.ROUND_ROBIN,
|
||||
});
|
||||
|
||||
const { team } = await owner.getFirstTeam();
|
||||
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 = `${teamEventTitle} between ${team.name} and ${testName}`;
|
||||
await expect(page.locator("[data-testid=booking-title]")).toHaveText(BookingTitle);
|
||||
|
||||
// Since all the users have the same leastRecentlyBooked value
|
||||
// Anyone of the teammates could be the Host of the booking.
|
||||
const chosenUser = await page.getByTestId("booking-host-name").textContent();
|
||||
expect(chosenUser).not.toBeNull();
|
||||
expect(teamMatesObj.some(({ name }) => name === chosenUser)).toBe(true);
|
||||
// TODO: Assert whether the user received an email
|
||||
});
|
||||
});
|
||||
|
||||
async function doOnOrgDomain(
|
||||
{ orgSlug, page }: { orgSlug: string | null; page: Page },
|
||||
callback: ({ page }: { page: Page }) => Promise<void>
|
||||
) {
|
||||
if (!orgSlug) {
|
||||
throw new Error("orgSlug is not available");
|
||||
}
|
||||
page.setExtraHTTPHeaders({
|
||||
"x-cal-force-slug": orgSlug,
|
||||
});
|
||||
await callback({ page });
|
||||
}
|
||||
|
|
|
@ -18,7 +18,7 @@ test.afterAll(async ({ users }) => {
|
|||
test.describe("Unpublished", () => {
|
||||
test("Regular team profile", async ({ page, users }) => {
|
||||
const owner = await users.create(undefined, { hasTeam: true, isUnpublished: true });
|
||||
const { team } = await owner.getTeam();
|
||||
const { team } = await owner.getFirstTeam();
|
||||
const { requestedSlug } = team.metadata as { requestedSlug: string };
|
||||
await page.goto(`/team/${requestedSlug}`);
|
||||
expect(await page.locator('[data-testid="empty-screen"]').count()).toBe(1);
|
||||
|
@ -33,7 +33,7 @@ test.describe("Unpublished", () => {
|
|||
isUnpublished: true,
|
||||
schedulingType: SchedulingType.COLLECTIVE,
|
||||
});
|
||||
const { team } = await owner.getTeam();
|
||||
const { team } = await owner.getFirstTeam();
|
||||
const { requestedSlug } = team.metadata as { requestedSlug: string };
|
||||
const { slug: teamEventSlug } = await owner.getFirstTeamEvent(team.id);
|
||||
await page.goto(`/team/${requestedSlug}/${teamEventSlug}`);
|
||||
|
|
|
@ -54,7 +54,7 @@ export const getServerSideProps = async function getServerSideProps(
|
|||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { form: formId, slug: _slug, pages: _pages, ...fieldsResponses } = queryParsed.data;
|
||||
const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(context.req.headers.host ?? "");
|
||||
const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(context.req);
|
||||
|
||||
const form = await prisma.app_RoutingForms_Form.findFirst({
|
||||
where: {
|
||||
|
|
|
@ -248,7 +248,7 @@ export const getServerSideProps = async function getServerSideProps(
|
|||
notFound: true,
|
||||
};
|
||||
}
|
||||
const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(context.req.headers.host ?? "");
|
||||
const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(context.req);
|
||||
|
||||
const isEmbed = params.appPages[1] === "embed";
|
||||
|
||||
|
|
|
@ -1,16 +1,33 @@
|
|||
import type { Prisma } from "@prisma/client";
|
||||
import type { IncomingMessage } from "http";
|
||||
|
||||
import { ALLOWED_HOSTNAMES, RESERVED_SUBDOMAINS, WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import logger from "@calcom/lib/logger";
|
||||
import slugify from "@calcom/lib/slugify";
|
||||
|
||||
const log = logger.getSubLogger({
|
||||
prefix: ["orgDomains.ts"],
|
||||
});
|
||||
/**
|
||||
* return the org slug
|
||||
* @param hostname
|
||||
*/
|
||||
export function getOrgSlug(hostname: string) {
|
||||
export function getOrgSlug(hostname: string, forcedSlug?: string) {
|
||||
if (forcedSlug) {
|
||||
if (process.env.NEXT_PUBLIC_IS_E2E) {
|
||||
log.debug("Using provided forcedSlug in E2E", {
|
||||
forcedSlug,
|
||||
});
|
||||
return forcedSlug;
|
||||
}
|
||||
log.debug("Ignoring forcedSlug in non-test mode", {
|
||||
forcedSlug,
|
||||
});
|
||||
}
|
||||
|
||||
if (!hostname.includes(".")) {
|
||||
// A no-dot domain can never be org domain. It automatically handles localhost
|
||||
log.warn('Org support not enabled for hostname without "."', { hostname });
|
||||
// A no-dot domain can never be org domain. It automatically considers localhost to be non-org domain
|
||||
return null;
|
||||
}
|
||||
// Find which hostname is being currently used
|
||||
|
@ -19,24 +36,45 @@ export function getOrgSlug(hostname: string) {
|
|||
const testHostname = `${url.hostname}${url.port ? `:${url.port}` : ""}`;
|
||||
return testHostname.endsWith(`.${ahn}`);
|
||||
});
|
||||
logger.debug(`getOrgSlug: ${hostname} ${currentHostname}`, {
|
||||
ALLOWED_HOSTNAMES,
|
||||
WEBAPP_URL,
|
||||
currentHostname,
|
||||
hostname,
|
||||
});
|
||||
if (currentHostname) {
|
||||
|
||||
if (!currentHostname) {
|
||||
log.warn("Match of WEBAPP_URL with ALLOWED_HOSTNAME failed", { WEBAPP_URL, ALLOWED_HOSTNAMES });
|
||||
return null;
|
||||
}
|
||||
// Define which is the current domain/subdomain
|
||||
const slug = hostname.replace(`.${currentHostname}` ?? "", "");
|
||||
return slug.indexOf(".") === -1 ? slug : null;
|
||||
const hasNoDotInSlug = slug.indexOf(".") === -1;
|
||||
if (hasNoDotInSlug) {
|
||||
return slug;
|
||||
}
|
||||
log.warn("Derived slug ended up having dots, so not considering it an org domain", { slug });
|
||||
return null;
|
||||
}
|
||||
|
||||
export function orgDomainConfig(hostname: string, fallback?: string | string[]) {
|
||||
const currentOrgDomain = getOrgSlug(hostname);
|
||||
export function orgDomainConfig(req: IncomingMessage | undefined, fallback?: string | string[]) {
|
||||
const forcedSlugHeader = req?.headers?.["x-cal-force-slug"];
|
||||
|
||||
const forcedSlug = forcedSlugHeader instanceof Array ? forcedSlugHeader[0] : forcedSlugHeader;
|
||||
|
||||
const hostname = req?.headers?.host || "";
|
||||
return getOrgDomainConfigFromHostname({
|
||||
hostname,
|
||||
fallback,
|
||||
forcedSlug,
|
||||
});
|
||||
}
|
||||
|
||||
export function getOrgDomainConfigFromHostname({
|
||||
hostname,
|
||||
fallback,
|
||||
forcedSlug,
|
||||
}: {
|
||||
hostname: string;
|
||||
fallback?: string | string[];
|
||||
forcedSlug?: string;
|
||||
}) {
|
||||
const currentOrgDomain = getOrgSlug(hostname, forcedSlug);
|
||||
const isValidOrgDomain = currentOrgDomain !== null && !RESERVED_SUBDOMAINS.includes(currentOrgDomain);
|
||||
logger.debug(`orgDomainConfig: ${hostname} ${currentOrgDomain} ${isValidOrgDomain}`);
|
||||
if (isValidOrgDomain || !fallback) {
|
||||
return {
|
||||
currentOrgDomain: isValidOrgDomain ? currentOrgDomain : null,
|
||||
|
@ -100,6 +138,6 @@ export function whereClauseForOrgWithSlugOrRequestedSlug(slug: string) {
|
|||
}
|
||||
|
||||
export function userOrgQuery(hostname: string, fallback?: string | string[]) {
|
||||
const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(hostname, fallback);
|
||||
const { currentOrgDomain, isValidOrgDomain } = getOrgDomainConfigFromHostname({ hostname, fallback });
|
||||
return isValidOrgDomain && currentOrgDomain ? getSlugOrRequestedSlug(currentOrgDomain) : null;
|
||||
}
|
||||
|
|
|
@ -183,6 +183,7 @@ export default function TeamListItem(props: Props) {
|
|||
<div className={classNames("flex items-center justify-between", !isInvitee && "hover:bg-muted group")}>
|
||||
{!isInvitee ? (
|
||||
<Link
|
||||
data-testid="team-list-item-link"
|
||||
href={`/settings/teams/${team.id}/profile`}
|
||||
className="flex-grow cursor-pointer truncate text-sm"
|
||||
title={`${team.name}`}>
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { orgDomainConfig, getOrgSlug } from "@calcom/features/ee/organizations/lib/orgDomains";
|
||||
import { getOrgSlug, getOrgDomainConfigFromHostname } from "@calcom/features/ee/organizations/lib/orgDomains";
|
||||
import * as constants from "@calcom/lib/constants";
|
||||
|
||||
function setupEnvs({ WEBAPP_URL = "https://app.cal.com" } = {}) {
|
||||
|
@ -35,10 +35,10 @@ function setupEnvs({ WEBAPP_URL = "https://app.cal.com" } = {}) {
|
|||
}
|
||||
|
||||
describe("Org Domains Utils", () => {
|
||||
describe("orgDomainConfig", () => {
|
||||
describe("getOrgDomainConfigFromHostname", () => {
|
||||
it("should return a valid org domain", () => {
|
||||
setupEnvs();
|
||||
expect(orgDomainConfig("acme.cal.com")).toEqual({
|
||||
expect(getOrgDomainConfigFromHostname({ hostname: "acme.cal.com" })).toEqual({
|
||||
currentOrgDomain: "acme",
|
||||
isValidOrgDomain: true,
|
||||
});
|
||||
|
@ -46,7 +46,7 @@ describe("Org Domains Utils", () => {
|
|||
|
||||
it("should return a non valid org domain", () => {
|
||||
setupEnvs();
|
||||
expect(orgDomainConfig("app.cal.com")).toEqual({
|
||||
expect(getOrgDomainConfigFromHostname({ hostname: "app.cal.com" })).toEqual({
|
||||
currentOrgDomain: null,
|
||||
isValidOrgDomain: false,
|
||||
});
|
||||
|
@ -54,7 +54,7 @@ describe("Org Domains Utils", () => {
|
|||
|
||||
it("should return a non valid org domain for localhost", () => {
|
||||
setupEnvs();
|
||||
expect(orgDomainConfig("localhost:3000")).toEqual({
|
||||
expect(getOrgDomainConfigFromHostname({ hostname: "localhost:3000" })).toEqual({
|
||||
currentOrgDomain: null,
|
||||
isValidOrgDomain: false,
|
||||
});
|
||||
|
|
|
@ -266,7 +266,7 @@ export function getRegularOrDynamicEventType(
|
|||
}
|
||||
|
||||
export async function getAvailableSlots({ input, ctx }: GetScheduleOptions) {
|
||||
const orgDetails = orgDomainConfig(ctx?.req?.headers.host ?? "");
|
||||
const orgDetails = orgDomainConfig(ctx?.req);
|
||||
if (process.env.INTEGRATION_TEST_MODE === "true") {
|
||||
logger.settings.minLevel = 2;
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue