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
+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();
});
});