From 52e4e8e5f2abd5d507b44c589d617ddedfdf99c2 Mon Sep 17 00:00:00 2001 From: Artem Alexeyenko Date: Mon, 27 Jul 2026 19:00:21 -0400 Subject: [PATCH 1/7] [Angular] SXA Redirects support (cherry picked from commit b9350d50caac9cba066c03e6bc0644028e65e8e4) --- .changeset/six-deer-grin.md | 6 + packages/angular/src/config/http-types.ts | 4 +- .../angular/src/server/middleware/index.ts | 4 + .../middleware/redirects-middleware.spec.ts | 419 ++++++++++++++++++ .../server/middleware/redirects-middleware.ts | 396 +++++++++++++++++ .../src/templates/angular/src/server.ts | 23 +- 6 files changed, 849 insertions(+), 3 deletions(-) create mode 100644 .changeset/six-deer-grin.md create mode 100644 packages/angular/src/server/middleware/redirects-middleware.spec.ts create mode 100644 packages/angular/src/server/middleware/redirects-middleware.ts diff --git a/.changeset/six-deer-grin.md b/.changeset/six-deer-grin.md new file mode 100644 index 0000000000..de9cfb16b7 --- /dev/null +++ b/.changeset/six-deer-grin.md @@ -0,0 +1,6 @@ +--- +'@sitecore-content-sdk/angular': minor +'create-content-sdk-app': patch +--- + +SXA Redirects support in Angular diff --git a/packages/angular/src/config/http-types.ts b/packages/angular/src/config/http-types.ts index bc1e877df3..5c4a3fb92d 100644 --- a/packages/angular/src/config/http-types.ts +++ b/packages/angular/src/config/http-types.ts @@ -42,9 +42,11 @@ export interface ExpressResponse { */ setHeader?(name: string, value: string | string[]): void; /** - * Redirect the client to another URL. Used by sitemap middleware for 404 fallbacks. + * Redirect the client to another URL. Used by the sitemap middleware for 404 fallbacks and by + * the redirects middleware (with an explicit status) for 301/302 redirects. */ redirect?(url: string): void; + redirect?(status: number, url: string): void; /** * Set a response cookie. Used by multisite middleware to set the site cookie. */ diff --git a/packages/angular/src/server/middleware/index.ts b/packages/angular/src/server/middleware/index.ts index c91477fea0..94b80e9c5a 100644 --- a/packages/angular/src/server/middleware/index.ts +++ b/packages/angular/src/server/middleware/index.ts @@ -41,6 +41,10 @@ export { createPersonalizeMiddleware, type PersonalizeMiddlewareOptions, } from './personalize-middleware'; +export { + createRedirectsMiddleware, + type RedirectsMiddlewareOptions, +} from './redirects-middleware'; export { shouldProcessPath } from './utils'; export { isEditingPreview } from '../utils'; export type { PathPattern } from '../utils'; diff --git a/packages/angular/src/server/middleware/redirects-middleware.spec.ts b/packages/angular/src/server/middleware/redirects-middleware.spec.ts new file mode 100644 index 0000000000..beb18f717a --- /dev/null +++ b/packages/angular/src/server/middleware/redirects-middleware.spec.ts @@ -0,0 +1,419 @@ +import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest'; +import { + RedirectInfo, + RedirectsService, + REDIRECT_TYPE_301, + REDIRECT_TYPE_302, + REDIRECT_TYPE_SERVER_TRANSFER, + type SiteInfo, +} from '@sitecore-content-sdk/content/site'; +import { EDITING_PARAMS_HEADER } from '../../editing/constants'; +import { LOADER_DATA_ENDPOINT } from '../constants'; +import { + createRedirectsMiddleware, + type RedirectsMiddlewareOptions, +} from './redirects-middleware'; +import type { CsdkExpressRequest, ExpressResponse } from './models'; + +const SITES: SiteInfo[] = [{ hostName: '*', language: 'en', name: 'site-a' }]; + +function createService(redirects: RedirectInfo[]): RedirectsService { + return { + fetchRedirects: vi.fn().mockResolvedValue(redirects), + } as unknown as RedirectsService; +} + +function createOptions( + overrides: Partial = {} +): RedirectsMiddlewareOptions { + return { + enabled: true, + locales: ['en'], + defaultLanguage: 'en', + defaultSite: 'site-a', + sites: SITES, + redirectsService: createService([]), + ...overrides, + } as RedirectsMiddlewareOptions; +} + +function createReq(overrides: Partial = {}): CsdkExpressRequest { + return { + method: 'GET', + path: '/', + url: '/', + body: undefined, + query: {}, + cookies: {}, + headers: { host: 'a.example.com' }, + scParams: { siteName: 'site-a' }, + ...overrides, + }; +} + +function createRes() { + return { + redirect: vi.fn(), + setHeader: vi.fn(), + status: vi.fn().mockReturnThis(), + json: vi.fn(), + } as unknown as ExpressResponse & { redirect: Mock; setHeader: Mock }; +} + +const redirect = (overrides: Partial): RedirectInfo => ({ + pattern: '', + target: '', + redirectType: REDIRECT_TYPE_301, + isQueryStringPreserved: false, + locale: '', + ...overrides, +}); + +describe('createRedirectsMiddleware', () => { + const next = vi.fn(); + beforeEach(() => vi.clearAllMocks()); + + it('returns a middleware', () => { + expect(createRedirectsMiddleware(createOptions())).toBeTypeOf('function'); + }); + + it('matches a locale-versioned redirect item and issues a 301', async () => { + const req = createReq({ path: '/en/old', url: '/en/old' }); + const res = createRes(); + const options = createOptions({ + redirectsService: createService([ + redirect({ locale: 'en', pattern: '/old', target: '/new' }), + ]), + }); + await createRedirectsMiddleware(options)(req, res, next); + expect(res.redirect).toHaveBeenCalledWith(301, '/new'); + expect(next).not.toHaveBeenCalled(); + }); + + it('preserves the request locale on relative targets when isLanguagePreserved', async () => { + const req = createReq({ path: '/en/old', url: '/en/old' }); + const res = createRes(); + const options = createOptions({ + redirectsService: createService([ + redirect({ locale: 'en', pattern: '/old', target: '/new', isLanguagePreserved: true }), + ]), + }); + await createRedirectsMiddleware(options)(req, res, next); + expect(res.redirect).toHaveBeenCalledWith(301, '/en/new'); + }); + + it('keeps a locale-less URL locale-less when the target resolves to the same locale', async () => { + const req = createReq({ path: '/old', url: '/old' }); + const res = createRes(); + const options = createOptions({ + redirectsService: createService([ + redirect({ pattern: '/old', target: '/new', isLanguagePreserved: true }), + ]), + }); + await createRedirectsMiddleware(options)(req, res, next); + expect(res.redirect).toHaveBeenCalledWith(301, '/new'); + }); + + it('adds the target locale when it differs from a locale-less origin', async () => { + const req = createReq({ path: '/old', url: '/old' }); + const res = createRes(); + const options = createOptions({ + locales: ['en', 'fr'], + redirectsService: createService([redirect({ pattern: '/old', target: '/fr/new' })]), + }); + await createRedirectsMiddleware(options)(req, res, next); + expect(res.redirect).toHaveBeenCalledWith(301, '/fr/new'); + }); + + it('matches a static redirect-map rule and issues a 302', async () => { + const req = createReq({ path: '/about-us', url: '/about-us' }); + const res = createRes(); + const options = createOptions({ + redirectsService: createService([ + redirect({ pattern: '/about-us', target: '/about', redirectType: REDIRECT_TYPE_302 }), + ]), + }); + await createRedirectsMiddleware(options)(req, res, next); + expect(res.redirect).toHaveBeenCalledWith(302, '/about'); + }); + + it('matches a regex rule and applies capture-group substitution', async () => { + const req = createReq({ path: '/products/shoes', url: '/products/shoes' }); + const res = createRes(); + const options = createOptions({ + redirectsService: createService([ + redirect({ pattern: '/products/(.*)', target: '/shop/$1' }), + ]), + }); + await createRedirectsMiddleware(options)(req, res, next); + expect(res.redirect).toHaveBeenCalledWith(301, '/shop/shoes'); + }); + + it('substitutes the $siteLang token from the resolved site language', async () => { + // Locale-prefixed origin so the injected locale is preserved on the target. + const req = createReq({ path: '/en/home', url: '/en/home' }); + const res = createRes(); + const options = createOptions({ + redirectsService: createService([ + redirect({ pattern: '/home', target: '/$siteLang/welcome' }), + ]), + }); + await createRedirectsMiddleware(options)(req, res, next); + expect(res.redirect).toHaveBeenCalledWith(301, '/en/welcome'); + }); + + it('preserves the query string on relative targets when isQueryStringPreserved', async () => { + const req = createReq({ path: '/search', url: '/search?q=shoes', query: { q: 'shoes' } }); + const res = createRes(); + const options = createOptions({ + redirectsService: createService([ + redirect({ + pattern: '/search', + target: '/results', + redirectType: REDIRECT_TYPE_302, + isQueryStringPreserved: true, + }), + ]), + }); + await createRedirectsMiddleware(options)(req, res, next); + expect(res.redirect).toHaveBeenCalledWith(302, '/results?q=shoes'); + }); + + it('redirects to an absolute external target without locale stripping', async () => { + const req = createReq({ path: '/ext', url: '/ext' }); + const res = createRes(); + const options = createOptions({ + redirectsService: createService([ + redirect({ pattern: '/ext', target: 'https://example.org/page' }), + ]), + }); + await createRedirectsMiddleware(options)(req, res, next); + expect(res.redirect).toHaveBeenCalledWith(301, 'https://example.org/page'); + }); + + it('merges the query string into an absolute target when isQueryStringPreserved', async () => { + const req = createReq({ path: '/ext', url: '/ext?a=1', query: { a: '1' } }); + const res = createRes(); + const options = createOptions({ + redirectsService: createService([ + redirect({ + pattern: '/ext', + target: 'https://example.org/page', + isQueryStringPreserved: true, + }), + ]), + }); + await createRedirectsMiddleware(options)(req, res, next); + expect(res.redirect).toHaveBeenCalledWith(301, 'https://example.org/page?a=1'); + }); + + it('performs an internal rewrite (no browser redirect) for SERVER_TRANSFER', async () => { + const req = createReq({ path: '/legacy', url: '/legacy' }); + const res = createRes(); + const options = createOptions({ + redirectsService: createService([ + redirect({ + pattern: '/legacy', + target: '/modern', + redirectType: REDIRECT_TYPE_SERVER_TRANSFER, + }), + ]), + }); + await createRedirectsMiddleware(options)(req, res, next); + expect(res.redirect).not.toHaveBeenCalled(); + expect(req.url).toBe('/modern'); + expect(req.path).toBe('/modern'); + expect(next).toHaveBeenCalledTimes(1); + }); + + it('falls back to a 302 for SERVER_TRANSFER to an external target', async () => { + const req = createReq({ path: '/legacy', url: '/legacy' }); + const res = createRes(); + const options = createOptions({ + redirectsService: createService([ + redirect({ + pattern: '/legacy', + target: 'https://example.org/page', + redirectType: REDIRECT_TYPE_SERVER_TRANSFER, + }), + ]), + }); + await createRedirectsMiddleware(options)(req, res, next); + expect(res.redirect).toHaveBeenCalledWith(302, 'https://example.org/page'); + expect(next).not.toHaveBeenCalled(); + }); + + it('answers /_data navigations with a redirect envelope instead of an HTTP redirect', async () => { + const service = createService([redirect({ pattern: '/about-us', target: '/about' })]); + const req = createReq({ + method: 'POST', + path: LOADER_DATA_ENDPOINT, + url: LOADER_DATA_ENDPOINT, + body: { loaderId: 'home', url: '/about-us', routeParams: {}, query: {} }, + }); + const res = createRes(); + await createRedirectsMiddleware(createOptions({ redirectsService: service }))(req, res, next); + expect(res.json).toHaveBeenCalledWith({ + kind: 'redirect', + redirect: { loaderRedirectTarget: '/about', status: 301 }, + }); + expect(res.redirect).not.toHaveBeenCalled(); + expect(next).not.toHaveBeenCalled(); + }); + + it('returns the external url in the redirect envelope for /_data navigations', async () => { + const service = createService([ + redirect({ pattern: '/ext', target: 'https://example.org/page' }), + ]); + const req = createReq({ + method: 'POST', + path: LOADER_DATA_ENDPOINT, + url: LOADER_DATA_ENDPOINT, + body: { loaderId: 'home', url: '/ext', routeParams: {}, query: {} }, + }); + const res = createRes(); + await createRedirectsMiddleware(createOptions({ redirectsService: service }))(req, res, next); + expect(res.json).toHaveBeenCalledWith({ + kind: 'redirect', + redirect: { loaderRedirectTarget: 'https://example.org/page', status: 301 }, + }); + }); + + it('rewrites the loader payload url for a SERVER_TRANSFER /_data navigation', async () => { + const service = createService([ + redirect({ + pattern: '/legacy', + target: '/modern', + redirectType: REDIRECT_TYPE_SERVER_TRANSFER, + }), + ]); + const body = { loaderId: 'home', url: '/legacy', routeParams: {}, query: {} }; + const req = createReq({ + method: 'POST', + path: LOADER_DATA_ENDPOINT, + url: LOADER_DATA_ENDPOINT, + body, + }); + const res = createRes(); + await createRedirectsMiddleware(createOptions({ redirectsService: service }))(req, res, next); + expect(body.url).toBe('/modern'); + expect(res.json).not.toHaveBeenCalled(); + expect(res.redirect).not.toHaveBeenCalled(); + expect(next).toHaveBeenCalledTimes(1); + }); + + it('skips when disabled', async () => { + const service = createService([redirect({ pattern: '/about-us', target: '/about' })]); + const req = createReq({ path: '/about-us', url: '/about-us' }); + const res = createRes(); + await createRedirectsMiddleware(createOptions({ enabled: false, redirectsService: service }))( + req, + res, + next + ); + expect(res.redirect).not.toHaveBeenCalled(); + expect(service.fetchRedirects).not.toHaveBeenCalled(); + expect(next).toHaveBeenCalledTimes(1); + }); + + it('warns and no-ops when neither Edge nor local API config is provided', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + const req = createReq({ path: '/about-us', url: '/about-us' }); + const res = createRes(); + await createRedirectsMiddleware( + createOptions({ redirectsService: undefined }) + )(req, res, next); + expect(warn).toHaveBeenCalled(); + expect(res.redirect).not.toHaveBeenCalled(); + expect(next).toHaveBeenCalledTimes(1); + warn.mockRestore(); + }); + + it('skips editing/preview requests', async () => { + const service = createService([redirect({ pattern: '/about-us', target: '/about' })]); + const req = createReq({ + path: '/about-us', + url: '/about-us', + headers: { + host: 'a.example.com', + [EDITING_PARAMS_HEADER]: JSON.stringify({ site: 'site-a' }), + }, + }); + await createRedirectsMiddleware(createOptions({ redirectsService: service }))( + req, + createRes(), + next + ); + expect(service.fetchRedirects).not.toHaveBeenCalled(); + expect(next).toHaveBeenCalledTimes(1); + }); + + it('skips api, sitecore and static-file routes', async () => { + const service = createService([redirect({ pattern: '/x', target: '/y' })]); + for (const path of ['/api/data', '/sitecore/render', '/assets/logo.png']) { + const req = createReq({ path, url: path }); + await createRedirectsMiddleware(createOptions({ redirectsService: service }))( + req, + createRes(), + next + ); + } + expect(service.fetchRedirects).not.toHaveBeenCalled(); + }); + + it('skips when the custom skip predicate returns true', async () => { + const service = createService([redirect({ pattern: '/about-us', target: '/about' })]); + const req = createReq({ path: '/about-us', url: '/about-us' }); + await createRedirectsMiddleware( + createOptions({ redirectsService: service, skip: () => true }) + )(req, createRes(), next); + expect(service.fetchRedirects).not.toHaveBeenCalled(); + }); + + it('skips prefetch requests and disables caching', async () => { + const service = createService([redirect({ pattern: '/about-us', target: '/about' })]); + const req = createReq({ + path: '/about-us', + url: '/about-us', + headers: { host: 'a.example.com', purpose: 'prefetch' }, + }); + const res = createRes(); + await createRedirectsMiddleware(createOptions({ redirectsService: service }))(req, res, next); + expect(res.setHeader).toHaveBeenCalledWith('x-proxy-cache', 'no-cache'); + expect(res.setHeader).toHaveBeenCalledWith('Cache-Control', 'no-store, must-revalidate'); + expect(service.fetchRedirects).not.toHaveBeenCalled(); + expect(next).toHaveBeenCalledTimes(1); + }); + + it('skips when the site cannot be resolved', async () => { + const service = createService([redirect({ pattern: '/about-us', target: '/about' })]); + const req = createReq({ path: '/about-us', url: '/about-us', scParams: undefined }); + await createRedirectsMiddleware( + createOptions({ redirectsService: service, defaultSite: undefined }) + )(req, createRes(), next); + expect(service.fetchRedirects).not.toHaveBeenCalled(); + expect(next).toHaveBeenCalledTimes(1); + }); + + it('passes through when no redirect matches', async () => { + const req = createReq({ path: '/about-us', url: '/about-us' }); + const res = createRes(); + await createRedirectsMiddleware(createOptions())(req, res, next); + expect(res.redirect).not.toHaveBeenCalled(); + expect(next).toHaveBeenCalledTimes(1); + }); + + it('logs and calls next when fetching redirects throws (fail-open)', async () => { + const log = vi.spyOn(console, 'log').mockImplementation(() => undefined); + const service = { + fetchRedirects: vi.fn().mockRejectedValue(new Error('boom')), + } as unknown as RedirectsService; + const req = createReq({ path: '/about-us', url: '/about-us' }); + const res = createRes(); + await createRedirectsMiddleware(createOptions({ redirectsService: service }))(req, res, next); + expect(log).toHaveBeenCalledWith('Redirects middleware failed:'); + expect(res.redirect).not.toHaveBeenCalled(); + expect(next).toHaveBeenCalledTimes(1); + log.mockRestore(); + }); +}); diff --git a/packages/angular/src/server/middleware/redirects-middleware.ts b/packages/angular/src/server/middleware/redirects-middleware.ts new file mode 100644 index 0000000000..9d4cac6e5d --- /dev/null +++ b/packages/angular/src/server/middleware/redirects-middleware.ts @@ -0,0 +1,396 @@ +import { + breakDownPath, + isAbsoluteTarget, + matchFromRedirectMapRedirect, + matchRedirectItemRedirect, + processAbsoluteUrlTarget, + processRelativeUrlTarget, + resolveRedirectTarget, + RedirectResult, + RedirectsService, + RedirectsServiceConfig, + REDIRECT_TYPE_301, + REDIRECT_TYPE_302, + REDIRECT_TYPE_SERVER_TRANSFER, + SITE_KEY, + SiteInfo, + SiteResolver, +} from '@sitecore-content-sdk/content/site'; +import { SitecoreConfig } from '@sitecore-content-sdk/content/config'; +import { createGraphQLClientFactory } from '@sitecore-content-sdk/content/client'; +import { + BaseMiddlewareOptions, + CsdkExpressRequest, + ExpressMiddleware, + ExpressNextFunction, + ExpressRequest, + ExpressResponse, +} from './models'; +import { getMiddlewareRequest, isDataLoaderRequest, shouldProcessPath } from './utils'; +import { splitLocaleFromPath } from '../../i18n/locale-utils'; +import { isEditingPreview } from '../utils'; +import type { LoaderApiResponse, LoaderPayload } from '../../loaders/models'; +import debug from '../../debug'; + +/** + * Configuration for the redirects middleware. + * @public + */ +export type RedirectsMiddlewareOptions = BaseMiddlewareOptions & + Omit & + Partial & + Partial> & + SitecoreConfig['redirects'] & { + /** Fallback language when the request path has no locale prefix. Default is `'en'`. */ + defaultLanguage?: string; + /** Fallback site name when not resolved by the multisite middleware or site cookie. */ + defaultSite?: string; + /** Sites used to resolve the site's default language for the `$siteLang` token. */ + sites?: SiteInfo[]; + /** Override the redirects service instance (e.g. for testing). */ + redirectsService?: RedirectsService; + }; + +const isPrefetch = (req: ExpressRequest): boolean => + [req.headers?.purpose, req.headers?.['sec-purpose']].some( + (header) => typeof header === 'string' && header.includes('prefetch') + ); + +/** + * Serializes an Express query object back into a query string (without a leading `?`). + * @param {Record} query - Express query object. + * @returns {string} Query string. + */ +const serializeQuery = (query: Record): string => { + const params = new URLSearchParams(); + for (const [key, value] of Object.entries(query)) { + if (value === undefined) continue; + if (Array.isArray(value)) { + value.forEach((entry) => params.append(key, entry)); + } else { + params.append(key, value); + } + } + return params.toString(); +}; + +/** + * Builds the redirects service from the middleware options, mirroring the Next.js proxy: + * uses an injected service when provided, otherwise requires Edge (contextId/clientContextId) or + * local (apiHost/apiKey) API config. Returns `null` (disabling the middleware) when neither is set. + * @param {RedirectsMiddlewareOptions} options - Middleware options. + * @returns {RedirectsService | null} The redirects service or `null` when not configured. + */ +const resolveRedirectsService = (options: RedirectsMiddlewareOptions): RedirectsService | null => { + if (options.redirectsService) { + return options.redirectsService; + } + + const hasEdgeConfig = !!(options.contextId || options.clientContextId); + const hasLocalConfig = !!(options.apiHost && options.apiKey); + + if (!hasEdgeConfig && !hasLocalConfig) { + console.warn( + '[RedirectsMiddleware] Redirects middleware requires either Edge configuration (contextId/clientContextId) or local API configuration (apiHost/apiKey). ' + + 'Redirects features will be disabled. This is expected when API configuration is not available.' + ); + return null; + } + + const graphQLOptions = { + api: { + edge: { + contextId: options.contextId!, + clientContextId: options.clientContextId, + edgeUrl: options.edgeUrl, + }, + ...(options.apiHost && options.apiKey + ? { + local: { + apiHost: options.apiHost, + apiKey: options.apiKey, + path: options.path, + }, + } + : {}), + }, + }; + + return new RedirectsService({ + ...options, + clientFactory: createGraphQLClientFactory(graphQLOptions), + fetch: fetch, + }); +}; + +/** + * Middleware to support Sitecore redirects on the Angular Express SSR server. + * + * Fetches redirects for the resolved site, matches the incoming request against locale-versioned + * and redirect-map (static/regex) rules using the shared redirect utilities, and dispatches a + * 301/302 redirect or an internal server-transfer rewrite. Fails open (`next()`) on any error so a + * misconfigured redirect never takes the site down. + * + * Must run after the multisite middleware (which resolves `scParams.siteName`) and before the + * Angular SSR handler. + * @param {RedirectsMiddlewareOptions} options - Redirects middleware options. + * @returns {ExpressMiddleware} Express middleware. + * @public + */ +export function createRedirectsMiddleware(options: RedirectsMiddlewareOptions): ExpressMiddleware { + const redirectsService = resolveRedirectsService(options); + const locales = options.locales ?? []; + const siteResolver = new SiteResolver(options.sites ?? []); + + return async (req: ExpressRequest, res: ExpressResponse, next: ExpressNextFunction) => { + try { + // `enabled` defaults to true: omitting it keeps the middleware on (see BaseMiddlewareOptions). + if (options.enabled === false || !redirectsService) { + debug.redirects('redirects middleware disabled or not configured'); + return next(); + } + + // For browser loader navigations (/_data) routing data comes from the loader payload, not + // the request; getMiddlewareRequest normalizes both into path/query/data. + const { path, query, data } = getMiddlewareRequest(req); + + if (isEditingPreview(data.headers)) { + debug.redirects('skipped (editing/preview mode)'); + return next(); + } + + // Path matching also covers static files (paths with an extension), API and Sitecore routes. + if (!shouldProcessPath(path, options.matcher)) { + debug.redirects('redirects middleware skipped (path does not match)'); + return next(); + } + + if (options.skip?.(req)) { + debug.redirects('redirects middleware skipped (skip predicate)'); + return next(); + } + + if (isPrefetch(req)) { + // Don't burden the redirects service with prefetch traffic; disable caching so the + // real navigation still evaluates redirects. + debug.redirects('skipped (prefetch)'); + res.setHeader?.('x-proxy-cache', 'no-cache'); + res.setHeader?.('Cache-Control', 'no-store, must-revalidate'); + return next(); + } + + const startTimestamp = Date.now(); + const { locale } = splitLocaleFromPath(path, locales); + const language = locale || options.defaultLanguage || 'en'; + const siteName = + (req as CsdkExpressRequest).scParams?.siteName || + data.cookies?.[SITE_KEY] || + options.defaultSite; + + debug.redirects('redirects middleware start: %o', { path, language, siteName }); + + if (!siteName) { + debug.redirects('skipped (site could not be resolved)'); + return next(); + } + + const incomingURL = path; + const incomingQS = serializeQuery(query); + + const existsRedirect = await getExistsRedirect( + redirectsService, + siteName, + language, + locales, + incomingURL, + incomingQS + ); + + if (!existsRedirect) { + debug.redirects('skipped (redirect does not exist)'); + return next(); + } + + debug.redirects('Matched redirect rule: %o', { existsRedirect }); + + const incomingPathData = breakDownPath(locales, incomingURL); + incomingPathData.queryString = incomingQS || undefined; + + // Site's default language drives the `$siteLang` token; fall back to the request language. + const siteLanguage = siteResolver.getByName(siteName)?.language || language; + existsRedirect.target = resolveRedirectTarget(existsRedirect, siteLanguage, incomingURL); + + // Browser loader navigations (POST/GET /_data) can't be driven by an HTTP redirect: the + // client fetch would silently follow it. For those we answer through the loader-data channel + // so the client-side router performs the navigation (see dispatchRedirect). + const loaderDataRequest = isDataLoaderRequest(req); + + if (isAbsoluteTarget(existsRedirect.target)) { + const targetUrl = processAbsoluteUrlTarget(incomingPathData, existsRedirect); + dispatchRedirect( + targetUrl, + existsRedirect.redirectType, + req, + res, + next, + true, + loaderDataRequest + ); + } else { + const { targetLocale, targetPath } = processRelativeUrlTarget( + incomingPathData, + existsRedirect, + locales, + language + ); + // Angular is locale-in-path, but a locale-less request must stay locale-less when the + // target resolves to the same locale (e.g. `isLanguagePreserved` on a `/foo` → `/bar` + // rule). Only prefix the locale segment when the origin was already locale-prefixed, or + // the target locale actually differs from the request locale. + const incomingHadLocale = !!incomingPathData.locale; + const localeChanged = targetLocale.toLowerCase() !== language.toLowerCase(); + const shouldPrefixLocale = + !!targetLocale && locales.length > 0 && (incomingHadLocale || localeChanged); + const finalPath = shouldPrefixLocale ? `/${targetLocale}${targetPath}` : targetPath; + dispatchRedirect( + finalPath, + existsRedirect.redirectType, + req, + res, + next, + false, + loaderDataRequest + ); + } + + debug.redirects('redirects middleware end in %dms', Date.now() - startTimestamp); + } catch (error) { + console.log('Redirects middleware failed:'); + console.log(error); + next(); + } + }; +} + +/** + * Finds a matching redirect for the incoming request, mirroring `RedirectsProxy.getExistsRedirect`: + * locale-versioned item rules take precedence over redirect-map (static/regex) rules. + * @param {RedirectsService} service - Redirects service. + * @param {string} siteName - Resolved site name. + * @param {string} language - Request language used to match versioned rules. + * @param {string[]} locales - Configured locales. + * @param {string} incomingURL - Request pathname (no query string). + * @param {string} incomingQS - Request query string (without leading `?`). + * @returns {Promise} Matched redirect or `undefined`. + */ +async function getExistsRedirect( + service: RedirectsService, + siteName: string, + language: string, + locales: string[], + incomingURL: string, + incomingQS: string +): Promise { + const redirects = await service.fetchRedirects(siteName); + + // strip trailing slashes and lowercase to compare against locale-less patterns + const normalizedPath = incomingURL.replace(/\/*$/gi, '').toLowerCase(); + const { nonLocalePath } = breakDownPath(locales, normalizedPath); + const matchedLocaleRedirect = matchRedirectItemRedirect(redirects, language, nonLocalePath); + if (matchedLocaleRedirect) { + return matchedLocaleRedirect; + } + + const incomingPathData = breakDownPath(locales, incomingURL); + incomingPathData.queryString = incomingQS || undefined; + return matchFromRedirectMapRedirect(redirects, language, incomingPathData); +} + +/** + * Rewrites the loader request URL for a `/_data` navigation so the loader-data-service middleware + * resolves the target route's data (server-transfer / silent rewrite — the browser URL is unchanged). + * @param {ExpressRequest} req - Incoming `/_data` request. + * @param {string} target - Target loader URL (path with optional query string). + */ +function setLoaderRequestUrl(req: ExpressRequest, target: string): void { + if (req.method === 'POST' && req.body && typeof req.body === 'object') { + (req.body as LoaderPayload).url = target; + } else if (req.method === 'GET') { + req.query = { ...req.query, url: target }; + } +} + +/** + * Dispatches the resolved redirect through the right channel. + * + * Regular page requests: + * - 301/302 → `res.redirect(status, target)`. + * - SERVER_TRANSFER (internal) → rewrite `req.url`/`req.path` + `next()` so the SSR handler renders + * the target route without changing the browser URL. + * + * Browser loader navigations (`/_data`) can't follow an HTTP redirect, so they answer through the + * loader-data envelope instead: + * - 301/302 → `{ kind: 'redirect' }` so the client router navigates (internal) or the page reloads + * (external, via `applyRedirect`). + * - SERVER_TRANSFER (internal) → rewrite the loader payload URL + `next()` so the loader-data-service + * returns the target route's data while the browser URL stays put. + * + * External absolute targets can't be internally transferred, so SERVER_TRANSFER to an external URL + * falls back to a temporary (302) redirect. + * @param {string} target - Final redirect target (absolute URL or path). + * @param {string} type - Redirect type constant. + * @param {ExpressRequest} req - Incoming request. + * @param {ExpressResponse} res - Response. + * @param {ExpressNextFunction} next - Next function. + * @param {boolean} isExternal - Whether the target is an external absolute URL. + * @param {boolean} isDataRequest - Whether this is a `/_data` loader navigation. + */ +function dispatchRedirect( + target: string, + type: string, + req: ExpressRequest, + res: ExpressResponse, + next: ExpressNextFunction, + isExternal: boolean, + isDataRequest: boolean +): void { + const respondRedirect = (status: number): void => { + if (isDataRequest) { + const payload: LoaderApiResponse = { + kind: 'redirect', + redirect: { loaderRedirectTarget: target, status }, + }; + res.json(payload); + return; + } + res.redirect?.(status, target); + }; + + switch (type) { + case REDIRECT_TYPE_301: + respondRedirect(301); + return; + case REDIRECT_TYPE_302: + respondRedirect(302); + return; + case REDIRECT_TYPE_SERVER_TRANSFER: { + // External targets can't be rewritten internally; fall back to a temporary redirect. + if (isExternal) { + respondRedirect(302); + return; + } + // Internal rewrite: render the target route without changing the browser URL. + if (isDataRequest) { + setLoaderRequestUrl(req, target); + } else { + const [pathname] = target.split('?'); + req.url = target; + req.path = pathname; + } + next(); + return; + } + default: + next(); + } +} diff --git a/packages/create-content-sdk-app/src/templates/angular/src/server.ts b/packages/create-content-sdk-app/src/templates/angular/src/server.ts index 94e9cf9511..1b8df1eac3 100644 --- a/packages/create-content-sdk-app/src/templates/angular/src/server.ts +++ b/packages/create-content-sdk-app/src/templates/angular/src/server.ts @@ -16,6 +16,7 @@ import { createRobotsMiddleware, createMultisiteMiddleware, createPersonalizeMiddleware, + createRedirectsMiddleware, createSitecoreRevalidateMiddleware, createSitemapMiddleware, } from '@sitecore-content-sdk/angular'; @@ -95,8 +96,8 @@ app.use( app.use(createEditingRenderMiddleware()); /** - * Shared path matcher for the request-scoped middlewares (multisite + personalize, and any - * future redirects middleware). It decides which requests these middlewares act on. + * Shared path matcher for the request-scoped middlewares (multisite, redirects and personalize). + * It decides which requests these middlewares act on. * * Patterns are exact strings or RegExp. The SDK already skips API routes (`/api/*`), Sitecore * routes (`/sitecore/*`), static files (any path whose last segment has an extension) and @@ -125,6 +126,24 @@ app.use( }) ); +/** + * Redirects middleware. Matches each request against the site's Sitecore redirects (locale, + * static and regex rules) and issues a 301/302 redirect or an internal server-transfer rewrite. + * Runs after multisite (which resolves the site it fetches redirects for) and before personalize + * so a redirect short-circuits the request before a CDP call is made. + */ +app.use( + createRedirectsMiddleware({ + ...config.redirects, + ...config.api.edge, + ...(config.api.local ?? {}), + sites, + defaultLanguage: config.defaultLanguage, + defaultSite: config.defaultSite, + matcher: middlewareMatcher, + }) +); + /** * Personalize middleware. Identifies page/component variants for the request via * Sitecore CDP and writes them onto `req.scParams` so the page loader fetches the From d390ca7d77b1d95086597c3068147915494e4ee2 Mon Sep 17 00:00:00 2001 From: Artem Alexeyenko Date: Tue, 28 Jul 2026 19:46:45 -0400 Subject: [PATCH 2/7] adjust unit test --- .../middleware/redirects-middleware.spec.ts | 35 ++++++++++++------- 1 file changed, 22 insertions(+), 13 deletions(-) diff --git a/packages/angular/src/server/middleware/redirects-middleware.spec.ts b/packages/angular/src/server/middleware/redirects-middleware.spec.ts index beb18f717a..62d6fbce9b 100644 --- a/packages/angular/src/server/middleware/redirects-middleware.spec.ts +++ b/packages/angular/src/server/middleware/redirects-middleware.spec.ts @@ -1,20 +1,29 @@ -import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest'; -import { - RedirectInfo, - RedirectsService, - REDIRECT_TYPE_301, - REDIRECT_TYPE_302, - REDIRECT_TYPE_SERVER_TRANSFER, - type SiteInfo, -} from '@sitecore-content-sdk/content/site'; +import { describe, it, expect, vi, beforeAll, beforeEach, type Mock } from 'vitest'; +import type { RedirectInfo, RedirectsService, SiteInfo } from '@sitecore-content-sdk/content/site'; import { EDITING_PARAMS_HEADER } from '../../editing/constants'; import { LOADER_DATA_ENDPOINT } from '../constants'; -import { - createRedirectsMiddleware, - type RedirectsMiddlewareOptions, -} from './redirects-middleware'; +import type { RedirectsMiddlewareOptions } from './redirects-middleware'; import type { CsdkExpressRequest, ExpressResponse } from './models'; +// Loaded lazily in beforeAll rather than via static top-level imports. Statically importing the +// real ./redirects-middleware (and @sitecore-content-sdk/content/site) would eagerly pull the real +// @sitecore-content-sdk/core and /analytics-core into the module registry at file-eval time. Under +// the Angular test runner's `isolate: false`, that caches the real modules before sibling specs +// (personalize-middleware) register their `vi.mock`, defeating those mocks. This spec mocks nothing, +// so deferring the load keeps it from polluting the shared registry. +type SiteModule = typeof import('@sitecore-content-sdk/content/site'); +let createRedirectsMiddleware: typeof import('./redirects-middleware').createRedirectsMiddleware; +let REDIRECT_TYPE_301: SiteModule['REDIRECT_TYPE_301']; +let REDIRECT_TYPE_302: SiteModule['REDIRECT_TYPE_302']; +let REDIRECT_TYPE_SERVER_TRANSFER: SiteModule['REDIRECT_TYPE_SERVER_TRANSFER']; + +beforeAll(async () => { + ({ REDIRECT_TYPE_301, REDIRECT_TYPE_302, REDIRECT_TYPE_SERVER_TRANSFER } = await import( + '@sitecore-content-sdk/content/site' + )); + ({ createRedirectsMiddleware } = await import('./redirects-middleware')); +}); + const SITES: SiteInfo[] = [{ hostName: '*', language: 'en', name: 'site-a' }]; function createService(redirects: RedirectInfo[]): RedirectsService { From 76da9f55ad699d3ebba003101d020e44794fd985 Mon Sep 17 00:00:00 2001 From: Artem Alexeyenko Date: Wed, 29 Jul 2026 11:37:24 -0400 Subject: [PATCH 3/7] test without redirects spec --- .../middleware/redirects-middleware.spec.ts | 428 ------------------ 1 file changed, 428 deletions(-) delete mode 100644 packages/angular/src/server/middleware/redirects-middleware.spec.ts diff --git a/packages/angular/src/server/middleware/redirects-middleware.spec.ts b/packages/angular/src/server/middleware/redirects-middleware.spec.ts deleted file mode 100644 index 62d6fbce9b..0000000000 --- a/packages/angular/src/server/middleware/redirects-middleware.spec.ts +++ /dev/null @@ -1,428 +0,0 @@ -import { describe, it, expect, vi, beforeAll, beforeEach, type Mock } from 'vitest'; -import type { RedirectInfo, RedirectsService, SiteInfo } from '@sitecore-content-sdk/content/site'; -import { EDITING_PARAMS_HEADER } from '../../editing/constants'; -import { LOADER_DATA_ENDPOINT } from '../constants'; -import type { RedirectsMiddlewareOptions } from './redirects-middleware'; -import type { CsdkExpressRequest, ExpressResponse } from './models'; - -// Loaded lazily in beforeAll rather than via static top-level imports. Statically importing the -// real ./redirects-middleware (and @sitecore-content-sdk/content/site) would eagerly pull the real -// @sitecore-content-sdk/core and /analytics-core into the module registry at file-eval time. Under -// the Angular test runner's `isolate: false`, that caches the real modules before sibling specs -// (personalize-middleware) register their `vi.mock`, defeating those mocks. This spec mocks nothing, -// so deferring the load keeps it from polluting the shared registry. -type SiteModule = typeof import('@sitecore-content-sdk/content/site'); -let createRedirectsMiddleware: typeof import('./redirects-middleware').createRedirectsMiddleware; -let REDIRECT_TYPE_301: SiteModule['REDIRECT_TYPE_301']; -let REDIRECT_TYPE_302: SiteModule['REDIRECT_TYPE_302']; -let REDIRECT_TYPE_SERVER_TRANSFER: SiteModule['REDIRECT_TYPE_SERVER_TRANSFER']; - -beforeAll(async () => { - ({ REDIRECT_TYPE_301, REDIRECT_TYPE_302, REDIRECT_TYPE_SERVER_TRANSFER } = await import( - '@sitecore-content-sdk/content/site' - )); - ({ createRedirectsMiddleware } = await import('./redirects-middleware')); -}); - -const SITES: SiteInfo[] = [{ hostName: '*', language: 'en', name: 'site-a' }]; - -function createService(redirects: RedirectInfo[]): RedirectsService { - return { - fetchRedirects: vi.fn().mockResolvedValue(redirects), - } as unknown as RedirectsService; -} - -function createOptions( - overrides: Partial = {} -): RedirectsMiddlewareOptions { - return { - enabled: true, - locales: ['en'], - defaultLanguage: 'en', - defaultSite: 'site-a', - sites: SITES, - redirectsService: createService([]), - ...overrides, - } as RedirectsMiddlewareOptions; -} - -function createReq(overrides: Partial = {}): CsdkExpressRequest { - return { - method: 'GET', - path: '/', - url: '/', - body: undefined, - query: {}, - cookies: {}, - headers: { host: 'a.example.com' }, - scParams: { siteName: 'site-a' }, - ...overrides, - }; -} - -function createRes() { - return { - redirect: vi.fn(), - setHeader: vi.fn(), - status: vi.fn().mockReturnThis(), - json: vi.fn(), - } as unknown as ExpressResponse & { redirect: Mock; setHeader: Mock }; -} - -const redirect = (overrides: Partial): RedirectInfo => ({ - pattern: '', - target: '', - redirectType: REDIRECT_TYPE_301, - isQueryStringPreserved: false, - locale: '', - ...overrides, -}); - -describe('createRedirectsMiddleware', () => { - const next = vi.fn(); - beforeEach(() => vi.clearAllMocks()); - - it('returns a middleware', () => { - expect(createRedirectsMiddleware(createOptions())).toBeTypeOf('function'); - }); - - it('matches a locale-versioned redirect item and issues a 301', async () => { - const req = createReq({ path: '/en/old', url: '/en/old' }); - const res = createRes(); - const options = createOptions({ - redirectsService: createService([ - redirect({ locale: 'en', pattern: '/old', target: '/new' }), - ]), - }); - await createRedirectsMiddleware(options)(req, res, next); - expect(res.redirect).toHaveBeenCalledWith(301, '/new'); - expect(next).not.toHaveBeenCalled(); - }); - - it('preserves the request locale on relative targets when isLanguagePreserved', async () => { - const req = createReq({ path: '/en/old', url: '/en/old' }); - const res = createRes(); - const options = createOptions({ - redirectsService: createService([ - redirect({ locale: 'en', pattern: '/old', target: '/new', isLanguagePreserved: true }), - ]), - }); - await createRedirectsMiddleware(options)(req, res, next); - expect(res.redirect).toHaveBeenCalledWith(301, '/en/new'); - }); - - it('keeps a locale-less URL locale-less when the target resolves to the same locale', async () => { - const req = createReq({ path: '/old', url: '/old' }); - const res = createRes(); - const options = createOptions({ - redirectsService: createService([ - redirect({ pattern: '/old', target: '/new', isLanguagePreserved: true }), - ]), - }); - await createRedirectsMiddleware(options)(req, res, next); - expect(res.redirect).toHaveBeenCalledWith(301, '/new'); - }); - - it('adds the target locale when it differs from a locale-less origin', async () => { - const req = createReq({ path: '/old', url: '/old' }); - const res = createRes(); - const options = createOptions({ - locales: ['en', 'fr'], - redirectsService: createService([redirect({ pattern: '/old', target: '/fr/new' })]), - }); - await createRedirectsMiddleware(options)(req, res, next); - expect(res.redirect).toHaveBeenCalledWith(301, '/fr/new'); - }); - - it('matches a static redirect-map rule and issues a 302', async () => { - const req = createReq({ path: '/about-us', url: '/about-us' }); - const res = createRes(); - const options = createOptions({ - redirectsService: createService([ - redirect({ pattern: '/about-us', target: '/about', redirectType: REDIRECT_TYPE_302 }), - ]), - }); - await createRedirectsMiddleware(options)(req, res, next); - expect(res.redirect).toHaveBeenCalledWith(302, '/about'); - }); - - it('matches a regex rule and applies capture-group substitution', async () => { - const req = createReq({ path: '/products/shoes', url: '/products/shoes' }); - const res = createRes(); - const options = createOptions({ - redirectsService: createService([ - redirect({ pattern: '/products/(.*)', target: '/shop/$1' }), - ]), - }); - await createRedirectsMiddleware(options)(req, res, next); - expect(res.redirect).toHaveBeenCalledWith(301, '/shop/shoes'); - }); - - it('substitutes the $siteLang token from the resolved site language', async () => { - // Locale-prefixed origin so the injected locale is preserved on the target. - const req = createReq({ path: '/en/home', url: '/en/home' }); - const res = createRes(); - const options = createOptions({ - redirectsService: createService([ - redirect({ pattern: '/home', target: '/$siteLang/welcome' }), - ]), - }); - await createRedirectsMiddleware(options)(req, res, next); - expect(res.redirect).toHaveBeenCalledWith(301, '/en/welcome'); - }); - - it('preserves the query string on relative targets when isQueryStringPreserved', async () => { - const req = createReq({ path: '/search', url: '/search?q=shoes', query: { q: 'shoes' } }); - const res = createRes(); - const options = createOptions({ - redirectsService: createService([ - redirect({ - pattern: '/search', - target: '/results', - redirectType: REDIRECT_TYPE_302, - isQueryStringPreserved: true, - }), - ]), - }); - await createRedirectsMiddleware(options)(req, res, next); - expect(res.redirect).toHaveBeenCalledWith(302, '/results?q=shoes'); - }); - - it('redirects to an absolute external target without locale stripping', async () => { - const req = createReq({ path: '/ext', url: '/ext' }); - const res = createRes(); - const options = createOptions({ - redirectsService: createService([ - redirect({ pattern: '/ext', target: 'https://example.org/page' }), - ]), - }); - await createRedirectsMiddleware(options)(req, res, next); - expect(res.redirect).toHaveBeenCalledWith(301, 'https://example.org/page'); - }); - - it('merges the query string into an absolute target when isQueryStringPreserved', async () => { - const req = createReq({ path: '/ext', url: '/ext?a=1', query: { a: '1' } }); - const res = createRes(); - const options = createOptions({ - redirectsService: createService([ - redirect({ - pattern: '/ext', - target: 'https://example.org/page', - isQueryStringPreserved: true, - }), - ]), - }); - await createRedirectsMiddleware(options)(req, res, next); - expect(res.redirect).toHaveBeenCalledWith(301, 'https://example.org/page?a=1'); - }); - - it('performs an internal rewrite (no browser redirect) for SERVER_TRANSFER', async () => { - const req = createReq({ path: '/legacy', url: '/legacy' }); - const res = createRes(); - const options = createOptions({ - redirectsService: createService([ - redirect({ - pattern: '/legacy', - target: '/modern', - redirectType: REDIRECT_TYPE_SERVER_TRANSFER, - }), - ]), - }); - await createRedirectsMiddleware(options)(req, res, next); - expect(res.redirect).not.toHaveBeenCalled(); - expect(req.url).toBe('/modern'); - expect(req.path).toBe('/modern'); - expect(next).toHaveBeenCalledTimes(1); - }); - - it('falls back to a 302 for SERVER_TRANSFER to an external target', async () => { - const req = createReq({ path: '/legacy', url: '/legacy' }); - const res = createRes(); - const options = createOptions({ - redirectsService: createService([ - redirect({ - pattern: '/legacy', - target: 'https://example.org/page', - redirectType: REDIRECT_TYPE_SERVER_TRANSFER, - }), - ]), - }); - await createRedirectsMiddleware(options)(req, res, next); - expect(res.redirect).toHaveBeenCalledWith(302, 'https://example.org/page'); - expect(next).not.toHaveBeenCalled(); - }); - - it('answers /_data navigations with a redirect envelope instead of an HTTP redirect', async () => { - const service = createService([redirect({ pattern: '/about-us', target: '/about' })]); - const req = createReq({ - method: 'POST', - path: LOADER_DATA_ENDPOINT, - url: LOADER_DATA_ENDPOINT, - body: { loaderId: 'home', url: '/about-us', routeParams: {}, query: {} }, - }); - const res = createRes(); - await createRedirectsMiddleware(createOptions({ redirectsService: service }))(req, res, next); - expect(res.json).toHaveBeenCalledWith({ - kind: 'redirect', - redirect: { loaderRedirectTarget: '/about', status: 301 }, - }); - expect(res.redirect).not.toHaveBeenCalled(); - expect(next).not.toHaveBeenCalled(); - }); - - it('returns the external url in the redirect envelope for /_data navigations', async () => { - const service = createService([ - redirect({ pattern: '/ext', target: 'https://example.org/page' }), - ]); - const req = createReq({ - method: 'POST', - path: LOADER_DATA_ENDPOINT, - url: LOADER_DATA_ENDPOINT, - body: { loaderId: 'home', url: '/ext', routeParams: {}, query: {} }, - }); - const res = createRes(); - await createRedirectsMiddleware(createOptions({ redirectsService: service }))(req, res, next); - expect(res.json).toHaveBeenCalledWith({ - kind: 'redirect', - redirect: { loaderRedirectTarget: 'https://example.org/page', status: 301 }, - }); - }); - - it('rewrites the loader payload url for a SERVER_TRANSFER /_data navigation', async () => { - const service = createService([ - redirect({ - pattern: '/legacy', - target: '/modern', - redirectType: REDIRECT_TYPE_SERVER_TRANSFER, - }), - ]); - const body = { loaderId: 'home', url: '/legacy', routeParams: {}, query: {} }; - const req = createReq({ - method: 'POST', - path: LOADER_DATA_ENDPOINT, - url: LOADER_DATA_ENDPOINT, - body, - }); - const res = createRes(); - await createRedirectsMiddleware(createOptions({ redirectsService: service }))(req, res, next); - expect(body.url).toBe('/modern'); - expect(res.json).not.toHaveBeenCalled(); - expect(res.redirect).not.toHaveBeenCalled(); - expect(next).toHaveBeenCalledTimes(1); - }); - - it('skips when disabled', async () => { - const service = createService([redirect({ pattern: '/about-us', target: '/about' })]); - const req = createReq({ path: '/about-us', url: '/about-us' }); - const res = createRes(); - await createRedirectsMiddleware(createOptions({ enabled: false, redirectsService: service }))( - req, - res, - next - ); - expect(res.redirect).not.toHaveBeenCalled(); - expect(service.fetchRedirects).not.toHaveBeenCalled(); - expect(next).toHaveBeenCalledTimes(1); - }); - - it('warns and no-ops when neither Edge nor local API config is provided', async () => { - const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); - const req = createReq({ path: '/about-us', url: '/about-us' }); - const res = createRes(); - await createRedirectsMiddleware( - createOptions({ redirectsService: undefined }) - )(req, res, next); - expect(warn).toHaveBeenCalled(); - expect(res.redirect).not.toHaveBeenCalled(); - expect(next).toHaveBeenCalledTimes(1); - warn.mockRestore(); - }); - - it('skips editing/preview requests', async () => { - const service = createService([redirect({ pattern: '/about-us', target: '/about' })]); - const req = createReq({ - path: '/about-us', - url: '/about-us', - headers: { - host: 'a.example.com', - [EDITING_PARAMS_HEADER]: JSON.stringify({ site: 'site-a' }), - }, - }); - await createRedirectsMiddleware(createOptions({ redirectsService: service }))( - req, - createRes(), - next - ); - expect(service.fetchRedirects).not.toHaveBeenCalled(); - expect(next).toHaveBeenCalledTimes(1); - }); - - it('skips api, sitecore and static-file routes', async () => { - const service = createService([redirect({ pattern: '/x', target: '/y' })]); - for (const path of ['/api/data', '/sitecore/render', '/assets/logo.png']) { - const req = createReq({ path, url: path }); - await createRedirectsMiddleware(createOptions({ redirectsService: service }))( - req, - createRes(), - next - ); - } - expect(service.fetchRedirects).not.toHaveBeenCalled(); - }); - - it('skips when the custom skip predicate returns true', async () => { - const service = createService([redirect({ pattern: '/about-us', target: '/about' })]); - const req = createReq({ path: '/about-us', url: '/about-us' }); - await createRedirectsMiddleware( - createOptions({ redirectsService: service, skip: () => true }) - )(req, createRes(), next); - expect(service.fetchRedirects).not.toHaveBeenCalled(); - }); - - it('skips prefetch requests and disables caching', async () => { - const service = createService([redirect({ pattern: '/about-us', target: '/about' })]); - const req = createReq({ - path: '/about-us', - url: '/about-us', - headers: { host: 'a.example.com', purpose: 'prefetch' }, - }); - const res = createRes(); - await createRedirectsMiddleware(createOptions({ redirectsService: service }))(req, res, next); - expect(res.setHeader).toHaveBeenCalledWith('x-proxy-cache', 'no-cache'); - expect(res.setHeader).toHaveBeenCalledWith('Cache-Control', 'no-store, must-revalidate'); - expect(service.fetchRedirects).not.toHaveBeenCalled(); - expect(next).toHaveBeenCalledTimes(1); - }); - - it('skips when the site cannot be resolved', async () => { - const service = createService([redirect({ pattern: '/about-us', target: '/about' })]); - const req = createReq({ path: '/about-us', url: '/about-us', scParams: undefined }); - await createRedirectsMiddleware( - createOptions({ redirectsService: service, defaultSite: undefined }) - )(req, createRes(), next); - expect(service.fetchRedirects).not.toHaveBeenCalled(); - expect(next).toHaveBeenCalledTimes(1); - }); - - it('passes through when no redirect matches', async () => { - const req = createReq({ path: '/about-us', url: '/about-us' }); - const res = createRes(); - await createRedirectsMiddleware(createOptions())(req, res, next); - expect(res.redirect).not.toHaveBeenCalled(); - expect(next).toHaveBeenCalledTimes(1); - }); - - it('logs and calls next when fetching redirects throws (fail-open)', async () => { - const log = vi.spyOn(console, 'log').mockImplementation(() => undefined); - const service = { - fetchRedirects: vi.fn().mockRejectedValue(new Error('boom')), - } as unknown as RedirectsService; - const req = createReq({ path: '/about-us', url: '/about-us' }); - const res = createRes(); - await createRedirectsMiddleware(createOptions({ redirectsService: service }))(req, res, next); - expect(log).toHaveBeenCalledWith('Redirects middleware failed:'); - expect(res.redirect).not.toHaveBeenCalled(); - expect(next).toHaveBeenCalledTimes(1); - log.mockRestore(); - }); -}); From dab938c8ce9224a88fbde93138a2906b7663f609 Mon Sep 17 00:00:00 2001 From: Artem Alexeyenko Date: Wed, 29 Jul 2026 11:51:38 -0400 Subject: [PATCH 4/7] test without personalize middleware spec --- .../middleware/personalize-middleware.spec.ts | 460 ------------------ .../middleware/redirects-middleware.spec.ts | 428 ++++++++++++++++ 2 files changed, 428 insertions(+), 460 deletions(-) delete mode 100644 packages/angular/src/server/middleware/personalize-middleware.spec.ts create mode 100644 packages/angular/src/server/middleware/redirects-middleware.spec.ts diff --git a/packages/angular/src/server/middleware/personalize-middleware.spec.ts b/packages/angular/src/server/middleware/personalize-middleware.spec.ts deleted file mode 100644 index 0331daef2c..0000000000 --- a/packages/angular/src/server/middleware/personalize-middleware.spec.ts +++ /dev/null @@ -1,460 +0,0 @@ -/* eslint-disable jsdoc/require-jsdoc */ -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { - CdpHelper, - DEFAULT_VARIANT, - PersonalizeService, -} from '@sitecore-content-sdk/content/personalize'; -import { SITE_KEY } from '@sitecore-content-sdk/content/site'; -import { EDITING_PARAMS_HEADER } from '../../editing/constants'; -import { LOADER_DATA_ENDPOINT } from '../constants'; -import { SC_PARAMS_HEADER } from '../../loaders/constants'; -import type { CsdkExpressRequest, ExpressMiddleware, ExpressResponse } from './models'; -import type { PersonalizeMiddlewareOptions } from './personalize-middleware'; -const { - initContentSdkMock, - personalizeMock, - personalizeServerPluginMock, - personalizeServerAdapterMock, - analyticsPluginMock, - analyticsServerAdapterMock, -} = vi.hoisted(() => ({ - initContentSdkMock: vi.fn().mockResolvedValue(undefined), - personalizeMock: vi.fn(), - personalizeServerPluginMock: vi.fn(), - personalizeServerAdapterMock: vi.fn(), - analyticsPluginMock: vi.fn(), - analyticsServerAdapterMock: vi.fn(), -})); - -vi.mock('@sitecore-content-sdk/core', async (importOriginal) => ({ - ...(await importOriginal>()), - initContentSdk: initContentSdkMock, -})); -vi.mock('@sitecore-content-sdk/personalize', () => ({ - personalize: personalizeMock, - personalizeServerPlugin: personalizeServerPluginMock, - personalizeServerAdapter: personalizeServerAdapterMock, -})); -vi.mock('@sitecore-content-sdk/analytics-core', () => ({ - analyticsPlugin: analyticsPluginMock, - analyticsServerAdapter: analyticsServerAdapterMock, -})); - -type CreatePersonalizeMiddleware = (options: PersonalizeMiddlewareOptions) => ExpressMiddleware; - -let createPersonalizeMiddleware: CreatePersonalizeMiddleware; - -const getPersonalizeInfo = vi.fn(); -const personalizeService = { - getPersonalizeInfo, -} as unknown as PersonalizeService; - -function createOptions( - overrides: Partial = {} -): PersonalizeMiddlewareOptions { - return { - enabled: true, - contextId: 'context-id', - defaultSite: 'website', - locales: ['en', 'da'], - personalizeService, - ...overrides, - }; -} - -function createReq(overrides: Partial = {}): CsdkExpressRequest { - return { - method: 'GET', - path: '/about', - url: '/about', - body: undefined, - query: {}, - cookies: {}, - headers: { host: 'example.com' }, - scParams: { siteName: 'website', variantId: DEFAULT_VARIANT }, - ...overrides, - }; -} - -function createRes() { - return { setHeader: vi.fn() } as unknown as ExpressResponse & { - setHeader: ReturnType; - }; -} - -describe('createPersonalizeMiddleware', () => { - const next = vi.fn(); - - beforeEach(async () => { - // Vitest runs with `isolate: false` under the Angular unit-test builder; reset modules and - // re-import the SUT so it binds to this file's mocks regardless of sibling-spec ordering. - vi.resetModules(); - vi.clearAllMocks(); - initContentSdkMock.mockResolvedValue(undefined); - ({ createPersonalizeMiddleware } = await import('./personalize-middleware')); - }); - - it('should populate req.scParams with identified page-level variant', async () => { - getPersonalizeInfo.mockResolvedValue({ pageId: 'page-1', variantIds: ['variant-a'] }); - personalizeMock.mockResolvedValue({ variantId: 'variant-a' }); - const req = createReq(); - - await createPersonalizeMiddleware(createOptions())(req, createRes(), next); - - expect(getPersonalizeInfo).toHaveBeenCalledWith('/about', 'en', 'website'); - expect(initContentSdkMock).toHaveBeenCalled(); - expect(personalizeMock).toHaveBeenCalledWith( - expect.objectContaining({ - channel: 'WEB', - currency: 'USD', - friendlyId: CdpHelper.getPageFriendlyId('page-1', 'en'), - language: 'en', - pageVariantIds: ['variant-a'], - }), - { timeout: undefined } - ); - expect(req.scParams).toEqual({ - siteName: 'website', - variantId: 'variant-a', - componentVariantIds: [], - }); - expect(next).toHaveBeenCalledTimes(1); - }); - - it('should populate req.scParams.componentVariantIds with identified component-level variants', async () => { - getPersonalizeInfo.mockResolvedValue({ pageId: 'page-1', variantIds: ['comp1_var1'] }); - personalizeMock.mockResolvedValue({ variantId: 'comp1_var1' }); - const req = createReq(); - - await createPersonalizeMiddleware(createOptions())(req, createRes(), next); - - expect(personalizeMock).toHaveBeenCalledWith( - expect.objectContaining({ - friendlyId: CdpHelper.getComponentFriendlyId('page-1', 'comp1', 'en'), - pageVariantIds: [`comp1${DEFAULT_VARIANT}`, 'comp1_var1'], - }), - { timeout: undefined } - ); - expect(req.scParams).toEqual({ - siteName: 'website', - variantId: DEFAULT_VARIANT, - componentVariantIds: ['comp1_var1'], - }); - }); - - it('should extract language and content path from locale-prefixed url', async () => { - getPersonalizeInfo.mockResolvedValue(undefined); - const req = createReq({ path: '/da/products/shoes', url: '/da/products/shoes' }); - - await createPersonalizeMiddleware(createOptions())(req, createRes(), next); - - expect(getPersonalizeInfo).toHaveBeenCalledWith('/products/shoes', 'da', 'website'); - expect(next).toHaveBeenCalledTimes(1); - }); - - it('should use defaultLanguage when path has no locale prefix', async () => { - getPersonalizeInfo.mockResolvedValue(undefined); - const req = createReq(); - - await createPersonalizeMiddleware(createOptions({ defaultLanguage: 'fr' }))( - req, - createRes(), - next - ); - - expect(getPersonalizeInfo).toHaveBeenCalledWith('/about', 'fr', 'website'); - }); - - it('should resolve site from cookie and fall back to defaultSite when req.scParams is not set', async () => { - getPersonalizeInfo.mockResolvedValue(undefined); - const middleware = createPersonalizeMiddleware(createOptions()); - - await middleware( - createReq({ scParams: undefined, cookies: { [SITE_KEY]: 'other-site' } }), - createRes(), - next - ); - expect(getPersonalizeInfo).toHaveBeenCalledWith('/about', 'en', 'other-site'); - - await middleware(createReq({ scParams: undefined }), createRes(), next); - expect(getPersonalizeInfo).toHaveBeenCalledWith('/about', 'en', 'website'); - }); - - it('should skip when disabled', async () => { - const req = createReq(); - - await createPersonalizeMiddleware(createOptions({ enabled: false }))(req, createRes(), next); - - expect(getPersonalizeInfo).not.toHaveBeenCalled(); - expect(req.scParams?.variantId).toBe(DEFAULT_VARIANT); - expect(next).toHaveBeenCalledTimes(1); - }); - - it('should skip and warn when edge configuration is missing', async () => { - const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); - const middleware = createPersonalizeMiddleware( - createOptions({ contextId: undefined, personalizeService: undefined }) - ); - - await middleware(createReq(), createRes(), next); - - expect(warn).toHaveBeenCalledWith(expect.stringContaining('requires Edge configuration')); - expect(next).toHaveBeenCalledTimes(1); - warn.mockRestore(); - }); - - it('should skip when custom skip callback returns true', async () => { - await createPersonalizeMiddleware(createOptions({ skip: () => true }))( - createReq(), - createRes(), - next - ); - - expect(getPersonalizeInfo).not.toHaveBeenCalled(); - expect(next).toHaveBeenCalledTimes(1); - }); - - it('should skip bot requests marked with the bot cookie by default', async () => { - await createPersonalizeMiddleware(createOptions())( - createReq({ cookies: { sc_bot: '1' } }), - createRes(), - next - ); - - expect(getPersonalizeInfo).not.toHaveBeenCalled(); - expect(next).toHaveBeenCalledTimes(1); - }); - - it('should still personalize bot requests when skipForBot is false', async () => { - getPersonalizeInfo.mockResolvedValue({ pageId: 'page-1', variantIds: ['variant-a'] }); - personalizeMock.mockResolvedValue({ variantId: 'variant-a' }); - - await createPersonalizeMiddleware(createOptions({ skipForBot: false }))( - createReq({ cookies: { sc_bot: '1' } }), - createRes(), - next - ); - - expect(getPersonalizeInfo).toHaveBeenCalled(); - expect(next).toHaveBeenCalledTimes(1); - }); - - it('should skip api, sitecore and file routes', async () => { - const middleware = createPersonalizeMiddleware(createOptions()); - - for (const path of ['/api/data', '/sitecore/render', '/assets/logo.png']) { - await middleware(createReq({ path, url: path }), createRes(), next); - } - - expect(getPersonalizeInfo).not.toHaveBeenCalled(); - expect(next).toHaveBeenCalledTimes(3); - }); - - it('should skip editing render requests via the editing params header', async () => { - await createPersonalizeMiddleware(createOptions())( - createReq({ - headers: { host: 'example.com', [EDITING_PARAMS_HEADER]: JSON.stringify({ site: 'a' }) }, - }), - createRes(), - next - ); - - expect(getPersonalizeInfo).not.toHaveBeenCalled(); - expect(next).toHaveBeenCalledTimes(1); - }); - - it('should skip when personalize info is not found or has no variants', async () => { - const middleware = createPersonalizeMiddleware(createOptions()); - const req = createReq(); - - getPersonalizeInfo.mockResolvedValueOnce(undefined); - await middleware(req, createRes(), next); - - getPersonalizeInfo.mockResolvedValueOnce({ pageId: 'page-1', variantIds: [] }); - await middleware(req, createRes(), next); - - expect(personalizeMock).not.toHaveBeenCalled(); - expect(req.scParams?.variantId).toBe(DEFAULT_VARIANT); - expect(next).toHaveBeenCalledTimes(2); - }); - - it('should skip prefetch requests and disable caching', async () => { - getPersonalizeInfo.mockResolvedValue({ pageId: 'page-1', variantIds: ['variant-a'] }); - const req = createReq({ headers: { host: 'example.com', purpose: 'prefetch' } }); - const res = createRes(); - - await createPersonalizeMiddleware(createOptions())(req, res, next); - - expect(res.setHeader).toHaveBeenCalledWith('Cache-Control', 'no-store, must-revalidate'); - expect(personalizeMock).not.toHaveBeenCalled(); - expect(req.scParams?.variantId).toBe(DEFAULT_VARIANT); - }); - - it('should ignore variants not configured for the route', async () => { - getPersonalizeInfo.mockResolvedValue({ pageId: 'page-1', variantIds: ['variant-a'] }); - personalizeMock.mockResolvedValue({ variantId: 'unknown-variant' }); - const req = createReq(); - - await createPersonalizeMiddleware(createOptions())(req, createRes(), next); - - expect(req.scParams?.variantId).toBe(DEFAULT_VARIANT); - expect(next).toHaveBeenCalledTimes(1); - }); - - it('should pass utm params, referrer and geo data to personalize', async () => { - getPersonalizeInfo.mockResolvedValue({ pageId: 'page-1', variantIds: ['variant-a'] }); - personalizeMock.mockResolvedValue({ variantId: 'variant-a' }); - const req = createReq({ - query: { utm_campaign: 'sale', utm_source: 'newsletter' }, - headers: { host: 'example.com', referer: 'https://referrer.example' }, - }); - - await createPersonalizeMiddleware( - createOptions({ extractGeoDataCb: () => ({ city: 'Oslo' }) }) - )(req, createRes(), next); - - expect(personalizeMock).toHaveBeenCalledWith( - expect.objectContaining({ - params: { - referrer: 'https://referrer.example', - utm: { - campaign: 'sale', - content: undefined, - medium: undefined, - source: 'newsletter', - }, - }, - geo: { city: 'Oslo' }, - }), - { timeout: undefined } - ); - }); - - it('should call next and not fail the request when personalization throws', async () => { - const log = vi.spyOn(console, 'log').mockImplementation(() => undefined); - getPersonalizeInfo.mockRejectedValue(new Error('edge unavailable')); - const req = createReq(); - - await createPersonalizeMiddleware(createOptions())(req, createRes(), next); - - expect(req.scParams?.variantId).toBe(DEFAULT_VARIANT); - expect(next).toHaveBeenCalledTimes(1); - log.mockRestore(); - }); - - it('is enabled by default when enabled is omitted', async () => { - getPersonalizeInfo.mockResolvedValue(undefined); - const { enabled, ...optionsWithoutEnabled } = createOptions(); - void enabled; - - await createPersonalizeMiddleware(optionsWithoutEnabled)(createReq(), createRes(), next); - - expect(getPersonalizeInfo).toHaveBeenCalledTimes(1); - expect(next).toHaveBeenCalledTimes(1); - }); - - it('skips paths excluded by a custom matcher', async () => { - await createPersonalizeMiddleware(createOptions({ matcher: { excludePaths: ['/health'] } }))( - createReq({ path: '/health', url: '/health' }), - createRes(), - next - ); - - expect(getPersonalizeInfo).not.toHaveBeenCalled(); - expect(next).toHaveBeenCalledTimes(1); - }); - - it('resolves path and site from /_data loader payload', async () => { - getPersonalizeInfo.mockResolvedValue(undefined); - const req = createReq({ - method: 'POST', - path: LOADER_DATA_ENDPOINT, - url: LOADER_DATA_ENDPOINT, - scParams: undefined, - body: { - loaderId: 'home', - url: '/da/products', - routeParams: {}, - query: {}, - }, - cookies: { [SITE_KEY]: 'loader-site' }, - }); - - await createPersonalizeMiddleware(createOptions())(req, createRes(), next); - - expect(getPersonalizeInfo).toHaveBeenCalledWith('/products', 'da', 'loader-site'); - }); - - it('writes SC_PARAMS_HEADER when variants are identified', async () => { - getPersonalizeInfo.mockResolvedValue({ pageId: 'page-1', variantIds: ['variant-a'] }); - personalizeMock.mockResolvedValue({ variantId: 'variant-a' }); - const req = createReq(); - - await createPersonalizeMiddleware(createOptions())(req, createRes(), next); - - expect(req.headers?.[SC_PARAMS_HEADER]).toBe( - JSON.stringify({ - siteName: 'website', - variantId: 'variant-a', - componentVariantIds: [], - }) - ); - }); - - it('skips when site name cannot be resolved', async () => { - await createPersonalizeMiddleware(createOptions({ defaultSite: undefined }))( - createReq({ scParams: undefined, cookies: {} }), - createRes(), - next - ); - - expect(getPersonalizeInfo).not.toHaveBeenCalled(); - expect(next).toHaveBeenCalledTimes(1); - }); - - it('merges getExtraUtmParams into experience params', async () => { - getPersonalizeInfo.mockResolvedValue({ pageId: 'page-1', variantIds: ['variant-a'] }); - personalizeMock.mockResolvedValue({ variantId: 'variant-a' }); - - await createPersonalizeMiddleware( - createOptions({ getExtraUtmParams: () => ({ medium: 'email', campaign: 'override' }) }) - )(createReq({ query: { utm_campaign: 'original' } }), createRes(), next); - - expect(personalizeMock).toHaveBeenCalledWith( - expect.objectContaining({ - params: expect.objectContaining({ - utm: expect.objectContaining({ medium: 'email', campaign: 'override' }), - }), - }), - { timeout: undefined } - ); - }); - - it('treats sec-purpose prefetch headers like purpose prefetch', async () => { - getPersonalizeInfo.mockResolvedValue({ pageId: 'page-1', variantIds: ['variant-a'] }); - const res = createRes(); - - await createPersonalizeMiddleware(createOptions())( - createReq({ headers: { host: 'example.com', 'sec-purpose': 'prefetch' } }), - res, - next - ); - - expect(res.setHeader).toHaveBeenCalledWith('Cache-Control', 'no-store, must-revalidate'); - expect(personalizeMock).not.toHaveBeenCalled(); - }); - - it('calls next when personalize execution throws after info is loaded', async () => { - const log = vi.spyOn(console, 'log').mockImplementation(() => undefined); - getPersonalizeInfo.mockResolvedValue({ pageId: 'page-1', variantIds: ['variant-a'] }); - personalizeMock.mockRejectedValue(new Error('cdp unavailable')); - const req = createReq(); - - await createPersonalizeMiddleware(createOptions())(req, createRes(), next); - - expect(req.scParams?.variantId).toBe(DEFAULT_VARIANT); - expect(next).toHaveBeenCalledTimes(1); - log.mockRestore(); - }); -}); diff --git a/packages/angular/src/server/middleware/redirects-middleware.spec.ts b/packages/angular/src/server/middleware/redirects-middleware.spec.ts new file mode 100644 index 0000000000..7449f2ed0a --- /dev/null +++ b/packages/angular/src/server/middleware/redirects-middleware.spec.ts @@ -0,0 +1,428 @@ +import { describe, it, expect, vi, beforeAll, beforeEach, type Mock } from 'vitest'; +import type { RedirectInfo, RedirectsService, SiteInfo } from '@sitecore-content-sdk/content/site'; +import { EDITING_PARAMS_HEADER } from '../../editing/constants'; +import { LOADER_DATA_ENDPOINT } from '../constants'; +import type { RedirectsMiddlewareOptions } from './redirects-middleware'; +import type { CsdkExpressRequest, ExpressResponse } from './models'; + +// Loaded lazily in beforeAll rather than via static top-level imports. Statically importing the +// real ./redirects-middleware (and @sitecore-content-sdk/content/site) would eagerly pull the real +// @sitecore-content-sdk/core and /analytics-core into the module registry at file-eval time. Under +// the Angular test runner's `isolate: false`, that caches the real modules before sibling specs +// (personalize-middleware) register their `vi.mock`, defeating those mocks. This spec mocks nothing, +// so deferring the load keeps it from polluting the shared registry. +type SiteModule = typeof import('@sitecore-content-sdk/content/site'); +let createRedirectsMiddleware: typeof import('./redirects-middleware').createRedirectsMiddleware; +let REDIRECT_TYPE_301: SiteModule['REDIRECT_TYPE_301']; +let REDIRECT_TYPE_302: SiteModule['REDIRECT_TYPE_302']; +let REDIRECT_TYPE_SERVER_TRANSFER: SiteModule['REDIRECT_TYPE_SERVER_TRANSFER']; + +beforeAll(async () => { + ({ REDIRECT_TYPE_301, REDIRECT_TYPE_302, REDIRECT_TYPE_SERVER_TRANSFER } = await import( + '@sitecore-content-sdk/content/site' + )); + ({ createRedirectsMiddleware } = await import('./redirects-middleware')); +}); + +const SITES: SiteInfo[] = [{ hostName: '*', language: 'en', name: 'site-a' }]; + +function createService(redirects: RedirectInfo[]): RedirectsService { + return { + fetchRedirects: vi.fn().mockResolvedValue(redirects), + } as unknown as RedirectsService; +} + +function createOptions( + overrides: Partial = {} +): RedirectsMiddlewareOptions { + return { + enabled: true, + locales: ['en'], + defaultLanguage: 'en', + defaultSite: 'site-a', + sites: SITES, + redirectsService: createService([]), + ...overrides, + } as RedirectsMiddlewareOptions; +} + +function createReq(overrides: Partial = {}): CsdkExpressRequest { + return { + method: 'GET', + path: '/', + url: '/', + body: undefined, + query: {}, + cookies: {}, + headers: { host: 'a.example.com' }, + scParams: { siteName: 'site-a' }, + ...overrides, + }; +} + +function createRes() { + return { + redirect: vi.fn(), + setHeader: vi.fn(), + status: vi.fn().mockReturnThis(), + json: vi.fn(), + } as unknown as ExpressResponse & { redirect: Mock; setHeader: Mock }; +} + +const redirect = (overrides: Partial): RedirectInfo => ({ + pattern: '', + target: '', + redirectType: REDIRECT_TYPE_301, + isQueryStringPreserved: false, + locale: '', + ...overrides, +}); + +describe('createRedirectsMiddleware', () => { + const next = vi.fn(); + beforeEach(() => vi.clearAllMocks()); + + it('returns a middleware', () => { + expect(createRedirectsMiddleware(createOptions())).toBeTypeOf('function'); + }); + + it('matches a locale-versioned redirect item and issues a 301', async () => { + const req = createReq({ path: '/en/old', url: '/en/old' }); + const res = createRes(); + const options = createOptions({ + redirectsService: createService([ + redirect({ locale: 'en', pattern: '/old', target: '/new' }), + ]), + }); + await createRedirectsMiddleware(options)(req, res, next); + expect(res.redirect).toHaveBeenCalledWith(301, '/new'); + expect(next).not.toHaveBeenCalled(); + }); + + it('preserves the request locale on relative targets when isLanguagePreserved', async () => { + const req = createReq({ path: '/en/old', url: '/en/old' }); + const res = createRes(); + const options = createOptions({ + redirectsService: createService([ + redirect({ locale: 'en', pattern: '/old', target: '/new', isLanguagePreserved: true }), + ]), + }); + await createRedirectsMiddleware(options)(req, res, next); + expect(res.redirect).toHaveBeenCalledWith(301, '/en/new'); + }); + + it('keeps a locale-less URL locale-less when the target resolves to the same locale', async () => { + const req = createReq({ path: '/old', url: '/old' }); + const res = createRes(); + const options = createOptions({ + redirectsService: createService([ + redirect({ pattern: '/old', target: '/new', isLanguagePreserved: true }), + ]), + }); + await createRedirectsMiddleware(options)(req, res, next); + expect(res.redirect).toHaveBeenCalledWith(301, '/new'); + }); + + it('adds the target locale when it differs from a locale-less origin', async () => { + const req = createReq({ path: '/old', url: '/old' }); + const res = createRes(); + const options = createOptions({ + locales: ['en', 'fr'], + redirectsService: createService([redirect({ pattern: '/old', target: '/fr/new' })]), + }); + await createRedirectsMiddleware(options)(req, res, next); + expect(res.redirect).toHaveBeenCalledWith(301, '/fr/new'); + }); + + it('matches a static redirect-map rule and issues a 302', async () => { + const req = createReq({ path: '/about-us', url: '/about-us' }); + const res = createRes(); + const options = createOptions({ + redirectsService: createService([ + redirect({ pattern: '/about-us', target: '/about', redirectType: REDIRECT_TYPE_302 }), + ]), + }); + await createRedirectsMiddleware(options)(req, res, next); + expect(res.redirect).toHaveBeenCalledWith(302, '/about'); + }); + + it('matches a regex rule and applies capture-group substitution', async () => { + const req = createReq({ path: '/products/shoes', url: '/products/shoes' }); + const res = createRes(); + const options = createOptions({ + redirectsService: createService([ + redirect({ pattern: '/products/(.*)', target: '/shop/$1' }), + ]), + }); + await createRedirectsMiddleware(options)(req, res, next); + expect(res.redirect).toHaveBeenCalledWith(301, '/shop/shoes'); + }); + + it('substitutes the $siteLang token from the resolved site language', async () => { + // Locale-prefixed origin so the injected locale is preserved on the target. + const req = createReq({ path: '/en/home', url: '/en/home' }); + const res = createRes(); + const options = createOptions({ + redirectsService: createService([ + redirect({ pattern: '/home', target: '/$siteLang/welcome' }), + ]), + }); + await createRedirectsMiddleware(options)(req, res, next); + expect(res.redirect).toHaveBeenCalledWith(301, '/en/welcome'); + }); + + it('preserves the query string on relative targets when isQueryStringPreserved', async () => { + const req = createReq({ path: '/search', url: '/search?q=shoes', query: { q: 'shoes' } }); + const res = createRes(); + const options = createOptions({ + redirectsService: createService([ + redirect({ + pattern: '/search', + target: '/results', + redirectType: REDIRECT_TYPE_302, + isQueryStringPreserved: true, + }), + ]), + }); + await createRedirectsMiddleware(options)(req, res, next); + expect(res.redirect).toHaveBeenCalledWith(302, '/results?q=shoes'); + }); + + it('redirects to an absolute external target without locale stripping', async () => { + const req = createReq({ path: '/ext', url: '/ext' }); + const res = createRes(); + const options = createOptions({ + redirectsService: createService([ + redirect({ pattern: '/ext', target: 'https://example.org/page' }), + ]), + }); + await createRedirectsMiddleware(options)(req, res, next); + expect(res.redirect).toHaveBeenCalledWith(301, 'https://example.org/page'); + }); + + it('merges the query string into an absolute target when isQueryStringPreserved', async () => { + const req = createReq({ path: '/ext', url: '/ext?a=1', query: { a: '1' } }); + const res = createRes(); + const options = createOptions({ + redirectsService: createService([ + redirect({ + pattern: '/ext', + target: 'https://example.org/page', + isQueryStringPreserved: true, + }), + ]), + }); + await createRedirectsMiddleware(options)(req, res, next); + expect(res.redirect).toHaveBeenCalledWith(301, 'https://example.org/page?a=1'); + }); + + it('performs an internal rewrite (no browser redirect) for SERVER_TRANSFER', async () => { + const req = createReq({ path: '/legacy', url: '/legacy' }); + const res = createRes(); + const options = createOptions({ + redirectsService: createService([ + redirect({ + pattern: '/legacy', + target: '/modern', + redirectType: REDIRECT_TYPE_SERVER_TRANSFER, + }), + ]), + }); + await createRedirectsMiddleware(options)(req, res, next); + expect(res.redirect).not.toHaveBeenCalled(); + expect(req.url).toBe('/modern'); + expect(req.path).toBe('/modern'); + expect(next).toHaveBeenCalledTimes(1); + }); + + it('falls back to a 302 for SERVER_TRANSFER to an external target', async () => { + const req = createReq({ path: '/legacy', url: '/legacy' }); + const res = createRes(); + const options = createOptions({ + redirectsService: createService([ + redirect({ + pattern: '/legacy', + target: 'https://example.org/page', + redirectType: REDIRECT_TYPE_SERVER_TRANSFER, + }), + ]), + }); + await createRedirectsMiddleware(options)(req, res, next); + expect(res.redirect).toHaveBeenCalledWith(302, 'https://example.org/page'); + expect(next).not.toHaveBeenCalled(); + }); + + it('answers /_data navigations with a redirect envelope instead of an HTTP redirect', async () => { + const service = createService([redirect({ pattern: '/about-us', target: '/about' })]); + const req = createReq({ + method: 'POST', + path: LOADER_DATA_ENDPOINT, + url: LOADER_DATA_ENDPOINT, + body: { loaderId: 'home', url: '/about-us', routeParams: {}, query: {} }, + }); + const res = createRes(); + await createRedirectsMiddleware(createOptions({ redirectsService: service }))(req, res, next); + expect(res.json).toHaveBeenCalledWith({ + kind: 'redirect', + redirect: { loaderRedirectTarget: '/about', status: 301 }, + }); + expect(res.redirect).not.toHaveBeenCalled(); + expect(next).not.toHaveBeenCalled(); + }); + + it('returns the external url in the redirect envelope for /_data navigations', async () => { + const service = createService([ + redirect({ pattern: '/ext', target: 'https://example.org/page' }), + ]); + const req = createReq({ + method: 'POST', + path: LOADER_DATA_ENDPOINT, + url: LOADER_DATA_ENDPOINT, + body: { loaderId: 'home', url: '/ext', routeParams: {}, query: {} }, + }); + const res = createRes(); + await createRedirectsMiddleware(createOptions({ redirectsService: service }))(req, res, next); + expect(res.json).toHaveBeenCalledWith({ + kind: 'redirect', + redirect: { loaderRedirectTarget: 'https://example.org/page', status: 301 }, + }); + }); + + it('rewrites the loader payload url for a SERVER_TRANSFER /_data navigation', async () => { + const service = createService([ + redirect({ + pattern: '/legacy', + target: '/modern', + redirectType: REDIRECT_TYPE_SERVER_TRANSFER, + }), + ]); + const body = { loaderId: 'home', url: '/legacy', routeParams: {}, query: {} }; + const req = createReq({ + method: 'POST', + path: LOADER_DATA_ENDPOINT, + url: LOADER_DATA_ENDPOINT, + body, + }); + const res = createRes(); + await createRedirectsMiddleware(createOptions({ redirectsService: service }))(req, res, next); + expect(body.url).toBe('/modern'); + expect(res.json).not.toHaveBeenCalled(); + expect(res.redirect).not.toHaveBeenCalled(); + expect(next).toHaveBeenCalledTimes(1); + }); + + it('skips when disabled', async () => { + const service = createService([redirect({ pattern: '/about-us', target: '/about' })]); + const req = createReq({ path: '/about-us', url: '/about-us' }); + const res = createRes(); + await createRedirectsMiddleware(createOptions({ enabled: false, redirectsService: service }))( + req, + res, + next + ); + expect(res.redirect).not.toHaveBeenCalled(); + expect(service.fetchRedirects).not.toHaveBeenCalled(); + expect(next).toHaveBeenCalledTimes(1); + }); + + it('warns and no-ops when neither Edge nor local API config is provided', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + const req = createReq({ path: '/about-us', url: '/about-us' }); + const res = createRes(); + await createRedirectsMiddleware(createOptions({ redirectsService: undefined }))(req, res, next); + expect(warn).toHaveBeenCalled(); + expect(res.redirect).not.toHaveBeenCalled(); + expect(next).toHaveBeenCalledTimes(1); + warn.mockRestore(); + }); + + it('skips editing/preview requests', async () => { + const service = createService([redirect({ pattern: '/about-us', target: '/about' })]); + const req = createReq({ + path: '/about-us', + url: '/about-us', + headers: { + host: 'a.example.com', + [EDITING_PARAMS_HEADER]: JSON.stringify({ site: 'site-a' }), + }, + }); + await createRedirectsMiddleware(createOptions({ redirectsService: service }))( + req, + createRes(), + next + ); + expect(service.fetchRedirects).not.toHaveBeenCalled(); + expect(next).toHaveBeenCalledTimes(1); + }); + + it('skips api, sitecore and static-file routes', async () => { + const service = createService([redirect({ pattern: '/x', target: '/y' })]); + for (const path of ['/api/data', '/sitecore/render', '/assets/logo.png']) { + const req = createReq({ path, url: path }); + await createRedirectsMiddleware(createOptions({ redirectsService: service }))( + req, + createRes(), + next + ); + } + expect(service.fetchRedirects).not.toHaveBeenCalled(); + }); + + it('skips when the custom skip predicate returns true', async () => { + const service = createService([redirect({ pattern: '/about-us', target: '/about' })]); + const req = createReq({ path: '/about-us', url: '/about-us' }); + await createRedirectsMiddleware(createOptions({ redirectsService: service, skip: () => true }))( + req, + createRes(), + next + ); + expect(service.fetchRedirects).not.toHaveBeenCalled(); + }); + + it('skips prefetch requests and disables caching', async () => { + const service = createService([redirect({ pattern: '/about-us', target: '/about' })]); + const req = createReq({ + path: '/about-us', + url: '/about-us', + headers: { host: 'a.example.com', purpose: 'prefetch' }, + }); + const res = createRes(); + await createRedirectsMiddleware(createOptions({ redirectsService: service }))(req, res, next); + expect(res.setHeader).toHaveBeenCalledWith('x-proxy-cache', 'no-cache'); + expect(res.setHeader).toHaveBeenCalledWith('Cache-Control', 'no-store, must-revalidate'); + expect(service.fetchRedirects).not.toHaveBeenCalled(); + expect(next).toHaveBeenCalledTimes(1); + }); + + it('skips when the site cannot be resolved', async () => { + const service = createService([redirect({ pattern: '/about-us', target: '/about' })]); + const req = createReq({ path: '/about-us', url: '/about-us', scParams: undefined }); + await createRedirectsMiddleware( + createOptions({ redirectsService: service, defaultSite: undefined }) + )(req, createRes(), next); + expect(service.fetchRedirects).not.toHaveBeenCalled(); + expect(next).toHaveBeenCalledTimes(1); + }); + + it('passes through when no redirect matches', async () => { + const req = createReq({ path: '/about-us', url: '/about-us' }); + const res = createRes(); + await createRedirectsMiddleware(createOptions())(req, res, next); + expect(res.redirect).not.toHaveBeenCalled(); + expect(next).toHaveBeenCalledTimes(1); + }); + + it('logs and calls next when fetching redirects throws (fail-open)', async () => { + const log = vi.spyOn(console, 'log').mockImplementation(() => undefined); + const service = { + fetchRedirects: vi.fn().mockRejectedValue(new Error('boom')), + } as unknown as RedirectsService; + const req = createReq({ path: '/about-us', url: '/about-us' }); + const res = createRes(); + await createRedirectsMiddleware(createOptions({ redirectsService: service }))(req, res, next); + expect(log).toHaveBeenCalledWith('Redirects middleware failed:'); + expect(res.redirect).not.toHaveBeenCalled(); + expect(next).toHaveBeenCalledTimes(1); + log.mockRestore(); + }); +}); From 1cb7e823aca135d5362f3ad63f2c55b79e2ec6cd Mon Sep 17 00:00:00 2001 From: Artem Alexeyenko Date: Wed, 29 Jul 2026 14:45:43 -0400 Subject: [PATCH 5/7] resore tests, apply isloation via vitest conf --- packages/angular/angular.json | 3 +- .../middleware/personalize-middleware.spec.ts | 460 ++++++++++++++++++ .../middleware/redirects-middleware.spec.ts | 32 +- packages/angular/vitest-base.config.ts | 29 ++ 4 files changed, 501 insertions(+), 23 deletions(-) create mode 100644 packages/angular/src/server/middleware/personalize-middleware.spec.ts create mode 100644 packages/angular/vitest-base.config.ts diff --git a/packages/angular/angular.json b/packages/angular/angular.json index 277091673d..5f5b4c8248 100644 --- a/packages/angular/angular.json +++ b/packages/angular/angular.json @@ -30,7 +30,8 @@ "test": { "builder": "@angular/build:unit-test", "options": { - "tsConfig": "./tsconfig.spec.json" + "tsConfig": "./tsconfig.spec.json", + "runnerConfig": true } } } diff --git a/packages/angular/src/server/middleware/personalize-middleware.spec.ts b/packages/angular/src/server/middleware/personalize-middleware.spec.ts new file mode 100644 index 0000000000..0331daef2c --- /dev/null +++ b/packages/angular/src/server/middleware/personalize-middleware.spec.ts @@ -0,0 +1,460 @@ +/* eslint-disable jsdoc/require-jsdoc */ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { + CdpHelper, + DEFAULT_VARIANT, + PersonalizeService, +} from '@sitecore-content-sdk/content/personalize'; +import { SITE_KEY } from '@sitecore-content-sdk/content/site'; +import { EDITING_PARAMS_HEADER } from '../../editing/constants'; +import { LOADER_DATA_ENDPOINT } from '../constants'; +import { SC_PARAMS_HEADER } from '../../loaders/constants'; +import type { CsdkExpressRequest, ExpressMiddleware, ExpressResponse } from './models'; +import type { PersonalizeMiddlewareOptions } from './personalize-middleware'; +const { + initContentSdkMock, + personalizeMock, + personalizeServerPluginMock, + personalizeServerAdapterMock, + analyticsPluginMock, + analyticsServerAdapterMock, +} = vi.hoisted(() => ({ + initContentSdkMock: vi.fn().mockResolvedValue(undefined), + personalizeMock: vi.fn(), + personalizeServerPluginMock: vi.fn(), + personalizeServerAdapterMock: vi.fn(), + analyticsPluginMock: vi.fn(), + analyticsServerAdapterMock: vi.fn(), +})); + +vi.mock('@sitecore-content-sdk/core', async (importOriginal) => ({ + ...(await importOriginal>()), + initContentSdk: initContentSdkMock, +})); +vi.mock('@sitecore-content-sdk/personalize', () => ({ + personalize: personalizeMock, + personalizeServerPlugin: personalizeServerPluginMock, + personalizeServerAdapter: personalizeServerAdapterMock, +})); +vi.mock('@sitecore-content-sdk/analytics-core', () => ({ + analyticsPlugin: analyticsPluginMock, + analyticsServerAdapter: analyticsServerAdapterMock, +})); + +type CreatePersonalizeMiddleware = (options: PersonalizeMiddlewareOptions) => ExpressMiddleware; + +let createPersonalizeMiddleware: CreatePersonalizeMiddleware; + +const getPersonalizeInfo = vi.fn(); +const personalizeService = { + getPersonalizeInfo, +} as unknown as PersonalizeService; + +function createOptions( + overrides: Partial = {} +): PersonalizeMiddlewareOptions { + return { + enabled: true, + contextId: 'context-id', + defaultSite: 'website', + locales: ['en', 'da'], + personalizeService, + ...overrides, + }; +} + +function createReq(overrides: Partial = {}): CsdkExpressRequest { + return { + method: 'GET', + path: '/about', + url: '/about', + body: undefined, + query: {}, + cookies: {}, + headers: { host: 'example.com' }, + scParams: { siteName: 'website', variantId: DEFAULT_VARIANT }, + ...overrides, + }; +} + +function createRes() { + return { setHeader: vi.fn() } as unknown as ExpressResponse & { + setHeader: ReturnType; + }; +} + +describe('createPersonalizeMiddleware', () => { + const next = vi.fn(); + + beforeEach(async () => { + // Vitest runs with `isolate: false` under the Angular unit-test builder; reset modules and + // re-import the SUT so it binds to this file's mocks regardless of sibling-spec ordering. + vi.resetModules(); + vi.clearAllMocks(); + initContentSdkMock.mockResolvedValue(undefined); + ({ createPersonalizeMiddleware } = await import('./personalize-middleware')); + }); + + it('should populate req.scParams with identified page-level variant', async () => { + getPersonalizeInfo.mockResolvedValue({ pageId: 'page-1', variantIds: ['variant-a'] }); + personalizeMock.mockResolvedValue({ variantId: 'variant-a' }); + const req = createReq(); + + await createPersonalizeMiddleware(createOptions())(req, createRes(), next); + + expect(getPersonalizeInfo).toHaveBeenCalledWith('/about', 'en', 'website'); + expect(initContentSdkMock).toHaveBeenCalled(); + expect(personalizeMock).toHaveBeenCalledWith( + expect.objectContaining({ + channel: 'WEB', + currency: 'USD', + friendlyId: CdpHelper.getPageFriendlyId('page-1', 'en'), + language: 'en', + pageVariantIds: ['variant-a'], + }), + { timeout: undefined } + ); + expect(req.scParams).toEqual({ + siteName: 'website', + variantId: 'variant-a', + componentVariantIds: [], + }); + expect(next).toHaveBeenCalledTimes(1); + }); + + it('should populate req.scParams.componentVariantIds with identified component-level variants', async () => { + getPersonalizeInfo.mockResolvedValue({ pageId: 'page-1', variantIds: ['comp1_var1'] }); + personalizeMock.mockResolvedValue({ variantId: 'comp1_var1' }); + const req = createReq(); + + await createPersonalizeMiddleware(createOptions())(req, createRes(), next); + + expect(personalizeMock).toHaveBeenCalledWith( + expect.objectContaining({ + friendlyId: CdpHelper.getComponentFriendlyId('page-1', 'comp1', 'en'), + pageVariantIds: [`comp1${DEFAULT_VARIANT}`, 'comp1_var1'], + }), + { timeout: undefined } + ); + expect(req.scParams).toEqual({ + siteName: 'website', + variantId: DEFAULT_VARIANT, + componentVariantIds: ['comp1_var1'], + }); + }); + + it('should extract language and content path from locale-prefixed url', async () => { + getPersonalizeInfo.mockResolvedValue(undefined); + const req = createReq({ path: '/da/products/shoes', url: '/da/products/shoes' }); + + await createPersonalizeMiddleware(createOptions())(req, createRes(), next); + + expect(getPersonalizeInfo).toHaveBeenCalledWith('/products/shoes', 'da', 'website'); + expect(next).toHaveBeenCalledTimes(1); + }); + + it('should use defaultLanguage when path has no locale prefix', async () => { + getPersonalizeInfo.mockResolvedValue(undefined); + const req = createReq(); + + await createPersonalizeMiddleware(createOptions({ defaultLanguage: 'fr' }))( + req, + createRes(), + next + ); + + expect(getPersonalizeInfo).toHaveBeenCalledWith('/about', 'fr', 'website'); + }); + + it('should resolve site from cookie and fall back to defaultSite when req.scParams is not set', async () => { + getPersonalizeInfo.mockResolvedValue(undefined); + const middleware = createPersonalizeMiddleware(createOptions()); + + await middleware( + createReq({ scParams: undefined, cookies: { [SITE_KEY]: 'other-site' } }), + createRes(), + next + ); + expect(getPersonalizeInfo).toHaveBeenCalledWith('/about', 'en', 'other-site'); + + await middleware(createReq({ scParams: undefined }), createRes(), next); + expect(getPersonalizeInfo).toHaveBeenCalledWith('/about', 'en', 'website'); + }); + + it('should skip when disabled', async () => { + const req = createReq(); + + await createPersonalizeMiddleware(createOptions({ enabled: false }))(req, createRes(), next); + + expect(getPersonalizeInfo).not.toHaveBeenCalled(); + expect(req.scParams?.variantId).toBe(DEFAULT_VARIANT); + expect(next).toHaveBeenCalledTimes(1); + }); + + it('should skip and warn when edge configuration is missing', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + const middleware = createPersonalizeMiddleware( + createOptions({ contextId: undefined, personalizeService: undefined }) + ); + + await middleware(createReq(), createRes(), next); + + expect(warn).toHaveBeenCalledWith(expect.stringContaining('requires Edge configuration')); + expect(next).toHaveBeenCalledTimes(1); + warn.mockRestore(); + }); + + it('should skip when custom skip callback returns true', async () => { + await createPersonalizeMiddleware(createOptions({ skip: () => true }))( + createReq(), + createRes(), + next + ); + + expect(getPersonalizeInfo).not.toHaveBeenCalled(); + expect(next).toHaveBeenCalledTimes(1); + }); + + it('should skip bot requests marked with the bot cookie by default', async () => { + await createPersonalizeMiddleware(createOptions())( + createReq({ cookies: { sc_bot: '1' } }), + createRes(), + next + ); + + expect(getPersonalizeInfo).not.toHaveBeenCalled(); + expect(next).toHaveBeenCalledTimes(1); + }); + + it('should still personalize bot requests when skipForBot is false', async () => { + getPersonalizeInfo.mockResolvedValue({ pageId: 'page-1', variantIds: ['variant-a'] }); + personalizeMock.mockResolvedValue({ variantId: 'variant-a' }); + + await createPersonalizeMiddleware(createOptions({ skipForBot: false }))( + createReq({ cookies: { sc_bot: '1' } }), + createRes(), + next + ); + + expect(getPersonalizeInfo).toHaveBeenCalled(); + expect(next).toHaveBeenCalledTimes(1); + }); + + it('should skip api, sitecore and file routes', async () => { + const middleware = createPersonalizeMiddleware(createOptions()); + + for (const path of ['/api/data', '/sitecore/render', '/assets/logo.png']) { + await middleware(createReq({ path, url: path }), createRes(), next); + } + + expect(getPersonalizeInfo).not.toHaveBeenCalled(); + expect(next).toHaveBeenCalledTimes(3); + }); + + it('should skip editing render requests via the editing params header', async () => { + await createPersonalizeMiddleware(createOptions())( + createReq({ + headers: { host: 'example.com', [EDITING_PARAMS_HEADER]: JSON.stringify({ site: 'a' }) }, + }), + createRes(), + next + ); + + expect(getPersonalizeInfo).not.toHaveBeenCalled(); + expect(next).toHaveBeenCalledTimes(1); + }); + + it('should skip when personalize info is not found or has no variants', async () => { + const middleware = createPersonalizeMiddleware(createOptions()); + const req = createReq(); + + getPersonalizeInfo.mockResolvedValueOnce(undefined); + await middleware(req, createRes(), next); + + getPersonalizeInfo.mockResolvedValueOnce({ pageId: 'page-1', variantIds: [] }); + await middleware(req, createRes(), next); + + expect(personalizeMock).not.toHaveBeenCalled(); + expect(req.scParams?.variantId).toBe(DEFAULT_VARIANT); + expect(next).toHaveBeenCalledTimes(2); + }); + + it('should skip prefetch requests and disable caching', async () => { + getPersonalizeInfo.mockResolvedValue({ pageId: 'page-1', variantIds: ['variant-a'] }); + const req = createReq({ headers: { host: 'example.com', purpose: 'prefetch' } }); + const res = createRes(); + + await createPersonalizeMiddleware(createOptions())(req, res, next); + + expect(res.setHeader).toHaveBeenCalledWith('Cache-Control', 'no-store, must-revalidate'); + expect(personalizeMock).not.toHaveBeenCalled(); + expect(req.scParams?.variantId).toBe(DEFAULT_VARIANT); + }); + + it('should ignore variants not configured for the route', async () => { + getPersonalizeInfo.mockResolvedValue({ pageId: 'page-1', variantIds: ['variant-a'] }); + personalizeMock.mockResolvedValue({ variantId: 'unknown-variant' }); + const req = createReq(); + + await createPersonalizeMiddleware(createOptions())(req, createRes(), next); + + expect(req.scParams?.variantId).toBe(DEFAULT_VARIANT); + expect(next).toHaveBeenCalledTimes(1); + }); + + it('should pass utm params, referrer and geo data to personalize', async () => { + getPersonalizeInfo.mockResolvedValue({ pageId: 'page-1', variantIds: ['variant-a'] }); + personalizeMock.mockResolvedValue({ variantId: 'variant-a' }); + const req = createReq({ + query: { utm_campaign: 'sale', utm_source: 'newsletter' }, + headers: { host: 'example.com', referer: 'https://referrer.example' }, + }); + + await createPersonalizeMiddleware( + createOptions({ extractGeoDataCb: () => ({ city: 'Oslo' }) }) + )(req, createRes(), next); + + expect(personalizeMock).toHaveBeenCalledWith( + expect.objectContaining({ + params: { + referrer: 'https://referrer.example', + utm: { + campaign: 'sale', + content: undefined, + medium: undefined, + source: 'newsletter', + }, + }, + geo: { city: 'Oslo' }, + }), + { timeout: undefined } + ); + }); + + it('should call next and not fail the request when personalization throws', async () => { + const log = vi.spyOn(console, 'log').mockImplementation(() => undefined); + getPersonalizeInfo.mockRejectedValue(new Error('edge unavailable')); + const req = createReq(); + + await createPersonalizeMiddleware(createOptions())(req, createRes(), next); + + expect(req.scParams?.variantId).toBe(DEFAULT_VARIANT); + expect(next).toHaveBeenCalledTimes(1); + log.mockRestore(); + }); + + it('is enabled by default when enabled is omitted', async () => { + getPersonalizeInfo.mockResolvedValue(undefined); + const { enabled, ...optionsWithoutEnabled } = createOptions(); + void enabled; + + await createPersonalizeMiddleware(optionsWithoutEnabled)(createReq(), createRes(), next); + + expect(getPersonalizeInfo).toHaveBeenCalledTimes(1); + expect(next).toHaveBeenCalledTimes(1); + }); + + it('skips paths excluded by a custom matcher', async () => { + await createPersonalizeMiddleware(createOptions({ matcher: { excludePaths: ['/health'] } }))( + createReq({ path: '/health', url: '/health' }), + createRes(), + next + ); + + expect(getPersonalizeInfo).not.toHaveBeenCalled(); + expect(next).toHaveBeenCalledTimes(1); + }); + + it('resolves path and site from /_data loader payload', async () => { + getPersonalizeInfo.mockResolvedValue(undefined); + const req = createReq({ + method: 'POST', + path: LOADER_DATA_ENDPOINT, + url: LOADER_DATA_ENDPOINT, + scParams: undefined, + body: { + loaderId: 'home', + url: '/da/products', + routeParams: {}, + query: {}, + }, + cookies: { [SITE_KEY]: 'loader-site' }, + }); + + await createPersonalizeMiddleware(createOptions())(req, createRes(), next); + + expect(getPersonalizeInfo).toHaveBeenCalledWith('/products', 'da', 'loader-site'); + }); + + it('writes SC_PARAMS_HEADER when variants are identified', async () => { + getPersonalizeInfo.mockResolvedValue({ pageId: 'page-1', variantIds: ['variant-a'] }); + personalizeMock.mockResolvedValue({ variantId: 'variant-a' }); + const req = createReq(); + + await createPersonalizeMiddleware(createOptions())(req, createRes(), next); + + expect(req.headers?.[SC_PARAMS_HEADER]).toBe( + JSON.stringify({ + siteName: 'website', + variantId: 'variant-a', + componentVariantIds: [], + }) + ); + }); + + it('skips when site name cannot be resolved', async () => { + await createPersonalizeMiddleware(createOptions({ defaultSite: undefined }))( + createReq({ scParams: undefined, cookies: {} }), + createRes(), + next + ); + + expect(getPersonalizeInfo).not.toHaveBeenCalled(); + expect(next).toHaveBeenCalledTimes(1); + }); + + it('merges getExtraUtmParams into experience params', async () => { + getPersonalizeInfo.mockResolvedValue({ pageId: 'page-1', variantIds: ['variant-a'] }); + personalizeMock.mockResolvedValue({ variantId: 'variant-a' }); + + await createPersonalizeMiddleware( + createOptions({ getExtraUtmParams: () => ({ medium: 'email', campaign: 'override' }) }) + )(createReq({ query: { utm_campaign: 'original' } }), createRes(), next); + + expect(personalizeMock).toHaveBeenCalledWith( + expect.objectContaining({ + params: expect.objectContaining({ + utm: expect.objectContaining({ medium: 'email', campaign: 'override' }), + }), + }), + { timeout: undefined } + ); + }); + + it('treats sec-purpose prefetch headers like purpose prefetch', async () => { + getPersonalizeInfo.mockResolvedValue({ pageId: 'page-1', variantIds: ['variant-a'] }); + const res = createRes(); + + await createPersonalizeMiddleware(createOptions())( + createReq({ headers: { host: 'example.com', 'sec-purpose': 'prefetch' } }), + res, + next + ); + + expect(res.setHeader).toHaveBeenCalledWith('Cache-Control', 'no-store, must-revalidate'); + expect(personalizeMock).not.toHaveBeenCalled(); + }); + + it('calls next when personalize execution throws after info is loaded', async () => { + const log = vi.spyOn(console, 'log').mockImplementation(() => undefined); + getPersonalizeInfo.mockResolvedValue({ pageId: 'page-1', variantIds: ['variant-a'] }); + personalizeMock.mockRejectedValue(new Error('cdp unavailable')); + const req = createReq(); + + await createPersonalizeMiddleware(createOptions())(req, createRes(), next); + + expect(req.scParams?.variantId).toBe(DEFAULT_VARIANT); + expect(next).toHaveBeenCalledTimes(1); + log.mockRestore(); + }); +}); diff --git a/packages/angular/src/server/middleware/redirects-middleware.spec.ts b/packages/angular/src/server/middleware/redirects-middleware.spec.ts index 7449f2ed0a..d96ee11633 100644 --- a/packages/angular/src/server/middleware/redirects-middleware.spec.ts +++ b/packages/angular/src/server/middleware/redirects-middleware.spec.ts @@ -1,29 +1,17 @@ -import { describe, it, expect, vi, beforeAll, beforeEach, type Mock } from 'vitest'; -import type { RedirectInfo, RedirectsService, SiteInfo } from '@sitecore-content-sdk/content/site'; +import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest'; +import { + RedirectInfo, + RedirectsService, + REDIRECT_TYPE_301, + REDIRECT_TYPE_302, + REDIRECT_TYPE_SERVER_TRANSFER, + type SiteInfo, +} from '@sitecore-content-sdk/content/site'; import { EDITING_PARAMS_HEADER } from '../../editing/constants'; import { LOADER_DATA_ENDPOINT } from '../constants'; -import type { RedirectsMiddlewareOptions } from './redirects-middleware'; +import { createRedirectsMiddleware, type RedirectsMiddlewareOptions } from './redirects-middleware'; import type { CsdkExpressRequest, ExpressResponse } from './models'; -// Loaded lazily in beforeAll rather than via static top-level imports. Statically importing the -// real ./redirects-middleware (and @sitecore-content-sdk/content/site) would eagerly pull the real -// @sitecore-content-sdk/core and /analytics-core into the module registry at file-eval time. Under -// the Angular test runner's `isolate: false`, that caches the real modules before sibling specs -// (personalize-middleware) register their `vi.mock`, defeating those mocks. This spec mocks nothing, -// so deferring the load keeps it from polluting the shared registry. -type SiteModule = typeof import('@sitecore-content-sdk/content/site'); -let createRedirectsMiddleware: typeof import('./redirects-middleware').createRedirectsMiddleware; -let REDIRECT_TYPE_301: SiteModule['REDIRECT_TYPE_301']; -let REDIRECT_TYPE_302: SiteModule['REDIRECT_TYPE_302']; -let REDIRECT_TYPE_SERVER_TRANSFER: SiteModule['REDIRECT_TYPE_SERVER_TRANSFER']; - -beforeAll(async () => { - ({ REDIRECT_TYPE_301, REDIRECT_TYPE_302, REDIRECT_TYPE_SERVER_TRANSFER } = await import( - '@sitecore-content-sdk/content/site' - )); - ({ createRedirectsMiddleware } = await import('./redirects-middleware')); -}); - const SITES: SiteInfo[] = [{ hostName: '*', language: 'en', name: 'site-a' }]; function createService(redirects: RedirectInfo[]): RedirectsService { diff --git a/packages/angular/vitest-base.config.ts b/packages/angular/vitest-base.config.ts new file mode 100644 index 0000000000..b54122cb7c --- /dev/null +++ b/packages/angular/vitest-base.config.ts @@ -0,0 +1,29 @@ +import { defineConfig } from 'vitest/config'; + +/** + * Base Vitest config, loaded by the `@angular/build:unit-test` builder via `runnerConfig: true` in + * angular.json. + * + * The builder defaults to `isolate: false`, so all spec files in a worker share one module registry + * and one Angular runtime. That shared state makes the suite order-dependent, which is why it passes + * locally (single worker, one file order) but fails on CI (multiple workers distribute files into + * different per-worker orders). Two failure modes surface under an adversarial order: + * - registry pollution: a spec that loads a real workspace module (e.g. redirects-middleware.spec + * loading real `@sitecore-content-sdk/core`) before a sibling spec registers its `vi.mock` + * silently defeats that mock (personalize-middleware's real `initContentSdk` runs on CI). + * - global runtime state: `sitecore-analytics.spec` calls `enableProdMode()`, which only sticks if + * it runs before any other Angular bootstrap; otherwise `isDevMode()` stays true and analytics + * short-circuits. + * + * `isolate: true` gives each spec file a fresh module registry and Angular runtime, removing the + * order dependence entirely so both failure modes disappear. Verified against a single-worker, + * fixed-adversarial-order repro: the suite fails under `isolate: false` and passes under + * `isolate: true`, regardless of file order. Cheaper alternatives were rejected — `vi.resetModules` + * introduces new failures (breaks TestBed/decorator identity), and mock resets have no effect + * (the leak is module/runtime state, not spies). + */ +export default defineConfig({ + test: { + isolate: true, + }, +}); From e9c775699f71e6b1e2c7c9f57a18a875b6ab5e1c Mon Sep 17 00:00:00 2001 From: Artem Alexeyenko Date: Wed, 29 Jul 2026 14:58:46 -0400 Subject: [PATCH 6/7] de-verbose vitest conf comment --- packages/angular/vitest-base.config.ts | 23 ++++------------------- 1 file changed, 4 insertions(+), 19 deletions(-) diff --git a/packages/angular/vitest-base.config.ts b/packages/angular/vitest-base.config.ts index b54122cb7c..9a7ff24094 100644 --- a/packages/angular/vitest-base.config.ts +++ b/packages/angular/vitest-base.config.ts @@ -1,26 +1,11 @@ import { defineConfig } from 'vitest/config'; /** - * Base Vitest config, loaded by the `@angular/build:unit-test` builder via `runnerConfig: true` in - * angular.json. - * - * The builder defaults to `isolate: false`, so all spec files in a worker share one module registry - * and one Angular runtime. That shared state makes the suite order-dependent, which is why it passes - * locally (single worker, one file order) but fails on CI (multiple workers distribute files into - * different per-worker orders). Two failure modes surface under an adversarial order: - * - registry pollution: a spec that loads a real workspace module (e.g. redirects-middleware.spec - * loading real `@sitecore-content-sdk/core`) before a sibling spec registers its `vi.mock` - * silently defeats that mock (personalize-middleware's real `initContentSdk` runs on CI). - * - global runtime state: `sitecore-analytics.spec` calls `enableProdMode()`, which only sticks if - * it runs before any other Angular bootstrap; otherwise `isDevMode()` stays true and analytics - * short-circuits. - * + * The Angular test builder defaults to `isolate: false`, so all spec files in a worker share one module registry + * and one Angular runtime. That shared state makes the suite order-dependent, which may cause issues + * with mocks and module replacements in unit tests * `isolate: true` gives each spec file a fresh module registry and Angular runtime, removing the - * order dependence entirely so both failure modes disappear. Verified against a single-worker, - * fixed-adversarial-order repro: the suite fails under `isolate: false` and passes under - * `isolate: true`, regardless of file order. Cheaper alternatives were rejected — `vi.resetModules` - * introduces new failures (breaks TestBed/decorator identity), and mock resets have no effect - * (the leak is module/runtime state, not spies). + * order dependence entirely. */ export default defineConfig({ test: { From 5b9e78e8590226232e81dcf7836dcc4701910e18 Mon Sep 17 00:00:00 2001 From: Artem Alexeyenko Date: Thu, 30 Jul 2026 07:58:34 -0400 Subject: [PATCH 7/7] move redirect middleware before personalize --- .../src/templates/angular/src/server.ts | 36 +++++++++---------- 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/packages/create-content-sdk-app/src/templates/angular/src/server.ts b/packages/create-content-sdk-app/src/templates/angular/src/server.ts index 8511d63a4f..76467e5c4d 100644 --- a/packages/create-content-sdk-app/src/templates/angular/src/server.ts +++ b/packages/create-content-sdk-app/src/templates/angular/src/server.ts @@ -143,19 +143,17 @@ app.use( ); /** - * Personalize middleware. Identifies page/component variants for the request via - * Sitecore CDP and writes them onto `req.scParams` so the page loader fetches the - * personalized layout and the loader cache keys per variant. Skips bot requests marked - * by the bot tracking middleware (`skipForBot`, default true). - * - * NOTE: Personalize requires Edge configuration (contextId/clientContextId) and - * cannot work with local containers + * Redirects middleware. Matches each request against the site's Sitecore redirects (locale, + * static and regex rules) and issues a 301/302 redirect or an internal server-transfer rewrite. + * Runs after multisite (which resolves the site it fetches redirects for) and before personalize + * so a redirect short-circuits the request before a CDP call is made. */ app.use( - createPersonalizeMiddleware({ - ...config.personalize, + createRedirectsMiddleware({ + ...config.redirects, ...config.api.edge, - locales: config.angular.locales, + ...(config.api.local ?? {}), + sites, defaultLanguage: config.defaultLanguage, defaultSite: config.defaultSite, matcher: middlewareMatcher, @@ -163,17 +161,19 @@ app.use( ); /** - * Redirects middleware. Matches each request against the site's Sitecore redirects (locale, - * static and regex rules) and issues a 301/302 redirect or an internal server-transfer rewrite. - * Runs after multisite (which resolves the site it fetches redirects for) and before personalize - * so a redirect short-circuits the request before a CDP call is made. + * Personalize middleware. Identifies page/component variants for the request via + * Sitecore CDP and writes them onto `req.scParams` so the page loader fetches the + * personalized layout and the loader cache keys per variant. Skips bot requests marked + * by the bot tracking middleware (`skipForBot`, default true). + * + * NOTE: Personalize requires Edge configuration (contextId/clientContextId) and + * cannot work with local containers */ app.use( - createRedirectsMiddleware({ - ...config.redirects, + createPersonalizeMiddleware({ + ...config.personalize, ...config.api.edge, - ...(config.api.local ?? {}), - sites, + locales: config.angular.locales, defaultLanguage: config.defaultLanguage, defaultSite: config.defaultSite, matcher: middlewareMatcher,