/**
* http.test — timedFetch measures status/duration/size, parses JSON bodies, and
* converts network failures + timeouts into results instead of throwing.
*/
import { describe, it, expect } from 'vitest';
import { BODY_EXCERPT_MAX_LEN, bodyExcerptOf, timedFetch, timedJsonPost, type FetchLike } from '../http.js';
const jsonResponse = (body: unknown, status = 200): Response =>
new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });
describe('timedFetch', () => {
it('returns status, byte size and parsed JSON', async () => {
const fetchImpl: FetchLike = async () => jsonResponse({ hello: 'world' }, 201);
const res = await timedFetch('http://x/api', {}, { fetchImpl });
expect(res.ok).toBe(true);
expect(res.status).toBe(201);
expect(res.sizeBytes).toBe(JSON.stringify({ hello: 'world' }).length);
expect(res.json).toEqual({ hello: 'world' });
expect(res.durationMs).toBeGreaterThanOrEqual(0);
});
it('keeps bodyText and leaves json undefined on non-JSON bodies', async () => {
const fetchImpl: FetchLike = async () => new Response('', { status: 500 });
const res = await timedFetch('http://x', {}, { fetchImpl });
expect(res.ok).toBe(true);
expect(res.status).toBe(500);
expect(res.bodyText).toBe('');
expect(res.json).toBeUndefined();
});
it('reports a network failure as ok:false / status 0 without throwing', async () => {
const fetchImpl: FetchLike = async () => {
throw new Error('ECONNREFUSED');
};
const res = await timedFetch('http://down', {}, { fetchImpl });
expect(res.ok).toBe(false);
expect(res.status).toBe(0);
expect(res.error).toContain('ECONNREFUSED');
});
it('aborts after timeoutMs and labels the error as a timeout', async () => {
const fetchImpl: FetchLike = (_url, init) =>
new Promise((_resolve, reject) => {
init?.signal?.addEventListener('abort', () => reject(new DOMException('Aborted', 'AbortError')));
});
const res = await timedFetch('http://slow', {}, { fetchImpl, timeoutMs: 30 });
expect(res.ok).toBe(false);
expect(res.error).toBe('timeout after 30ms');
});
});
describe('bodyExcerptOf', () => {
it('extracts ProblemDetails title — detail + validation errors', () => {
const json = {
title: 'One or more validation errors occurred.',
detail: 'See errors.',
errors: { Name: ['Name is required.'], Email: ['Invalid email.', 'Domain not allowed.'] },
};
const excerpt = bodyExcerptOf(JSON.stringify(json), json);
expect(excerpt).toBe(
'One or more validation errors occurred. — See errors.\nName: Name is required.\nEmail: Invalid email.; Domain not allowed.',
);
});
it('falls back to raw text for non-ProblemDetails bodies and truncates with a marker', () => {
const long = 'x'.repeat(BODY_EXCERPT_MAX_LEN + 500);
const excerpt = bodyExcerptOf(long, undefined);
expect(excerpt).toHaveLength(BODY_EXCERPT_MAX_LEN + ' … [truncated]'.length);
expect(excerpt!.endsWith('[truncated]')).toBe(true);
});
it('scrubs control chars but keeps newlines/tabs; empty body → undefined', () => {
expect(bodyExcerptOf('a\u0001b\u0007c', undefined)).toBe('a b c');
expect(bodyExcerptOf('line1\nline2\tend', undefined)).toBe('line1\nline2\tend');
expect(bodyExcerptOf('', undefined)).toBeUndefined();
expect(bodyExcerptOf(' ', undefined)).toBeUndefined();
});
it('passes hostile HTML through unmodified (escaping is the renderer job)', () => {
const hostile = '';
expect(bodyExcerptOf(hostile, undefined)).toBe(hostile);
});
it('a JSON object without ProblemDetails fields falls back to the raw text', () => {
const body = JSON.stringify({ message: 'nope' });
expect(bodyExcerptOf(body, JSON.parse(body))).toBe(body);
});
});
describe('timedJsonPost', () => {
it('sends a JSON body with Content-Type and merges headers', async () => {
let seen: RequestInit | undefined;
const fetchImpl: FetchLike = async (_url, init) => {
seen = init;
return jsonResponse({ ok: true });
};
await timedJsonPost('http://x', { a: 1 }, { Authorization: 'Bearer t' }, { fetchImpl });
expect(seen?.method).toBe('POST');
expect(seen?.body).toBe('{"a":1}');
expect((seen?.headers as Record)['Content-Type']).toBe('application/json');
expect((seen?.headers as Record).Authorization).toBe('Bearer t');
});
});