test(client): unit-test the pure-logic modules + wire coverage into CI/Sonar
Stand up a client test suite on Node's built-in runner (no vitest/jsdom — the targeted modules are plain ESM with no browser/DOM deps) and cover the meaningful client logic, not presentational components: - api/client.js: the fetch wrapper — always sends the session cookie, maps a non-2xx response to a thrown ApiError (body.message → statusText fallback), resolves an empty body to null, sets Content-Type for JSON but NOT for raw FormData uploads, and builds/encodes query strings + path params. - lib/shardEvents.js: describe() across event kinds (payload vs live frame, actor name→acct→"Someone" fallback, sale pluralization, champ.update branches) and the categoryOf table-consistency check. - data/regionBuckets.js: the presence roll-up, incl. the first-match-wins ordering and the "buckets always reconcile to the total" invariant. - lib/heroLayout.js: parseLayout's version/shape guard and heroBackground's default-vs-custom branch. - lib/format.js: the date/label formatters + relative-time buckets. Wiring so these actually count: - client/package.json gains a `test` script (node --test); - pr-checks.yml runs the client tests as a PR gate; - sonarqube.yml generates a client LCOV report and sonar-project.properties feeds it alongside the server report (SF paths resolve to client/src/...). 43 client tests; coverage on the tested modules: format/regionBuckets/ heroLayout 100%, api client 86%, shardEvents 80%. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
142
client/test/apiClient.test.js
Normal file
142
client/test/apiClient.test.js
Normal file
@@ -0,0 +1,142 @@
|
||||
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$/)
|
||||
})
|
||||
Reference in New Issue
Block a user