TASKS-1823 WebManager module

This commit is contained in:
Igor Martyniuk
2026-08-28 08:35:58 +00:00
parent fdb2cf3837
commit c93d8c025b
654 changed files with 26232 additions and 6075 deletions
+280
View File
@@ -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('');
});
});