diff --git a/README.md b/README.md index a43c67e..5cba865 100644 --- a/README.md +++ b/README.md @@ -657,10 +657,10 @@ const sequences = await analytics.V1.Sequences.getSequences({ page: 1 }); #### createSequenceEmail -Wraps [`POST /v1/fetch/sequences/:id/emails/templates`](https://docs.bentonow.com/sequences_api#create-sequence-email) so you can add messages to a sequence via code. Pass the sequence prefix ID (e.g., `sequence_abc123`) plus the subject/HTML and any optional delay/snippet/editor fields. +Wraps [`POST /v1/fetch/sequences/:id/emails/templates`](https://docs.bentonow.com/sequences_api#create-sequence-email) so you can add messages to a sequence via code. Pass the sequence ID returned by `getSequences()` plus the subject/HTML and any optional delay/snippet/editor fields. ```javascript -const createdTemplate = await analytics.V1.Sequences.createSequenceEmail('sequence_abc123', { +const createdTemplate = await analytics.V1.Sequences.createSequenceEmail('123', { subject: 'Welcome to Bento', html: '

Hello {{ visitor.first_name }}

', delay_interval: 'days', diff --git a/__tests__/broadcasts/broadcasts.test.ts b/__tests__/broadcasts/broadcasts.test.ts index 3b28c3a..7515dd1 100644 --- a/__tests__/broadcasts/broadcasts.test.ts +++ b/__tests__/broadcasts/broadcasts.test.ts @@ -146,22 +146,15 @@ describe('BentoBroadcasts', () => { describe('createBroadcast', () => { test('successfully creates broadcast', async () => { const mockResponse = { - data: [ + results: 1, + failed: 0, + failures: [], + broadcasts: [ { - id: 'new-broadcast-1', - type: EntityType.EVENTS, - attributes: { - name: 'New Broadcast', - subject: 'New Subject', - content: '

Content

