diff --git a/src/resend.ts b/src/resend.ts index 59c5e822..f06306fc 100644 --- a/src/resend.ts +++ b/src/resend.ts @@ -10,6 +10,7 @@ import { Contacts } from './contacts/contacts'; import { Domains } from './domains/domains'; import { Emails } from './emails/emails'; import type { ErrorResponse } from './interfaces'; +import { Topics } from './topics/topics'; const defaultBaseUrl = 'https://api.resend.com'; const defaultUserAgent = `resend-node:${version}`; @@ -32,6 +33,7 @@ export class Resend { readonly contacts = new Contacts(this); readonly domains = new Domains(this); readonly emails = new Emails(this); + readonly topics = new Topics(this); constructor(readonly key?: string) { if (!key) { diff --git a/src/topics/interfaces/create-topic-options.interface.ts b/src/topics/interfaces/create-topic-options.interface.ts new file mode 100644 index 00000000..165edde9 --- /dev/null +++ b/src/topics/interfaces/create-topic-options.interface.ts @@ -0,0 +1,15 @@ +import type { ErrorResponse } from '../../interfaces'; +import type { Topic } from './topic'; + +export interface CreateTopicOptions { + name: string; + description?: string; + default_subscription: 'opt_in' | 'opt_out'; +} + +export type CreateTopicResponseSuccess = Pick; + +export interface CreateTopicResponse { + data: CreateTopicResponseSuccess | null; + error: ErrorResponse | null; +} diff --git a/src/topics/interfaces/get-contact.interface.ts b/src/topics/interfaces/get-contact.interface.ts new file mode 100644 index 00000000..f3de6ac8 --- /dev/null +++ b/src/topics/interfaces/get-contact.interface.ts @@ -0,0 +1,13 @@ +import type { ErrorResponse } from '../../interfaces'; +import type { Topic } from './topic'; + +export interface GetTopicOptions { + id: string; +} + +export type GetTopicResponseSuccess = Topic; + +export interface GetTopicResponse { + data: GetTopicResponseSuccess | null; + error: ErrorResponse | null; +} diff --git a/src/topics/interfaces/list-topics.interface.ts b/src/topics/interfaces/list-topics.interface.ts new file mode 100644 index 00000000..e90aa6ea --- /dev/null +++ b/src/topics/interfaces/list-topics.interface.ts @@ -0,0 +1,11 @@ +import type { ErrorResponse } from '../../interfaces'; +import type { Topic } from './topic'; + +export interface ListTopicsResponseSuccess { + data: Topic[]; +} + +export interface ListTopicsResponse { + data: ListTopicsResponseSuccess | null; + error: ErrorResponse | null; +} diff --git a/src/topics/interfaces/remove-topic.interface.ts b/src/topics/interfaces/remove-topic.interface.ts new file mode 100644 index 00000000..2d80584e --- /dev/null +++ b/src/topics/interfaces/remove-topic.interface.ts @@ -0,0 +1,12 @@ +import type { ErrorResponse } from '../../interfaces'; +import type { Topic } from './topic'; + +export type RemoveTopicResponseSuccess = Pick & { + object: 'topic'; + deleted: boolean; +}; + +export interface RemoveTopicResponse { + data: RemoveTopicResponseSuccess | null; + error: ErrorResponse | null; +} diff --git a/src/topics/interfaces/topic.ts b/src/topics/interfaces/topic.ts new file mode 100644 index 00000000..999bcc04 --- /dev/null +++ b/src/topics/interfaces/topic.ts @@ -0,0 +1,7 @@ +export interface Topic { + id: string; + name: string; + description?: string; + default_subscription: 'opt_in' | 'opt_out'; + created_at: string; +} diff --git a/src/topics/interfaces/update-topic.interface.ts b/src/topics/interfaces/update-topic.interface.ts new file mode 100644 index 00000000..f78f2fee --- /dev/null +++ b/src/topics/interfaces/update-topic.interface.ts @@ -0,0 +1,15 @@ +import type { ErrorResponse } from '../../interfaces'; +import type { Topic } from './topic'; + +export interface UpdateTopicOptions { + id: string; + name?: string; + description?: string; +} + +export type UpdateTopicResponseSuccess = Pick; + +export interface UpdateTopicResponse { + data: UpdateTopicResponseSuccess | null; + error: ErrorResponse | null; +} diff --git a/src/topics/topics.spec.ts b/src/topics/topics.spec.ts new file mode 100644 index 00000000..4a7a02ba --- /dev/null +++ b/src/topics/topics.spec.ts @@ -0,0 +1,354 @@ +import { enableFetchMocks } from 'jest-fetch-mock'; +import type { ErrorResponse } from '../interfaces'; +import { Resend } from '../resend'; +import type { + CreateTopicOptions, + CreateTopicResponseSuccess, +} from './interfaces/create-topic-options.interface'; +import type { GetTopicResponseSuccess } from './interfaces/get-contact.interface'; +import type { ListTopicsResponseSuccess } from './interfaces/list-topics.interface'; +import type { RemoveTopicResponseSuccess } from './interfaces/remove-topic.interface'; +import type { UpdateTopicOptions } from './interfaces/update-topic.interface'; + +enableFetchMocks(); + +describe('Topics', () => { + afterEach(() => fetchMock.resetMocks()); + + describe('create', () => { + it('creates a topic', async () => { + const payload: CreateTopicOptions = { + name: 'Newsletter', + description: 'Weekly newsletter updates', + default_subscription: 'opt_in', + }; + const response: CreateTopicResponseSuccess = { + id: '3deaccfb-f47f-440a-8875-ea14b1716b43', + }; + + fetchMock.mockOnce(JSON.stringify(response), { + status: 200, + headers: { + 'content-type': 'application/json', + Authorization: 'Bearer re_924b3rjh2387fbewf823', + }, + }); + + const resend = new Resend('re_zKa4RCko_Lhm9ost2YjNCctnPjbLw8Nop'); + await expect( + resend.topics.create(payload), + ).resolves.toMatchInlineSnapshot(` +{ + "data": { + "id": "3deaccfb-f47f-440a-8875-ea14b1716b43", + }, + "error": null, +} +`); + }); + + it('throws error when missing name', async () => { + const payload: CreateTopicOptions = { + name: '', + default_subscription: 'opt_in', + }; + const response: ErrorResponse = { + name: 'missing_required_field', + message: 'Missing `name` field.', + }; + + fetchMock.mockOnce(JSON.stringify(response), { + status: 422, + headers: { + 'content-type': 'application/json', + Authorization: 'Bearer re_924b3rjh2387fbewf823', + }, + }); + + const resend = new Resend('re_zKa4RCko_Lhm9ost2YjNCctnPjbLw8Nop'); + + const result = resend.topics.create(payload); + + await expect(result).resolves.toMatchInlineSnapshot(` +{ + "data": null, + "error": { + "message": "Missing \`name\` field.", + "name": "missing_required_field", + }, +} +`); + }); + + it('throws error when missing default_subscription', async () => { + const payload = { + name: 'Newsletter', + description: 'Weekly newsletter updates', + }; + const response: ErrorResponse = { + name: 'missing_required_field', + message: 'Missing `default_subscription` field.', + }; + + fetchMock.mockOnce(JSON.stringify(response), { + status: 422, + headers: { + 'content-type': 'application/json', + Authorization: 'Bearer re_924b3rjh2387fbewf823', + }, + }); + + const resend = new Resend('re_zKa4RCko_Lhm9ost2YjNCctnPjbLw8Nop'); + + const result = resend.topics.create(payload as CreateTopicOptions); + + await expect(result).resolves.toMatchInlineSnapshot(` +{ + "data": null, + "error": { + "message": "Missing \`default_subscription\` field.", + "name": "missing_required_field", + }, +} +`); + }); + }); + + describe('list', () => { + it('lists topics', async () => { + const response: ListTopicsResponseSuccess = { + data: [ + { + id: 'b6d24b8e-af0b-4c3c-be0c-359bbd97381e', + name: 'Newsletter', + description: 'Weekly newsletter updates', + default_subscription: 'opt_in', + created_at: '2023-04-07T23:13:52.669661+00:00', + }, + { + id: 'ac7503ac-e027-4aea-94b3-b0acd46f65f9', + name: 'Product Updates', + description: 'Product announcements and updates', + default_subscription: 'opt_out', + created_at: '2023-04-07T23:13:20.417116+00:00', + }, + ], + }; + fetchMock.mockOnce(JSON.stringify(response), { + status: 200, + headers: { + 'content-type': 'application/json', + Authorization: 'Bearer re_924b3rjh2387fbewf823', + }, + }); + + const resend = new Resend('re_zKa4RCko_Lhm9ost2YjNCctnPjbLw8Nop'); + + await expect(resend.topics.list()).resolves.toMatchInlineSnapshot(` +{ + "data": { + "data": [ + { + "created_at": "2023-04-07T23:13:52.669661+00:00", + "default_subscription": "opt_in", + "description": "Weekly newsletter updates", + "id": "b6d24b8e-af0b-4c3c-be0c-359bbd97381e", + "name": "Newsletter", + }, + { + "created_at": "2023-04-07T23:13:20.417116+00:00", + "default_subscription": "opt_out", + "description": "Product announcements and updates", + "id": "ac7503ac-e027-4aea-94b3-b0acd46f65f9", + "name": "Product Updates", + }, + ], + }, + "error": null, +} +`); + }); + }); + + describe('get', () => { + describe('when topic not found', () => { + it('returns error', async () => { + const response: ErrorResponse = { + name: 'not_found', + message: 'Topic not found', + }; + + fetchMock.mockOnce(JSON.stringify(response), { + status: 404, + headers: { + 'content-type': 'application/json', + Authorization: 'Bearer re_924b3rjh2387fbewf823', + }, + }); + + const resend = new Resend('re_zKa4RCko_Lhm9ost2YjNCctnPjbLw8Nop'); + + const result = resend.topics.get( + '3d4a472d-bc6d-4dd2-aa9d-d3d50ce87223', + ); + + await expect(result).resolves.toMatchInlineSnapshot(` +{ + "data": null, + "error": { + "message": "Topic not found", + "name": "not_found", + }, +} +`); + }); + }); + + it('get topic by id', async () => { + const response: GetTopicResponseSuccess = { + id: 'fd61172c-cafc-40f5-b049-b45947779a29', + name: 'Newsletter', + description: 'Weekly newsletter updates', + default_subscription: 'opt_in', + created_at: '2024-01-16T18:12:26.514Z', + }; + + fetchMock.mockOnce(JSON.stringify(response), { + status: 200, + headers: { + 'content-type': 'application/json', + Authorization: 'Bearer re_924b3rjh2387fbewf823', + }, + }); + + const resend = new Resend('re_zKa4RCko_Lhm9ost2YjNCctnPjbLw8Nop'); + await expect( + resend.topics.get('fd61172c-cafc-40f5-b049-b45947779a29'), + ).resolves.toMatchInlineSnapshot(` +{ + "data": { + "created_at": "2024-01-16T18:12:26.514Z", + "default_subscription": "opt_in", + "description": "Weekly newsletter updates", + "id": "fd61172c-cafc-40f5-b049-b45947779a29", + "name": "Newsletter", + }, + "error": null, +} +`); + }); + + it('returns error when missing id', async () => { + const resend = new Resend('re_zKa4RCko_Lhm9ost2YjNCctnPjbLw8Nop'); + const result = resend.topics.get(''); + + await expect(result).resolves.toMatchInlineSnapshot(` +{ + "data": null, + "error": { + "message": "Missing \`id\` field.", + "name": "missing_required_field", + }, +} +`); + }); + }); + + describe('update', () => { + it('updates a topic', async () => { + const payload: UpdateTopicOptions = { + id: '3d4a472d-bc6d-4dd2-aa9d-d3d50ce87223', + name: 'Updated Newsletter', + description: 'Updated weekly newsletter', + }; + const response = { + id: '3d4a472d-bc6d-4dd2-aa9d-d3d50ce87223', + }; + fetchMock.mockOnce(JSON.stringify(response), { + status: 200, + headers: { + 'content-type': 'application/json', + Authorization: 'Bearer re_924b3rjh2387fbewf823', + }, + }); + + const resend = new Resend('re_zKa4RCko_Lhm9ost2YjNCctnPjbLw8Nop'); + + await expect( + resend.topics.update(payload), + ).resolves.toMatchInlineSnapshot(` +{ + "data": { + "id": "3d4a472d-bc6d-4dd2-aa9d-d3d50ce87223", + }, + "error": null, +} +`); + }); + + it('returns error when missing id', async () => { + const payload = { + name: 'Updated Newsletter', + }; + const resend = new Resend('re_zKa4RCko_Lhm9ost2YjNCctnPjbLw8Nop'); + + const result = resend.topics.update(payload as UpdateTopicOptions); + + await expect(result).resolves.toMatchInlineSnapshot(` +{ + "data": null, + "error": { + "message": "Missing \`id\` field.", + "name": "missing_required_field", + }, +} +`); + }); + }); + + describe('remove', () => { + it('removes a topic', async () => { + const response: RemoveTopicResponseSuccess = { + id: '3d4a472d-bc6d-4dd2-aa9d-d3d50ce87223', + object: 'topic', + deleted: true, + }; + fetchMock.mockOnce(JSON.stringify(response), { + status: 200, + headers: { + 'content-type': 'application/json', + Authorization: 'Bearer re_924b3rjh2387fbewf823', + }, + }); + + const resend = new Resend('re_zKa4RCko_Lhm9ost2YjNCctnPjbLw8Nop'); + await expect( + resend.topics.remove('3d4a472d-bc6d-4dd2-aa9d-d3d50ce87223'), + ).resolves.toMatchInlineSnapshot(` +{ + "data": { + "deleted": true, + "id": "3d4a472d-bc6d-4dd2-aa9d-d3d50ce87223", + "object": "topic", + }, + "error": null, +} +`); + }); + + it('returns error when missing id', async () => { + const resend = new Resend('re_zKa4RCko_Lhm9ost2YjNCctnPjbLw8Nop'); + const result = resend.topics.remove(''); + + await expect(result).resolves.toMatchInlineSnapshot(` +{ + "data": null, + "error": { + "message": "Missing \`id\` field.", + "name": "missing_required_field", + }, +} +`); + }); + }); +}); diff --git a/src/topics/topics.ts b/src/topics/topics.ts new file mode 100644 index 00000000..f531be23 --- /dev/null +++ b/src/topics/topics.ts @@ -0,0 +1,96 @@ +import type { Resend } from '../resend'; +import type { + CreateTopicOptions, + CreateTopicResponse, + CreateTopicResponseSuccess, +} from './interfaces/create-topic-options.interface'; +import type { + GetTopicResponse, + GetTopicResponseSuccess, +} from './interfaces/get-contact.interface'; +import type { + ListTopicsResponse, + ListTopicsResponseSuccess, +} from './interfaces/list-topics.interface'; +import type { + RemoveTopicResponse, + RemoveTopicResponseSuccess, +} from './interfaces/remove-topic.interface'; +import type { + UpdateTopicOptions, + UpdateTopicResponse, + UpdateTopicResponseSuccess, +} from './interfaces/update-topic.interface'; + +export class Topics { + constructor(private readonly resend: Resend) {} + + async create(payload: CreateTopicOptions): Promise { + const data = await this.resend.post( + '/topics', + payload, + ); + + return data; + } + + async list(): Promise { + const data = await this.resend.get('/topics'); + + return data; + } + + async get(id: string): Promise { + if (!id) { + return { + data: null, + error: { + message: 'Missing `id` field.', + name: 'missing_required_field', + }, + }; + } + const data = await this.resend.get( + `/topics/${id}`, + ); + + return data; + } + + async update(payload: UpdateTopicOptions): Promise { + if (!payload.id) { + return { + data: null, + error: { + message: 'Missing `id` field.', + name: 'missing_required_field', + }, + }; + } + + const data = await this.resend.patch( + `/topics/${payload.id}`, + payload, + ); + + return data; + } + + async remove(id: string): Promise { + if (!id) { + return { + data: null, + error: { + message: 'Missing `id` field.', + name: 'missing_required_field', + }, + }; + } + + const data = await this.resend.delete( + `/topics/${id}`, + ); + + return data; + } +}