diff --git a/.c8rc.json b/.c8rc.json index 8734c3d4..02dc2992 100644 --- a/.c8rc.json +++ b/.c8rc.json @@ -1,6 +1,5 @@ { "exclude": [ - "**/index.ts", "package/**/*.ts", "examples/**/*.ts", "**/*.test.ts", diff --git a/README.md b/README.md index 59a9fd52..b007d188 100644 --- a/README.md +++ b/README.md @@ -474,7 +474,8 @@ default. The Axios client and retry behavior may be configured with custom initiation options via [`axiosOptions`][axiosOptions] and [`axiosRetryOptions`][axiosRetryOptions]. -Options are deep merged with the default options. +Options are shallow merged with the default options: +each provided top-level option replaces the default value. By default, the SDK makes up to three attempts: the initial request and two retries. Retries are limited to `GET`, `HEAD`, `OPTIONS`, `PUT`, and `DELETE` diff --git a/test/seam/connect/error-guards.test.ts b/test/seam/connect/error-guards.test.ts new file mode 100644 index 00000000..9fcc72d0 --- /dev/null +++ b/test/seam/connect/error-guards.test.ts @@ -0,0 +1,118 @@ +import test from 'ava' + +import { + type ActionAttempt, + type FailedActionAttempt, + isSeamActionAttemptError, + isSeamActionAttemptFailedError, + isSeamActionAttemptTimeoutError, + isSeamHttpApiError, + isSeamHttpInvalidInputError, + isSeamHttpUnauthorizedError, + SeamActionAttemptError, + SeamActionAttemptFailedError, + SeamActionAttemptTimeoutError, + SeamHttpApiError, + SeamHttpInvalidInputError, + SeamHttpUnauthorizedError, +} from '@seamapi/http/connect' + +const apiError = new SeamHttpApiError( + { type: 'device_not_found', message: 'Device not found' }, + 404, + 'request-1', +) + +const unauthorizedError = new SeamHttpUnauthorizedError('request-1') + +const invalidInputError = new SeamHttpInvalidInputError( + { + type: 'invalid_input', + message: 'Invalid input', + validation_errors: { device_id: { _errors: ['Required'] } }, + }, + 400, + 'request-1', +) + +const pendingActionAttempt = { + action_attempt_id: 'e2192660-0e45-4a11-9800-eb4d086cca09', + action_type: 'UNLOCK_DOOR', + status: 'pending', + error: null, + result: null, +} as unknown as ActionAttempt + +const failedActionAttempt = { + ...pendingActionAttempt, + status: 'error', + error: { message: 'Failed', type: 'foo' }, +} as unknown as FailedActionAttempt + +const actionAttemptError = new SeamActionAttemptError( + 'Something happened', + pendingActionAttempt, +) + +const actionAttemptFailedError = new SeamActionAttemptFailedError( + failedActionAttempt, +) + +const actionAttemptTimeoutError = new SeamActionAttemptTimeoutError( + pendingActionAttempt, + 100, +) + +const unrelatedValues = [new Error('unrelated'), null, undefined, {}, 'error'] + +test('isSeamHttpApiError: matches every Seam API error', (t) => { + t.true(isSeamHttpApiError(apiError)) + t.true(isSeamHttpApiError(unauthorizedError)) + t.true(isSeamHttpApiError(invalidInputError)) + t.false(isSeamHttpApiError(actionAttemptError)) + for (const value of unrelatedValues) t.false(isSeamHttpApiError(value)) +}) + +test('isSeamHttpUnauthorizedError: matches only the unauthorized error', (t) => { + t.true(isSeamHttpUnauthorizedError(unauthorizedError)) + t.false(isSeamHttpUnauthorizedError(apiError)) + t.false(isSeamHttpUnauthorizedError(invalidInputError)) + for (const value of unrelatedValues) { + t.false(isSeamHttpUnauthorizedError(value)) + } +}) + +test('isSeamHttpInvalidInputError: matches only the invalid input error', (t) => { + t.true(isSeamHttpInvalidInputError(invalidInputError)) + t.false(isSeamHttpInvalidInputError(apiError)) + t.false(isSeamHttpInvalidInputError(unauthorizedError)) + for (const value of unrelatedValues) { + t.false(isSeamHttpInvalidInputError(value)) + } +}) + +test('isSeamActionAttemptError: matches every action attempt error', (t) => { + t.true(isSeamActionAttemptError(actionAttemptError)) + t.true(isSeamActionAttemptError(actionAttemptFailedError)) + t.true(isSeamActionAttemptError(actionAttemptTimeoutError)) + t.false(isSeamActionAttemptError(apiError)) + for (const value of unrelatedValues) t.false(isSeamActionAttemptError(value)) +}) + +test('isSeamActionAttemptFailedError: matches only the failed error', (t) => { + t.true(isSeamActionAttemptFailedError(actionAttemptFailedError)) + t.false(isSeamActionAttemptFailedError(actionAttemptError)) + t.false(isSeamActionAttemptFailedError(actionAttemptTimeoutError)) + for (const value of unrelatedValues) { + t.false(isSeamActionAttemptFailedError(value)) + } +}) + +test('isSeamActionAttemptTimeoutError: matches only the timeout error', (t) => { + t.true(isSeamActionAttemptTimeoutError(actionAttemptTimeoutError)) + t.false(isSeamActionAttemptTimeoutError(actionAttemptError)) + t.false(isSeamActionAttemptTimeoutError(actionAttemptFailedError)) + for (const value of unrelatedValues) { + t.false(isSeamActionAttemptTimeoutError(value)) + } +}) diff --git a/test/seam/connect/token.test.ts b/test/seam/connect/token.test.ts new file mode 100644 index 00000000..d737f3aa --- /dev/null +++ b/test/seam/connect/token.test.ts @@ -0,0 +1,77 @@ +import test from 'ava' + +import { + isApiKey, + isClientSessionToken, + isConsoleSessionToken, + isPersonalAccessToken, + isPublishableKey, +} from '@seamapi/http/connect' + +import { isAccessToken, isJwt, isSeamToken } from 'lib/token.js' + +const apiKey = 'seam_apikey1_token' +const accessToken = 'seam_at1_token' +const clientSessionToken = 'seam_cst1_token' +const publishableKey = 'seam_pk1_token' +const jwt = 'ey1.ey2.token' +const unknownToken = 'some-other-token' + +test('isApiKey: only matches an api key', (t) => { + t.true(isApiKey(apiKey)) + t.false(isApiKey(accessToken)) + t.false(isApiKey(clientSessionToken)) + t.false(isApiKey(publishableKey)) + t.false(isApiKey(jwt)) + t.false(isApiKey(unknownToken)) +}) + +test('isAccessToken: matches the access token prefix', (t) => { + t.true(isAccessToken(accessToken)) + t.false(isAccessToken(apiKey)) + t.false(isAccessToken(jwt)) + t.false(isAccessToken(unknownToken)) +}) + +test('isPersonalAccessToken: matches an access token', (t) => { + t.true(isPersonalAccessToken(accessToken)) + t.false(isPersonalAccessToken(apiKey)) + t.false(isPersonalAccessToken(clientSessionToken)) + t.false(isPersonalAccessToken(unknownToken)) +}) + +test('isClientSessionToken: matches the client session token prefix', (t) => { + t.true(isClientSessionToken(clientSessionToken)) + t.false(isClientSessionToken(apiKey)) + t.false(isClientSessionToken(publishableKey)) + t.false(isClientSessionToken(unknownToken)) +}) + +test('isPublishableKey: matches the publishable key prefix', (t) => { + t.true(isPublishableKey(publishableKey)) + t.false(isPublishableKey(apiKey)) + t.false(isPublishableKey(clientSessionToken)) + t.false(isPublishableKey(unknownToken)) +}) + +test('isJwt: matches the jwt prefix', (t) => { + t.true(isJwt(jwt)) + t.false(isJwt(apiKey)) + t.false(isJwt(unknownToken)) +}) + +test('isConsoleSessionToken: matches a jwt', (t) => { + t.true(isConsoleSessionToken(jwt)) + t.false(isConsoleSessionToken(apiKey)) + t.false(isConsoleSessionToken(accessToken)) + t.false(isConsoleSessionToken(unknownToken)) +}) + +test('isSeamToken: matches the seam token prefix', (t) => { + t.true(isSeamToken(apiKey)) + t.true(isSeamToken(accessToken)) + t.true(isSeamToken(clientSessionToken)) + t.true(isSeamToken(publishableKey)) + t.false(isSeamToken(jwt)) + t.false(isSeamToken(unknownToken)) +})