', - type: 'html', - from: { - name: 'Sender', - email: 'sender@example.com', - }, - batch_size_per_hour: 100, - created_at: '2024-01-01T00:00:00Z', - }, + id: 42, + template_id: 99, + name: 'New Broadcast', + dashboard_url: 'https://app.bentonow.com/account/emails/broadcasts/42/edit', }, ], }; @@ -182,13 +175,15 @@ describe('BentoBroadcasts', () => { }, ]); - expect(result).toHaveLength(1); - // @ts-ignore - expect(result[0].attributes.name).toBe('New Broadcast'); + expect(result.results).toBe(1); + expect(result.failed).toBe(0); + expect(result.broadcasts).toHaveLength(1); + expect(result.broadcasts?.[0].name).toBe('New Broadcast'); + expect(result.broadcasts?.[0].id).toBe(42); }); test('uses correct /batch/broadcasts endpoint for POST', async () => { - setupMockFetch({ data: [] }); + setupMockFetch({ results: 0, failed: 0, broadcasts: [], failures: [] }); await analytics.V1.Broadcasts.createBroadcast([ { @@ -208,27 +203,23 @@ describe('BentoBroadcasts', () => { expect(lastFetchMethod).toBe('POST'); }); - test('handles broadcast with segments and tags', async () => { + test('returns partial success with failures', async () => { const mockResponse = { - data: [ + results: 1, + failed: 1, + broadcasts: [ { - id: 'broadcast-2', - type: EntityType.EVENTS, - attributes: { - name: 'Segmented Broadcast', - subject: 'For Segment', - content: 'Content', - type: 'plain', - from: { - name: 'Sender', - email: 'sender@example.com', - }, - inclusive_tags: 'tag1,tag2', - exclusive_tags: 'tag3', - segment_id: 'segment-123', - batch_size_per_hour: 50, - created_at: '2024-01-01T00:00:00Z', - }, + id: 42, + template_id: 99, + name: 'Created draft', + dashboard_url: 'https://app.bentonow.com/account/emails/broadcasts/42/edit', + }, + ], + failures: [ + { + index: 1, + name: 'Invalid draft', + error: 'batch_size_per_hour must be between 10 and 250000', }, ], }; @@ -237,7 +228,7 @@ describe('BentoBroadcasts', () => { const result = await analytics.V1.Broadcasts.createBroadcast([ { - name: 'Segmented Broadcast', + name: 'Created draft', subject: 'For Segment', content: 'Content', type: 'plain', @@ -245,18 +236,25 @@ describe('BentoBroadcasts', () => { name: 'Sender', email: 'sender@example.com', }, - inclusive_tags: 'tag1,tag2', - exclusive_tags: 'tag3', - segment_id: 'segment-123', batch_size_per_hour: 50, }, + { + name: 'Invalid draft', + subject: 'Bad', + content: 'Content', + type: 'plain', + from: { + name: 'Sender', + email: 'sender@example.com', + }, + batch_size_per_hour: 1, + }, ]); - expect(result).toHaveLength(1); - // @ts-ignore - expect(result[0].attributes.segment_id).toBe('segment-123'); - // @ts-ignore - expect(result[0].attributes.inclusive_tags).toBe('tag1,tag2'); + expect(result.results).toBe(1); + expect(result.failed).toBe(1); + expect(result.broadcasts).toHaveLength(1); + expect(result.failures?.[0].error).toContain('batch_size_per_hour'); }); }); }); diff --git a/__tests__/commands/addField.test.ts b/__tests__/commands/addField.test.ts index b634d02..f4c760e 100644 --- a/__tests__/commands/addField.test.ts +++ b/__tests__/commands/addField.test.ts @@ -1,7 +1,6 @@ import { expect, test, describe, beforeEach } from 'bun:test'; import { Analytics } from '../../src'; import { mockOptions } from '../helpers/mockClient'; -import { mockSubscriberResponse } from '../helpers/mockResponses'; import { setupMockFetch } from '../helpers/mockFetch'; describe('BentoCommands - addField', () => { @@ -11,34 +10,18 @@ describe('BentoCommands - addField', () => { analytics = new Analytics(mockOptions); }); - test('successfully adds a field to a subscriber', async () => { - const email = 'test@example.com'; - setupMockFetch(mockSubscriberResponse(email)); - - const result = await analytics.V1.Commands.addField({ - email, - field: { - key: 'testField', - value: 'testValue' - } - }); - - expect(result).toBeDefined(); - expect(result?.attributes.email).toBe(email); - }); - - test('returns null when response is empty', async () => { - setupMockFetch({ data: null }); + test('successfully queues an add_field command', async () => { + setupMockFetch({ results: 1 }); const result = await analytics.V1.Commands.addField({ email: 'test@example.com', field: { key: 'testField', - value: 'testValue' - } + value: 'testValue', + }, }); - expect(result).toBeNull(); + expect(result).toBe(1); }); test('handles server error gracefully', async () => { @@ -54,4 +37,4 @@ describe('BentoCommands - addField', () => { }) ).rejects.toThrow(); }); -}); \ No newline at end of file +}); diff --git a/__tests__/commands/addTag.test.ts b/__tests__/commands/addTag.test.ts index b31f155..b28ee13 100644 --- a/__tests__/commands/addTag.test.ts +++ b/__tests__/commands/addTag.test.ts @@ -1,7 +1,6 @@ import { expect, test, describe, beforeEach } from 'bun:test'; import { Analytics } from '../../src'; import { mockOptions } from '../helpers/mockClient'; -import { mockSubscriberResponse } from '../helpers/mockResponses'; import { setupMockFetch } from '../helpers/mockFetch'; describe('BentoCommands - addTag', () => { @@ -11,27 +10,15 @@ describe('BentoCommands - addTag', () => { analytics = new Analytics(mockOptions); }); - test('successfully adds a tag to a subscriber', async () => { - const email = 'test@example.com'; - const tagName = 'TestTag'; - setupMockFetch(mockSubscriberResponse(email, ['tag-123'])); - - const result = await analytics.V1.Commands.addTag({ email, tagName }); - - expect(result).toBeDefined(); - expect(result?.attributes.email).toBe(email); - expect(result?.attributes.cached_tag_ids).toContain('tag-123'); - }); - - test('returns null when response is empty', async () => { - setupMockFetch({ data: null }); + test('successfully queues an add_tag command', async () => { + setupMockFetch({ results: 1 }); const result = await analytics.V1.Commands.addTag({ email: 'test@example.com', - tagName: 'TestTag' + tagName: 'TestTag', }); - expect(result).toBeNull(); + expect(result).toBe(1); }); test('handles server error gracefully', async () => { @@ -44,4 +31,4 @@ describe('BentoCommands - addTag', () => { }) ).rejects.toThrow(); }); -}); \ No newline at end of file +}); diff --git a/__tests__/commands/changeEmail.test.ts b/__tests__/commands/changeEmail.test.ts index a05a48b..493a7b7 100644 --- a/__tests__/commands/changeEmail.test.ts +++ b/__tests__/commands/changeEmail.test.ts @@ -1,7 +1,6 @@ import { expect, test, describe, beforeEach } from 'bun:test'; import { Analytics } from '../../src'; import { mockOptions } from '../helpers/mockClient'; -import { mockSubscriberResponse } from '../helpers/mockResponses'; import { setupMockFetch } from '../helpers/mockFetch'; describe('BentoCommands - changeEmail', () => { @@ -11,29 +10,15 @@ describe('BentoCommands - changeEmail', () => { analytics = new Analytics(mockOptions); }); - test('successfully changes email address', async () => { - const oldEmail = 'old@example.com'; - const newEmail = 'new@example.com'; - setupMockFetch(mockSubscriberResponse(newEmail)); - - const result = await analytics.V1.Commands.changeEmail({ - oldEmail, - newEmail - }); - - expect(result).toBeDefined(); - expect(result?.attributes.email).toBe(newEmail); - }); - - test('returns null when response is empty', async () => { - setupMockFetch({ data: null }); + test('successfully queues a change_email command', async () => { + setupMockFetch({ results: 1 }); const result = await analytics.V1.Commands.changeEmail({ oldEmail: 'old@example.com', - newEmail: 'new@example.com' + newEmail: 'new@example.com', }); - expect(result).toBeNull(); + expect(result).toBe(1); }); test('handles server error gracefully', async () => { @@ -46,4 +31,4 @@ describe('BentoCommands - changeEmail', () => { }) ).rejects.toThrow(); }); -}); \ No newline at end of file +}); diff --git a/__tests__/commands/removeField.test.ts b/__tests__/commands/removeField.test.ts index f99e94f..f067240 100644 --- a/__tests__/commands/removeField.test.ts +++ b/__tests__/commands/removeField.test.ts @@ -1,7 +1,6 @@ import { expect, test, describe, beforeEach } from 'bun:test'; import { Analytics } from '../../src'; import { mockOptions } from '../helpers/mockClient'; -import { mockSubscriberResponse } from '../helpers/mockResponses'; import { setupMockFetch } from '../helpers/mockFetch'; describe('BentoCommands - removeField', () => { @@ -11,28 +10,15 @@ describe('BentoCommands - removeField', () => { analytics = new Analytics(mockOptions); }); - test('successfully removes a field from a subscriber', async () => { - const email = 'test@example.com'; - setupMockFetch(mockSubscriberResponse(email)); - - const result = await analytics.V1.Commands.removeField({ - email, - fieldName: 'testField' - }); - - expect(result).toBeDefined(); - expect(result?.attributes.email).toBe(email); - }); - - test('returns null when response is empty', async () => { - setupMockFetch({ data: null }); + test('successfully queues a remove_field command', async () => { + setupMockFetch({ results: 1 }); const result = await analytics.V1.Commands.removeField({ email: 'test@example.com', - fieldName: 'testField' + fieldName: 'testField', }); - expect(result).toBeNull(); + expect(result).toBe(1); }); test('handles server error gracefully', async () => { @@ -45,4 +31,4 @@ describe('BentoCommands - removeField', () => { }) ).rejects.toThrow(); }); -}); \ No newline at end of file +}); diff --git a/__tests__/commands/removeTag.test.ts b/__tests__/commands/removeTag.test.ts index fa93234..c639c5b 100644 --- a/__tests__/commands/removeTag.test.ts +++ b/__tests__/commands/removeTag.test.ts @@ -1,7 +1,6 @@ import { expect, test, describe, beforeEach } from 'bun:test'; import { Analytics } from '../../src'; import { mockOptions } from '../helpers/mockClient'; -import { mockSubscriberResponse } from '../helpers/mockResponses'; import { setupMockFetch } from '../helpers/mockFetch'; describe('BentoCommands - removeTag', () => { @@ -11,28 +10,14 @@ describe('BentoCommands - removeTag', () => { analytics = new Analytics(mockOptions); }); - test('successfully removes a tag from a subscriber', async () => { - const email = 'test@example.com'; - setupMockFetch(mockSubscriberResponse(email, [])); - - const result = await analytics.V1.Commands.removeTag({ - email, - tagName: 'TestTag' - }); - - expect(result).toBeDefined(); - expect(result?.attributes.email).toBe(email); - expect(result?.attributes.cached_tag_ids).toHaveLength(0); - }); - - test('returns null when response is empty', async () => { - setupMockFetch({ data: null }); + test('successfully queues a remove_tag command', async () => { + setupMockFetch({ results: 1 }); const result = await analytics.V1.Commands.removeTag({ email: 'test@example.com', - tagName: 'TestTag' + tagName: 'TestTag', }); - expect(result).toBeNull(); + expect(result).toBe(1); }); -}); \ No newline at end of file +}); diff --git a/__tests__/commands/subscribe.test.ts b/__tests__/commands/subscribe.test.ts index d2e95d0..a22389f 100644 --- a/__tests__/commands/subscribe.test.ts +++ b/__tests__/commands/subscribe.test.ts @@ -1,7 +1,6 @@ import { expect, test, describe, beforeEach } from 'bun:test'; import { Analytics } from '../../src'; import { mockOptions } from '../helpers/mockClient'; -import { mockSubscriberResponse } from '../helpers/mockResponses'; import { setupMockFetch } from '../helpers/mockFetch'; describe('BentoCommands - subscribe', () => { @@ -11,25 +10,12 @@ describe('BentoCommands - subscribe', () => { analytics = new Analytics(mockOptions); }); - test('successfully subscribes a user', async () => { - const email = 'test@example.com'; - setupMockFetch(mockSubscriberResponse(email)); + test('successfully queues a subscribe command', async () => { + setupMockFetch({ results: 1 }); - const result = await analytics.V1.Commands.subscribe({ email }); + const result = await analytics.V1.Commands.subscribe({ email: 'test@example.com' }); - expect(result).toBeDefined(); - expect(result?.attributes.email).toBe(email); - expect(result?.attributes.unsubscribed_at).toBeNull(); - }); - - test('returns null when response is empty', async () => { - setupMockFetch({ data: null }); - - const result = await analytics.V1.Commands.subscribe({ - email: 'test@example.com' - }); - - expect(result).toBeNull(); + expect(result).toBe(1); }); test('handles server error gracefully', async () => { @@ -41,4 +27,4 @@ describe('BentoCommands - subscribe', () => { }) ).rejects.toThrow(); }); -}); \ No newline at end of file +}); diff --git a/__tests__/commands/unsubscribe.test.ts b/__tests__/commands/unsubscribe.test.ts index 20a9deb..63e1a8f 100644 --- a/__tests__/commands/unsubscribe.test.ts +++ b/__tests__/commands/unsubscribe.test.ts @@ -1,7 +1,6 @@ import { expect, test, describe, beforeEach } from 'bun:test'; import { Analytics } from '../../src'; import { mockOptions } from '../helpers/mockClient'; -import { mockSubscriberResponse } from '../helpers/mockResponses'; import { setupMockFetch } from '../helpers/mockFetch'; describe('BentoCommands - unsubscribe', () => { @@ -11,27 +10,12 @@ describe('BentoCommands - unsubscribe', () => { analytics = new Analytics(mockOptions); }); - test('successfully unsubscribes a user', async () => { - const email = 'test@example.com'; - const unsubscribedResponse = mockSubscriberResponse(email); - unsubscribedResponse.data.attributes.unsubscribed_at = new Date().toISOString(); - setupMockFetch(unsubscribedResponse); + test('successfully queues an unsubscribe command', async () => { + setupMockFetch({ results: 1 }); - const result = await analytics.V1.Commands.unsubscribe({ email }); + const result = await analytics.V1.Commands.unsubscribe({ email: 'test@example.com' }); - expect(result).toBeDefined(); - expect(result?.attributes.email).toBe(email); - expect(result?.attributes.unsubscribed_at).not.toBeNull(); - }); - - test('returns null when response is empty', async () => { - setupMockFetch({ data: null }); - - const result = await analytics.V1.Commands.unsubscribe({ - email: 'test@example.com' - }); - - expect(result).toBeNull(); + expect(result).toBe(1); }); test('handles server error gracefully', async () => { @@ -43,4 +27,4 @@ describe('BentoCommands - unsubscribe', () => { }) ).rejects.toThrow(); }); -}); \ No newline at end of file +}); diff --git a/__tests__/contract/http-contract.test.ts b/__tests__/contract/http-contract.test.ts new file mode 100644 index 0000000..049da9d --- /dev/null +++ b/__tests__/contract/http-contract.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, test, beforeEach, afterEach } from 'bun:test'; +import { Analytics, bentoEndpointContracts } from '../../src'; +import { mockOptions } from '../helpers/mockClient'; +import { + getEndpointFromUrl, + lastFetchBody, + lastFetchHeaders, + lastFetchMethod, + lastFetchUrl, + resetMockFetchTracking, + setupMockFetch, +} from '../helpers/mockFetch'; + +function headerValue(headers: HeadersInit | undefined, name: string): string | undefined { + if (!headers) return undefined; + + if (headers instanceof Headers) { + return headers.get(name) ?? undefined; + } + + if (Array.isArray(headers)) { + const match = headers.find(([key]) => key.toLowerCase() === name.toLowerCase()); + return match?.[1]; + } + + const record = headers as Record; + return record[name] ?? record[name.toLowerCase()]; +} + +describe('Bento endpoint contract', () => { + let analytics: Analytics; + + beforeEach(() => { + analytics = new Analytics(mockOptions); + }); + + afterEach(() => { + resetMockFetchTracking(); + }); + + test('contains the sequence email create contract', () => { + expect(bentoEndpointContracts).toContainEqual({ + domain: 'sequences', + operation: 'createSequenceEmail', + method: 'POST', + path: '/fetch/sequences/:sequenceId/emails/templates', + siteUuid: 'body', + auth: { + authorization: 'basic', + userAgentPrefix: 'bento-node-', + }, + bodyWrapper: 'email_template', + successStatuses: [200, 201], + }); + }); + + test('SDK createSequenceEmail matches the published contract', async () => { + setupMockFetch({ + data: { + id: '123', + type: 'email_template', + attributes: {}, + }, + }); + + await analytics.V1.Sequences.createSequenceEmail('123', { + subject: 'Contract subject', + html: '

