From 13a976e9bcd5cc02f8471c6995e684aee5699917 Mon Sep 17 00:00:00 2001 From: Test User Date: Fri, 3 Apr 2026 22:06:41 -0400 Subject: [PATCH 1/9] feat(http-request): add hooks, maxResponseSize, rawResponse, enriched errors - onRequest/onResponse hooks fire per-attempt (retries/redirects each trigger) - maxResponseSize rejects responses exceeding byte limit - rawResponse exposes IncomingMessage on HttpResponse for advanced use - Enriched error messages for ECONNREFUSED, ENOTFOUND, ETIMEDOUT, ECONNRESET, EPIPE, and SSL/TLS cert errors - New exported types: HttpHooks, HttpHookRequestInfo, HttpHookResponseInfo --- src/http-request.ts | 196 ++++++++++++++++++++++++++------ test/unit/http-request.test.mts | 6 +- 2 files changed, 167 insertions(+), 35 deletions(-) diff --git a/src/http-request.ts b/src/http-request.ts index a04f72b..43e00e7 100644 --- a/src/http-request.ts +++ b/src/http-request.ts @@ -74,6 +74,38 @@ function getHttps() { return _https as typeof import('node:https') } +/** + * Information passed to the onRequest hook before each request attempt. + */ +export interface HttpHookRequestInfo { + headers: Record + method: string + timeout: number + url: string +} + +/** + * Information passed to the onResponse hook after each request attempt. + */ +export interface HttpHookResponseInfo { + duration: number + error?: Error | undefined + headers?: Record | undefined + method: string + status?: number | undefined + statusText?: string | undefined + url: string +} + +/** + * Lifecycle hooks for observing HTTP request/response events. + * Hooks fire per-attempt (retries produce multiple hook calls). + */ +export interface HttpHooks { + onRequest?: ((info: HttpHookRequestInfo) => void) | undefined + onResponse?: ((info: HttpHookResponseInfo) => void) | undefined +} + /** * Configuration options for HTTP/HTTPS requests. */ @@ -136,6 +168,11 @@ export interface HttpRequestOptions { * ``` */ followRedirects?: boolean | undefined + /** + * Lifecycle hooks for observing request/response events. + * Hooks fire per-attempt — retries and redirects each trigger separate hook calls. + */ + hooks?: HttpHooks | undefined /** * HTTP headers to send with the request. * A `User-Agent` header is automatically added if not provided. @@ -167,6 +204,14 @@ export interface HttpRequestOptions { * ``` */ maxRedirects?: number | undefined + /** + * Maximum response body size in bytes. Responses exceeding this limit + * will be rejected with an error. Prevents memory exhaustion from + * unexpectedly large responses. + * + * @default undefined (no limit) + */ + maxResponseSize?: number | undefined /** * HTTP method to use for the request. * @@ -346,6 +391,12 @@ export interface HttpResponse { * ``` */ text(): string + /** + * The underlying Node.js IncomingMessage for advanced use cases + * (e.g., streaming, custom header inspection). Only available when + * the response was not consumed by the convenience methods. + */ + rawResponse?: IncomingMessage | undefined } /** @@ -891,8 +942,48 @@ async function httpDownloadAttempt( }) } +/** + * Build an enriched error message based on the error code. + * Generic guidance (no product-specific branding). + * @private + */ +function enrichErrorMessage( + url: string, + method: string, + error: NodeJS.ErrnoException, +): string { + const code = error.code + let message = `${method} request failed: ${url}` + if (code === 'ECONNREFUSED') { + message += + '\n→ Connection refused. Server is unreachable.\n→ Check: Network connectivity and firewall settings.' + } else if (code === 'ENOTFOUND') { + message += + '\n→ DNS lookup failed. Cannot resolve hostname.\n→ Check: Internet connection and DNS settings.' + } else if (code === 'ETIMEDOUT') { + message += + '\n→ Connection timed out. Network or server issue.\n→ Try: Check network connectivity and retry.' + } else if (code === 'ECONNRESET') { + message += + '\n→ Connection reset by server. Possible network interruption.\n→ Try: Retry the request.' + } else if (code === 'EPIPE') { + message += + '\n→ Broken pipe. Server closed connection unexpectedly.\n→ Check: Authentication credentials and permissions.' + } else if ( + code === 'CERT_HAS_EXPIRED' || + code === 'UNABLE_TO_VERIFY_LEAF_SIGNATURE' + ) { + message += + '\n→ SSL/TLS certificate error.\n→ Check: System time and date are correct.\n→ Try: Update CA certificates on your system.' + } else if (code) { + message += `\n→ Error code: ${code}` + } + return message +} + /** * Single HTTP request attempt (used internally by httpRequest with retry logic). + * Supports hooks (fire per-attempt), maxResponseSize, and rawResponse. * @private */ async function httpRequestAttempt( @@ -904,21 +995,28 @@ async function httpRequestAttempt( ca, followRedirects = true, headers = {}, + hooks, maxRedirects = 5, + maxResponseSize, method = 'GET', timeout = 30_000, } = { __proto__: null, ...options } as HttpRequestOptions + const startTime = Date.now() + const mergedHeaders = { + 'User-Agent': 'socket-registry/1.0', + ...headers, + } + + hooks?.onRequest?.({ method, url, headers: mergedHeaders, timeout }) + return await new Promise((resolve, reject) => { const parsedUrl = new URL(url) const isHttps = parsedUrl.protocol === 'https:' const httpModule = isHttps ? getHttps() : getHttp() const requestOptions: Record = { - headers: { - 'User-Agent': 'socket-registry/1.0', - ...headers, - }, + headers: mergedHeaders, hostname: parsedUrl.hostname, method, path: parsedUrl.pathname + parsedUrl.search, @@ -926,16 +1024,23 @@ async function httpRequestAttempt( timeout, } - // Pass custom CA certificates for TLS connections. if (ca && isHttps) { requestOptions['ca'] = ca } + const emitResponse = (info: Partial) => { + hooks?.onResponse?.({ + duration: Date.now() - startTime, + method, + url, + ...info, + }) + } + /* c8 ignore start - External HTTP/HTTPS request */ const request = httpModule.request( requestOptions, (res: IncomingMessage) => { - // Handle redirects if ( followRedirects && res.statusCode && @@ -943,6 +1048,15 @@ async function httpRequestAttempt( res.statusCode < 400 && res.headers.location ) { + emitResponse({ + headers: res.headers as Record< + string, + string | string[] | undefined + >, + status: res.statusCode, + statusText: res.statusMessage, + }) + if (maxRedirects <= 0) { reject( new Error( @@ -952,12 +1066,10 @@ async function httpRequestAttempt( return } - // Follow redirect const redirectUrl = res.headers.location.startsWith('http') ? res.headers.location : new URL(res.headers.location, url).toString() - // Reject HTTPS-to-HTTP downgrade redirects. const redirectParsed = new URL(redirectUrl) if (isHttps && redirectParsed.protocol !== 'https:') { reject( @@ -974,7 +1086,9 @@ async function httpRequestAttempt( ca, followRedirects, headers, + hooks, maxRedirects: maxRedirects - 1, + maxResponseSize, method, timeout, }), @@ -982,9 +1096,22 @@ async function httpRequestAttempt( return } - // Collect response data const chunks: Buffer[] = [] + let totalBytes = 0 + res.on('data', (chunk: Buffer) => { + totalBytes += chunk.length + if (maxResponseSize && totalBytes > maxResponseSize) { + res.destroy() + const sizeMB = (totalBytes / (1024 * 1024)).toFixed(2) + const maxMB = (maxResponseSize / (1024 * 1024)).toFixed(2) + const err = new Error( + `Response exceeds maximum size limit (${sizeMB}MB > ${maxMB}MB)`, + ) + emitResponse({ error: err }) + reject(err) + return + } chunks.push(chunk) }) @@ -1011,6 +1138,7 @@ async function httpRequestAttempt( return JSON.parse(responseBody.toString('utf8')) as T }, ok, + rawResponse: res, status: res.statusCode || 0, statusText: res.statusMessage || '', text(): string { @@ -1018,45 +1146,45 @@ async function httpRequestAttempt( }, } + emitResponse({ + headers: res.headers as Record< + string, + string | string[] | undefined + >, + status: res.statusCode, + statusText: res.statusMessage, + }) + resolve(response) }) res.on('error', (error: Error) => { + emitResponse({ error }) reject(error) }) }, ) request.on('error', (error: Error) => { - const code = (error as NodeJS.ErrnoException).code - let message = `HTTP request failed for ${url}: ${error.message}\n` - - if (code === 'ENOTFOUND') { - message += - 'DNS lookup failed. Check the hostname and your network connection.' - } else if (code === 'ECONNREFUSED') { - message += - 'Connection refused. Verify the server is running and accessible.' - } else if (code === 'ETIMEDOUT') { - message += - 'Request timed out. Check your network or increase the timeout value.' - } else if (code === 'ECONNRESET') { - message += - 'Connection reset. The server may have closed the connection unexpectedly.' - } else { - message += - 'Check your network connection and verify the URL is correct.' - } - - reject(new Error(message, { cause: error })) + const message = enrichErrorMessage( + url, + method, + error as NodeJS.ErrnoException, + ) + const enhanced = new Error(message, { cause: error }) + emitResponse({ error: enhanced }) + reject(enhanced) }) request.on('timeout', () => { request.destroy() - reject(new Error(`Request timed out after ${timeout}ms`)) + const err = new Error( + `${method} request timed out after ${timeout}ms: ${url}\n→ Server did not respond in time.\n→ Try: Increase timeout or check network connectivity.`, + ) + emitResponse({ error: err }) + reject(err) }) - // Send body if present if (body) { request.write(body) } @@ -1384,7 +1512,9 @@ export async function httpRequest( ca, followRedirects = true, headers = {}, + hooks, maxRedirects = 5, + maxResponseSize, method = 'GET', retries = 0, retryDelay = 1000, @@ -1401,7 +1531,9 @@ export async function httpRequest( ca, followRedirects, headers, + hooks, maxRedirects, + maxResponseSize, method, timeout, }) diff --git a/test/unit/http-request.test.mts b/test/unit/http-request.test.mts index 9957637..55dc4b8 100644 --- a/test/unit/http-request.test.mts +++ b/test/unit/http-request.test.mts @@ -428,7 +428,7 @@ describe('http-request', () => { retries: 2, retryDelay: 10, }), - ).rejects.toThrow(/HTTP request failed/) + ).rejects.toThrow(/request failed/) expect(attemptCount).toBe(3) // Initial attempt + 2 retries } finally { await new Promise(resolve => { @@ -440,7 +440,7 @@ describe('http-request', () => { it('should handle network errors', async () => { await expect( httpRequest('http://localhost:1/nonexistent', { timeout: 100 }), - ).rejects.toThrow(/HTTP request failed/) + ).rejects.toThrow(/request failed/) }) it('should handle invalid URLs gracefully', async () => { @@ -498,7 +498,7 @@ describe('http-request', () => { try { await expect( httpRequest(`http://localhost:${testPort}/`), - ).rejects.toThrow(/HTTP request failed/) + ).rejects.toThrow(/request failed/) } finally { await new Promise(resolve => { testServer.close(() => resolve()) From 5271f138e0cc957460c7e5829e4fb7dfffeedea1 Mon Sep 17 00:00:00 2001 From: Test User Date: Fri, 3 Apr 2026 22:11:52 -0400 Subject: [PATCH 2/9] test(http-request): add 25 tests for hooks, maxResponseSize, rawResponse, enrichErrorMessage - hooks: onRequest/onResponse fire per-attempt, per-redirect, with POST method, custom headers - maxResponseSize: rejects oversized responses, works with httpJson/httpText, fires error hooks - rawResponse: exposes IncomingMessage on success and non-2xx responses - enrichErrorMessage: tests all 7 error codes + unknown + no-code cases - Export enrichErrorMessage for testability --- src/http-request.ts | 3 +- test/unit/http-request.test.mts | 276 ++++++++++++++++++++++++++++++++ 2 files changed, 277 insertions(+), 2 deletions(-) diff --git a/src/http-request.ts b/src/http-request.ts index 43e00e7..19a4f8a 100644 --- a/src/http-request.ts +++ b/src/http-request.ts @@ -945,9 +945,8 @@ async function httpDownloadAttempt( /** * Build an enriched error message based on the error code. * Generic guidance (no product-specific branding). - * @private */ -function enrichErrorMessage( +export function enrichErrorMessage( url: string, method: string, error: NodeJS.ErrnoException, diff --git a/test/unit/http-request.test.mts b/test/unit/http-request.test.mts index 55dc4b8..e01134b 100644 --- a/test/unit/http-request.test.mts +++ b/test/unit/http-request.test.mts @@ -18,6 +18,7 @@ import path from 'node:path' import { Writable } from 'node:stream' import { + enrichErrorMessage, fetchChecksums, httpDownload, httpJson, @@ -25,6 +26,10 @@ import { httpText, parseChecksums, } from '@socketsecurity/lib/http-request' +import type { + HttpHookRequestInfo, + HttpHookResponseInfo, +} from '@socketsecurity/lib/http-request' import { Logger } from '@socketsecurity/lib/logger' import { afterAll, beforeAll, describe, expect, it } from 'vitest' import { runWithTempDir } from './utils/temp-file-helper' @@ -163,6 +168,13 @@ beforeAll(async () => { // Empty checksums file (only comments). res.writeHead(200, { 'Content-Type': 'text/plain' }) res.end('# This file has no checksums\n\n') + } else if (url === '/large-body') { + const content = 'X'.repeat(10_000) + res.writeHead(200, { + 'Content-Length': String(content.length), + 'Content-Type': 'text/plain', + }) + res.end(content) } else if (url === '/post-success') { if (req.method === 'POST') { res.writeHead(201, { 'Content-Type': 'application/json' }) @@ -1804,4 +1816,268 @@ abc123def456789012345678901234567890123456789012345678901234abcd expect(response.text()).toBe('Plain text response') }) }) + + describe('hooks', () => { + it('should call onRequest before the request', async () => { + const requestInfos: HttpHookRequestInfo[] = [] + await httpRequest(`${httpBaseUrl}/json`, { + hooks: { + onRequest: info => requestInfos.push(info), + }, + }) + expect(requestInfos).toHaveLength(1) + expect(requestInfos[0]!.method).toBe('GET') + expect(requestInfos[0]!.url).toBe(`${httpBaseUrl}/json`) + expect(requestInfos[0]!.timeout).toBe(30_000) + expect(requestInfos[0]!.headers).toBeDefined() + expect(requestInfos[0]!.headers['User-Agent']).toBe('socket-registry/1.0') + }) + + it('should call onResponse after a successful request', async () => { + const responseInfos: HttpHookResponseInfo[] = [] + await httpRequest(`${httpBaseUrl}/json`, { + hooks: { + onResponse: info => responseInfos.push(info), + }, + }) + expect(responseInfos).toHaveLength(1) + expect(responseInfos[0]!.method).toBe('GET') + expect(responseInfos[0]!.url).toBe(`${httpBaseUrl}/json`) + expect(responseInfos[0]!.status).toBe(200) + expect(responseInfos[0]!.statusText).toBe('OK') + expect(responseInfos[0]!.duration).toBeGreaterThanOrEqual(0) + expect(responseInfos[0]!.error).toBeUndefined() + }) + + it('should call onResponse with error on failure', async () => { + const responseInfos: HttpHookResponseInfo[] = [] + await httpRequest(`${httpBaseUrl}/timeout`, { + timeout: 50, + hooks: { + onResponse: info => responseInfos.push(info), + }, + }).catch(() => {}) + expect(responseInfos).toHaveLength(1) + expect(responseInfos[0]!.error).toBeDefined() + }) + + it('should fire hooks per-attempt on retries', async () => { + const requestInfos: HttpHookRequestInfo[] = [] + const responseInfos: HttpHookResponseInfo[] = [] + + let attemptCount = 0 + const testServer = http.createServer((_req, _res) => { + attemptCount++ + _res.socket?.destroy() + }) + + await new Promise(resolve => { + testServer.listen(0, () => resolve()) + }) + const address = testServer.address() + const testPort = address && typeof address === 'object' ? address.port : 0 + + try { + await httpRequest(`http://localhost:${testPort}/`, { + retries: 1, + retryDelay: 10, + hooks: { + onRequest: info => requestInfos.push(info), + onResponse: info => responseInfos.push(info), + }, + }).catch(() => {}) + + expect(attemptCount).toBe(2) + expect(requestInfos).toHaveLength(2) + expect(responseInfos).toHaveLength(2) + for (const info of responseInfos) { + expect(info.error).toBeDefined() + } + } finally { + await new Promise(resolve => { testServer.close(() => resolve()) }) + } + }) + + it('should fire hooks on redirect hops', async () => { + const requestInfos: HttpHookRequestInfo[] = [] + const responseInfos: HttpHookResponseInfo[] = [] + + await httpRequest(`${httpBaseUrl}/redirect`, { + hooks: { + onRequest: info => requestInfos.push(info), + onResponse: info => responseInfos.push(info), + }, + }) + + // redirect hop + final request = 2 onRequest, 2 onResponse + expect(requestInfos).toHaveLength(2) + expect(responseInfos).toHaveLength(2) + expect(responseInfos[0]!.status).toBe(302) + expect(responseInfos[1]!.status).toBe(200) + }) + + it('should pass custom headers through hooks', async () => { + const requestInfos: HttpHookRequestInfo[] = [] + await httpRequest(`${httpBaseUrl}/json`, { + headers: { 'X-Custom': 'test-value' }, + hooks: { + onRequest: info => requestInfos.push(info), + }, + }) + expect(requestInfos[0]!.headers['X-Custom']).toBe('test-value') + }) + + it('should include method in hook info for POST', async () => { + const requestInfos: HttpHookRequestInfo[] = [] + await httpRequest(`${httpBaseUrl}/echo-body`, { + method: 'POST', + body: 'test', + hooks: { + onRequest: info => requestInfos.push(info), + }, + }) + expect(requestInfos[0]!.method).toBe('POST') + }) + }) + + describe('maxResponseSize', () => { + it('should reject responses exceeding maxResponseSize', async () => { + await expect( + httpRequest(`${httpBaseUrl}/large-body`, { + maxResponseSize: 100, + }), + ).rejects.toThrow(/exceeds maximum size limit/) + }) + + it('should allow responses within maxResponseSize', async () => { + const response = await httpRequest(`${httpBaseUrl}/json`, { + maxResponseSize: 1_000_000, + }) + expect(response.ok).toBe(true) + }) + + it('should include size info in the error message', async () => { + try { + await httpRequest(`${httpBaseUrl}/large-body`, { + maxResponseSize: 50, + }) + expect.unreachable('should have thrown') + } catch (e) { + expect((e as Error).message).toMatch(/MB.*>.*MB/) + } + }) + + it('should work with httpJson', async () => { + await expect( + httpJson(`${httpBaseUrl}/json`, { + maxResponseSize: 5, + }), + ).rejects.toThrow(/exceeds maximum size limit/) + }) + + it('should work with httpText', async () => { + await expect( + httpText(`${httpBaseUrl}/text`, { + maxResponseSize: 5, + }), + ).rejects.toThrow(/exceeds maximum size limit/) + }) + + it('should fire onResponse hook with error on size limit', async () => { + const responseInfos: HttpHookResponseInfo[] = [] + await httpRequest(`${httpBaseUrl}/large-body`, { + maxResponseSize: 50, + hooks: { + onResponse: info => responseInfos.push(info), + }, + }).catch(() => {}) + + // At least one hook call should contain the size limit error + expect(responseInfos.length).toBeGreaterThanOrEqual(1) + const sizeError = responseInfos.find( + info => info.error?.message?.includes('exceeds maximum size limit'), + ) + expect(sizeError).toBeDefined() + }) + }) + + describe('rawResponse', () => { + it('should expose rawResponse on HttpResponse', async () => { + const response = await httpRequest(`${httpBaseUrl}/json`) + expect(response.rawResponse).toBeDefined() + expect(response.rawResponse!.statusCode).toBe(200) + }) + + it('should have headers on rawResponse', async () => { + const response = await httpRequest(`${httpBaseUrl}/json`) + expect(response.rawResponse!.headers['content-type']).toContain('application/json') + }) + + it('should be available on non-2xx responses', async () => { + const response = await httpRequest(`${httpBaseUrl}/not-found`) + expect(response.rawResponse).toBeDefined() + expect(response.rawResponse!.statusCode).toBe(404) + }) + }) + + describe('enrichErrorMessage', () => { + it('should enrich ECONNREFUSED', () => { + const err = Object.assign(new Error('connect failed'), { code: 'ECONNREFUSED' }) as NodeJS.ErrnoException + const msg = enrichErrorMessage('http://localhost:1', 'GET', err) + expect(msg).toContain('Connection refused') + expect(msg).toContain('GET request failed') + }) + + it('should enrich ENOTFOUND', () => { + const err = Object.assign(new Error('not found'), { code: 'ENOTFOUND' }) as NodeJS.ErrnoException + const msg = enrichErrorMessage('http://no-such-host.invalid', 'POST', err) + expect(msg).toContain('DNS lookup failed') + expect(msg).toContain('POST request failed') + }) + + it('should enrich ETIMEDOUT', () => { + const err = Object.assign(new Error('timed out'), { code: 'ETIMEDOUT' }) as NodeJS.ErrnoException + const msg = enrichErrorMessage('http://example.com', 'GET', err) + expect(msg).toContain('Connection timed out') + }) + + it('should enrich ECONNRESET', () => { + const err = Object.assign(new Error('reset'), { code: 'ECONNRESET' }) as NodeJS.ErrnoException + const msg = enrichErrorMessage('http://example.com', 'GET', err) + expect(msg).toContain('Connection reset') + }) + + it('should enrich EPIPE', () => { + const err = Object.assign(new Error('broken pipe'), { code: 'EPIPE' }) as NodeJS.ErrnoException + const msg = enrichErrorMessage('http://example.com', 'PUT', err) + expect(msg).toContain('Broken pipe') + expect(msg).toContain('PUT request failed') + }) + + it('should enrich CERT_HAS_EXPIRED', () => { + const err = Object.assign(new Error('cert expired'), { code: 'CERT_HAS_EXPIRED' }) as NodeJS.ErrnoException + const msg = enrichErrorMessage('https://expired.example.com', 'GET', err) + expect(msg).toContain('SSL/TLS certificate error') + }) + + it('should enrich UNABLE_TO_VERIFY_LEAF_SIGNATURE', () => { + const err = Object.assign(new Error('leaf sig'), { code: 'UNABLE_TO_VERIFY_LEAF_SIGNATURE' }) as NodeJS.ErrnoException + const msg = enrichErrorMessage('https://badcert.example.com', 'GET', err) + expect(msg).toContain('SSL/TLS certificate error') + }) + + it('should include error code for unknown codes', () => { + const err = Object.assign(new Error('something'), { code: 'ESOMETHING' }) as NodeJS.ErrnoException + const msg = enrichErrorMessage('http://example.com', 'DELETE', err) + expect(msg).toContain('Error code: ESOMETHING') + expect(msg).toContain('DELETE request failed') + }) + + it('should handle errors without a code', () => { + const err = new Error('generic error') as NodeJS.ErrnoException + const msg = enrichErrorMessage('http://example.com', 'GET', err) + expect(msg).toContain('GET request failed') + expect(msg).not.toContain('Error code:') + }) + }) }) From 140e3c93ceb28695d8f3fc06a4e5dc9e30266a4e Mon Sep 17 00:00:00 2001 From: Test User Date: Fri, 3 Apr 2026 22:15:37 -0400 Subject: [PATCH 3/9] test(http-request): add 16 more tests for complete coverage of new features - hooks edge cases: onRequest-only, onResponse-only, empty hooks, httpJson/httpText passthrough, response headers in hook, duration - maxResponseSize edge cases: exact size match, zero (no limit), enforcement after redirect - rawResponse edge cases: after redirect (final response), on server error - enriched errors integration: method+url in timeout, method+url in connection error, cause chain preserved, url in enrichErrorMessage --- test/unit/http-request.test.mts | 190 ++++++++++++++++++++++++++++++-- 1 file changed, 178 insertions(+), 12 deletions(-) diff --git a/test/unit/http-request.test.mts b/test/unit/http-request.test.mts index e01134b..7098664 100644 --- a/test/unit/http-request.test.mts +++ b/test/unit/http-request.test.mts @@ -1894,7 +1894,9 @@ abc123def456789012345678901234567890123456789012345678901234abcd expect(info.error).toBeDefined() } } finally { - await new Promise(resolve => { testServer.close(() => resolve()) }) + await new Promise(resolve => { + testServer.close(() => resolve()) + }) } }) @@ -1994,8 +1996,8 @@ abc123def456789012345678901234567890123456789012345678901234abcd // At least one hook call should contain the size limit error expect(responseInfos.length).toBeGreaterThanOrEqual(1) - const sizeError = responseInfos.find( - info => info.error?.message?.includes('exceeds maximum size limit'), + const sizeError = responseInfos.find(info => + info.error?.message?.includes('exceeds maximum size limit'), ) expect(sizeError).toBeDefined() }) @@ -2010,7 +2012,9 @@ abc123def456789012345678901234567890123456789012345678901234abcd it('should have headers on rawResponse', async () => { const response = await httpRequest(`${httpBaseUrl}/json`) - expect(response.rawResponse!.headers['content-type']).toContain('application/json') + expect(response.rawResponse!.headers['content-type']).toContain( + 'application/json', + ) }) it('should be available on non-2xx responses', async () => { @@ -2022,52 +2026,68 @@ abc123def456789012345678901234567890123456789012345678901234abcd describe('enrichErrorMessage', () => { it('should enrich ECONNREFUSED', () => { - const err = Object.assign(new Error('connect failed'), { code: 'ECONNREFUSED' }) as NodeJS.ErrnoException + const err = Object.assign(new Error('connect failed'), { + code: 'ECONNREFUSED', + }) as NodeJS.ErrnoException const msg = enrichErrorMessage('http://localhost:1', 'GET', err) expect(msg).toContain('Connection refused') expect(msg).toContain('GET request failed') }) it('should enrich ENOTFOUND', () => { - const err = Object.assign(new Error('not found'), { code: 'ENOTFOUND' }) as NodeJS.ErrnoException + const err = Object.assign(new Error('not found'), { + code: 'ENOTFOUND', + }) as NodeJS.ErrnoException const msg = enrichErrorMessage('http://no-such-host.invalid', 'POST', err) expect(msg).toContain('DNS lookup failed') expect(msg).toContain('POST request failed') }) it('should enrich ETIMEDOUT', () => { - const err = Object.assign(new Error('timed out'), { code: 'ETIMEDOUT' }) as NodeJS.ErrnoException + const err = Object.assign(new Error('timed out'), { + code: 'ETIMEDOUT', + }) as NodeJS.ErrnoException const msg = enrichErrorMessage('http://example.com', 'GET', err) expect(msg).toContain('Connection timed out') }) it('should enrich ECONNRESET', () => { - const err = Object.assign(new Error('reset'), { code: 'ECONNRESET' }) as NodeJS.ErrnoException + const err = Object.assign(new Error('reset'), { + code: 'ECONNRESET', + }) as NodeJS.ErrnoException const msg = enrichErrorMessage('http://example.com', 'GET', err) expect(msg).toContain('Connection reset') }) it('should enrich EPIPE', () => { - const err = Object.assign(new Error('broken pipe'), { code: 'EPIPE' }) as NodeJS.ErrnoException + const err = Object.assign(new Error('broken pipe'), { + code: 'EPIPE', + }) as NodeJS.ErrnoException const msg = enrichErrorMessage('http://example.com', 'PUT', err) expect(msg).toContain('Broken pipe') expect(msg).toContain('PUT request failed') }) it('should enrich CERT_HAS_EXPIRED', () => { - const err = Object.assign(new Error('cert expired'), { code: 'CERT_HAS_EXPIRED' }) as NodeJS.ErrnoException + const err = Object.assign(new Error('cert expired'), { + code: 'CERT_HAS_EXPIRED', + }) as NodeJS.ErrnoException const msg = enrichErrorMessage('https://expired.example.com', 'GET', err) expect(msg).toContain('SSL/TLS certificate error') }) it('should enrich UNABLE_TO_VERIFY_LEAF_SIGNATURE', () => { - const err = Object.assign(new Error('leaf sig'), { code: 'UNABLE_TO_VERIFY_LEAF_SIGNATURE' }) as NodeJS.ErrnoException + const err = Object.assign(new Error('leaf sig'), { + code: 'UNABLE_TO_VERIFY_LEAF_SIGNATURE', + }) as NodeJS.ErrnoException const msg = enrichErrorMessage('https://badcert.example.com', 'GET', err) expect(msg).toContain('SSL/TLS certificate error') }) it('should include error code for unknown codes', () => { - const err = Object.assign(new Error('something'), { code: 'ESOMETHING' }) as NodeJS.ErrnoException + const err = Object.assign(new Error('something'), { + code: 'ESOMETHING', + }) as NodeJS.ErrnoException const msg = enrichErrorMessage('http://example.com', 'DELETE', err) expect(msg).toContain('Error code: ESOMETHING') expect(msg).toContain('DELETE request failed') @@ -2079,5 +2099,151 @@ abc123def456789012345678901234567890123456789012345678901234abcd expect(msg).toContain('GET request failed') expect(msg).not.toContain('Error code:') }) + + it('should include url in the message', () => { + const err = Object.assign(new Error('fail'), { code: 'ECONNREFUSED' }) as NodeJS.ErrnoException + const msg = enrichErrorMessage('http://my-server:8080/api', 'GET', err) + expect(msg).toContain('http://my-server:8080/api') + }) + }) + + describe('hooks — edge cases', () => { + it('should work with only onRequest (no onResponse)', async () => { + const infos: HttpHookRequestInfo[] = [] + await httpRequest(`${httpBaseUrl}/json`, { + hooks: { onRequest: info => infos.push(info) }, + }) + expect(infos).toHaveLength(1) + }) + + it('should work with only onResponse (no onRequest)', async () => { + const infos: HttpHookResponseInfo[] = [] + await httpRequest(`${httpBaseUrl}/json`, { + hooks: { onResponse: info => infos.push(info) }, + }) + expect(infos).toHaveLength(1) + expect(infos[0]!.status).toBe(200) + }) + + it('should work with empty hooks object', async () => { + const response = await httpRequest(`${httpBaseUrl}/json`, { hooks: {} }) + expect(response.ok).toBe(true) + }) + + it('should pass hooks through httpJson', async () => { + const infos: HttpHookResponseInfo[] = [] + await httpJson(`${httpBaseUrl}/json`, { + hooks: { onResponse: info => infos.push(info) }, + }) + expect(infos).toHaveLength(1) + expect(infos[0]!.status).toBe(200) + }) + + it('should pass hooks through httpText', async () => { + const infos: HttpHookResponseInfo[] = [] + await httpText(`${httpBaseUrl}/text`, { + hooks: { onResponse: info => infos.push(info) }, + }) + expect(infos).toHaveLength(1) + expect(infos[0]!.status).toBe(200) + }) + + it('should include response headers in onResponse', async () => { + const infos: HttpHookResponseInfo[] = [] + await httpRequest(`${httpBaseUrl}/json`, { + hooks: { onResponse: info => infos.push(info) }, + }) + expect(infos[0]!.headers).toBeDefined() + const ct = infos[0]!.headers?.['content-type'] + expect(ct).toContain('application/json') + }) + + it('should report non-zero duration in onResponse', async () => { + const infos: HttpHookResponseInfo[] = [] + await httpRequest(`${httpBaseUrl}/slow`, { + hooks: { onResponse: info => infos.push(info) }, + }) + expect(infos[0]!.duration).toBeGreaterThanOrEqual(0) + }) + }) + + describe('maxResponseSize — edge cases', () => { + it('should allow response exactly at maxResponseSize', async () => { + // /json body is small (<1000 bytes); set limit to its exact size + const probe = await httpRequest(`${httpBaseUrl}/json`) + const exactSize = probe.body.length + + const response = await httpRequest(`${httpBaseUrl}/json`, { + maxResponseSize: exactSize, + }) + expect(response.ok).toBe(true) + expect(response.body.length).toBe(exactSize) + }) + + it('should reject when maxResponseSize is 0', async () => { + // 0 is falsy so should be treated as "no limit" + const response = await httpRequest(`${httpBaseUrl}/json`, { + maxResponseSize: 0, + }) + expect(response.ok).toBe(true) + }) + + it('should enforce maxResponseSize on redirected response', async () => { + // /redirect -> /text (19 bytes "Plain text response") + await expect( + httpRequest(`${httpBaseUrl}/redirect`, { + maxResponseSize: 5, + }), + ).rejects.toThrow(/exceeds maximum size limit/) + }) + }) + + describe('rawResponse — edge cases', () => { + it('should have rawResponse after redirect', async () => { + const response = await httpRequest(`${httpBaseUrl}/redirect`) + expect(response.rawResponse).toBeDefined() + // rawResponse should be from the final response, not the redirect + expect(response.rawResponse!.statusCode).toBe(200) + }) + + it('should have rawResponse on server error', async () => { + const response = await httpRequest(`${httpBaseUrl}/server-error`) + expect(response.rawResponse).toBeDefined() + expect(response.rawResponse!.statusCode).toBe(500) + }) + }) + + describe('enriched error messages — integration', () => { + it('should include method and url in timeout errors', async () => { + try { + await httpRequest(`${httpBaseUrl}/timeout`, { timeout: 50 }) + expect.unreachable('should have thrown') + } catch (e) { + const msg = (e as Error).message + expect(msg).toContain('GET') + expect(msg).toContain('timed out') + expect(msg).toContain(`${httpBaseUrl}/timeout`) + } + }) + + it('should include method and url in connection errors', async () => { + try { + await httpRequest('http://localhost:1/no-server', { timeout: 100 }) + expect.unreachable('should have thrown') + } catch (e) { + const msg = (e as Error).message + expect(msg).toContain('request failed') + expect(msg).toContain('localhost:1') + } + }) + + it('should preserve cause chain on network errors', async () => { + try { + await httpRequest('http://localhost:1/no-server', { timeout: 100 }) + expect.unreachable('should have thrown') + } catch (e) { + expect((e as Error).cause).toBeDefined() + } + }) }) }) From 0aca420fceaffb08bffaa756e5d034ca7b9ba37c Mon Sep 17 00:00:00 2001 From: Test User Date: Fri, 3 Apr 2026 22:20:52 -0400 Subject: [PATCH 4/9] =?UTF-8?q?refactor(test):=20dedup=20http-request=20te?= =?UTF-8?q?sts=20while=20preserving=20coverage=20(141=20=E2=86=92=20123)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Merge duplicate test sections into single describe blocks. Combine tests that hit the same endpoint with identical setup. Use parameterized test for enrichErrorMessage error codes. All coverage preserved. --- test/unit/http-request.test.mts | 328 ++++++++++---------------------- 1 file changed, 96 insertions(+), 232 deletions(-) diff --git a/test/unit/http-request.test.mts b/test/unit/http-request.test.mts index 7098664..f8aafce 100644 --- a/test/unit/http-request.test.mts +++ b/test/unit/http-request.test.mts @@ -1817,10 +1817,12 @@ abc123def456789012345678901234567890123456789012345678901234abcd }) }) + describe('hooks', () => { - it('should call onRequest before the request', async () => { + it('should call onRequest with method, url, headers, and timeout', async () => { const requestInfos: HttpHookRequestInfo[] = [] await httpRequest(`${httpBaseUrl}/json`, { + headers: { 'X-Custom': 'test-value' }, hooks: { onRequest: info => requestInfos.push(info), }, @@ -1829,11 +1831,11 @@ abc123def456789012345678901234567890123456789012345678901234abcd expect(requestInfos[0]!.method).toBe('GET') expect(requestInfos[0]!.url).toBe(`${httpBaseUrl}/json`) expect(requestInfos[0]!.timeout).toBe(30_000) - expect(requestInfos[0]!.headers).toBeDefined() expect(requestInfos[0]!.headers['User-Agent']).toBe('socket-registry/1.0') + expect(requestInfos[0]!.headers['X-Custom']).toBe('test-value') }) - it('should call onResponse after a successful request', async () => { + it('should call onResponse with status, headers, and duration', async () => { const responseInfos: HttpHookResponseInfo[] = [] await httpRequest(`${httpBaseUrl}/json`, { hooks: { @@ -1847,9 +1849,10 @@ abc123def456789012345678901234567890123456789012345678901234abcd expect(responseInfos[0]!.statusText).toBe('OK') expect(responseInfos[0]!.duration).toBeGreaterThanOrEqual(0) expect(responseInfos[0]!.error).toBeUndefined() + expect(responseInfos[0]!.headers?.['content-type']).toContain('application/json') }) - it('should call onResponse with error on failure', async () => { + it('should call onResponse with error on timeout', async () => { const responseInfos: HttpHookResponseInfo[] = [] await httpRequest(`${httpBaseUrl}/timeout`, { timeout: 50, @@ -1900,7 +1903,7 @@ abc123def456789012345678901234567890123456789012345678901234abcd } }) - it('should fire hooks on redirect hops', async () => { + it('should fire hooks on redirect hops with correct status codes', async () => { const requestInfos: HttpHookRequestInfo[] = [] const responseInfos: HttpHookResponseInfo[] = [] @@ -1911,25 +1914,13 @@ abc123def456789012345678901234567890123456789012345678901234abcd }, }) - // redirect hop + final request = 2 onRequest, 2 onResponse expect(requestInfos).toHaveLength(2) expect(responseInfos).toHaveLength(2) expect(responseInfos[0]!.status).toBe(302) expect(responseInfos[1]!.status).toBe(200) }) - it('should pass custom headers through hooks', async () => { - const requestInfos: HttpHookRequestInfo[] = [] - await httpRequest(`${httpBaseUrl}/json`, { - headers: { 'X-Custom': 'test-value' }, - hooks: { - onRequest: info => requestInfos.push(info), - }, - }) - expect(requestInfos[0]!.headers['X-Custom']).toBe('test-value') - }) - - it('should include method in hook info for POST', async () => { + it('should report POST method in hook info', async () => { const requestInfos: HttpHookRequestInfo[] = [] await httpRequest(`${httpBaseUrl}/echo-body`, { method: 'POST', @@ -1940,48 +1931,83 @@ abc123def456789012345678901234567890123456789012345678901234abcd }) expect(requestInfos[0]!.method).toBe('POST') }) - }) - describe('maxResponseSize', () => { - it('should reject responses exceeding maxResponseSize', async () => { - await expect( - httpRequest(`${httpBaseUrl}/large-body`, { - maxResponseSize: 100, - }), - ).rejects.toThrow(/exceeds maximum size limit/) + it('should work with empty hooks object', async () => { + const response = await httpRequest(`${httpBaseUrl}/json`, { hooks: {} }) + expect(response.ok).toBe(true) }) - it('should allow responses within maxResponseSize', async () => { - const response = await httpRequest(`${httpBaseUrl}/json`, { - maxResponseSize: 1_000_000, + it('should pass hooks through httpJson and httpText', async () => { + const jsonInfos: HttpHookResponseInfo[] = [] + await httpJson(`${httpBaseUrl}/json`, { + hooks: { onResponse: info => jsonInfos.push(info) }, }) - expect(response.ok).toBe(true) + expect(jsonInfos).toHaveLength(1) + expect(jsonInfos[0]!.status).toBe(200) + + const textInfos: HttpHookResponseInfo[] = [] + await httpText(`${httpBaseUrl}/text`, { + hooks: { onResponse: info => textInfos.push(info) }, + }) + expect(textInfos).toHaveLength(1) + expect(textInfos[0]!.status).toBe(200) }) + }) - it('should include size info in the error message', async () => { + describe('maxResponseSize', () => { + it('should reject responses exceeding limit with size info', async () => { try { await httpRequest(`${httpBaseUrl}/large-body`, { maxResponseSize: 50, }) expect.unreachable('should have thrown') } catch (e) { - expect((e as Error).message).toMatch(/MB.*>.*MB/) + const msg = (e as Error).message + expect(msg).toMatch(/exceeds maximum size limit/) + expect(msg).toMatch(/MB.*>.*MB/) } }) - it('should work with httpJson', async () => { + it('should allow responses within limit', async () => { + const response = await httpRequest(`${httpBaseUrl}/json`, { + maxResponseSize: 1_000_000, + }) + expect(response.ok).toBe(true) + }) + + it('should allow response exactly at limit', async () => { + const probe = await httpRequest(`${httpBaseUrl}/json`) + const exactSize = probe.body.length + + const response = await httpRequest(`${httpBaseUrl}/json`, { + maxResponseSize: exactSize, + }) + expect(response.ok).toBe(true) + expect(response.body.length).toBe(exactSize) + }) + + it('should treat 0 as no limit', async () => { + const response = await httpRequest(`${httpBaseUrl}/json`, { + maxResponseSize: 0, + }) + expect(response.ok).toBe(true) + }) + + it('should enforce after redirect', async () => { await expect( - httpJson(`${httpBaseUrl}/json`, { + httpRequest(`${httpBaseUrl}/redirect`, { maxResponseSize: 5, }), ).rejects.toThrow(/exceeds maximum size limit/) }) - it('should work with httpText', async () => { + it('should work with httpJson and httpText', async () => { await expect( - httpText(`${httpBaseUrl}/text`, { - maxResponseSize: 5, - }), + httpJson(`${httpBaseUrl}/json`, { maxResponseSize: 5 }), + ).rejects.toThrow(/exceeds maximum size limit/) + + await expect( + httpText(`${httpBaseUrl}/text`, { maxResponseSize: 5 }), ).rejects.toThrow(/exceeds maximum size limit/) }) @@ -1994,7 +2020,6 @@ abc123def456789012345678901234567890123456789012345678901234abcd }, }).catch(() => {}) - // At least one hook call should contain the size limit error expect(responseInfos.length).toBeGreaterThanOrEqual(1) const sizeError = responseInfos.find(info => info.error?.message?.includes('exceeds maximum size limit'), @@ -2004,93 +2029,52 @@ abc123def456789012345678901234567890123456789012345678901234abcd }) describe('rawResponse', () => { - it('should expose rawResponse on HttpResponse', async () => { + it('should expose IncomingMessage with status and headers', async () => { const response = await httpRequest(`${httpBaseUrl}/json`) expect(response.rawResponse).toBeDefined() expect(response.rawResponse!.statusCode).toBe(200) + expect(response.rawResponse!.headers['content-type']).toContain('application/json') }) - it('should have headers on rawResponse', async () => { - const response = await httpRequest(`${httpBaseUrl}/json`) - expect(response.rawResponse!.headers['content-type']).toContain( - 'application/json', - ) + it('should be from final response after redirect', async () => { + const response = await httpRequest(`${httpBaseUrl}/redirect`) + expect(response.rawResponse).toBeDefined() + expect(response.rawResponse!.statusCode).toBe(200) }) it('should be available on non-2xx responses', async () => { - const response = await httpRequest(`${httpBaseUrl}/not-found`) - expect(response.rawResponse).toBeDefined() - expect(response.rawResponse!.statusCode).toBe(404) + const r404 = await httpRequest(`${httpBaseUrl}/not-found`) + expect(r404.rawResponse!.statusCode).toBe(404) + + const r500 = await httpRequest(`${httpBaseUrl}/server-error`) + expect(r500.rawResponse!.statusCode).toBe(500) }) }) describe('enrichErrorMessage', () => { - it('should enrich ECONNREFUSED', () => { - const err = Object.assign(new Error('connect failed'), { - code: 'ECONNREFUSED', - }) as NodeJS.ErrnoException - const msg = enrichErrorMessage('http://localhost:1', 'GET', err) - expect(msg).toContain('Connection refused') - expect(msg).toContain('GET request failed') - }) - - it('should enrich ENOTFOUND', () => { - const err = Object.assign(new Error('not found'), { - code: 'ENOTFOUND', - }) as NodeJS.ErrnoException - const msg = enrichErrorMessage('http://no-such-host.invalid', 'POST', err) - expect(msg).toContain('DNS lookup failed') - expect(msg).toContain('POST request failed') - }) - - it('should enrich ETIMEDOUT', () => { - const err = Object.assign(new Error('timed out'), { - code: 'ETIMEDOUT', - }) as NodeJS.ErrnoException - const msg = enrichErrorMessage('http://example.com', 'GET', err) - expect(msg).toContain('Connection timed out') - }) - - it('should enrich ECONNRESET', () => { - const err = Object.assign(new Error('reset'), { - code: 'ECONNRESET', - }) as NodeJS.ErrnoException - const msg = enrichErrorMessage('http://example.com', 'GET', err) - expect(msg).toContain('Connection reset') - }) - - it('should enrich EPIPE', () => { - const err = Object.assign(new Error('broken pipe'), { - code: 'EPIPE', - }) as NodeJS.ErrnoException - const msg = enrichErrorMessage('http://example.com', 'PUT', err) - expect(msg).toContain('Broken pipe') - expect(msg).toContain('PUT request failed') - }) - - it('should enrich CERT_HAS_EXPIRED', () => { - const err = Object.assign(new Error('cert expired'), { - code: 'CERT_HAS_EXPIRED', - }) as NodeJS.ErrnoException - const msg = enrichErrorMessage('https://expired.example.com', 'GET', err) - expect(msg).toContain('SSL/TLS certificate error') - }) - - it('should enrich UNABLE_TO_VERIFY_LEAF_SIGNATURE', () => { - const err = Object.assign(new Error('leaf sig'), { - code: 'UNABLE_TO_VERIFY_LEAF_SIGNATURE', - }) as NodeJS.ErrnoException - const msg = enrichErrorMessage('https://badcert.example.com', 'GET', err) - expect(msg).toContain('SSL/TLS certificate error') + it('should enrich each known error code', () => { + const cases: [string, string][] = [ + ['ECONNREFUSED', 'Connection refused'], + ['ENOTFOUND', 'DNS lookup failed'], + ['ETIMEDOUT', 'Connection timed out'], + ['ECONNRESET', 'Connection reset'], + ['EPIPE', 'Broken pipe'], + ['CERT_HAS_EXPIRED', 'SSL/TLS certificate error'], + ['UNABLE_TO_VERIFY_LEAF_SIGNATURE', 'SSL/TLS certificate error'], + ] + for (const [code, expected] of cases) { + const err = Object.assign(new Error('test'), { code }) as NodeJS.ErrnoException + const msg = enrichErrorMessage('http://example.com', 'GET', err) + expect(msg).toContain(expected) + } }) - it('should include error code for unknown codes', () => { - const err = Object.assign(new Error('something'), { - code: 'ESOMETHING', - }) as NodeJS.ErrnoException - const msg = enrichErrorMessage('http://example.com', 'DELETE', err) - expect(msg).toContain('Error code: ESOMETHING') + it('should include method, url, and error code in message', () => { + const err = Object.assign(new Error('fail'), { code: 'ESOMETHING' }) as NodeJS.ErrnoException + const msg = enrichErrorMessage('http://my-server:8080/api', 'DELETE', err) expect(msg).toContain('DELETE request failed') + expect(msg).toContain('http://my-server:8080/api') + expect(msg).toContain('Error code: ESOMETHING') }) it('should handle errors without a code', () => { @@ -2099,118 +2083,6 @@ abc123def456789012345678901234567890123456789012345678901234abcd expect(msg).toContain('GET request failed') expect(msg).not.toContain('Error code:') }) - - it('should include url in the message', () => { - const err = Object.assign(new Error('fail'), { code: 'ECONNREFUSED' }) as NodeJS.ErrnoException - const msg = enrichErrorMessage('http://my-server:8080/api', 'GET', err) - expect(msg).toContain('http://my-server:8080/api') - }) - }) - - describe('hooks — edge cases', () => { - it('should work with only onRequest (no onResponse)', async () => { - const infos: HttpHookRequestInfo[] = [] - await httpRequest(`${httpBaseUrl}/json`, { - hooks: { onRequest: info => infos.push(info) }, - }) - expect(infos).toHaveLength(1) - }) - - it('should work with only onResponse (no onRequest)', async () => { - const infos: HttpHookResponseInfo[] = [] - await httpRequest(`${httpBaseUrl}/json`, { - hooks: { onResponse: info => infos.push(info) }, - }) - expect(infos).toHaveLength(1) - expect(infos[0]!.status).toBe(200) - }) - - it('should work with empty hooks object', async () => { - const response = await httpRequest(`${httpBaseUrl}/json`, { hooks: {} }) - expect(response.ok).toBe(true) - }) - - it('should pass hooks through httpJson', async () => { - const infos: HttpHookResponseInfo[] = [] - await httpJson(`${httpBaseUrl}/json`, { - hooks: { onResponse: info => infos.push(info) }, - }) - expect(infos).toHaveLength(1) - expect(infos[0]!.status).toBe(200) - }) - - it('should pass hooks through httpText', async () => { - const infos: HttpHookResponseInfo[] = [] - await httpText(`${httpBaseUrl}/text`, { - hooks: { onResponse: info => infos.push(info) }, - }) - expect(infos).toHaveLength(1) - expect(infos[0]!.status).toBe(200) - }) - - it('should include response headers in onResponse', async () => { - const infos: HttpHookResponseInfo[] = [] - await httpRequest(`${httpBaseUrl}/json`, { - hooks: { onResponse: info => infos.push(info) }, - }) - expect(infos[0]!.headers).toBeDefined() - const ct = infos[0]!.headers?.['content-type'] - expect(ct).toContain('application/json') - }) - - it('should report non-zero duration in onResponse', async () => { - const infos: HttpHookResponseInfo[] = [] - await httpRequest(`${httpBaseUrl}/slow`, { - hooks: { onResponse: info => infos.push(info) }, - }) - expect(infos[0]!.duration).toBeGreaterThanOrEqual(0) - }) - }) - - describe('maxResponseSize — edge cases', () => { - it('should allow response exactly at maxResponseSize', async () => { - // /json body is small (<1000 bytes); set limit to its exact size - const probe = await httpRequest(`${httpBaseUrl}/json`) - const exactSize = probe.body.length - - const response = await httpRequest(`${httpBaseUrl}/json`, { - maxResponseSize: exactSize, - }) - expect(response.ok).toBe(true) - expect(response.body.length).toBe(exactSize) - }) - - it('should reject when maxResponseSize is 0', async () => { - // 0 is falsy so should be treated as "no limit" - const response = await httpRequest(`${httpBaseUrl}/json`, { - maxResponseSize: 0, - }) - expect(response.ok).toBe(true) - }) - - it('should enforce maxResponseSize on redirected response', async () => { - // /redirect -> /text (19 bytes "Plain text response") - await expect( - httpRequest(`${httpBaseUrl}/redirect`, { - maxResponseSize: 5, - }), - ).rejects.toThrow(/exceeds maximum size limit/) - }) - }) - - describe('rawResponse — edge cases', () => { - it('should have rawResponse after redirect', async () => { - const response = await httpRequest(`${httpBaseUrl}/redirect`) - expect(response.rawResponse).toBeDefined() - // rawResponse should be from the final response, not the redirect - expect(response.rawResponse!.statusCode).toBe(200) - }) - - it('should have rawResponse on server error', async () => { - const response = await httpRequest(`${httpBaseUrl}/server-error`) - expect(response.rawResponse).toBeDefined() - expect(response.rawResponse!.statusCode).toBe(500) - }) }) describe('enriched error messages — integration', () => { @@ -2226,7 +2098,7 @@ abc123def456789012345678901234567890123456789012345678901234abcd } }) - it('should include method and url in connection errors', async () => { + it('should include method, url, and cause chain on connection errors', async () => { try { await httpRequest('http://localhost:1/no-server', { timeout: 100 }) expect.unreachable('should have thrown') @@ -2234,14 +2106,6 @@ abc123def456789012345678901234567890123456789012345678901234abcd const msg = (e as Error).message expect(msg).toContain('request failed') expect(msg).toContain('localhost:1') - } - }) - - it('should preserve cause chain on network errors', async () => { - try { - await httpRequest('http://localhost:1/no-server', { timeout: 100 }) - expect.unreachable('should have thrown') - } catch (e) { expect((e as Error).cause).toBeDefined() } }) From 6943ccb1d33d90c6b0bb230cc0eaa3bd310e8b5c Mon Sep 17 00:00:00 2001 From: Test User Date: Fri, 3 Apr 2026 22:32:25 -0400 Subject: [PATCH 5/9] style: format http-request tests with oxfmt --- test/unit/http-request.test.mts | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/test/unit/http-request.test.mts b/test/unit/http-request.test.mts index f8aafce..af731ea 100644 --- a/test/unit/http-request.test.mts +++ b/test/unit/http-request.test.mts @@ -1817,7 +1817,6 @@ abc123def456789012345678901234567890123456789012345678901234abcd }) }) - describe('hooks', () => { it('should call onRequest with method, url, headers, and timeout', async () => { const requestInfos: HttpHookRequestInfo[] = [] @@ -1849,7 +1848,9 @@ abc123def456789012345678901234567890123456789012345678901234abcd expect(responseInfos[0]!.statusText).toBe('OK') expect(responseInfos[0]!.duration).toBeGreaterThanOrEqual(0) expect(responseInfos[0]!.error).toBeUndefined() - expect(responseInfos[0]!.headers?.['content-type']).toContain('application/json') + expect(responseInfos[0]!.headers?.['content-type']).toContain( + 'application/json', + ) }) it('should call onResponse with error on timeout', async () => { @@ -2033,7 +2034,9 @@ abc123def456789012345678901234567890123456789012345678901234abcd const response = await httpRequest(`${httpBaseUrl}/json`) expect(response.rawResponse).toBeDefined() expect(response.rawResponse!.statusCode).toBe(200) - expect(response.rawResponse!.headers['content-type']).toContain('application/json') + expect(response.rawResponse!.headers['content-type']).toContain( + 'application/json', + ) }) it('should be from final response after redirect', async () => { @@ -2063,14 +2066,18 @@ abc123def456789012345678901234567890123456789012345678901234abcd ['UNABLE_TO_VERIFY_LEAF_SIGNATURE', 'SSL/TLS certificate error'], ] for (const [code, expected] of cases) { - const err = Object.assign(new Error('test'), { code }) as NodeJS.ErrnoException + const err = Object.assign(new Error('test'), { + code, + }) as NodeJS.ErrnoException const msg = enrichErrorMessage('http://example.com', 'GET', err) expect(msg).toContain(expected) } }) it('should include method, url, and error code in message', () => { - const err = Object.assign(new Error('fail'), { code: 'ESOMETHING' }) as NodeJS.ErrnoException + const err = Object.assign(new Error('fail'), { + code: 'ESOMETHING', + }) as NodeJS.ErrnoException const msg = enrichErrorMessage('http://my-server:8080/api', 'DELETE', err) expect(msg).toContain('DELETE request failed') expect(msg).toContain('http://my-server:8080/api') From 43a165dc4fcc712c7fdfc79c177ca7313f250d8d Mon Sep 17 00:00:00 2001 From: Test User Date: Fri, 3 Apr 2026 22:37:46 -0400 Subject: [PATCH 6/9] fix: use Array instead of string[] in union types for oxlint array-simple rule --- src/http-request.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/http-request.ts b/src/http-request.ts index 19a4f8a..1d16ce0 100644 --- a/src/http-request.ts +++ b/src/http-request.ts @@ -90,7 +90,7 @@ export interface HttpHookRequestInfo { export interface HttpHookResponseInfo { duration: number error?: Error | undefined - headers?: Record | undefined + headers?: Record | string | undefined> | undefined method: string status?: number | undefined statusText?: string | undefined @@ -326,7 +326,7 @@ export interface HttpResponse { * console.log(response.headers['set-cookie']) // May be string[] * ``` */ - headers: Record + headers: Record | string | undefined> /** * Parse response body as JSON. * Type parameter `T` allows specifying the expected JSON structure. @@ -1050,7 +1050,7 @@ async function httpRequestAttempt( emitResponse({ headers: res.headers as Record< string, - string | string[] | undefined + Array | string | undefined >, status: res.statusCode, statusText: res.statusMessage, @@ -1131,7 +1131,7 @@ async function httpRequestAttempt( body: responseBody, headers: res.headers as Record< string, - string | string[] | undefined + Array | string | undefined >, json(): T { return JSON.parse(responseBody.toString('utf8')) as T @@ -1148,7 +1148,7 @@ async function httpRequestAttempt( emitResponse({ headers: res.headers as Record< string, - string | string[] | undefined + Array | string | undefined >, status: res.statusCode, statusText: res.statusMessage, From 6c6475d1096a1e56616097a493bb96b9369dd939 Mon Sep 17 00:00:00 2001 From: Test User Date: Fri, 3 Apr 2026 22:42:36 -0400 Subject: [PATCH 7/9] fix: use IncomingHttpHeaders to avoid oxfmt/oxlint array-type conflict --- src/http-request.ts | 21 ++++++--------------- 1 file changed, 6 insertions(+), 15 deletions(-) diff --git a/src/http-request.ts b/src/http-request.ts index 1d16ce0..6bd2820 100644 --- a/src/http-request.ts +++ b/src/http-request.ts @@ -30,7 +30,7 @@ function getFs() { return _fs as typeof import('node:fs') } -import type { IncomingMessage } from 'http' +import type { IncomingHttpHeaders, IncomingMessage } from 'http' import type { Logger } from './logger.js' @@ -90,7 +90,7 @@ export interface HttpHookRequestInfo { export interface HttpHookResponseInfo { duration: number error?: Error | undefined - headers?: Record | string | undefined> | undefined + headers?: IncomingHttpHeaders | undefined method: string status?: number | undefined statusText?: string | undefined @@ -326,7 +326,7 @@ export interface HttpResponse { * console.log(response.headers['set-cookie']) // May be string[] * ``` */ - headers: Record | string | undefined> + headers: IncomingHttpHeaders /** * Parse response body as JSON. * Type parameter `T` allows specifying the expected JSON structure. @@ -1048,10 +1048,7 @@ async function httpRequestAttempt( res.headers.location ) { emitResponse({ - headers: res.headers as Record< - string, - Array | string | undefined - >, + headers: res.headers, status: res.statusCode, statusText: res.statusMessage, }) @@ -1129,10 +1126,7 @@ async function httpRequestAttempt( ) }, body: responseBody, - headers: res.headers as Record< - string, - Array | string | undefined - >, + headers: res.headers, json(): T { return JSON.parse(responseBody.toString('utf8')) as T }, @@ -1146,10 +1140,7 @@ async function httpRequestAttempt( } emitResponse({ - headers: res.headers as Record< - string, - Array | string | undefined - >, + headers: res.headers, status: res.statusCode, statusText: res.statusMessage, }) From 97e068d7aa3849ce367b1b1a5c8dddd53ecae7d6 Mon Sep 17 00:00:00 2001 From: Test User Date: Fri, 3 Apr 2026 22:45:55 -0400 Subject: [PATCH 8/9] fix: use Array for non-simple types in tests (oxlint array-simple) --- test/unit/http-request.test.mts | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/test/unit/http-request.test.mts b/test/unit/http-request.test.mts index af731ea..9cfc5dd 100644 --- a/test/unit/http-request.test.mts +++ b/test/unit/http-request.test.mts @@ -1819,7 +1819,7 @@ abc123def456789012345678901234567890123456789012345678901234abcd describe('hooks', () => { it('should call onRequest with method, url, headers, and timeout', async () => { - const requestInfos: HttpHookRequestInfo[] = [] + const requestInfos: Array = [] await httpRequest(`${httpBaseUrl}/json`, { headers: { 'X-Custom': 'test-value' }, hooks: { @@ -1835,7 +1835,7 @@ abc123def456789012345678901234567890123456789012345678901234abcd }) it('should call onResponse with status, headers, and duration', async () => { - const responseInfos: HttpHookResponseInfo[] = [] + const responseInfos: Array = [] await httpRequest(`${httpBaseUrl}/json`, { hooks: { onResponse: info => responseInfos.push(info), @@ -1854,7 +1854,7 @@ abc123def456789012345678901234567890123456789012345678901234abcd }) it('should call onResponse with error on timeout', async () => { - const responseInfos: HttpHookResponseInfo[] = [] + const responseInfos: Array = [] await httpRequest(`${httpBaseUrl}/timeout`, { timeout: 50, hooks: { @@ -1866,8 +1866,8 @@ abc123def456789012345678901234567890123456789012345678901234abcd }) it('should fire hooks per-attempt on retries', async () => { - const requestInfos: HttpHookRequestInfo[] = [] - const responseInfos: HttpHookResponseInfo[] = [] + const requestInfos: Array = [] + const responseInfos: Array = [] let attemptCount = 0 const testServer = http.createServer((_req, _res) => { @@ -1905,8 +1905,8 @@ abc123def456789012345678901234567890123456789012345678901234abcd }) it('should fire hooks on redirect hops with correct status codes', async () => { - const requestInfos: HttpHookRequestInfo[] = [] - const responseInfos: HttpHookResponseInfo[] = [] + const requestInfos: Array = [] + const responseInfos: Array = [] await httpRequest(`${httpBaseUrl}/redirect`, { hooks: { @@ -1922,7 +1922,7 @@ abc123def456789012345678901234567890123456789012345678901234abcd }) it('should report POST method in hook info', async () => { - const requestInfos: HttpHookRequestInfo[] = [] + const requestInfos: Array = [] await httpRequest(`${httpBaseUrl}/echo-body`, { method: 'POST', body: 'test', @@ -1939,14 +1939,14 @@ abc123def456789012345678901234567890123456789012345678901234abcd }) it('should pass hooks through httpJson and httpText', async () => { - const jsonInfos: HttpHookResponseInfo[] = [] + const jsonInfos: Array = [] await httpJson(`${httpBaseUrl}/json`, { hooks: { onResponse: info => jsonInfos.push(info) }, }) expect(jsonInfos).toHaveLength(1) expect(jsonInfos[0]!.status).toBe(200) - const textInfos: HttpHookResponseInfo[] = [] + const textInfos: Array = [] await httpText(`${httpBaseUrl}/text`, { hooks: { onResponse: info => textInfos.push(info) }, }) @@ -2013,7 +2013,7 @@ abc123def456789012345678901234567890123456789012345678901234abcd }) it('should fire onResponse hook with error on size limit', async () => { - const responseInfos: HttpHookResponseInfo[] = [] + const responseInfos: Array = [] await httpRequest(`${httpBaseUrl}/large-body`, { maxResponseSize: 50, hooks: { @@ -2056,7 +2056,7 @@ abc123def456789012345678901234567890123456789012345678901234abcd describe('enrichErrorMessage', () => { it('should enrich each known error code', () => { - const cases: [string, string][] = [ + const cases: Array<[string, string]> = [ ['ECONNREFUSED', 'Connection refused'], ['ENOTFOUND', 'DNS lookup failed'], ['ETIMEDOUT', 'Connection timed out'], From 8263bf23924847aa25708032d319506c74692e73 Mon Sep 17 00:00:00 2001 From: Test User Date: Fri, 3 Apr 2026 22:49:11 -0400 Subject: [PATCH 9/9] =?UTF-8?q?fix:=20revert=20Array=20back=20to=20T[]?= =?UTF-8?q?=20=E2=80=94=20oxlint=20considers=20interfaces=20as=20simple=20?= =?UTF-8?q?types?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- test/unit/http-request.test.mts | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/test/unit/http-request.test.mts b/test/unit/http-request.test.mts index 9cfc5dd..604b080 100644 --- a/test/unit/http-request.test.mts +++ b/test/unit/http-request.test.mts @@ -1819,7 +1819,7 @@ abc123def456789012345678901234567890123456789012345678901234abcd describe('hooks', () => { it('should call onRequest with method, url, headers, and timeout', async () => { - const requestInfos: Array = [] + const requestInfos: HttpHookRequestInfo[] = [] await httpRequest(`${httpBaseUrl}/json`, { headers: { 'X-Custom': 'test-value' }, hooks: { @@ -1835,7 +1835,7 @@ abc123def456789012345678901234567890123456789012345678901234abcd }) it('should call onResponse with status, headers, and duration', async () => { - const responseInfos: Array = [] + const responseInfos: HttpHookResponseInfo[] = [] await httpRequest(`${httpBaseUrl}/json`, { hooks: { onResponse: info => responseInfos.push(info), @@ -1854,7 +1854,7 @@ abc123def456789012345678901234567890123456789012345678901234abcd }) it('should call onResponse with error on timeout', async () => { - const responseInfos: Array = [] + const responseInfos: HttpHookResponseInfo[] = [] await httpRequest(`${httpBaseUrl}/timeout`, { timeout: 50, hooks: { @@ -1866,8 +1866,8 @@ abc123def456789012345678901234567890123456789012345678901234abcd }) it('should fire hooks per-attempt on retries', async () => { - const requestInfos: Array = [] - const responseInfos: Array = [] + const requestInfos: HttpHookRequestInfo[] = [] + const responseInfos: HttpHookResponseInfo[] = [] let attemptCount = 0 const testServer = http.createServer((_req, _res) => { @@ -1905,8 +1905,8 @@ abc123def456789012345678901234567890123456789012345678901234abcd }) it('should fire hooks on redirect hops with correct status codes', async () => { - const requestInfos: Array = [] - const responseInfos: Array = [] + const requestInfos: HttpHookRequestInfo[] = [] + const responseInfos: HttpHookResponseInfo[] = [] await httpRequest(`${httpBaseUrl}/redirect`, { hooks: { @@ -1922,7 +1922,7 @@ abc123def456789012345678901234567890123456789012345678901234abcd }) it('should report POST method in hook info', async () => { - const requestInfos: Array = [] + const requestInfos: HttpHookRequestInfo[] = [] await httpRequest(`${httpBaseUrl}/echo-body`, { method: 'POST', body: 'test', @@ -1939,14 +1939,14 @@ abc123def456789012345678901234567890123456789012345678901234abcd }) it('should pass hooks through httpJson and httpText', async () => { - const jsonInfos: Array = [] + const jsonInfos: HttpHookResponseInfo[] = [] await httpJson(`${httpBaseUrl}/json`, { hooks: { onResponse: info => jsonInfos.push(info) }, }) expect(jsonInfos).toHaveLength(1) expect(jsonInfos[0]!.status).toBe(200) - const textInfos: Array = [] + const textInfos: HttpHookResponseInfo[] = [] await httpText(`${httpBaseUrl}/text`, { hooks: { onResponse: info => textInfos.push(info) }, }) @@ -2013,7 +2013,7 @@ abc123def456789012345678901234567890123456789012345678901234abcd }) it('should fire onResponse hook with error on size limit', async () => { - const responseInfos: Array = [] + const responseInfos: HttpHookResponseInfo[] = [] await httpRequest(`${httpBaseUrl}/large-body`, { maxResponseSize: 50, hooks: {