Files
ccs/tests/mocks/mock-fetch.ts
T
kaitranntt 2b91c40e37 fix(tests): address edge cases in mock infrastructure
- Prevent spy leak on double mockFetch() call
- Differentiate body types (URLSearchParams, ArrayBuffer, Blob, ReadableStream)
- Fix route path parsing to preserve spaces
- Escape regex special chars before wildcard replacement
- Remove unused MockResponse.error and MockRoute interface
2026-01-23 23:41:49 -05:00

180 lines
5.0 KiB
TypeScript

/**
* Mock Fetch Utilities
*
* Intercept global fetch() for testing without network operations.
* Uses Bun's native spyOn for reliable mocking.
*/
import { spyOn, type Mock } from 'bun:test';
import type { MockFetchHandler, CapturedRequest } from './types';
/** Store for original fetch and mock state */
let originalFetch: typeof fetch | null = null;
let mockInstance: Mock<typeof fetch> | null = null;
let capturedRequests: CapturedRequest[] = [];
/**
* Install mock fetch handlers
*
* @example
* beforeEach(() => {
* mockFetch([
* { url: /\/health/, response: { ok: true } },
* { url: /\/upload/, response: { id: '123' }, status: 201 },
* ]);
* });
*
* afterEach(() => restoreFetch());
*/
export function mockFetch(handlers: MockFetchHandler[]): void {
// Restore previous mock if exists (prevents spy leak on double-call)
if (mockInstance) {
mockInstance.mockRestore();
mockInstance = null;
}
// Store original if not already stored
if (!originalFetch) {
originalFetch = globalThis.fetch;
}
// Clear previous captures
capturedRequests = [];
// Create mock implementation
mockInstance = spyOn(globalThis, 'fetch').mockImplementation(
async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
const method = init?.method ?? 'GET';
// Capture request
const captured: CapturedRequest = {
url,
method,
headers: {},
body: null,
};
// Extract headers
if (init?.headers) {
if (init.headers instanceof Headers) {
init.headers.forEach((value, key) => {
captured.headers[key] = value;
});
} else if (Array.isArray(init.headers)) {
for (const [key, value] of init.headers) {
captured.headers[key] = value;
}
} else {
captured.headers = { ...init.headers };
}
}
// Extract body with type differentiation
if (init?.body) {
if (typeof init.body === 'string') {
captured.body = init.body;
} else if (init.body instanceof FormData) {
captured.body = '[FormData]';
} else if (init.body instanceof URLSearchParams) {
captured.body = '[URLSearchParams]';
} else if (init.body instanceof ArrayBuffer || ArrayBuffer.isView(init.body)) {
captured.body = '[ArrayBuffer]';
} else if (init.body instanceof Blob) {
captured.body = '[Blob]';
} else if (typeof init.body === 'object' && 'getReader' in init.body) {
captured.body = '[ReadableStream]';
} else {
captured.body = '[Binary]';
}
}
capturedRequests.push(captured);
// Find matching handler
for (const handler of handlers) {
// Check method match
if (handler.method && handler.method !== method) continue;
// Check URL match
const urlMatches =
typeof handler.url === 'string' ? url === handler.url : handler.url.test(url);
if (!urlMatches) continue;
// Call onRequest callback if provided
if (handler.onRequest && input instanceof Request) {
handler.onRequest(input);
}
// Apply delay if specified
if (handler.delay) {
await new Promise((resolve) => setTimeout(resolve, handler.delay));
}
// Build response
const status = handler.status ?? 200;
const headers = new Headers(handler.headers ?? {});
let body: string | null = null;
if (handler.response !== null && handler.response !== undefined) {
if (typeof handler.response === 'object') {
body = JSON.stringify(handler.response);
if (!headers.has('Content-Type')) {
headers.set('Content-Type', 'application/json');
}
} else {
body = String(handler.response);
}
}
return new Response(body, { status, headers });
}
// No handler matched - return 404
return new Response(JSON.stringify({ error: 'No mock handler matched', url, method }), {
status: 404,
headers: { 'Content-Type': 'application/json' },
});
}
);
}
/**
* Restore original fetch after tests
* MUST be called in afterEach to prevent test pollution
*/
export function restoreFetch(): void {
if (mockInstance) {
mockInstance.mockRestore();
mockInstance = null;
}
if (originalFetch) {
globalThis.fetch = originalFetch;
originalFetch = null;
}
capturedRequests = [];
}
/**
* Get all requests captured during mock
* Useful for asserting request parameters
*/
export function getCapturedFetchRequests(): CapturedRequest[] {
return [...capturedRequests];
}
/**
* Clear captured requests without restoring fetch
*/
export function clearCapturedFetchRequests(): void {
capturedRequests = [];
}
/**
* Check if fetch is currently mocked
*/
export function isFetchMocked(): boolean {
return mockInstance !== null;
}