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$/) })