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 });