import {describe, expect, test} from 'bun:test' import type {CloudErrorCode, CloudErrorStatus} from '../cloud-spi/errors' import {isAwsSdkError, mapAwsSdkError} from './awsErrors' /** Build an error shaped like an AWS SDK v3 failure. */ function sdkError(name: string, extra: Record = {}): Error { const err = new Error(`${name} from the runtime`) err.name = name return Object.assign(err, {$fault: 'client', $metadata: {httpStatusCode: 500}, ...extra}) } describe('isAwsSdkError', () => { test('recognises the SDK envelope', () => { expect(isAwsSdkError(sdkError('NoSuchBucket'))).toBe(true) expect(isAwsSdkError(Object.assign(new Error('x'), {$retryable: {}}))).toBe(true) }) test('rejects anything else so the generic mapper can handle it', () => { expect(isAwsSdkError(new Error('plain'))).toBe(false) expect(isAwsSdkError(null)).toBe(false) expect(isAwsSdkError('string')).toBe(false) }) }) describe('mapAwsSdkError', () => { test('returns null for a non-SDK error', () => { expect(mapAwsSdkError(new Error('plain'))).toBeNull() }) const cases: Array<[string, CloudErrorStatus, CloudErrorCode]> = [ // throttling — must win over everything, including the 500 metadata status ['ThrottlingException', 429, 'rate_limited'], ['ProvisionedThroughputExceededException', 429, 'rate_limited'], ['SlowDown', 429, 'rate_limited'], // conflicts — previously all 502 ['BucketAlreadyExists', 409, 'resource_conflict'], ['BucketAlreadyOwnedByYou', 409, 'resource_conflict'], ['EntityAlreadyExists', 409, 'resource_conflict'], ['ResourceExistsException', 409, 'resource_conflict'], ['StateMachineAlreadyExists', 409, 'resource_conflict'], ['ResourceInUseException', 409, 'resource_conflict'], ['QueueNameExists', 409, 'resource_conflict'], // access ['AccessDenied', 403, 'access_denied'], ['UnauthorizedOperation', 403, 'access_denied'], ['SignatureDoesNotMatch', 403, 'access_denied'], // validation ['ValidationException', 400, 'invalid_request'], ['InvalidParameterValue', 400, 'invalid_request'], ['MalformedPolicyDocument', 400, 'invalid_request'], // A bad parameter set, not an existing-resource clash. ['InvalidParameterCombination', 400, 'invalid_request'], // not found ['NoSuchBucket', 404, 'resource_not_found'], ['NoSuchKey', 404, 'resource_not_found'], ['ResourceNotFoundException', 404, 'resource_not_found'], ['QueueDoesNotExist', 404, 'resource_not_found'], // EC2 uses a dotted suffix convention ['InvalidVpcID.NotFound', 404, 'resource_not_found'], ['InvalidInstanceID.NotFound', 404, 'resource_not_found'], // runtime gaps ['NotImplemented', 501, 'operation_not_implemented'], ['UnsupportedOperation', 501, 'operation_not_implemented'], ['ServiceUnavailable', 503, 'runtime_unavailable'], ] for (const [name, status, code] of cases) { test(`${name} maps to ${status}`, () => { const mapped = mapAwsSdkError(sdkError(name)) expect(mapped?.status).toBe(status) expect(mapped?.code).toBe(code) expect(mapped?.message).toContain(name) }) } test('matches the AlreadyExists suffix convention for unlisted names', () => { expect(mapAwsSdkError(sdkError('WidgetAlreadyExistsException'))?.status).toBe(409) }) test('honours the $retryable throttling flag even for an unknown name', () => { const mapped = mapAwsSdkError(sdkError('SomeNewException', {$retryable: {throttling: true}})) expect(mapped?.status).toBe(429) }) test('falls back to the metadata status only when the name is unknown', () => { expect(mapAwsSdkError(sdkError('MysteryFailure', {$metadata: {httpStatusCode: 404}}))?.status).toBe(404) expect(mapAwsSdkError(sdkError('MysteryFailure', {$metadata: {httpStatusCode: 409}}))?.status).toBe(409) }) test('prefers the error name over a misleading metadata status', () => { // Local emulators frequently answer 500 for what is really a conflict. const mapped = mapAwsSdkError(sdkError('BucketAlreadyExists', {$metadata: {httpStatusCode: 500}})) expect(mapped?.status).toBe(409) }) test('defaults to 502 when neither name nor status is informative', () => { expect(mapAwsSdkError(sdkError('MysteryFailure'))?.status).toBe(502) }) }) /** * CloudWatch Logs returns both of these with HTTP 400, verified against the * runtime on 2026-07-28: * DescribeLogStreams on a missing group -> 400 ResourceNotFoundException * duplicate CreateLogGroup -> 400 ResourceAlreadyExistsException * * Both already map correctly, because the name tables run ahead of the * `$metadata.httpStatusCode` floor. Nothing pinned that ordering against a * 400-carrying not-found, and an unpinned ordering is how #168 shipped a green * suite over a 400-instead-of-404 bug. */ describe('logs errors carry HTTP 400 and must not fall through to it', () => { test('a not-found keeps its 404 despite the 400 status', () => { const mapped = mapAwsSdkError( sdkError('ResourceNotFoundException', {$metadata: {httpStatusCode: 400}}), ) expect(mapped?.status).toBe(404) expect(mapped?.code).toBe('resource_not_found') }) test('an already-exists keeps its 409 despite the 400 status', () => { const mapped = mapAwsSdkError( sdkError('ResourceAlreadyExistsException', {$metadata: {httpStatusCode: 400}}), ) expect(mapped?.status).toBe(409) }) }) /** * Cognito reports every failure with HTTP 400, so these names must be mapped * explicitly or a missing user would read as a validation error. */ describe('cognito errors carry HTTP 400 and keep their meaning', () => { const cases: Array<[string, CloudErrorStatus]> = [ ['UserNotFoundException', 404], ['UsernameExistsException', 409], ['AliasExistsException', 409], ['InvalidPasswordException', 400], ] for (const [name, status] of cases) { test(`${name} maps to ${status}`, () => { expect(mapAwsSdkError(sdkError(name, {$metadata: {httpStatusCode: 400}}))?.status).toBe(status) }) } })