import { describe, expect, it } from 'vitest'; import { createEmptySiteSettings, normalizePage, normalizePageResolution, normalizePageTeaserList, normalizeRoutePath, normalizeSiteSettings } from '#web-runtime/data/normalize'; const request = { locale: 'en', path: 'about' }; /** * Everything the public site renders passes through here, and it is the only guard between * an unexpected backend payload and a crashed page. So the rule these tests hold it to is: * degrade, never throw. */ describe('normalizePageResolution', () => { /** The exact envelope `GET /web-manager/public/pages/resolve` returns. */ const backendPayload = { page: { id: 61, status: 'published', locale: 'en', path: 'blog/api-webhooks', groupKey: 'blog-api-webhooks', layout: 'blog-item', pageType: 'blog-item', title: 'Working with webhooks', hero: { type: 'low', title: 'Working with webhooks', ctaItems: [] }, seo: { title: 'Working with webhooks — Crux', description: 'A seeded page.' } }, components: [ { id: 238, type: 'content', data: { title: 'Introduction', subtitle: '', columns: [] } } ], settings: { locale: 'en', header: { menus: [] }, footer: {}, legal: {} }, breadcrumbs: [ { label: 'Home', href: '/en' }, { label: 'Blog', href: '/en/blog' } ], languageAlternatives: [{ locale: 'de', path: 'blog/api-webhooks' }] }; it('reads the wrapped shape the backend actually sends', () => { const resolution = normalizePageResolution(backendPayload, request); expect(resolution?.page.id).toBe('61'); expect(resolution?.page.layout).toBe('blog-item'); expect(resolution?.page.title).toBe('Working with webhooks'); expect(resolution?.page.blocks).toHaveLength(1); expect(resolution?.page.breadcrumbs.map((crumb) => crumb.label)).toEqual(['Home', 'Blog']); expect(resolution?.languageAlternatives).toEqual([{ locale: 'de', path: 'blog/api-webhooks' }]); expect(resolution?.settings?.locale).toBe('en'); }); it('unwraps a `data` envelope, so a wrapped response still renders', () => { const wrapped = normalizePageResolution({ data: backendPayload }, request); expect(wrapped?.page.path).toBe('blog/api-webhooks'); }); it('returns null for anything that is not a page', () => { expect(normalizePageResolution(null, request)).toBeNull(); expect(normalizePageResolution('nope', request)).toBeNull(); expect(normalizePageResolution({}, request)).toBeNull(); expect(normalizePageResolution({ page: {} }, request)).toBeNull(); }); it('omits settings rather than inventing them when the payload has none', () => { const resolution = normalizePageResolution({ ...backendPayload, settings: null }, request); expect(resolution?.settings).toBeUndefined(); }); }); describe('normalizePage', () => { it('falls back to the request and the hero when fields are missing', () => { const page = normalizePage({ title: 'About us' }, request); expect(page?.locale).toBe('en'); expect(page?.path).toBe('about'); expect(page?.status).toBe('published'); expect(page?.layout).toBe('landing'); expect(page?.seo.title).toBe('About us'); expect(page?.seo.description).toBe(''); }); it('takes the seo title from the flat backend fields too', () => { const page = normalizePage( { title: 'About us', seoTitle: 'About us — Crux', seoDescription: 'Who we are' }, request ); expect(page?.seo).toEqual({ title: 'About us — Crux', description: 'Who we are' }); }); it('strips stray slashes from the path', () => { const page = normalizePage({ title: 'x', path: '/blog/post/' }, request); expect(page?.path).toBe('blog/post'); }); it('reads blocks from either `blocks` or the backend `components` key', () => { const fromComponents = normalizePage( { title: 'x', components: [{ type: 'banner', data: { style: 'info' } }] }, request ); const fromBlocks = normalizePage( { title: 'x', blocks: [{ type: 'banner', style: 'info' }] }, request ); expect(fromComponents?.blocks[0]?.type).toBe('banner'); expect(fromBlocks?.blocks[0]?.type).toBe('banner'); }); }); describe('block normalization', () => { const blocksOf = (blocks: unknown[]) => normalizePage({ title: 'x', blocks }, request)?.blocks ?? []; it('keeps an unknown block instead of dropping it, so the page shows it is unsupported', () => { const [block] = blocksOf([{ type: 'carousel', slides: 3 }]); expect(block?.type).toBe('carousel'); expect(block).toHaveProperty('data'); }); it('drops entries that cannot be a block at all', () => { expect(blocksOf([null, 'text', 42, {}, { data: {} }])).toEqual([]); }); it('gives every block a stable id, falling back to its position', () => { const blocks = blocksOf([{ type: 'banner' }, { id: 9, type: 'banner' }]); expect(blocks[0]?.id).toBe('banner-1'); expect(blocks[1]?.id).toBe('9'); }); it('falls back to safe defaults for unknown enum values', () => { const [banner] = blocksOf([{ type: 'banner', style: 'chartreuse' }]); const [mediaContent] = blocksOf([{ type: 'media-content', mediaPosition: 'sideways' }]); expect(banner).toMatchObject({ style: 'info' }); expect(mediaContent).toMatchObject({ mediaPosition: 'left' }); }); it('accepts partial block data without throwing', () => { expect(() => blocksOf([{ type: 'content' }, { type: 'faq' }, { type: 'articles' }]) ).not.toThrow(); const [content, faq, articles] = blocksOf([ { type: 'content' }, { type: 'faq' }, { type: 'articles' } ]); expect(content).toMatchObject({ columns: [] }); expect(faq).toMatchObject({ items: [] }); expect(articles).toMatchObject({ items: [] }); }); }); describe('media normalization', () => { const mediaOf = (media: unknown) => normalizePage({ title: 'x', blocks: [{ type: 'media', media }] }, request)?.blocks[0]; it('drops media that has no url, since an empty is worse than nothing', () => { expect(mediaOf({ alt: 'No file' })).toMatchObject({ media: undefined }); expect(mediaOf(null)).toMatchObject({ media: undefined }); }); it('keeps optional dimensions only when they are real numbers', () => { expect(mediaOf({ url: '/a.jpg', width: 800, height: 525 })).toMatchObject({ media: { url: '/a.jpg', alt: '', width: 800, height: 525 } }); expect(mediaOf({ url: '/a.jpg', width: 'wide', height: Number.NaN })).toMatchObject({ media: { url: '/a.jpg', alt: '' } }); }); }); describe('navigation link normalization', () => { const linksOf = (ctaItems: unknown) => normalizePage({ title: 'x', blocks: [{ type: 'banner', ctaItems }] }, request)?.blocks[0]; it('drops links with no label, which would render as an invisible target', () => { expect(linksOf([{ href: '/en/about' }, { label: '', href: '/x' }])).toMatchObject({ ctaItems: [] }); }); it('defaults an unknown variant and link type rather than rendering nothing', () => { expect(linksOf([{ label: 'Go', variant: 'sparkly', linkType: 'telepathic' }])).toMatchObject({ ctaItems: [{ variant: 'ghost', linkType: 'external' }] }); }); it('accepts the href under any of the keys the backend has used', () => { expect(linksOf([{ label: 'a', href: '/1' }])).toMatchObject({ ctaItems: [{ href: '/1' }] }); expect(linksOf([{ label: 'b', url: '/2' }])).toMatchObject({ ctaItems: [{ href: '/2' }] }); expect(linksOf([{ label: 'c', path: '/3' }])).toMatchObject({ ctaItems: [{ href: '/3' }] }); }); it('normalizes a numeric pageId to a string so lookups match', () => { expect(linksOf([{ label: 'a', linkType: 'internal', pageId: 13 }])).toMatchObject({ ctaItems: [{ pageId: '13' }] }); }); }); describe('site settings normalization', () => { it('fills in every section a payload leaves out', () => { const settings = normalizeSiteSettings({ locale: 'uk' }, 'en'); expect(settings).toEqual({ locale: 'uk', header: { menus: [], ctaItems: [] }, footer: { copyright: '', menus: [] }, legal: { privacyPath: '', termsPath: '' } }); }); it('returns null for a non-object, letting the caller substitute empty settings', () => { expect(normalizeSiteSettings(null, 'en')).toBeNull(); expect(createEmptySiteSettings('en').locale).toBe('en'); }); }); describe('blog teaser list normalization', () => { it('reads items and pagination from the backend payload', () => { const list = normalizePageTeaserList({ items: [ { id: 40, title: 'Getting started', path: '/blog/getting-started', publishedAt: '2026-01-01' } ], pagination: { page: 2, perPage: 12, total: 30, lastPage: 3 } }); expect(list.items).toEqual([ { id: '40', path: 'blog/getting-started', title: 'Getting started', description: '', publishedAt: '2026-01-01' } ]); expect(list.pagination).toEqual({ page: 2, perPage: 12, total: 30, lastPage: 3 }); }); it('drops teasers that could not be linked to', () => { const list = normalizePageTeaserList({ items: [{ title: 'No path' }, { path: 'no-title' }, { title: 'Good', path: 'good' }] }); expect(list.items.map((item) => item.title)).toEqual(['Good']); }); it('survives a missing or nonsense pagination block', () => { expect(normalizePageTeaserList({ items: [] }).pagination).toEqual({ page: 1, perPage: 1, total: 0, lastPage: 1 }); expect( normalizePageTeaserList({ items: [], pagination: { page: -3, total: 'lots' } }) ).toMatchObject({ pagination: { page: 1, total: 0 } }); expect(normalizePageTeaserList(null).items).toEqual([]); }); }); describe('normalizeRoutePath', () => { it('produces the same path shape the backend stores', () => { expect(normalizeRoutePath('/blog/post/')).toBe('blog/post'); expect(normalizeRoutePath('')).toBe(''); expect(normalizeRoutePath('/')).toBe(''); }); });