feat: Implement comprehensive web request handling with caching, retry, and interceptors
- Added cache strategies: NetworkFirst, CacheFirst, StaleWhileRevalidate, NetworkOnly, and CacheOnly. - Introduced InterceptorManager for managing request, response, and error interceptors. - Developed RetryManager for handling request retries with customizable backoff strategies. - Implemented RequestDeduplicator to prevent simultaneous identical requests. - Created timeout utilities for handling request timeouts. - Enhanced WebrequestClient to support global interceptors, caching, and retry logic. - Added convenience methods for common HTTP methods (GET, POST, PUT, DELETE) with JSON handling. - Established a fetch-compatible webrequest function for seamless integration. - Defined core type structures for caching, retry options, interceptors, and web request configurations.
This commit is contained in:
		
							
								
								
									
										312
									
								
								test/test.v4.node.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										312
									
								
								test/test.v4.node.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,312 @@ | ||||
| import { expect, tap } from '@git.zone/tstest/tapbundle'; | ||||
| import { webrequest, WebrequestClient } from '../ts/index.js'; | ||||
| import * as typedserver from '@api.global/typedserver'; | ||||
|  | ||||
| let testServer: typedserver.servertools.Server; | ||||
|  | ||||
| // Setup test server | ||||
| tap.test('setup test server for v4 tests', async () => { | ||||
|   testServer = new typedserver.servertools.Server({ | ||||
|     cors: false, | ||||
|     forceSsl: false, | ||||
|     port: 2346, | ||||
|   }); | ||||
|  | ||||
|   // Route that returns JSON with cache headers | ||||
|   testServer.addRoute( | ||||
|     '/cached', | ||||
|     new typedserver.servertools.Handler('GET', (req, res) => { | ||||
|       res.setHeader('Cache-Control', 'max-age=60'); | ||||
|       res.setHeader('ETag', '"12345"'); | ||||
|       res.status(200); | ||||
|       res.send({ data: 'cached response', timestamp: Date.now() }); | ||||
|     }), | ||||
|   ); | ||||
|  | ||||
|   // Route that returns different data each time | ||||
|   testServer.addRoute( | ||||
|     '/dynamic', | ||||
|     new typedserver.servertools.Handler('GET', (req, res) => { | ||||
|       res.status(200); | ||||
|       res.send({ data: 'dynamic response', timestamp: Date.now() }); | ||||
|     }), | ||||
|   ); | ||||
|  | ||||
|   // Route that sometimes fails (for retry testing) | ||||
|   let requestCount = 0; | ||||
|   testServer.addRoute( | ||||
|     '/flaky', | ||||
|     new typedserver.servertools.Handler('GET', (req, res) => { | ||||
|       requestCount++; | ||||
|       if (requestCount < 3) { | ||||
|         res.status(500); | ||||
|         res.end(); | ||||
|       } else { | ||||
|         res.status(200); | ||||
|         res.send({ success: true, attempts: requestCount }); | ||||
|       } | ||||
|     }), | ||||
|   ); | ||||
|  | ||||
|   // Route that returns 304 when ETag matches | ||||
|   testServer.addRoute( | ||||
|     '/conditional', | ||||
|     new typedserver.servertools.Handler('GET', (req, res) => { | ||||
|       const ifNoneMatch = req.headers['if-none-match']; | ||||
|       if (ifNoneMatch === '"67890"') { | ||||
|         res.status(304); | ||||
|         res.end(); | ||||
|       } else { | ||||
|         res.setHeader('ETag', '"67890"'); | ||||
|         res.status(200); | ||||
|         res.send({ data: 'conditional response' }); | ||||
|       } | ||||
|     }), | ||||
|   ); | ||||
|  | ||||
|   // POST route for testing | ||||
|   testServer.addRoute( | ||||
|     '/post', | ||||
|     new typedserver.servertools.Handler('POST', (req, res) => { | ||||
|       res.status(200); | ||||
|       res.send({ received: true }); | ||||
|     }), | ||||
|   ); | ||||
|  | ||||
|   await testServer.start(); | ||||
| }); | ||||
|  | ||||
| // Test 1: Basic fetch-compatible API | ||||
| tap.test('should work as fetch replacement', async () => { | ||||
|   const response = await webrequest('http://localhost:2346/dynamic'); | ||||
|   expect(response).toBeInstanceOf(Response); | ||||
|   expect(response.ok).toEqual(true); | ||||
|   expect(response.status).toEqual(200); | ||||
|  | ||||
|   const data = await response.json(); | ||||
|   expect(data).toHaveProperty('data'); | ||||
|   expect(data.data).toEqual('dynamic response'); | ||||
| }); | ||||
|  | ||||
| // Test 2: getJson convenience method with generics | ||||
| tap.test('should support getJson with type safety', async () => { | ||||
|   interface TestData { | ||||
|     data: string; | ||||
|     timestamp: number; | ||||
|   } | ||||
|  | ||||
|   const data = await webrequest.getJson<TestData>('http://localhost:2346/dynamic'); | ||||
|   expect(data).toHaveProperty('data'); | ||||
|   expect(data).toHaveProperty('timestamp'); | ||||
|   expect(typeof data.timestamp).toEqual('number'); | ||||
| }); | ||||
|  | ||||
| // Test 3: POST with JSON | ||||
| tap.test('should support postJson', async () => { | ||||
|   const data = await webrequest.postJson('http://localhost:2346/post', { | ||||
|     test: 'data' | ||||
|   }); | ||||
|   expect(data).toHaveProperty('received'); | ||||
|   expect(data.received).toEqual(true); | ||||
| }); | ||||
|  | ||||
| // Test 4: Cache strategy - network-first | ||||
| tap.test('should support network-first cache strategy', async () => { | ||||
|   const response1 = await webrequest('http://localhost:2346/cached', { | ||||
|     cacheStrategy: 'network-first' | ||||
|   }); | ||||
|   const data1 = await response1.json(); | ||||
|   expect(data1).toHaveProperty('timestamp'); | ||||
|  | ||||
|   // Second request should hit network but may use cache on error | ||||
|   const response2 = await webrequest('http://localhost:2346/cached', { | ||||
|     cacheStrategy: 'network-first' | ||||
|   }); | ||||
|   const data2 = await response2.json(); | ||||
|   expect(data2).toHaveProperty('timestamp'); | ||||
| }); | ||||
|  | ||||
| // Test 5: Cache strategy - cache-first | ||||
| tap.test('should support cache-first strategy', async () => { | ||||
|   // First request goes to network | ||||
|   const response1 = await webrequest('http://localhost:2346/cached', { | ||||
|     cacheStrategy: 'cache-first' | ||||
|   }); | ||||
|   const data1 = await response1.json(); | ||||
|   const timestamp1 = data1.timestamp; | ||||
|  | ||||
|   // Second request should use cache | ||||
|   const response2 = await webrequest('http://localhost:2346/cached', { | ||||
|     cacheStrategy: 'cache-first' | ||||
|   }); | ||||
|   const data2 = await response2.json(); | ||||
|  | ||||
|   // Timestamps should be identical if cached | ||||
|   expect(data2.timestamp).toEqual(timestamp1); | ||||
| }); | ||||
|  | ||||
| // Test 6: Retry system | ||||
| tap.test('should retry failed requests', async () => { | ||||
|   const response = await webrequest('http://localhost:2346/flaky', { | ||||
|     retry: { | ||||
|       maxAttempts: 3, | ||||
|       backoff: 'constant', | ||||
|       initialDelay: 100 | ||||
|     } | ||||
|   }); | ||||
|  | ||||
|   const data = await response.json(); | ||||
|   expect(data.success).toEqual(true); | ||||
|   expect(data.attempts).toBeGreaterThanOrEqual(3); | ||||
| }); | ||||
|  | ||||
| // Test 7: Fallback URLs | ||||
| tap.test('should support fallback URLs', async () => { | ||||
|   const response = await webrequest('http://localhost:2346/nonexistent', { | ||||
|     fallbackUrls: ['http://localhost:2346/dynamic'], | ||||
|     retry: { | ||||
|       maxAttempts: 2 | ||||
|     } | ||||
|   }); | ||||
|  | ||||
|   expect(response.ok).toEqual(true); | ||||
|   const data = await response.json(); | ||||
|   expect(data).toHaveProperty('data'); | ||||
| }); | ||||
|  | ||||
| // Test 8: Request interceptors | ||||
| tap.test('should support request interceptors', async () => { | ||||
|   let interceptorCalled = false; | ||||
|  | ||||
|   const response = await webrequest('http://localhost:2346/dynamic', { | ||||
|     interceptors: { | ||||
|       request: [(req) => { | ||||
|         interceptorCalled = true; | ||||
|         const headers = new Headers(req.headers); | ||||
|         headers.set('X-Custom-Header', 'test'); | ||||
|         return new Request(req, { headers }); | ||||
|       }] | ||||
|     } | ||||
|   }); | ||||
|  | ||||
|   expect(interceptorCalled).toEqual(true); | ||||
|   expect(response.ok).toEqual(true); | ||||
| }); | ||||
|  | ||||
| // Test 9: Response interceptors | ||||
| tap.test('should support response interceptors', async () => { | ||||
|   let interceptorCalled = false; | ||||
|   let capturedStatus: number; | ||||
|  | ||||
|   const response = await webrequest('http://localhost:2346/dynamic', { | ||||
|     interceptors: { | ||||
|       response: [(res) => { | ||||
|         interceptorCalled = true; | ||||
|         capturedStatus = res.status; | ||||
|         return res; | ||||
|       }] | ||||
|     } | ||||
|   }); | ||||
|  | ||||
|   expect(interceptorCalled).toEqual(true); | ||||
|   expect(capturedStatus!).toEqual(200); | ||||
| }); | ||||
|  | ||||
| // Test 10: Global interceptors with WebrequestClient | ||||
| tap.test('should support global interceptors', async () => { | ||||
|   const client = new WebrequestClient(); | ||||
|  | ||||
|   let globalInterceptorCalled = false; | ||||
|   client.addRequestInterceptor((req) => { | ||||
|     globalInterceptorCalled = true; | ||||
|     return req; | ||||
|   }); | ||||
|  | ||||
|   await client.request('http://localhost:2346/dynamic'); | ||||
|   expect(globalInterceptorCalled).toEqual(true); | ||||
| }); | ||||
|  | ||||
| // Test 11: Request deduplication | ||||
| tap.test('should deduplicate simultaneous requests', async () => { | ||||
|   const start = Date.now(); | ||||
|  | ||||
|   // Make 3 identical requests simultaneously | ||||
|   const [res1, res2, res3] = await Promise.all([ | ||||
|     webrequest('http://localhost:2346/dynamic', { deduplicate: true }), | ||||
|     webrequest('http://localhost:2346/dynamic', { deduplicate: true }), | ||||
|     webrequest('http://localhost:2346/dynamic', { deduplicate: true }), | ||||
|   ]); | ||||
|  | ||||
|   const [data1, data2, data3] = await Promise.all([ | ||||
|     res1.json(), | ||||
|     res2.json(), | ||||
|     res3.json(), | ||||
|   ]); | ||||
|  | ||||
|   // All should have the same timestamp (same response) | ||||
|   expect(data1.timestamp).toEqual(data2.timestamp); | ||||
|   expect(data2.timestamp).toEqual(data3.timestamp); | ||||
| }); | ||||
|  | ||||
| // Test 12: Timeout | ||||
| tap.test('should support timeout', async () => { | ||||
|   try { | ||||
|     await webrequest('http://localhost:2346/dynamic', { | ||||
|       timeout: 1 // 1ms timeout should fail | ||||
|     }); | ||||
|     throw new Error('Should have timed out'); | ||||
|   } catch (error) { | ||||
|     expect(error.message).toContain('timeout'); | ||||
|   } | ||||
| }); | ||||
|  | ||||
| // Test 13: WebrequestClient with default options | ||||
| tap.test('should support WebrequestClient with defaults', async () => { | ||||
|   const client = new WebrequestClient({ | ||||
|     logging: false, | ||||
|     cacheStrategy: 'network-first', | ||||
|     timeout: 30000 | ||||
|   }); | ||||
|  | ||||
|   const data = await client.getJson('http://localhost:2346/dynamic'); | ||||
|   expect(data).toHaveProperty('data'); | ||||
| }); | ||||
|  | ||||
| // Test 14: Clear cache | ||||
| tap.test('should clear cache', async () => { | ||||
|   // Cache a request | ||||
|   await webrequest('http://localhost:2346/cached', { | ||||
|     cacheStrategy: 'cache-first' | ||||
|   }); | ||||
|  | ||||
|   // Clear cache | ||||
|   await webrequest.clearCache(); | ||||
|  | ||||
|   // This should work even though cache is cleared | ||||
|   const response = await webrequest('http://localhost:2346/cached', { | ||||
|     cacheStrategy: 'cache-first' | ||||
|   }); | ||||
|   expect(response.ok).toEqual(true); | ||||
| }); | ||||
|  | ||||
| // Test 15: Backward compatibility with WebRequest class | ||||
| tap.test('should maintain backward compatibility with v3 API', async () => { | ||||
|   const { WebRequest } = await import('../ts/index.js'); | ||||
|   const client = new WebRequest({ logging: false }); | ||||
|  | ||||
|   const data = await client.getJson('http://localhost:2346/dynamic'); | ||||
|   expect(data).toHaveProperty('data'); | ||||
|  | ||||
|   // Test POST | ||||
|   const postData = await client.postJson('http://localhost:2346/post', { | ||||
|     test: 'data' | ||||
|   }); | ||||
|   expect(postData).toHaveProperty('received'); | ||||
| }); | ||||
|  | ||||
| // Cleanup | ||||
| tap.test('stop test server', async () => { | ||||
|   await testServer.stop(); | ||||
| }); | ||||
|  | ||||
| export default tap.start(); | ||||
		Reference in New Issue
	
	Block a user