Skip to content

Commit

Permalink
feat: Adding Address Class with tests (#10)
Browse files Browse the repository at this point in the history
* Adding Address Class with tests and updating faucet_transaction test file name
* Updating JSDocs
* Updating error type in types file
* Updating mock Address Model object
* Adding APIErrors Class to handle HTTP errors globally 
* Creating createAxiosMock and registerAxiosInterceptors functions
* Updating Address and Coinbase class test cases
  • Loading branch information
erdimaden authored May 15, 2024
1 parent 3efbe1e commit b34089d
Show file tree
Hide file tree
Showing 10 changed files with 409 additions and 20 deletions.
84 changes: 84 additions & 0 deletions src/coinbase/address.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { Address as AddressModel } from "../client";
import { InternalError } from "./errors";
import { FaucetTransaction } from "./faucet_transaction";
import { AddressAPIClient } from "./types";

/**
* A representation of a blockchain address, which is a user-controlled account on a network.
*/
export class Address {
private model: AddressModel;
private client: AddressAPIClient;

/**
* Initializes a new Address instance.
* @param {AddressModel} model - The address model data.
* @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: AddressAPIClient) {
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.
* Only supported on testnet networks.
* @returns {Promise<FaucetTransaction>} The faucet transaction object.
* @throws {InternalError} If the request does not return a transaction hash.
* @throws {Error} If the request fails.
*/
async faucet(): Promise<FaucetTransaction> {
const response = await this.client.requestFaucetFunds(
this.model.wallet_id,
this.model.address_id,
);
return new FaucetTransaction(response.data);
}

/**
* Returns the address ID.
* @returns {string} The address ID.
*/
public getId(): string {
return this.model.address_id;
}

/**
* Returns the network ID.
* @returns {string} The network ID.
*/
public getNetworkId(): string {
return this.model.network_id;
}

/**
* Returns the public key.
* @returns {string} The public key.
*/
public getPublicKey(): string {
return this.model.public_key;
}

/**
* Returns 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}'}`;
}
}
130 changes: 130 additions & 0 deletions src/coinbase/api_error.ts
Original file line number Diff line number Diff line change
@@ -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 {}
24 changes: 11 additions & 13 deletions src/coinbase/coinbase.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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);
}

Expand Down Expand Up @@ -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<User> {
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);
}
}
2 changes: 1 addition & 1 deletion src/coinbase/faucet_transaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
103 changes: 103 additions & 0 deletions src/coinbase/tests/address_test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import { ethers } from "ethers";
import { AddressesApiFactory, Address as AddressModel } from "../../client";
import { Address } from "./../address";
import { FaucetTransaction } from "./../faucet_transaction";

import MockAdapter from "axios-mock-adapter";
import { randomUUID } from "crypto";
import { APIError, FaucetLimitReachedError } from "../api_error";
import { createAxiosMock } from "./utils";
import { InternalError } from "../errors";

const newEthAddress = ethers.Wallet.createRandom();

const VALID_ADDRESS_MODEL: AddressModel = {
address_id: newEthAddress.address,
network_id: "base-sepolia",
public_key: newEthAddress.publicKey,
wallet_id: randomUUID(),
};

// Test suite for Address class
describe("Address", () => {
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 initialize a new Address", () => {
expect(address).toBeInstanceOf(Address);
});

it("should return the network ID", () => {
expect(address.getId()).toBe(newEthAddress.address);
});

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 when model is not provided", () => {
expect(() => new Address(null!, client)).toThrow(`Address model cannot be empty`);
});

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 funds from the faucet and returns the faucet transaction", async () => {
const transactionHash = "0xdeadbeef";
axiosMock.onPost().reply(200, {
transaction_hash: transactionHash,
});
const faucetTransaction = await address.faucet();
expect(faucetTransaction).toBeInstanceOf(FaucetTransaction);
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 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 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", () => {
expect(address.toString()).toBe(
`Coinbase:Address{addressId: '${VALID_ADDRESS_MODEL.address_id}', networkId: '${VALID_ADDRESS_MODEL.network_id}', walletId: '${VALID_ADDRESS_MODEL.wallet_id}'}`,
);
});
});
5 changes: 2 additions & 3 deletions src/coinbase/tests/coinbase_test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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);
});
});
Loading

0 comments on commit b34089d

Please sign in to comment.