Add initial project files
This commit is contained in:
@@ -0,0 +1,74 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { pagerPages, requestedPage } from '#web-runtime/layouts/components/blog-pager';
|
||||
|
||||
describe('requestedPage', () => {
|
||||
it('defaults to page 1 with no search params at all', () => {
|
||||
expect(requestedPage()).toBe(1);
|
||||
expect(requestedPage({})).toBe(1);
|
||||
});
|
||||
|
||||
it('parses a plain string value', () => {
|
||||
expect(requestedPage({ page: '3' })).toBe(3);
|
||||
});
|
||||
|
||||
it('takes the first entry when the param is repeated', () => {
|
||||
expect(requestedPage({ page: ['4', '9'] })).toBe(4);
|
||||
});
|
||||
|
||||
it('falls back to 1 for an empty repeated param', () => {
|
||||
expect(requestedPage({ page: [] })).toBe(1);
|
||||
});
|
||||
|
||||
it('falls back to 1 for values that are not numbers', () => {
|
||||
expect(requestedPage({ page: 'abc' })).toBe(1);
|
||||
expect(requestedPage({ page: '' })).toBe(1);
|
||||
expect(requestedPage({ page: undefined })).toBe(1);
|
||||
});
|
||||
|
||||
it('falls back to 1 for zero and negative pages', () => {
|
||||
expect(requestedPage({ page: '0' })).toBe(1);
|
||||
expect(requestedPage({ page: '-2' })).toBe(1);
|
||||
});
|
||||
|
||||
it('truncates decimals via parseInt (pinned behavior)', () => {
|
||||
expect(requestedPage({ page: '2.9' })).toBe(2);
|
||||
});
|
||||
|
||||
it('accepts numbers with trailing junk, as parseInt does (pinned behavior)', () => {
|
||||
expect(requestedPage({ page: '3abc' })).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('pagerPages', () => {
|
||||
it('shows a single page as itself', () => {
|
||||
expect(pagerPages(1, 1)).toEqual([1]);
|
||||
});
|
||||
|
||||
it('shows every page when the range is small', () => {
|
||||
expect(pagerPages(2, 3)).toEqual([1, 2, 3]);
|
||||
expect(pagerPages(3, 4)).toEqual([1, 2, 3, 4]);
|
||||
});
|
||||
|
||||
it('windows a large range around the current page, keeping the first and last', () => {
|
||||
expect(pagerPages(5, 10)).toEqual([1, 4, 5, 6, 10]);
|
||||
});
|
||||
|
||||
it('anchors the window at the start when on page 1', () => {
|
||||
expect(pagerPages(1, 10)).toEqual([1, 2, 10]);
|
||||
});
|
||||
|
||||
it('anchors the window at the end when on the last page', () => {
|
||||
expect(pagerPages(10, 10)).toEqual([1, 9, 10]);
|
||||
});
|
||||
|
||||
it('deduplicates when the window overlaps the anchors', () => {
|
||||
expect(pagerPages(2, 2)).toEqual([1, 2]);
|
||||
expect(pagerPages(2, 4)).toEqual([1, 2, 3, 4]);
|
||||
});
|
||||
|
||||
it('always returns a sorted ascending list', () => {
|
||||
const pages = pagerPages(37, 80);
|
||||
expect(pages).toEqual([...pages].sort((a, b) => a - b));
|
||||
expect(pages).toEqual([1, 36, 37, 38, 80]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { blogCacheTag, blogListCacheTags, pageCacheTag, pageCacheTags, settingsCacheTag, settingsCacheTags, WEB_MANAGER_TAG } from '#web-runtime/api/cache-tags';
|
||||
|
||||
/**
|
||||
* These strings are a contract with the backend: it purges by the exact names the web app
|
||||
* labels its fetches with. The literals are duplicated in
|
||||
* `apps/backend/app/base_modules/WebManager/services/web_revalidation_service.ts`, and the
|
||||
* backend has a matching spec — change one side without the other and published content
|
||||
* silently keeps serving from cache until the 60s window expires.
|
||||
*/
|
||||
describe('cache tag names', () => {
|
||||
it('matches the names the backend purges', () => {
|
||||
expect(pageCacheTag('en', 'pricing')).toBe('web-manager:page:en:pricing');
|
||||
expect(settingsCacheTag('uk')).toBe('web-manager:settings:uk');
|
||||
expect(blogCacheTag('de')).toBe('web-manager:blog:de');
|
||||
});
|
||||
|
||||
it('gives the home page a tag that cannot collide with a real path', () => {
|
||||
expect(pageCacheTag('en', '')).toBe('web-manager:page:en:/');
|
||||
expect(pageCacheTag('en', '')).not.toBe(pageCacheTag('en', 'index'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('cache tag sets', () => {
|
||||
it('puts the site-wide tag on every fetch so a structural purge reaches all of them', () => {
|
||||
expect(pageCacheTags('en', 'about')).toContain(WEB_MANAGER_TAG);
|
||||
expect(blogListCacheTags('en')).toContain(WEB_MANAGER_TAG);
|
||||
expect(settingsCacheTags('en')).toContain(WEB_MANAGER_TAG);
|
||||
});
|
||||
|
||||
it('tags a page with its own settings, since the page payload embeds them', () => {
|
||||
expect(pageCacheTags('en', 'about')).toContain(settingsCacheTag('en'));
|
||||
});
|
||||
|
||||
it('keeps locales apart', () => {
|
||||
expect(pageCacheTags('en', 'about')).not.toContain(pageCacheTag('uk', 'about'));
|
||||
expect(settingsCacheTags('en')).not.toContain(settingsCacheTag('uk'));
|
||||
});
|
||||
});
|
||||
@@ -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('');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,163 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { pageMetadata } from '#web-runtime/lib/page-metadata';
|
||||
import type { PageDocument, PageRenderData, SiteSettings } from '#web-runtime/types';
|
||||
|
||||
const settings: SiteSettings = {
|
||||
locale: 'en',
|
||||
header: { menus: [], ctaItems: [] },
|
||||
footer: { copyright: '', menus: [] },
|
||||
legal: { privacyPath: '', termsPath: '' }
|
||||
};
|
||||
|
||||
function page(overrides: Partial<PageDocument> = {}): PageDocument {
|
||||
return {
|
||||
id: '7',
|
||||
status: 'published',
|
||||
locale: 'en',
|
||||
path: 'about',
|
||||
groupKey: 'about',
|
||||
layout: 'landing',
|
||||
title: 'About us',
|
||||
seo: { title: 'About us — Crux', description: 'Who we are' },
|
||||
breadcrumbs: [],
|
||||
blocks: [],
|
||||
publishedAt: null,
|
||||
updatedAt: null,
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
function renderData(overrides: Partial<PageRenderData> = {}): PageRenderData {
|
||||
return {
|
||||
page: page(),
|
||||
settings,
|
||||
languageAlternatives: [],
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
/** The site serves locale-prefixed paths; metadataBase turns these into absolute URLs. */
|
||||
const publicPath = (locale: string, path: string) => `/${locale}${path ? `/${path}` : ''}`;
|
||||
|
||||
describe('title and description', () => {
|
||||
it('prefers the editor SEO fields', () => {
|
||||
const metadata = pageMetadata(renderData(), 'en', publicPath);
|
||||
|
||||
expect(metadata.title).toBe('About us — Crux');
|
||||
expect(metadata.description).toBe('Who we are');
|
||||
});
|
||||
|
||||
it('falls back to the page title, and omits an empty description entirely', () => {
|
||||
const data = renderData({ page: page({ seo: { title: '', description: '' } }) });
|
||||
const metadata = pageMetadata(data, 'en', publicPath);
|
||||
|
||||
expect(metadata.title).toBe('About us');
|
||||
expect(metadata.description).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('canonical and hreflang', () => {
|
||||
it('always sets a canonical for the page itself', () => {
|
||||
expect(pageMetadata(renderData(), 'en', publicPath).alternates?.canonical).toBe('/en/about');
|
||||
});
|
||||
|
||||
it('emits no language map for a page with no translations', () => {
|
||||
const data = renderData({ languageAlternatives: [{ locale: 'en', path: 'about' }] });
|
||||
|
||||
expect(pageMetadata(data, 'en', publicPath).alternates?.languages).toBeUndefined();
|
||||
});
|
||||
|
||||
it('lists every locale including this one, plus x-default', () => {
|
||||
const data = renderData({
|
||||
languageAlternatives: [
|
||||
{ locale: 'de', path: 'ueber-uns' },
|
||||
{ locale: 'en', path: 'about' },
|
||||
{ locale: 'uk', path: 'pro-nas' }
|
||||
]
|
||||
});
|
||||
|
||||
expect(pageMetadata(data, 'en', publicPath).alternates?.languages).toEqual({
|
||||
'de': '/de/ueber-uns',
|
||||
'en': '/en/about',
|
||||
'uk': '/uk/pro-nas',
|
||||
'x-default': '/en/about'
|
||||
});
|
||||
});
|
||||
|
||||
it('points x-default at this page when the default locale has no translation', () => {
|
||||
const data = renderData({
|
||||
page: page({ locale: 'uk', path: 'pro-nas' }),
|
||||
languageAlternatives: [
|
||||
{ locale: 'uk', path: 'pro-nas' },
|
||||
{ locale: 'de', path: 'ueber-uns' }
|
||||
]
|
||||
});
|
||||
|
||||
expect(pageMetadata(data, 'en', publicPath).alternates?.languages).toMatchObject({
|
||||
'x-default': '/uk/pro-nas'
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('share cards', () => {
|
||||
const media = {
|
||||
url: 'https://cdn.example.com/hero.jpg',
|
||||
alt: 'Team photo',
|
||||
width: 800,
|
||||
height: 525
|
||||
};
|
||||
|
||||
it('uses the hero image, with the dimensions crawlers lay out against', () => {
|
||||
const data = renderData({ page: page({ hero: { type: 'middle', ctaItems: [], media } }) });
|
||||
const metadata = pageMetadata(data, 'en', publicPath);
|
||||
|
||||
expect(metadata.openGraph?.images).toEqual([
|
||||
{ url: media.url, alt: 'Team photo', width: 800, height: 525 }
|
||||
]);
|
||||
expect(metadata.twitter).toMatchObject({ card: 'summary_large_image' });
|
||||
});
|
||||
|
||||
it('falls back to the page title when the image has no alt text', () => {
|
||||
const data = renderData({
|
||||
page: page({ hero: { type: 'middle', ctaItems: [], media: { ...media, alt: '' } } })
|
||||
});
|
||||
|
||||
expect(pageMetadata(data, 'en', publicPath).openGraph?.images).toMatchObject([
|
||||
{ alt: 'About us' }
|
||||
]);
|
||||
});
|
||||
|
||||
it('omits dimensions the backend did not provide rather than guessing', () => {
|
||||
const data = renderData({
|
||||
page: page({ hero: { type: 'middle', ctaItems: [], media: { url: media.url, alt: 'x' } } })
|
||||
});
|
||||
|
||||
expect(pageMetadata(data, 'en', publicPath).openGraph?.images).toEqual([
|
||||
{ url: media.url, alt: 'x' }
|
||||
]);
|
||||
});
|
||||
|
||||
it('drops to a small card when the page has no hero image', () => {
|
||||
const metadata = pageMetadata(renderData(), 'en', publicPath);
|
||||
|
||||
expect(metadata.openGraph?.images).toBeUndefined();
|
||||
expect(metadata.twitter).toMatchObject({ card: 'summary' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('open graph type', () => {
|
||||
it('marks an editorial page as an article and everything else as a page', () => {
|
||||
const article = renderData({ page: page({ layout: 'blog-item' }) });
|
||||
const landing = renderData({ page: page({ layout: 'landing' }) });
|
||||
|
||||
expect(pageMetadata(article, 'en', publicPath).openGraph).toMatchObject({ type: 'article' });
|
||||
expect(pageMetadata(landing, 'en', publicPath).openGraph).toMatchObject({ type: 'website' });
|
||||
});
|
||||
|
||||
it('carries the page locale and its own url', () => {
|
||||
expect(pageMetadata(renderData(), 'en', publicPath).openGraph).toMatchObject({
|
||||
locale: 'en',
|
||||
url: '/en/about'
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { PAGE_BLOCK_REGISTRY } from '#web-runtime/blocks/registry';
|
||||
import { normalizePage } from '#web-runtime/data/normalize';
|
||||
import { PAGE_LAYOUT_REGISTRY } from '#web-runtime/layouts/registry';
|
||||
|
||||
/**
|
||||
* The registries are how the renderer decides what to draw. A block type the backend can
|
||||
* publish but the registry does not know renders as "unsupported", and a page type it does
|
||||
* not know silently renders with the wrong layout — neither fails a build, so these are the
|
||||
* checks that catch it.
|
||||
*/
|
||||
|
||||
/** Every block type the backend can store — mirrors `BLOCK_TYPES` in the backend content types. */
|
||||
const BACKEND_BLOCK_TYPES = ['content', 'faq', 'media-content', 'articles', 'banner', 'media'];
|
||||
|
||||
/** Every page type the backend can store — mirrors `PAGE_TYPES`. */
|
||||
const BACKEND_PAGE_TYPES = ['landing', 'blog', 'blog-item'];
|
||||
|
||||
describe('block registry', () => {
|
||||
it.each(BACKEND_BLOCK_TYPES)('renders the %s block the backend can publish', (type) => {
|
||||
expect(PAGE_BLOCK_REGISTRY[type]).toBeTypeOf('function');
|
||||
});
|
||||
|
||||
it('registers nothing the normalizer cannot produce', () => {
|
||||
expect(Object.keys(PAGE_BLOCK_REGISTRY).sort()).toEqual([...BACKEND_BLOCK_TYPES].sort());
|
||||
});
|
||||
|
||||
it('has no renderer for an unknown type, so it falls through to the unsupported block', () => {
|
||||
expect(PAGE_BLOCK_REGISTRY['carousel']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('covers every block a normalized page can contain', () => {
|
||||
const page = normalizePage(
|
||||
{ title: 'x', blocks: BACKEND_BLOCK_TYPES.map((type) => ({ type })) },
|
||||
{ locale: 'en', path: 'x' }
|
||||
);
|
||||
|
||||
expect(page?.blocks).toHaveLength(BACKEND_BLOCK_TYPES.length);
|
||||
for (const block of page?.blocks ?? []) {
|
||||
expect(PAGE_BLOCK_REGISTRY[block.type]).toBeTypeOf('function');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('layout registry', () => {
|
||||
it.each(BACKEND_PAGE_TYPES)('has a layout for the %s page type', (pageType) => {
|
||||
expect(PAGE_LAYOUT_REGISTRY[pageType]).toBeTypeOf('function');
|
||||
});
|
||||
|
||||
it('leaves an unknown layout to the standard fallback', () => {
|
||||
expect(PAGE_LAYOUT_REGISTRY['microsite']).toBeUndefined();
|
||||
expect(PAGE_LAYOUT_REGISTRY['landing']).toBeTypeOf('function');
|
||||
});
|
||||
|
||||
it('resolves the layout a normalized page reports', () => {
|
||||
const page = normalizePage({ title: 'x', pageType: 'blog-item' }, { locale: 'en', path: 'x' });
|
||||
|
||||
expect(page?.layout).toBe('blog-item');
|
||||
expect(PAGE_LAYOUT_REGISTRY[page?.layout ?? '']).toBeTypeOf('function');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,175 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { resolvePageRenderData, resolvePreviewRenderData } from '#web-runtime/api/render-data';
|
||||
import { createEmptySiteSettings } from '#web-runtime/data/normalize';
|
||||
import type { PageDocument, SiteSettings } from '#web-runtime/types';
|
||||
|
||||
const { resolvePage, getSettings, getPreview } = vi.hoisted(() => ({
|
||||
resolvePage: vi.fn(),
|
||||
getSettings: vi.fn(),
|
||||
getPreview: vi.fn()
|
||||
}));
|
||||
|
||||
vi.mock('#web-runtime/api/web-runtime-api', () => ({
|
||||
getWebRuntimeAPI: () => ({ resolvePage, getSettings, getPreview })
|
||||
}));
|
||||
|
||||
function page(overrides: Partial<PageDocument> = {}): PageDocument {
|
||||
return {
|
||||
id: '7',
|
||||
status: 'published',
|
||||
locale: 'en',
|
||||
path: 'about',
|
||||
groupKey: 'about',
|
||||
layout: 'landing',
|
||||
title: 'About us',
|
||||
seo: { title: 'About us', description: '' },
|
||||
breadcrumbs: [],
|
||||
blocks: [],
|
||||
publishedAt: null,
|
||||
updatedAt: null,
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
function settings(locale = 'en'): SiteSettings {
|
||||
return {
|
||||
locale,
|
||||
header: {
|
||||
menus: [],
|
||||
ctaItems: [
|
||||
{
|
||||
linkType: 'external',
|
||||
pageId: '',
|
||||
externalUrl: 'https://example.com',
|
||||
openInNewTab: false,
|
||||
label: 'CTA',
|
||||
variant: 'primary'
|
||||
}
|
||||
]
|
||||
},
|
||||
footer: { copyright: '© Crux', menus: [] },
|
||||
legal: { privacyPath: 'privacy', termsPath: 'terms' }
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resolvePage.mockReset();
|
||||
getSettings.mockReset();
|
||||
getPreview.mockReset();
|
||||
});
|
||||
|
||||
// resolvePageRenderData is wrapped in React cache(); outside an RSC render
|
||||
// react's cache has no store and calls straight through, but each test still
|
||||
// uses a distinct locale/path pair so results can never leak between tests.
|
||||
|
||||
describe('resolvePageRenderData', () => {
|
||||
it('returns null when the backend cannot resolve the page', async () => {
|
||||
resolvePage.mockResolvedValue(null);
|
||||
|
||||
expect(await resolvePageRenderData('en', 'missing')).toBeNull();
|
||||
expect(getSettings).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('refuses to render a page that is not published (the unpublish gate)', async () => {
|
||||
resolvePage.mockResolvedValue({
|
||||
page: page({ status: 'draft', path: 'draft-page' }),
|
||||
languageAlternatives: []
|
||||
});
|
||||
|
||||
expect(await resolvePageRenderData('en', 'draft-page')).toBeNull();
|
||||
expect(getSettings).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('trims the locale and normalizes the path before asking the backend', async () => {
|
||||
resolvePage.mockResolvedValue(null);
|
||||
|
||||
await resolvePageRenderData(' en ', '/about/team/');
|
||||
|
||||
expect(resolvePage).toHaveBeenCalledWith({ locale: 'en', path: 'about/team' });
|
||||
});
|
||||
|
||||
it('uses the settings bundled into the resolution without a second request', async () => {
|
||||
const bundled = settings();
|
||||
resolvePage.mockResolvedValue({
|
||||
page: page({ path: 'bundled' }),
|
||||
settings: bundled,
|
||||
languageAlternatives: [{ locale: 'de', path: 'gebuendelt' }]
|
||||
});
|
||||
|
||||
const data = await resolvePageRenderData('en', 'bundled');
|
||||
|
||||
expect(data?.settings).toBe(bundled);
|
||||
expect(data?.languageAlternatives).toEqual([{ locale: 'de', path: 'gebuendelt' }]);
|
||||
expect(getSettings).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('fetches settings separately when the resolution has none', async () => {
|
||||
const fetched = settings();
|
||||
resolvePage.mockResolvedValue({ page: page({ path: 'fetched' }), languageAlternatives: [] });
|
||||
getSettings.mockResolvedValue(fetched);
|
||||
|
||||
const data = await resolvePageRenderData('en', 'fetched');
|
||||
|
||||
expect(getSettings).toHaveBeenCalledWith('en');
|
||||
expect(data?.settings).toBe(fetched);
|
||||
});
|
||||
|
||||
it('falls back to empty settings when the backend has none at all', async () => {
|
||||
resolvePage.mockResolvedValue({ page: page({ path: 'bare' }), languageAlternatives: [] });
|
||||
getSettings.mockResolvedValue(null);
|
||||
|
||||
const data = await resolvePageRenderData('en', 'bare');
|
||||
|
||||
expect(data?.settings).toEqual(createEmptySiteSettings('en'));
|
||||
});
|
||||
|
||||
it('returns the resolved page unchanged', async () => {
|
||||
const resolved = page({ path: 'unchanged' });
|
||||
resolvePage.mockResolvedValue({
|
||||
page: resolved,
|
||||
settings: settings(),
|
||||
languageAlternatives: []
|
||||
});
|
||||
|
||||
const data = await resolvePageRenderData('en', 'unchanged');
|
||||
|
||||
expect(data?.page).toBe(resolved);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolvePreviewRenderData', () => {
|
||||
it('returns null for an empty token without calling the backend', async () => {
|
||||
expect(await resolvePreviewRenderData('')).toBeNull();
|
||||
expect(getPreview).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns null when the token resolves to nothing', async () => {
|
||||
getPreview.mockResolvedValue(null);
|
||||
|
||||
expect(await resolvePreviewRenderData('token-1')).toBeNull();
|
||||
expect(getPreview).toHaveBeenCalledWith('token-1');
|
||||
});
|
||||
|
||||
it('renders a draft: previews deliberately skip the published gate', async () => {
|
||||
getPreview.mockResolvedValue({
|
||||
page: page({ status: 'draft' }),
|
||||
settings: settings(),
|
||||
languageAlternatives: []
|
||||
});
|
||||
|
||||
const data = await resolvePreviewRenderData('token-2');
|
||||
|
||||
expect(data?.page.status).toBe('draft');
|
||||
});
|
||||
|
||||
it('falls back to empty settings in the locale of the previewed page', async () => {
|
||||
getPreview.mockResolvedValue({
|
||||
page: page({ locale: 'de', status: 'draft' }),
|
||||
languageAlternatives: []
|
||||
});
|
||||
|
||||
const data = await resolvePreviewRenderData('token-3');
|
||||
|
||||
expect(data?.settings).toEqual(createEmptySiteSettings('de'));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,108 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { POST } from '#app/api/revalidate/route';
|
||||
|
||||
const { revalidateTag } = vi.hoisted(() => ({ revalidateTag: vi.fn() }));
|
||||
|
||||
vi.mock('next/cache', () => ({ revalidateTag }));
|
||||
|
||||
const SECRET = 'test-revalidate-secret';
|
||||
|
||||
function request({
|
||||
secret = SECRET,
|
||||
body = JSON.stringify({ tags: ['web-manager'] })
|
||||
}: { secret?: string | null; body?: string } = {}) {
|
||||
const headers = new Headers({ 'content-type': 'application/json' });
|
||||
if (secret !== null) {
|
||||
headers.set('x-revalidate-secret', secret);
|
||||
}
|
||||
|
||||
return new Request('http://web.test/api/revalidate', { method: 'POST', headers, body });
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubEnv('REVALIDATE_SECRET', SECRET);
|
||||
revalidateTag.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
describe('authentication', () => {
|
||||
it('rejects a request with no secret header', async () => {
|
||||
const response = await POST(request({ secret: null }));
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(revalidateTag).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects a wrong secret', async () => {
|
||||
const response = await POST(request({ secret: 'wrong' }));
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(revalidateTag).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects everything when the server has no secret configured — even a matching empty header', async () => {
|
||||
vi.stubEnv('REVALIDATE_SECRET', '');
|
||||
|
||||
expect((await POST(request({ secret: '' }))).status).toBe(401);
|
||||
expect((await POST(request())).status).toBe(401);
|
||||
expect(revalidateTag).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('payload validation', () => {
|
||||
it('rejects a body that is not JSON', async () => {
|
||||
const response = await POST(request({ body: 'not json {' }));
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(await response.json()).toEqual({ message: 'Invalid JSON body' });
|
||||
});
|
||||
|
||||
it.each([
|
||||
['a non-object payload', JSON.stringify('tags')],
|
||||
['a null payload', JSON.stringify(null)],
|
||||
['a missing tags field', JSON.stringify({})],
|
||||
['tags that are not an array', JSON.stringify({ tags: 'web-manager' })],
|
||||
['an empty tags array', JSON.stringify({ tags: [] })],
|
||||
['a non-string tag', JSON.stringify({ tags: ['ok', 7] })],
|
||||
['an empty-string tag', JSON.stringify({ tags: [''] })]
|
||||
])('rejects %s with 400', async (_label, body) => {
|
||||
const response = await POST(request({ body }));
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(revalidateTag).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('accepts exactly 50 tags but rejects 51', async () => {
|
||||
const fifty = Array.from({ length: 50 }, (_, i) => `tag-${i}`);
|
||||
|
||||
expect((await POST(request({ body: JSON.stringify({ tags: fifty }) }))).status).toBe(200);
|
||||
expect(
|
||||
(await POST(request({ body: JSON.stringify({ tags: [...fifty, 'one-more'] }) }))).status
|
||||
).toBe(400);
|
||||
});
|
||||
|
||||
it('accepts a 256-character tag but rejects 257', async () => {
|
||||
expect(
|
||||
(await POST(request({ body: JSON.stringify({ tags: ['a'.repeat(256)] }) }))).status
|
||||
).toBe(200);
|
||||
expect(
|
||||
(await POST(request({ body: JSON.stringify({ tags: ['a'.repeat(257)] }) }))).status
|
||||
).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe('revalidation', () => {
|
||||
it('revalidates every tag with the max profile and echoes them back', async () => {
|
||||
const tags = ['web-manager', 'web-manager:page:en:about'];
|
||||
const response = await POST(request({ body: JSON.stringify({ tags }) }));
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.json()).toEqual({ revalidated: tags });
|
||||
expect(revalidateTag).toHaveBeenCalledTimes(2);
|
||||
expect(revalidateTag).toHaveBeenNthCalledWith(1, 'web-manager', 'max');
|
||||
expect(revalidateTag).toHaveBeenNthCalledWith(2, 'web-manager:page:en:about', 'max');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import robots from '#app/robots';
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
describe('robots.txt', () => {
|
||||
it('lets crawlers in everywhere except preview URLs', () => {
|
||||
vi.stubEnv('SITE_URL', 'https://crux.example.com');
|
||||
|
||||
const result = robots();
|
||||
|
||||
expect(result.rules).toEqual({
|
||||
userAgent: '*',
|
||||
allow: '/',
|
||||
disallow: '/*/preview'
|
||||
});
|
||||
});
|
||||
|
||||
it('advertises the sitemap on the configured origin', () => {
|
||||
vi.stubEnv('SITE_URL', 'https://crux.example.com');
|
||||
|
||||
expect(robots().sitemap).toBe('https://crux.example.com/sitemap.xml');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { safeHref } from '#web-runtime/components/links';
|
||||
import type { NavigationLink } from '#web-runtime/types';
|
||||
|
||||
/**
|
||||
* `safeHref` is the gate between CMS-authored link data and the `href`
|
||||
* attribute the browser receives, so these tests pin the allowlist hard:
|
||||
* anything not explicitly safe must come back as `null`.
|
||||
*/
|
||||
function link(overrides: Partial<NavigationLink> = {}): NavigationLink {
|
||||
return {
|
||||
linkType: 'external',
|
||||
pageId: '',
|
||||
externalUrl: '',
|
||||
openInNewTab: false,
|
||||
label: 'A link',
|
||||
variant: 'primary',
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
describe('allowed protocols', () => {
|
||||
it.each([
|
||||
'http://example.com/page',
|
||||
'https://example.com/page?q=1#frag',
|
||||
'mailto:hello@example.com',
|
||||
'tel:+380441234567'
|
||||
])('passes %s through untouched', (href) => {
|
||||
expect(safeHref(link({ href }))).toBe(href);
|
||||
});
|
||||
});
|
||||
|
||||
describe('dangerous protocols', () => {
|
||||
it.each([
|
||||
'javascript:alert(1)',
|
||||
'data:text/html,<script>alert(1)</script>',
|
||||
'vbscript:msgbox(1)',
|
||||
'file:///etc/passwd',
|
||||
'blob:https://example.com/uuid',
|
||||
'ftp://example.com/file'
|
||||
])('rejects %s', (href) => {
|
||||
expect(safeHref(link({ href }))).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects javascript: regardless of letter case', () => {
|
||||
expect(safeHref(link({ href: 'JavaScript:alert(1)' }))).toBeNull();
|
||||
expect(safeHref(link({ href: 'JAVASCRIPT:alert(1)' }))).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects javascript: hidden behind leading whitespace, which URL parsing strips', () => {
|
||||
expect(safeHref(link({ href: ' javascript:alert(1)' }))).toBeNull();
|
||||
expect(safeHref(link({ href: '\njavascript:alert(1)' }))).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects a dangerous externalUrl the same way as a dangerous href', () => {
|
||||
expect(safeHref(link({ externalUrl: 'javascript:alert(1)' }))).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('malformed and missing values', () => {
|
||||
it('rejects plain text that is not a URL at all', () => {
|
||||
expect(safeHref(link({ href: 'not a url' }))).toBeNull();
|
||||
expect(safeHref(link({ href: 'example.com/no-protocol' }))).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when neither href nor externalUrl is set', () => {
|
||||
expect(safeHref(link())).toBeNull();
|
||||
expect(safeHref(link({ href: '' }))).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('href source precedence', () => {
|
||||
it('prefers href over externalUrl when both exist', () => {
|
||||
expect(
|
||||
safeHref(
|
||||
link({ href: 'https://primary.example.com', externalUrl: 'https://other.example.com' })
|
||||
)
|
||||
).toBe('https://primary.example.com');
|
||||
});
|
||||
|
||||
it('falls back to externalUrl when href is empty', () => {
|
||||
expect(safeHref(link({ href: '', externalUrl: 'https://fallback.example.com' }))).toBe(
|
||||
'https://fallback.example.com'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('anchors and relative paths', () => {
|
||||
it('passes in-page anchors through untouched', () => {
|
||||
expect(safeHref(link({ href: '#pricing' }))).toBe('#pricing');
|
||||
});
|
||||
|
||||
it('routes site-relative paths through toPublicHref, which keeps the locale prefix in this multi-locale config', () => {
|
||||
// site.config.ts declares en/uk/de, so localePrefix is 'always' and
|
||||
// toPublicHref is the identity function.
|
||||
expect(safeHref(link({ href: '/en/about' }))).toBe('/en/about');
|
||||
expect(safeHref(link({ href: '/pricing' }))).toBe('/pricing');
|
||||
});
|
||||
|
||||
it('currently lets protocol-relative URLs through via the "/" branch (pinned behavior)', () => {
|
||||
// '//evil.example.com' starts with '/', so it never reaches the protocol
|
||||
// allowlist. Browsers resolve it against the current scheme (http/https),
|
||||
// so it cannot smuggle javascript:, but it is an external URL in disguise.
|
||||
expect(safeHref(link({ href: '//evil.example.com' }))).toBe('//evil.example.com');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { siteUrl } from '#web-runtime/lib/site-url';
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('siteUrl', () => {
|
||||
it('uses SITE_URL, trimming stray whitespace', () => {
|
||||
vi.stubEnv('SITE_URL', ' https://crux.example.com ');
|
||||
|
||||
expect(siteUrl().origin).toBe('https://crux.example.com');
|
||||
});
|
||||
|
||||
it('falls back to localhost for local development', () => {
|
||||
vi.stubEnv('SITE_URL', '');
|
||||
vi.stubEnv('NODE_ENV', 'development');
|
||||
|
||||
expect(siteUrl().origin).toBe('http://localhost:3000');
|
||||
});
|
||||
|
||||
it('complains loudly when a production build has none, since the symptom is silent', () => {
|
||||
const error = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
vi.stubEnv('SITE_URL', '');
|
||||
vi.stubEnv('NODE_ENV', 'production');
|
||||
|
||||
expect(siteUrl().origin).toBe('http://localhost:3000');
|
||||
expect(error).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('throws on a malformed SITE_URL rather than building wrong canonicals', () => {
|
||||
vi.stubEnv('SITE_URL', 'crux.example.com');
|
||||
|
||||
expect(() => siteUrl()).toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,130 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { normalizeSitemapEntries, sitemapLastPage } from '#web-runtime/data/normalize';
|
||||
import { buildSitemap } from '#web-runtime/lib/sitemap';
|
||||
import type { SitemapEntry } from '#web-runtime/types';
|
||||
|
||||
const origin = new URL('https://crux.example.com');
|
||||
const publicPath = (locale: string, path: string) => `/${locale}${path ? `/${path}` : ''}`;
|
||||
|
||||
function entry(overrides: Partial<SitemapEntry> = {}): SitemapEntry {
|
||||
return {
|
||||
locale: 'en',
|
||||
path: 'about',
|
||||
groupKey: 'about',
|
||||
pageType: 'landing',
|
||||
publishedAt: '2026-01-01T00:00:00.000+00:00',
|
||||
updatedAt: '2026-02-01T00:00:00.000+00:00',
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
describe('sitemap entry normalization', () => {
|
||||
it('reads the backend listing', () => {
|
||||
const entries = normalizeSitemapEntries({
|
||||
items: [
|
||||
{
|
||||
id: 7,
|
||||
locale: 'en',
|
||||
path: '/about/',
|
||||
groupKey: 'about',
|
||||
pageType: 'landing',
|
||||
publishedAt: '2026-01-01T00:00:00.000+00:00',
|
||||
updatedAt: null
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
expect(entries).toEqual([
|
||||
{
|
||||
locale: 'en',
|
||||
path: 'about',
|
||||
groupKey: 'about',
|
||||
pageType: 'landing',
|
||||
publishedAt: '2026-01-01T00:00:00.000+00:00',
|
||||
updatedAt: null
|
||||
}
|
||||
]);
|
||||
});
|
||||
|
||||
it('drops entries with no locale, which cannot become a URL', () => {
|
||||
expect(normalizeSitemapEntries({ items: [{ path: 'about' }] })).toEqual([]);
|
||||
});
|
||||
|
||||
it('survives a payload that is not a listing at all', () => {
|
||||
expect(normalizeSitemapEntries(null)).toEqual([]);
|
||||
expect(normalizeSitemapEntries({})).toEqual([]);
|
||||
expect(normalizeSitemapEntries({ items: 'nope' })).toEqual([]);
|
||||
});
|
||||
|
||||
it('reads the page count, defaulting to a single page', () => {
|
||||
expect(sitemapLastPage({ pagination: { lastPage: 57 } })).toBe(57);
|
||||
expect(sitemapLastPage({ pagination: { lastPage: 0 } })).toBe(1);
|
||||
expect(sitemapLastPage({})).toBe(1);
|
||||
expect(sitemapLastPage(null)).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildSitemap', () => {
|
||||
it('emits absolute URLs against the configured origin', () => {
|
||||
const [item] = buildSitemap([entry()], origin, publicPath);
|
||||
|
||||
expect(item?.url).toBe('https://crux.example.com/en/about');
|
||||
});
|
||||
|
||||
it('uses the home path for a page with no path segment', () => {
|
||||
const [item] = buildSitemap([entry({ path: '', groupKey: 'home' })], origin, publicPath);
|
||||
|
||||
expect(item?.url).toBe('https://crux.example.com/en');
|
||||
});
|
||||
|
||||
it('reports lastModified from the update date, falling back to publication', () => {
|
||||
const [updated] = buildSitemap([entry()], origin, publicPath);
|
||||
const [neverUpdated] = buildSitemap([entry({ updatedAt: null })], origin, publicPath);
|
||||
const [undated] = buildSitemap(
|
||||
[entry({ updatedAt: null, publishedAt: null })],
|
||||
origin,
|
||||
publicPath
|
||||
);
|
||||
|
||||
expect(updated?.lastModified).toEqual(new Date('2026-02-01T00:00:00.000+00:00'));
|
||||
expect(neverUpdated?.lastModified).toEqual(new Date('2026-01-01T00:00:00.000+00:00'));
|
||||
expect(undated?.lastModified).toBeUndefined();
|
||||
});
|
||||
|
||||
it('links translations of the same page to each other', () => {
|
||||
const sitemap = buildSitemap(
|
||||
[
|
||||
entry({ locale: 'en', path: 'about' }),
|
||||
entry({ locale: 'de', path: 'ueber-uns' }),
|
||||
entry({ locale: 'uk', path: 'pro-nas' })
|
||||
],
|
||||
origin,
|
||||
publicPath
|
||||
);
|
||||
|
||||
expect(sitemap).toHaveLength(3);
|
||||
for (const item of sitemap) {
|
||||
expect(item.alternates?.languages).toEqual({
|
||||
en: 'https://crux.example.com/en/about',
|
||||
de: 'https://crux.example.com/de/ueber-uns',
|
||||
uk: 'https://crux.example.com/uk/pro-nas'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('omits the alternates block for a page that exists in one locale only', () => {
|
||||
const [item] = buildSitemap([entry()], origin, publicPath);
|
||||
|
||||
expect(item?.alternates).toBeUndefined();
|
||||
});
|
||||
|
||||
it('never groups pages that merely lack a group key', () => {
|
||||
const sitemap = buildSitemap(
|
||||
[entry({ groupKey: '', path: 'a' }), entry({ groupKey: '', path: 'b', locale: 'de' })],
|
||||
origin,
|
||||
publicPath
|
||||
);
|
||||
|
||||
expect(sitemap.every((item) => item.alternates === undefined)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { articleJsonLd, breadcrumbListJsonLd } from '#web-runtime/lib/structured-data';
|
||||
import type { PageDocument } from '#web-runtime/types';
|
||||
|
||||
function page(overrides: Partial<PageDocument> = {}): PageDocument {
|
||||
return {
|
||||
id: '61',
|
||||
status: 'published',
|
||||
locale: 'en',
|
||||
path: 'blog/api-webhooks',
|
||||
groupKey: 'blog-api-webhooks',
|
||||
layout: 'blog-item',
|
||||
title: 'Working with webhooks',
|
||||
seo: { title: 'Working with webhooks — Crux', description: 'How webhooks work.' },
|
||||
breadcrumbs: [],
|
||||
blocks: [],
|
||||
publishedAt: '2026-06-23T13:28:30.697+00:00',
|
||||
updatedAt: '2026-08-13T13:28:30.698+00:00',
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
describe('articleJsonLd', () => {
|
||||
it('marks up the dates and the hero image', () => {
|
||||
const media = { url: 'https://cdn.example.com/hero.jpg', alt: 'Hero' };
|
||||
const data = articleJsonLd(page({ hero: { type: 'low', ctaItems: [], media } }));
|
||||
|
||||
expect(data).toEqual({
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'Article',
|
||||
'headline': 'Working with webhooks — Crux',
|
||||
'description': 'How webhooks work.',
|
||||
'datePublished': '2026-06-23T13:28:30.697+00:00',
|
||||
'dateModified': '2026-08-13T13:28:30.698+00:00',
|
||||
'image': ['https://cdn.example.com/hero.jpg'],
|
||||
'inLanguage': 'en'
|
||||
});
|
||||
});
|
||||
|
||||
it('emits nothing without a publication date, rather than an Article a crawler will drop', () => {
|
||||
expect(articleJsonLd(page({ publishedAt: null }))).toBeNull();
|
||||
});
|
||||
|
||||
it('omits dateModified when the page has never been changed since publishing', () => {
|
||||
const published = '2026-06-23T13:28:30.697+00:00';
|
||||
const data = articleJsonLd(page({ publishedAt: published, updatedAt: published }));
|
||||
|
||||
expect(data).not.toHaveProperty('dateModified');
|
||||
expect(data).toMatchObject({ datePublished: published });
|
||||
});
|
||||
|
||||
it('falls back to the page title and omits fields the page does not have', () => {
|
||||
const data = articleJsonLd(page({ seo: { title: '', description: '' } }));
|
||||
|
||||
expect(data).toMatchObject({ headline: 'Working with webhooks' });
|
||||
expect(data).not.toHaveProperty('description');
|
||||
expect(data).not.toHaveProperty('image');
|
||||
});
|
||||
});
|
||||
|
||||
describe('breadcrumbListJsonLd', () => {
|
||||
it('numbers the trail from one and leaves the current page without a url', () => {
|
||||
const data = breadcrumbListJsonLd([
|
||||
{ label: 'Home', href: '/en' },
|
||||
{ label: 'Blog', href: '/en/blog' },
|
||||
{ label: 'Working with webhooks', href: '/en/blog/api-webhooks' }
|
||||
]) as { itemListElement: Array<Record<string, unknown>> };
|
||||
|
||||
expect(data.itemListElement.map((item) => item['position'])).toEqual([1, 2, 3]);
|
||||
expect(data.itemListElement[0]).toHaveProperty('item');
|
||||
expect(data.itemListElement[2]).not.toHaveProperty('item');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* `server-only` is resolved by Next's bundler, not by node_modules, so importing any module
|
||||
* that guards itself with it fails outside a Next build. Vitest aliases the specifier here;
|
||||
* the guard has nothing to enforce in a test process that is server-side by definition.
|
||||
*/
|
||||
export {};
|
||||
@@ -0,0 +1,201 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { WebRuntimeAPI } from '#web-runtime/api/web-runtime-api';
|
||||
|
||||
const fetchMock = vi.fn();
|
||||
|
||||
function jsonResponse(payload: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(payload), {
|
||||
status,
|
||||
headers: { 'content-type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
/** The URL the nth fetch call received, as a URL object. */
|
||||
function fetchedUrl(call = 0): URL {
|
||||
return new URL(String(fetchMock.mock.calls[call]?.[0]));
|
||||
}
|
||||
|
||||
// Next augments the global RequestInit with `next.revalidate?: number | false`,
|
||||
// so the recorded init has to carry the same shape.
|
||||
type RecordedInit = RequestInit & { next?: { revalidate?: number | false; tags?: string[] } };
|
||||
|
||||
function fetchedInit(call = 0): RecordedInit {
|
||||
return fetchMock.mock.calls[call]?.[1] as RecordedInit;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
fetchMock.mockReset();
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
const api = () => new WebRuntimeAPI('https://backend.test');
|
||||
|
||||
describe('request plumbing', () => {
|
||||
it('resolves pages against the resolve endpoint with locale and path params', async () => {
|
||||
fetchMock.mockResolvedValue(jsonResponse({ id: 1, title: 'About', path: 'about' }));
|
||||
|
||||
await api().resolvePage({ locale: 'en', path: 'about' });
|
||||
|
||||
const url = fetchedUrl();
|
||||
expect(url.origin).toBe('https://backend.test');
|
||||
expect(url.pathname).toBe('/web-manager/public/pages/resolve');
|
||||
expect(url.searchParams.get('locale')).toBe('en');
|
||||
expect(url.searchParams.get('path')).toBe('about');
|
||||
expect(fetchedInit().next).toEqual({
|
||||
revalidate: 60,
|
||||
tags: ['web-manager', 'web-manager:page:en:about', 'web-manager:settings:en']
|
||||
});
|
||||
});
|
||||
|
||||
it('treats a 404 as "no such page", not an error', async () => {
|
||||
fetchMock.mockResolvedValue(jsonResponse({ message: 'not found' }, 404));
|
||||
|
||||
expect(await api().resolvePage({ locale: 'en', path: 'missing' })).toBeNull();
|
||||
});
|
||||
|
||||
it('throws loudly on any other failure status', async () => {
|
||||
fetchMock.mockResolvedValue(jsonResponse({ message: 'boom' }, 500));
|
||||
|
||||
await expect(api().resolvePage({ locale: 'en', path: 'about' })).rejects.toThrow(
|
||||
'Web Manager request to /web-manager/public/pages/resolve failed with status 500.'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSettings', () => {
|
||||
it('unwraps a { settings } envelope and normalizes it', async () => {
|
||||
fetchMock.mockResolvedValue(jsonResponse({ settings: { footer: { copyright: '© Crux' } } }));
|
||||
|
||||
const settings = await api().getSettings('de');
|
||||
|
||||
expect(fetchedUrl().pathname).toBe('/web-manager/public/settings');
|
||||
expect(fetchedUrl().searchParams.get('locale')).toBe('de');
|
||||
expect(settings?.locale).toBe('de');
|
||||
expect(settings?.footer.copyright).toBe('© Crux');
|
||||
});
|
||||
|
||||
it('returns null on 404', async () => {
|
||||
fetchMock.mockResolvedValue(jsonResponse(null, 404));
|
||||
|
||||
expect(await api().getSettings('en')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getPreview', () => {
|
||||
it('passes the token and opts out of caching', async () => {
|
||||
fetchMock.mockResolvedValue(
|
||||
jsonResponse({
|
||||
page: { id: 3, title: 'Draft', path: 'draft', locale: 'de', status: 'draft' }
|
||||
})
|
||||
);
|
||||
|
||||
const resolution = await api().getPreview('tok-123');
|
||||
|
||||
expect(fetchedUrl().pathname).toBe('/web-manager/public/pages/preview');
|
||||
expect(fetchedUrl().searchParams.get('token')).toBe('tok-123');
|
||||
expect(fetchedInit().cache).toBe('no-store');
|
||||
expect(resolution?.page.locale).toBe('de');
|
||||
expect(resolution?.page.status).toBe('draft');
|
||||
});
|
||||
|
||||
it('returns null for an expired or unknown token (404)', async () => {
|
||||
fetchMock.mockResolvedValue(jsonResponse(null, 404));
|
||||
|
||||
expect(await api().getPreview('tok-dead')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('listSitemapPages pagination', () => {
|
||||
it('walks every page the backend reports and concatenates the entries', async () => {
|
||||
fetchMock.mockImplementation((url: URL) => {
|
||||
const page = Number(new URL(String(url)).searchParams.get('page'));
|
||||
return Promise.resolve(
|
||||
jsonResponse({
|
||||
items: [{ locale: 'en', path: `page-${page}` }],
|
||||
pagination: { lastPage: 3 }
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
const entries = await api().listSitemapPages();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(3);
|
||||
expect(
|
||||
fetchMock.mock.calls.map((call) => new URL(String(call[0])).searchParams.get('page'))
|
||||
).toEqual(['1', '2', '3']);
|
||||
expect(fetchedUrl().pathname).toBe('/web-manager/public/pages/sitemap');
|
||||
expect(entries.map((entry) => entry.path)).toEqual(['page-1', 'page-2', 'page-3']);
|
||||
});
|
||||
|
||||
it('makes exactly one request when the listing fits on one page', async () => {
|
||||
fetchMock.mockResolvedValue(
|
||||
jsonResponse({ items: [{ locale: 'en', path: 'only' }], pagination: { lastPage: 1 } })
|
||||
);
|
||||
|
||||
const entries = await api().listSitemapPages();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(entries).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('caps a runaway lastPage at 50 requests instead of spinning forever', async () => {
|
||||
fetchMock.mockImplementation(() =>
|
||||
Promise.resolve(
|
||||
jsonResponse({
|
||||
items: [{ locale: 'en', path: 'p' }],
|
||||
pagination: { lastPage: 100000 }
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
const entries = await api().listSitemapPages();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(50);
|
||||
expect(entries).toHaveLength(50);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getWebRuntimeAPI singleton', () => {
|
||||
async function freshModule() {
|
||||
vi.resetModules();
|
||||
return await import('#web-runtime/api/web-runtime-api');
|
||||
}
|
||||
|
||||
it('throws when BACKEND_API_URL is missing or blank', async () => {
|
||||
const mod = await freshModule();
|
||||
vi.stubEnv('BACKEND_API_URL', '');
|
||||
|
||||
expect(() => mod.getWebRuntimeAPI()).toThrow('BACKEND_API_URL is required');
|
||||
|
||||
vi.stubEnv('BACKEND_API_URL', ' ');
|
||||
expect(() => mod.getWebRuntimeAPI()).toThrow('BACKEND_API_URL is required');
|
||||
});
|
||||
|
||||
it('trims whitespace and a trailing slash off the base URL', async () => {
|
||||
const mod = await freshModule();
|
||||
vi.stubEnv('BACKEND_API_URL', ' https://backend.test/ ');
|
||||
fetchMock.mockResolvedValue(jsonResponse(null, 404));
|
||||
|
||||
await mod.getWebRuntimeAPI().getSettings('en');
|
||||
|
||||
expect(fetchedUrl().href).toBe('https://backend.test/web-manager/public/settings?locale=en');
|
||||
});
|
||||
|
||||
it('constructs the client once and ignores later env changes (pinned singleton behavior)', async () => {
|
||||
const mod = await freshModule();
|
||||
vi.stubEnv('BACKEND_API_URL', 'https://first.test');
|
||||
|
||||
const first = mod.getWebRuntimeAPI();
|
||||
|
||||
vi.stubEnv('BACKEND_API_URL', 'https://second.test');
|
||||
expect(mod.getWebRuntimeAPI()).toBe(first);
|
||||
|
||||
vi.stubEnv('BACKEND_API_URL', '');
|
||||
expect(() => mod.getWebRuntimeAPI()).not.toThrow();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user