Contract body

', + }); + + const contract = bentoEndpointContracts.find( + (endpoint) => endpoint.operation === 'createSequenceEmail' + ); + const body = JSON.parse(String(lastFetchBody)); + + expect(contract).toBeDefined(); + expect(lastFetchMethod).toBe(contract?.method); + expect(getEndpointFromUrl(lastFetchUrl ?? '')).toBe( + contract?.path.replace(':sequenceId', '123') + ); + expect(body.site_uuid).toBe(mockOptions.siteUuid); + expect(body.email_template).toEqual({ + subject: 'Contract subject', + html: '

Contract body

', + }); + expect(headerValue(lastFetchHeaders, 'Authorization')).toMatch(/^Basic /); + expect(headerValue(lastFetchHeaders, 'User-Agent')).toBe( + `${contract?.auth.userAgentPrefix}${mockOptions.siteUuid}` + ); + }); + + test('SDK listSequences matches the published contract', async () => { + setupMockFetch({ data: [] }); + + await analytics.V1.Sequences.getSequences({ page: 2 }); + + const contract = bentoEndpointContracts.find( + (endpoint) => endpoint.operation === 'listSequences' + ); + const url = new URL(lastFetchUrl ?? ''); + + expect(contract).toBeDefined(); + expect(lastFetchMethod).toBe(contract?.method); + expect(getEndpointFromUrl(lastFetchUrl ?? '')).toBe(contract?.path); + expect(url.searchParams.get('site_uuid')).toBe(mockOptions.siteUuid); + expect(url.searchParams.get('page')).toBe('2'); + expect(headerValue(lastFetchHeaders, 'Authorization')).toMatch(/^Basic /); + expect(headerValue(lastFetchHeaders, 'User-Agent')).toBe( + `${contract?.auth.userAgentPrefix}${mockOptions.siteUuid}` + ); + }); +}); diff --git a/__tests__/experimental/experimental.test.ts b/__tests__/experimental/experimental.test.ts index f9e3852..f6722d2 100644 --- a/__tests__/experimental/experimental.test.ts +++ b/__tests__/experimental/experimental.test.ts @@ -1,7 +1,7 @@ import { expect, test, describe, beforeEach } from 'bun:test'; import { Analytics } from '../../src'; import { mockOptions } from '../helpers/mockClient'; -import { setupMockFetch } from '../helpers/mockFetch'; +import { lastFetchUrl, setupMockFetch } from '../helpers/mockFetch'; describe('BentoExperimental', () => { let analytics: Analytics; @@ -116,6 +116,9 @@ describe('BentoExperimental', () => { domain: 'example.com', }); + const url = new URL(lastFetchUrl || ''); + expect(url.pathname).toBe('/api/v1/experimental/blacklist'); + expect(url.searchParams.get('domain')).toBe('example.com'); expect(result.query).toBe('example.com'); expect(result.results['blacklist1.com']).toBe(false); expect(result.results['blacklist2.com']).toBe(true); @@ -137,6 +140,10 @@ describe('BentoExperimental', () => { ipAddress: '192.168.1.1', }); + const url = new URL(lastFetchUrl || ''); + expect(url.pathname).toBe('/api/v1/experimental/blacklist'); + expect(url.searchParams.get('ip')).toBe('192.168.1.1'); + expect(url.searchParams.has('ipAddress')).toBe(false); expect(result.query).toBe('192.168.1.1'); expect(Object.values(result.results)).not.toContain(true); }); @@ -171,25 +178,9 @@ describe('BentoExperimental', () => { describe('getContentModeration', () => { test('successfully moderates safe content', async () => { const mockResponse = { - flagged: false, - categories: { - hate: false, - 'hate/threatening': false, - 'self-harm': false, - sexual: false, - 'sexual/minors': false, - violence: false, - 'violence/graphic': false, - }, - category_scores: { - hate: 0.01, - 'hate/threatening': 0.01, - 'self-harm': 0.01, - sexual: 0.01, - 'sexual/minors': 0.01, - violence: 0.01, - 'violence/graphic': 0.01, - }, + valid: true, + reasons: [], + safe_original_content: 'Hello world, this is a test message.', }; setupMockFetch(mockResponse); @@ -198,32 +189,17 @@ describe('BentoExperimental', () => { 'Hello world, this is a test message.' ); - expect(result.flagged).toBe(false); - expect(result.categories.hate).toBe(false); - expect(result.category_scores.hate).toBeLessThan(0.1); + const url = new URL(lastFetchUrl || ''); + expect(url.pathname).toBe('/api/v1/experimental/content_moderation'); + expect(result.valid).toBe(true); + expect(result.reasons).toEqual([]); }); test('successfully flags problematic content', async () => { const mockResponse = { - flagged: true, - categories: { - hate: true, - 'hate/threatening': false, - 'self-harm': false, - sexual: false, - 'sexual/minors': false, - violence: false, - 'violence/graphic': false, - }, - category_scores: { - hate: 0.92, - 'hate/threatening': 0.01, - 'self-harm': 0.01, - sexual: 0.01, - 'sexual/minors': 0.01, - violence: 0.01, - 'violence/graphic': 0.01, - }, + valid: false, + reasons: ['Link spam'], + safe_original_content: 'Test content with problematic material', }; setupMockFetch(mockResponse); @@ -232,9 +208,20 @@ describe('BentoExperimental', () => { 'Test content with problematic material' ); - expect(result.flagged).toBe(true); - expect(result.categories.hate).toBe(true); - expect(result.category_scores.hate).toBeGreaterThan(0.9); + expect(result.valid).toBe(false); + expect(result.reasons).toContain('Link spam'); + }); + + test('handles blank content', async () => { + setupMockFetch({ + valid: false, + reasons: ['Content is required'], + }); + + const result = await analytics.V1.Experimental.getContentModeration(''); + + expect(result.valid).toBe(false); + expect(result.reasons).toEqual(['Content is required']); }); test('handles server error gracefully', async () => { @@ -312,6 +299,9 @@ describe('BentoExperimental', () => { ip: '192.0.2.1', }); + const url = new URL(lastFetchUrl || ''); + expect(url.pathname).toBe('/api/v1/experimental/blacklist'); + expect(url.searchParams.get('ip')).toBe('192.0.2.1'); expect(result.query).toBe('example.com'); expect(result.results['blacklist2.com']).toBe(true); }); diff --git a/__tests__/fields/fields.test.ts b/__tests__/fields/fields.test.ts index c183c67..62bbf1d 100644 --- a/__tests__/fields/fields.test.ts +++ b/__tests__/fields/fields.test.ts @@ -68,90 +68,84 @@ describe('BentoFields', () => { describe('createField', () => { test('successfully creates a field with camelCase key', async () => { const mockResponse = { - data: [ - { - id: 'new-field-1', - type: EntityType.VISITORS_FIELDS, - attributes: { - key: 'userLocation', - name: 'User Location', - created_at: '2024-01-07T00:00:00Z', - whitelisted: true - } - } - ] + data: { + id: 'new-field-1', + type: EntityType.VISITORS_FIELDS, + attributes: { + key: 'userLocation', + name: 'User Location', + created_at: '2024-01-07T00:00:00Z', + whitelisted: true, + }, + }, }; setupMockFetch(mockResponse); const result = await analytics.V1.Fields.createField({ - key: 'userLocation' + key: 'userLocation', }); expect(result).toBeDefined(); - expect(result?.[0].attributes.key).toBe('userLocation'); - expect(result?.[0].attributes.name).toBe('User Location'); + expect(result?.attributes.key).toBe('userLocation'); + expect(result?.attributes.name).toBe('User Location'); }); test('successfully creates a field with snake_case key', async () => { const mockResponse = { - data: [ - { - id: 'new-field-2', - type: EntityType.VISITORS_FIELDS, - attributes: { - key: 'user_status', - name: 'User Status', - created_at: '2024-01-07T00:00:00Z', - whitelisted: true - } - } - ] + data: { + id: 'new-field-2', + type: EntityType.VISITORS_FIELDS, + attributes: { + key: 'user_status', + name: 'User Status', + created_at: '2024-01-07T00:00:00Z', + whitelisted: true, + }, + }, }; setupMockFetch(mockResponse); const result = await analytics.V1.Fields.createField({ - key: 'user_status' + key: 'user_status', }); expect(result).toBeDefined(); - expect(result?.[0].attributes.key).toBe('user_status'); - expect(result?.[0].attributes.name).toBe('User Status'); + expect(result?.attributes.key).toBe('user_status'); + expect(result?.attributes.name).toBe('User Status'); }); test('successfully creates a field with kebab-case key', async () => { const mockResponse = { - data: [ - { - id: 'new-field-3', - type: EntityType.VISITORS_FIELDS, - attributes: { - key: 'last-login-date', - name: 'Last Login Date', - created_at: '2024-01-07T00:00:00Z', - whitelisted: true - } - } - ] + data: { + id: 'new-field-3', + type: EntityType.VISITORS_FIELDS, + attributes: { + key: 'last-login-date', + name: 'Last Login Date', + created_at: '2024-01-07T00:00:00Z', + whitelisted: true, + }, + }, }; setupMockFetch(mockResponse); const result = await analytics.V1.Fields.createField({ - key: 'last-login-date' + key: 'last-login-date', }); expect(result).toBeDefined(); - expect(result?.[0].attributes.key).toBe('last-login-date'); - expect(result?.[0].attributes.name).toBe('Last Login Date'); + expect(result?.attributes.key).toBe('last-login-date'); + expect(result?.attributes.name).toBe('Last Login Date'); }); test('returns null when response is empty', async () => { setupMockFetch({ data: null }); const result = await analytics.V1.Fields.createField({ - key: 'testField' + key: 'testField', }); expect(result).toBeNull(); @@ -169,66 +163,26 @@ describe('BentoFields', () => { test('creates field with special characters in key', async () => { const mockResponse = { - data: [ - { - id: 'special-field-1', - type: EntityType.VISITORS_FIELDS, - attributes: { - key: 'field_123!@#', - name: 'Field 123!@#', - created_at: '2024-01-07T00:00:00Z', - whitelisted: true - } - } - ] - }; - - setupMockFetch(mockResponse); - - const result = await analytics.V1.Fields.createField({ - key: 'field_123!@#' - }); - - expect(result).toBeDefined(); - expect(result?.[0].attributes.key).toBe('field_123!@#'); - }); - - test('creates multiple fields in one response', async () => { - const mockResponse = { - data: [ - { - id: 'field-1', - type: EntityType.VISITORS_FIELDS, - attributes: { - key: 'field1', - name: 'Field 1', - created_at: '2024-01-07T00:00:00Z', - whitelisted: true - } + data: { + id: 'special-field-1', + type: EntityType.VISITORS_FIELDS, + attributes: { + key: 'field_123!@#', + name: 'Field 123!@#', + created_at: '2024-01-07T00:00:00Z', + whitelisted: true, }, - { - id: 'field-2', - type: EntityType.VISITORS_FIELDS, - attributes: { - key: 'field2', - name: 'Field 2', - created_at: '2024-01-07T00:00:00Z', - whitelisted: true - } - } - ] + }, }; setupMockFetch(mockResponse); const result = await analytics.V1.Fields.createField({ - key: 'multipleFields' + key: 'field_123!@#', }); expect(result).toBeDefined(); - expect(result).toHaveLength(2); - expect(result?.[0].attributes.key).toBe('field1'); - expect(result?.[1].attributes.key).toBe('field2'); + expect(result?.attributes.key).toBe('field_123!@#'); }); }); }); \ No newline at end of file diff --git a/__tests__/helpers/mockFetch.ts b/__tests__/helpers/mockFetch.ts index a63e3a9..675e5e7 100644 --- a/__tests__/helpers/mockFetch.ts +++ b/__tests__/helpers/mockFetch.ts @@ -28,6 +28,8 @@ const normalizeEntry = ( export let lastFetchUrl: string | null = null; export let lastFetchMethod: string | null = null; export let lastFetchSignal: AbortSignal | null = null; +export let lastFetchBody: BodyInit | null | undefined = null; +export let lastFetchHeaders: HeadersInit | undefined = undefined; let originalFetch: typeof globalThis.fetch | undefined; @@ -35,6 +37,8 @@ export const resetMockFetchTracking = (): void => { lastFetchUrl = null; lastFetchMethod = null; lastFetchSignal = null; + lastFetchBody = null; + lastFetchHeaders = undefined; }; export const cleanupMockFetch = (): void => { @@ -65,6 +69,8 @@ export const setupMockFetch = ( lastFetchUrl = typeof url === 'string' ? url : url.toString(); lastFetchMethod = options?.method || 'GET'; lastFetchSignal = options?.signal ?? null; + lastFetchBody = options?.body; + lastFetchHeaders = options?.headers; const entry = queue ? queue.shift() ?? fallbackEntry : singleEntry; const currentStatus = entry.status ?? status; diff --git a/__tests__/sequences/identity.test.ts b/__tests__/sequences/identity.test.ts new file mode 100644 index 0000000..12911c5 --- /dev/null +++ b/__tests__/sequences/identity.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, test } from 'bun:test'; +import { + getSequenceId, + isSequenceId, + toSequenceIdentity, + toSequenceId, +} from '../../src/sdk/sequences/identity'; + +describe('sequence identity helpers', () => { + test('accepts non-empty sequence IDs returned by list sequences', () => { + expect(isSequenceId('123')).toBe(true); + expect(isSequenceId('sequence_abc123')).toBe(true); + expect(toSequenceId(' 123 ')).toBe('123'); + }); + + test('rejects blank sequence IDs before HTTP', () => { + expect(isSequenceId(' ')).toBe(false); + expect(() => toSequenceId(' ')).toThrow( + 'Sequence ID must be the non-empty id returned by list sequences.' + ); + }); + + test('prefers the top-level id returned by list sequences', () => { + const id = getSequenceId({ + id: '123', + attributes: { + name: 'Welcome', + id: 'sequence_abc123', + email_templates: [], + }, + }); + + expect(id).toBe('123'); + }); + + test('falls back to attributes.id or prefix_id when top-level id is missing', () => { + const id = getSequenceId({ + id: '', + attributes: { + name: 'Direct', + id: '456', + prefix_id: 'sequence_direct', + email_templates: [], + }, + }); + + expect(id).toBe('456'); + }); + + test('builds a SequenceIdentity with the create ID and template IDs', () => { + const identity = toSequenceIdentity({ + id: '123', + attributes: { + name: 'Onboarding', + email_templates: [ + { id: 10, subject: 'Welcome', stats: null }, + { id: 11, subject: 'Next step', stats: null }, + ], + }, + }); + + expect(identity).toEqual({ + id: '123', + name: 'Onboarding', + templateIds: [10, 11], + }); + }); +}); diff --git a/__tests__/sequences/sequences.test.ts b/__tests__/sequences/sequences.test.ts index 5491073..b71bd82 100644 --- a/__tests__/sequences/sequences.test.ts +++ b/__tests__/sequences/sequences.test.ts @@ -1,7 +1,13 @@ import { expect, test, describe, beforeEach, afterEach } from 'bun:test'; import { Analytics } from '../../src'; import { mockOptions } from '../helpers/mockClient'; -import { setupMockFetch, lastFetchUrl, lastFetchMethod, resetMockFetchTracking } from '../helpers/mockFetch'; +import { + setupMockFetch, + lastFetchUrl, + lastFetchMethod, + lastFetchBody, + resetMockFetchTracking, +} from '../helpers/mockFetch'; import { EntityType } from '../../src/sdk/enums'; describe('BentoSequences', () => { @@ -149,7 +155,7 @@ describe('BentoSequences', () => { setupMockFetch(mockTemplate, 201); - const result = await analytics.V1.Sequences.createSequenceEmail('sequence_abc123', { + const result = await analytics.V1.Sequences.createSequenceEmail('123', { subject: 'Welcome aboard', html: '

