diff --git a/packages/core/README.md b/packages/core/README.md index db47cf55a2..03b8e1bb17 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -137,6 +137,16 @@ Files can be placed in `cms/components/` and `cms/pages/`, or co-located alongsi > For schema syntax and the full architectural overview, see the [CMS architecture and schema declarations](https://developers.vtex.com/docs/guides/understanding-cms-architecture-and-schema-declarations) guide. +### Testing Content Platform branch variants + +When `contentSource.type` is set to `CP`, a storefront URL can render content from a Content Platform branch by adding the `__variant` query parameter. The value must be the Content Platform branch UUID, not the branch display name. + +```text +/?__variant=ebe75532-bb2f-417a-9c02-9b9f1c957367 +``` + +The proxy rewrites the request to the internal `/_variant/[branchId]` route. That route resolves all Content Platform entries with the same `branchId` while keeping Next.js preview mode disabled, so ISR behavior remains unchanged. + ## How to test ```sh diff --git a/packages/core/src/components/ExperimentDataLayer/index.tsx b/packages/core/src/components/ExperimentDataLayer/index.tsx new file mode 100644 index 0000000000..dd14677638 --- /dev/null +++ b/packages/core/src/components/ExperimentDataLayer/index.tsx @@ -0,0 +1,66 @@ +import { useEffect } from 'react' + +/** + * The PoC runs a single A/B experiment, so the experiment id is hardcoded. + * When the program graduates beyond one experiment this should be sourced from + * config or the edge alongside the variant cookie. + */ +export const EXPERIMENT_ID = 'ab-test-variant-branch' + +/** Session cookie set by the edge with the assigned variant. */ +export const EXPERIMENT_VARIANT_COOKIE = 'vtex_exp_variant' + +export type ExperimentContext = { + experiment_id: string + variant_id: string +} + +/** Reads a single cookie value from a `document.cookie` string. */ +export function readCookie( + name: string, + cookieString: string +): string | undefined { + const match = cookieString.match(new RegExp(`(?:^|;\\s*)${name}=([^;]*)`)) + return match ? decodeURIComponent(match[1]) : undefined +} + +/** + * Pushes the experiment context to the provided dataLayer when the variant + * cookie is present. No-op when the cookie is absent so non-experiment traffic + * is never tagged. + */ +export function pushExperimentContext( + cookieString: string, + dataLayer: Array> +): void { + const variantId = readCookie(EXPERIMENT_VARIANT_COOKIE, cookieString) + + if (!variantId) { + return + } + + dataLayer.push({ + experiment_id: EXPERIMENT_ID, + variant_id: variantId, + } satisfies ExperimentContext) +} + +/** + * Client-side script that reads the `vtex_exp_variant` cookie set by the edge + * and pushes the experiment context to `window.dataLayer`, so the existing + * Activity Flow can tag events with the experiment/variant. + */ +function ExperimentDataLayer(): null { + useEffect(() => { + if (typeof window === 'undefined') { + return + } + + window.dataLayer = window.dataLayer ?? [] + pushExperimentContext(document.cookie, window.dataLayer) + }, []) + + return null +} + +export default ExperimentDataLayer diff --git a/packages/core/src/pages/_app.tsx b/packages/core/src/pages/_app.tsx index 3f82c16aba..49e24fa361 100644 --- a/packages/core/src/pages/_app.tsx +++ b/packages/core/src/pages/_app.tsx @@ -7,6 +7,7 @@ import { useSearch } from '@faststore/sdk' import { UIProvider } from '@faststore/ui' import { useReloadAfterLogoutReturn } from 'src/components/account/MyAccountDrawer/OrganizationDrawer/useReloadAfterLogoutReturn' +import ExperimentDataLayer from 'src/components/ExperimentDataLayer' import ThirdPartyScripts from 'src/components/ThirdPartyScripts' import Layout from 'src/Layout' import AnalyticsHandler from 'src/sdk/analytics' @@ -47,6 +48,7 @@ function App({ Component, pageProps }: AppProps) { + diff --git a/packages/core/src/pages/_variant/[branchId]/[...slug].tsx b/packages/core/src/pages/_variant/[branchId]/[...slug].tsx new file mode 100644 index 0000000000..f55a437a73 --- /dev/null +++ b/packages/core/src/pages/_variant/[branchId]/[...slug].tsx @@ -0,0 +1,215 @@ +import { isNotFoundError } from '@faststore/api' +import storeConfig from 'discovery.config' +import type { GetStaticPaths, GetStaticProps } from 'next' + +import { gql } from '@generated' +import type { + ServerCollectionPageQueryQuery, + ServerCollectionPageQueryQueryVariables, + ServerManyProductsQueryQuery, + ServerManyProductsQueryQueryVariables, +} from '@generated/graphql' +import type { SearchState } from '@faststore/sdk' +import { + type GlobalSectionsData, + getGlobalSectionsData, +} from 'src/components/cms/GlobalSections' +import { getLandingPageBySlug } from 'src/components/templates/LandingPage' +import { getRedirect } from 'src/sdk/redirects' +import { execute } from 'src/server' +import type { PageContentType } from 'src/server/cms' +import { injectGlobalSections } from 'src/server/cms/global' +import type { PLPContentType } from 'src/server/cms/plp' +import { contentService } from 'src/server/content/service' +import type { PreviewData } from 'src/server/content/types' +import { getVariantBranchId } from 'src/server/content/utils' +import { getDynamicContent } from 'src/utils/dynamicContent' +import { fetchServerManyProducts } from 'src/utils/fetchProductGallerySSR' + +// Reuse the original PLP/landing page component — only the data fetching differs. +import CatchAllPage from '../../[...slug]' + +type BaseProps = { + globalSections: GlobalSectionsData +} + +type Props = BaseProps & + ( + | { + type: 'plp' + page: PLPContentType + data: ServerCollectionPageQueryQuery & ServerManyProductsQueryQuery + serverManyProductsVariables: ServerManyProductsQueryQueryVariables + } + | { + type: 'page' + slug: string + page: PageContentType + serverData?: unknown + } + ) + +const query = gql(` + query ServerCollectionPageQuery($slug: String!) { + ...ServerCollectionPage + collection(slug: $slug) { + seo { + title + description + } + breadcrumbList { + itemListElement { + item + name + position + } + } + meta { + selectedFacets { + key + value + } + } + } + } +`) + +export const getStaticProps: GetStaticProps< + Props, + { branchId: string; slug: string[] }, + PreviewData +> = async ({ params, previewData, locale }) => { + const slug = params?.slug.join('/') ?? '' + const branchId = getVariantBranchId(params) + const rewrites = (await storeConfig.rewrites?.()) ?? [] + const contentContext = { previewData, locale, branchId } + + const [ + globalSectionsPromise, + globalSectionsHeaderPromise, + globalSectionsFooterPromise, + ] = getGlobalSectionsData(contentContext) + + const landingPagePromise = getLandingPageBySlug(slug, contentContext) + + const landingPage = await landingPagePromise + + if (landingPage && Object.keys(landingPage).length > 0) { + const [ + serverData, + globalSections, + globalSectionsHeader, + globalSectionsFooter, + ] = await Promise.all([ + getDynamicContent({ pageType: slug }), + globalSectionsPromise, + globalSectionsHeaderPromise, + globalSectionsFooterPromise, + ]) + + const globalSectionsResult = injectGlobalSections({ + globalSections, + globalSectionsHeader, + globalSectionsFooter, + }) + + return { + props: { + page: landingPage, + globalSections: globalSectionsResult, + type: 'page', + slug, + serverData, + }, + } + } + + const [ + { data, errors = [] }, + cmsPage, + globalSections, + globalSectionsHeader, + globalSectionsFooter, + ] = await Promise.all([ + execute< + ServerCollectionPageQueryQueryVariables, + ServerCollectionPageQueryQuery + >({ + variables: { slug }, + operation: query, + }), + contentService.getPlpContent( + { + ...contentContext, + slug, + locale, + }, + rewrites + ), + globalSectionsPromise, + globalSectionsHeaderPromise, + globalSectionsFooterPromise, + ]) + + const [serverManyProductsData, serverManyProductsVariables] = + await fetchServerManyProducts({ + itemsPerPage: cmsPage?.settings?.productGallery?.itemsPerPage, + sort: cmsPage?.settings?.productGallery + ?.sortBySelection as SearchState['sort'], + term: '', + selectedFacets: data?.collection?.meta.selectedFacets, + locale, + }) + + const notFound = errors.find(isNotFoundError) + + if (notFound) { + if (storeConfig.experimental.enableRedirects) { + const redirect = await getRedirect({ pathname: `/${slug}` }) + if (redirect) { + return { + redirect, + revalidate: 60 * 5, // 5 minutes + } + } + } + + return { + notFound: true, + } + } + + if (errors.length > 0) { + console.error(...errors) + throw errors[0] + } + + const globalSectionsResult = injectGlobalSections({ + globalSections, + globalSectionsHeader, + globalSectionsFooter, + }) + + return { + props: { + data: { + ...data, + ...serverManyProductsData, + }, + serverManyProductsVariables, + page: cmsPage, + globalSections: globalSectionsResult, + type: 'plp', + key: slug, + }, + } +} + +export const getStaticPaths: GetStaticPaths = async () => { + return { + paths: [], + fallback: 'blocking', + } +} + +export default CatchAllPage diff --git a/packages/core/src/pages/_variant/[branchId]/[slug]/p.tsx b/packages/core/src/pages/_variant/[branchId]/[slug]/p.tsx new file mode 100644 index 0000000000..3bee9131b3 --- /dev/null +++ b/packages/core/src/pages/_variant/[branchId]/[slug]/p.tsx @@ -0,0 +1,217 @@ +import { isNotFoundError } from '@faststore/api' +import storeConfig from 'discovery.config' +import type { GetStaticPaths, GetStaticProps } from 'next' + +import { gql } from '@generated' +import type { + ServerProductQueryQuery, + ServerProductQueryQueryVariables, +} from '@generated/graphql' +import { + type GlobalSectionsData, + getGlobalSectionsData, +} from 'src/components/cms/GlobalSections' +import { getStoreURL } from 'src/sdk/localization/useLocalizationConfig' +import { getRedirect } from 'src/sdk/redirects' +import { execute } from 'src/server' +import { injectGlobalSections } from 'src/server/cms/global' +import type { PDPContentType } from 'src/server/cms/pdp' +import { contentService } from 'src/server/content/service' +import type { PreviewData } from 'src/server/content/types' +import { getVariantBranchId } from 'src/server/content/utils' +import { getChannelForLocale } from 'src/utils/localization/bindingPaths' + +// Reuse the original PDP page component — only the data fetching differs. +import ProductPage from '../../../[slug]/p' + +type StoreConfig = typeof storeConfig & { + experimental: { + revalidate?: number + enableClientOffer?: boolean + } +} + +const query = gql(` + query ServerProductQuery($locator: [IStoreSelectedFacet!]!) { + ...ServerProduct + product(locator: $locator) { + id: productID + + seo { + title + description + canonical + } + + brand { + name + } + + sku + gtin + mpn + name + description + releaseDate + + breadcrumbList { + itemListElement { + item + name + position + } + } + + image { + url + alternateName + } + + offers { + lowPrice + highPrice + lowPriceWithTaxes + priceCurrency + offers { + availability + price + priceValidUntil + priceCurrency + itemCondition + seller { + identifier + } + } + } + + isVariantOf { + productGroupID + } + + ...ProductDetailsFragment_product + } + } +`) + +export const getStaticProps: GetStaticProps< + PDPContentType & { + data: ServerProductQueryQuery + globalSections: GlobalSectionsData + meta: { title: string; description: string; canonical: string } + }, + { branchId: string; slug: string }, + PreviewData +> = async ({ params, previewData, locale }) => { + const slug = params?.slug ?? '' + const branchId = getVariantBranchId(params) + const contentContext = { previewData, locale, branchId } + + const [ + globalSectionsPromise, + globalSectionsHeaderPromise, + globalSectionsFooterPromise, + ] = getGlobalSectionsData(contentContext) + + const [ + searchResult, + globalSections, + globalSectionsHeader, + globalSectionsFooter, + ] = await Promise.all([ + execute({ + variables: { + locator: [ + { key: 'slug', value: slug }, + { key: 'channel', value: getChannelForLocale(locale) }, + { key: 'locale', value: locale }, + ], + }, + operation: query, + }), + globalSectionsPromise, + globalSectionsHeaderPromise, + globalSectionsFooterPromise, + ]) + + const { data, errors = [] } = searchResult + + const notFound = errors.find(isNotFoundError) + + if (notFound) { + if (storeConfig.experimental.enableRedirects) { + const redirect = await getRedirect({ pathname: `/${slug}/p` }) + + if (redirect) { + return { + redirect, + revalidate: 60 * 5, // 5 minutes + } + } + } + + return { + notFound: true, + } + } + + if (errors.length > 0) { + throw errors[0] + } + + const cmsPage: PDPContentType = await contentService.getPdpContent( + data.product, + { + ...contentContext, + slug, + locale, + } + ) + + const { seo } = data.product + const title = seo.title + const description = seo.description + const canonical = `${getStoreURL()}${seo.canonical}` + + const meta = { title, description, canonical } + + let offer = {} + + if (data.product.offers.offers.length > 0) { + const { listPrice, ...offerData } = data.product.offers.offers[0] + + offer = offerData + } + + const offers = { + ...offer, + priceCurrency: data.product.offers.priceCurrency, + url: canonical, + } + + const globalSectionsResult = injectGlobalSections({ + globalSections, + globalSectionsHeader, + globalSectionsFooter, + }) + + return { + props: { + data, + ...cmsPage, + meta, + offers, + globalSections: globalSectionsResult, + key: seo.canonical, + }, + revalidate: (storeConfig as StoreConfig).experimental.revalidate ?? false, + } +} + +export const getStaticPaths: GetStaticPaths = async () => { + return { + paths: [], + fallback: 'blocking', + } +} + +export default ProductPage diff --git a/packages/core/src/pages/_variant/[branchId]/index.tsx b/packages/core/src/pages/_variant/[branchId]/index.tsx new file mode 100644 index 0000000000..237f7c90ed --- /dev/null +++ b/packages/core/src/pages/_variant/[branchId]/index.tsx @@ -0,0 +1,81 @@ +import type { GetStaticPaths, GetStaticProps } from 'next' + +import { + type GlobalSectionsData, + getGlobalSectionsData, +} from 'src/components/cms/GlobalSections' +import type { PageContentType } from 'src/server/cms' +import { injectGlobalSections } from 'src/server/cms/global' +import { contentService } from 'src/server/content/service' +import type { PreviewData } from 'src/server/content/types' +import { getVariantBranchId } from 'src/server/content/utils' +import { getDynamicContent } from 'src/utils/dynamicContent' + +// Reuse the original home page component — only the data fetching differs. +import HomePage from '../../index' + +type Props = { + page: PageContentType + globalSections: GlobalSectionsData + serverData?: unknown +} + +export const getStaticProps: GetStaticProps< + Props, + { branchId: string }, + PreviewData +> = async ({ params, previewData, locale }) => { + const branchId = getVariantBranchId(params) + const contentContext = { previewData, locale, branchId } + + const [ + globalSectionsPromise, + globalSectionsHeaderPromise, + globalSectionsFooterPromise, + ] = getGlobalSectionsData(contentContext) + const serverDataPromise = getDynamicContent({ pageType: 'home' }) + + // The variant branch resolves its own `home` entry via branchId, so we skip + // the build-time baked `cms.data` locators used by the main-branch page. + const pagePromise = contentService.getSingleContent({ + ...contentContext, + contentType: 'home', + }) + + const [ + page, + globalSections, + globalSectionsHeader, + globalSectionsFooter, + serverData, + ] = await Promise.all([ + pagePromise, + globalSectionsPromise, + globalSectionsHeaderPromise, + globalSectionsFooterPromise, + serverDataPromise, + ]) + + const globalSectionsResult = injectGlobalSections({ + globalSections, + globalSectionsHeader, + globalSectionsFooter, + }) + + return { + props: { + page, + globalSections: globalSectionsResult, + serverData, + }, + } +} + +export const getStaticPaths: GetStaticPaths = async () => { + return { + paths: [], + fallback: 'blocking', + } +} + +export default HomePage diff --git a/packages/core/src/proxy.ts b/packages/core/src/proxy.ts index d55407778e..0e430d6559 100644 --- a/packages/core/src/proxy.ts +++ b/packages/core/src/proxy.ts @@ -6,6 +6,7 @@ import { getSubdomainBindings, isValidLocale, } from 'src/utils/localization/bindingPaths' +import { resolveVariantRewrite } from 'src/utils/variant' type RewriteRule = { regex: RegExp @@ -94,6 +95,14 @@ function rewriteSubdomainRequest( } export function proxy(request: NextRequest) { + // A/B test variant branch takes precedence: when `__variant` is present, + // rewrite to the internal `/_variant/[branchId]/...` route. Runs before the + // localization early-return so it also applies when localization is disabled. + const variantRewrite = resolveVariantRewrite(request.nextUrl) + if (variantRewrite) { + return NextResponse.rewrite(variantRewrite) + } + if (!storeConfig.localization?.enabled) { return NextResponse.next() } @@ -148,6 +157,7 @@ export function proxy(request: NextRequest) { export const config = { matcher: [ + '/', '/((?!api|_next/static|_next/image|favicon.ico|.*\\.[^/]+$).*)', '/_next/data/:path*', ], diff --git a/packages/core/src/server/content/service.ts b/packages/core/src/server/content/service.ts index 13a38097f9..9cde1fb953 100644 --- a/packages/core/src/server/content/service.ts +++ b/packages/core/src/server/content/service.ts @@ -18,6 +18,7 @@ import type { ContentOptions, ContentParams } from './types' import { isBranchPreview, isContentPlatformSource } from './utils' type ContentResult = ContentData | (ContentEntry & PageContentType) +type EntriesListResult = { entries?: ContentEntry[] } | ContentEntry[] const OPTIONAL_CONTENT_TYPES = [ 'globalHeaderSections', @@ -171,7 +172,7 @@ export class ContentService { throw new Error(`${operation} requires entryId or slug`) } - if (isPreview) { + if (isPreview || params.branchId) { return params.entryId ? (this.clientCP.previewEntryById(params) as Promise) : (this.clientCP.previewEntryBySlug(params) as Promise) @@ -186,7 +187,13 @@ export class ContentService { params: EntryPathParams, isPreview: boolean ): Promise { - const { entries } = await this.clientCP.listEntries(params) + const result = ( + params.branchId + ? await this.clientCP.listPreviewEntries(params) + : await this.clientCP.listEntries(params) + ) as EntriesListResult + const entries = Array.isArray(result) ? result : result.entries + if (!entries || entries.length === 0) { const isOptional = OPTIONAL_CONTENT_TYPES.includes( params.contentType as (typeof OPTIONAL_CONTENT_TYPES)[number] @@ -272,6 +279,7 @@ export class ContentService { versionId, releaseId, filters, + branchId, } = params const { slug: _, locale: __, ...previewLocator } = previewData ?? {} @@ -286,6 +294,10 @@ export class ContentService { ...(versionId !== undefined && { versionId }), ...(releaseId !== undefined && { releaseId }), ...(filters && { filters }), + // A/B variant branch: resolve content from the variant branch via the CP + // `branchId` (mapped from `versionId`). Placed last so it wins over the + // preview locator. Does not toggle `isPreview`, so ISR is preserved. + ...(branchId ? { versionId: branchId } : {}), } } diff --git a/packages/core/src/server/content/types.ts b/packages/core/src/server/content/types.ts index 48d9ed21ff..b2717ab37f 100644 --- a/packages/core/src/server/content/types.ts +++ b/packages/core/src/server/content/types.ts @@ -13,6 +13,12 @@ export type PreviewData = Locator & { export interface ContentRequestContext { previewData?: PreviewData | null locale?: string + /** + * A/B test variant branch. When set, all CP calls resolve content from this + * branch (mapped to the CP `branchId`) without enabling preview mode, so ISR + * stays intact. Sourced from the `__variant` querystring via middleware. + */ + branchId?: string } export interface ContentParams extends ContentRequestContext { diff --git a/packages/core/src/server/content/utils.ts b/packages/core/src/server/content/utils.ts index 4ac26041f8..4b1ed96c03 100644 --- a/packages/core/src/server/content/utils.ts +++ b/packages/core/src/server/content/utils.ts @@ -15,3 +15,16 @@ export function isBranchPreview( !!(previewData?.versionId || previewData?.releaseId) ) } + +/** + * Extracts the A/B test variant branch id from route params. An empty string + * (e.g. `?__variant=` rewritten to an empty path segment) is treated as absent. + */ +export function getVariantBranchId( + params: { branchId?: string } | null | undefined +): string | undefined { + const branchId = params?.branchId + return typeof branchId === 'string' && branchId.length > 0 + ? branchId + : undefined +} diff --git a/packages/core/src/utils/variant.ts b/packages/core/src/utils/variant.ts new file mode 100644 index 0000000000..8040a7a7ae --- /dev/null +++ b/packages/core/src/utils/variant.ts @@ -0,0 +1,26 @@ +export const VARIANT_QUERY_PARAM = '__variant' +export const VARIANT_PATH_PREFIX = '/_variant' + +/** + * Resolves the internal A/B variant route for an incoming request URL. + * + * When `__variant` is present and non-empty, returns the URL rewritten to + * `/_variant/[branchId]/` (querystring preserved). When it is + * absent or empty, returns `null` so the request passes through untouched. + * + * Pure and side-effect free so the rewrite decision is unit-testable, and so + * it can be composed into the Next.js `proxy` entrypoint without pulling in + * any heavy dependencies. + */ +export function resolveVariantRewrite(url: URL): URL | null { + const variant = url.searchParams.get(VARIANT_QUERY_PARAM) + + if (!variant) { + return null + } + + const rewritten = new URL(url) + rewritten.pathname = `${VARIANT_PATH_PREFIX}/${variant}${url.pathname}` + + return rewritten +} diff --git a/packages/core/test/components/ExperimentDataLayer.browser.test.tsx b/packages/core/test/components/ExperimentDataLayer.browser.test.tsx new file mode 100644 index 0000000000..d06e4be06b --- /dev/null +++ b/packages/core/test/components/ExperimentDataLayer.browser.test.tsx @@ -0,0 +1,60 @@ +import { render } from '@testing-library/react' +import { afterEach, describe, expect, it } from 'vitest' + +import ExperimentDataLayer, { + EXPERIMENT_ID, + pushExperimentContext, +} from '../../src/components/ExperimentDataLayer' + +afterEach(() => { + // Reset cookies and dataLayer between tests + for (const cookie of document.cookie.split(';')) { + const name = cookie.split('=')[0].trim() + if (name) { + document.cookie = `${name}=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/` + } + } + window.dataLayer = [] +}) + +describe('pushExperimentContext', () => { + it('pushes experiment context when the variant cookie is present', () => { + const dataLayer: Array> = [] + + pushExperimentContext('vtex_exp_variant=treatment', dataLayer) + + expect(dataLayer).toEqual([ + { experiment_id: EXPERIMENT_ID, variant_id: 'treatment' }, + ]) + }) + + it('does not push anything when the variant cookie is absent', () => { + const dataLayer: Array> = [] + + pushExperimentContext('some_other_cookie=1', dataLayer) + + expect(dataLayer).toEqual([]) + }) +}) + +describe('', () => { + it('pushes variant_id from the cookie plus the hardcoded experiment_id', () => { + window.dataLayer = [] + document.cookie = 'vtex_exp_variant=treatment; path=/' + + render() + + expect(window.dataLayer).toContainEqual({ + experiment_id: EXPERIMENT_ID, + variant_id: 'treatment', + }) + }) + + it('does not push to dataLayer when the cookie is absent', () => { + window.dataLayer = [] + + render() + + expect(window.dataLayer).toEqual([]) + }) +}) diff --git a/packages/core/test/server/content/service.test.ts b/packages/core/test/server/content/service.test.ts index 7540d9e7d7..262a56ab6f 100644 --- a/packages/core/test/server/content/service.test.ts +++ b/packages/core/test/server/content/service.test.ts @@ -10,6 +10,14 @@ import type { type ContentServiceInternals = { createContentOptions: (params: ContentParams) => ContentOptions convertOptionsToParams: (options: ContentOptions) => EntryPathParams + getEntryData: ( + params: EntryPathParams, + isPreview: boolean + ) => Promise + fetchFirstEntryFromList: ( + params: EntryPathParams, + isPreview: boolean + ) => Promise } const getServiceInternals = (service: ContentService) => @@ -95,4 +103,158 @@ describe('ContentService', () => { expect('locale' in options.cmsOptions).toBe(false) }) }) + + describe('createContentOptions with variant branchId', () => { + it('maps context.branchId to cmsOptions.versionId without enabling preview', () => { + const service = getServiceInternals(new ContentService()) + + const options = service.createContentOptions({ + contentType: 'home', + branchId: 'campaign-x', + }) + + expect((options.cmsOptions as { versionId?: string }).versionId).toBe( + 'campaign-x' + ) + expect(options.isPreview).toBe(false) + }) + + it('prefers context.branchId over previewData.versionId', () => { + const service = getServiceInternals(new ContentService()) + + const options = service.createContentOptions({ + contentType: 'home', + branchId: 'campaign-x', + previewData: { + contentType: 'home', + documentId: 'entry-1', + versionId: 'branch-1', + }, + }) + + expect((options.cmsOptions as { versionId?: string }).versionId).toBe( + 'campaign-x' + ) + }) + + it('leaves behavior unchanged when branchId is absent', () => { + const service = getServiceInternals(new ContentService()) + + const options = service.createContentOptions({ + contentType: 'home', + }) + + expect('versionId' in options.cmsOptions).toBe(false) + expect(options.isPreview).toBe(false) + }) + }) + + describe('variant branchId propagation across content types', () => { + // Every module a variant page renders funnels its CP request through + // ContentService, so asserting branchId reaches the CP params for each + // content type guards against the "mixed rendering" failure mode (US2). + const CONTENT_TYPES = [ + 'home', + 'pdp', + 'plp', + 'landingPage', + 'globalSections', + 'globalHeaderSections', + 'globalFooterSections', + ] as const + + it.each(CONTENT_TYPES)( + 'forwards branchId to the CP params for "%s"', + (contentType) => { + const service = getServiceInternals(new ContentService()) + + const options = service.createContentOptions({ + contentType, + branchId: 'campaign-x', + }) + const params = service.convertOptionsToParams(options) + + expect(params.branchId).toBe('campaign-x') + expect(options.isPreview).toBe(false) + } + ) + }) + + describe('variant branchId CP calls', () => { + it('uses the branch-aware CP endpoint without marking the page as preview', async () => { + const service = getServiceInternals(new ContentService()) + const clientCP = { + previewEntryById: async () => ({ sections: [] }), + getEntry: async () => { + throw new Error('data-plane getEntry should not be used for branchId') + }, + } + + Object.assign(service, { clientCP }) + + const result = await service.getEntryData( + { + accountName: 'brandless', + storeId: 'faststore', + contentType: 'home', + entryId: 'entry-1', + branchId: 'campaign-x', + }, + false + ) + + expect(result).toEqual({ sections: [] }) + }) + + it('uses branch-aware listing when resolving the first entry from a branch', async () => { + const service = getServiceInternals(new ContentService()) + const clientCP = { + listPreviewEntries: async () => ({ + entries: [{ id: 'entry-1', name: 'Home' }], + }), + previewEntryById: async () => ({ sections: [] }), + listEntries: async () => { + throw new Error( + 'data-plane listEntries should not be used for branchId' + ) + }, + } + + Object.assign(service, { clientCP }) + + const result = await service.fetchFirstEntryFromList( + { + accountName: 'brandless', + storeId: 'faststore', + contentType: 'home', + branchId: 'campaign-x', + }, + false + ) + + expect(result).toEqual({ sections: [] }) + }) + + it('accepts branch-aware entry lists returned as a raw array', async () => { + const service = getServiceInternals(new ContentService()) + const clientCP = { + listPreviewEntries: async () => [{ id: 'entry-1', name: 'Home' }], + previewEntryById: async () => ({ sections: [] }), + } + + Object.assign(service, { clientCP }) + + const result = await service.fetchFirstEntryFromList( + { + accountName: 'brandless', + storeId: 'faststore', + contentType: 'home', + branchId: 'pala-test', + }, + false + ) + + expect(result).toEqual({ sections: [] }) + }) + }) }) diff --git a/packages/core/test/server/content/utils.test.ts b/packages/core/test/server/content/utils.test.ts new file mode 100644 index 0000000000..3eadcf60d5 --- /dev/null +++ b/packages/core/test/server/content/utils.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from 'vitest' + +import { getVariantBranchId } from '../../../src/server/content/utils' + +describe('getVariantBranchId', () => { + it('returns the branchId when present', () => { + expect(getVariantBranchId({ branchId: 'campaign-x' })).toBe('campaign-x') + }) + + it('treats an empty branchId as absent', () => { + expect(getVariantBranchId({ branchId: '' })).toBeUndefined() + }) + + it('returns undefined when branchId is missing', () => { + expect(getVariantBranchId({})).toBeUndefined() + }) +}) diff --git a/packages/core/test/utils/variant.test.ts b/packages/core/test/utils/variant.test.ts new file mode 100644 index 0000000000..93a36db94e --- /dev/null +++ b/packages/core/test/utils/variant.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest' + +import { config as proxyConfig } from '../../src/proxy' +import { resolveVariantRewrite } from '../../src/utils/variant' + +const requestUrl = (path: string) => new URL(`https://store.example.com${path}`) + +describe('resolveVariantRewrite', () => { + it('rewrites to the internal variant route when __variant is present', () => { + const rewritten = resolveVariantRewrite( + requestUrl('/produto/p?__variant=campaign-x') + ) + + expect(rewritten?.pathname).toBe('/_variant/campaign-x/produto/p') + }) + + it('does not rewrite when __variant is empty', () => { + expect( + resolveVariantRewrite(requestUrl('/produto/p?__variant=')) + ).toBeNull() + }) + + it('does not rewrite when __variant is absent', () => { + expect(resolveVariantRewrite(requestUrl('/produto/p'))).toBeNull() + }) + + it('preserves other querystring params in the rewrite', () => { + const rewritten = resolveVariantRewrite( + requestUrl('/produto/p?__variant=campaign-x&utm_source=newsletter') + ) + + expect(rewritten?.pathname).toBe('/_variant/campaign-x/produto/p') + expect(rewritten?.searchParams.get('utm_source')).toBe('newsletter') + }) + + it('runs the proxy for the home page route', () => { + expect(proxyConfig.matcher).toContain('/') + }) +})