/ tests / utils / error-handler.test.js
error-handler.test.js
  1  /**
  2   * Tests for Error Handler Module
  3   */
  4  
  5  import { test, describe } from 'node:test';
  6  import assert from 'node:assert';
  7  import {
  8    retryWithBackoff,
  9    isRetryableError,
 10    withTimeout,
 11    sleep,
 12    safeJsonParse,
 13    extractDomain,
 14    processBatch,
 15  } from '../../src/utils/error-handler.js';
 16  
 17  describe('Error Handler Module', () => {
 18    describe('sleep', () => {
 19      test('should resolve after specified delay', async () => {
 20        const start = Date.now();
 21        await sleep(100);
 22        const elapsed = Date.now() - start;
 23        assert.ok(elapsed >= 95, 'Should wait at least 95ms'); // Allow small margin
 24        assert.ok(elapsed < 200, 'Should not wait too long');
 25      });
 26    });
 27  
 28    describe('isRetryableError', () => {
 29      test('should identify network errors as retryable', () => {
 30        assert.strictEqual(isRetryableError(new Error('ENOTFOUND')), true);
 31        assert.strictEqual(isRetryableError(new Error('ECONNRESET')), true);
 32        assert.strictEqual(isRetryableError(new Error('ETIMEDOUT')), true);
 33        assert.strictEqual(isRetryableError(new Error('ECONNREFUSED')), true);
 34      });
 35  
 36      test('should identify rate limit errors as retryable', () => {
 37        assert.strictEqual(isRetryableError(new Error('rate limit exceeded')), true);
 38        assert.strictEqual(isRetryableError(new Error('too many requests')), true);
 39        assert.strictEqual(isRetryableError(new Error('HTTP 429')), true);
 40      });
 41  
 42      test('should identify server errors as retryable', () => {
 43        assert.strictEqual(isRetryableError(new Error('HTTP 503 Service Unavailable')), true);
 44        assert.strictEqual(isRetryableError(new Error('HTTP 504 Gateway Timeout')), true);
 45      });
 46  
 47      test('should not identify other errors as retryable', () => {
 48        assert.strictEqual(isRetryableError(new Error('Invalid input')), false);
 49        assert.strictEqual(isRetryableError(new Error('HTTP 404 Not Found')), false);
 50        assert.strictEqual(isRetryableError(new Error('HTTP 400 Bad Request')), false);
 51      });
 52  
 53      test('should handle case-insensitive matching', () => {
 54        assert.strictEqual(isRetryableError(new Error('RATE LIMIT')), true);
 55        assert.strictEqual(isRetryableError(new Error('Rate Limit')), true);
 56      });
 57    });
 58  
 59    describe('withTimeout', () => {
 60      test('should resolve if promise completes before timeout', async () => {
 61        const promise = new Promise(resolve => setTimeout(() => resolve('success'), 50));
 62        const result = await withTimeout(promise, 200);
 63        assert.strictEqual(result, 'success');
 64      });
 65  
 66      test('should reject if promise exceeds timeout', async () => {
 67        const promise = new Promise(resolve => setTimeout(() => resolve('late'), 200));
 68        await assert.rejects(
 69          async () => {
 70            await withTimeout(promise, 50);
 71          },
 72          { message: 'Operation timed out' }
 73        );
 74      });
 75  
 76      test('should use custom error message', async () => {
 77        const promise = new Promise(resolve => setTimeout(() => resolve('late'), 200));
 78        await assert.rejects(
 79          async () => {
 80            await withTimeout(promise, 50, 'Custom timeout message');
 81          },
 82          { message: 'Custom timeout message' }
 83        );
 84      });
 85  
 86      test('should reject if promise rejects before timeout', async () => {
 87        const promise = new Promise((_, reject) => setTimeout(() => reject(new Error('Failed')), 50));
 88        await assert.rejects(
 89          async () => {
 90            await withTimeout(promise, 200);
 91          },
 92          { message: 'Failed' }
 93        );
 94      });
 95    });
 96  
 97    describe('safeJsonParse', () => {
 98      test('should parse valid JSON', () => {
 99        const result = safeJsonParse('{"key":"value"}');
100        assert.deepStrictEqual(result, { key: 'value' });
101      });
102  
103      test('should parse JSON arrays', () => {
104        const result = safeJsonParse('[1,2,3]');
105        assert.deepStrictEqual(result, [1, 2, 3]);
106      });
107  
108      test('should return fallback for invalid JSON', () => {
109        const result = safeJsonParse('not json', { default: true });
110        assert.deepStrictEqual(result, { default: true });
111      });
112  
113      test('should return null fallback by default', () => {
114        const result = safeJsonParse('invalid');
115        assert.strictEqual(result, null);
116      });
117  
118      test('should handle empty string', () => {
119        const result = safeJsonParse('', 'fallback');
120        assert.strictEqual(result, 'fallback');
121      });
122  
123      test('should handle null input', () => {
124        // Note: JSON.parse(null) actually returns null, it doesn't throw
125        const result = safeJsonParse(null, 'fallback');
126        assert.strictEqual(result, null);
127      });
128    });
129  
130    describe('extractDomain', () => {
131      test('should extract domain from URL', () => {
132        assert.strictEqual(extractDomain('https://example.com/path'), 'example.com');
133        assert.strictEqual(extractDomain('http://example.com'), 'example.com');
134        assert.strictEqual(extractDomain('https://example.com:8080/page'), 'example.com');
135      });
136  
137      test('should remove www prefix', () => {
138        assert.strictEqual(extractDomain('https://www.example.com'), 'example.com');
139        assert.strictEqual(extractDomain('http://www.example.com/page'), 'example.com');
140      });
141  
142      test('should handle subdomains', () => {
143        assert.strictEqual(extractDomain('https://api.example.com'), 'api.example.com');
144        assert.strictEqual(extractDomain('https://blog.example.com/post'), 'blog.example.com');
145      });
146  
147      test('should return original string for invalid URL', () => {
148        assert.strictEqual(extractDomain('not a url'), 'not a url');
149        assert.strictEqual(extractDomain(''), '');
150      });
151  
152      test('should handle URLs with query parameters', () => {
153        assert.strictEqual(extractDomain('https://example.com?param=value'), 'example.com');
154      });
155  
156      test('should handle URLs with hash', () => {
157        assert.strictEqual(extractDomain('https://example.com#section'), 'example.com');
158      });
159    });
160  
161    describe('retryWithBackoff', () => {
162      test('should succeed on first attempt', async () => {
163        let attempts = 0;
164        const fn = async () => {
165          attempts++;
166          return 'success';
167        };
168  
169        const result = await retryWithBackoff(fn, { maxRetries: 3 });
170        assert.strictEqual(result, 'success');
171        assert.strictEqual(attempts, 1);
172      });
173  
174      test('should retry on failure and eventually succeed', async () => {
175        let attempts = 0;
176        const fn = async () => {
177          attempts++;
178          if (attempts < 3) {
179            throw new Error('Temporary failure');
180          }
181          return 'success';
182        };
183  
184        const result = await retryWithBackoff(fn, {
185          maxRetries: 3,
186          initialDelay: 10,
187        });
188        assert.strictEqual(result, 'success');
189        assert.strictEqual(attempts, 3);
190      });
191  
192      test('should throw after exhausting retries', async () => {
193        let attempts = 0;
194        const fn = async () => {
195          attempts++;
196          throw new Error('Persistent failure');
197        };
198  
199        await assert.rejects(
200          async () => {
201            await retryWithBackoff(fn, {
202              maxRetries: 2,
203              initialDelay: 10,
204            });
205          },
206          { message: 'Persistent failure' }
207        );
208        assert.strictEqual(attempts, 3); // 1 initial + 2 retries
209      });
210  
211      test('should call onRetry callback', async () => {
212        const retryAttempts = [];
213        let attempts = 0;
214        const fn = async () => {
215          attempts++;
216          if (attempts < 2) {
217            throw new Error('Fail');
218          }
219          return 'success';
220        };
221  
222        await retryWithBackoff(fn, {
223          maxRetries: 2,
224          initialDelay: 10,
225          onRetry: (attempt, error) => {
226            retryAttempts.push({ attempt, message: error.message });
227          },
228        });
229  
230        assert.strictEqual(retryAttempts.length, 1);
231        assert.strictEqual(retryAttempts[0].attempt, 0);
232        assert.strictEqual(retryAttempts[0].message, 'Fail');
233      });
234  
235      test('should respect shouldRetry predicate', async () => {
236        let attempts = 0;
237        const fn = async () => {
238          attempts++;
239          throw new Error('Do not retry');
240        };
241  
242        await assert.rejects(
243          async () => {
244            await retryWithBackoff(fn, {
245              maxRetries: 3,
246              shouldRetry: error => error.message !== 'Do not retry',
247            });
248          },
249          { message: 'Do not retry' }
250        );
251        assert.strictEqual(attempts, 1); // Should not retry
252      });
253  
254      test('should apply exponential backoff', async () => {
255        const delays = [];
256        let attempts = 0;
257        const fn = async () => {
258          attempts++;
259          if (attempts < 4) {
260            throw new Error('Fail');
261          }
262          return 'success';
263        };
264  
265        const start = Date.now();
266        await retryWithBackoff(fn, {
267          maxRetries: 3,
268          initialDelay: 50,
269          backoffFactor: 2,
270        });
271        const elapsed = Date.now() - start;
272  
273        // Should wait: 50ms + 100ms + 200ms = 350ms minimum
274        assert.ok(elapsed >= 300, 'Should apply exponential backoff delays');
275      });
276  
277      test('should cap delay at maxDelay', async () => {
278        let attempts = 0;
279        const fn = async () => {
280          attempts++;
281          if (attempts < 4) {
282            throw new Error('Fail');
283          }
284          return 'success';
285        };
286  
287        const start = Date.now();
288        await retryWithBackoff(fn, {
289          maxRetries: 3,
290          initialDelay: 100,
291          maxDelay: 150,
292          backoffFactor: 3,
293        });
294        const elapsed = Date.now() - start;
295  
296        // Delays: 100ms (capped to 150), 150ms (capped), 150ms (capped)
297        // Should not exceed ~500ms even with 3x backoff factor
298        assert.ok(elapsed < 600, 'Should cap delays at maxDelay');
299      });
300    });
301  
302    describe('processBatch', () => {
303      test('should process all items successfully', async () => {
304        const items = [1, 2, 3, 4, 5];
305        const processor = async item => item * 2;
306  
307        const { results, errors } = await processBatch(items, processor, { concurrency: 2 });
308  
309        assert.deepStrictEqual(results, [2, 4, 6, 8, 10]);
310        assert.strictEqual(errors.length, 0);
311      });
312  
313      test('should handle errors without stopping', async () => {
314        const items = [1, 2, 3, 4, 5];
315        const processor = async item => {
316          if (item === 3) {
317            throw new Error('Failed on 3');
318          }
319          return item * 2;
320        };
321  
322        const { results, errors } = await processBatch(items, processor, { concurrency: 2 });
323  
324        assert.strictEqual(results.length, 4);
325        assert.strictEqual(errors.length, 1);
326        assert.strictEqual(errors[0].message, 'Failed on 3');
327        assert.ok(results.includes(2));
328        assert.ok(results.includes(4));
329      });
330  
331      test('should call onProgress callback', async () => {
332        const items = [1, 2, 3];
333        const progressUpdates = [];
334        const processor = async item => item * 2;
335  
336        await processBatch(items, processor, {
337          concurrency: 2,
338          onProgress: (completed, total) => {
339            progressUpdates.push({ completed, total });
340          },
341        });
342  
343        assert.strictEqual(progressUpdates.length, 3);
344        assert.deepStrictEqual(progressUpdates[2], { completed: 3, total: 3 });
345      });
346  
347      test('should call onError callback for failures', async () => {
348        const items = [1, 2, 3];
349        const errorItems = [];
350        const processor = async item => {
351          if (item === 2) {
352            throw new Error('Error on 2');
353          }
354          return item;
355        };
356  
357        await processBatch(items, processor, {
358          concurrency: 2,
359          onError: error => {
360            errorItems.push(error.message);
361          },
362        });
363  
364        assert.deepStrictEqual(errorItems, ['Error on 2']);
365      });
366  
367      test('should respect concurrency limit', async () => {
368        const items = [1, 2, 3, 4, 5, 6];
369        let concurrent = 0;
370        let maxConcurrent = 0;
371  
372        const processor = async item => {
373          concurrent++;
374          maxConcurrent = Math.max(maxConcurrent, concurrent);
375          await sleep(50);
376          concurrent--;
377          return item;
378        };
379  
380        await processBatch(items, processor, { concurrency: 2 });
381  
382        assert.ok(maxConcurrent <= 2, `Max concurrent was ${maxConcurrent}, expected <= 2`);
383      });
384  
385      test('should handle empty array', async () => {
386        const items = [];
387        const processor = async item => item;
388  
389        const { results, errors } = await processBatch(items, processor);
390  
391        assert.deepStrictEqual(results, []);
392        assert.deepStrictEqual(errors, []);
393      });
394  
395      test('should use default concurrency', async () => {
396        const items = [1, 2, 3];
397        const processor = async item => item * 2;
398  
399        const { results, errors } = await processBatch(items, processor);
400  
401        assert.strictEqual(results.length, 3);
402        assert.strictEqual(errors.length, 0);
403      });
404    });
405  });