diff --git a/api/sandboxHubs.ts b/api/sandboxHubs.ts index 7144bbe4..a8b4ac54 100644 --- a/api/sandboxHubs.ts +++ b/api/sandboxHubs.ts @@ -16,7 +16,7 @@ const SANDBOX_API_PATH_V2 = 'sandbox-hubs/v2'; export async function createSandbox( accountId: number, name: string, - type: string + type: 1 | 2 ): Promise { return http.post(accountId, { data: { name, type, generatePersonalAccessKey: true }, // For CLI, generatePersonalAccessKey will always be true since we'll be saving the entry to the config diff --git a/errors/__tests__/apiErrors.ts b/errors/__tests__/apiErrors.ts index 088d94aa..bdad09c0 100644 --- a/errors/__tests__/apiErrors.ts +++ b/errors/__tests__/apiErrors.ts @@ -7,6 +7,7 @@ import { getAxiosErrorWithContext, throwApiError, throwApiUploadError, + isSpecifiedError, } from '../apiErrors'; import { BaseError } from '../../types/Error'; @@ -42,19 +43,68 @@ export const newAxiosError = (overrides = {}): AxiosError => { }; describe('errors/apiErrors', () => { + describe('isSpecifiedError()', () => { + it('returns true for a matching specified error', () => { + const error1 = newAxiosError({ + response: { + status: 403, + data: { category: 'BANNED', subCategory: 'USER_ACCESS_NOT_ALLOWED' }, + }, + }); + expect( + isSpecifiedError(error1, { + statusCode: 403, + category: 'BANNED', + subCategory: 'USER_ACCESS_NOT_ALLOWED', + }) + ).toBe(true); + }); + + it('returns false for non matching specified errors', () => { + const error1 = newAxiosError({ + response: { + status: 403, + data: { category: 'BANNED', subCategory: 'USER_ACCESS_NOT_ALLOWED' }, + }, + }); + const error2 = newAxiosError({ isAxiosError: false }); + expect( + isSpecifiedError(error1, { + statusCode: 400, + category: 'GATED', + }) + ).toBe(false); + expect(isMissingScopeError(error2)).toBe(false); + }); + + it('handles AxiosError returned in cause property', () => { + const axiosError = newAxiosError({ + response: { + status: 403, + data: { category: 'BANNED', subCategory: 'USER_ACCESS_NOT_ALLOWED' }, + }, + }); + const error1 = newError({ cause: axiosError }); + expect( + isSpecifiedError(error1, { + statusCode: 403, + category: 'BANNED', + subCategory: 'USER_ACCESS_NOT_ALLOWED', + }) + ).toBe(true); + }); + }); describe('isMissingScopeError()', () => { it('returns true for missing scope errors', () => { const error1 = newAxiosError({ - status: 403, - response: { data: { category: 'MISSING_SCOPES' } }, + response: { status: 403, data: { category: 'MISSING_SCOPES' } }, }); expect(isMissingScopeError(error1)).toBe(true); }); it('returns false for non missing scope errors', () => { const error1 = newAxiosError({ - status: 400, - response: { data: { category: 'MISSING_SCOPES' } }, + response: { status: 400, data: { category: 'MISSING_SCOPES' } }, }); const error2 = newAxiosError({ isAxiosError: false }); expect(isMissingScopeError(error1)).toBe(false); @@ -65,16 +115,14 @@ describe('errors/apiErrors', () => { describe('isGatingError()', () => { it('returns true for gating errors', () => { const error1 = newAxiosError({ - status: 403, - response: { data: { category: 'GATED' } }, + response: { status: 403, data: { category: 'GATED' } }, }); expect(isGatingError(error1)).toBe(true); }); it('returns false for non gating errors', () => { const error1 = newAxiosError({ - status: 400, - response: { data: { category: 'GATED' } }, + response: { status: 400, data: { category: 'GATED' } }, }); const error2 = newAxiosError({ isAxiosError: false }); expect(isGatingError(error1)).toBe(false); @@ -98,8 +146,7 @@ describe('errors/apiErrors', () => { it('returns false for non api upload validation errors', () => { const error1 = newAxiosError({ - status: 400, - response: { data: null }, + response: { status: 400, data: null }, }); const error2 = newAxiosError({ isAxiosError: false }); expect(isApiUploadValidationError(error1)).toBe(false); diff --git a/errors/apiErrors.ts b/errors/apiErrors.ts index 992a4f9b..a6f1570a 100644 --- a/errors/apiErrors.ts +++ b/errors/apiErrors.ts @@ -13,24 +13,30 @@ import { HttpMethod } from '../types/Api'; const i18nKey = 'errors.apiErrors'; -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export function isMissingScopeError(err: AxiosError): boolean { - return ( - err.isAxiosError && - err.status === 403 && - !!err.response && - err.response.data.category === 'MISSING_SCOPES' - ); +export function isSpecifiedError( + err: Error | AxiosError, + { + statusCode, + category, + subCategory, + }: { statusCode?: number; category?: string; subCategory?: string } +): boolean { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const error = (err && (err.cause as AxiosError)) || err; + const statusCodeErr = !statusCode || error.response?.status === statusCode; + const categoryErr = !category || error.response?.data?.category === category; + const subCategoryErr = + !subCategory || error.response?.data?.subCategory === subCategory; + + return error.isAxiosError && statusCodeErr && categoryErr && subCategoryErr; } -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export function isGatingError(err: AxiosError): boolean { - return ( - err.isAxiosError && - err.status === 403 && - !!err.response && - err.response.data.category === 'GATED' - ); +export function isMissingScopeError(err: Error | AxiosError): boolean { + return isSpecifiedError(err, { statusCode: 403, category: 'MISSING_SCOPES' }); +} + +export function isGatingError(err: Error | AxiosError): boolean { + return isSpecifiedError(err, { statusCode: 403, category: 'GATED' }); } // eslint-disable-next-line @typescript-eslint/no-explicit-any diff --git a/errors/standardErrors.ts b/errors/standardErrors.ts index 65607051..b827ea15 100644 --- a/errors/standardErrors.ts +++ b/errors/standardErrors.ts @@ -17,7 +17,7 @@ function genericThrowErrorWithMessage( ErrorType: ErrorConstructor, identifier: LangKey, interpolation?: { [key: string]: string | number }, - cause?: BaseError + cause?: BaseError | AxiosError ): never { const message = i18n(identifier, interpolation); if (cause) { @@ -32,7 +32,7 @@ function genericThrowErrorWithMessage( export function throwErrorWithMessage( identifier: LangKey, interpolation?: { [key: string]: string | number }, - cause?: BaseError + cause?: BaseError | AxiosError ): never { genericThrowErrorWithMessage(Error, identifier, interpolation, cause); } diff --git a/lang/en.json b/lang/en.json index 61684432..ca5f312a 100644 --- a/lang/en.json +++ b/lang/en.json @@ -71,16 +71,6 @@ "invalidPersonalAccessKey": "Error while retrieving new access token: {{ errorMessage }}" } }, - "sandboxes": { - "errors": { - "createSandbox": "There was an error creating your sandbox.", - "deleteSandbox": "There was an error deleting your sandbox.", - "getSandboxUsageLimits": "There was an error fetching sandbox usage limits.", - "initiateSync": "There was an error initiating the sandbox sync.", - "fetchTaskStatus": "There was an error fetching the task status while syncing sandboxes.", - "fetchTypes": "There was an error fetching sandbox types." - } - }, "cms": { "modules": { "createModule": { diff --git a/lib/__tests__/sandboxes.ts b/lib/__tests__/sandboxes.ts new file mode 100644 index 00000000..8e201481 --- /dev/null +++ b/lib/__tests__/sandboxes.ts @@ -0,0 +1,108 @@ +import { + createSandbox as __createSandbox, + getSandboxUsageLimits as __getSandboxUsageLimits, +} from '../../api/sandboxHubs'; +import { fetchTypes as __fetchTypes } from '../../api/sandboxSync'; +import { Sandbox, Usage } from '../../types/Sandbox'; +import { + createSandbox as createSandboxAction, + deleteSandbox as deleteSandboxAction, + getSandboxUsageLimits as getSandboxUsageLimitsAction, + fetchTypes as fetchTypesAction, +} from '../sandboxes'; + +jest.mock('../../api/sandboxHubs'); +jest.mock('../../api/sandboxSync'); + +const createSandbox = __createSandbox as jest.MockedFunction< + typeof __createSandbox +>; +const getSandboxUsageLimits = __getSandboxUsageLimits as jest.MockedFunction< + typeof __getSandboxUsageLimits +>; +const fetchTypes = __fetchTypes as jest.MockedFunction; + +const sandboxName = 'Mock Standard Sandbox'; +const sandboxHubId = 987654; +const accountId = 123456; + +const MOCK_SANDBOX_ACCOUNT: Sandbox = { + sandboxHubId: sandboxHubId, + parentHubId: accountId, + createdAt: '2023-01-27T22:24:27.279Z', + updatedAt: '2023-02-09T19:36:25.123Z', + archivedAt: null, + type: 'developer', + archived: false, + name: sandboxName, + domain: 'mockStandardSandbox.com', + createdByUser: { + userId: 111, + firstName: 'Test', + lastName: 'User', + }, +}; + +const MOCK_USAGE_DATA: Usage = { + STANDARD: { + used: 0, + available: 1, + limit: 1, + }, + DEVELOPER: { + used: 0, + available: 1, + limit: 1, + }, +}; + +const MOCK_TYPES = [ + { + name: 'object-schemas', + dependsOn: [], + pushToProductionEnabled: true, + isBeta: false, + diffEnabled: true, + groupType: 'object-schemas', + syncMandatory: true, + }, +]; + +describe('lib/sandboxes', () => { + it('createSandbox()', async () => { + const personalAccessKey = 'pak-test-123'; + createSandbox.mockResolvedValue({ + sandbox: MOCK_SANDBOX_ACCOUNT, + personalAccessKey, + }); + + const response = await createSandboxAction(accountId, sandboxName, 1); + expect(response.personalAccessKey).toEqual(personalAccessKey); + expect(response.name).toEqual(sandboxName); + expect(response.sandbox).toBeTruthy(); + }); + + it('deleteSandbox()', async () => { + const response = await deleteSandboxAction(accountId, sandboxHubId); + expect(response.parentAccountId).toEqual(accountId); + expect(response.sandboxAccountId).toEqual(sandboxHubId); + }); + + it('getSandboxUsageLimits()', async () => { + getSandboxUsageLimits.mockResolvedValue({ + usage: MOCK_USAGE_DATA, + }); + + const response = await getSandboxUsageLimitsAction(accountId); + expect(response).toMatchObject(MOCK_USAGE_DATA); + }); + + it('fetchTypes()', async () => { + fetchTypes.mockResolvedValue({ + results: MOCK_TYPES, + }); + + const response = await fetchTypesAction(accountId, sandboxHubId); + expect(response).toMatchObject(MOCK_TYPES); + }); +}); diff --git a/lib/sandboxes.ts b/lib/sandboxes.ts index 86077634..14b17d21 100644 --- a/lib/sandboxes.ts +++ b/lib/sandboxes.ts @@ -16,15 +16,13 @@ import { Task, Usage, } from '../types/Sandbox'; -import { throwErrorWithMessage } from '../errors/standardErrors'; -import { BaseError } from '../types/Error'; - -const i18nKey = 'lib.sandboxes'; +import { AxiosError } from 'axios'; +import { throwApiError } from '../errors/apiErrors'; export async function createSandbox( accountId: number, name: string, - type: string + type: 1 | 2 ): Promise<{ name: string; sandbox: Sandbox; @@ -37,11 +35,7 @@ export async function createSandbox( ...resp, }; } catch (err) { - throwErrorWithMessage( - `${i18nKey}.errors.createSandbox`, - {}, - err as BaseError - ); + throwApiError(err as AxiosError); } } @@ -52,11 +46,7 @@ export async function deleteSandbox( try { await _deleteSandbox(parentAccountId, sandboxAccountId); } catch (err) { - throwErrorWithMessage( - `${i18nKey}.errors.deleteSandbox`, - {}, - err as BaseError - ); + throwApiError(err as AxiosError); } return { @@ -72,11 +62,7 @@ export async function getSandboxUsageLimits( const resp = await _getSandboxUsageLimits(parentAccountId); return resp && resp.usage; } catch (err) { - throwErrorWithMessage( - `${i18nKey}.errors.getSandboxUsageLimits`, - {}, - err as BaseError - ); + throwApiError(err as AxiosError); } } @@ -89,11 +75,7 @@ export async function initiateSync( try { return await _initiateSync(fromHubId, toHubId, tasks, sandboxHubId); } catch (err) { - throwErrorWithMessage( - `${i18nKey}.errors.initiateSync`, - {}, - err as BaseError - ); + throwApiError(err as AxiosError); } } @@ -104,11 +86,7 @@ export async function fetchTaskStatus( try { return await _fetchTaskStatus(accountId, taskId); } catch (err) { - throwErrorWithMessage( - `${i18nKey}.errors.fetchTaskStatus`, - {}, - err as BaseError - ); + throwApiError(err as AxiosError); } } @@ -120,6 +98,6 @@ export async function fetchTypes( const resp = await _fetchTypes(accountId, toHubId); return resp && resp.results; } catch (err) { - throwErrorWithMessage(`${i18nKey}.errors.fetchTypes`, {}, err as BaseError); + throwApiError(err as AxiosError); } } diff --git a/types/Sandbox.ts b/types/Sandbox.ts index a6dabb92..721dd89c 100644 --- a/types/Sandbox.ts +++ b/types/Sandbox.ts @@ -7,9 +7,9 @@ type User = { userId: number; firstName: string; lastName: string; - gdprDeleted: boolean; - removed: boolean; - deactivated: boolean; + gdprDeleted?: boolean; + removed?: boolean; + deactivated?: boolean; }; type TaskError = { @@ -84,15 +84,15 @@ export type Sandbox = { sandboxHubId: number; parentHubId: number; createdAt: string; - updatedAt: string | null; - archivedAt: string | null; + updatedAt?: string | null; + archivedAt?: string | null; type: string; archived: boolean; name: string; domain: string; createdByUser: User; - updatedByUser: User | null; - lastSync: { + updatedByUser?: User | null; + lastSync?: { id: string; parentHubId: number; sandboxHubId: number; @@ -109,10 +109,10 @@ export type Sandbox = { completedAt: string; tasks: Array; }; - currentUserHasAccess: boolean | null; - currentUserHasSuperAdminAccess: boolean | null; - requestAccessFrom: User | null; - superAdminsInSandbox: number | null; + currentUserHasAccess?: boolean; + currentUserHasSuperAdminAccess?: boolean; + requestAccessFrom?: User | null; + superAdminsInSandbox?: number; }; export type SandboxResponse = { @@ -164,10 +164,11 @@ export type InitiateSyncResponse = { export type SandboxType = { name: string; dependsOn: Array; - pushToParentEnabled: boolean; + pushToProductionEnabled: boolean; isBeta: boolean; diffEnabled: boolean; groupType: string; + syncMandatory: boolean; }; export type FetchTypesResponse = { diff --git a/types/Utils.ts b/types/Utils.ts index a83e4c18..64aac4b9 100644 --- a/types/Utils.ts +++ b/types/Utils.ts @@ -9,5 +9,5 @@ type Join = K extends string | number export type Leaves = [10] extends [never] ? never : T extends object - ? { [K in keyof T]-?: Join> }[keyof T] - : ''; + ? { [K in keyof T]-?: Join> }[keyof T] + : '';