wbxcallprov/test/http.test.js
jmcqueen 203850fd69
Some checks are pending
CI / verify (push) Waiting to run
Fix requestJson dropping Content-Type on POSTs (HTTP 415)
requestJson merged Content-Type: application/json into a headers object,
then spread `...extra` after `headers` in the fetch options — which let
extra.headers (always passed by webexJson for Authorization) overwrite
the merged object, stripping Content-Type entirely. Undici then
defaulted the string body's Content-Type to text/plain;charset=UTF-8,
and strict Webex endpoints like POST /locations rejected the request
with HTTP 415. Latent since the node-fetch -> native fetch migration.

Reorder to `{ method, ...extra, headers }` so the explicit merged
headers win, and add a test/http.test.js suite (7 tests) that pins the
merge behaviour down so this can't regress silently again.

Also: update the npm test script to `test/**/*.test.js` glob. Node
22.23 no longer accepts a bare `test/` directory as a positional arg
to `--test` (fails with ERR_UNSUPPORTED_DIR_IMPORT).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-09 12:07:15 -04:00

100 lines
3.8 KiB
JavaScript

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', '<xml/>', {
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;
});
});
});