// The menu client against a fake fetcher: the URL built from base, code and // locale, the envelope unwrapped, 404 as null. No network. import { test } from 'node:test' import assert from 'node:assert/strict' import { createMenuClient, isNotFound, menuPath } from '../app/utils/client.ts' type Call = { path: string; query?: Record } function fakeFetcher(answer: (path: string) => unknown) { const calls: Call[] = [] const fetcher = async (path: string, query?: Record) => { calls.push(query === undefined ? { path } : { path, query }) return answer(path) } return { fetcher, calls } } const MENU = { data: { code: 'main', name: 'Main', css_class: null, items: [{ id: 1, title: 'Home', type: 'url', url: '/', target: null, css_class: null, icon: null, code: null, items: [] }], }, } test('menuPath encodes the code', () => { assert.equal(menuPath('main'), '/menus/main') assert.equal(menuPath('a b/c'), '/menus/a%20b%2Fc') }) test('getMenu calls /menus/ and passes locale only when set', async () => { const { fetcher, calls } = fakeFetcher(() => MENU) const client = createMenuClient(fetcher) const menu = await client.getMenu('main') assert.equal(menu?.code, 'main') assert.equal(menu?.items.length, 1) await client.getMenu('main', 'en') assert.deepEqual(calls, [{ path: '/menus/main' }, { path: '/menus/main', query: { locale: 'en' } }]) }) test('the fetcher base plus the path is the API URL', async () => { const urls: string[] = [] const base = 'http://127.0.0.1:8096/_menu/api/v1' const client = createMenuClient(async (path, query) => { const u = new URL(base + path) for (const [k, v] of Object.entries(query ?? {})) u.searchParams.set(k, v) urls.push(u.toString()) return MENU }) await client.getMenu('main-menu', 'pl') assert.deepEqual(urls, ['http://127.0.0.1:8096/_menu/api/v1/menus/main-menu?locale=pl']) }) test('a 404 resolves to null; other errors are thrown', async () => { const notFound = createMenuClient(async () => { throw Object.assign(new Error('Not Found'), { statusCode: 404 }) }) assert.equal(await notFound.getMenu('nope'), null) const failing = createMenuClient(async () => { throw Object.assign(new Error('boom'), { status: 500 }) }) await assert.rejects(failing.getMenu('main'), /boom/) }) test('a body without data is null; missing items become an empty list', async () => { assert.equal(await createMenuClient(async () => ({})).getMenu('x'), null) const menu = await createMenuClient(async () => ({ data: { code: 'x', name: 'X', css_class: null } })).getMenu('x') assert.deepEqual(menu?.items, []) }) test('isNotFound reads status, statusCode and response.status', () => { assert.equal(isNotFound({ status: 404 }), true) assert.equal(isNotFound({ statusCode: 404 }), true) assert.equal(isNotFound({ response: { status: 404 } }), true) assert.equal(isNotFound({ status: 500 }), false) assert.equal(isNotFound(null), false) }) // Plan 06 coverage: useMenuApi picks the base. The composable reads Nuxt // globals and import.meta.server, so the test loads its source with // import.meta.server replaced by a flag, next to the original (its relative // import resolves), with stubbed globals. import { readFileSync, rmSync, writeFileSync } from 'node:fs' import { dirname, join } from 'node:path' import { fileURLToPath, pathToFileURL } from 'node:url' type FetchCall = { path: string; opts: { baseURL: string; query?: Record; headers?: Record } } let loads = 0 async function useMenuApiAs(server: boolean, request: { context?: Record; xff?: string; peer?: string } = {}) { const calls: FetchCall[] = [] const g = globalThis as Record g.__menuLayerServer = server g.useRuntimeConfig = () => ({ menu: { apiBase: 'http://127.0.0.1:8096/_menu/api/v1' }, public: { menu: { apiBase: '/_menu/api/v1' } } }) g.useRequestEvent = () => ({ context: request.context ?? {}, node: { req: { socket: { remoteAddress: request.peer } } } }) g.useRequestHeaders = () => (request.xff === undefined ? {} : { 'x-forwarded-for': request.xff }) g.$fetch = async (path: string, opts: FetchCall['opts']) => { calls.push({ path, opts }) return MENU } const source = join(dirname(fileURLToPath(import.meta.url)), '..', 'app', 'composables', 'useMenuApi.ts') const out = join(dirname(source), `.useMenuApi-${process.pid}-${++loads}.compiled.ts`) writeFileSync(out, readFileSync(source, 'utf8').replaceAll('import.meta.server', '(globalThis as Record).__menuLayerServer')) try { const mod = (await import(pathToFileURL(out).href)) as { useMenuApi: () => ReturnType } return { client: mod.useMenuApi(), calls } } finally { rmSync(out, { force: true }) } } test('the server base is used on the server, with the visitor forwarded', async () => { const { client, calls } = await useMenuApiAs(true, { xff: '203.0.113.7', peer: '10.0.0.2' }) await client.getMenu('main menu', 'pl') assert.deepEqual(calls, [ { path: '/menus/main%20menu', opts: { baseURL: 'http://127.0.0.1:8096/_menu/api/v1', query: { locale: 'pl' }, headers: { 'x-forwarded-for': '203.0.113.7, 10.0.0.2' } } }, ]) // A value recorded on the request context (route cache) wins. const recorded = await useMenuApiAs(true, { context: { menuForwardedFor: '198.51.100.9' }, xff: '1.1.1.1', peer: '10.0.0.2' }) await recorded.client.getMenu('main') assert.deepEqual(recorded.calls[0]!.opts.headers, { 'x-forwarded-for': '198.51.100.9' }) // Nothing to forward: no header at all. const bare = await useMenuApiAs(true) await bare.client.getMenu('main') assert.deepEqual(bare.calls[0]!.opts, { baseURL: 'http://127.0.0.1:8096/_menu/api/v1', query: undefined, headers: {} }) }) test('the public base is used in the browser, without forwarded headers', async () => { const { client, calls } = await useMenuApiAs(false, { xff: '203.0.113.7', peer: '10.0.0.2' }) await client.getMenu('main', 'en') assert.deepEqual(calls, [{ path: '/menus/main', opts: { baseURL: '/_menu/api/v1', query: { locale: 'en' } } }]) })