TASKS-1823 WebManager module
This commit is contained in:
@@ -0,0 +1,280 @@
|
||||
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('');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user