Skip to content

Commit 47b34f7

Browse files
committed
feat: implement configurable fetch utility with multiple backends
- Add ConfigurableFetch class supporting built-in, cache-memory, and cache-disk backends - Implement runtime configuration switching and per-request overrides - Support header merging, User-Agent configuration, and cache management - Include helper functions fetchJson() and fetchText() for common use cases - Add comprehensive test suite with 22 tests covering all functionality - Enable standardized HTTP request handling across MCP servers
1 parent ef4d32f commit 47b34f7

File tree

2 files changed

+669
-0
lines changed

2 files changed

+669
-0
lines changed

src/utils/fetch.test.ts

Lines changed: 326 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,326 @@
1+
/**
2+
* @fileoverview Tests for the configurable fetch utility
3+
* @module utils/fetch.test
4+
*/
5+
6+
import { beforeEach, describe, expect, it, vi } from 'vitest';
7+
import {
8+
ConfigurableFetch,
9+
FetchBackend,
10+
configurableFetch,
11+
createFetch,
12+
fetchJson,
13+
fetchText,
14+
} from './fetch.js';
15+
16+
// Mock node-fetch-cache
17+
vi.mock('node-fetch-cache', () => {
18+
const MockNodeFetchCache = vi.fn().mockImplementation(() => ({
19+
fetch: vi.fn(),
20+
clear: vi.fn(),
21+
}));
22+
return { default: MockNodeFetchCache };
23+
});
24+
25+
// Mock global fetch
26+
global.fetch = vi.fn();
27+
28+
describe('ConfigurableFetch', () => {
29+
beforeEach(() => {
30+
vi.clearAllMocks();
31+
});
32+
33+
describe('constructor and configuration', () => {
34+
it('should create instance with default configuration', () => {
35+
const fetch = new ConfigurableFetch();
36+
const config = fetch.getConfig();
37+
38+
expect(config.backend).toBe(FetchBackend.BUILT_IN);
39+
expect(config.cacheTtl).toBe(5 * 60 * 1000); // 5 minutes
40+
expect(config.cacheDir).toBe('.cache');
41+
expect(config.maxCacheSize).toBe(100 * 1024 * 1024); // 100MB
42+
expect(config.defaultHeaders).toEqual({});
43+
});
44+
45+
it('should create instance with custom configuration', () => {
46+
const customConfig = {
47+
backend: FetchBackend.CACHE_MEMORY,
48+
cacheTtl: 10000,
49+
userAgent: 'test-agent',
50+
defaultHeaders: { 'X-Test': 'header' },
51+
};
52+
53+
const fetch = new ConfigurableFetch(customConfig);
54+
const config = fetch.getConfig();
55+
56+
expect(config.backend).toBe(FetchBackend.CACHE_MEMORY);
57+
expect(config.cacheTtl).toBe(10000);
58+
expect(config.userAgent).toBe('test-agent');
59+
expect(config.defaultHeaders).toEqual({ 'X-Test': 'header' });
60+
});
61+
62+
it('should validate configuration with schema', () => {
63+
expect(() => {
64+
new ConfigurableFetch({
65+
backend: 'invalid-backend' as FetchBackend,
66+
});
67+
}).toThrow();
68+
});
69+
});
70+
71+
describe('updateConfig', () => {
72+
it('should update configuration', () => {
73+
const fetch = new ConfigurableFetch();
74+
75+
fetch.updateConfig({
76+
backend: FetchBackend.CACHE_DISK,
77+
userAgent: 'updated-agent',
78+
});
79+
80+
const config = fetch.getConfig();
81+
expect(config.backend).toBe(FetchBackend.CACHE_DISK);
82+
expect(config.userAgent).toBe('updated-agent');
83+
});
84+
});
85+
86+
describe('fetch with built-in backend', () => {
87+
it('should use built-in fetch', async () => {
88+
const mockResponse = new Response('test', { status: 200 });
89+
vi.mocked(global.fetch).mockResolvedValueOnce(mockResponse);
90+
91+
const fetch = new ConfigurableFetch({ backend: FetchBackend.BUILT_IN });
92+
const result = await fetch.fetch('https://example.com');
93+
94+
expect(global.fetch).toHaveBeenCalledWith('https://example.com', {
95+
headers: {},
96+
});
97+
expect(result).toBeDefined();
98+
});
99+
100+
it('should merge headers correctly', async () => {
101+
const mockResponse = new Response('test', { status: 200 });
102+
vi.mocked(global.fetch).mockResolvedValueOnce(mockResponse);
103+
104+
const fetch = new ConfigurableFetch({
105+
backend: FetchBackend.BUILT_IN,
106+
userAgent: 'test-agent',
107+
defaultHeaders: { 'X-Default': 'value' },
108+
});
109+
110+
await fetch.fetch('https://example.com', {
111+
headers: { 'X-Custom': 'custom' },
112+
});
113+
114+
expect(global.fetch).toHaveBeenCalledWith('https://example.com', {
115+
headers: {
116+
'X-Default': 'value',
117+
'User-Agent': 'test-agent',
118+
'X-Custom': 'custom',
119+
},
120+
});
121+
});
122+
123+
it('should handle Headers object', async () => {
124+
const mockResponse = new Response('test', { status: 200 });
125+
vi.mocked(global.fetch).mockResolvedValueOnce(mockResponse);
126+
127+
const fetch = new ConfigurableFetch({ backend: FetchBackend.BUILT_IN });
128+
const headers = new Headers();
129+
headers.set('X-Test', 'value');
130+
131+
await fetch.fetch('https://example.com', { headers });
132+
133+
expect(global.fetch).toHaveBeenCalledWith('https://example.com', {
134+
headers: {
135+
'x-test': 'value', // Headers are normalized to lowercase
136+
},
137+
});
138+
});
139+
140+
it('should handle array headers', async () => {
141+
const mockResponse = new Response('test', { status: 200 });
142+
vi.mocked(global.fetch).mockResolvedValueOnce(mockResponse);
143+
144+
const fetch = new ConfigurableFetch({ backend: FetchBackend.BUILT_IN });
145+
const headers: [string, string][] = [['X-Test', 'value']];
146+
147+
await fetch.fetch('https://example.com', { headers });
148+
149+
expect(global.fetch).toHaveBeenCalledWith('https://example.com', {
150+
headers: {
151+
'X-Test': 'value', // Array headers maintain original case
152+
},
153+
});
154+
});
155+
});
156+
157+
describe('fetch with per-request overrides', () => {
158+
it('should override backend per request', async () => {
159+
const mockResponse = new Response('test', { status: 200 });
160+
vi.mocked(global.fetch).mockResolvedValueOnce(mockResponse);
161+
162+
const fetch = new ConfigurableFetch({ backend: FetchBackend.CACHE_MEMORY });
163+
164+
// Override to use built-in fetch
165+
await fetch.fetch('https://example.com', {
166+
backend: FetchBackend.BUILT_IN,
167+
});
168+
169+
expect(global.fetch).toHaveBeenCalled();
170+
});
171+
172+
it('should handle noCache option', async () => {
173+
const mockResponse = new Response('test', { status: 200 });
174+
vi.mocked(global.fetch).mockResolvedValueOnce(mockResponse);
175+
176+
const fetch = new ConfigurableFetch({ backend: FetchBackend.CACHE_MEMORY });
177+
178+
// noCache should force built-in fetch even with cache backend
179+
await fetch.fetch('https://example.com', { noCache: true });
180+
181+
expect(global.fetch).toHaveBeenCalled();
182+
});
183+
});
184+
185+
describe('error handling', () => {
186+
it('should handle fetch errors', async () => {
187+
vi.mocked(global.fetch).mockRejectedValueOnce(new Error('Network error'));
188+
189+
const fetch = new ConfigurableFetch({ backend: FetchBackend.BUILT_IN });
190+
191+
await expect(fetch.fetch('https://example.com')).rejects.toThrow(
192+
'Fetch failed (backend: built-in): Network error',
193+
);
194+
});
195+
196+
it('should throw error for unsupported backend', async () => {
197+
const fetch = new ConfigurableFetch();
198+
199+
await expect(
200+
fetch.fetch('https://example.com', {
201+
backend: 'unsupported' as FetchBackend,
202+
}),
203+
).rejects.toThrow('Unsupported fetch backend: unsupported');
204+
});
205+
});
206+
207+
describe('cache operations', () => {
208+
it('should clear caches', async () => {
209+
const fetch = new ConfigurableFetch();
210+
211+
// Should not throw
212+
await expect(fetch.clearCaches()).resolves.toBeUndefined();
213+
});
214+
215+
it('should get cache stats', () => {
216+
const fetch = new ConfigurableFetch();
217+
const stats = fetch.getCacheStats();
218+
219+
expect(stats).toBeTypeOf('object');
220+
expect(stats.memory).toBeDefined();
221+
expect(stats.disk).toBeDefined();
222+
});
223+
});
224+
});
225+
226+
describe('global configurableFetch', () => {
227+
beforeEach(() => {
228+
vi.clearAllMocks();
229+
// Reset global instance to default config
230+
configurableFetch.updateConfig({ backend: FetchBackend.BUILT_IN });
231+
});
232+
233+
it('should be accessible globally', () => {
234+
expect(configurableFetch).toBeInstanceOf(ConfigurableFetch);
235+
});
236+
237+
it('should work as drop-in fetch replacement', async () => {
238+
const mockResponse = new Response('test', { status: 200 });
239+
vi.mocked(global.fetch).mockResolvedValueOnce(mockResponse);
240+
241+
await configurableFetch.fetch('https://example.com');
242+
243+
expect(global.fetch).toHaveBeenCalledWith('https://example.com', {
244+
headers: {},
245+
});
246+
});
247+
});
248+
249+
describe('createFetch', () => {
250+
it('should create new instance with configuration', () => {
251+
const fetch = createFetch({
252+
backend: FetchBackend.CACHE_DISK,
253+
userAgent: 'custom-agent',
254+
});
255+
256+
const config = fetch.getConfig();
257+
expect(config.backend).toBe(FetchBackend.CACHE_DISK);
258+
expect(config.userAgent).toBe('custom-agent');
259+
});
260+
});
261+
262+
describe('fetchJson', () => {
263+
beforeEach(() => {
264+
vi.clearAllMocks();
265+
configurableFetch.updateConfig({ backend: FetchBackend.BUILT_IN });
266+
});
267+
268+
it('should fetch and parse JSON', async () => {
269+
const mockData = { message: 'test' };
270+
const mockResponse = new Response(JSON.stringify(mockData), {
271+
status: 200,
272+
headers: { 'Content-Type': 'application/json' },
273+
});
274+
vi.mocked(global.fetch).mockResolvedValueOnce(mockResponse);
275+
276+
const result = await fetchJson('https://api.example.com/data');
277+
278+
expect(global.fetch).toHaveBeenCalledWith('https://api.example.com/data', {
279+
headers: {
280+
Accept: 'application/json',
281+
},
282+
});
283+
expect(result).toEqual(mockData);
284+
});
285+
286+
it('should throw on HTTP error', async () => {
287+
const mockResponse = new Response('Not found', { status: 404 });
288+
vi.mocked(global.fetch).mockResolvedValueOnce(mockResponse);
289+
290+
await expect(fetchJson('https://api.example.com/data')).rejects.toThrow(
291+
'HTTP error! status: 404',
292+
);
293+
});
294+
});
295+
296+
describe('fetchText', () => {
297+
beforeEach(() => {
298+
vi.clearAllMocks();
299+
configurableFetch.updateConfig({ backend: FetchBackend.BUILT_IN });
300+
});
301+
302+
it('should fetch and return text', async () => {
303+
const mockText = 'Hello, world!';
304+
const mockResponse = new Response(mockText, { status: 200 });
305+
vi.mocked(global.fetch).mockResolvedValueOnce(mockResponse);
306+
307+
const result = await fetchText('https://example.com');
308+
309+
expect(result).toBe(mockText);
310+
});
311+
312+
it('should throw on HTTP error', async () => {
313+
const mockResponse = new Response('Not found', { status: 404 });
314+
vi.mocked(global.fetch).mockResolvedValueOnce(mockResponse);
315+
316+
await expect(fetchText('https://example.com')).rejects.toThrow('HTTP error! status: 404');
317+
});
318+
});
319+
320+
describe('FetchBackend enum', () => {
321+
it('should have expected values', () => {
322+
expect(FetchBackend.BUILT_IN).toBe('built-in');
323+
expect(FetchBackend.CACHE_MEMORY).toBe('cache-memory');
324+
expect(FetchBackend.CACHE_DISK).toBe('cache-disk');
325+
});
326+
});

0 commit comments

Comments
 (0)