From 5f5cf924563294d4e52cf274f51bdaec51e72063 Mon Sep 17 00:00:00 2001 From: Hariom Balhara Date: Thu, 23 May 2024 16:17:46 +0530 Subject: [PATCH] fix: Use origin as provided in HeadSeo. origin would be different for Domain cases (#14935) * fix: Use origin as provided in HeadSeo. origin would be different for Domain cases * Add a test * add more test * Add tests * Cleanup tests * Add an e2e as well * fix-test * Reset mocks --- ...ookings-single-view.getServerSideProps.tsx | 3 + .../views/bookings-single-view.test.tsx | 142 +++++++++++++ .../bookings/views/bookings-single-view.tsx | 15 +- apps/web/modules/test-setup.ts | 186 ++++++++++++++++++ .../users/views/users-public-view.test.tsx | 102 ++++++++++ .../modules/users/views/users-public-view.tsx | 3 +- apps/web/pages/team/[slug].tsx | 2 + apps/web/playwright/booking-pages.e2e.ts | 5 + .../playwright/organization/booking.e2e.ts | 62 ++++++ .../bookings/components/BookerSeo.test.tsx | 94 +++++++++ .../bookings/components/BookerSeo.tsx | 2 + .../ee/organizations/lib/orgDomains.ts | 3 +- packages/features/test/orgDomains.test.ts | 27 ++- packages/ui/components/head-seo/HeadSeo.tsx | 4 +- .../ui/components/head-seo/head-seo.test.tsx | 98 +++++++-- vitest.workspace.ts | 9 + 16 files changed, 730 insertions(+), 27 deletions(-) create mode 100644 apps/web/modules/bookings/views/bookings-single-view.test.tsx create mode 100644 apps/web/modules/test-setup.ts create mode 100644 apps/web/modules/users/views/users-public-view.test.tsx create mode 100644 packages/features/bookings/components/BookerSeo.test.tsx diff --git a/apps/web/modules/bookings/views/bookings-single-view.getServerSideProps.tsx b/apps/web/modules/bookings/views/bookings-single-view.getServerSideProps.tsx index eb3430591a..3927de97a6 100644 --- a/apps/web/modules/bookings/views/bookings-single-view.getServerSideProps.tsx +++ b/apps/web/modules/bookings/views/bookings-single-view.getServerSideProps.tsx @@ -1,6 +1,7 @@ import type { GetServerSidePropsContext } from "next"; import { z } from "zod"; +import { orgDomainConfig } from "@calcom/ee/organizations/lib/orgDomains"; import { getServerSession } from "@calcom/features/auth/lib/getServerSession"; import getBookingInfo from "@calcom/features/bookings/lib/getBookingInfo"; import { parseRecurringEvent } from "@calcom/lib"; @@ -159,8 +160,10 @@ export async function getServerSideProps(context: GetServerSidePropsContext) { } } + const { currentOrgDomain } = orgDomainConfig(context.req); return { props: { + orgSlug: currentOrgDomain, themeBasis: eventType.team ? eventType.team.slug : eventType.users[0]?.username, hideBranding: eventType.team ? eventType.team.hideBranding : eventType.users[0].hideBranding, profile, diff --git a/apps/web/modules/bookings/views/bookings-single-view.test.tsx b/apps/web/modules/bookings/views/bookings-single-view.test.tsx new file mode 100644 index 0000000000..484c565dc2 --- /dev/null +++ b/apps/web/modules/bookings/views/bookings-single-view.test.tsx @@ -0,0 +1,142 @@ +import { render } from "@testing-library/react"; +import { useSession } from "next-auth/react"; +import React from "react"; +import { describe, it, expect, vi } from "vitest"; +import type { z } from "zod"; + +import { getOrgFullOrigin } from "@calcom/features/ee/organizations/lib/orgDomains"; +import { useRouterQuery } from "@calcom/lib/hooks/useRouterQuery"; +import { BookingStatus } from "@calcom/prisma/enums"; +import { HeadSeo } from "@calcom/ui"; + +import Success from "./bookings-single-view"; + +function mockedSuccessComponentProps(props: Partial>) { + return { + eventType: { + id: 1, + title: "Event Title", + description: "", + locations: null, + length: 15, + userId: null, + eventName: "d", + timeZone: null, + recurringEvent: null, + requiresConfirmation: false, + disableGuests: false, + seatsPerTimeSlot: null, + seatsShowAttendees: null, + seatsShowAvailabilityCount: null, + schedulingType: null, + price: 0, + currency: "usd", + successRedirectUrl: null, + customInputs: [], + team: null, + workflows: [], + hosts: [], + users: [], + owner: null, + isDynamic: false, + periodStartDate: "1", + periodEndDate: "1", + metadata: null, + bookingFields: [] as unknown as [] & z.BRAND<"HAS_SYSTEM_FIELDS">, + }, + profile: { + name: "John", + email: null, + theme: null, + brandColor: null, + darkBrandColor: null, + slug: null, + }, + bookingInfo: { + uid: "uid", + metadata: null, + customInputs: [], + startTime: new Date(), + endTime: new Date(), + id: 1, + user: null, + eventType: null, + seatsReferences: [], + userPrimaryEmail: null, + eventTypeId: null, + title: "Booking Title", + description: null, + location: null, + recurringEventId: null, + smsReminderNumber: "0", + cancellationReason: null, + rejectionReason: null, + status: BookingStatus.ACCEPTED, + attendees: [], + responses: { + name: "John", + }, + }, + orgSlug: null, + userTimeFormat: 12, + requiresLoginToUpdate: false, + themeBasis: "dark", + hideBranding: false, + recurringBookings: null, + trpcState: { + queries: [], + mutations: [], + }, + dynamicEventName: "Event Title", + paymentStatus: null, + ...props, + } satisfies React.ComponentProps; +} + +describe("Success Component", () => { + it("renders HeadSeo correctly", () => { + vi.mocked(getOrgFullOrigin).mockImplementation((text: string | null) => `${text}.cal.local`); + vi.mocked(useRouterQuery).mockReturnValue({ + uid: "uid", + }); + vi.mocked(useSession).mockReturnValue({ + update: vi.fn(), + status: "authenticated", + data: { + hasValidLicense: true, + upId: "1", + expires: "1", + user: { + name: "John", + id: 1, + profile: { + id: null, + upId: "1", + username: null, + organizationId: null, + organization: null, + }, + }, + }, + }); + + const mockObject = { + props: mockedSuccessComponentProps({ + orgSlug: "org1", + }), + }; + + render(); + + const expectedTitle = `booking_confirmed`; + const expectedDescription = expectedTitle; + expect(HeadSeo).toHaveBeenCalledWith( + { + origin: `${mockObject.props.orgSlug}.cal.local`, + title: expectedTitle, + description: expectedDescription, + }, + {} + ); + }); +}); diff --git a/apps/web/modules/bookings/views/bookings-single-view.tsx b/apps/web/modules/bookings/views/bookings-single-view.tsx index 68e2ca8739..ca841004d7 100644 --- a/apps/web/modules/bookings/views/bookings-single-view.tsx +++ b/apps/web/modules/bookings/views/bookings-single-view.tsx @@ -19,6 +19,7 @@ import type { nameObjectSchema } from "@calcom/core/event"; import { getEventName } from "@calcom/core/event"; import type { ConfigType } from "@calcom/dayjs"; import dayjs from "@calcom/dayjs"; +import { getOrgFullOrigin } from "@calcom/ee/organizations/lib/orgDomains"; import { useEmbedNonStylesConfig, useIsBackgroundTransparent, @@ -60,12 +61,10 @@ import { EmptyScreen, Icon, } from "@calcom/ui"; - -import { timeZone } from "@lib/clock"; - -import PageWrapper from "@components/PageWrapper"; -import CancelBooking from "@components/booking/CancelBooking"; -import EventReservationSchema from "@components/schemas/EventReservationSchema"; +import PageWrapper from "@calcom/web/components/PageWrapper"; +import CancelBooking from "@calcom/web/components/booking/CancelBooking"; +import EventReservationSchema from "@calcom/web/components/schemas/EventReservationSchema"; +import { timeZone } from "@calcom/web/lib/clock"; import type { PageProps } from "./bookings-single-view.getServerSideProps"; @@ -109,7 +108,7 @@ export default function Success(props: PageProps) { const routerQuery = useRouterQuery(); const pathname = usePathname(); const searchParams = useCompatSearchParams(); - const { eventType, bookingInfo, requiresLoginToUpdate } = props; + const { eventType, bookingInfo, requiresLoginToUpdate, orgSlug } = props; const { allRemainingBookings, @@ -377,7 +376,7 @@ export default function Success(props: PageProps) { )} - +
diff --git a/apps/web/modules/test-setup.ts b/apps/web/modules/test-setup.ts new file mode 100644 index 0000000000..51ee523554 --- /dev/null +++ b/apps/web/modules/test-setup.ts @@ -0,0 +1,186 @@ +import React from "react"; +import { vi, afterEach } from "vitest"; + +global.React = React; + +afterEach(() => { + vi.resetAllMocks(); +}); + +// Mock all modules that are used in multiple tests for modules +// We don't intend to provide the mock implementation here. They should be provided by respective tests. +// But it makes it super easy to start testing any module view without worrying about mocking the dependencies. +vi.mock("next-auth/react", () => ({ + useSession: vi.fn(), +})); + +vi.mock("next/navigation", () => ({ + useRouter: vi.fn().mockReturnValue({ + replace: vi.fn(), + }), + usePathname: vi.fn(), +})); + +vi.mock("@calcom/app-store/BookingPageTagManager", () => ({ + default: vi.fn(), +})); + +vi.mock("@calcom/app-store/locations", () => ({ + DailyLocationType: "daily", + guessEventLocationType: vi.fn(), + getSuccessPageLocationMessage: vi.fn(), +})); + +vi.mock("@calcom/app-store/utils", () => ({ + getEventTypeAppData: vi.fn(), +})); + +vi.mock("@calcom/core/event", () => ({ + getEventName: vi.fn(), +})); + +vi.mock("@calcom/ee/organizations/lib/orgDomains", () => ({ + getOrgFullOrigin: vi.fn(), +})); + +vi.mock("@calcom/features/eventtypes/components", () => ({ + EventTypeDescriptionLazy: vi.fn(), +})); + +vi.mock("@calcom/embed-core/embed-iframe", () => { + return { + useIsBackgroundTransparent: vi.fn(), + useIsEmbed: vi.fn(), + useEmbedNonStylesConfig: vi.fn(), + useEmbedStyles: vi.fn(), + }; +}); + +vi.mock("@calcom/features/bookings/components/event-meta/Price", () => { + return {}; +}); + +vi.mock("@calcom/features/bookings/lib/SystemField", () => { + return {}; +}); + +vi.mock("@calcom/lib/constants", () => { + return { + DEFAULT_LIGHT_BRAND_COLOR: "DEFAULT_LIGHT_BRAND_COLOR", + DEFAULT_DARK_BRAND_COLOR: "DEFAULT_DARK_BRAND_COLOR", + BOOKER_NUMBER_OF_DAYS_TO_LOAD: 1, + }; +}); + +vi.mock("@calcom/lib/date-fns", () => { + return {}; +}); + +vi.mock("@calcom/lib/getBrandColours", () => { + return { + default: vi.fn(), + }; +}); + +vi.mock("@calcom/lib/hooks/useCompatSearchParams", () => { + return { + useCompatSearchParams: vi.fn(), + }; +}); + +vi.mock("@calcom/lib/hooks/useLocale", () => { + return { + useLocale: vi.fn().mockReturnValue({ + t: vi.fn().mockImplementation((text: string) => { + return text; + }), + i18n: { + language: "en", + }, + }), + }; +}); + +vi.mock("@calcom/lib/hooks/useRouterQuery", () => { + return { + useRouterQuery: vi.fn(), + }; +}); + +vi.mock("@calcom/lib/hooks/useTheme", () => { + return { + default: vi.fn(), + }; +}); + +vi.mock("@calcom/lib/recurringStrings", () => { + return {}; +}); + +vi.mock("@calcom/lib/recurringStrings", () => { + return {}; +}); + +vi.mock("@calcom/prisma/zod-utils", () => ({ + BookerLayouts: { + MONTH_VIEW: "month", + }, + EventTypeMetaDataSchema: { + parse: vi.fn(), + }, + bookingMetadataSchema: { + parse: vi.fn(), + }, +})); + +vi.mock("@calcom/trpc/react", () => ({ + trpc: { + viewer: { + public: { + submitRating: { + useMutation: vi.fn(), + }, + noShow: { + useMutation: vi.fn(), + }, + }, + }, + }, +})); + +vi.mock("@calcom/ui", () => ({ + HeadSeo: vi.fn(), + useCalcomTheme: vi.fn(), + Icon: vi.fn(), + UnpublishedEntity: vi.fn(), + UserAvatar: vi.fn(), +})); + +vi.mock("@calcom/web/components/PageWrapper", () => ({ + default: vi.fn(), +})); + +vi.mock("@calcom/web/components/booking/CancelBooking", () => ({})); + +vi.mock("@calcom/web/components/schemas/EventReservationSchema", () => ({ + default: vi.fn(), +})); + +vi.mock("@calcom/web/lib/clock", () => ({ + timeZone: vi.fn(), +})); + +vi.mock("./bookings-single-view.getServerSideProps", () => ({})); + +vi.mock("@calcom/lib/webstorage", () => ({ + localStorage: { + getItem: vi.fn(), + setItem: vi.fn(), + }, +})); + +vi.mock("@calcom/lib/timeFormat", () => ({ + detectBrowserTimeFormat: vi.fn(), + isBrowserLocale24h: vi.fn(), + getIs24hClockFromLocalStorage: vi.fn(), +})); diff --git a/apps/web/modules/users/views/users-public-view.test.tsx b/apps/web/modules/users/views/users-public-view.test.tsx new file mode 100644 index 0000000000..9e6c3ff6f9 --- /dev/null +++ b/apps/web/modules/users/views/users-public-view.test.tsx @@ -0,0 +1,102 @@ +import { render } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +import { getOrgFullOrigin } from "@calcom/ee/organizations/lib/orgDomains"; +import { useRouterQuery } from "@calcom/lib/hooks/useRouterQuery"; +import { HeadSeo } from "@calcom/ui"; + +import UserPage from "./users-public-view"; + +function mockedUserPageComponentProps(props: Partial>) { + return { + trpcState: { + mutations: [], + queries: [], + }, + themeBasis: "dark", + safeBio: "My Bio", + profile: { + name: "John Doe", + image: "john-profile-url", + theme: "dark", + brandColor: "red", + darkBrandColor: "black", + organization: { requestedSlug: "slug", slug: "slug", id: 1 }, + allowSEOIndexing: true, + username: "john", + }, + users: [ + { + name: "John Doe", + username: "john", + avatarUrl: "john-user-url", + bio: "", + verified: false, + profile: { + upId: "1", + id: 1, + username: "john", + organizationId: null, + organization: null, + }, + }, + ], + markdownStrippedBio: "My Bio", + entity: { + considerUnpublished: false, + ...(props.entity ?? null), + }, + eventTypes: [], + } satisfies React.ComponentProps; +} + +describe("UserPage Component", () => { + it("should render HeadSeo with correct props", () => { + const mockData = { + props: mockedUserPageComponentProps({ + entity: { + considerUnpublished: false, + orgSlug: "org1", + }, + }), + }; + + vi.mocked(getOrgFullOrigin).mockImplementation((orgSlug: string | null) => { + return `${orgSlug}.cal.local`; + }); + + vi.mocked(useRouterQuery).mockReturnValue({ + uid: "uid", + }); + + render(); + + const expectedDescription = mockData.props.markdownStrippedBio; + const expectedTitle = expectedDescription; + expect(HeadSeo).toHaveBeenCalledWith( + { + origin: `${mockData.props.entity.orgSlug}.cal.local`, + title: `${mockData.props.profile.name}`, + description: expectedDescription, + meeting: { + profile: { + name: mockData.props.profile.name, + image: mockData.props.users[0].avatarUrl, + }, + title: expectedTitle, + users: [ + { + name: mockData.props.users[0].name, + username: mockData.props.users[0].username, + }, + ], + }, + nextSeoProps: { + nofollow: !mockData.props.profile.allowSEOIndexing, + noindex: !mockData.props.profile.allowSEOIndexing, + }, + }, + {} + ); + }); +}); diff --git a/apps/web/modules/users/views/users-public-view.tsx b/apps/web/modules/users/views/users-public-view.tsx index acc329eb25..91a50a9c85 100644 --- a/apps/web/modules/users/views/users-public-view.tsx +++ b/apps/web/modules/users/views/users-public-view.tsx @@ -11,6 +11,7 @@ import { useEmbedStyles, useIsEmbed, } from "@calcom/embed-core/embed-iframe"; +import { getOrgFullOrigin } from "@calcom/features/ee/organizations/lib/orgDomains"; import { EventTypeDescriptionLazy as EventTypeDescription } from "@calcom/features/eventtypes/components"; import EmptyPage from "@calcom/features/eventtypes/components/EmptyPage"; import { useLocale } from "@calcom/lib/hooks/useLocale"; @@ -59,10 +60,10 @@ export function UserPage(props: InferGetServerSidePropsType { 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(`${WEBAPP_URL}/${user.username}/30-min`); + expect(canonicalLink).toEqual(`${WEBAPP_URL}/${user.username}/30-min`); // 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" diff --git a/apps/web/playwright/organization/booking.e2e.ts b/apps/web/playwright/organization/booking.e2e.ts index f9e4bab934..3ee64a4a7d 100644 --- a/apps/web/playwright/organization/booking.e2e.ts +++ b/apps/web/playwright/organization/booking.e2e.ts @@ -1,7 +1,9 @@ import type { Page } from "@playwright/test"; import { expect } from "@playwright/test"; +import { JSDOM } from "jsdom"; 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"; @@ -16,6 +18,16 @@ 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(({ orgs, users }) => { orgs.deleteAll(); @@ -187,6 +199,7 @@ test.describe("Bookings", () => { } ); }); + 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({ @@ -251,6 +264,55 @@ test.describe("Bookings", () => { ); }); }); + + 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", () => { diff --git a/packages/features/bookings/components/BookerSeo.test.tsx b/packages/features/bookings/components/BookerSeo.test.tsx new file mode 100644 index 0000000000..37581cbad8 --- /dev/null +++ b/packages/features/bookings/components/BookerSeo.test.tsx @@ -0,0 +1,94 @@ +import { render } from "@testing-library/react"; +import React from "react"; +import { describe, it, expect, vi } from "vitest"; + +import { getOrgFullOrigin } from "@calcom/ee/organizations/lib/orgDomains"; +import { trpc } from "@calcom/trpc/react"; +import { HeadSeo } from "@calcom/ui"; + +import { BookerSeo } from "./BookerSeo"; + +// Mocking necessary modules and hooks +vi.mock("@calcom/trpc/react", () => ({ + trpc: { + viewer: { + public: { + event: { + useQuery: vi.fn(), + }, + }, + }, + }, +})); + +vi.mock("@calcom/lib/hooks/useLocale", () => ({ + useLocale: () => ({ t: (key: string) => key }), +})); + +vi.mock("@calcom/ee/organizations/lib/orgDomains", () => ({ + getOrgFullOrigin: vi.fn(), +})); + +vi.mock("@calcom/ui", () => ({ + HeadSeo: vi.fn(), +})); + +describe("BookerSeo Component", () => { + it("renders HeadSeo with correct props", () => { + const mockData = { + event: { + slug: "event-slug", + profile: { name: "John Doe", image: "image-url", username: "john" }, + title: "30min", + hidden: false, + users: [{ name: "Jane Doe", username: "jane" }], + }, + entity: { fromRedirectOfNonOrgLink: false, orgSlug: "org1" }, + }; + + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + trpc.viewer.public.event.useQuery.mockReturnValueOnce({ + data: mockData.event, + }); + + vi.mocked(getOrgFullOrigin).mockImplementation((text: string | null) => `${text}.cal.local`); + + render( + + ); + + expect(HeadSeo).toHaveBeenCalledWith( + { + origin: `${mockData.entity.orgSlug}.cal.local`, + isBrandingHidden: undefined, + // Don't know why we are adding space in the beginning + title: ` ${mockData.event.title} | ${mockData.event.profile.name}`, + description: ` ${mockData.event.title}`, + meeting: { + profile: { + name: mockData.event.profile.name, + image: mockData.event.profile.image, + }, + title: mockData.event.title, + users: [ + { + name: mockData.event.users[0].name, + username: mockData.event.users[0].username, + }, + ], + }, + nextSeoProps: { + nofollow: true, + noindex: true, + }, + }, + {} + ); + }); +}); diff --git a/packages/features/bookings/components/BookerSeo.tsx b/packages/features/bookings/components/BookerSeo.tsx index ba14d550c4..fed2b24e94 100644 --- a/packages/features/bookings/components/BookerSeo.tsx +++ b/packages/features/bookings/components/BookerSeo.tsx @@ -1,3 +1,4 @@ +import { getOrgFullOrigin } from "@calcom/ee/organizations/lib/orgDomains"; import type { GetBookingType } from "@calcom/features/bookings/lib/get-booking"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import { trpc } from "@calcom/trpc/react"; @@ -47,6 +48,7 @@ export const BookerSeo = (props: BookerSeoProps) => { const title = event?.title ?? ""; return ( { expect(getOrgSlug("acme.cal.com:3000")).toEqual(null); }); }); + + describe("getOrgFullOrigin", () => { + it("should return the regular(non-org) origin if slug is null", () => { + setupEnvs({ + WEBAPP_URL: "https://app.cal.com", + WEBSITE_URL: "https://abc.com", + }); + expect(getOrgFullOrigin(null)).toEqual("https://abc.com"); + }); + it("should return the org origin if slug is set", () => { + setupEnvs({ + WEBAPP_URL: "https://app.cal-app.com", + WEBSITE_URL: "https://cal.com", + }); + // We are supposed to use WEBAPP_URL to derive the origin from and not WEBSITE_URL + expect(getOrgFullOrigin("org")).toEqual("https://org.cal-app.com"); + }); + }); }); diff --git a/packages/ui/components/head-seo/HeadSeo.tsx b/packages/ui/components/head-seo/HeadSeo.tsx index bf91882f70..fd37274bb2 100644 --- a/packages/ui/components/head-seo/HeadSeo.tsx +++ b/packages/ui/components/head-seo/HeadSeo.tsx @@ -18,6 +18,7 @@ export type HeadSeoProps = { app?: AppImageProps; meeting?: MeetingImageProps; isBrandingHidden?: boolean; + origin?: string; }; /** @@ -69,10 +70,11 @@ const buildSeoMeta = (pageProps: { export const HeadSeo = (props: HeadSeoProps): JSX.Element => { const path = usePathname(); + // The below code sets the defaultUrl for our canonical tags // Get the router's path // Set the default URL to either the current URL (if self-hosted) or https://cal.com canonical URL - const defaultUrl = buildCanonical({ path, origin: CAL_URL }); + const defaultUrl = buildCanonical({ path, origin: props.origin || CAL_URL }); const { title, diff --git a/packages/ui/components/head-seo/head-seo.test.tsx b/packages/ui/components/head-seo/head-seo.test.tsx index beebcce401..54f283729b 100644 --- a/packages/ui/components/head-seo/head-seo.test.tsx +++ b/packages/ui/components/head-seo/head-seo.test.tsx @@ -1,11 +1,27 @@ import { render, waitFor } from "@testing-library/react"; import type { NextSeoProps } from "next-seo"; import type { OpenGraph } from "next/dist/lib/metadata/types/opengraph-types"; +import { usePathname } from "next/navigation"; import { vi } from "vitest"; +import { CAL_URL } from "@calcom/lib/constants"; + import type { HeadSeoProps } from "./HeadSeo"; import HeadSeo from "./HeadSeo"; +vi.mock("next/navigation", () => { + return { + usePathname: vi.fn(), + }; +}); +vi.mock("@calcom/lib/constants", () => { + return { + SEO_IMG_DEFAULT: "", + SEO_IMG_OGIMG: "", + APP_NAME: "Cal.com", + CAL_URL: "http://cal.com", + }; +}); vi.mock("next-seo", () => { return { NextSeo: (props: NextSeoProps) => { @@ -16,7 +32,7 @@ vi.mock("next-seo", () => { image: images[0].url, // eslint-disable-next-line @typescript-eslint/no-explicit-any } as any; - return ; + return <div id="mocked-next-seo" {...mockedProps} />; }, }; }); @@ -48,43 +64,97 @@ describe("Tests for HeadSeo component", () => { test("Should render mocked NextSeo", async () => { const { container } = render(<HeadSeo {...basicProps} />); await waitFor(async () => { - const titleEl = container.querySelector("title"); - expect(titleEl?.getAttribute("canonical")).toEqual(basicProps.canonical); - expect(titleEl?.getAttribute("description")).toEqual(basicProps.description); - expect(titleEl?.getAttribute("site_name")).toEqual(basicProps.siteName); - expect(titleEl?.getAttribute("image")).toContain("constructGenericImage"); + const mockedNextSeoEl = container.querySelector("#mocked-next-seo"); + expect(mockedNextSeoEl?.getAttribute("canonical")).toEqual(basicProps.canonical); + expect(mockedNextSeoEl?.getAttribute("description")).toEqual(basicProps.description); + expect(mockedNextSeoEl?.getAttribute("site_name")).toEqual(basicProps.siteName); + expect(mockedNextSeoEl?.getAttribute("image")).toContain("constructGenericImage"); + }); + }); + + describe("Canonical Url", () => { + test("Should provide `canonical` prop to NextSeo derived from `origin` prop if provided", async () => { + const props = { + title: "Test Title", + description: "Test Description", + origin: "http://acme.cal.local", + siteName: "Cal.com", + }; + vi.mocked(usePathname).mockReturnValue("/mocked-path"); + const { container } = render(<HeadSeo {...props} />); + await waitFor(async () => { + const mockedNextSeoEl = container.querySelector("#mocked-next-seo"); + expect(mockedNextSeoEl?.getAttribute("canonical")).toEqual(`${props.origin}/mocked-path`); + expect(mockedNextSeoEl?.getAttribute("description")).toEqual(props.description); + expect(mockedNextSeoEl?.getAttribute("site_name")).toEqual(props.siteName); + expect(mockedNextSeoEl?.getAttribute("image")).toContain("constructGenericImage"); + }); + }); + test("Should provide `canonical` prop to NextSeo derived from `CAL_URL` prop if origin not provided", async () => { + const props = { + title: "Test Title", + description: "Test Description", + siteName: "Cal.com", + }; + vi.mocked(usePathname).mockReturnValue("/mocked-path"); + const { container } = render(<HeadSeo {...props} />); + await waitFor(async () => { + const mockedNextSeoEl = container.querySelector("#mocked-next-seo"); + expect(mockedNextSeoEl?.getAttribute("canonical")).toEqual(`${CAL_URL}/mocked-path`); + expect(mockedNextSeoEl?.getAttribute("description")).toEqual(props.description); + expect(mockedNextSeoEl?.getAttribute("site_name")).toEqual(props.siteName); + expect(mockedNextSeoEl?.getAttribute("image")).toContain("constructGenericImage"); + }); + }); + test("Should provide `canonical` prop to NextSeo from canonical prop if provided", async () => { + const props = { + title: "Test Title", + description: "Test Description", + siteName: "Cal.com", + origin: "http://acme.cal.local", + canonical: "http://acme.cal.local/some-path", + }; + vi.mocked(usePathname).mockReturnValue("/mocked-path"); + const { container } = render(<HeadSeo {...props} />); + await waitFor(async () => { + const mockedNextSeoEl = container.querySelector("#mocked-next-seo"); + expect(mockedNextSeoEl?.getAttribute("canonical")).toEqual(props.canonical); + expect(mockedNextSeoEl?.getAttribute("description")).toEqual(props.description); + expect(mockedNextSeoEl?.getAttribute("site_name")).toEqual(props.siteName); + expect(mockedNextSeoEl?.getAttribute("image")).toContain("constructGenericImage"); + }); }); }); test("Should render title with brand", async () => { const { container } = render(<HeadSeo {...basicProps} />); await waitFor(async () => { - const titleEl = container.querySelector("title"); - expect(titleEl?.getAttribute("title")).toEqual(`${basicProps.title} | Cal.com`); + const mockedNextSeoEl = container.querySelector("#mocked-next-seo"); + expect(mockedNextSeoEl?.getAttribute("title")).toEqual(`${basicProps.title} | Cal.com`); }); }); test("Should render title without brand", async () => { const { container } = render(<HeadSeo {...basicProps} isBrandingHidden />); await waitFor(async () => { - const titleEl = container.querySelector("title"); - expect(titleEl?.getAttribute("title")).toEqual(`${basicProps.title}`); + const mockedNextSeoEl = container.querySelector("#mocked-next-seo"); + expect(mockedNextSeoEl?.getAttribute("title")).toEqual(`${basicProps.title}`); }); }); test("Should render with app props", async () => { const { container } = render(<HeadSeo {...basicProps} app={{} as HeadSeoProps["app"]} />); await waitFor(async () => { - const titleEl = container.querySelector("title"); - expect(titleEl?.getAttribute("image")).toContain("constructAppImage"); + const mockedNextSeoEl = container.querySelector("#mocked-next-seo"); + expect(mockedNextSeoEl?.getAttribute("image")).toContain("constructAppImage"); }); }); test("Should render with meeting props", async () => { const { container } = render(<HeadSeo {...basicProps} meeting={{} as HeadSeoProps["meeting"]} />); await waitFor(async () => { - const titleEl = container.querySelector("title"); - expect(titleEl?.getAttribute("image")).toContain("constructMeetingImage"); + const mockedNextSeoEl = container.querySelector("#mocked-next-seo"); + expect(mockedNextSeoEl?.getAttribute("image")).toContain("constructMeetingImage"); }); }); }); diff --git a/vitest.workspace.ts b/vitest.workspace.ts index 71868f1545..cfb6070c38 100644 --- a/vitest.workspace.ts +++ b/vitest.workspace.ts @@ -146,6 +146,15 @@ const workspaces = packagedEmbedTestsOnly setupFiles: [], }, }, + { + test: { + globals: true, + environment: "jsdom", + name: "@calcom/web/modules/views", + include: ["apps/web/modules/**/*.{test,spec}.tsx"], + setupFiles: ["apps/web/modules/test-setup.ts"], + }, + }, ]; export default defineWorkspace(workspaces);