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
+74
View File
@@ -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]);
});
});
+39
View File
@@ -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'));
});
});
+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('');
});
});
+163
View File
@@ -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'
});
});
});
+61
View File
@@ -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');
});
});
+175
View File
@@ -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'));
});
});
+108
View File
@@ -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');
});
});
+26
View File
@@ -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');
});
});
+106
View File
@@ -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');
});
});
+37
View File
@@ -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();
});
});
+130
View File
@@ -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);
});
});
+73
View File
@@ -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');
});
});
+6
View File
@@ -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 {};
+201
View File
@@ -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();
});
});