- 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.
		
			
				
	
	
		
			313 lines
		
	
	
		
			8.8 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
			
		
		
	
	
			313 lines
		
	
	
		
			8.8 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
| 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();
 |