Hello there

', delay_interval: 'days', @@ -170,19 +176,83 @@ describe('BentoSequences', () => { }, }); - await analytics.V1.Sequences.createSequenceEmail('sequence_xyz789', { + await analytics.V1.Sequences.createSequenceEmail('789', { subject: 'Test Subject', html: '

Test

', }); - expect(lastFetchUrl).toContain('/fetch/sequences/sequence_xyz789/emails/templates'); + expect(lastFetchUrl).toContain('/fetch/sequences/789/emails/templates'); expect(lastFetchMethod).toBe('POST'); }); + test('wraps the payload and site_uuid in the POST body', async () => { + setupMockFetch({ + data: { + id: '123', + type: EntityType.EMAIL_TEMPLATES, + attributes: {}, + }, + }); + + await analytics.V1.Sequences.createSequenceEmail('789', { + subject: 'Test Subject', + html: '

Test

', + delay_interval: 'days', + delay_interval_count: 7, + inbox_snippet: 'Start here', + }); + + expect(JSON.parse(String(lastFetchBody))).toEqual({ + site_uuid: mockOptions.siteUuid, + email_template: { + subject: 'Test Subject', + html: '

Test

', + delay_interval: 'days', + delay_interval_count: 7, + inbox_snippet: 'Start here', + }, + }); + }); + + test('rejects blank IDs before sending a request', async () => { + setupMockFetch({ + data: { + id: '123', + type: EntityType.EMAIL_TEMPLATES, + attributes: {}, + }, + }); + + await expect( + analytics.V1.Sequences.createSequenceEmail(' ', { + subject: 'Test Subject', + html: '

Test

', + }) + ).rejects.toThrow('Sequence ID must be the non-empty id returned by list sequences'); + expect(lastFetchUrl).toBeNull(); + }); + + test('normalizes the sequence ID in the POST path', async () => { + setupMockFetch({ + data: { + id: '123', + type: EntityType.EMAIL_TEMPLATES, + attributes: {}, + }, + }); + + await analytics.V1.Sequences.createSequenceEmail(' 123 ', { + subject: 'Test Subject', + html: '

Test

', + }); + + expect(lastFetchUrl).toContain('/fetch/sequences/123/emails/templates'); + }); + test('returns null when response is empty object', async () => { setupMockFetch({}, 201); - const result = await analytics.V1.Sequences.createSequenceEmail('sequence_empty', { + const result = await analytics.V1.Sequences.createSequenceEmail('123', { subject: 'Test Subject', html: '

Test

', }); @@ -193,7 +263,7 @@ describe('BentoSequences', () => { test('returns null when response has no data', async () => { setupMockFetch({ data: null }, 201); - const result = await analytics.V1.Sequences.createSequenceEmail('sequence_nodata', { + const result = await analytics.V1.Sequences.createSequenceEmail('123', { subject: 'Test Subject', html: '

Test

