import { createRequire } from 'node:module' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { getSitemapBaseURL, normalizeSitemapBaseURL } from '@/utilities/sitemapBaseURL.cjs' type SitemapCollection = 'pages' | 'posts' | 'events' | 'preistraeger' type SitemapDocument = { _status: 'draft' | 'published' slug?: string spaPath?: string updatedAt?: string } type SitemapFindQuery = { collection: SitemapCollection draft?: boolean overrideAccess?: boolean where?: { _status?: { equals?: string } } } const routeState = vi.hoisted(() => ({ docs: { events: [], pages: [], posts: [], preistraeger: [], } as Record, queries: [] as SitemapFindQuery[], })) vi.mock('@payload-config', () => ({ default: {} })) vi.mock('next/cache', () => ({ unstable_cache: (callback: () => unknown) => callback, })) vi.mock('payload', () => ({ getPayload: async () => ({ find: async (query: SitemapFindQuery) => { routeState.queries.push(query) const isPublishedPublicQuery = query.draft === false && query.overrideAccess === false && query.where?._status?.equals === 'published' return { docs: isPublishedPublicQuery ? routeState.docs[query.collection].filter((document) => document._status === 'published') : routeState.docs[query.collection], } }, }), })) import { GET as getPagesSitemap } from '@/app/(frontend)/(sitemaps)/pages-sitemap.xml/route' import { GET as getPostsSitemap } from '@/app/(frontend)/(sitemaps)/posts-sitemap.xml/route' const productionURL = 'https://www.bayerischer-mittelstandspreis.de' async function sitemapLocations(response: Response): Promise { const xml = await response.text() return [...xml.matchAll(/([^<]+)<\/loc>/g)].map((match) => match[1]) } describe('sitemap route generation', () => { beforeEach(() => { process.env.NEXT_PUBLIC_SERVER_URL = `${productionURL}/` process.env.VERCEL_PROJECT_PRODUCTION_URL = '' for (const collection of Object.keys(routeState.docs) as SitemapCollection[]) { routeState.docs[collection] = [] } routeState.queries = [] }) afterEach(() => { vi.restoreAllMocks() }) it.each([ { label: 'stored homepage path', document: { _status: 'published', slug: 'startseite', spaPath: '/' }, expectedPath: '/', }, { label: 'home slug fallback', document: { _status: 'published', slug: 'home' }, expectedPath: '/', }, { label: 'startseite slug fallback', document: { _status: 'published', slug: 'startseite' }, expectedPath: '/', }, { label: 'normal stored page path', document: { _status: 'published', slug: 'legacy-contact', spaPath: '/kontakt' }, expectedPath: '/kontakt', }, ] as const)('emits the $label', async ({ document, expectedPath }) => { routeState.docs.pages = [document] await expect(sitemapLocations(await getPagesSitemap())).resolves.toEqual([ `${productionURL}${expectedPath}`, ]) }) it.each([ { collection: 'posts', prefix: '/presse/blog', slug: 'presseartikel' }, { collection: 'events', prefix: '/presse/events', slug: 'preisverleihung' }, { collection: 'preistraeger', prefix: '/preistraeger', slug: 'muster-gmbh' }, ] as const)( 'emits $collection detail routes under $prefix', async ({ collection, prefix, slug }) => { routeState.docs[collection] = [ { _status: 'published', slug, spaPath: `${prefix}/${slug}`, }, ] await expect(sitemapLocations(await getPostsSitemap())).resolves.toContain( `${productionURL}${prefix}/${slug}`, ) }, ) it('excludes unpublished documents through the established anonymous public query contract', async () => { routeState.docs.pages = [ { _status: 'published', slug: 'sichtbar', spaPath: '/sichtbar' }, { _status: 'draft', slug: 'entwurf', spaPath: '/entwurf' }, ] routeState.docs.posts = [ { _status: 'published', slug: 'sichtbar', spaPath: '/presse/blog/sichtbar' }, { _status: 'draft', slug: 'entwurf', spaPath: '/presse/blog/entwurf' }, ] const locations = [ ...(await sitemapLocations(await getPagesSitemap())), ...(await sitemapLocations(await getPostsSitemap())), ] expect(locations).toContain(`${productionURL}/sichtbar`) expect(locations).toContain(`${productionURL}/presse/blog/sichtbar`) expect(locations.some((location) => location.includes('entwurf'))).toBe(false) expect(routeState.queries).toHaveLength(4) expect(routeState.queries).toEqual( expect.arrayContaining([ expect.objectContaining({ draft: false, overrideAccess: false, where: { _status: { equals: 'published' } }, }), ]), ) }) }) describe('sitemap production base URL', () => { it.each([ [ 'trailing path and slash', 'https://www.bayerischer-mittelstandspreis.de/path///', productionURL, ], ['bare deployment hostname', 'bmp-production.vercel.app', 'https://bmp-production.vercel.app'], ])('normalizes a %s', (_label, configuredURL, expectedURL) => { expect(normalizeSitemapBaseURL(configuredURL)).toBe(expectedURL) }) it('rejects a localhost production origin', () => { expect(() => normalizeSitemapBaseURL('http://localhost:3000', { production: true })).toThrow( 'Production sitemap base URL must be a public HTTPS origin.', ) }) it('fails instead of emitting a fallback when no public URL is configured', () => { expect(() => getSitemapBaseURL({ NODE_ENV: 'production' })).toThrow( 'Sitemap base URL is not configured.', ) }) it('uses the normalized production origin for the robots sitemap URLs', () => { const require = createRequire(import.meta.url) const configPath = require.resolve('../../next-sitemap.config.cjs') delete require.cache[configPath] process.env.NEXT_PUBLIC_SERVER_URL = `${productionURL}/deployment/path/` const config = require(configPath) as { robotsTxtOptions: { additionalSitemaps: string[] } siteUrl: string } expect(config.siteUrl).toBe(productionURL) expect(config.robotsTxtOptions.additionalSitemaps).toEqual([ `${productionURL}/pages-sitemap.xml`, `${productionURL}/posts-sitemap.xml`, ]) }) })