Phase 3's acceptance criterion 1, made real. Three things, one review: **The dead bindings.** `client/src/api/client.js` still carried ~190 lines of UO namespaces — `shard`, `atlas`, the two SSE URLs, `admin.shard/shardOps/atlas/ userShard`, the uo-link and town-crier calls, `player.shard` — with zero core consumers since slice 3 deleted the views. module-uo vendors its own bindings. The five assertions core's `apiClient.test.js` made about those URLs moved with them (Module-uo#5); the encoding test that used `governorHistory` now uses a core route. **The copy.** Core is the platform, not one game's site, so its words are game-neutral now: `About`, `Screenshots`, `Website`'s cards, `Status` (which was never about a game server at all — it reports site mode), `Wiki`, `SiteFooter`, the default hero, `brand.js`'s tagline and description, the seeded wiki categories, and two user-visible NavEditor strings that named a module's admin screen by its proper name. Which game an instance is for is the operator's to say — BRAND_* vars, the hero editor, CMS pages — and every real instance already does: `.env.uomysticmoon.example` sets both brand strings explicitly, so nothing live changes wording. Wiki page SLUGS are untouched: `seedDefault*` only inserts what is absent, so renaming one adds a duplicate page to every install. Also gone: an orphan comment block in `schema.sql` describing the spawn-atlas tables slice 1 took away, and the two settings rows core seeded for a module (`game_account_signup`, `uo_link_protocol_3_migrated`). The second was a live defect — see Module-uo#5, which takes ownership of both and repairs the one-shot migration core's ordering had disabled. **The check.** `scripts/checkModuleIdentifiers.js` + `npm run check:modules`, first step of the server-tests job because it needs no dependencies. It reads CODE, not prose — file names, import specifiers, route path literals, declared identifiers and property names — per §5.2, so core's English may still say "shard" where saying it is worth more than the word costs. Two things it gets right only because getting them wrong was tried first: it matches WHOLE WORDS (a substring pass flags `defaultImage`, which contains "ultIma", four times in this repo), and it strips comments and string bodies in one character walk (a comment contains quotes, a string contains `//`) — the `checkImports.js` lesson. It has its own 17-test suite, because a boundary check that silently stops checking is worse than none. The three §6.5 grandfathering allowlists are exempt by name, and an exemption that stops matching fails the build rather than lingering. BREAKING CHANGE: core no longer seeds `game_account_signup` or `uo_link_protocol_3_migrated`; module-uo's schema fragment does. An install running core without module-uo keeps whatever rows it already has and gains no new ones — nothing in core reads either key. Deferred to slice 5, deliberately: README.md's 48 UO mentions, including a `## Shard integration (uo-link)` section and the architecture diagram. That is documentation, which §5.2 does not cover, and it belongs with the phase-closing docs pass rather than half-done here. Co-Authored-By: Claude <noreply@anthropic.com>
143 lines
5.9 KiB
JavaScript
143 lines
5.9 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 with unsafe characters is escaped)', async () => {
|
|
willReply({ body: {} })
|
|
await api.getInvite('a b/c?d')
|
|
assert.equal(calls[0].url, '/api/v1/auth/invite/a%20b%2Fc%3Fd')
|
|
})
|
|
|
|
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$/)
|
|
})
|