Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ Currently, with this SDK you can:
- Suppressions management
- Account access management
- Permissions management
- Billing usage management
- List accounts you have access to


Expand Down Expand Up @@ -178,6 +179,7 @@ Refer to the [`examples`](examples) folder for the source code of this and other
- [List User & Invite account accesses](examples/general/account-accesses.ts)
- [Remove account access](examples/general/accounts.ts)
- [Permissions](examples/general/permissions.ts)
- [Billing usage](examples/general/billing.ts)

## Development

Expand Down
20 changes: 20 additions & 0 deletions examples/general/billing.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { MailtrapClient } from "mailtrap"

const TOKEN = "<YOUR-TOKEN-HERE>";
const TEST_INBOX_ID = "<YOUR-TEST-INBOX-ID-HERE>"
const ACCOUNT_ID = "<YOUR-ACCOUNT-ID-HERE>"

const client = new MailtrapClient({ token: TOKEN, testInboxId: TEST_INBOX_ID, accountId: ACCOUNT_ID });

const billingClient = client.general.billing

const testBillingCycleUsage = async () => {
try {
const result = await billingClient.getCurrentBillingCycleUsage()
console.log("Billing cycle usage:", JSON.stringify(result, null, 2))
} catch (error) {
console.error(error)
}
}

testBillingCycleUsage()
19 changes: 19 additions & 0 deletions src/__tests__/lib/api/General.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,13 @@ describe("lib/api/General: ", () => {
expect(generalAPI).toHaveProperty("accountAccesses");
expect(generalAPI).toHaveProperty("accounts");
expect(generalAPI).toHaveProperty("permissions");
expect(generalAPI).toHaveProperty("billing");
});

it("lazily instantiates account-specific APIs via getters when accountId is provided.", () => {
expect(generalAPI.accountAccesses).toBeDefined();
expect(generalAPI.permissions).toBeDefined();
expect(generalAPI.billing).toBeDefined();
expect(generalAPI.accounts).toBeDefined();
});
});
Expand Down Expand Up @@ -56,6 +58,15 @@ describe("lib/api/General: ", () => {
"Account ID is required for this operation. Please provide accountId when creating GeneralAPI instance."
);
});

it("throws error when accessing billing without accountId.", () => {
expect(() => {
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
generalAPI.billing;
}).toThrow(
"Account ID is required for this operation. Please provide accountId when creating GeneralAPI instance."
);
});
});

describe("account discovery functionality: ", () => {
Expand All @@ -78,10 +89,14 @@ describe("lib/api/General: ", () => {
it("maintains existing API surface for account-specific operations.", () => {
expect(generalAPI.accountAccesses).toBeDefined();
expect(generalAPI.permissions).toBeDefined();
expect(generalAPI.billing).toBeDefined();
expect(typeof generalAPI.accountAccesses.listAccountAccesses).toBe(
"function"
);
expect(typeof generalAPI.permissions.getResources).toBe("function");
expect(typeof generalAPI.billing.getCurrentBillingCycleUsage).toBe(
"function"
);
});
});

Expand Down Expand Up @@ -109,10 +124,14 @@ describe("lib/api/General: ", () => {
expect(generalAPI.accounts).toBeDefined();
expect(generalAPI.accountAccesses).toBeDefined();
expect(generalAPI.permissions).toBeDefined();
expect(generalAPI.billing).toBeDefined();
expect(typeof generalAPI.accountAccesses.listAccountAccesses).toBe(
"function"
);
expect(typeof generalAPI.permissions.getResources).toBe("function");
expect(typeof generalAPI.billing.getCurrentBillingCycleUsage).toBe(
"function"
);
});
});
});
Expand Down
155 changes: 155 additions & 0 deletions src/__tests__/lib/api/resources/Billing.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
import axios from "axios";
import AxiosMockAdapter from "axios-mock-adapter";

import Billing from "../../../../lib/api/resources/Billing";
import handleSendingError from "../../../../lib/axios-logger";
import MailtrapError from "../../../../lib/MailtrapError";

import CONFIG from "../../../../config";

const { CLIENT_SETTINGS } = CONFIG;
const { GENERAL_ENDPOINT } = CLIENT_SETTINGS;

