Files
Module-uo/client/test/api.test.js
wtclaude 675e879b48
Some checks failed
PR Checks / frozen-manifest (pull_request) Successful in 1m3s
PR Checks / server-tests (pull_request) Successful in 8m4s
PR Checks / client-build (pull_request) Failing after 14m21s
feat(assets): the panel that operates the client-file imports (Phase 8)
Admin -> Client Files: one page over the three things that come out of the
operator's UO client -- creature portraits, item and land pictures, and the
cliloc table. One page rather than three because they are one job: same client
install, same bridge, and all of them change at the same moment, when the
operator patches that client. Boot never asks the shard for any of it, so these
buttons are the only thing that imports.

The cliloc pair had had no UI at all since phase 2. On a bridged install, where
boot deliberately stopped calling the shard, that meant `curl` was the only way
to load 67,496 names.

Update and Re-import everything are section 6's two stages as two buttons rather
than one button and a checkbox, because they cost wildly different things. A
vanished key is reviewed in the page and not in a table -- an asset import only
happens because someone pressed a button here, so the review is already in front
of the person who caused it -- and it shows each key's PICTURE, since
`body/820/a23` names nothing a human recognises. `shard_asset_meta` gained a
`last` block (what the import did, who ran it) so the panel can answer "did last
week's import do anything" without scrolling core's whole activity log.

The live walk against a real shard imported 1,095 portraits in 3.5 s, warmed 313
item pictures in 0.6 s and reloaded 67,496 cliloc rows in 1.7 s -- and found two
DELETIONS that predate this phase and that no test could see, because only a
screen showing the numbers together makes them visible:

  * The body import diffed its manifest against every family's rows. Phase 5 put
    item and land art in the same table, and a body manifest never mentions
    them, so all 313 item pictures were staged for deletion with a sentence
    saying the shard had stopped offering them.
  * An approved vanish unlinked the sprite and kept the row. The catalogue went
    on counting a picture that was gone, the atlas could point a creature page at
    a missing file, and the next forced import offered the same key for review
    again -- reporting "nothing was changed" about a file it had deleted.

Both fixed here, with the removals now inside `saveAssets`'s own transaction.
The same whole-table read made the panel announce a 1,408-row creature catalogue
on an install holding 1,095 portraits and 313 item pictures.

Protocol stays 8 and EXTRACTOR_VERSION stays 3: nothing on the wire changed.

Refs: docs/link/v8.md sections 12.2, 14, 16 (phase 8)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
2026-09-14 08:10:16 -05:00

176 lines
7.9 KiB
JavaScript
Raw 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.

