TASKS-1823 WebManager module
This commit is contained in:
@@ -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