281 lines
10 KiB
TypeScript
281 lines
10 KiB
TypeScript
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 <img> 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('');
|
|
});
|
|
});
|