|
| 1 | +import { describe, expect, it } from 'vitest'; |
| 2 | +import { |
| 3 | + AIAuthError, |
| 4 | + AIModelRestrictionError, |
| 5 | + AINetworkError, |
| 6 | + AISubscriptionError, |
| 7 | + AIProviderError, |
| 8 | + AIServerError, |
| 9 | + AIUnknownError, |
| 10 | + TimeoutError, |
| 11 | + classifyAIError, |
| 12 | + mapAIError, |
| 13 | +} from './errors'; |
| 14 | + |
| 15 | +describe('classifyAIError', () => { |
| 16 | + it('treats a fetch TypeError as a network failure and does not capture it', () => { |
| 17 | + const classification = classifyAIError(new TypeError('Failed to fetch')); |
| 18 | + expect(classification).toMatchObject({ errorName: 'AINetworkError', shouldCaptureException: false }); |
| 19 | + }); |
| 20 | + |
| 21 | + it('captures an unknown error under its own name with a privacy-safe cause type', () => { |
| 22 | + const classification = classifyAIError(new Error('student motion and private notes')); |
| 23 | + expect(classification).toMatchObject({ |
| 24 | + errorName: 'AIUnknownError', |
| 25 | + shouldCaptureException: true, |
| 26 | + causeType: 'Error:Error', |
| 27 | + }); |
| 28 | + expect(classification.causeType).not.toContain('student'); |
| 29 | + expect(JSON.stringify(classification)).not.toContain('private notes'); |
| 30 | + }); |
| 31 | + |
| 32 | + it('keeps subscription, model and timeout failures on their existing names', () => { |
| 33 | + expect(classifyAIError(new AISubscriptionError('limit', { statusCode: 402 })).shouldCaptureException).toBe(false); |
| 34 | + expect(classifyAIError(new AISubscriptionError('limit', { statusCode: 402 })).errorName).toBe('AISubscriptionError'); |
| 35 | + expect(classifyAIError(new AIModelRestrictionError('upgrade')).errorName).toBe('AIModelRestrictionError'); |
| 36 | + expect(classifyAIError(new AIAuthError('no', { statusCode: 403 })).shouldCaptureException).toBe(false); |
| 37 | + expect(classifyAIError(new TimeoutError())).toMatchObject({ errorName: 'TimeoutError', shouldCaptureException: true }); |
| 38 | + }); |
| 39 | + |
| 40 | + it('recognizes the other browser network messages and an offline navigator', () => { |
| 41 | + expect(classifyAIError(new TypeError('NetworkError when attempting to fetch resource')).errorName).toBe('AINetworkError'); |
| 42 | + expect(classifyAIError(new TypeError('Load failed')).errorName).toBe('AINetworkError'); |
| 43 | + expect(classifyAIError(new TypeError('Network error')).errorName).toBe('AINetworkError'); |
| 44 | + const online = navigator.onLine; |
| 45 | + Object.defineProperty(navigator, 'onLine', { configurable: true, value: false }); |
| 46 | + try { |
| 47 | + expect(classifyAIError(new Error('socket closed')).errorName).toBe('AINetworkError'); |
| 48 | + expect(classifyAIError(new AISubscriptionError('limit', { statusCode: 402 })).errorName).toBe('AISubscriptionError'); |
| 49 | + } finally { |
| 50 | + Object.defineProperty(navigator, 'onLine', { configurable: true, value: online }); |
| 51 | + } |
| 52 | + }); |
| 53 | +}); |
| 54 | + |
| 55 | +describe('mapAIError', () => { |
| 56 | + it('maps a wrapped fetch failure to a network error without a status code', () => { |
| 57 | + const error = mapAIError( |
| 58 | + { message: 'Failed to send a request to the Edge Function', context: new TypeError('Failed to fetch') }, |
| 59 | + 'req-1', |
| 60 | + 'openrouter', |
| 61 | + 'm', |
| 62 | + ); |
| 63 | + expect(error).toBeInstanceOf(AINetworkError); |
| 64 | + expect(error.statusCode).toBeUndefined(); |
| 65 | + expect(classifyAIError(error).shouldCaptureException).toBe(false); |
| 66 | + }); |
| 67 | + |
| 68 | + it('maps an unknown transport error without putting its message in the cause type', () => { |
| 69 | + const error = mapAIError({ message: 'student motion leaked' }, 'req-1', 'openrouter', 'm'); |
| 70 | + expect(error).toBeInstanceOf(AIUnknownError); |
| 71 | + expect((error as AIUnknownError).causeType).toBe('Unknown:Unknown'); |
| 72 | + expect((error as AIUnknownError).causeType).not.toContain('student'); |
| 73 | + expect(classifyAIError(error).shouldCaptureException).toBe(true); |
| 74 | + }); |
| 75 | + |
| 76 | + it('still maps 402, 403 and abort messages as before', () => { |
| 77 | + const limited = mapAIError( |
| 78 | + { message: 'Edge Function returned a non-2xx status code', context: { status: 402 } }, |
| 79 | + 'req-1', |
| 80 | + 'openrouter', |
| 81 | + 'm', |
| 82 | + { error: 'limit', code: 'limit_reached' }, |
| 83 | + ); |
| 84 | + expect(limited).toBeInstanceOf(AISubscriptionError); |
| 85 | + expect(limited.statusCode).toBe(402); |
| 86 | + |
| 87 | + const upgrade = mapAIError( |
| 88 | + { message: 'Edge Function returned a non-2xx status code', context: { status: 403 } }, |
| 89 | + 'req-1', |
| 90 | + 'openrouter', |
| 91 | + 'm', |
| 92 | + { code: 'upgrade_required', error: 'This model requires Pro' }, |
| 93 | + ); |
| 94 | + expect(upgrade).toBeInstanceOf(AIModelRestrictionError); |
| 95 | + |
| 96 | + const forbidden = mapAIError( |
| 97 | + { message: 'Edge Function returned a non-2xx status code', context: { status: 403 } }, |
| 98 | + 'req-1', |
| 99 | + 'openrouter', |
| 100 | + 'm', |
| 101 | + { error: 'Access denied' }, |
| 102 | + ); |
| 103 | + expect(forbidden).toBeInstanceOf(AIAuthError); |
| 104 | + |
| 105 | + expect(mapAIError({ message: 'The operation was aborted' }, 'req-1', 'openrouter', 'm')).toBeInstanceOf(TimeoutError); |
| 106 | + }); |
| 107 | + |
| 108 | + it('keeps a provider_error 500 on AIProviderError and other 500s on AIServerError', () => { |
| 109 | + const provider = mapAIError( |
| 110 | + { message: 'Edge Function returned a non-2xx status code', context: { status: 500 } }, |
| 111 | + 'req-1', |
| 112 | + 'openrouter', |
| 113 | + 'm', |
| 114 | + { error: 'AI request failed. Please try again.', code: 'provider_error' }, |
| 115 | + ); |
| 116 | + expect(provider).toBeInstanceOf(AIProviderError); |
| 117 | + expect(provider.statusCode).toBe(500); |
| 118 | + expect(classifyAIError(provider).errorName).toBe('AIProviderError'); |
| 119 | + |
| 120 | + const server = mapAIError( |
| 121 | + { message: 'Edge Function returned a non-2xx status code', context: { status: 500 } }, |
| 122 | + 'req-1', |
| 123 | + 'openrouter', |
| 124 | + 'm', |
| 125 | + { error: 'Unable to verify your quota. Please try again.' }, |
| 126 | + ); |
| 127 | + expect(server).toBeInstanceOf(AIServerError); |
| 128 | + expect(server.statusCode).toBe(500); |
| 129 | + expect(classifyAIError(server)).toMatchObject({ errorName: 'AIServerError', statusCode: 500, shouldCaptureException: true }); |
| 130 | + }); |
| 131 | +}); |
0 commit comments