Protocol 3.0 order 3 (Part C), second of two website PRs. #112 built the data pipeline; this makes it reachable — six public routes, five admin ones, two public pages and an admin panel. Still website-only: no plugin, no sidecar, no new event kinds, no wire change. The API sits at /api/v1/public/atlas, not under /public/shard. Nothing here touches the sidecar, so the pages stay complete while the shard is down, and a /shard prefix would imply a dependency the atlas does not have. Unlike /shard/* it IS site-mode gated, like /posts and /wiki: a bestiary is site content. Every route carries requireFeature('atlas') and projects its response. The atlas feature declares no sensitive fields, so the projection is a no-op today — the call is there because v3.md 3.6.1's rule is that the FIRST field needing a gate should be covered by construction rather than by a retrofit. Two bugs the UI surfaced, both fixed here: Respawn delays were stored in the wrong unit, sometimes. XmlSpawner writes MinDelay/MaxDelay in minutes and switches to seconds only when a delay does not divide into whole minutes, flagging it per record with DelayInSec. A `5` means five minutes on one spawner and five seconds on the next, both plausible, and the pipeline stored the raw number. 170 of 6,455 stock spawners are second flagged. The parser normalises to seconds; the API and UI carry seconds. That exposed the hash gate as a trap. "Has the tree changed?" is the wrong question on its own: an install whose maps never change would have kept serving the old readings forever, because the only thing compared was the tree. PARSER_VERSION is now stored beside the source hashes and a mismatch counts as drift, so any future parse correction lands on the next boot. Also renamed the detail route's spawn-point array to `spawners` — it was `points`, which is the COUNT on the search route, so one key meant a number in one place and an array in the other. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01U7CBg11prhLimL9iHSX1bP
184 lines
7.8 KiB
JavaScript
184 lines
7.8 KiB
JavaScript
import { test, beforeEach, afterEach } from 'node:test'
|
||
import assert from 'node:assert/strict'
|
||
import { api, ApiError } from '../src/api/client.js'
|
||
|
||
// Unit-test the fetch wrapper that every API call flows through. The behaviors
|
||
// that matter to the whole app:
|
||
// - it always sends the session cookie (credentials: 'include');
|
||
// - a non-2xx response becomes a thrown ApiError carrying status + a message
|
||
// (server body.message → statusText → generic), never a silent bad value;
|
||
// - an empty body resolves to null (not a JSON parse throw);
|
||
// - JSON bodies get a Content-Type, but a raw FormData upload does NOT (so the
|
||
// browser can set the multipart boundary);
|
||
// - query strings and path params are built/encoded correctly.
|
||
// We drive the real req() by mocking global.fetch and inspecting what it received.
|
||
|
||
let calls
|
||
const realFetch = global.fetch
|
||
|
||
// Build a fake Response-ish object req() understands (ok/status/statusText/text()).
|
||
function reply({ status = 200, statusText = 'OK', body = '' } = {}) {
|
||
return {
|
||
ok: status >= 200 && status < 300,
|
||
status,
|
||
statusText,
|
||
text: async () => (typeof body === 'string' ? body : JSON.stringify(body)),
|
||
}
|
||
}
|
||
|
||
beforeEach(() => {
|
||
calls = []
|
||
global.fetch = async (url, opts) => {
|
||
calls.push({ url, opts })
|
||
return calls.nextReply || reply({ body: { ok: true } })
|
||
}
|
||
})
|
||
afterEach(() => {
|
||
global.fetch = realFetch
|
||
})
|
||
|
||
// helper to queue the next response
|
||
function willReply(r) {
|
||
global.fetch = async (url, opts) => {
|
||
calls.push({ url, opts })
|
||
return reply(r)
|
||
}
|
||
}
|
||
|
||
// ── happy path + cookie + base path ─────────────────────────────────────
|
||
test('a GET hits the same-origin /api/v1 base, sends cookies, and returns parsed JSON', async () => {
|
||
willReply({ body: { user: { id: 1 } } })
|
||
const out = await api.me()
|
||
assert.equal(calls[0].url, '/api/v1/auth/me')
|
||
assert.equal(calls[0].opts.credentials, 'include')
|
||
assert.equal(calls[0].opts.method, 'GET')
|
||
assert.deepEqual(out, { user: { id: 1 } })
|
||
})
|
||
|
||
// ── error mapping ───────────────────────────────────────────────────────
|
||
test('a non-ok response throws an ApiError with status and the server message', async () => {
|
||
willReply({ status: 401, statusText: 'Unauthorized', body: { message: 'Incorrect username or password.' } })
|
||
await assert.rejects(
|
||
() => api.login('u', 'bad'),
|
||
(err) => {
|
||
assert.ok(err instanceof ApiError)
|
||
assert.equal(err.status, 401)
|
||
assert.equal(err.message, 'Incorrect username or password.')
|
||
assert.deepEqual(err.body, { message: 'Incorrect username or password.' })
|
||
return true
|
||
},
|
||
)
|
||
})
|
||
|
||
test('an error with no JSON message falls back to statusText', async () => {
|
||
willReply({ status: 503, statusText: 'Service Unavailable', body: '' })
|
||
await assert.rejects(
|
||
() => api.status(),
|
||
(err) => err instanceof ApiError && err.status === 503 && err.message === 'Service Unavailable',
|
||
)
|
||
})
|
||
|
||
// ── empty body ──────────────────────────────────────────────────────────
|
||
test('an empty 200 body resolves to null instead of throwing on JSON.parse', async () => {
|
||
willReply({ status: 200, body: '' })
|
||
const out = await api.logout()
|
||
assert.equal(out, null)
|
||
})
|
||
|
||
test('a non-JSON body is returned as the raw text (safeParse tolerates it)', async () => {
|
||
willReply({ status: 200, body: 'plain text' })
|
||
const out = await api.me()
|
||
assert.equal(out, 'plain text')
|
||
})
|
||
|
||
// ── request body encoding ───────────────────────────────────────────────
|
||
test('a JSON POST serializes the body and sets Content-Type', async () => {
|
||
willReply({ body: { user: { id: 9 } } })
|
||
await api.register('newbie', 'pw', { company: '' })
|
||
const { opts } = calls[0]
|
||
assert.equal(opts.method, 'POST')
|
||
assert.equal(opts.headers['Content-Type'], 'application/json')
|
||
assert.deepEqual(JSON.parse(opts.body), { username: 'newbie', password: 'pw', company: '' })
|
||
})
|
||
|
||
test('a raw FormData upload does NOT set Content-Type and passes the body untouched', async () => {
|
||
willReply({ body: { url: '/uploads/x.png' } })
|
||
const fakeFile = { name: 'x.png' }
|
||
await api.admin.upload(fakeFile)
|
||
const { opts } = calls[0]
|
||
assert.equal(opts.method, 'POST')
|
||
assert.equal(opts.headers['Content-Type'], undefined) // browser sets the multipart boundary
|
||
assert.ok(opts.body instanceof FormData)
|
||
})
|
||
|
||
// ── query strings + path param encoding ─────────────────────────────────
|
||
test('wiki() builds a query string only from the params that are set', async () => {
|
||
willReply({ body: [] })
|
||
await api.wiki({ category: 'lore', q: 'dragon slayer' })
|
||
const url = new URL(calls[0].url, 'http://x')
|
||
assert.equal(url.pathname, '/api/v1/public/wiki')
|
||
assert.equal(url.searchParams.get('category'), 'lore')
|
||
assert.equal(url.searchParams.get('q'), 'dragon slayer')
|
||
assert.equal(url.searchParams.get('tag'), null) // omitted when unset
|
||
})
|
||
|
||
test('wiki() with no options sends no query string at all', async () => {
|
||
willReply({ body: [] })
|
||
await api.wiki()
|
||
assert.equal(calls[0].url, '/api/v1/public/wiki')
|
||
})
|
||
|
||
test('path params are URL-encoded (a token/city with unsafe characters is escaped)', async () => {
|
||
willReply({ body: {} })
|
||
await api.shard.governorHistory('Serpent’s Hold', 5)
|
||
assert.match(calls[0].url, /\/governors\/Serpent%E2%80%99s%20Hold\/history\?limit=5/)
|
||
})
|
||
|
||
test('DELETE self-service session revoke encodes the id and uses the DELETE method', async () => {
|
||
willReply({ body: {} })
|
||
await api.revokeMySession('a b/c')
|
||
assert.equal(calls[0].opts.method, 'DELETE')
|
||
assert.match(calls[0].url, /\/auth\/me\/sessions\/a%20b%2Fc$/)
|
||
})
|
||
|
||
// ── spawn atlas (Protocol 3.0 Part C) ───────────────────────────────────
|
||
// The atlas lives at /public/atlas, NOT under /public/shard: it is static shard
|
||
// content parsed from the shard's own files, so it must not look sidecar-backed.
|
||
// Asserted here because the split is a design decision, not an accident of
|
||
// spelling.
|
||
test('atlas reads hit /public/atlas, not /public/shard', async () => {
|
||
willReply({ body: { creatures: [] } })
|
||
await api.atlas.creatures()
|
||
assert.equal(calls[0].url, '/api/v1/public/atlas/creatures')
|
||
})
|
||
|
||
test('atlas.creatures() sends only the filters that are set', async () => {
|
||
willReply({ body: { creatures: [] } })
|
||
await api.atlas.creatures({ q: 'lizard man', facet: 'Ter Mur', limit: 25 })
|
||
const url = new URL(calls[0].url, 'http://x')
|
||
assert.equal(url.pathname, '/api/v1/public/atlas/creatures')
|
||
assert.equal(url.searchParams.get('q'), 'lizard man')
|
||
assert.equal(url.searchParams.get('facet'), 'Ter Mur')
|
||
assert.equal(url.searchParams.get('limit'), '25')
|
||
assert.equal(url.searchParams.get('offset'), null) // 0 is not sent
|
||
})
|
||
|
||
test('atlas.creature() encodes the slug and carries the facet filter through', async () => {
|
||
willReply({ body: {} })
|
||
await api.atlas.creature('lizardman/rare', { facet: 'Felucca' })
|
||
assert.match(calls[0].url, /\/public\/atlas\/creatures\/lizardman%2Frare\?facet=Felucca$/)
|
||
})
|
||
|
||
test('admin atlas actions use the right methods and bodies', async () => {
|
||
willReply({ body: {} })
|
||
await api.admin.atlas.import(true)
|
||
assert.equal(calls[0].url, '/api/v1/admin/shard/atlas/import')
|
||
assert.equal(calls[0].opts.method, 'POST')
|
||
assert.equal(calls[0].opts.body, JSON.stringify({ force: true }))
|
||
|
||
willReply({ body: {} })
|
||
await api.admin.atlas.setPath('/srv/servuo')
|
||
assert.equal(calls[1].opts.method, 'PUT')
|
||
assert.equal(calls[1].opts.body, JSON.stringify({ path: '/srv/servuo' }))
|
||
})
|