import { after, afterEach, before, beforeEach, describe, it } from 'node:test'; import assert from 'node:assert/strict'; import { requestJson } from '../src/http.js'; // requestJson wraps global fetch, so we intercept there. Each test // records the URL + options actually passed to fetch and returns a // stubbed 200 JSON response. Restoring the real fetch after the suite // keeps this file from leaking a mock into any later test file. let originalFetch; let calls; before(() => { originalFetch = globalThis.fetch; }); after(() => { globalThis.fetch = originalFetch; }); beforeEach(() => { calls = []; globalThis.fetch = async (url, options) => { calls.push({ url, options }); return new Response('{}', { status: 200, headers: { 'content-type': 'application/json' }, }); }; }); afterEach(() => { globalThis.fetch = originalFetch; }); describe('requestJson', () => { it('sends Content-Type: application/json on POSTs by default', async () => { await requestJson('POST', 'https://example.test/x', { foo: 1 }); assert.equal(calls[0].options.headers['Content-Type'], 'application/json'); }); // Regression: prior to the fix, `options = { ...headers, ...extra }` // let extra.headers overwrite the merged headers object, silently // dropping Content-Type. Undici then guessed text/plain, which // strict Webex endpoints (POST /locations, etc.) rejected with // HTTP 415. This test locks the merge order down so any future // regression fails loudly here rather than at 3am against Webex. it('preserves Content-Type when the caller passes extra headers (e.g. Authorization)', async () => { await requestJson( 'POST', 'https://example.test/x', { foo: 1 }, { headers: { Authorization: 'Bearer test-token' }, }, ); assert.equal(calls[0].options.headers['Content-Type'], 'application/json'); assert.equal(calls[0].options.headers.Authorization, 'Bearer test-token'); }); it('lets the caller override Content-Type when they explicitly need to', async () => { await requestJson('POST', 'https://example.test/x', '', { headers: { 'Content-Type': 'application/xml' }, }); assert.equal(calls[0].options.headers['Content-Type'], 'application/xml'); }); it('JSON-stringifies plain-object bodies', async () => { await requestJson('POST', 'https://example.test/x', { foo: 1, bar: [true] }); assert.equal(calls[0].options.body, '{"foo":1,"bar":[true]}'); }); it('passes string bodies through as-is (already serialized)', async () => { await requestJson('POST', 'https://example.test/x', '{"already":"json"}'); assert.equal(calls[0].options.body, '{"already":"json"}'); }); it('sends no body when body is undefined or null', async () => { await requestJson('GET', 'https://example.test/x'); assert.equal(calls[0].options.body, undefined); await requestJson('DELETE', 'https://example.test/x', null); assert.equal(calls[1].options.body, undefined); }); it('throws a descriptive error on non-2xx responses', async () => { globalThis.fetch = async () => new Response('{"message":"nope"}', { status: 415, statusText: 'Unsupported Media Type', headers: { 'content-type': 'application/json' }, }); await assert.rejects(requestJson('POST', 'https://example.test/x', { foo: 1 }), (err) => { assert.equal(err.status, 415); assert.match(err.message, /HTTP 415/); assert.match(err.message, /POST https:\/\/example\.test\/x/); assert.equal(err.body, '{"message":"nope"}'); return true; }); }); });