From d70e5e10d0d21a969245495b9ba6994041423957 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Tue, 11 Aug 2026 21:31:49 -0500 Subject: [PATCH] test(client): assert the URLs this module calls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five assertions that stayed behind in core's `apiClient.test.js` when the bindings moved in slice 1 — the atlas-vs-shard path split, the query-string filtering, the slug encoding, the admin atlas methods — plus a new one pinning the seven admin URLs the shipped Android app calls by name. They were asserting UO URLs from inside core's suite, which is the boundary Phase 3 removes, and core's slice-4 deletion of those bindings would otherwise have deleted the coverage with them. The fake `window.__rg` carries the REAL react/react-dom/router rather than stubs: `src/core.js` compares its imported bindings against the published ones and logs a "bundled its own copy" error when they differ, so stubs make every run of this file print the exact wording of a real defect. Co-Authored-By: Claude --- client/test/api.test.js | 139 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 139 insertions(+) create mode 100644 client/test/api.test.js diff --git a/client/test/api.test.js b/client/test/api.test.js new file mode 100644 index 0000000..fd843df --- /dev/null +++ b/client/test/api.test.js @@ -0,0 +1,139 @@ +// ── 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' }) +}) + +// ── 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('Serpent’s 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') +})