// ── The URLs this module calls ─────────────────────────────────────────────
//
// `src/api.js` binds the paths whose routes live in `server/router/**`, and the
// interesting assertions about it are the ones that encode a DECISION rather
// than a spelling. Three of these came across from core's `apiClient.test.js`
// in slice 4: they had stayed behind when the bindings moved, still asserting
// UO URLs from inside core's suite, which is the boundary this phase removes.
//
// What is NOT re-tested here is the fetch wrapper itself — status mapping, empty
// bodies, FormData, cookie inclusion. That is `req`, core's primitive, and core
// tests it. A module asserting core's contract back at it is a second copy that
// drifts.
//
// The chunk reads its shared bindings off `window.__rg` at module scope
// (src/core.js), so the fake global has to be in place before `src/api.js` is
// imported — hence the dynamic import below rather than a static one.
import { test, beforeEach, afterEach } from 'node:test'
import assert from 'node:assert/strict'
import * as react from 'react'
import * as reactDom from 'react-dom/client'
import * as router from 'react-router-dom'
import * as jsxRuntime from 'react/jsx-runtime'
const BASE = '/api/v1'
let calls = []
function reply({ status = 200, statusText = 'OK', body = '' } = {}) {
return {
ok: status >= 200 && status < 300,
status,
statusText,
text: async () => (typeof body === 'string' ? body : JSON.stringify(body)),
}
}
// Core's `req`, close enough for a path assertion: the only property this file
// cares about is the URL it was handed. Recording it here rather than mocking
// global.fetch keeps the test honest about the boundary — a module never sees
// fetch, it sees the primitive.
function request(path, opts = {}) {
calls.push({ url: BASE + path, opts })
return Promise.resolve(reply({ body: {} }).text().then(() => ({})))
}
// The REAL react/react-dom/router go in, not stubs: `src/core.js` compares the
// bindings it imported against the ones here and logs a "bundled its own copy"
// error when they differ. With stubs that error fires on every run of this file
// — a false alarm in the exact words of a real defect, which is how a check
// gets ignored.
globalThis.window = globalThis.window || {}
globalThis.window.__rg = {
react, reactDom, router, jsxRuntime,
api: { request, BASE },
ui: {},
registry: { registerRoutes() {}, registerNav() {}, registerFeatureProvider() {}, registerExtension() {} },
}
const { shard, atlas, admin } = await import('../src/api.js')
beforeEach(() => {
calls = []
})
afterEach(() => {
calls = []
})
// ── 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 because the split is a design decision, not an accident of spelling.
test('atlas reads hit /public/atlas, not /public/shard', async () => {
await atlas.creatures()
assert.equal(calls[0].url, '/api/v1/public/atlas/creatures')
})
test('atlas.creatures() sends only the filters that are set', async () => {
await 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 () => {
await 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 () => {
await admin.atlas.import(true)
assert.equal(calls[0].url, '/api/v1/admin/shard/atlas/import')
assert.equal(calls[0].opts.method, 'POST')
assert.deepEqual(calls[0].opts.body, { force: true })
await admin.atlas.setPath('/srv/servuo')
assert.equal(calls[1].opts.method, 'PUT')
assert.deepEqual(calls[1].opts.body, { path: '/srv/servuo' })
})
// ── the Asset Bridge's two stages (docs/link/v8.md §6) ──────────────────────
// Update and Re-import are one route and differ only by `force`, and the
// difference is not cosmetic: one transfers nothing when the client files are
// unchanged, the other fetches the whole catalogue. A binding that sent `force`
// on both would make the cheap button the expensive one, and nothing visible
// would change — the pictures would be correct either way.
test('assets.update asks for the diff and assets.reimport asks for everything', async () => {
await admin.assets.update()
assert.equal(calls[0].url, '/api/v1/admin/shard/assets/import')
assert.equal(calls[0].opts.method, 'POST')
assert.deepEqual(calls[0].opts.body, { approve: false })
await admin.assets.reimport()
assert.deepEqual(calls[1].opts.body, { force: true, approve: false })
})
// Approving a vanished key re-runs the SAME operation the operator pressed, so
// `approve` has to ride on both. Sending the update's approval as a re-import
// would quietly turn "yes, accept those deletions" into a full re-download.
test('approve rides on whichever import the operator ran', async () => {
await admin.assets.update(true)
await admin.assets.reimport(true)
assert.deepEqual(calls[0].opts.body, { approve: true })
assert.deepEqual(calls[1].opts.body, { force: true, approve: true })
})
test('cliloc admin actions use the right methods and bodies', async () => {
await admin.clilocs.import({ force: true })
assert.equal(calls[0].url, '/api/v1/admin/shard/clilocs/import')
assert.deepEqual(calls[0].opts.body, { force: true, approve: false })
await admin.clilocs.setPath('/srv/uo-client')
assert.equal(calls[1].opts.method, 'PUT')
assert.deepEqual(calls[1].opts.body, { path: '/srv/uo-client' })
})
// ── path encoding ───────────────────────────────────────────────────────────
// A city name with an apostrophe and a space is the real case: "Serpent's Hold"
// is a governor city, and an unencoded one would break the route match rather
// than 404 cleanly.
test('path params are URL-encoded', async () => {
await shard.governorHistory('Serpents Hold', 5)
assert.match(calls[0].url, /\/governors\/Serpent%E2%80%99s%20Hold\/history\?limit=5/)
})
// ── the API surface §1.2 freezes ────────────────────────────────────────────
// The shipped Android app calls these seven by name (data/api/AdminApi.kt), which
// is why the extraction moved which repo declares them and not what they are. A
// rename here is a client break, not a refactor.
test('the seven admin URLs the Android app calls are unchanged', async () => {
const expected = [
['kick', '/api/v1/admin/shard/kick'],
['ban', '/api/v1/admin/shard/ban'],
['unban', '/api/v1/admin/shard/unban'],
['broadcast', '/api/v1/admin/shard/broadcast'],
]
for (const [fn, url] of expected) {
calls = []
await admin.shardOps[fn]({})
assert.equal(calls[0].url, url, fn)
}
calls = []
await admin.shardOps.pages()
assert.equal(calls[0].url, '/api/v1/admin/shard/pages')
calls = []
await admin.shardOps.respondPage('7', {})
assert.equal(calls[0].url, '/api/v1/admin/shard/pages/7/respond')
calls = []
await admin.shardOps.closePage('7')
assert.equal(calls[0].url, '/api/v1/admin/shard/pages/7/close')
})