135 lines
5.2 KiB
TypeScript
135 lines
5.2 KiB
TypeScript
import {describe, expect, test} from 'bun:test'
|
|
import {
|
|
AccessDeniedError,
|
|
type CloudError,
|
|
type CloudErrorCode,
|
|
type CloudErrorStatus,
|
|
ConflictError,
|
|
NotFoundError,
|
|
NotImplementedByRuntimeError,
|
|
NotSupportedError,
|
|
RateLimitedError,
|
|
RuntimeError,
|
|
RuntimeUnavailableError,
|
|
ValidationError,
|
|
httpStatusToCloudError,
|
|
isUnreachableCause,
|
|
toHttpError,
|
|
} from './errors'
|
|
|
|
describe('CloudError wire shape', () => {
|
|
test('keeps the generic label in message and the raw text in detail', () => {
|
|
const body = new RuntimeUnavailableError('Cannot reach Floci-AZ at http://localhost:4577').toBody()
|
|
|
|
expect(body.code).toBe('runtime_unavailable')
|
|
expect(body.message).toBe('Runtime unavailable')
|
|
expect(body.error).toBe(body.message)
|
|
expect(body.detail).toBe('Cannot reach Floci-AZ at http://localhost:4577')
|
|
})
|
|
|
|
test('omits detail when it would duplicate the message', () => {
|
|
// ValidationError intentionally uses its own message as the label, so a
|
|
// field-level complaint reaches the UI verbatim instead of being generified.
|
|
const body = new ValidationError('bucketName is required').toBody()
|
|
|
|
expect(body.message).toBe('bucketName is required')
|
|
expect(body.detail).toBeUndefined()
|
|
})
|
|
|
|
test('preserves the cause chain for debugging', () => {
|
|
const cause = new Error('socket hang up')
|
|
expect(new RuntimeError('wrapped', {cause}).cause).toBe(cause)
|
|
})
|
|
|
|
const statuses: Array<[CloudError, CloudErrorStatus, CloudErrorCode]> = [
|
|
[new ValidationError('x'), 400, 'invalid_request'],
|
|
[new AccessDeniedError('x'), 403, 'access_denied'],
|
|
[new NotFoundError('x'), 404, 'resource_not_found'],
|
|
[new ConflictError('x'), 409, 'resource_conflict'],
|
|
[new RateLimitedError('x'), 429, 'rate_limited'],
|
|
[new NotSupportedError('x'), 501, 'operation_not_supported'],
|
|
[new NotImplementedByRuntimeError('x'), 501, 'operation_not_implemented'],
|
|
[new RuntimeError('x'), 502, 'runtime_error'],
|
|
[new RuntimeUnavailableError('x'), 503, 'runtime_unavailable'],
|
|
]
|
|
|
|
for (const [error, status, code] of statuses) {
|
|
test(`${error.name} carries ${status}/${code}`, () => {
|
|
expect(error.status).toBe(status)
|
|
expect(error.code).toBe(code)
|
|
expect(toHttpError(error).status).toBe(status)
|
|
})
|
|
}
|
|
})
|
|
|
|
describe('httpStatusToCloudError', () => {
|
|
const cases: Array<[number, CloudErrorStatus, CloudErrorCode]> = [
|
|
[400, 400, 'invalid_request'],
|
|
[401, 403, 'access_denied'],
|
|
[403, 403, 'access_denied'],
|
|
[404, 404, 'resource_not_found'],
|
|
[409, 409, 'resource_conflict'],
|
|
[429, 429, 'rate_limited'],
|
|
[501, 501, 'operation_not_implemented'],
|
|
[502, 503, 'runtime_unavailable'],
|
|
[503, 503, 'runtime_unavailable'],
|
|
[504, 503, 'runtime_unavailable'],
|
|
[418, 502, 'runtime_error'],
|
|
]
|
|
|
|
for (const [runtimeStatus, expectedStatus, expectedCode] of cases) {
|
|
test(`HTTP ${runtimeStatus} becomes ${expectedStatus}`, () => {
|
|
const error = httpStatusToCloudError(runtimeStatus, `HTTP ${runtimeStatus}`)
|
|
expect(error.status).toBe(expectedStatus)
|
|
expect(error.code).toBe(expectedCode)
|
|
})
|
|
}
|
|
})
|
|
|
|
describe('isUnreachableCause', () => {
|
|
test('detects a nested transport failure code', () => {
|
|
const inner = Object.assign(new Error('connect ECONNREFUSED'), {code: 'ECONNREFUSED'})
|
|
expect(isUnreachableCause(new Error('fetch failed', {cause: inner}))).toBe(true)
|
|
})
|
|
|
|
test('detects an aborted or timed-out request by name', () => {
|
|
const timeout = new Error('timed out')
|
|
timeout.name = 'TimeoutError'
|
|
expect(isUnreachableCause(timeout)).toBe(true)
|
|
})
|
|
|
|
test('does not treat an ordinary error as unreachable', () => {
|
|
expect(isUnreachableCause(new Error('bucket already exists'))).toBe(false)
|
|
expect(isUnreachableCause('not an error')).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('toHttpError', () => {
|
|
test('defaults an unknown failure to 502 rather than guessing', () => {
|
|
const {status, body} = toHttpError(new Error('something odd happened'))
|
|
|
|
expect(status).toBe(502)
|
|
expect(body.code).toBe('runtime_error')
|
|
expect(body.detail).toBe('something odd happened')
|
|
})
|
|
|
|
test('promotes a transport failure to 503 without any message matching', () => {
|
|
const inner = Object.assign(new Error('connect ECONNREFUSED 127.0.0.1:4566'), {code: 'ECONNREFUSED'})
|
|
expect(toHttpError(new Error('fetch failed', {cause: inner})).status).toBe(503)
|
|
})
|
|
|
|
test('lets a vendor mapper win over the generic fallback', () => {
|
|
const {status} = toHttpError(new Error('vendor specific'), () => new ConflictError('already exists'))
|
|
expect(status).toBe(409)
|
|
})
|
|
|
|
test('a CloudError outranks the vendor mapper', () => {
|
|
const {status} = toHttpError(new NotFoundError('missing'), () => new ConflictError('nope'))
|
|
expect(status).toBe(404)
|
|
})
|
|
|
|
test('handles a non-Error throw', () => {
|
|
expect(toHttpError('boom').status).toBe(502)
|
|
})
|
|
})
|