', }); @@ -212,7 +282,7 @@ describe('BentoSequences', () => { ); await expect( - analytics.V1.Sequences.createSequenceEmail('sequence_bad_delay', { + analytics.V1.Sequences.createSequenceEmail('123', { subject: 'Test Subject', html: '

Test

', delay_interval: 'days', @@ -220,5 +290,16 @@ describe('BentoSequences', () => { }) ).rejects.toThrow(); }); + + test('surfaces not found errors for valid missing sequence IDs', async () => { + setupMockFetch({ error: 'Sequence not found' }, 404); + + await expect( + analytics.V1.Sequences.createSequenceEmail('404', { + subject: 'Test Subject', + html: '

Test

', + }) + ).rejects.toThrow('[404]'); + }); }); }); diff --git a/__tests__/stats/stats.test.ts b/__tests__/stats/stats.test.ts index 1785d52..8208a03 100644 --- a/__tests__/stats/stats.test.ts +++ b/__tests__/stats/stats.test.ts @@ -13,21 +13,18 @@ describe('BentoStats', () => { describe('getSiteStats', () => { test('successfully retrieves site statistics', async () => { const mockStats = { - total_subscribers: 1000, - active_subscribers: 950, - unsubscribed_count: 50, - broadcast_count: 25, - average_open_rate: 45.5, - average_click_rate: 12.3 + user_count: 10, + subscriber_count: 7, + unsubscriber_count: 3, }; setupMockFetch(mockStats); const result = await analytics.V1.Stats.getSiteStats(); - expect(result.total_subscribers).toBe(1000); - expect(result.active_subscribers).toBe(950); - expect(result.average_open_rate).toBe(45.5); + expect(result.user_count).toBe(10); + expect(result.subscriber_count).toBe(7); + expect(result.unsubscriber_count).toBe(3); }); test('handles server error gracefully', async () => { @@ -42,20 +39,18 @@ describe('BentoStats', () => { describe('getSegmentStats', () => { test('successfully retrieves segment statistics', async () => { const mockStats = { - segment_id: 'segment-123', - subscriber_count: 500, - growth_rate: 2.5, - engagement_rate: 35.8, - last_updated: '2024-01-01T00:00:00Z' + user_count: 12, + subscriber_count: 8, + unsubscriber_count: 4, }; setupMockFetch(mockStats); const result = await analytics.V1.Stats.getSegmentStats('segment-123'); - expect(result.segment_id).toBe('segment-123'); - expect(result.subscriber_count).toBe(500); - expect(result.growth_rate).toBe(2.5); + expect(result.user_count).toBe(12); + expect(result.subscriber_count).toBe(8); + expect(result.unsubscriber_count).toBe(4); }); test('handles non-existent segment', async () => { @@ -70,24 +65,20 @@ describe('BentoStats', () => { describe('getReportStats', () => { test('successfully retrieves report statistics', async () => { const mockStats = { - report_id: 'report-123', - total_sent: 1000, - total_opens: 750, - unique_opens: 500, - total_clicks: 250, - unique_clicks: 200, - unsubscribes: 5, - spam_reports: 1, - created_at: '2024-01-01T00:00:00Z' + data: 42, + chart_style: 'counter', + report_type: 'Reporting::Reports::VisitorCountReport', + report_name: 'API Report', }; setupMockFetch(mockStats); const result = await analytics.V1.Stats.getReportStats('report-123'); - expect(result.report_id).toBe('report-123'); - expect(result.total_sent).toBe(1000); - expect(result.unique_opens).toBe(500); + expect(result.data).toBe(42); + expect(result.chart_style).toBe('counter'); + expect(result.report_type).toBe('Reporting::Reports::VisitorCountReport'); + expect(result.report_name).toBe('API Report'); }); test('handles non-existent report', async () => { diff --git a/__tests__/tags/tags.test.ts b/__tests__/tags/tags.test.ts index cf1e678..c84d125 100644 --- a/__tests__/tags/tags.test.ts +++ b/__tests__/tags/tags.test.ts @@ -68,36 +68,34 @@ describe('BentoTags', () => { describe('createTag', () => { test('successfully creates a tag', async () => { const mockResponse = { - data: [ - { - id: 'new-tag-1', - type: EntityType.TAGS, - attributes: { - name: 'New Tag', - created_at: '2024-01-07T00:00:00Z', - discarded_at: null, - site_id: 123 - } - } - ] + data: { + id: 'new-tag-1', + type: EntityType.TAGS, + attributes: { + name: 'New Tag', + created_at: '2024-01-07T00:00:00Z', + discarded_at: null, + site_id: 123, + }, + }, }; setupMockFetch(mockResponse); const result = await analytics.V1.Tags.createTag({ - name: 'New Tag' + name: 'New Tag', }); expect(result).toBeDefined(); - expect(result?.[0].attributes.name).toBe('New Tag'); - expect(result?.[0].attributes.discarded_at).toBeNull(); + expect(result?.attributes.name).toBe('New Tag'); + expect(result?.attributes.discarded_at).toBeNull(); }); test('returns null when response is empty', async () => { setupMockFetch({ data: null }); const result = await analytics.V1.Tags.createTag({ - name: 'Test Tag' + name: 'Test Tag', }); expect(result).toBeNull(); @@ -115,92 +113,60 @@ describe('BentoTags', () => { test('creates tag with special characters', async () => { const mockResponse = { - data: [ - { - id: 'special-tag-1', - type: EntityType.TAGS, - attributes: { - name: 'Special-Tag!@#', - created_at: '2024-01-07T00:00:00Z', - discarded_at: null, - site_id: 123 - } - } - ] + data: { + id: 'special-tag-1', + type: EntityType.TAGS, + attributes: { + name: 'Special-Tag!@#', + created_at: '2024-01-07T00:00:00Z', + discarded_at: null, + site_id: 123, + }, + }, }; setupMockFetch(mockResponse); const result = await analytics.V1.Tags.createTag({ - name: 'Special-Tag!@#' + name: 'Special-Tag!@#', }); expect(result).toBeDefined(); - expect(result?.[0].attributes.name).toBe('Special-Tag!@#'); + expect(result?.attributes.name).toBe('Special-Tag!@#'); }); test('creates tag with non-ascii characters', async () => { const mockResponse = { - data: [ - { - id: 'unicode-tag-1', - type: EntityType.TAGS, - attributes: { - name: '🏷️タグ', - created_at: '2024-01-07T00:00:00Z', - discarded_at: null, - site_id: 123 - } - } - ] + data: { + id: 'unicode-tag-1', + type: EntityType.TAGS, + attributes: { + name: '🏷️タグ', + created_at: '2024-01-07T00:00:00Z', + discarded_at: null, + site_id: 123, + }, + }, }; setupMockFetch(mockResponse); const result = await analytics.V1.Tags.createTag({ - name: '🏷️タグ' + name: '🏷️タグ', }); expect(result).toBeDefined(); - expect(result?.[0].attributes.name).toBe('🏷️タグ'); + expect(result?.attributes.name).toBe('🏷️タグ'); }); + }); - test('creates multiple tags in one response', async () => { - const mockResponse = { - data: [ - { - id: 'tag-1', - type: EntityType.TAGS, - attributes: { - name: 'Tag 1', - created_at: '2024-01-07T00:00:00Z', - discarded_at: null, - site_id: 123 - } - }, - { - id: 'tag-2', - type: EntityType.TAGS, - attributes: { - name: 'Tag 2', - created_at: '2024-01-07T00:00:00Z', - discarded_at: null, - site_id: 123 - } - } - ] - }; - - setupMockFetch(mockResponse); + describe('deleteTag', () => { + test('successfully deletes a tag by name', async () => { + setupMockFetch({ message: 'Tag (VIP) deleted successfully' }); - const result = await analytics.V1.Tags.createTag({ - name: 'Multiple Tags' - }); + const result = await analytics.V1.Tags.deleteTag('tag-1', { name: 'VIP' }); - expect(result).toBeDefined(); - expect(result).toHaveLength(2); - expect(result?.[0].attributes.name).toBe('Tag 1'); - expect(result?.[1].attributes.name).toBe('Tag 2'); + expect(result.message).toBe('Tag (VIP) deleted successfully'); }); }); }); \ No newline at end of file diff --git a/__tests__/versions/v1/index.test.ts b/__tests__/versions/v1/index.test.ts index 9a9920d..b7878ef 100644 --- a/__tests__/versions/v1/index.test.ts +++ b/__tests__/versions/v1/index.test.ts @@ -108,89 +108,37 @@ describe('BentoAPIV1', () => { // Core functionality tests describe('core functionality', () => { test('successfully adds subscriber', async () => { - const mockResponse = { - data: { - id: 'sub-1', - type: EntityType.VISITORS, - attributes: { - uuid: 'uuid-123', - email: 'test@example.com', - fields: null, - cached_tag_ids: [], - unsubscribed_at: null, - }, - }, - }; - setupMockFetch(mockResponse); + setupMockFetch({ results: 1 }); const result = await api.Commands.subscribe({ email: 'test@example.com' }); - expect(result?.attributes.email).toBe('test@example.com'); + expect(result).toBe(1); }); test('successfully removes subscriber', async () => { - const mockResponse = { - data: { - id: 'sub-1', - type: EntityType.VISITORS, - attributes: { - uuid: 'uuid-123', - email: 'test@example.com', - fields: null, - cached_tag_ids: [], - unsubscribed_at: new Date().toISOString(), - }, - }, - }; - setupMockFetch(mockResponse); + setupMockFetch({ results: 1 }); const result = await api.Commands.unsubscribe({ email: 'test@example.com' }); - expect(result?.attributes.unsubscribed_at).not.toBeNull(); + expect(result).toBe(1); }); test('successfully tags subscriber', async () => { - const mockResponse = { - data: { - id: 'sub-1', - type: EntityType.VISITORS, - attributes: { - uuid: 'uuid-123', - email: 'test@example.com', - fields: null, - cached_tag_ids: ['tag-1'], - unsubscribed_at: null, - }, - }, - }; - setupMockFetch(mockResponse); + setupMockFetch({ results: 1 }); const result = await api.Commands.addTag({ email: 'test@example.com', tagName: 'TestTag', }); - expect(result?.attributes.cached_tag_ids).toContain('tag-1'); + expect(result).toBe(1); }); test('successfully removes tag from subscriber', async () => { - const mockResponse = { - data: { - id: 'sub-1', - type: EntityType.VISITORS, - attributes: { - uuid: 'uuid-123', - email: 'test@example.com', - fields: null, - cached_tag_ids: [], - unsubscribed_at: null, - }, - }, - }; - setupMockFetch(mockResponse); + setupMockFetch({ results: 1 }); const result = await api.Commands.removeTag({ email: 'test@example.com', tagName: 'TestTag', }); - expect(result?.attributes.cached_tag_ids).toHaveLength(0); + expect(result).toBe(1); }); }); diff --git a/package-lock.json b/package-lock.json index f0b0ed5..72e19fa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@bentonow/bento-node-sdk", - "version": "1.1.0", + "version": "2.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@bentonow/bento-node-sdk", - "version": "1.1.0", + "version": "2.0.0", "license": "MIT", "devDependencies": { "@size-limit/preset-small-lib": "11.2.0", diff --git a/package.json b/package.json index 6b4f24b..84dee44 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@bentonow/bento-node-sdk", - "version": "1.1.0", + "version": "2.0.0", "description": "🍱 Bento Node.JS SDK and tracking library", "author": "Backpack Internet", "license": "MIT", diff --git a/src/index.ts b/src/index.ts index 064f4a2..170a5d8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -11,3 +11,31 @@ export class Analytics { export * from './sdk/client/errors'; export * from './sdk/stats/types'; +export type { CommandResult } from './sdk/commands/types'; +export type { + BatchBroadcastCreateResult, + BroadcastFailure, + CreatedBroadcast, +} from './sdk/broadcasts/types'; +export type { ContentModerationResult } from './sdk/experimental/types'; +export type { DeleteTagParameters, TagDeleteResult } from './sdk/tags/types'; +export * from './sdk/contracts/endpoints'; +export { + getSequenceId, + isSequenceId, + toSequenceIdentity, + toSequenceId, +} from './sdk/sequences/identity'; +export type { SequenceId, SequenceIdentity } from './sdk/sequences/identity'; +export type { + CreateSequenceEmailParameters, + GetSequencesParameters, + Sequence, + SequenceAttributes, + SequenceDelayInterval, + SequenceEmailTemplate, +} from './sdk/sequences/types'; +export type { + EmailTemplate, + UpdateEmailTemplateParameters, +} from './sdk/email-templates/types'; diff --git a/src/sdk/broadcasts/index.ts b/src/sdk/broadcasts/index.ts index ca770ee..6bf80b2 100644 --- a/src/sdk/broadcasts/index.ts +++ b/src/sdk/broadcasts/index.ts @@ -1,6 +1,11 @@ import type { BentoClient } from '../client'; import type { DataResponse } from '../client/types'; -import type { Broadcast, CreateBroadcastInput, EmailData } from './types'; +import type { + BatchBroadcastCreateResult, + Broadcast, + CreateBroadcastInput, + EmailData, +} from './types'; export class BentoBroadcasts { private readonly _fetchUrl = '/fetch/broadcasts'; @@ -33,12 +38,13 @@ export class BentoBroadcasts { /** * Creates new broadcast campaigns * @param broadcasts Array of broadcast data to create - * @returns Promise + * @returns Promise */ - public async createBroadcast(broadcasts: CreateBroadcastInput[]): Promise { - const result = await this._client.post>(this._batchUrl, { + public async createBroadcast( + broadcasts: CreateBroadcastInput[] + ): Promise { + return this._client.post(this._batchUrl, { broadcasts, }); - return result.data ?? []; } } diff --git a/src/sdk/broadcasts/types.ts b/src/sdk/broadcasts/types.ts index 6315c85..247d0ab 100644 --- a/src/sdk/broadcasts/types.ts +++ b/src/sdk/broadcasts/types.ts @@ -24,6 +24,26 @@ export type Broadcast = BaseEntity; export type CreateBroadcastInput = Omit; +export type CreatedBroadcast = { + id: number; + template_id: number; + name: string; + dashboard_url: string; +}; + +export type BroadcastFailure = { + index: number; + name: string | null; + error: string; +}; + +export type BatchBroadcastCreateResult = { + results: number; + failed: number; + broadcasts?: CreatedBroadcast[]; + failures?: BroadcastFailure[]; +}; + /** * Email data for transactional emails. * Note: This is the same structure as TransactionalEmail in batch/types.ts diff --git a/src/sdk/client/index.ts b/src/sdk/client/index.ts index b76b520..277fb3e 100644 --- a/src/sdk/client/index.ts +++ b/src/sdk/client/index.ts @@ -117,6 +117,40 @@ export class BentoClient { return this._handleResponse(response); } + /** + * Wraps a DELETE request to the Bento API and automatically adds the required + * headers. + * + * @param endpoint string + * @param payload object + * @returns Promise\ + * */ + public async delete( + endpoint: string, + payload: Record = {}, + requestOptions: RequestOptions = {} + ): Promise { + const body = this._getBody(payload); + const url = `${this._baseUrl}${endpoint}`; + + const timeoutMs = + requestOptions.timeout === undefined ? this._timeout : requestOptions.timeout; + const response = await this._fetchWithTimeout( + url, + { + method: 'DELETE', + headers: { + ...this._headers, + 'Content-Type': 'application/json', + }, + body, + }, + timeoutMs + ); + + return this._handleResponse(response); + } + /** * Wraps a PATCH request to the Bento API and automatically adds the required * headers. diff --git a/src/sdk/commands/index.ts b/src/sdk/commands/index.ts index d95705e..49411e8 100644 --- a/src/sdk/commands/index.ts +++ b/src/sdk/commands/index.ts @@ -1,11 +1,10 @@ import type { BentoClient } from '../client'; -import type { DataResponse } from '../client/types'; -import type { Subscriber } from '../subscribers/types'; import { CommandTypes } from './enums'; import type { AddFieldParameters, AddTagParameters, ChangeEmailParameters, + CommandResult, RemoveFieldParameters, RemoveTagParameters, SubscribeParameters, @@ -28,10 +27,10 @@ export class BentoCommands { * * * @param parameters \{ email: string, tagName: string \} - * @returns Promise\ + * @returns Promise\ Number of commands queued */ - public async addTag(parameters: AddTagParameters): Promise | null> { - const result = await this._client.post>>(this._url, { + public async addTag(parameters: AddTagParameters): Promise { + const result = await this._client.post(this._url, { command: { command: CommandTypes.ADD_TAG, email: parameters.email, @@ -39,18 +38,17 @@ export class BentoCommands { }, }); - if (Object.keys(result).length === 0 || !result.data) return null; - return result.data; + return result.results; } /** * Removes the specified tag from the subscriber with the matching email. * * @param parameters \{ email: string, tagName: string \} - * @returns Promise\ + * @returns Promise\ Number of commands queued */ - public async removeTag(parameters: RemoveTagParameters): Promise | null> { - const result = await this._client.post>>(this._url, { + public async removeTag(parameters: RemoveTagParameters): Promise { + const result = await this._client.post(this._url, { command: { command: CommandTypes.REMOVE_TAG, email: parameters.email, @@ -58,8 +56,7 @@ export class BentoCommands { }, }); - if (Object.keys(result).length === 0 || !result.data) return null; - return result.data; + return result.results; } /** @@ -72,10 +69,10 @@ export class BentoCommands { * from system. * * @param parameters \{ email: string, field: \{ key: string; value: string; \} \} - * @returns Promise\ + * @returns Promise\ Number of commands queued */ - public async addField(parameters: AddFieldParameters): Promise | null> { - const result = await this._client.post>>(this._url, { + public async addField(parameters: AddFieldParameters): Promise { + const result = await this._client.post(this._url, { command: { command: CommandTypes.ADD_FIELD, email: parameters.email, @@ -83,18 +80,17 @@ export class BentoCommands { }, }); - if (Object.keys(result).length === 0 || !result.data) return null; - return result.data; + return result.results; } /** * Removes a field to the subscriber with the matching email. * * @param parameters \{ email: string, fieldName: string \} - * @returns Promise\ + * @returns Promise\ Number of commands queued */ - public async removeField(parameters: RemoveFieldParameters): Promise | null> { - const result = await this._client.post>>(this._url, { + public async removeField(parameters: RemoveFieldParameters): Promise { + const result = await this._client.post(this._url, { command: { command: CommandTypes.REMOVE_FIELD, email: parameters.email, @@ -102,8 +98,7 @@ export class BentoCommands { }, }); - if (Object.keys(result).length === 0 || !result.data) return null; - return result.data; + return result.results; } /** @@ -114,18 +109,17 @@ export class BentoCommands { * If the subscriber had previously unsubscribed, they will be re-subscribed. * * @param parameters \{ email: string \} - * @returns Promise\ + * @returns Promise\ Number of commands queued */ - public async subscribe(parameters: SubscribeParameters): Promise | null> { - const result = await this._client.post>>(this._url, { + public async subscribe(parameters: SubscribeParameters): Promise { + const result = await this._client.post(this._url, { command: { command: CommandTypes.SUBSCRIBE, email: parameters.email, }, }); - if (Object.keys(result).length === 0 || !result.data) return null; - return result.data; + return result.results; } /** @@ -137,28 +131,27 @@ export class BentoCommands { * is updated. * * @param parameters \{ email: string \} - * @returns Promise\ + * @returns Promise\ Number of commands queued */ - public async unsubscribe(parameters: UnsubscribeParameters): Promise | null> { - const result = await this._client.post>>(this._url, { + public async unsubscribe(parameters: UnsubscribeParameters): Promise { + const result = await this._client.post(this._url, { command: { command: CommandTypes.UNSUBSCRIBE, email: parameters.email, }, }); - if (Object.keys(result).length === 0 || !result.data) return null; - return result.data; + return result.results; } /** * Updates the email of a user in Bento. * * @param parameters \{ oldEmail: string, newEmail: string \} - * @returns Promise\ + * @returns Promise\ Number of commands queued */ - public async changeEmail(parameters: ChangeEmailParameters): Promise | null> { - const result = await this._client.post>>(this._url, { + public async changeEmail(parameters: ChangeEmailParameters): Promise { + const result = await this._client.post(this._url, { command: { command: CommandTypes.CHANGE_EMAIL, email: parameters.oldEmail, @@ -166,7 +159,6 @@ export class BentoCommands { }, }); - if (Object.keys(result).length === 0 || !result.data) return null; - return result.data; + return result.results; } } diff --git a/src/sdk/commands/types.ts b/src/sdk/commands/types.ts index a720f2c..c270128 100644 --- a/src/sdk/commands/types.ts +++ b/src/sdk/commands/types.ts @@ -1,3 +1,7 @@ +export type CommandResult = { + results: number; +}; + /** * Command Method Parameter Types */ diff --git a/src/sdk/contracts/endpoints.ts b/src/sdk/contracts/endpoints.ts new file mode 100644 index 0000000..c559035 --- /dev/null +++ b/src/sdk/contracts/endpoints.ts @@ -0,0 +1,286 @@ +export type BentoAuthContract = { + readonly authorization: 'basic'; + readonly userAgentPrefix: 'bento-node-'; +}; + +export type BentoEndpointContract = { + readonly domain: string; + readonly operation: string; + readonly method: 'GET' | 'POST' | 'PATCH' | 'DELETE'; + readonly path: string; + readonly siteUuid: 'query' | 'body'; + readonly auth: BentoAuthContract; + readonly bodyWrapper?: string; + readonly queryKeys?: readonly string[]; + readonly successStatuses?: readonly number[]; +}; + +const defaultAuth = { + authorization: 'basic', + userAgentPrefix: 'bento-node-', +} as const satisfies BentoAuthContract; + +export const bentoEndpointContracts = [ + { + domain: 'sequences', + operation: 'listSequences', + method: 'GET', + path: '/fetch/sequences', + siteUuid: 'query', + auth: defaultAuth, + queryKeys: ['page'], + successStatuses: [200], + }, + { + domain: 'sequences', + operation: 'createSequenceEmail', + method: 'POST', + path: '/fetch/sequences/:sequenceId/emails/templates', + siteUuid: 'body', + auth: defaultAuth, + bodyWrapper: 'email_template', + successStatuses: [200, 201], + }, + { + domain: 'emailTemplates', + operation: 'getEmailTemplate', + method: 'GET', + path: '/fetch/emails/templates/:id', + siteUuid: 'query', + auth: defaultAuth, + successStatuses: [200], + }, + { + domain: 'emailTemplates', + operation: 'updateEmailTemplate', + method: 'PATCH', + path: '/fetch/emails/templates/:id', + siteUuid: 'body', + auth: defaultAuth, + bodyWrapper: 'email_template', + successStatuses: [200], + }, + { + domain: 'tags', + operation: 'listTags', + method: 'GET', + path: '/fetch/tags', + siteUuid: 'query', + auth: defaultAuth, + successStatuses: [200], + }, + { + domain: 'tags', + operation: 'createTag', + method: 'POST', + path: '/fetch/tags', + siteUuid: 'body', + auth: defaultAuth, + bodyWrapper: 'tag', + successStatuses: [200, 201], + }, + { + domain: 'tags', + operation: 'deleteTag', + method: 'DELETE', + path: '/fetch/tags/:id', + siteUuid: 'body', + auth: defaultAuth, + bodyWrapper: 'tag', + successStatuses: [200], + }, + { + domain: 'fields', + operation: 'listFields', + method: 'GET', + path: '/fetch/fields', + siteUuid: 'query', + auth: defaultAuth, + successStatuses: [200], + }, + { + domain: 'fields', + operation: 'createField', + method: 'POST', + path: '/fetch/fields', + siteUuid: 'body', + auth: defaultAuth, + bodyWrapper: 'field', + successStatuses: [200, 201], + }, + { + domain: 'subscribers', + operation: 'getSubscriber', + method: 'GET', + path: '/fetch/subscribers', + siteUuid: 'query', + auth: defaultAuth, + queryKeys: ['email', 'uuid'], + successStatuses: [200], + }, + { + domain: 'subscribers', + operation: 'createSubscriber', + method: 'POST', + path: '/fetch/subscribers', + siteUuid: 'body', + auth: defaultAuth, + bodyWrapper: 'subscriber', + successStatuses: [200, 201], + }, + { + domain: 'segments', + operation: 'matchSegment', + method: 'POST', + path: '/fetch/segments/:id/match', + siteUuid: 'body', + auth: defaultAuth, + bodyWrapper: undefined, + successStatuses: [200], + }, + { + domain: 'commands', + operation: 'runCommand', + method: 'POST', + path: '/fetch/commands', + siteUuid: 'body', + auth: defaultAuth, + bodyWrapper: 'command', + successStatuses: [200, 201], + }, + { + domain: 'batch', + operation: 'importSubscribers', + method: 'POST', + path: '/batch/subscribers', + siteUuid: 'body', + auth: defaultAuth, + bodyWrapper: 'subscribers', + successStatuses: [200, 201], + }, + { + domain: 'batch', + operation: 'importEvents', + method: 'POST', + path: '/batch/events', + siteUuid: 'body', + auth: defaultAuth, + bodyWrapper: 'events', + successStatuses: [200, 201], + }, + { + domain: 'batch', + operation: 'sendTransactionalEmails', + method: 'POST', + path: '/batch/emails', + siteUuid: 'body', + auth: defaultAuth, + bodyWrapper: 'emails', + successStatuses: [200, 201], + }, + { + domain: 'broadcasts', + operation: 'listBroadcasts', + method: 'GET', + path: '/fetch/broadcasts', + siteUuid: 'query', + auth: defaultAuth, + successStatuses: [200], + }, + { + domain: 'broadcasts', + operation: 'createBroadcast', + method: 'POST', + path: '/batch/broadcasts', + siteUuid: 'body', + auth: defaultAuth, + bodyWrapper: 'broadcasts', + successStatuses: [200, 201], + }, + { + domain: 'stats', + operation: 'getSiteStats', + method: 'GET', + path: '/stats/site', + siteUuid: 'query', + auth: defaultAuth, + successStatuses: [200], + }, + { + domain: 'stats', + operation: 'getSegmentStats', + method: 'GET', + path: '/stats/segment', + siteUuid: 'query', + auth: defaultAuth, + queryKeys: ['segment_id'], + successStatuses: [200], + }, + { + domain: 'stats', + operation: 'getReportStats', + method: 'GET', + path: '/stats/report', + siteUuid: 'query', + auth: defaultAuth, + queryKeys: ['report_id'], + successStatuses: [200], + }, + { + domain: 'stats', + operation: 'getAdsStats', + method: 'GET', + path: '/stats/ads', + siteUuid: 'query', + auth: defaultAuth, + queryKeys: ['dimension', 'value', 'start_date', 'end_date', 'revenue_mode'], + successStatuses: [200], + }, + { + domain: 'experimental', + operation: 'validateEmail', + method: 'POST', + path: '/experimental/validation', + siteUuid: 'body', + auth: defaultAuth, + successStatuses: [200], + }, + { + domain: 'experimental', + operation: 'guessGender', + method: 'POST', + path: '/experimental/gender', + siteUuid: 'body', + auth: defaultAuth, + successStatuses: [200], + }, + { + domain: 'experimental', + operation: 'geolocateIp', + method: 'GET', + path: '/experimental/geolocation', + siteUuid: 'query', + auth: defaultAuth, + queryKeys: ['ip'], + successStatuses: [200], + }, + { + domain: 'experimental', + operation: 'checkBlacklist', + method: 'GET', + path: '/experimental/blacklist', + siteUuid: 'query', + auth: defaultAuth, + queryKeys: ['domain', 'ip'], + successStatuses: [200], + }, + { + domain: 'experimental', + operation: 'getContentModeration', + method: 'POST', + path: '/experimental/content_moderation', + siteUuid: 'body', + auth: defaultAuth, + successStatuses: [200], + }, +] as const satisfies readonly BentoEndpointContract[]; diff --git a/src/sdk/experimental/index.ts b/src/sdk/experimental/index.ts index 617fc94..5119bc2 100644 --- a/src/sdk/experimental/index.ts +++ b/src/sdk/experimental/index.ts @@ -90,10 +90,7 @@ export class BentoExperimental { * @returns Promise\ */ public async checkBlacklist(parameters: BlacklistParameters): Promise { - const result = await this._client.get( - `${this._url}/blacklist.json`, - parameters - ); + const result = await this._client.get(`${this._url}/blacklist`, parameters); return result; } @@ -104,7 +101,10 @@ export class BentoExperimental { * @returns Promise */ public async getBlacklistStatus(input: BlacklistCheckInput): Promise { - return this._client.get(`${this._url}/blacklist`, input); + return this._client.get(`${this._url}/blacklist`, { + domain: input.domain, + ip: input.ipAddress, + }); } /** @@ -113,8 +113,8 @@ export class BentoExperimental { * @returns Promise */ public async getContentModeration(content: string): Promise { - return this._client.post(`${this._url}/moderation`, { - content + return this._client.post(`${this._url}/content_moderation`, { + content, }); } diff --git a/src/sdk/experimental/types.ts b/src/sdk/experimental/types.ts index a48c5ec..587b3a1 100644 --- a/src/sdk/experimental/types.ts +++ b/src/sdk/experimental/types.ts @@ -62,23 +62,7 @@ export type BlacklistResult = { }; export type ContentModerationResult = { - flagged: boolean; - categories: { - hate: boolean; - 'hate/threatening': boolean; - 'self-harm': boolean; - sexual: boolean; - 'sexual/minors': boolean; - violence: boolean; - 'violence/graphic': boolean; - }; - category_scores: { - hate: number; - 'hate/threatening': number; - 'self-harm': number; - sexual: number; - 'sexual/minors': number; - violence: number; - 'violence/graphic': number; - }; + valid: boolean; + reasons?: string[]; + safe_original_content?: string; }; diff --git a/src/sdk/fields/index.ts b/src/sdk/fields/index.ts index c9051e8..95290f4 100644 --- a/src/sdk/fields/index.ts +++ b/src/sdk/fields/index.ts @@ -32,10 +32,10 @@ export class BentoFields { * Name: `This Is A Key` * * @param parameters \{ key: string \} - * @returns Promise + * @returns Promise */ - public async createField(parameters: CreateFieldParameters): Promise { - const result = await this._client.post>(this._url, { + public async createField(parameters: CreateFieldParameters): Promise { + const result = await this._client.post>(this._url, { field: parameters, }); diff --git a/src/sdk/sequences/identity.ts b/src/sdk/sequences/identity.ts new file mode 100644 index 0000000..27e3052 --- /dev/null +++ b/src/sdk/sequences/identity.ts @@ -0,0 +1,54 @@ +import type { Sequence, SequenceEmailTemplate } from './types'; + +export type SequenceId = string & { readonly __brand: 'SequenceId' }; + +export type SequenceIdentity = { + id: SequenceId | null; + name: string; + templateIds: readonly number[]; +}; + +export type SequenceIdentitySource = Pick & { + attributes: Pick & + Partial>; +}; + +export function toSequenceId(value: string): SequenceId { + const normalized = value.trim(); + + if (!normalized) { + throw new Error('Sequence ID must be the non-empty id returned by list sequences.'); + } + + return normalized as SequenceId; +} + +export function isSequenceId(value: string): boolean { + return value.trim().length > 0; +} + +export function getSequenceId(sequence: SequenceIdentitySource): SequenceId | null { + const candidates = [sequence.id, sequence.attributes.id, sequence.attributes.prefix_id]; + + for (const candidate of candidates) { + if (typeof candidate !== 'string') continue; + const normalized = candidate.trim(); + if (isSequenceId(normalized)) { + return normalized as SequenceId; + } + } + + return null; +} + +function getTemplateIds(templates: readonly SequenceEmailTemplate[]): readonly number[] { + return templates.map((template) => template.id); +} + +export function toSequenceIdentity(sequence: SequenceIdentitySource): SequenceIdentity { + return { + id: getSequenceId(sequence), + name: sequence.attributes.name, + templateIds: getTemplateIds(sequence.attributes.email_templates), + }; +} diff --git a/src/sdk/sequences/index.ts b/src/sdk/sequences/index.ts index 76d6a91..33b8732 100644 --- a/src/sdk/sequences/index.ts +++ b/src/sdk/sequences/index.ts @@ -2,6 +2,7 @@ import type { BentoClient } from '../client'; import type { DataResponse } from '../client/types'; import type { EmailTemplate } from '../email-templates/types'; import type { CreateSequenceEmailParameters, GetSequencesParameters, Sequence } from './types'; +import { toSequenceId } from './identity'; export class BentoSequences { private readonly _url = '/fetch/sequences'; @@ -32,8 +33,9 @@ export class BentoSequences { sequenceId: string, parameters: CreateSequenceEmailParameters ): Promise { + const id = toSequenceId(sequenceId); const result = await this._client.post>( - `${this._url}/${sequenceId}/emails/templates`, + `${this._url}/${encodeURIComponent(id)}/emails/templates`, { email_template: parameters, } diff --git a/src/sdk/sequences/types.ts b/src/sdk/sequences/types.ts index 4469dbe..2a8d76e 100644 --- a/src/sdk/sequences/types.ts +++ b/src/sdk/sequences/types.ts @@ -14,6 +14,8 @@ export type SequenceEmailTemplate = { * Core Sequence Types */ export type SequenceAttributes = { + id?: string; + prefix_id?: string; name: string; created_at: string; email_templates: SequenceEmailTemplate[]; diff --git a/src/sdk/stats/index.ts b/src/sdk/stats/index.ts index 1000c49..d64a469 100644 --- a/src/sdk/stats/index.ts +++ b/src/sdk/stats/index.ts @@ -21,7 +21,9 @@ export class BentoStats { * @returns Promise */ public async getSegmentStats(segmentId: string): Promise { - const result = await this._client.get(`${this._url}/segments/${segmentId}`); + const result = await this._client.get(`${this._url}/segment`, { + segment_id: segmentId, + }); return result; } @@ -31,7 +33,9 @@ export class BentoStats { * @returns Promise */ public async getReportStats(reportId: string): Promise { - const result = await this._client.get(`${this._url}/reports/${reportId}`); + const result = await this._client.get(`${this._url}/report`, { + report_id: reportId, + }); return result; } diff --git a/src/sdk/stats/types.ts b/src/sdk/stats/types.ts index a97bd9b..b477622 100644 --- a/src/sdk/stats/types.ts +++ b/src/sdk/stats/types.ts @@ -1,30 +1,18 @@ -export type SiteStats = { - total_subscribers: number; - active_subscribers: number; - unsubscribed_count: number; - broadcast_count: number; - average_open_rate: number; - average_click_rate: number; -}; - -export type SegmentStats = { - segment_id: string; +export type StatsCounts = { + user_count: number; subscriber_count: number; - growth_rate: number; - engagement_rate: number; - last_updated: string; + unsubscriber_count: number; }; +export type SiteStats = StatsCounts; + +export type SegmentStats = StatsCounts; + export type ReportStats = { - report_id: string; - total_sent: number; - total_opens: number; - unique_opens: number; - total_clicks: number; - unique_clicks: number; - unsubscribes: number; - spam_reports: number; - created_at: string; + data: unknown; + chart_style?: string; + report_type?: string; + report_name?: string; }; export type AdsStatsDimension = 'source' | 'campaign' | 'medium' | 'content' | 'ad'; @@ -92,4 +80,4 @@ export type AdsStats = { chartData: AdsStatsChartDataPoint[]; breakdown: AdsStatsBreakdownRow[]; revenueTruncated: boolean; -}; \ No newline at end of file +}; diff --git a/src/sdk/tags/index.ts b/src/sdk/tags/index.ts index 400d11f..1ca0a7c 100644 --- a/src/sdk/tags/index.ts +++ b/src/sdk/tags/index.ts @@ -1,6 +1,6 @@ import type { BentoClient } from '../client'; import type { DataResponse } from '../client/types'; -import type { CreateTagParameters, Tag } from './types'; +import type { CreateTagParameters, DeleteTagParameters, Tag, TagDeleteResult } from './types'; export class BentoTags { private readonly _url = '/fetch/tags'; @@ -23,14 +23,27 @@ export class BentoTags { * Creates a tag inside of Bento. * * @param parameters CreateTagParameters - * @returns Promise\ + * @returns Promise\ */ - public async createTag(parameters: CreateTagParameters): Promise { - const result = await this._client.post>(this._url, { + public async createTag(parameters: CreateTagParameters): Promise { + const result = await this._client.post>(this._url, { tag: parameters, }); if (Object.keys(result).length === 0 || !result.data) return null; return result.data; } + + /** + * Deletes or restores a tag by name. + * + * @param id Tag ID from list/create responses + * @param parameters DeleteTagParameters + * @returns Promise\ + */ + public async deleteTag(id: string, parameters: DeleteTagParameters): Promise { + return this._client.delete(`${this._url}/${id}`, { + tag: parameters, + }); + } } diff --git a/src/sdk/tags/types.ts b/src/sdk/tags/types.ts index a6c4a11..aa814bb 100644 --- a/src/sdk/tags/types.ts +++ b/src/sdk/tags/types.ts @@ -7,6 +7,14 @@ export type CreateTagParameters = { name: string; }; +export type DeleteTagParameters = { + name: string; +}; + +export type TagDeleteResult = { + message: string; +}; + /** * Core Tag Types */