describe("lib/api/resources/Billing: ", () => {
let mock: AxiosMockAdapter;
const accountId = 100;
const billingAPI = new Billing(axios, accountId);
const responseData = {
billing: {
cycle_start: "2024-01-01T00:00:00Z",
cycle_end: "2024-01-31T23:59:59Z",
},
sending: {
plan: {
name: "Pro",
},
usage: {
sent_messages_count: {
current: 1000,
limit: 10000,
},
},
},
testing: {
plan: {
name: "Pro",
},
usage: {
sent_messages_count: {
current: 500,
limit: 5000,
},
forwarded_messages_count: {
current: 200,
limit: 2000,
},
},
},
};

describe("class Billing(): ", () => {
describe("init: ", () => {
it("initializes with all necessary params.", () => {
expect(billingAPI).toHaveProperty("getCurrentBillingCycleUsage");
});
});
});

beforeAll(() => {
/**
* Init Axios interceptors for handling response.data, errors.
*/
axios.interceptors.response.use(
(response) => response.data,
handleSendingError
);
mock = new AxiosMockAdapter(axios);
});

afterEach(() => {
mock.reset();
});

describe("getCurrentBillingCycleUsage(): ", () => {
it("successfully gets billing cycle usage.", async () => {
const endpoint = `${GENERAL_ENDPOINT}/api/accounts/${accountId}/billing/usage`;

expect.assertions(2);

mock.onGet(endpoint).reply(200, responseData);
const result = await billingAPI.getCurrentBillingCycleUsage();

expect(mock.history.get[0].url).toEqual(endpoint);
expect(result).toEqual(responseData);
});

it("fails with error when accountId is invalid.", async () => {
const endpoint = `${GENERAL_ENDPOINT}/api/accounts/${accountId}/billing/usage`;
const expectedErrorMessage = "Account not found";
const statusCode = 404;

expect.assertions(3);

mock.onGet(endpoint).reply(statusCode, { error: expectedErrorMessage });

try {
await billingAPI.getCurrentBillingCycleUsage();
} catch (error) {
expect(error).toBeInstanceOf(MailtrapError);

if (error instanceof MailtrapError) {
expect(error.message).toEqual(expectedErrorMessage);
// Verify status code is accessible via cause property
// @ts-expect-error ES5 types don't know about cause property
expect(error.cause?.response?.status).toEqual(statusCode);
}
}
});

it("fails with error when billing endpoint returns 403 Forbidden.", async () => {
const endpoint = `${GENERAL_ENDPOINT}/api/accounts/${accountId}/billing/usage`;
const expectedErrorMessage = "Access denied";
const statusCode = 403;

expect.assertions(3);

mock.onGet(endpoint).reply(statusCode, { error: expectedErrorMessage });

try {
await billingAPI.getCurrentBillingCycleUsage();
} catch (error) {
expect(error).toBeInstanceOf(MailtrapError);

if (error instanceof MailtrapError) {
expect(error.message).toEqual(expectedErrorMessage);
// Verify status code is accessible via cause property
// @ts-expect-error ES5 types don't know about cause property
expect(error.cause?.response?.status).toEqual(statusCode);
}
}
});

it("fails with error when no error body is provided.", async () => {
const endpoint = `${GENERAL_ENDPOINT}/api/accounts/${accountId}/billing/usage`;
const expectedErrorMessage = "Request failed with status code 500";
const statusCode = 500;

expect.assertions(3);

mock.onGet(endpoint).reply(statusCode);

try {
await billingAPI.getCurrentBillingCycleUsage();
} catch (error) {
expect(error).toBeInstanceOf(MailtrapError);

if (error instanceof MailtrapError) {
expect(error.message).toEqual(expectedErrorMessage);
// Verify status code is accessible via cause property
// @ts-expect-error ES5 types don't know about cause property
expect(error.cause?.response?.status).toEqual(statusCode);
}
}
});
});
});
24 changes: 24 additions & 0 deletions src/lib/api/General.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { AxiosInstance } from "axios";

import AccountAccessesApi from "./resources/AccountAccesses";
import AccountsApi from "./resources/Accounts";
import BillingApi from "./resources/Billing";
import PermissionsApi from "./resources/Permissions";

export default class GeneralAPI {
Expand All @@ -15,6 +16,8 @@ export default class GeneralAPI {

private permissionsInstance: PermissionsApi | null = null;

private billingInstance: BillingApi | null = null;

constructor(client: AxiosInstance, accountId?: number) {
this.client = client;
this.accountId = accountId ?? null;
Expand All @@ -30,6 +33,9 @@ export default class GeneralAPI {
}
}

/**
* Checks if the account ID is present.
*/
private checkAccountIdPresence(): number {
if (this.accountId === null) {
throw new Error(
Expand All @@ -39,6 +45,9 @@ export default class GeneralAPI {
return this.accountId;
}

/**
* Singleton getter for Account Accesses API.
*/
public get accountAccesses(): AccountAccessesApi {
if (this.accountAccessesInstance === null) {
const accountId = this.checkAccountIdPresence();
Expand All @@ -51,6 +60,9 @@ export default class GeneralAPI {
return this.accountAccessesInstance;
}

/**
* Singleton getter for Permissions API.
*/
public get permissions(): PermissionsApi {
if (this.permissionsInstance === null) {
const accountId = this.checkAccountIdPresence();
Expand All @@ -59,4 +71,16 @@ export default class GeneralAPI {

return this.permissionsInstance;
}

/**
* Singleton getter for Billing API.
*/
public get billing(): BillingApi {
if (this.billingInstance === null) {
const accountId = this.checkAccountIdPresence();
this.billingInstance = new BillingApi(this.client, accountId);
}

return this.billingInstance;
}
}
28 changes: 28 additions & 0 deletions src/lib/api/resources/Billing.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { AxiosInstance } from "axios";

import CONFIG from "../../../config";

import { BillingCycleUsage } from "../../../types/api/billing";

const { CLIENT_SETTINGS } = CONFIG;
const { GENERAL_ENDPOINT } = CLIENT_SETTINGS;

export default class BillingApi {
private client: AxiosInstance;

private billingURL: string;

constructor(client: AxiosInstance, accountId: number) {
this.client = client;
this.billingURL = `${GENERAL_ENDPOINT}/api/accounts/${accountId}/billing/usage`;
}

/**
* Get billing usage for the account.
*/
public async getCurrentBillingCycleUsage() {
const url = this.billingURL;

return this.client.get<BillingCycleUsage, BillingCycleUsage>(url);
}
}
32 changes: 32 additions & 0 deletions src/types/api/billing.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
export type BillingCycleUsage = {
billing: {
cycle_start: string;
cycle_end: string;
};
sending: {
plan: {
name: string;
};
usage: {
sent_messages_count: {
current: number;
limit: number;
};
};
};
testing: {
plan: {
name: string;
};
usage: {
sent_messages_count: {
current: number;
limit: number;
};
forwarded_messages_count: {
current: number;
limit: number;
};
};
};
};