/** * 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'); }); });