- every refused scheme and spacing renders as text; protocol-relative URLs are anchors, never site paths or active; rel only for _blank; active matching rules and same-host absolute URLs; the trail marks ancestors only - useMenuApi uses the server base with the forwarded visitor on the server and the public base in the browser
135 lines
6.1 KiB
TypeScript
135 lines
6.1 KiB
TypeScript
// 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<string, string> }
|
|
|
|
function fakeFetcher(answer: (path: string) => unknown) {
|
|
const calls: Call[] = []
|
|
const fetcher = async (path: string, query?: Record<string, string>) => {
|
|
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/<code> 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<string, string>; headers?: Record<string, string> } }
|
|
|
|
let loads = 0
|
|
|
|
async function useMenuApiAs(server: boolean, request: { context?: Record<string, unknown>; xff?: string; peer?: string } = {}) {
|
|
const calls: FetchCall[] = []
|
|
const g = globalThis as Record<string, unknown>
|
|
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<string, unknown>).__menuLayerServer'))
|
|
try {
|
|
const mod = (await import(pathToFileURL(out).href)) as { useMenuApi: () => ReturnType<typeof createMenuClient> }
|
|
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' } } }])
|
|
})
|