From fc1202ee40d353a507aad8247c5ab69d7cebb39a Mon Sep 17 00:00:00 2001 From: Erdi Maden Date: Tue, 14 May 2024 13:58:14 -0500 Subject: [PATCH 1/7] Adding Address Class with tests and updating faucet_transaction test file name --- src/coinbase/address.ts | 87 +++++++++++++++++++ src/coinbase/tests/address_test.ts | 60 +++++++++++++ ...ion_test.ts => faucet_transaction_test.ts} | 1 - src/coinbase/types.ts | 17 ++++ 4 files changed, 164 insertions(+), 1 deletion(-) create mode 100644 src/coinbase/address.ts create mode 100644 src/coinbase/tests/address_test.ts rename src/coinbase/tests/{faucetTransaction_test.ts => faucet_transaction_test.ts} (95%) diff --git a/src/coinbase/address.ts b/src/coinbase/address.ts new file mode 100644 index 00000000..e8a30b50 --- /dev/null +++ b/src/coinbase/address.ts @@ -0,0 +1,87 @@ +import { Address as AddressModel } from "../client"; +import { InternalError } from "./errors"; +import { FaucetTransaction } from "./faucet_transaction"; +import { AddressClient } from "./types"; + +/** + * Class representing an Address in the Coinbase SDK. + */ +export class Address { + private model: AddressModel; + private client: AddressClient; + + /** + * Creates an instance of Address. + * @param {AddressModel} model - The address model data. + * @param {AddressClient} client - The API client to interact with address-related endpoints. + * @throws {InternalError} If the model or client is empty. + */ + constructor(model: AddressModel, client: AddressClient) { + if (!model) { + throw new InternalError("Address model cannot be empty"); + } + if (!client) { + throw new InternalError("Address client cannot be empty"); + } + this.model = model; + this.client = client; + } + + /** + * Requests faucet funds for the address. + * @returns {Promise} The faucet transaction object. + * @throws {InternalError} If the request does not return a transaction hash. + * @throws {Error} If the request fails. + */ + async faucet(): Promise { + try { + const response = await this.client.requestFaucetFunds( + this.model.wallet_id, + this.model.address_id, + ); + return new FaucetTransaction(response.data); + } catch (e) { + throw new Error(`Failed to request faucet funds`); + } + } + + /** + * Gets the address ID. + * @returns {string} The address ID. + */ + public getId(): string { + return this.model.address_id; + } + + /** + * Gets the network ID. + * @returns {string} The network ID. + */ + public getNetworkId(): string { + return this.model.network_id; + } + + /** + * Gets the public key. + * @returns {string} The public key. + */ + public getPublicKey(): string { + return this.model.public_key; + } + + /** + * Gets the wallet ID. + * @returns {string} The wallet ID. + */ + public getWalletId(): string { + return this.model.wallet_id; + } + + /** + * Returns a string representation of the address. + * @returns {string} A string representing the address. + */ + public toString(): string { + return `Coinbase:Address{addressId: '${this.model.address_id}', networkId: '${this.model.network_id}', walletId: '${this.model.wallet_id}'}`; + } +} diff --git a/src/coinbase/tests/address_test.ts b/src/coinbase/tests/address_test.ts new file mode 100644 index 00000000..f4a987f7 --- /dev/null +++ b/src/coinbase/tests/address_test.ts @@ -0,0 +1,60 @@ +import { AddressesApiFactory, Address as AddressModel } from "../../client"; +import { Address } from "./../address"; +import { FaucetTransaction } from "./../faucet_transaction"; + +import axios from "axios"; +import MockAdapter from "axios-mock-adapter"; + +const axiosMock = new MockAdapter(axios); + +const VALID_ADDRESS_MODEL: AddressModel = { + address_id: "mocked_address_id", + network_id: "mocked_network_id", + public_key: "mocked_public_key", + wallet_id: "mocked_wallet_id", +}; + +// Test suite for Address class +describe("Address", () => { + const client = AddressesApiFactory(); + + it("should create an Address instance", () => { + const address = new Address(VALID_ADDRESS_MODEL, client); + expect(address).toBeInstanceOf(Address); + expect(address.getId()).toBe("mocked_address_id"); + expect(address.getNetworkId()).toBe("mocked_network_id"); + expect(address.getPublicKey()).toBe("mocked_public_key"); + expect(address.getWalletId()).toBe("mocked_wallet_id"); + }); + + it("should throw an InternalError if model is not provided", () => { + expect(() => new Address(null!, client)).toThrow(`Address model cannot be empty`); + }); + + it("should throw an InternalError if client is not provided", () => { + expect(() => new Address(VALID_ADDRESS_MODEL, null!)).toThrow(`Address client cannot be empty`); + }); + + it("should request faucet funds and return a FaucetTransaction", async () => { + axiosMock.onPost().reply(200, { + transaction_hash: "mocked_transaction_hash", + }); + const address = new Address(VALID_ADDRESS_MODEL, client); + const faucetTransaction = await address.faucet(); + expect(faucetTransaction).toBeInstanceOf(FaucetTransaction); + expect(faucetTransaction.getTransactionHash()).toBe("mocked_transaction_hash"); + }); + + it("should throw an error if faucet request fails", async () => { + axiosMock.onPost().reply(400); + const address = new Address(VALID_ADDRESS_MODEL, client); + await expect(address.faucet()).rejects.toThrow("Failed to request faucet funds"); + }); + + it("should return the correct string representation", () => { + const address = new Address(VALID_ADDRESS_MODEL, client); + expect(address.toString()).toBe( + "Coinbase:Address{addressId: 'mocked_address_id', networkId: 'mocked_network_id', walletId: 'mocked_wallet_id'}", + ); + }); +}); diff --git a/src/coinbase/tests/faucetTransaction_test.ts b/src/coinbase/tests/faucet_transaction_test.ts similarity index 95% rename from src/coinbase/tests/faucetTransaction_test.ts rename to src/coinbase/tests/faucet_transaction_test.ts index 0dc9ad64..78b6dab8 100644 --- a/src/coinbase/tests/faucetTransaction_test.ts +++ b/src/coinbase/tests/faucet_transaction_test.ts @@ -1,4 +1,3 @@ -import { InternalError } from "./../errors"; import { FaucetTransaction } from "../faucet_transaction"; describe("FaucetTransaction tests", () => { diff --git a/src/coinbase/types.ts b/src/coinbase/types.ts index d5e0ad34..46395f7c 100644 --- a/src/coinbase/types.ts +++ b/src/coinbase/types.ts @@ -1,6 +1,23 @@ import { AxiosPromise, AxiosRequestConfig } from "axios"; import { User as UserModel } from "./../client/api"; +/** + * AddressAPI client type definition. + */ +export type AddressClient = { + /** + * Requests faucet funds for the address. + * @param {string} walletId - The wallet ID. + * @param {string} addressId - The address ID. + * @returns {Promise<{ data: { transaction_hash: string } }>} - The transaction hash + * @throws {Error} If the request fails. + */ + requestFaucetFunds( + walletId: string, + addressId: string, + ): Promise<{ data: { transaction_hash: string } }>; +}; + /** * UserAPI client type definition. */ From 19563da2c6318e9405dced21e19a5055e304f1b3 Mon Sep 17 00:00:00 2001 From: Erdi Maden Date: Tue, 14 May 2024 15:54:24 -0500 Subject: [PATCH 2/7] Updating JSDocs --- src/coinbase/address.ts | 28 ++++++++++++++++++---------- src/coinbase/faucet_transaction.ts | 2 +- src/coinbase/tests/address_test.ts | 13 +++++++++++-- src/coinbase/types.ts | 2 +- 4 files changed, 31 insertions(+), 14 deletions(-) diff --git a/src/coinbase/address.ts b/src/coinbase/address.ts index e8a30b50..e299f733 100644 --- a/src/coinbase/address.ts +++ b/src/coinbase/address.ts @@ -1,22 +1,26 @@ +import { AxiosError } from "axios"; import { Address as AddressModel } from "../client"; import { InternalError } from "./errors"; import { FaucetTransaction } from "./faucet_transaction"; -import { AddressClient } from "./types"; +import { AddressAPIClient } from "./types"; /** * Class representing an Address in the Coinbase SDK. + * A representation of a blockchain Address, which is a user-controlled account on a Network. Addresses are used to + * send and receive Assets, and should be created using Wallet#create_address. Addresses require an + * Eth::Key to sign transaction data. */ export class Address { private model: AddressModel; - private client: AddressClient; + private client: AddressAPIClient; /** - * Creates an instance of Address. + * Initializes a new Address instance. * @param {AddressModel} model - The address model data. - * @param {AddressClient} client - The API client to interact with address-related endpoints. + * @param {AddressAPIClient} client - The API client to interact with address-related endpoints. * @throws {InternalError} If the model or client is empty. */ - constructor(model: AddressModel, client: AddressClient) { + constructor(model: AddressModel, client: AddressAPIClient) { if (!model) { throw new InternalError("Address model cannot be empty"); } @@ -29,6 +33,7 @@ export class Address { /** * Requests faucet funds for the address. + * Only supported on testnet networks. * @returns {Promise} The faucet transaction object. * @throws {InternalError} If the request does not return a transaction hash. * @throws {Error} If the request fails. @@ -41,12 +46,15 @@ export class Address { ); return new FaucetTransaction(response.data); } catch (e) { - throw new Error(`Failed to request faucet funds`); + if (e instanceof AxiosError) { + throw e; + } + throw new Error(`Failed to complete faucet request`); } } /** - * Gets the address ID. + * Returns the address ID. * @returns {string} The address ID. */ public getId(): string { @@ -54,7 +62,7 @@ export class Address { } /** - * Gets the network ID. + * Returns the network ID. * @returns {string} The network ID. */ public getNetworkId(): string { @@ -62,7 +70,7 @@ export class Address { } /** - * Gets the public key. + * Returns the public key. * @returns {string} The public key. */ public getPublicKey(): string { @@ -70,7 +78,7 @@ export class Address { } /** - * Gets the wallet ID. + * Returns the wallet ID. * @returns {string} The wallet ID. */ public getWalletId(): string { diff --git a/src/coinbase/faucet_transaction.ts b/src/coinbase/faucet_transaction.ts index a0da023b..877162b3 100644 --- a/src/coinbase/faucet_transaction.ts +++ b/src/coinbase/faucet_transaction.ts @@ -15,7 +15,7 @@ export class FaucetTransaction { * @throws {InternalError} If the model does not exist. */ constructor(model: FaucetTransactionModel) { - if (!model) { + if (!model?.transaction_hash) { throw new InternalError("FaucetTransaction model cannot be empty"); } this.model = model; diff --git a/src/coinbase/tests/address_test.ts b/src/coinbase/tests/address_test.ts index f4a987f7..dfa0a379 100644 --- a/src/coinbase/tests/address_test.ts +++ b/src/coinbase/tests/address_test.ts @@ -18,6 +18,10 @@ const VALID_ADDRESS_MODEL: AddressModel = { describe("Address", () => { const client = AddressesApiFactory(); + afterEach(() => { + axiosMock.reset(); + }); + it("should create an Address instance", () => { const address = new Address(VALID_ADDRESS_MODEL, client); expect(address).toBeInstanceOf(Address); @@ -44,11 +48,16 @@ describe("Address", () => { expect(faucetTransaction).toBeInstanceOf(FaucetTransaction); expect(faucetTransaction.getTransactionHash()).toBe("mocked_transaction_hash"); }); + it("should request faucet funds and return a FaucetTransactionaaa", async () => { + axiosMock.onPost().reply(200, {}); + const address = new Address(VALID_ADDRESS_MODEL, client); + await expect(address.faucet()).rejects.toThrow("Failed to complete faucet request"); + }); - it("should throw an error if faucet request fails", async () => { + it("should throw an AxiosError if faucet request fails", async () => { axiosMock.onPost().reply(400); const address = new Address(VALID_ADDRESS_MODEL, client); - await expect(address.faucet()).rejects.toThrow("Failed to request faucet funds"); + await expect(address.faucet()).rejects.toThrow("Request failed with status code 400"); }); it("should return the correct string representation", () => { diff --git a/src/coinbase/types.ts b/src/coinbase/types.ts index 46395f7c..af89d59b 100644 --- a/src/coinbase/types.ts +++ b/src/coinbase/types.ts @@ -4,7 +4,7 @@ import { User as UserModel } from "./../client/api"; /** * AddressAPI client type definition. */ -export type AddressClient = { +export type AddressAPIClient = { /** * Requests faucet funds for the address. * @param {string} walletId - The wallet ID. From d52fc661e09032881ccc8c9a6886334823871758 Mon Sep 17 00:00:00 2001 From: Erdi Maden Date: Tue, 14 May 2024 15:59:43 -0500 Subject: [PATCH 3/7] Updating error type in types file --- src/coinbase/tests/address_test.ts | 3 ++- src/coinbase/types.ts | 4 ++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/coinbase/tests/address_test.ts b/src/coinbase/tests/address_test.ts index dfa0a379..52fe09dd 100644 --- a/src/coinbase/tests/address_test.ts +++ b/src/coinbase/tests/address_test.ts @@ -48,7 +48,8 @@ describe("Address", () => { expect(faucetTransaction).toBeInstanceOf(FaucetTransaction); expect(faucetTransaction.getTransactionHash()).toBe("mocked_transaction_hash"); }); - it("should request faucet funds and return a FaucetTransactionaaa", async () => { + + it("should request faucet funds and throw an InternalError if the request does not return a transaction hash", async () => { axiosMock.onPost().reply(200, {}); const address = new Address(VALID_ADDRESS_MODEL, client); await expect(address.faucet()).rejects.toThrow("Failed to complete faucet request"); diff --git a/src/coinbase/types.ts b/src/coinbase/types.ts index af89d59b..d0e175de 100644 --- a/src/coinbase/types.ts +++ b/src/coinbase/types.ts @@ -10,7 +10,7 @@ export type AddressAPIClient = { * @param {string} walletId - The wallet ID. * @param {string} addressId - The address ID. * @returns {Promise<{ data: { transaction_hash: string } }>} - The transaction hash - * @throws {Error} If the request fails. + * @throws {AxiosError} If the request fails. */ requestFaucetFunds( walletId: string, @@ -26,7 +26,7 @@ export type UserAPIClient = { * Retrieves the current user. * @param {AxiosRequestConfig} [options] - Axios request options. * @returns {AxiosPromise} - A promise resolving to the User model. - * @throws {Error} If the request fails. + * @throws {AxiosError} If the request fails. */ getCurrentUser(options?: AxiosRequestConfig): AxiosPromise; }; From 4cc5c7b3efa344302fa3a9f1f741ae932403e1a0 Mon Sep 17 00:00:00 2001 From: Erdi Maden Date: Tue, 14 May 2024 17:17:35 -0500 Subject: [PATCH 4/7] Updating Address JSDoc --- src/coinbase/address.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/coinbase/address.ts b/src/coinbase/address.ts index e299f733..85be8a09 100644 --- a/src/coinbase/address.ts +++ b/src/coinbase/address.ts @@ -5,10 +5,7 @@ import { FaucetTransaction } from "./faucet_transaction"; import { AddressAPIClient } from "./types"; /** - * Class representing an Address in the Coinbase SDK. - * A representation of a blockchain Address, which is a user-controlled account on a Network. Addresses are used to - * send and receive Assets, and should be created using Wallet#create_address. Addresses require an - * Eth::Key to sign transaction data. + * A representation of a blockchain address, which is a user-controlled account on a network. */ export class Address { private model: AddressModel; From d7af21da1858a068fb6e5c6fb945075b8ec436dc Mon Sep 17 00:00:00 2001 From: Erdi Maden Date: Tue, 14 May 2024 20:32:59 -0500 Subject: [PATCH 5/7] Updating mock Address Model object --- src/coinbase/tests/address_test.ts | 24 ++++++++++++++---------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/src/coinbase/tests/address_test.ts b/src/coinbase/tests/address_test.ts index 52fe09dd..70a55393 100644 --- a/src/coinbase/tests/address_test.ts +++ b/src/coinbase/tests/address_test.ts @@ -1,17 +1,21 @@ +import { ethers } from "ethers"; import { AddressesApiFactory, Address as AddressModel } from "../../client"; import { Address } from "./../address"; import { FaucetTransaction } from "./../faucet_transaction"; import axios from "axios"; import MockAdapter from "axios-mock-adapter"; +import { randomUUID } from "crypto"; const axiosMock = new MockAdapter(axios); +const newEthAddress = ethers.Wallet.createRandom(); + const VALID_ADDRESS_MODEL: AddressModel = { - address_id: "mocked_address_id", - network_id: "mocked_network_id", - public_key: "mocked_public_key", - wallet_id: "mocked_wallet_id", + address_id: newEthAddress.address, + network_id: "SEPOLIA", + public_key: newEthAddress.publicKey, + wallet_id: randomUUID(), }; // Test suite for Address class @@ -25,10 +29,10 @@ describe("Address", () => { it("should create an Address instance", () => { const address = new Address(VALID_ADDRESS_MODEL, client); expect(address).toBeInstanceOf(Address); - expect(address.getId()).toBe("mocked_address_id"); - expect(address.getNetworkId()).toBe("mocked_network_id"); - expect(address.getPublicKey()).toBe("mocked_public_key"); - expect(address.getWalletId()).toBe("mocked_wallet_id"); + expect(address.getId()).toBe(newEthAddress.address); + expect(address.getPublicKey()).toBe(newEthAddress.publicKey); + expect(address.getNetworkId()).toBe(VALID_ADDRESS_MODEL.network_id); + expect(address.getWalletId()).toBe(VALID_ADDRESS_MODEL.wallet_id); }); it("should throw an InternalError if model is not provided", () => { @@ -48,7 +52,7 @@ describe("Address", () => { expect(faucetTransaction).toBeInstanceOf(FaucetTransaction); expect(faucetTransaction.getTransactionHash()).toBe("mocked_transaction_hash"); }); - + it("should request faucet funds and throw an InternalError if the request does not return a transaction hash", async () => { axiosMock.onPost().reply(200, {}); const address = new Address(VALID_ADDRESS_MODEL, client); @@ -64,7 +68,7 @@ describe("Address", () => { it("should return the correct string representation", () => { const address = new Address(VALID_ADDRESS_MODEL, client); expect(address.toString()).toBe( - "Coinbase:Address{addressId: 'mocked_address_id', networkId: 'mocked_network_id', walletId: 'mocked_wallet_id'}", + `Coinbase:Address{addressId: '${VALID_ADDRESS_MODEL.address_id}', networkId: '${VALID_ADDRESS_MODEL.network_id}', walletId: '${VALID_ADDRESS_MODEL.wallet_id}'}`, ); }); }); From 6598071dc2b708363640fca9e642746a540ed1b5 Mon Sep 17 00:00:00 2001 From: Erdi Maden Date: Tue, 14 May 2024 21:58:03 -0500 Subject: [PATCH 6/7] Updating mock value --- src/coinbase/tests/address_test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/coinbase/tests/address_test.ts b/src/coinbase/tests/address_test.ts index 70a55393..784b27cb 100644 --- a/src/coinbase/tests/address_test.ts +++ b/src/coinbase/tests/address_test.ts @@ -13,7 +13,7 @@ const newEthAddress = ethers.Wallet.createRandom(); const VALID_ADDRESS_MODEL: AddressModel = { address_id: newEthAddress.address, - network_id: "SEPOLIA", + network_id: "base-sepolia", public_key: newEthAddress.publicKey, wallet_id: randomUUID(), }; From 7cd9c7dbaca6b3f084aaf36d843ec0e364e038aa Mon Sep 17 00:00:00 2001 From: Erdi Maden Date: Wed, 15 May 2024 15:11:47 -0500 Subject: [PATCH 7/7] - Adding APIErrors Class to handle HTTP errors globally, - Creating createAxiosMock and registerAxiosInterceptors functions - Updating Address and Coinbase class test cases --- src/coinbase/address.ts | 18 ++-- src/coinbase/api_error.ts | 130 ++++++++++++++++++++++++++++ src/coinbase/coinbase.ts | 24 +++-- src/coinbase/tests/address_test.ts | 73 +++++++++++----- src/coinbase/tests/coinbase_test.ts | 5 +- src/coinbase/tests/utils.ts | 24 +++++ src/coinbase/utils.ts | 37 +++++++- 7 files changed, 259 insertions(+), 52 deletions(-) create mode 100644 src/coinbase/api_error.ts create mode 100644 src/coinbase/tests/utils.ts diff --git a/src/coinbase/address.ts b/src/coinbase/address.ts index 85be8a09..524c3fb8 100644 --- a/src/coinbase/address.ts +++ b/src/coinbase/address.ts @@ -1,4 +1,3 @@ -import { AxiosError } from "axios"; import { Address as AddressModel } from "../client"; import { InternalError } from "./errors"; import { FaucetTransaction } from "./faucet_transaction"; @@ -36,18 +35,11 @@ export class Address { * @throws {Error} If the request fails. */ async faucet(): Promise { - try { - const response = await this.client.requestFaucetFunds( - this.model.wallet_id, - this.model.address_id, - ); - return new FaucetTransaction(response.data); - } catch (e) { - if (e instanceof AxiosError) { - throw e; - } - throw new Error(`Failed to complete faucet request`); - } + const response = await this.client.requestFaucetFunds( + this.model.wallet_id, + this.model.address_id, + ); + return new FaucetTransaction(response.data); } /** diff --git a/src/coinbase/api_error.ts b/src/coinbase/api_error.ts new file mode 100644 index 00000000..96d2683a --- /dev/null +++ b/src/coinbase/api_error.ts @@ -0,0 +1,130 @@ +import { AxiosError } from "axios"; +import { InternalError } from "./errors"; + +/** + * The API error response type. + */ +type APIErrorResponseType = { + code: string; + message: string; +}; + +/** + * A wrapper for API errors to provide more context. + */ +export class APIError extends AxiosError { + httpCode: number | null; + apiCode: string | null; + apiMessage: string | null; + + /** + * Initializes a new APIError object. + * @constructor + * @param {AxiosError} error - The Axios error. + */ + constructor(error) { + super(); + this.name = this.constructor.name; + this.httpCode = error.response ? error.response.status : null; + this.apiCode = null; + this.apiMessage = null; + + if (error.response && error.response.data) { + const body = error.response.data; + this.apiCode = body.code; + this.apiMessage = body.message; + } + } + + /** + * Creates a specific APIError based on the API error code. + * @param {AxiosError} error - The underlying error object. + * @returns {APIError} A specific APIError instance. + */ + static fromError(error: AxiosError) { + const apiError = new APIError(error); + if (!error.response || !error.response.data) { + return apiError; + } + + const body = error?.response?.data as APIErrorResponseType; + switch (body?.code) { + case "unimplemented": + return new UnimplementedError(error); + case "unauthorized": + return new UnauthorizedError(error); + case "internal": + return new InternalError(error.message); + case "not_found": + return new NotFoundError(error); + case "invalid_wallet_id": + return new InvalidWalletIDError(error); + case "invalid_address_id": + return new InvalidAddressIDError(error); + case "invalid_wallet": + return new InvalidWalletError(error); + case "invalid_address": + return new InvalidAddressError(error); + case "invalid_amount": + return new InvalidAmountError(error); + case "invalid_transfer_id": + return new InvalidTransferIDError(error); + case "invalid_page_token": + return new InvalidPageError(error); + case "invalid_page_limit": + return new InvalidLimitError(error); + case "already_exists": + return new AlreadyExistsError(error); + case "malformed_request": + return new MalformedRequestError(error); + case "unsupported_asset": + return new UnsupportedAssetError(error); + case "invalid_asset_id": + return new InvalidAssetIDError(error); + case "invalid_destination": + return new InvalidDestinationError(error); + case "invalid_network_id": + return new InvalidNetworkIDError(error); + case "resource_exhausted": + return new ResourceExhaustedError(error); + case "faucet_limit_reached": + return new FaucetLimitReachedError(error); + case "invalid_signed_payload": + return new InvalidSignedPayloadError(error); + case "invalid_transfer_status": + return new InvalidTransferStatusError(error); + default: + return apiError; + } + } + + /** + * Returns a String representation of the APIError. + * @returns {string} a String representation of the APIError + */ + toString() { + return `APIError{httpCode: ${this.httpCode}, apiCode: ${this.apiCode}, apiMessage: ${this.apiMessage}}`; + } +} + +export class UnimplementedError extends APIError {} +export class UnauthorizedError extends APIError {} +export class NotFoundError extends APIError {} +export class InvalidWalletIDError extends APIError {} +export class InvalidAddressIDError extends APIError {} +export class InvalidWalletError extends APIError {} +export class InvalidAddressError extends APIError {} +export class InvalidAmountError extends APIError {} +export class InvalidTransferIDError extends APIError {} +export class InvalidPageError extends APIError {} +export class InvalidLimitError extends APIError {} +export class AlreadyExistsError extends APIError {} +export class MalformedRequestError extends APIError {} +export class UnsupportedAssetError extends APIError {} +export class InvalidAssetIDError extends APIError {} +export class InvalidDestinationError extends APIError {} +export class InvalidNetworkIDError extends APIError {} +export class ResourceExhaustedError extends APIError {} +export class FaucetLimitReachedError extends APIError {} +export class InvalidSignedPayloadError extends APIError {} +export class InvalidTransferStatusError extends APIError {} diff --git a/src/coinbase/coinbase.ts b/src/coinbase/coinbase.ts index ea80bc43..00268f59 100644 --- a/src/coinbase/coinbase.ts +++ b/src/coinbase/coinbase.ts @@ -1,13 +1,13 @@ import globalAxios from "axios"; import fs from "fs"; -import { UsersApiFactory, User as UserModel } from "../client"; +import { User as UserModel, UsersApiFactory } from "../client"; import { BASE_PATH } from "./../client/base"; import { Configuration } from "./../client/configuration"; import { CoinbaseAuthenticator } from "./authenticator"; +import { InternalError, InvalidAPIKeyFormat, InvalidConfiguration } from "./errors"; import { ApiClients } from "./types"; import { User } from "./user"; -import { logApiResponse } from "./utils"; -import { InvalidAPIKeyFormat, InternalError, InvalidConfiguration } from "./errors"; +import { logApiResponse, registerAxiosInterceptors } from "./utils"; // The Coinbase SDK. export class Coinbase { @@ -40,10 +40,12 @@ export class Coinbase { basePath: basePath, }); const axiosInstance = globalAxios.create(); - axiosInstance.interceptors.request.use(config => - coinbaseAuthenticator.authenticateRequest(config, debugging), + registerAxiosInterceptors( + axiosInstance, + config => coinbaseAuthenticator.authenticateRequest(config, debugging), + response => logApiResponse(response, debugging), ); - axiosInstance.interceptors.response.use(response => logApiResponse(response, debugging)); + this.apiClients.user = UsersApiFactory(config, BASE_PATH, axiosInstance); } @@ -85,14 +87,10 @@ export class Coinbase { /** * Returns User object for the default user. * @returns {User} The default user. - * @throws {InternalError} If the request fails. + * @throws {APIError} If the request fails. */ async getDefaultUser(): Promise { - try { - const userResponse = await this.apiClients.user!.getCurrentUser(); - return new User(userResponse.data as UserModel, this.apiClients); - } catch (error) { - throw new InternalError(`Failed to retrieve user: ${(error as Error).message}`); - } + const userResponse = await this.apiClients.user!.getCurrentUser(); + return new User(userResponse.data as UserModel, this.apiClients); } } diff --git a/src/coinbase/tests/address_test.ts b/src/coinbase/tests/address_test.ts index 784b27cb..1ebf5fc4 100644 --- a/src/coinbase/tests/address_test.ts +++ b/src/coinbase/tests/address_test.ts @@ -3,11 +3,11 @@ import { AddressesApiFactory, Address as AddressModel } from "../../client"; import { Address } from "./../address"; import { FaucetTransaction } from "./../faucet_transaction"; -import axios from "axios"; import MockAdapter from "axios-mock-adapter"; import { randomUUID } from "crypto"; - -const axiosMock = new MockAdapter(axios); +import { APIError, FaucetLimitReachedError } from "../api_error"; +import { createAxiosMock } from "./utils"; +import { InternalError } from "../errors"; const newEthAddress = ethers.Wallet.createRandom(); @@ -20,53 +20,82 @@ const VALID_ADDRESS_MODEL: AddressModel = { // Test suite for Address class describe("Address", () => { - const client = AddressesApiFactory(); + const [axiosInstance, configuration, BASE_PATH] = createAxiosMock(); + const client = AddressesApiFactory(configuration, BASE_PATH, axiosInstance); + let address, axiosMock; + + beforeAll(() => { + axiosMock = new MockAdapter(axiosInstance); + }); + + beforeEach(() => { + address = new Address(VALID_ADDRESS_MODEL, client); + }); afterEach(() => { axiosMock.reset(); }); - it("should create an Address instance", () => { - const address = new Address(VALID_ADDRESS_MODEL, client); + it("should initialize a new Address", () => { expect(address).toBeInstanceOf(Address); + }); + + it("should return the network ID", () => { expect(address.getId()).toBe(newEthAddress.address); - expect(address.getPublicKey()).toBe(newEthAddress.publicKey); + }); + + it("should return the address ID", () => { expect(address.getNetworkId()).toBe(VALID_ADDRESS_MODEL.network_id); + }); + + it("should return the public key", () => { + expect(address.getPublicKey()).toBe(newEthAddress.publicKey); + }); + + it("should return the wallet ID", () => { expect(address.getWalletId()).toBe(VALID_ADDRESS_MODEL.wallet_id); }); - it("should throw an InternalError if model is not provided", () => { + it("should throw an InternalError when model is not provided", () => { expect(() => new Address(null!, client)).toThrow(`Address model cannot be empty`); }); - it("should throw an InternalError if client is not provided", () => { + it("should throw an InternalError when client is not provided", () => { expect(() => new Address(VALID_ADDRESS_MODEL, null!)).toThrow(`Address client cannot be empty`); }); - it("should request faucet funds and return a FaucetTransaction", async () => { + it("should request funds from the faucet and returns the faucet transaction", async () => { + const transactionHash = "0xdeadbeef"; axiosMock.onPost().reply(200, { - transaction_hash: "mocked_transaction_hash", + transaction_hash: transactionHash, }); - const address = new Address(VALID_ADDRESS_MODEL, client); const faucetTransaction = await address.faucet(); expect(faucetTransaction).toBeInstanceOf(FaucetTransaction); - expect(faucetTransaction.getTransactionHash()).toBe("mocked_transaction_hash"); + expect(faucetTransaction.getTransactionHash()).toBe(transactionHash); + }); + + it("should throw an APIError when the request is unsuccesful", async () => { + axiosMock.onPost().reply(400); + await expect(address.faucet()).rejects.toThrow(APIError); }); - it("should request faucet funds and throw an InternalError if the request does not return a transaction hash", async () => { - axiosMock.onPost().reply(200, {}); - const address = new Address(VALID_ADDRESS_MODEL, client); - await expect(address.faucet()).rejects.toThrow("Failed to complete faucet request"); + it("should throw a FaucetLimitReachedError when the faucet limit is reached", async () => { + axiosMock.onPost().reply(429, { + code: "faucet_limit_reached", + message: "Faucet limit reached", + }); + await expect(address.faucet()).rejects.toThrow(FaucetLimitReachedError); }); - it("should throw an AxiosError if faucet request fails", async () => { - axiosMock.onPost().reply(400); - const address = new Address(VALID_ADDRESS_MODEL, client); - await expect(address.faucet()).rejects.toThrow("Request failed with status code 400"); + it("should throw an InternalError when the request fails unexpectedly", async () => { + axiosMock.onPost().reply(500, { + code: "internal", + message: "unexpected error occurred while requesting faucet funds", + }); + await expect(address.faucet()).rejects.toThrow(InternalError); }); it("should return the correct string representation", () => { - const address = new Address(VALID_ADDRESS_MODEL, client); expect(address.toString()).toBe( `Coinbase:Address{addressId: '${VALID_ADDRESS_MODEL.address_id}', networkId: '${VALID_ADDRESS_MODEL.network_id}', walletId: '${VALID_ADDRESS_MODEL.wallet_id}'}`, ); diff --git a/src/coinbase/tests/coinbase_test.ts b/src/coinbase/tests/coinbase_test.ts index ca5cb423..ae5af200 100644 --- a/src/coinbase/tests/coinbase_test.ts +++ b/src/coinbase/tests/coinbase_test.ts @@ -1,6 +1,7 @@ import { Coinbase } from "../coinbase"; import MockAdapter from "axios-mock-adapter"; import axios from "axios"; +import { APIError } from "../api_error"; const axiosMock = new MockAdapter(axios); const PATH_PREFIX = "./src/coinbase/tests/config"; @@ -54,8 +55,6 @@ describe("Coinbase tests", () => { it("should raise an error if the user is not found", async () => { axiosMock.onGet().reply(404); const cbInstance = Coinbase.configureFromJson(`${PATH_PREFIX}/coinbase_cloud_api_key.json`); - await expect(cbInstance.getDefaultUser()).rejects.toThrow( - "Failed to retrieve user: Request failed with status code 404", - ); + await expect(cbInstance.getDefaultUser()).rejects.toThrow(APIError); }); }); diff --git a/src/coinbase/tests/utils.ts b/src/coinbase/tests/utils.ts new file mode 100644 index 00000000..d9c961b7 --- /dev/null +++ b/src/coinbase/tests/utils.ts @@ -0,0 +1,24 @@ +import axios, { AxiosInstance } from "axios"; +import { Configuration } from "../../client"; +import { BASE_PATH } from "../../client/base"; +import { registerAxiosInterceptors } from "../utils"; + +/** + * AxiosMockReturn type. Represents the Axios instance, configuration, and base path. + */ +type AxiosMockType = [AxiosInstance, Configuration, string]; + +/** + * Returns an Axios instance with interceptors and configuration for testing. + * @returns {AxiosMockType} - The Axios instance, configuration, and base path. + */ +export const createAxiosMock = (): AxiosMockType => { + const axiosInstance = axios.create(); + registerAxiosInterceptors( + axiosInstance, + request => request, + response => response, + ); + const configuration = new Configuration(); + return [axiosInstance, configuration, BASE_PATH]; +}; diff --git a/src/coinbase/utils.ts b/src/coinbase/utils.ts index 42923843..8e34b314 100644 --- a/src/coinbase/utils.ts +++ b/src/coinbase/utils.ts @@ -1,4 +1,6 @@ -import { AxiosResponse } from "axios"; +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { Axios, AxiosResponse, InternalAxiosRequestConfig } from "axios"; +import { APIError } from "./api_error"; /** * Prints Axios response to the console for debugging purposes. @@ -20,3 +22,36 @@ export const logApiResponse = (response: AxiosResponse, debugging = false): Axio } return response; }; + +/** + * Axios Request interceptor function type. + * @param {InternalAxiosRequestConfig} value - The Axios request configuration. + * @returns {InternalAxiosRequestConfig} The modified Axios request configuration. + */ +type RequestFunctionType = ( + value: InternalAxiosRequestConfig, +) => Promise | InternalAxiosRequestConfig; + +/** + * Axios Response interceptor function type. + * @param {AxiosResponse} value - The Axios response object. + * @returns {AxiosResponse} The modified Axios response object. + */ +type ResponseFunctionType = (value: AxiosResponse) => AxiosResponse; + +/** + * + * @param {Axios} axiosInstance - The Axios instance to register the interceptors. + * @param {RequestFunctionType} requestFn - The request interceptor function. + * @param {ResponseFunctionType} responseFn - The response interceptor function. + */ +export const registerAxiosInterceptors = ( + axiosInstance: Axios, + requestFn: RequestFunctionType, + responseFn: ResponseFunctionType, +) => { + axiosInstance.interceptors.request.use(requestFn); + axiosInstance.interceptors.response.use(responseFn, error => { + return Promise.reject(APIError.fromError(error)); + }); +};