diff --git a/src/api-keys/api-keys.spec.ts b/src/api-keys/api-keys.spec.ts index ad9f30f5..7ece09e5 100644 --- a/src/api-keys/api-keys.spec.ts +++ b/src/api-keys/api-keys.spec.ts @@ -1,6 +1,10 @@ import { enableFetchMocks } from 'jest-fetch-mock'; import type { ErrorResponse } from '../interfaces'; import { Resend } from '../resend'; +import { + mockErrorResponse, + mockSuccessResponse, +} from '../test-utils/mock-fetch'; import type { CreateApiKeyOptions, CreateApiKeyResponseSuccess, @@ -23,12 +27,8 @@ describe('API Keys', () => { id: '430eed87-632a-4ea6-90db-0aace67ec228', }; - fetchMock.mockOnce(JSON.stringify(response), { - status: 201, - headers: { - 'content-type': 'application/json', - Authorization: 'Bearer re_924b3rjh2387fbewf823', - }, + mockSuccessResponse(response, { + headers: { Authorization: 'Bearer re_924b3rjh2387fbewf823' }, }); const resend = new Resend('re_zKa4RCko_Lhm9ost2YjNCctnPjbLw8Nop'); @@ -42,6 +42,11 @@ describe('API Keys', () => { "token": "re_PKr4RCko_Lhm9ost2YjNCctnPjbLw8Nqk", }, "error": null, + "rateLimiting": { + "limit": 2, + "remainingRequests": 2, + "shouldResetAfter": 1, + }, } `); }); @@ -55,12 +60,8 @@ describe('API Keys', () => { name: 'validation_error', }; - fetchMock.mockOnce(JSON.stringify(response), { - status: 422, - headers: { - 'content-type': 'application/json', - Authorization: 'Bearer re_924b3rjh2387fbewf823', - }, + mockErrorResponse(response, { + headers: { Authorization: 'Bearer re_924b3rjh2387fbewf823' }, }); const resend = new Resend('re_zKa4RCko_Lhm9ost2YjNCctnPjbLw8Nop'); @@ -74,6 +75,11 @@ describe('API Keys', () => { "message": "String must contain at least 1 character(s)", "name": "validation_error", }, + "rateLimiting": { + "limit": 2, + "remainingRequests": 2, + "shouldResetAfter": 1, + }, } `); }); @@ -90,12 +96,8 @@ describe('API Keys', () => { id: '430eed87-632a-4ea6-90db-0aace67ec228', }; - fetchMock.mockOnce(JSON.stringify(response), { - status: 201, - headers: { - 'content-type': 'application/json', - Authorization: 'Bearer re_924b3rjh2387fbewf823', - }, + mockSuccessResponse(response, { + headers: { Authorization: 'Bearer re_924b3rjh2387fbewf823' }, }); const resend = new Resend('re_zKa4RCko_Lhm9ost2YjNCctnPjbLw8Nop'); @@ -109,6 +111,11 @@ describe('API Keys', () => { "token": "re_PKr4RCko_Lhm9ost2YjNCctnPjbLw8Nqk", }, "error": null, + "rateLimiting": { + "limit": 2, + "remainingRequests": 2, + "shouldResetAfter": 1, + }, } `); }); @@ -123,12 +130,8 @@ describe('API Keys', () => { id: '430eed87-632a-4ea6-90db-0aace67ec228', }; - fetchMock.mockOnce(JSON.stringify(response), { - status: 201, - headers: { - 'content-type': 'application/json', - Authorization: 'Bearer re_924b3rjh2387fbewf823', - }, + mockSuccessResponse(response, { + headers: { Authorization: 'Bearer re_924b3rjh2387fbewf823' }, }); const resend = new Resend('re_zKa4RCko_Lhm9ost2YjNCctnPjbLw8Nop'); @@ -142,6 +145,11 @@ describe('API Keys', () => { "token": "re_PKr4RCko_Lhm9ost2YjNCctnPjbLw8Nqk", }, "error": null, + "rateLimiting": { + "limit": 2, + "remainingRequests": 2, + "shouldResetAfter": 1, + }, } `); }); @@ -152,12 +160,8 @@ describe('API Keys', () => { message: 'Access must be "full_access" | "sending_access"', }; - fetchMock.mockOnce(JSON.stringify(response), { - status: 422, - headers: { - 'content-type': 'application/json', - Authorization: 'Bearer re_924b3rjh2387fbewf823', - }, + mockErrorResponse(response, { + headers: { Authorization: 'Bearer re_924b3rjh2387fbewf823' }, }); const resend = new Resend('re_zKa4RCko_Lhm9ost2YjNCctnPjbLw8Nop'); @@ -176,6 +180,11 @@ describe('API Keys', () => { "message": "Access must be "full_access" | "sending_access"", "name": "invalid_access", }, + "rateLimiting": { + "limit": 2, + "remainingRequests": 2, + "shouldResetAfter": 1, + }, } `); }); @@ -271,12 +280,8 @@ describe('API Keys', () => { created_at: '2023-04-06T23:09:49.093947+00:00', }, ]; - fetchMock.mockOnce(JSON.stringify(response), { - status: 200, - headers: { - 'content-type': 'application/json', - Authorization: 'Bearer re_924b3rjh2387fbewf823', - }, + mockSuccessResponse(response, { + headers: { Authorization: 'Bearer re_924b3rjh2387fbewf823' }, }); const resend = new Resend('re_zKa4RCko_Lhm9ost2YjNCctnPjbLw8Nop'); @@ -296,6 +301,11 @@ describe('API Keys', () => { }, ], "error": null, + "rateLimiting": { + "limit": 2, + "remainingRequests": 2, + "shouldResetAfter": 1, + }, } `); }); @@ -306,12 +316,8 @@ describe('API Keys', () => { const response: RemoveApiKeyResponseSuccess = {}; it('removes an api key', async () => { - fetchMock.mockOnce(JSON.stringify(response), { - status: 200, - headers: { - 'content-type': 'application/json', - Authorization: 'Bearer re_924b3rjh2387fbewf823', - }, + mockSuccessResponse(response, { + headers: { Authorization: 'Bearer re_924b3rjh2387fbewf823' }, }); const resend = new Resend('re_zKa4RCko_Lhm9ost2YjNCctnPjbLw8Nop'); @@ -320,6 +326,11 @@ describe('API Keys', () => { { "data": {}, "error": null, + "rateLimiting": { + "limit": 2, + "remainingRequests": 2, + "shouldResetAfter": 1, + }, } `); }); @@ -330,12 +341,8 @@ describe('API Keys', () => { message: 'Something went wrong', }; - fetchMock.mockOnce(JSON.stringify(response), { - status: 500, - headers: { - 'content-type': 'application/json', - Authorization: 'Bearer re_924b3rjh2387fbewf823', - }, + mockErrorResponse(response, { + headers: { Authorization: 'Bearer re_924b3rjh2387fbewf823' }, }); const resend = new Resend('re_zKa4RCko_Lhm9ost2YjNCctnPjbLw8Nop'); @@ -349,6 +356,11 @@ describe('API Keys', () => { "message": "Something went wrong", "name": "application_error", }, + "rateLimiting": { + "limit": 2, + "remainingRequests": 2, + "shouldResetAfter": 1, + }, } `); }); @@ -359,12 +371,8 @@ describe('API Keys', () => { message: 'API key not found', }; - fetchMock.mockOnce(JSON.stringify(response), { - status: 404, - headers: { - 'content-type': 'application/json', - Authorization: 'Bearer re_924b3rjh2387fbewf823', - }, + mockErrorResponse(response, { + headers: { Authorization: 'Bearer re_924b3rjh2387fbewf823' }, }); const resend = new Resend('re_zKa4RCko_Lhm9ost2YjNCctnPjbLw8Nop'); @@ -380,6 +388,11 @@ describe('API Keys', () => { "message": "API key not found", "name": "not_found", }, + "rateLimiting": { + "limit": 2, + "remainingRequests": 2, + "shouldResetAfter": 1, + }, } `); }); diff --git a/src/api-keys/interfaces/create-api-key-options.interface.ts b/src/api-keys/interfaces/create-api-key-options.interface.ts index 628600a8..59274575 100644 --- a/src/api-keys/interfaces/create-api-key-options.interface.ts +++ b/src/api-keys/interfaces/create-api-key-options.interface.ts @@ -1,5 +1,5 @@ import type { PostOptions } from '../../common/interfaces'; -import type { ErrorResponse } from '../../interfaces'; +import type { Response } from '../../interfaces'; export interface CreateApiKeyOptions { name: string; @@ -14,12 +14,4 @@ export interface CreateApiKeyResponseSuccess { id: string; } -export type CreateApiKeyResponse = - | { - data: CreateApiKeyResponseSuccess; - error: null; - } - | { - data: null; - error: ErrorResponse; - }; +export type CreateApiKeyResponse = Response; diff --git a/src/api-keys/interfaces/list-api-keys.interface.ts b/src/api-keys/interfaces/list-api-keys.interface.ts index 59a14b79..a4696d5a 100644 --- a/src/api-keys/interfaces/list-api-keys.interface.ts +++ b/src/api-keys/interfaces/list-api-keys.interface.ts @@ -1,4 +1,4 @@ -import type { ErrorResponse } from '../../interfaces'; +import type { Response } from '../../interfaces'; import type { ApiKey } from './api-key'; export type ListApiKeysResponseSuccess = Pick< @@ -6,12 +6,4 @@ export type ListApiKeysResponseSuccess = Pick< 'name' | 'id' | 'created_at' >[]; -export type ListApiKeysResponse = - | { - data: ListApiKeysResponseSuccess; - error: null; - } - | { - data: null; - error: ErrorResponse; - }; +export type ListApiKeysResponse = Response; diff --git a/src/api-keys/interfaces/remove-api-keys.interface.ts b/src/api-keys/interfaces/remove-api-keys.interface.ts index c299238b..0981478e 100644 --- a/src/api-keys/interfaces/remove-api-keys.interface.ts +++ b/src/api-keys/interfaces/remove-api-keys.interface.ts @@ -1,14 +1,6 @@ -import type { ErrorResponse } from '../../interfaces'; +import type { Response } from '../../interfaces'; // biome-ignore lint/complexity/noBannedTypes: export type RemoveApiKeyResponseSuccess = {}; -export type RemoveApiKeyResponse = - | { - data: RemoveApiKeyResponseSuccess; - error: null; - } - | { - data: null; - error: ErrorResponse; - }; +export type RemoveApiKeyResponse = Response; diff --git a/src/audiences/audiences.spec.ts b/src/audiences/audiences.spec.ts index cf42f416..202fe1b7 100644 --- a/src/audiences/audiences.spec.ts +++ b/src/audiences/audiences.spec.ts @@ -1,6 +1,10 @@ import { enableFetchMocks } from 'jest-fetch-mock'; import type { ErrorResponse } from '../interfaces'; import { Resend } from '../resend'; +import { + mockErrorResponse, + mockSuccessResponse, +} from '../test-utils/mock-fetch'; import type { CreateAudienceOptions, CreateAudienceResponseSuccess, @@ -23,12 +27,8 @@ describe('Audiences', () => { object: 'audience', }; - fetchMock.mockOnce(JSON.stringify(response), { - status: 200, - headers: { - 'content-type': 'application/json', - Authorization: 'Bearer re_924b3rjh2387fbewf823', - }, + mockSuccessResponse(response, { + headers: { Authorization: 'Bearer re_924b3rjh2387fbewf823' }, }); const resend = new Resend('re_zKa4RCko_Lhm9ost2YjNCctnPjbLw8Nop'); @@ -42,6 +42,11 @@ describe('Audiences', () => { "object": "audience", }, "error": null, + "rateLimiting": { + "limit": 2, + "remainingRequests": 2, + "shouldResetAfter": 1, + }, } `); }); @@ -53,12 +58,8 @@ describe('Audiences', () => { message: 'Missing "name" field', }; - fetchMock.mockOnce(JSON.stringify(response), { - status: 422, - headers: { - 'content-type': 'application/json', - Authorization: 'Bearer re_924b3rjh2387fbewf823', - }, + mockErrorResponse(response, { + headers: { Authorization: 'Bearer re_924b3rjh2387fbewf823' }, }); const resend = new Resend('re_zKa4RCko_Lhm9ost2YjNCctnPjbLw8Nop'); @@ -72,6 +73,11 @@ describe('Audiences', () => { "message": "Missing "name" field", "name": "missing_required_field", }, + "rateLimiting": { + "limit": 2, + "remainingRequests": 2, + "shouldResetAfter": 1, + }, } `); }); @@ -94,12 +100,8 @@ describe('Audiences', () => { }, ], }; - fetchMock.mockOnce(JSON.stringify(response), { - status: 200, - headers: { - 'content-type': 'application/json', - Authorization: 'Bearer re_924b3rjh2387fbewf823', - }, + mockSuccessResponse(response, { + headers: { Authorization: 'Bearer re_924b3rjh2387fbewf823' }, }); const resend = new Resend('re_zKa4RCko_Lhm9ost2YjNCctnPjbLw8Nop'); @@ -122,6 +124,11 @@ describe('Audiences', () => { "object": "list", }, "error": null, + "rateLimiting": { + "limit": 2, + "remainingRequests": 2, + "shouldResetAfter": 1, + }, } `); }); @@ -135,12 +142,8 @@ describe('Audiences', () => { message: 'Audience not found', }; - fetchMock.mockOnce(JSON.stringify(response), { - status: 404, - headers: { - 'content-type': 'application/json', - Authorization: 'Bearer re_924b3rjh2387fbewf823', - }, + mockErrorResponse(response, { + headers: { Authorization: 'Bearer re_924b3rjh2387fbewf823' }, }); const resend = new Resend('re_zKa4RCko_Lhm9ost2YjNCctnPjbLw8Nop'); @@ -154,6 +157,11 @@ describe('Audiences', () => { "message": "Audience not found", "name": "not_found", }, + "rateLimiting": { + "limit": 2, + "remainingRequests": 2, + "shouldResetAfter": 1, + }, } `); }); @@ -167,12 +175,8 @@ describe('Audiences', () => { created_at: '2023-06-21T06:10:36.144Z', }; - fetchMock.mockOnce(JSON.stringify(response), { - status: 200, - headers: { - 'content-type': 'application/json', - Authorization: 'Bearer re_924b3rjh2387fbewf823', - }, + mockSuccessResponse(response, { + headers: { Authorization: 'Bearer re_924b3rjh2387fbewf823' }, }); const resend = new Resend('re_zKa4RCko_Lhm9ost2YjNCctnPjbLw8Nop'); @@ -188,6 +192,11 @@ describe('Audiences', () => { "object": "audience", }, "error": null, + "rateLimiting": { + "limit": 2, + "remainingRequests": 2, + "shouldResetAfter": 1, + }, } `); }); @@ -201,12 +210,8 @@ describe('Audiences', () => { id, deleted: true, }; - fetchMock.mockOnce(JSON.stringify(response), { - status: 200, - headers: { - 'content-type': 'application/json', - Authorization: 'Bearer re_924b3rjh2387fbewf823', - }, + mockSuccessResponse(response, { + headers: { Authorization: 'Bearer re_924b3rjh2387fbewf823' }, }); const resend = new Resend('re_zKa4RCko_Lhm9ost2YjNCctnPjbLw8Nop'); @@ -219,6 +224,11 @@ describe('Audiences', () => { "object": "audience", }, "error": null, + "rateLimiting": { + "limit": 2, + "remainingRequests": 2, + "shouldResetAfter": 1, + }, } `); }); diff --git a/src/audiences/interfaces/create-audience-options.interface.ts b/src/audiences/interfaces/create-audience-options.interface.ts index f8775dba..4bd6ae88 100644 --- a/src/audiences/interfaces/create-audience-options.interface.ts +++ b/src/audiences/interfaces/create-audience-options.interface.ts @@ -1,5 +1,5 @@ import type { PostOptions } from '../../common/interfaces'; -import type { ErrorResponse } from '../../interfaces'; +import type { Response } from '../../interfaces'; import type { Audience } from './audience'; export interface CreateAudienceOptions { @@ -13,12 +13,4 @@ export interface CreateAudienceResponseSuccess object: 'audience'; } -export type CreateAudienceResponse = - | { - data: CreateAudienceResponseSuccess; - error: null; - } - | { - data: null; - error: ErrorResponse; - }; +export type CreateAudienceResponse = Response; diff --git a/src/audiences/interfaces/get-audience.interface.ts b/src/audiences/interfaces/get-audience.interface.ts index 9c08efac..f229a6f9 100644 --- a/src/audiences/interfaces/get-audience.interface.ts +++ b/src/audiences/interfaces/get-audience.interface.ts @@ -1,4 +1,4 @@ -import type { ErrorResponse } from '../../interfaces'; +import type { Response } from '../../interfaces'; import type { Audience } from './audience'; export interface GetAudienceResponseSuccess @@ -6,12 +6,4 @@ export interface GetAudienceResponseSuccess object: 'audience'; } -export type GetAudienceResponse = - | { - data: GetAudienceResponseSuccess; - error: null; - } - | { - data: null; - error: ErrorResponse; - }; +export type GetAudienceResponse = Response; diff --git a/src/audiences/interfaces/list-audiences.interface.ts b/src/audiences/interfaces/list-audiences.interface.ts index 86a92bc2..12e1c95f 100644 --- a/src/audiences/interfaces/list-audiences.interface.ts +++ b/src/audiences/interfaces/list-audiences.interface.ts @@ -1,4 +1,4 @@ -import type { ErrorResponse } from '../../interfaces'; +import type { Response } from '../../interfaces'; import type { Audience } from './audience'; export type ListAudiencesResponseSuccess = { @@ -6,12 +6,4 @@ export type ListAudiencesResponseSuccess = { data: Audience[]; }; -export type ListAudiencesResponse = - | { - data: ListAudiencesResponseSuccess; - error: null; - } - | { - data: null; - error: ErrorResponse; - }; +export type ListAudiencesResponse = Response; diff --git a/src/audiences/interfaces/remove-audience.interface.ts b/src/audiences/interfaces/remove-audience.interface.ts index e82e0b39..97de36a4 100644 --- a/src/audiences/interfaces/remove-audience.interface.ts +++ b/src/audiences/interfaces/remove-audience.interface.ts @@ -1,4 +1,4 @@ -import type { ErrorResponse } from '../../interfaces'; +import type { Response } from '../../interfaces'; import type { Audience } from './audience'; export interface RemoveAudiencesResponseSuccess extends Pick { @@ -6,12 +6,4 @@ export interface RemoveAudiencesResponseSuccess extends Pick { deleted: boolean; } -export type RemoveAudiencesResponse = - | { - data: RemoveAudiencesResponseSuccess; - error: null; - } - | { - data: null; - error: ErrorResponse; - }; +export type RemoveAudiencesResponse = Response; diff --git a/src/batch/batch.spec.ts b/src/batch/batch.spec.ts index 6fa7fecc..afbd8531 100644 --- a/src/batch/batch.spec.ts +++ b/src/batch/batch.spec.ts @@ -1,5 +1,6 @@ import { enableFetchMocks } from 'jest-fetch-mock'; import { Resend } from '../resend'; +import { mockSuccessResponse } from '../test-utils/mock-fetch'; import type { CreateBatchOptions, CreateBatchSuccessResponse, @@ -41,10 +42,8 @@ describe('Batch', () => { { id: 'b2bc2598-f98b-4da4-86c9-7b32881ef394' }, ], }; - fetchMock.mockOnce(JSON.stringify(response), { - status: 200, + mockSuccessResponse(response, { headers: { - 'content-type': 'application/json', Authorization: 'Bearer re_zKa4RCko_Lhm9ost2YjNCctnPjbLw8Nop', }, }); @@ -66,6 +65,11 @@ describe('Batch', () => { ], }, "error": null, + "rateLimiting": { + "limit": 2, + "remainingRequests": 2, + "shouldResetAfter": 1, + }, } `); }); @@ -79,10 +83,8 @@ describe('Batch', () => { ], }; - fetchMock.mockOnce(JSON.stringify(response), { - status: 200, + mockSuccessResponse(response, { headers: { - 'content-type': 'application/json', Authorization: 'Bearer re_zKa4RCko_Lhm9ost2YjNCctnPjbLw8Nop', }, }); @@ -120,10 +122,8 @@ describe('Batch', () => { ], }; - fetchMock.mockOnce(JSON.stringify(response), { - status: 200, + mockSuccessResponse(response, { headers: { - 'content-type': 'application/json', Authorization: 'Bearer re_zKa4RCko_Lhm9ost2YjNCctnPjbLw8Nop', }, }); @@ -188,10 +188,8 @@ describe('Batch', () => { ], }; - fetchMock.mockOnce(JSON.stringify(response), { - status: 200, + mockSuccessResponse(response, { headers: { - 'content-type': 'application/json', Authorization: 'Bearer re_zKa4RCko_Lhm9ost2YjNCctnPjbLw8Nop', }, }); @@ -213,6 +211,11 @@ describe('Batch', () => { ], }, "error": null, + "rateLimiting": { + "limit": 2, + "remainingRequests": 2, + "shouldResetAfter": 1, + }, } `); }); @@ -226,10 +229,8 @@ describe('Batch', () => { ], }; - fetchMock.mockOnce(JSON.stringify(response), { - status: 200, + mockSuccessResponse(response, { headers: { - 'content-type': 'application/json', Authorization: 'Bearer re_zKa4RCko_Lhm9ost2YjNCctnPjbLw8Nop', }, }); @@ -267,10 +268,8 @@ describe('Batch', () => { ], }; - fetchMock.mockOnce(JSON.stringify(response), { - status: 200, + mockSuccessResponse(response, { headers: { - 'content-type': 'application/json', Authorization: 'Bearer re_zKa4RCko_Lhm9ost2YjNCctnPjbLw8Nop', }, }); diff --git a/src/batch/interfaces/create-batch-options.interface.ts b/src/batch/interfaces/create-batch-options.interface.ts index d19dbe1c..ffdca0e2 100644 --- a/src/batch/interfaces/create-batch-options.interface.ts +++ b/src/batch/interfaces/create-batch-options.interface.ts @@ -1,7 +1,7 @@ import type { PostOptions } from '../../common/interfaces'; import type { IdempotentRequest } from '../../common/interfaces/idempotent-request.interface'; import type { CreateEmailOptions } from '../../emails/interfaces/create-email-options.interface'; -import type { ErrorResponse } from '../../interfaces'; +import type { Response } from '../../interfaces'; export type CreateBatchOptions = CreateEmailOptions[]; @@ -16,12 +16,4 @@ export interface CreateBatchSuccessResponse { }[]; } -export type CreateBatchResponse = - | { - data: CreateBatchSuccessResponse; - error: null; - } - | { - data: null; - error: ErrorResponse; - }; +export type CreateBatchResponse = Response; diff --git a/src/broadcasts/broadcasts.spec.ts b/src/broadcasts/broadcasts.spec.ts index 5c6a2310..59e13d36 100644 --- a/src/broadcasts/broadcasts.spec.ts +++ b/src/broadcasts/broadcasts.spec.ts @@ -1,6 +1,11 @@ import { enableFetchMocks } from 'jest-fetch-mock'; import type { ErrorResponse } from '../interfaces'; import { Resend } from '../resend'; +import { + mockErrorResponse, + mockFetchWithRateLimit, + mockSuccessResponse, +} from '../test-utils/mock-fetch'; import type { CreateBroadcastOptions, CreateBroadcastResponseSuccess, @@ -24,10 +29,8 @@ describe('Broadcasts', () => { message: 'Missing `from` field.', }; - fetchMock.mockOnce(JSON.stringify(response), { - status: 422, + mockErrorResponse(response, { headers: { - 'content-type': 'application/json', Authorization: 'Bearer re_zKa4RCko_Lhm9ost2YjNCctnPjbLw8Nop', }, }); @@ -40,6 +43,11 @@ describe('Broadcasts', () => { "message": "Missing \`from\` field.", "name": "missing_required_field", }, + "rateLimiting": { + "limit": 2, + "remainingRequests": 2, + "shouldResetAfter": 1, + }, } `); }); @@ -48,10 +56,8 @@ describe('Broadcasts', () => { const response: CreateBroadcastResponseSuccess = { id: '71cdfe68-cf79-473a-a9d7-21f91db6a526', }; - fetchMock.mockOnce(JSON.stringify(response), { - status: 200, + mockSuccessResponse(response, { headers: { - 'content-type': 'application/json', Authorization: 'Bearer re_zKa4RCko_Lhm9ost2YjNCctnPjbLw8Nop', }, }); @@ -70,6 +76,11 @@ describe('Broadcasts', () => { "id": "71cdfe68-cf79-473a-a9d7-21f91db6a526", }, "error": null, + "rateLimiting": { + "limit": 2, + "remainingRequests": 2, + "shouldResetAfter": 1, + }, } `); }); @@ -79,12 +90,8 @@ describe('Broadcasts', () => { id: '124dc0f1-e36c-417c-a65c-e33773abc768', }; - fetchMock.mockOnce(JSON.stringify(response), { - status: 200, - headers: { - 'content-type': 'application/json', - Authorization: 'Bearer re_924b3rjh2387fbewf823', - }, + mockSuccessResponse(response, { + headers: { Authorization: 'Bearer re_924b3rjh2387fbewf823' }, }); const payload: CreateBroadcastOptions = { @@ -100,6 +107,11 @@ describe('Broadcasts', () => { "id": "124dc0f1-e36c-417c-a65c-e33773abc768", }, "error": null, + "rateLimiting": { + "limit": 2, + "remainingRequests": 2, + "shouldResetAfter": 1, + }, } `); }); @@ -109,12 +121,8 @@ describe('Broadcasts', () => { id: '124dc0f1-e36c-417c-a65c-e33773abc768', }; - fetchMock.mockOnce(JSON.stringify(response), { - status: 200, - headers: { - 'content-type': 'application/json', - Authorization: 'Bearer re_924b3rjh2387fbewf823', - }, + mockSuccessResponse(response, { + headers: { Authorization: 'Bearer re_924b3rjh2387fbewf823' }, }); const payload: CreateBroadcastOptions = { @@ -132,6 +140,11 @@ describe('Broadcasts', () => { "id": "124dc0f1-e36c-417c-a65c-e33773abc768", }, "error": null, + "rateLimiting": { + "limit": 2, + "remainingRequests": 2, + "shouldResetAfter": 1, + }, } `); }); @@ -143,12 +156,8 @@ describe('Broadcasts', () => { 'Invalid `from` field. The email address needs to follow the `email@example.com` or `Name ` format', }; - fetchMock.mockOnce(JSON.stringify(response), { - status: 422, - headers: { - 'content-type': 'application/json', - Authorization: 'Bearer re_924b3rjh2387fbewf823', - }, + mockErrorResponse(response, { + headers: { Authorization: 'Bearer re_924b3rjh2387fbewf823' }, }); const payload: CreateBroadcastOptions = { @@ -162,14 +171,19 @@ describe('Broadcasts', () => { const result = resend.broadcasts.create(payload); await expect(result).resolves.toMatchInlineSnapshot(` - { - "data": null, - "error": { - "message": "Invalid \`from\` field. The email address needs to follow the \`email@example.com\` or \`Name \` format", - "name": "invalid_parameter", - }, - } - `); +{ + "data": null, + "error": { + "message": "Invalid \`from\` field. The email address needs to follow the \`email@example.com\` or \`Name \` format", + "name": "invalid_parameter", + }, + "rateLimiting": { + "limit": 2, + "remainingRequests": 2, + "shouldResetAfter": 1, + }, +} +`); }); it('returns an error when fetch fails', async () => { @@ -199,7 +213,7 @@ describe('Broadcasts', () => { }); it('returns an error when api responds with text payload', async () => { - fetchMock.mockOnce('local_rate_limited', { + mockFetchWithRateLimit('local_rate_limited', { status: 422, headers: { Authorization: 'Bearer re_924b3rjh2387fbewf823', @@ -233,10 +247,8 @@ describe('Broadcasts', () => { id: randomBroadcastId, }; - fetchMock.mockOnce(JSON.stringify(response), { - status: 200, + mockSuccessResponse(response, { headers: { - 'content-type': 'application/json', Authorization: 'Bearer re_zKa4RCko_Lhm9ost2YjNCctnPjbLw8Nop', }, }); @@ -249,6 +261,11 @@ describe('Broadcasts', () => { "id": "b01e0de9-7c27-4a53-bf38-2e3f98389a65", }, "error": null, + "rateLimiting": { + "limit": 2, + "remainingRequests": 2, + "shouldResetAfter": 1, + }, } `); }); @@ -279,12 +296,8 @@ describe('Broadcasts', () => { }, ], }; - fetchMock.mockOnce(JSON.stringify(response), { - status: 200, - headers: { - 'content-type': 'application/json', - Authorization: 'Bearer re_924b3rjh2387fbewf823', - }, + mockSuccessResponse(response, { + headers: { Authorization: 'Bearer re_924b3rjh2387fbewf823' }, }); const resend = new Resend('re_zKa4RCko_Lhm9ost2YjNCctnPjbLw8Nop'); @@ -315,6 +328,11 @@ describe('Broadcasts', () => { "object": "list", }, "error": null, + "rateLimiting": { + "limit": 2, + "remainingRequests": 2, + "shouldResetAfter": 1, + }, } `); }); @@ -328,12 +346,8 @@ describe('Broadcasts', () => { message: 'Broadcast not found', }; - fetchMock.mockOnce(JSON.stringify(response), { - status: 404, - headers: { - 'content-type': 'application/json', - Authorization: 'Bearer re_924b3rjh2387fbewf823', - }, + mockErrorResponse(response, { + headers: { Authorization: 'Bearer re_924b3rjh2387fbewf823' }, }); const resend = new Resend('re_zKa4RCko_Lhm9ost2YjNCctnPjbLw8Nop'); @@ -349,6 +363,11 @@ describe('Broadcasts', () => { "message": "Broadcast not found", "name": "not_found", }, + "rateLimiting": { + "limit": 2, + "remainingRequests": 2, + "shouldResetAfter": 1, + }, } `); }); @@ -370,12 +389,8 @@ describe('Broadcasts', () => { sent_at: null, }; - fetchMock.mockOnce(JSON.stringify(response), { - status: 200, - headers: { - 'content-type': 'application/json', - Authorization: 'Bearer re_924b3rjh2387fbewf823', - }, + mockSuccessResponse(response, { + headers: { Authorization: 'Bearer re_924b3rjh2387fbewf823' }, }); const resend = new Resend('re_zKa4RCko_Lhm9ost2YjNCctnPjbLw8Nop'); @@ -399,6 +414,11 @@ describe('Broadcasts', () => { "subject": "hello world", }, "error": null, + "rateLimiting": { + "limit": 2, + "remainingRequests": 2, + "shouldResetAfter": 1, + }, } `); }); @@ -412,12 +432,8 @@ describe('Broadcasts', () => { id, deleted: true, }; - fetchMock.mockOnce(JSON.stringify(response), { - status: 200, - headers: { - 'content-type': 'application/json', - Authorization: 'Bearer re_924b3rjh2387fbewf823', - }, + mockSuccessResponse(response, { + headers: { Authorization: 'Bearer re_924b3rjh2387fbewf823' }, }); const resend = new Resend('re_zKa4RCko_Lhm9ost2YjNCctnPjbLw8Nop'); @@ -432,6 +448,11 @@ describe('Broadcasts', () => { "object": "broadcast", }, "error": null, + "rateLimiting": { + "limit": 2, + "remainingRequests": 2, + "shouldResetAfter": 1, + }, } `); }); @@ -441,12 +462,8 @@ describe('Broadcasts', () => { it('updates a broadcast', async () => { const id = 'b01e0de9-7c27-4a53-bf38-2e3f98389a65'; const response: UpdateBroadcastResponseSuccess = { id }; - fetchMock.mockOnce(JSON.stringify(response), { - status: 200, - headers: { - 'content-type': 'application/json', - Authorization: 'Bearer re_924b3rjh2387fbewf823', - }, + mockSuccessResponse(response, { + headers: { Authorization: 'Bearer re_924b3rjh2387fbewf823' }, }); const resend = new Resend('re_zKa4RCko_Lhm9ost2YjNCctnPjbLw8Nop'); @@ -459,6 +476,11 @@ describe('Broadcasts', () => { "id": "b01e0de9-7c27-4a53-bf38-2e3f98389a65", }, "error": null, + "rateLimiting": { + "limit": 2, + "remainingRequests": 2, + "shouldResetAfter": 1, + }, } `); }); diff --git a/src/broadcasts/interfaces/create-broadcast-options.interface.ts b/src/broadcasts/interfaces/create-broadcast-options.interface.ts index 69d6940b..6b36357d 100644 --- a/src/broadcasts/interfaces/create-broadcast-options.interface.ts +++ b/src/broadcasts/interfaces/create-broadcast-options.interface.ts @@ -1,7 +1,7 @@ import type * as React from 'react'; import type { PostOptions } from '../../common/interfaces'; import type { RequireAtLeastOne } from '../../common/interfaces/require-at-least-one'; -import type { ErrorResponse } from '../../interfaces'; +import type { Response } from '../../interfaces'; interface EmailRenderOptions { /** @@ -73,12 +73,4 @@ export interface CreateBroadcastResponseSuccess { id: string; } -export type CreateBroadcastResponse = - | { - data: CreateBroadcastResponseSuccess; - error: null; - } - | { - data: null; - error: ErrorResponse; - }; +export type CreateBroadcastResponse = Response; diff --git a/src/broadcasts/interfaces/get-broadcast.interface.ts b/src/broadcasts/interfaces/get-broadcast.interface.ts index 2b7b2ac0..e456f200 100644 --- a/src/broadcasts/interfaces/get-broadcast.interface.ts +++ b/src/broadcasts/interfaces/get-broadcast.interface.ts @@ -1,16 +1,8 @@ -import type { ErrorResponse } from '../../interfaces'; +import type { Response } from '../../interfaces'; import type { Broadcast } from './broadcast'; export interface GetBroadcastResponseSuccess extends Broadcast { object: 'broadcast'; } -export type GetBroadcastResponse = - | { - data: GetBroadcastResponseSuccess; - error: null; - } - | { - data: null; - error: ErrorResponse; - }; +export type GetBroadcastResponse = Response; diff --git a/src/broadcasts/interfaces/list-broadcasts.interface.ts b/src/broadcasts/interfaces/list-broadcasts.interface.ts index fd91e66b..575a756b 100644 --- a/src/broadcasts/interfaces/list-broadcasts.interface.ts +++ b/src/broadcasts/interfaces/list-broadcasts.interface.ts @@ -1,4 +1,4 @@ -import type { ErrorResponse } from '../../interfaces'; +import type { Response } from '../../interfaces'; import type { Broadcast } from './broadcast'; export type ListBroadcastsResponseSuccess = { @@ -15,12 +15,4 @@ export type ListBroadcastsResponseSuccess = { >[]; }; -export type ListBroadcastsResponse = - | { - data: ListBroadcastsResponseSuccess; - error: null; - } - | { - data: null; - error: ErrorResponse; - }; +export type ListBroadcastsResponse = Response; diff --git a/src/broadcasts/interfaces/remove-broadcast.interface.ts b/src/broadcasts/interfaces/remove-broadcast.interface.ts index 438b4884..1b45e8ce 100644 --- a/src/broadcasts/interfaces/remove-broadcast.interface.ts +++ b/src/broadcasts/interfaces/remove-broadcast.interface.ts @@ -1,4 +1,4 @@ -import type { ErrorResponse } from '../../interfaces'; +import type { Response } from '../../interfaces'; import type { Broadcast } from './broadcast'; export interface RemoveBroadcastResponseSuccess extends Pick { @@ -6,12 +6,4 @@ export interface RemoveBroadcastResponseSuccess extends Pick { deleted: boolean; } -export type RemoveBroadcastResponse = - | { - data: RemoveBroadcastResponseSuccess; - error: null; - } - | { - data: null; - error: ErrorResponse; - }; +export type RemoveBroadcastResponse = Response; diff --git a/src/broadcasts/interfaces/send-broadcast-options.interface.ts b/src/broadcasts/interfaces/send-broadcast-options.interface.ts index 75f79393..1d92a6ae 100644 --- a/src/broadcasts/interfaces/send-broadcast-options.interface.ts +++ b/src/broadcasts/interfaces/send-broadcast-options.interface.ts @@ -1,5 +1,5 @@ import type { PostOptions } from '../../common/interfaces'; -import type { ErrorResponse } from '../../interfaces'; +import type { Response } from '../../interfaces'; interface SendBroadcastBaseOptions { /** @@ -21,12 +21,4 @@ export interface SendBroadcastResponseSuccess { id: string; } -export type SendBroadcastResponse = - | { - data: SendBroadcastResponseSuccess; - error: null; - } - | { - data: null; - error: ErrorResponse; - }; +export type SendBroadcastResponse = Response; diff --git a/src/broadcasts/interfaces/update-broadcast.interface.ts b/src/broadcasts/interfaces/update-broadcast.interface.ts index e458dce2..b3d8fd5e 100644 --- a/src/broadcasts/interfaces/update-broadcast.interface.ts +++ b/src/broadcasts/interfaces/update-broadcast.interface.ts @@ -1,4 +1,4 @@ -import type { ErrorResponse } from '../../interfaces'; +import type { Response } from '../../interfaces'; export interface UpdateBroadcastResponseSuccess { id: string; @@ -15,12 +15,4 @@ export interface UpdateBroadcastOptions { previewText?: string; } -export type UpdateBroadcastResponse = - | { - data: UpdateBroadcastResponseSuccess; - error: null; - } - | { - data: null; - error: ErrorResponse; - }; +export type UpdateBroadcastResponse = Response; diff --git a/src/contacts/contacts.spec.ts b/src/contacts/contacts.spec.ts index 20d182a5..396a8609 100644 --- a/src/contacts/contacts.spec.ts +++ b/src/contacts/contacts.spec.ts @@ -1,6 +1,10 @@ import { enableFetchMocks } from 'jest-fetch-mock'; import type { ErrorResponse } from '../interfaces'; import { Resend } from '../resend'; +import { + mockErrorResponse, + mockSuccessResponse, +} from '../test-utils/mock-fetch'; import type { CreateContactOptions, CreateContactResponseSuccess, @@ -35,12 +39,8 @@ describe('Contacts', () => { id: '3deaccfb-f47f-440a-8875-ea14b1716b43', }; - fetchMock.mockOnce(JSON.stringify(response), { - status: 200, - headers: { - 'content-type': 'application/json', - Authorization: 'Bearer re_924b3rjh2387fbewf823', - }, + mockSuccessResponse(response, { + headers: { Authorization: 'Bearer re_924b3rjh2387fbewf823' }, }); const resend = new Resend('re_zKa4RCko_Lhm9ost2YjNCctnPjbLw8Nop'); @@ -53,6 +53,11 @@ describe('Contacts', () => { "object": "contact", }, "error": null, + "rateLimiting": { + "limit": 2, + "remainingRequests": 2, + "shouldResetAfter": 1, + }, } `); }); @@ -67,12 +72,8 @@ describe('Contacts', () => { message: 'Missing `email` field.', }; - fetchMock.mockOnce(JSON.stringify(response), { - status: 422, - headers: { - 'content-type': 'application/json', - Authorization: 'Bearer re_924b3rjh2387fbewf823', - }, + mockErrorResponse(response, { + headers: { Authorization: 'Bearer re_924b3rjh2387fbewf823' }, }); const resend = new Resend('re_zKa4RCko_Lhm9ost2YjNCctnPjbLw8Nop'); @@ -86,6 +87,11 @@ describe('Contacts', () => { "message": "Missing \`email\` field.", "name": "missing_required_field", }, + "rateLimiting": { + "limit": 2, + "remainingRequests": 2, + "shouldResetAfter": 1, + }, } `); }); @@ -117,12 +123,8 @@ describe('Contacts', () => { }, ], }; - fetchMock.mockOnce(JSON.stringify(response), { - status: 200, - headers: { - 'content-type': 'application/json', - Authorization: 'Bearer re_924b3rjh2387fbewf823', - }, + mockSuccessResponse(response, { + headers: { Authorization: 'Bearer re_924b3rjh2387fbewf823' }, }); const resend = new Resend('re_zKa4RCko_Lhm9ost2YjNCctnPjbLw8Nop'); @@ -153,6 +155,11 @@ describe('Contacts', () => { "object": "list", }, "error": null, + "rateLimiting": { + "limit": 2, + "remainingRequests": 2, + "shouldResetAfter": 1, + }, } `); }); @@ -166,12 +173,8 @@ describe('Contacts', () => { message: 'Contact not found', }; - fetchMock.mockOnce(JSON.stringify(response), { - status: 404, - headers: { - 'content-type': 'application/json', - Authorization: 'Bearer re_924b3rjh2387fbewf823', - }, + mockErrorResponse(response, { + headers: { Authorization: 'Bearer re_924b3rjh2387fbewf823' }, }); const resend = new Resend('re_zKa4RCko_Lhm9ost2YjNCctnPjbLw8Nop'); @@ -189,6 +192,11 @@ describe('Contacts', () => { "message": "Contact not found", "name": "not_found", }, + "rateLimiting": { + "limit": 2, + "remainingRequests": 2, + "shouldResetAfter": 1, + }, } `); }); @@ -205,12 +213,8 @@ describe('Contacts', () => { unsubscribed: false, }; - fetchMock.mockOnce(JSON.stringify(response), { - status: 200, - headers: { - 'content-type': 'application/json', - Authorization: 'Bearer re_924b3rjh2387fbewf823', - }, + mockSuccessResponse(response, { + headers: { Authorization: 'Bearer re_924b3rjh2387fbewf823' }, }); const resend = new Resend('re_zKa4RCko_Lhm9ost2YjNCctnPjbLw8Nop'); @@ -232,6 +236,11 @@ describe('Contacts', () => { "unsubscribed": false, }, "error": null, + "rateLimiting": { + "limit": 2, + "remainingRequests": 2, + "shouldResetAfter": 1, + }, } `); }); @@ -247,12 +256,8 @@ describe('Contacts', () => { unsubscribed: false, }; - fetchMock.mockOnce(JSON.stringify(response), { - status: 200, - headers: { - 'content-type': 'application/json', - Authorization: 'Bearer re_924b3rjh2387fbewf823', - }, + mockSuccessResponse(response, { + headers: { Authorization: 'Bearer re_924b3rjh2387fbewf823' }, }); const resend = new Resend('re_zKa4RCko_Lhm9ost2YjNCctnPjbLw8Nop'); @@ -274,6 +279,11 @@ describe('Contacts', () => { "unsubscribed": false, }, "error": null, + "rateLimiting": { + "limit": 2, + "remainingRequests": 2, + "shouldResetAfter": 1, + }, } `); }); @@ -290,12 +300,8 @@ describe('Contacts', () => { id: '3d4a472d-bc6d-4dd2-aa9d-d3d50ce87223', object: 'contact', }; - fetchMock.mockOnce(JSON.stringify(response), { - status: 200, - headers: { - 'content-type': 'application/json', - Authorization: 'Bearer re_924b3rjh2387fbewf823', - }, + mockSuccessResponse(response, { + headers: { Authorization: 'Bearer re_924b3rjh2387fbewf823' }, }); const resend = new Resend('re_zKa4RCko_Lhm9ost2YjNCctnPjbLw8Nop'); @@ -309,6 +315,11 @@ describe('Contacts', () => { "object": "contact", }, "error": null, + "rateLimiting": { + "limit": 2, + "remainingRequests": 2, + "shouldResetAfter": 1, + }, } `); }); @@ -321,12 +332,8 @@ describe('Contacts', () => { object: 'contact', deleted: true, }; - fetchMock.mockOnce(JSON.stringify(response), { - status: 200, - headers: { - 'content-type': 'application/json', - Authorization: 'Bearer re_924b3rjh2387fbewf823', - }, + mockSuccessResponse(response, { + headers: { Authorization: 'Bearer re_924b3rjh2387fbewf823' }, }); const resend = new Resend('re_zKa4RCko_Lhm9ost2YjNCctnPjbLw8Nop'); @@ -344,6 +351,11 @@ describe('Contacts', () => { "object": "contact", }, "error": null, + "rateLimiting": { + "limit": 2, + "remainingRequests": 2, + "shouldResetAfter": 1, + }, } `); }); @@ -354,12 +366,8 @@ describe('Contacts', () => { object: 'contact', deleted: true, }; - fetchMock.mockOnce(JSON.stringify(response), { - status: 200, - headers: { - 'content-type': 'application/json', - Authorization: 'Bearer re_924b3rjh2387fbewf823', - }, + mockSuccessResponse(response, { + headers: { Authorization: 'Bearer re_924b3rjh2387fbewf823' }, }); const resend = new Resend('re_zKa4RCko_Lhm9ost2YjNCctnPjbLw8Nop'); @@ -370,15 +378,20 @@ describe('Contacts', () => { await expect( resend.contacts.remove(options), ).resolves.toMatchInlineSnapshot(` - { - "data": { - "contact": "acme@example.com", - "deleted": true, - "object": "contact", - }, - "error": null, - } - `); +{ + "data": { + "contact": "acme@example.com", + "deleted": true, + "object": "contact", + }, + "error": null, + "rateLimiting": { + "limit": 2, + "remainingRequests": 2, + "shouldResetAfter": 1, + }, +} +`); }); }); }); diff --git a/src/contacts/contacts.ts b/src/contacts/contacts.ts index 5fefa506..1e967586 100644 --- a/src/contacts/contacts.ts +++ b/src/contacts/contacts.ts @@ -57,6 +57,7 @@ export class Contacts { if (!options.id && !options.email) { return { data: null, + rateLimiting: null, error: { message: 'Missing `id` or `email` field.', name: 'missing_required_field', @@ -74,6 +75,7 @@ export class Contacts { if (!payload.id && !payload.email) { return { data: null, + rateLimiting: null, error: { message: 'Missing `id` or `email` field.', name: 'missing_required_field', @@ -96,6 +98,7 @@ export class Contacts { if (!payload.id && !payload.email) { return { data: null, + rateLimiting: null, error: { message: 'Missing `id` or `email` field.', name: 'missing_required_field', diff --git a/src/contacts/interfaces/create-contact-options.interface.ts b/src/contacts/interfaces/create-contact-options.interface.ts index ff73f25b..49304acd 100644 --- a/src/contacts/interfaces/create-contact-options.interface.ts +++ b/src/contacts/interfaces/create-contact-options.interface.ts @@ -1,5 +1,5 @@ import type { PostOptions } from '../../common/interfaces'; -import type { ErrorResponse } from '../../interfaces'; +import type { Response } from '../../interfaces'; import type { Contact } from './contact'; export interface CreateContactOptions { @@ -16,12 +16,4 @@ export interface CreateContactResponseSuccess extends Pick { object: 'contact'; } -export type CreateContactResponse = - | { - data: CreateContactResponseSuccess; - error: null; - } - | { - data: null; - error: ErrorResponse; - }; +export type CreateContactResponse = Response; diff --git a/src/contacts/interfaces/get-contact.interface.ts b/src/contacts/interfaces/get-contact.interface.ts index 95e4cf6a..735b87c8 100644 --- a/src/contacts/interfaces/get-contact.interface.ts +++ b/src/contacts/interfaces/get-contact.interface.ts @@ -1,4 +1,4 @@ -import type { ErrorResponse } from '../../interfaces'; +import type { Response } from '../../interfaces'; import type { Contact } from './contact'; export interface GetContactOptions { @@ -15,12 +15,4 @@ export interface GetContactResponseSuccess object: 'contact'; } -export type GetContactResponse = - | { - data: GetContactResponseSuccess; - error: null; - } - | { - data: null; - error: ErrorResponse; - }; +export type GetContactResponse = Response; diff --git a/src/contacts/interfaces/list-contacts.interface.ts b/src/contacts/interfaces/list-contacts.interface.ts index a3970786..8b71b435 100644 --- a/src/contacts/interfaces/list-contacts.interface.ts +++ b/src/contacts/interfaces/list-contacts.interface.ts @@ -1,4 +1,4 @@ -import type { ErrorResponse } from '../../interfaces'; +import type { Response } from '../../interfaces'; import type { Contact } from './contact'; export interface ListContactsOptions { @@ -10,12 +10,4 @@ export interface ListContactsResponseSuccess { data: Contact[]; } -export type ListContactsResponse = - | { - data: ListContactsResponseSuccess; - error: null; - } - | { - data: null; - error: ErrorResponse; - }; +export type ListContactsResponse = Response; diff --git a/src/contacts/interfaces/remove-contact.interface.ts b/src/contacts/interfaces/remove-contact.interface.ts index aedf40ba..bb69e44a 100644 --- a/src/contacts/interfaces/remove-contact.interface.ts +++ b/src/contacts/interfaces/remove-contact.interface.ts @@ -1,4 +1,4 @@ -import type { ErrorResponse } from '../../interfaces'; +import type { Response } from '../../interfaces'; export type RemoveContactsResponseSuccess = { object: 'contact'; @@ -25,12 +25,4 @@ export interface RemoveContactOptions extends RemoveByOptions { audienceId: string; } -export type RemoveContactsResponse = - | { - data: RemoveContactsResponseSuccess; - error: null; - } - | { - data: null; - error: ErrorResponse; - }; +export type RemoveContactsResponse = Response; diff --git a/src/contacts/interfaces/update-contact.interface.ts b/src/contacts/interfaces/update-contact.interface.ts index 8a5d98e6..385ed430 100644 --- a/src/contacts/interfaces/update-contact.interface.ts +++ b/src/contacts/interfaces/update-contact.interface.ts @@ -1,4 +1,4 @@ -import type { ErrorResponse } from '../../interfaces'; +import type { Response } from '../../interfaces'; import type { Contact } from './contact'; interface UpdateContactBaseOptions { @@ -17,12 +17,4 @@ export type UpdateContactResponseSuccess = Pick & { object: 'contact'; }; -export type UpdateContactResponse = - | { - data: UpdateContactResponseSuccess; - error: null; - } - | { - data: null; - error: ErrorResponse; - }; +export type UpdateContactResponse = Response; diff --git a/src/domains/domains.spec.ts b/src/domains/domains.spec.ts index 369f9c68..7ac04a9c 100644 --- a/src/domains/domains.spec.ts +++ b/src/domains/domains.spec.ts @@ -1,6 +1,10 @@ import { enableFetchMocks } from 'jest-fetch-mock'; import type { ErrorResponse } from '../interfaces'; import { Resend } from '../resend'; +import { + mockErrorResponse, + mockSuccessResponse, +} from '../test-utils/mock-fetch'; import type { CreateDomainOptions, CreateDomainResponseSuccess, @@ -69,12 +73,8 @@ describe('Domains', () => { ], region: 'us-east-1', }; - fetchMock.mockOnce(JSON.stringify(response), { - status: 200, - headers: { - 'content-type': 'application/json', - Authorization: 'Bearer re_924b3rjh2387fbewf823', - }, + mockSuccessResponse(response, { + headers: { Authorization: 'Bearer re_924b3rjh2387fbewf823' }, }); const payload: CreateDomainOptions = { name: 'resend.com' }; @@ -134,6 +134,11 @@ describe('Domains', () => { "status": "not_started", }, "error": null, + "rateLimiting": { + "limit": 2, + "remainingRequests": 2, + "shouldResetAfter": 1, + }, } `); }); @@ -144,12 +149,8 @@ describe('Domains', () => { message: 'Missing "name" field', }; - fetchMock.mockOnce(JSON.stringify(response), { - status: 422, - headers: { - 'content-type': 'application/json', - Authorization: 'Bearer re_924b3rjh2387fbewf823', - }, + mockErrorResponse(response, { + headers: { Authorization: 'Bearer re_924b3rjh2387fbewf823' }, }); const payload: CreateDomainOptions = { @@ -167,6 +168,11 @@ describe('Domains', () => { "message": "Missing "name" field", "name": "missing_required_field", }, + "rateLimiting": { + "limit": 2, + "remainingRequests": 2, + "shouldResetAfter": 1, + }, } `); }); @@ -234,57 +240,12 @@ describe('Domains', () => { resend.domains.create(payload), ).resolves.toMatchInlineSnapshot(` { - "data": { - "created_at": "2023-04-07T22:48:33.420498+00:00", - "id": "3d4a472d-bc6d-4dd2-aa9d-d3d50ce87222", - "name": "resend.com", - "records": [ - { - "name": "bounces", - "priority": 10, - "record": "SPF", - "status": "not_started", - "ttl": "Auto", - "type": "MX", - "value": "feedback-smtp.eu-west-1.com", - }, - { - "name": "bounces", - "record": "SPF", - "status": "not_started", - "ttl": "Auto", - "type": "TXT", - "value": ""v=spf1 include:com ~all"", - }, - { - "name": "nu22pfdfqaxdybogtw3ebaokmalv5mxg._domainkey", - "record": "DKIM", - "status": "not_started", - "ttl": "Auto", - "type": "CNAME", - "value": "nu22pfdfqaxdybogtw3ebaokmalv5mxg.dkim.com.", - }, - { - "name": "qklz5ozk742hhql3vmekdu3pr4f5ggsj._domainkey", - "record": "DKIM", - "status": "not_started", - "ttl": "Auto", - "type": "CNAME", - "value": "qklz5ozk742hhql3vmekdu3pr4f5ggsj.dkim.com.", - }, - { - "name": "eeaemodxoao5hxwjvhywx4bo5mswjw6v._domainkey", - "record": "DKIM", - "status": "not_started", - "ttl": "Auto", - "type": "CNAME", - "value": "eeaemodxoao5hxwjvhywx4bo5mswjw6v.dkim.com.", - }, - ], - "region": "eu-west-1", - "status": "not_started", + "data": null, + "error": { + "message": "Unable to fetch data. The request could not be resolved.", + "name": "application_error", }, - "error": null, + "rateLimiting": null, } `); }); @@ -295,12 +256,8 @@ describe('Domains', () => { message: 'Region must be "us-east-1" | "eu-west-1" | "sa-east-1"', }; - fetchMock.mockOnce(JSON.stringify(errorResponse), { - status: 422, - headers: { - 'content-type': 'application/json', - Authorization: 'Bearer re_924b3rjh2387fbewf823', - }, + mockErrorResponse(errorResponse, { + headers: { Authorization: 'Bearer re_924b3rjh2387fbewf823' }, }); const resend = new Resend('re_zKa4RCko_Lhm9ost2YjNCctnPjbLw8Nop'); @@ -317,6 +274,11 @@ describe('Domains', () => { "message": "Region must be "us-east-1" | "eu-west-1" | "sa-east-1"", "name": "invalid_region", }, + "rateLimiting": { + "limit": 2, + "remainingRequests": 2, + "shouldResetAfter": 1, + }, } `); }); @@ -359,12 +321,8 @@ describe('Domains', () => { region: 'us-east-1', }; - fetchMock.mockOnce(JSON.stringify(response), { - status: 200, - headers: { - 'content-type': 'application/json', - Authorization: 'Bearer re_924b3rjh2387fbewf823', - }, + mockSuccessResponse(response, { + headers: { Authorization: 'Bearer re_924b3rjh2387fbewf823' }, }); const payload: CreateDomainOptions = { @@ -412,6 +370,11 @@ describe('Domains', () => { "status": "not_started", }, "error": null, + "rateLimiting": { + "limit": 2, + "remainingRequests": 2, + "shouldResetAfter": 1, + }, } `); }); @@ -438,12 +401,8 @@ describe('Domains', () => { }, ], }; - fetchMock.mockOnce(JSON.stringify(response), { - status: 200, - headers: { - 'content-type': 'application/json', - Authorization: 'Bearer re_924b3rjh2387fbewf823', - }, + mockSuccessResponse(response, { + headers: { Authorization: 'Bearer re_924b3rjh2387fbewf823' }, }); const resend = new Resend('re_zKa4RCko_Lhm9ost2YjNCctnPjbLw8Nop'); @@ -469,6 +428,11 @@ describe('Domains', () => { ], }, "error": null, + "rateLimiting": { + "limit": 2, + "remainingRequests": 2, + "shouldResetAfter": 1, + }, } `); }); @@ -482,12 +446,8 @@ describe('Domains', () => { message: 'Domain not found', }; - fetchMock.mockOnce(JSON.stringify(response), { - status: 404, - headers: { - 'content-type': 'application/json', - Authorization: 'Bearer re_924b3rjh2387fbewf823', - }, + mockErrorResponse(response, { + headers: { Authorization: 'Bearer re_924b3rjh2387fbewf823' }, }); const resend = new Resend('re_zKa4RCko_Lhm9ost2YjNCctnPjbLw8Nop'); @@ -501,6 +461,11 @@ describe('Domains', () => { "message": "Domain not found", "name": "not_found", }, + "rateLimiting": { + "limit": 2, + "remainingRequests": 2, + "shouldResetAfter": 1, + }, } `); }); @@ -544,12 +509,8 @@ describe('Domains', () => { ], }; - fetchMock.mockOnce(JSON.stringify(response), { - status: 200, - headers: { - 'content-type': 'application/json', - Authorization: 'Bearer re_924b3rjh2387fbewf823', - }, + mockSuccessResponse(response, { + headers: { Authorization: 'Bearer re_924b3rjh2387fbewf823' }, }); const resend = new Resend('re_zKa4RCko_Lhm9ost2YjNCctnPjbLw8Nop'); @@ -592,6 +553,11 @@ describe('Domains', () => { "status": "not_started", }, "error": null, + "rateLimiting": { + "limit": 2, + "remainingRequests": 2, + "shouldResetAfter": 1, + }, } `); }); @@ -605,12 +571,8 @@ describe('Domains', () => { id, }; - fetchMock.mockOnce(JSON.stringify(response), { - status: 200, - headers: { - 'content-type': 'application/json', - Authorization: 'Bearer re_924b3rjh2387fbewf823', - }, + mockSuccessResponse(response, { + headers: { Authorization: 'Bearer re_924b3rjh2387fbewf823' }, }); const resend = new Resend('re_zKa4RCko_Lhm9ost2YjNCctnPjbLw8Nop'); @@ -627,6 +589,11 @@ describe('Domains', () => { "object": "domain", }, "error": null, + "rateLimiting": { + "limit": 2, + "remainingRequests": 2, + "shouldResetAfter": 1, + }, } `); }); @@ -639,12 +606,8 @@ describe('Domains', () => { object: 'domain', id, }; - fetchMock.mockOnce(JSON.stringify(response), { - status: 200, - headers: { - 'content-type': 'application/json', - Authorization: 'Bearer re_924b3rjh2387fbewf823', - }, + mockSuccessResponse(response, { + headers: { Authorization: 'Bearer re_924b3rjh2387fbewf823' }, }); const resend = new Resend('re_zKa4RCko_Lhm9ost2YjNCctnPjbLw8Nop'); @@ -656,6 +619,11 @@ describe('Domains', () => { "object": "domain", }, "error": null, + "rateLimiting": { + "limit": 2, + "remainingRequests": 2, + "shouldResetAfter": 1, + }, } `); }); @@ -669,12 +637,8 @@ describe('Domains', () => { id, deleted: true, }; - fetchMock.mockOnce(JSON.stringify(response), { - status: 200, - headers: { - 'content-type': 'application/json', - Authorization: 'Bearer re_924b3rjh2387fbewf823', - }, + mockSuccessResponse(response, { + headers: { Authorization: 'Bearer re_924b3rjh2387fbewf823' }, }); const resend = new Resend('re_zKa4RCko_Lhm9ost2YjNCctnPjbLw8Nop'); @@ -687,6 +651,11 @@ describe('Domains', () => { "object": "domain", }, "error": null, + "rateLimiting": { + "limit": 2, + "remainingRequests": 2, + "shouldResetAfter": 1, + }, } `); }); diff --git a/src/domains/interfaces/create-domain-options.interface.ts b/src/domains/interfaces/create-domain-options.interface.ts index 13ba9562..ce278e95 100644 --- a/src/domains/interfaces/create-domain-options.interface.ts +++ b/src/domains/interfaces/create-domain-options.interface.ts @@ -1,5 +1,5 @@ import type { PostOptions } from '../../common/interfaces'; -import type { ErrorResponse } from '../../interfaces'; +import type { Response } from '../../interfaces'; import type { Domain, DomainRecords, DomainRegion } from './domain'; export interface CreateDomainOptions { @@ -15,12 +15,4 @@ export interface CreateDomainResponseSuccess records: DomainRecords[]; } -export type CreateDomainResponse = - | { - data: CreateDomainResponseSuccess; - error: null; - } - | { - data: null; - error: ErrorResponse; - }; +export type CreateDomainResponse = Response; diff --git a/src/domains/interfaces/get-domain.interface.ts b/src/domains/interfaces/get-domain.interface.ts index 6c79410f..1b86eab3 100644 --- a/src/domains/interfaces/get-domain.interface.ts +++ b/src/domains/interfaces/get-domain.interface.ts @@ -1,4 +1,4 @@ -import type { ErrorResponse } from '../../interfaces'; +import type { Response } from '../../interfaces'; import type { Domain, DomainRecords } from './domain'; export interface GetDomainResponseSuccess @@ -7,12 +7,4 @@ export interface GetDomainResponseSuccess records: DomainRecords[]; } -export type GetDomainResponse = - | { - data: GetDomainResponseSuccess; - error: null; - } - | { - data: null; - error: ErrorResponse; - }; +export type GetDomainResponse = Response; diff --git a/src/domains/interfaces/list-domains.interface.ts b/src/domains/interfaces/list-domains.interface.ts index fcba8ab9..af6a5604 100644 --- a/src/domains/interfaces/list-domains.interface.ts +++ b/src/domains/interfaces/list-domains.interface.ts @@ -1,14 +1,6 @@ -import type { ErrorResponse } from '../../interfaces'; +import type { Response } from '../../interfaces'; import type { Domain } from './domain'; export type ListDomainsResponseSuccess = { data: Domain[] }; -export type ListDomainsResponse = - | { - data: ListDomainsResponseSuccess; - error: null; - } - | { - data: null; - error: ErrorResponse; - }; +export type ListDomainsResponse = Response; diff --git a/src/domains/interfaces/remove-domain.interface.ts b/src/domains/interfaces/remove-domain.interface.ts index 7c11f64d..aa1cb266 100644 --- a/src/domains/interfaces/remove-domain.interface.ts +++ b/src/domains/interfaces/remove-domain.interface.ts @@ -1,4 +1,4 @@ -import type { ErrorResponse } from '../../interfaces'; +import type { Response } from '../../interfaces'; import type { Domain } from './domain'; export type RemoveDomainsResponseSuccess = Pick & { @@ -6,12 +6,4 @@ export type RemoveDomainsResponseSuccess = Pick & { deleted: boolean; }; -export type RemoveDomainsResponse = - | { - data: RemoveDomainsResponseSuccess; - error: null; - } - | { - data: null; - error: ErrorResponse; - }; +export type RemoveDomainsResponse = Response; diff --git a/src/domains/interfaces/update-domain.interface.ts b/src/domains/interfaces/update-domain.interface.ts index c07333eb..9b36b90e 100644 --- a/src/domains/interfaces/update-domain.interface.ts +++ b/src/domains/interfaces/update-domain.interface.ts @@ -1,4 +1,4 @@ -import type { ErrorResponse } from '../../interfaces'; +import type { Response } from '../../interfaces'; import type { Domain } from './domain'; export interface UpdateDomainsOptions { @@ -12,12 +12,4 @@ export type UpdateDomainsResponseSuccess = Pick & { object: 'domain'; }; -export type UpdateDomainsResponse = - | { - data: UpdateDomainsResponseSuccess; - error: null; - } - | { - data: null; - error: ErrorResponse; - }; +export type UpdateDomainsResponse = Response; diff --git a/src/domains/interfaces/verify-domain.interface.ts b/src/domains/interfaces/verify-domain.interface.ts index 069b9193..6bb420b9 100644 --- a/src/domains/interfaces/verify-domain.interface.ts +++ b/src/domains/interfaces/verify-domain.interface.ts @@ -1,16 +1,8 @@ -import type { ErrorResponse } from '../../interfaces'; +import type { Response } from '../../interfaces'; import type { Domain } from './domain'; export type VerifyDomainsResponseSuccess = Pick & { object: 'domain'; }; -export type VerifyDomainsResponse = - | { - data: VerifyDomainsResponseSuccess; - error: null; - } - | { - data: null; - error: ErrorResponse; - }; +export type VerifyDomainsResponse = Response; diff --git a/src/emails/emails.spec.ts b/src/emails/emails.spec.ts index c71fe78e..ec0df21f 100644 --- a/src/emails/emails.spec.ts +++ b/src/emails/emails.spec.ts @@ -1,6 +1,11 @@ import { enableFetchMocks } from 'jest-fetch-mock'; import type { ErrorResponse } from '../interfaces'; import { Resend } from '../resend'; +import { + mockErrorResponse, + mockFetchWithRateLimit, + mockSuccessResponse, +} from '../test-utils/mock-fetch'; import type { CreateEmailOptions, CreateEmailResponseSuccess, @@ -21,10 +26,8 @@ describe('Emails', () => { message: 'Missing `from` field.', }; - fetchMock.mockOnce(JSON.stringify(response), { - status: 422, + mockErrorResponse(response, { headers: { - 'content-type': 'application/json', Authorization: 'Bearer re_zKa4RCko_Lhm9ost2YjNCctnPjbLw8Nop', }, }); @@ -38,6 +41,11 @@ describe('Emails', () => { "message": "Missing \`from\` field.", "name": "missing_required_field", }, + "rateLimiting": { + "limit": 2, + "remainingRequests": 2, + "shouldResetAfter": 1, + }, } `); }); @@ -47,10 +55,8 @@ describe('Emails', () => { id: 'not-idempotent-123', }; - fetchMock.mockOnce(JSON.stringify(response), { - status: 200, + mockSuccessResponse(response, { headers: { - 'content-type': 'application/json', Authorization: 'Bearer re_zKa4RCko_Lhm9ost2YjNCctnPjbLw8Nop', }, }); @@ -83,10 +89,8 @@ describe('Emails', () => { id: 'idempotent-123', }; - fetchMock.mockOnce(JSON.stringify(response), { - status: 200, + mockSuccessResponse(response, { headers: { - 'content-type': 'application/json', Authorization: 'Bearer re_zKa4RCko_Lhm9ost2YjNCctnPjbLw8Nop', }, }); @@ -124,10 +128,8 @@ describe('Emails', () => { const response: CreateEmailResponseSuccess = { id: '71cdfe68-cf79-473a-a9d7-21f91db6a526', }; - fetchMock.mockOnce(JSON.stringify(response), { - status: 200, + mockSuccessResponse(response, { headers: { - 'content-type': 'application/json', Authorization: 'Bearer re_zKa4RCko_Lhm9ost2YjNCctnPjbLw8Nop', }, }); @@ -146,6 +148,11 @@ describe('Emails', () => { "id": "71cdfe68-cf79-473a-a9d7-21f91db6a526", }, "error": null, + "rateLimiting": { + "limit": 2, + "remainingRequests": 2, + "shouldResetAfter": 1, + }, } `); }); @@ -155,12 +162,8 @@ describe('Emails', () => { id: '124dc0f1-e36c-417c-a65c-e33773abc768', }; - fetchMock.mockOnce(JSON.stringify(response), { - status: 200, - headers: { - 'content-type': 'application/json', - Authorization: 'Bearer re_924b3rjh2387fbewf823', - }, + mockSuccessResponse(response, { + headers: { Authorization: 'Bearer re_924b3rjh2387fbewf823' }, }); const payload: CreateEmailOptions = { @@ -176,6 +179,11 @@ describe('Emails', () => { "id": "124dc0f1-e36c-417c-a65c-e33773abc768", }, "error": null, + "rateLimiting": { + "limit": 2, + "remainingRequests": 2, + "shouldResetAfter": 1, + }, } `); }); @@ -185,12 +193,8 @@ describe('Emails', () => { id: '124dc0f1-e36c-417c-a65c-e33773abc768', }; - fetchMock.mockOnce(JSON.stringify(response), { - status: 200, - headers: { - 'content-type': 'application/json', - Authorization: 'Bearer re_924b3rjh2387fbewf823', - }, + mockSuccessResponse(response, { + headers: { Authorization: 'Bearer re_924b3rjh2387fbewf823' }, }); const payload: CreateEmailOptions = { @@ -208,6 +212,11 @@ describe('Emails', () => { "id": "124dc0f1-e36c-417c-a65c-e33773abc768", }, "error": null, + "rateLimiting": { + "limit": 2, + "remainingRequests": 2, + "shouldResetAfter": 1, + }, } `); }); @@ -217,12 +226,8 @@ describe('Emails', () => { id: '124dc0f1-e36c-417c-a65c-e33773abc768', }; - fetchMock.mockOnce(JSON.stringify(response), { - status: 200, - headers: { - 'content-type': 'application/json', - Authorization: 'Bearer re_924b3rjh2387fbewf823', - }, + mockSuccessResponse(response, { + headers: { Authorization: 'Bearer re_924b3rjh2387fbewf823' }, }); const payload: CreateEmailOptions = { @@ -240,6 +245,11 @@ describe('Emails', () => { "id": "124dc0f1-e36c-417c-a65c-e33773abc768", }, "error": null, + "rateLimiting": { + "limit": 2, + "remainingRequests": 2, + "shouldResetAfter": 1, + }, } `); }); @@ -249,12 +259,8 @@ describe('Emails', () => { id: '124dc0f1-e36c-417c-a65c-e33773abc768', }; - fetchMock.mockOnce(JSON.stringify(response), { - status: 200, - headers: { - 'content-type': 'application/json', - Authorization: 'Bearer re_924b3rjh2387fbewf823', - }, + mockSuccessResponse(response, { + headers: { Authorization: 'Bearer re_924b3rjh2387fbewf823' }, }); const payload: CreateEmailOptions = { @@ -272,6 +278,11 @@ describe('Emails', () => { "id": "124dc0f1-e36c-417c-a65c-e33773abc768", }, "error": null, + "rateLimiting": { + "limit": 2, + "remainingRequests": 2, + "shouldResetAfter": 1, + }, } `); }); @@ -281,12 +292,8 @@ describe('Emails', () => { id: '124dc0f1-e36c-417c-a65c-e33773abc768', }; - fetchMock.mockOnce(JSON.stringify(response), { - status: 200, - headers: { - 'content-type': 'application/json', - Authorization: 'Bearer re_924b3rjh2387fbewf823', - }, + mockSuccessResponse(response, { + headers: { Authorization: 'Bearer re_924b3rjh2387fbewf823' }, }); const payload: CreateEmailOptions = { @@ -306,6 +313,11 @@ describe('Emails', () => { "id": "124dc0f1-e36c-417c-a65c-e33773abc768", }, "error": null, + "rateLimiting": { + "limit": 2, + "remainingRequests": 2, + "shouldResetAfter": 1, + }, } `); }); @@ -317,12 +329,8 @@ describe('Emails', () => { 'Invalid `from` field. The email address needs to follow the `email@example.com` or `Name ` format', }; - fetchMock.mockOnce(JSON.stringify(response), { - status: 422, - headers: { - 'content-type': 'application/json', - Authorization: 'Bearer re_924b3rjh2387fbewf823', - }, + mockErrorResponse(response, { + headers: { Authorization: 'Bearer re_924b3rjh2387fbewf823' }, }); const payload: CreateEmailOptions = { @@ -336,14 +344,19 @@ describe('Emails', () => { const result = resend.emails.send(payload); await expect(result).resolves.toMatchInlineSnapshot(` - { - "data": null, - "error": { - "message": "Invalid \`from\` field. The email address needs to follow the \`email@example.com\` or \`Name \` format", - "name": "invalid_parameter", - }, - } - `); +{ + "data": null, + "error": { + "message": "Invalid \`from\` field. The email address needs to follow the \`email@example.com\` or \`Name \` format", + "name": "invalid_parameter", + }, + "rateLimiting": { + "limit": 2, + "remainingRequests": 2, + "shouldResetAfter": 1, + }, +} +`); }); it('returns an error when fetch fails', async () => { @@ -373,7 +386,7 @@ describe('Emails', () => { }); it('returns an error when api responds with text payload', async () => { - fetchMock.mockOnce('local_rate_limited', { + mockFetchWithRateLimit('local_rate_limited', { status: 422, headers: { Authorization: 'Bearer re_924b3rjh2387fbewf823', @@ -408,10 +421,8 @@ describe('Emails', () => { message: 'Email not found', }; - fetchMock.mockOnce(JSON.stringify(response), { - status: 404, + mockErrorResponse(response, { headers: { - 'content-type': 'application/json', Authorization: 'Bearer re_zKa4RCko_Lhm9ost2YjNCctnPjbLw8Nop', }, }); @@ -427,6 +438,11 @@ describe('Emails', () => { "message": "Email not found", "name": "not_found", }, + "rateLimiting": { + "limit": 2, + "remainingRequests": 2, + "shouldResetAfter": 1, + }, } `); }); @@ -450,10 +466,8 @@ describe('Emails', () => { scheduled_at: null, }; - fetchMock.mockOnce(JSON.stringify(response), { - status: 200, + mockSuccessResponse(response, { headers: { - 'content-type': 'application/json', Authorization: 'Bearer re_zKa4RCko_Lhm9ost2YjNCctnPjbLw8Nop', }, }); @@ -480,6 +494,11 @@ describe('Emails', () => { ], }, "error": null, + "rateLimiting": { + "limit": 2, + "remainingRequests": 2, + "shouldResetAfter": 1, + }, } `); }); @@ -501,10 +520,8 @@ describe('Emails', () => { scheduled_at: null, }; - fetchMock.mockOnce(JSON.stringify(response), { - status: 200, + mockSuccessResponse(response, { headers: { - 'content-type': 'application/json', Authorization: 'Bearer re_zKa4RCko_Lhm9ost2YjNCctnPjbLw8Nop', }, }); @@ -534,6 +551,11 @@ describe('Emails', () => { ], }, "error": null, + "rateLimiting": { + "limit": 2, + "remainingRequests": 2, + "shouldResetAfter": 1, + }, } `); }); diff --git a/src/emails/interfaces/cancel-email-options.interface.ts b/src/emails/interfaces/cancel-email-options.interface.ts index c34fbf2d..e7ff6ff8 100644 --- a/src/emails/interfaces/cancel-email-options.interface.ts +++ b/src/emails/interfaces/cancel-email-options.interface.ts @@ -1,16 +1,8 @@ -import type { ErrorResponse } from '../../interfaces'; +import type { Response } from '../../interfaces'; export interface CancelEmailResponseSuccess { object: 'email'; id: string; } -export type CancelEmailResponse = - | { - data: CancelEmailResponseSuccess; - error: null; - } - | { - data: null; - error: ErrorResponse; - }; +export type CancelEmailResponse = Response; diff --git a/src/emails/interfaces/create-email-options.interface.ts b/src/emails/interfaces/create-email-options.interface.ts index 11db678a..509ff88f 100644 --- a/src/emails/interfaces/create-email-options.interface.ts +++ b/src/emails/interfaces/create-email-options.interface.ts @@ -2,7 +2,7 @@ import type * as React from 'react'; import type { PostOptions } from '../../common/interfaces'; import type { IdempotentRequest } from '../../common/interfaces/idempotent-request.interface'; import type { RequireAtLeastOne } from '../../common/interfaces/require-at-least-one'; -import type { ErrorResponse } from '../../interfaces'; +import type { Response } from '../../interfaces'; interface EmailRenderOptions { /** @@ -101,15 +101,7 @@ export interface CreateEmailResponseSuccess { id: string; } -export type CreateEmailResponse = - | { - data: CreateEmailResponseSuccess; - error: null; - } - | { - data: null; - error: ErrorResponse; - }; +export type CreateEmailResponse = Response; export interface Attachment { /** Content of an attached file. */ diff --git a/src/emails/interfaces/get-email-options.interface.ts b/src/emails/interfaces/get-email-options.interface.ts index 1d83248a..4c840a4a 100644 --- a/src/emails/interfaces/get-email-options.interface.ts +++ b/src/emails/interfaces/get-email-options.interface.ts @@ -1,4 +1,4 @@ -import type { ErrorResponse } from '../../interfaces'; +import type { Response } from '../../interfaces'; export interface GetEmailResponseSuccess { bcc: string[] | null; @@ -28,12 +28,4 @@ export interface GetEmailResponseSuccess { object: 'email'; } -export type GetEmailResponse = - | { - data: GetEmailResponseSuccess; - error: null; - } - | { - data: null; - error: ErrorResponse; - }; +export type GetEmailResponse = Response; diff --git a/src/emails/interfaces/update-email-options.interface.ts b/src/emails/interfaces/update-email-options.interface.ts index 89d04637..b3184539 100644 --- a/src/emails/interfaces/update-email-options.interface.ts +++ b/src/emails/interfaces/update-email-options.interface.ts @@ -1,4 +1,4 @@ -import type { ErrorResponse } from '../../interfaces'; +import type { Response } from '../../interfaces'; export interface UpdateEmailOptions { id: string; @@ -10,12 +10,4 @@ export interface UpdateEmailResponseSuccess { object: 'email'; } -export type UpdateEmailResponse = - | { - data: UpdateEmailResponseSuccess; - error: null; - } - | { - data: null; - error: ErrorResponse; - }; +export type UpdateEmailResponse = Response; diff --git a/src/interfaces.ts b/src/interfaces.ts index f3843d9e..9b78f8fb 100644 --- a/src/interfaces.ts +++ b/src/interfaces.ts @@ -1,3 +1,5 @@ +import type { RateLimit } from './rate-limiting'; + export const RESEND_ERROR_CODES_BY_KEY = { missing_required_field: 422, invalid_idempotency_key: 400, @@ -19,9 +21,30 @@ export const RESEND_ERROR_CODES_BY_KEY = { export type RESEND_ERROR_CODE_KEY = keyof typeof RESEND_ERROR_CODES_BY_KEY; -export interface ErrorResponse { - message: string; - name: RESEND_ERROR_CODE_KEY; -} +export type ErrorResponse = + | { + message: string; + name: Exclude; + } + | { + message: string; + name: Extract; + /** + * Time in seconds. + */ + retryAfter: number; + }; + +export type Response = + | { + data: Data; + rateLimiting: RateLimit; + error: null; + } + | { + data: null; + rateLimiting: RateLimit | null; + error: ErrorResponse; + }; export type Tag = { name: string; value: string }; diff --git a/src/rate-limiting.ts b/src/rate-limiting.ts new file mode 100644 index 00000000..d9f6a4b5 --- /dev/null +++ b/src/rate-limiting.ts @@ -0,0 +1,45 @@ +// @ts-ignore: this is used in the jsdoc for `shouldResetAfter` +import type { Response } from './interfaces'; + +export type RateLimit = { + /** + * The maximum amount of requests that can be made in the time window of {@link shouldResetAfter}. + */ + limit: number; + /** + * The amount of requests that can still be made before hitting {@link RateLimit.limit}. + * + * Resets after the seconds in {@link RateLimit.shouldResetAfter} go by. + */ + remainingRequests: number; + /** + * The number of seconds after which the rate limiting will reset, + * and {@link RateLimit.remainingRequests} goes back to the value of + * {@link RateLimit.limit}. + * + * @see {@link import('./interfaces').Response.retryAfter} + */ + shouldResetAfter: number; +}; + +export function parseRateLimit(headers: Headers): RateLimit { + const limitHeader = headers.get('ratelimit-limit'); + const remainingHeader = headers.get('ratelimit-remaining'); + const resetHeader = headers.get('ratelimit-reset'); + + if (!limitHeader || !remainingHeader || !resetHeader) { + throw new Error( + "The rate limit headers are not present in the response, something must've gone wrong, please email us at support@resend.com", + ); + } + + const limit = Number.parseInt(limitHeader, 10); + const remaining = Number.parseInt(remainingHeader, 10); + const reset = Number.parseInt(resetHeader, 10); + + return { + limit, + remainingRequests: remaining, + shouldResetAfter: reset, + }; +} diff --git a/src/resend.ts b/src/resend.ts index fb547964..3c114417 100644 --- a/src/resend.ts +++ b/src/resend.ts @@ -9,7 +9,8 @@ import type { PatchOptions } from './common/interfaces/patch-option.interface'; import { Contacts } from './contacts/contacts'; import { Domains } from './domains/domains'; import { Emails } from './emails/emails'; -import type { ErrorResponse } from './interfaces'; +import type { ErrorResponse, Response } from './interfaces'; +import { parseRateLimit } from './rate-limiting'; const defaultBaseUrl = 'https://api.resend.com'; const defaultUserAgent = `resend-node:${version}`; @@ -53,21 +54,28 @@ export class Resend { }); } - async fetchRequest( - path: string, - options = {}, - ): Promise<{ data: T; error: null } | { data: null; error: ErrorResponse }> { + async fetchRequest(path: string, options = {}): Promise> { try { const response = await fetch(`${baseUrl}${path}`, options); + const rateLimiting = parseRateLimit(response.headers); + if (!response.ok) { try { const rawError = await response.text(); - return { data: null, error: JSON.parse(rawError) }; + const error: ErrorResponse = JSON.parse(rawError); + if (error.name === 'rate_limit_exceeded' && response.status === 429) { + const retryAfterHeader = response.headers.get('retry-after'); + if (retryAfterHeader) { + error.retryAfter = Number.parseInt(retryAfterHeader, 10); + } + } + return { data: null, rateLimiting, error }; } catch (err) { if (err instanceof SyntaxError) { return { data: null, + rateLimiting, error: { name: 'application_error', message: @@ -82,18 +90,23 @@ export class Resend { }; if (err instanceof Error) { - return { data: null, error: { ...error, message: err.message } }; + return { + data: null, + rateLimiting: rateLimiting, + error: { ...error, message: err.message }, + }; } - return { data: null, error }; + return { data: null, rateLimiting, error }; } } const data = await response.json(); - return { data, error: null }; + return { data, rateLimiting, error: null }; } catch (error) { return { data: null, + rateLimiting: null, error: { name: 'application_error', message: 'Unable to fetch data. The request could not be resolved.', diff --git a/src/test-utils/mock-fetch.ts b/src/test-utils/mock-fetch.ts new file mode 100644 index 00000000..6539fe24 --- /dev/null +++ b/src/test-utils/mock-fetch.ts @@ -0,0 +1,77 @@ +import type { MockResponseInit } from 'jest-fetch-mock'; + +export interface MockFetchOptions extends Omit { + headers?: Record; + rateLimiting?: { + limit?: number; + remaining?: number; + reset?: number; + }; +} + +/** + * Mock fetch response with rate limiting headers included by default + */ +export function mockFetchWithRateLimit( + body: string, + options: MockFetchOptions = {}, +): void { + const { + rateLimiting = {}, + headers = {}, + status = 200, + ...restOptions + } = options; + + const defaultRateLimit = { + limit: 2, + remaining: 2, + reset: 1, // Fixed timestamp for consistent tests + }; + + const rateLimitHeaders = { + 'ratelimit-limit': String(rateLimiting.limit ?? defaultRateLimit.limit), + 'ratelimit-remaining': String( + rateLimiting.remaining ?? defaultRateLimit.remaining, + ), + 'ratelimit-reset': String(rateLimiting.reset ?? defaultRateLimit.reset), + }; + + const allHeaders = { + 'content-type': 'application/json', + ...rateLimitHeaders, + ...headers, + }; + + fetchMock.mockOnce(body, { + status, + headers: allHeaders, + ...restOptions, + }); +} + +/** + * Mock successful response with rate limiting headers + */ +export function mockSuccessResponse( + data: T, + options: MockFetchOptions = {}, +): void { + mockFetchWithRateLimit(JSON.stringify(data), { + status: 200, + ...options, + }); +} + +/** + * Mock error response with rate limiting headers + */ +export function mockErrorResponse( + error: { name: string; message: string }, + options: MockFetchOptions = {}, +): void { + mockFetchWithRateLimit(JSON.stringify(error), { + status: 422, + ...options, + }); +}