Files
website/server/test/atlasController.test.js
wtclaude 7c769ea8fd feat(atlas): serve the spawn atlas and give operators a panel for it
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
2026-07-28 19:51:22 -05:00

239 lines
9.6 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Point the DB at a closed port BEFORE requiring the controllers (their models
// build the pool). Every model call is monkeypatched, so no query runs;
// db.close() at the end releases the pool so the process exits cleanly.
process.env.DB_HOST = '127.0.0.1'
process.env.DB_PORT = '59999'
const { test, after, afterEach } = require('node:test')
const assert = require('node:assert/strict')
// The spawn atlas API, public and admin. What is worth asserting here is not the
// SQL (that is the parser suite's job) but the contracts the two surfaces make:
//
// • the public reads project through the visibility framework — v3.md §3.6.1's
// standing rule is that a read path returning shard data and not calling
// projectFeature is a bug, and `atlas` declaring no sensitive fields TODAY is
// exactly why the call has to be there before one does;
// • the public /meta route reports the game world only, never the operator's
// filesystem — the ServUO path, the per-file hashes and any pending refresh
// stay on the admin route;
// • a missing creature is a 404, not an empty 200;
// • an unreadable ServUO tree is a 200 carrying `status: 'unavailable'`, NOT a
// 500. The refresh contract reports outcomes rather than throwing (so boot is
// never blocked by a bad tree), and the admin needs to be told what is wrong
// with their path;
// • a model failure degrades to a 500 rather than a thrown/uncaught error.
const pub = require('../src/router/v1/public/atlas.controller')
const admin = require('../src/router/v1/admin/shardAtlas.controller')
const atlas = require('../src/model/shardAtlas/shardAtlas.model')
const activity = require('../src/model/activity/activity.model')
const visibility = require('../src/utils/shardVisibility')
const db = require('../src/utils/db')
after(() => db.close())
// Stub the visibility MODEL rather than the util's exports: project() calls the
// module-internal getConfig, which an exports-level stub would not intercept — it
// would hit the closed DB port and cost a ~10s pool timeout per test before
// falling back to these same defaults.
const visibilityModel = require('../src/model/shardVisibility/shardVisibility.model')
visibilityModel.listAll = async () => [] // no overrides ⇒ compiled defaults
visibility.viewerLevel = async (req) => req?.viewerLevel || 'anonymous'
// The admin controller logs every action; keep it off the DB.
activity.log = async () => {}
function mockRes() {
return {
statusCode: 200,
body: null,
status(c) {
this.statusCode = c
return this
},
json(b) {
this.body = b
return this
},
}
}
const originals = {
searchCreatures: atlas.searchCreatures,
getCreature: atlas.getCreature,
listRegions: atlas.listRegions,
listLandmarks: atlas.listLandmarks,
listChampions: atlas.listChampions,
publicMeta: atlas.publicMeta,
status: atlas.status,
refresh: atlas.refresh,
approvePending: atlas.approvePending,
rejectPending: atlas.rejectPending,
setServuoPath: atlas.setServuoPath,
}
afterEach(() => Object.assign(atlas, originals))
// ── Public reads ────────────────────────────────────────────────────────
test('getCreatures passes the search through and returns the page shape', async () => {
let seen = null
atlas.searchCreatures = async (opts) => {
seen = opts
return { total: 1, limit: 50, offset: 0, creatures: [{ slug: 'lizardman', name: 'Lizardman' }] }
}
const res = mockRes()
await pub.getCreatures({ query: { q: ' lizard ', facet: 'Felucca', limit: '10', offset: '20' } }, res)
assert.deepEqual(seen, { q: 'lizard', facet: 'Felucca', limit: 10, offset: 20 })
assert.equal(res.body.total, 1)
assert.equal(res.body.creatures[0].slug, 'lizardman')
})
test('getCreatures falls back to the documented defaults when nothing is passed', async () => {
let seen = null
atlas.searchCreatures = async (opts) => {
seen = opts
return { total: 0, limit: 50, offset: 0, creatures: [] }
}
await pub.getCreatures({ query: {} }, mockRes())
assert.deepEqual(seen, { q: '', facet: '', limit: 50, offset: 0 })
})
test('an unknown creature is a 404, not an empty 200', async () => {
atlas.getCreature = async () => null
const res = mockRes()
await pub.getCreature({ params: { slug: 'nosuchthing' }, query: {} }, res)
assert.equal(res.statusCode, 404)
})
test('getCreature returns places and spawners, and `points` stays the COUNT', async () => {
atlas.getCreature = async () => ({
slug: 'lizardman',
name: 'Lizardman',
total: 214,
points: 62,
places: [{ facet: 'Trammel', label: 'Shrines', spawners: 7, maxAlive: 21 }],
spawners: [{ id: 1, facet: 'Trammel', label: 'Shrines', x: 1, y: 2 }],
spawnersTruncated: false,
alsoHere: [],
})
const res = mockRes()
await pub.getCreature({ params: { slug: 'lizardman' }, query: {} }, res)
// The list route uses `points` as a number; the detail route must not quietly
// turn the same key into an array.
assert.equal(typeof res.body.points, 'number')
assert.ok(Array.isArray(res.body.spawners))
assert.equal(res.body.places[0].label, 'Shrines')
})
// ── The projection rule (§3.6.1) ────────────────────────────────────────
test('public reads run through projectFeature, so a locked field can never survive', async () => {
// `atlas` declares no sensitive fields, so nothing here is stripped by a
// FEATURE rule. acct/webId are stripped anyway — they are locked by meaning,
// for every feature, and this is what proves the read path projects at all.
atlas.searchCreatures = async () => ({
total: 1,
limit: 50,
offset: 0,
creatures: [{ slug: 'lizardman', name: 'Lizardman', acct: 'someacct', ownerWebId: 7 }],
})
const res = mockRes()
await pub.getCreatures({ query: {}, viewerLevel: 'anonymous' }, res)
const row = res.body.creatures[0]
assert.equal(row.name, 'Lizardman')
assert.ok(!('acct' in row), 'acct must never reach an anonymous caller')
assert.ok(!('ownerWebId' in row), 'a flattened webId spelling is locked too')
})
test('getMeta reports the game world only — never the operators filesystem', async () => {
// The model is what enforces this; the assertion documents the boundary so a
// future "just return status() here" shortcut fails loudly.
atlas.publicMeta = async () => ({
importedAt: '2026-07-28T00:00:00.000Z',
generatedAt: '2026-07-28T00:00:00.000Z',
counts: { points: 6455, creatures: 800 },
facets: ['Felucca', 'Trammel'],
})
const res = mockRes()
await pub.getMeta({ query: {} }, res)
assert.deepEqual(Object.keys(res.body).sort(), ['counts', 'facets', 'generatedAt', 'importedAt'])
assert.ok(!('path' in res.body))
assert.ok(!('pending' in res.body))
})
test('a model failure degrades to a 500 rather than throwing', async () => {
atlas.listChampions = async () => {
throw new Error('table is gone')
}
const res = mockRes()
await pub.getChampions({ query: {} }, res)
assert.equal(res.statusCode, 500)
})
// ── Admin ───────────────────────────────────────────────────────────────
test('an unreadable tree answers 200 with the reason, not a 500', async () => {
atlas.refresh = async () => ({ status: 'unavailable', reason: 'no Spawns directory', path: '/bad' })
const res = mockRes()
await admin.importAtlas({ body: {}, user: { id: 1 } }, res)
assert.equal(res.statusCode, 200)
assert.equal(res.body.status, 'unavailable')
assert.equal(res.body.reason, 'no Spawns directory')
})
test('import passes `force` through and coerces it to a boolean', async () => {
let seen = null
atlas.refresh = async (opts) => {
seen = opts
return { status: 'unchanged' }
}
await admin.importAtlas({ body: { force: true }, user: { id: 1 } }, mockRes())
assert.deepEqual(seen, { force: true })
})
test('approve applies a staged refresh (facet loss included)', async () => {
let called = false
atlas.approvePending = async () => {
called = true
return { status: 'imported', removedFacets: ['Malas'], counts: { points: 6162 } }
}
const res = mockRes()
await admin.approve({ user: { id: 1 } }, res)
assert.ok(called)
assert.equal(res.body.status, 'imported')
})
test('rejecting when nothing is staged is a 404', async () => {
atlas.rejectPending = async () => ({ status: 'none' })
const res = mockRes()
await admin.reject({ user: { id: 1 } }, res)
assert.equal(res.statusCode, 404)
})
test('setPath trims, persists, and answers with fresh status — it does not import', async () => {
let saved = null
let imported = false
atlas.setServuoPath = async (value) => {
saved = value
}
atlas.refresh = async () => {
imported = true
return { status: 'imported' }
}
atlas.status = async () => ({ configured: true, path: '/srv/servuo', treeReadable: true })
const res = mockRes()
await admin.setPath({ body: { path: ' /srv/servuo ' }, user: { id: 3 } }, res)
assert.equal(saved, '/srv/servuo')
assert.equal(imported, false, 'changing the path must not reload the atlas as a side effect')
assert.equal(res.body.path, '/srv/servuo')
})
test('setPath accepts a blank path (clearing it turns the atlas off)', async () => {
let saved = 'unset'
atlas.setServuoPath = async (value) => {
saved = value
}
atlas.status = async () => ({ configured: false, path: '' })
const res = mockRes()
await admin.setPath({ body: {}, user: { id: 3 } }, res)
assert.equal(saved, '')
assert.equal(res.statusCode, 200)
})