import test from 'brittle'
import { Router, json, html, readJson, parsePagination } from '../lib/router.js'
// Mock req/res
function mockReq (method, url, opts = {}) {
const req = {
method,
url,
headers: opts.headers || {},
params: {},
query: {},
[Symbol.asyncIterator]: async function * () {
if (opts.body) yield Buffer.from(JSON.stringify(opts.body))
}
}
return req
}
function mockRes () {
const res = {
statusCode: null,
headers: {},
_setHeaders: {},
body: '',
writableEnded: false,
writeHead (status, headers) { res.statusCode = status; res.headers = { ...res._setHeaders, ...headers } },
setHeader (k, v) { res._setHeaders[k] = v },
end (data) { res.body = data || ''; res.writableEnded = true },
write (data) { res.body += data }
}
return res
}
test('router matches pattern with params', async (t) => {
const router = new Router()
let captured = null
router.get('/api/repos/:name', async (req) => {
captured = req.params
return { name: req.params.name }
})
const req = mockReq('GET', '/api/repos/pear-git')
const res = mockRes()
const handled = await router.handle(req, res)
t.is(handled, true)
t.is(captured.name, 'pear-git')
t.is(res.statusCode, 200)
t.ok(res.body.includes('pear-git'))
})
test('router returns JSON by default', async (t) => {
const router = new Router()
router.get('/api/status', async () => ({ ok: true }))
const req = mockReq('GET', '/api/status')
const res = mockRes()
await router.handle(req, res)
t.is(res.headers['Content-Type'], 'application/json')
t.is(JSON.parse(res.body).ok, true)
})
test('content negotiation — SSR when Accept: text/html + renderer', async (t) => {
const router = new Router()
router.get('/repos/:name', async (req) => ({ name: req.params.name }), {
render: (data) => `<h1>${data.name}</h1>`
})
const req = mockReq('GET', '/repos/pear-git', { headers: { accept: 'text/html' } })
const res = mockRes()
await router.handle(req, res)
t.is(res.headers['Content-Type'], 'text/html; charset=utf-8')
t.ok(res.body.includes('<h1>pear-git</h1>'))
})
test('content negotiation — JSON when Accept: text/html but NO renderer', async (t) => {
const router = new Router()
router.get('/api/repos/:name', async (req) => ({ name: req.params.name }))
const req = mockReq('GET', '/api/repos/pear-git', { headers: { accept: 'text/html' } })
const res = mockRes()
await router.handle(req, res)
t.is(res.headers['Content-Type'], 'application/json')
})
test('router handles prefix', async (t) => {
const router = new Router('/git')
router.get('/api/status', async () => ({ ok: true }))
const req = mockReq('GET', '/git/api/status')
const res = mockRes()
const handled = await router.handle(req, res)
t.is(handled, true)
t.is(JSON.parse(res.body).ok, true)
})
test('router returns false for unmatched routes', async (t) => {
const router = new Router()
router.get('/api/status', async () => ({ ok: true }))
const req = mockReq('GET', '/api/nonexistent')
const res = mockRes()
const handled = await router.handle(req, res)
t.is(handled, false)
})
test('query string parsing', async (t) => {
const router = new Router()
let captured = null
router.get('/api/repos', async (req) => { captured = req.query; return {} })
const req = mockReq('GET', '/api/repos?status=open&sort=newest')
const res = mockRes()
await router.handle(req, res)
t.is(captured.status, 'open')
t.is(captured.sort, 'newest')
})
test('POST with JSON body', async (t) => {
const router = new Router()
let captured = null
router.post('/api/issues', async (req) => {
captured = await readJson(req)
return { id: 1 }
})
const req = mockReq('POST', '/api/issues', { body: { title: 'Bug', author: 'alice' } })
const res = mockRes()
await router.handle(req, res)
t.is(captured.title, 'Bug')
t.is(captured.author, 'alice')
t.is(JSON.parse(res.body).id, 1)
t.is(res.statusCode, 200)
})
test('error handler returns 500', async (t) => {
const router = new Router()
router.get('/api/crash', async () => { throw new Error('boom') })
const req = mockReq('GET', '/api/crash')
const res = mockRes()
await router.handle(req, res)
t.is(res.statusCode, 500)
t.is(JSON.parse(res.body).error, 'boom')
})
// ── parsePagination ───────────────────────────────────────
test('parsePagination — defaults', (t) => {
const p = parsePagination({})
t.is(p.limit, 20)
t.is(p.offset, 0)
})
test('parsePagination — custom values', (t) => {
const p = parsePagination({ limit: '50', offset: '10' })
t.is(p.limit, 50)
t.is(p.offset, 10)
})
test('parsePagination — clamps to maxLimit', (t) => {
const p = parsePagination({ limit: '999' }, { maxLimit: 50 })
t.is(p.limit, 50)
})
test('parsePagination — rejects negative/NaN', (t) => {
const p = parsePagination({ limit: '-5', offset: 'abc' })
t.is(p.limit, 20)
t.is(p.offset, 0)
})
// ── CORS ──────────────────────────────────────────────────
test('CORS preflight returns 204', async (t) => {
const router = new Router('', { cors: true })
router.get('/api/status', async () => ({ ok: true }))
const req = mockReq('OPTIONS', '/api/status')
const res = mockRes()
const handled = await router.handle(req, res)
t.is(handled, true)
t.is(res.statusCode, 204)
t.is(res.headers['Access-Control-Allow-Origin'], '*')
t.is(res.headers['Access-Control-Allow-Methods'], 'GET, POST, PATCH, DELETE, OPTIONS')
})
test('CORS headers on JSON response', async (t) => {
const router = new Router('', { cors: true })
router.get('/api/data', async () => ({ x: 1 }))
const req = mockReq('GET', '/api/data')
const res = mockRes()
await router.handle(req, res)
t.is(res.headers['Access-Control-Allow-Origin'], '*')
t.is(res.statusCode, 200)
})
test('CORS custom origin', async (t) => {
const router = new Router('', { cors: true, corsOrigin: 'https://example.com' })
router.get('/api/data', async () => ({ x: 1 }))
const req = mockReq('GET', '/api/data')
const res = mockRes()
await router.handle(req, res)
t.is(res.headers['Access-Control-Allow-Origin'], 'https://example.com')
})
test('CORS disabled by default on existing routers (backward compat)', async (t) => {
const router = new Router()
router.get('/api/data', async () => ({ x: 1 }))
const req = mockReq('GET', '/api/data')
const res = mockRes()
await router.handle(req, res)
t.is(res.headers['Access-Control-Allow-Origin'], undefined)
})