diff --git a/README.md b/README.md index 236d62a..77663f4 100644 --- a/README.md +++ b/README.md @@ -117,6 +117,10 @@ const bento = new Analytics({ secretKey: 'bento-secret-key', }, siteUuid: 'bento-site-uuid', + // Optional: Configure request timeout (default: 30000ms) + clientOptions: { + timeout: 30000, + }, }); bento.V1.track({ @@ -179,10 +183,11 @@ bento.V1.removeSubscriber({ #### upsertSubscriber -Updates existing subscriber or creates a new one if they don't exist: +Creates or updates a subscriber. The SDK queues the import job and then attempts to fetch +the subscriber record once the job has been accepted. ```javascript -await analytics.V1.upsertSubscriber({ +const subscriber = await analytics.V1.upsertSubscriber({ email: 'user@example.com', fields: { firstName: 'John', @@ -194,6 +199,9 @@ await analytics.V1.upsertSubscriber({ }); ``` +> **Note:** Imports are processed asynchronously by Bento and may take 1-5 minutes to +> complete. If the subscriber is not yet available, the method will return `null`. + #### updateFields Updates custom fields for a subscriber. @@ -824,6 +832,32 @@ Note: The `S` and `E` generic types are used for TypeScript support. `S` represe - The SDK supports TypeScript with generics for custom fields and events. - Batch operations are available for importing subscribers and events efficiently. - The SDK doesn't currently support anonymous events (coming soon). +- Requests have a default timeout of 30 seconds, configurable via `clientOptions.timeout`. + +## Error Handling + +The SDK exports several error types for specific error conditions: + +```javascript +import { + NotAuthorizedError, // 401 - Invalid credentials + RateLimitedError, // 429 - Too many requests + AuthorNotAuthorizedError, // Author not permitted to send emails + RequestTimeoutError, // Request exceeded timeout +} from '@bentonow/bento-node-sdk'; + +try { + await bento.V1.Tags.getTags(); +} catch (error) { + if (error instanceof RequestTimeoutError) { + // Handle timeout - maybe retry + } else if (error instanceof RateLimitedError) { + // Handle rate limiting - back off and retry + } else if (error instanceof NotAuthorizedError) { + // Handle auth error - check credentials + } +} +``` ## Contributing diff --git a/__tests__/batch/events.test.ts b/__tests__/batch/events.test.ts index 41b296c..cbf4cce 100644 --- a/__tests__/batch/events.test.ts +++ b/__tests__/batch/events.test.ts @@ -1,7 +1,7 @@ -import { expect, test, describe, beforeEach } from 'bun:test'; +import { expect, test, describe, beforeEach, afterEach } from 'bun:test'; import { Analytics } from '../../src'; import { mockOptions } from '../helpers/mockClient'; -import { setupMockFetch } from '../helpers/mockFetch'; +import { setupMockFetch, lastFetchSignal, resetMockFetchTracking } from '../helpers/mockFetch'; import { BentoEvents } from '../../src/sdk/batch/enums'; describe('BentoBatch - importEvents', () => { let analytics: Analytics; @@ -9,6 +9,9 @@ describe('BentoBatch - importEvents', () => { beforeEach(() => { analytics = new Analytics(mockOptions); }); + afterEach(() => { + resetMockFetchTracking(); + }); test('successfully imports purchase event', async () => { setupMockFetch({ results: 1 }); @@ -310,4 +313,20 @@ describe('BentoBatch - importEvents', () => { expect(result).toBe(3); }); -}); \ No newline at end of file + + test('uses unlimited timeout for imports', async () => { + setupMockFetch({ results: 1 }); + + await analytics.V1.Batch.importEvents({ + events: [{ + type: BentoEvents.TAG, + email: 'signal@example.com', + details: { + tag: 'VIP' + } + }] + }); + + expect(lastFetchSignal).toBeNull(); + }); +}); diff --git a/__tests__/batch/subscribers.test.ts b/__tests__/batch/subscribers.test.ts new file mode 100644 index 0000000..93bdcec --- /dev/null +++ b/__tests__/batch/subscribers.test.ts @@ -0,0 +1,31 @@ +import { expect, test, describe, beforeEach, afterEach } from 'bun:test'; +import { Analytics } from '../../src'; +import { mockOptions } from '../helpers/mockClient'; +import { setupMockFetch, lastFetchSignal, resetMockFetchTracking } from '../helpers/mockFetch'; + +describe('BentoBatch - importSubscribers', () => { + let analytics: Analytics; + + beforeEach(() => { + analytics = new Analytics(mockOptions); + }); + afterEach(() => { + resetMockFetchTracking(); + }); + + test('successfully imports subscribers without timeout signal', async () => { + setupMockFetch({ results: 1 }); + + const result = await analytics.V1.Batch.importSubscribers({ + subscribers: [ + { + email: 'test@example.com', + firstName: 'Test', + }, + ], + }); + + expect(result).toBe(1); + expect(lastFetchSignal).toBeNull(); + }); +}); diff --git a/__tests__/broadcasts/broadcasts.test.ts b/__tests__/broadcasts/broadcasts.test.ts index ef2740f..3b28c3a 100644 --- a/__tests__/broadcasts/broadcasts.test.ts +++ b/__tests__/broadcasts/broadcasts.test.ts @@ -1,7 +1,7 @@ -import { expect, test, describe, beforeEach } from 'bun:test'; +import { expect, test, describe, beforeEach, afterEach } from 'bun:test'; import { Analytics } from '../../src'; import { mockOptions } from '../helpers/mockClient'; -import { setupMockFetch } from '../helpers/mockFetch'; +import { setupMockFetch, lastFetchUrl, lastFetchMethod, resetMockFetchTracking } from '../helpers/mockFetch'; import { EntityType } from '../../src/sdk/enums'; describe('BentoBroadcasts', () => { @@ -10,21 +10,26 @@ describe('BentoBroadcasts', () => { beforeEach(() => { analytics = new Analytics(mockOptions); }); + afterEach(() => { + resetMockFetchTracking(); + }); describe('createEmails', () => { test('successfully creates single email', async () => { setupMockFetch({ results: 1 }); - const result = await analytics.V1.Broadcasts.createEmails([{ - to: 'recipient@example.com', - from: 'sender@example.com', - subject: 'Test Email', - html_body: '

Hello {{ name }}

', - transactional: true, - personalizations: { - name: 'John Doe' - } - }]); + const result = await analytics.V1.Broadcasts.createEmails([ + { + to: 'recipient@example.com', + from: 'sender@example.com', + subject: 'Test Email', + html_body: '

Hello {{ name }}

', + transactional: true, + personalizations: { + name: 'John Doe', + }, + }, + ]); expect(result).toBe(1); }); @@ -38,31 +43,50 @@ describe('BentoBroadcasts', () => { from: 'sender@example.com', subject: 'Test Email 1', html_body: '

Hello

', - transactional: true + transactional: true, }, { to: 'recipient2@example.com', from: 'sender@example.com', subject: 'Test Email 2', html_body: '

World

', - transactional: true - } + transactional: true, + }, ]); expect(result).toBe(2); }); - test('handles server error gracefully', async () => { - setupMockFetch({ error: 'Server Error' }, 500); + test('uses correct /batch/emails endpoint', async () => { + setupMockFetch({ results: 1 }); - await expect( - analytics.V1.Broadcasts.createEmails([{ - to: 'recipient@example.com', + await analytics.V1.Broadcasts.createEmails([ + { + to: 'test@example.com', from: 'sender@example.com', subject: 'Test', html_body: '

Test

', - transactional: true - }]) + transactional: true, + }, + ]); + + expect(lastFetchUrl).toContain('/batch/emails'); + expect(lastFetchMethod).toBe('POST'); + }); + + test('handles server error gracefully', async () => { + setupMockFetch({ error: 'Server Error' }, 500); + + await expect( + analytics.V1.Broadcasts.createEmails([ + { + to: 'recipient@example.com', + from: 'sender@example.com', + subject: 'Test', + html_body: '

Test

', + transactional: true, + }, + ]) ).rejects.toThrow(); }); }); @@ -81,13 +105,13 @@ describe('BentoBroadcasts', () => { type: 'html', from: { name: 'Sender Name', - email: 'sender@example.com' + email: 'sender@example.com', }, batch_size_per_hour: 100, - created_at: '2024-01-01T00:00:00Z' - } - } - ] + created_at: '2024-01-01T00:00:00Z', + }, + }, + ], }; setupMockFetch(mockBroadcasts); @@ -101,6 +125,15 @@ describe('BentoBroadcasts', () => { expect(result[0].attributes.type).toBe('html'); }); + test('uses correct /fetch/broadcasts endpoint for GET', async () => { + setupMockFetch({ data: [] }); + + await analytics.V1.Broadcasts.getBroadcasts(); + + expect(lastFetchUrl).toContain('/fetch/broadcasts'); + expect(lastFetchMethod).toBe('GET'); + }); + test('returns empty array when no broadcasts exist', async () => { setupMockFetch({ data: [] }); @@ -124,34 +157,57 @@ describe('BentoBroadcasts', () => { type: 'html', from: { name: 'Sender', - email: 'sender@example.com' + email: 'sender@example.com', }, batch_size_per_hour: 100, - created_at: '2024-01-01T00:00:00Z' - } - } - ] + created_at: '2024-01-01T00:00:00Z', + }, + }, + ], }; setupMockFetch(mockResponse); - const result = await analytics.V1.Broadcasts.createBroadcast([{ - name: 'New Broadcast', - subject: 'New Subject', - content: '

Content

', - type: 'html', - from: { - name: 'Sender', - email: 'sender@example.com' + const result = await analytics.V1.Broadcasts.createBroadcast([ + { + name: 'New Broadcast', + subject: 'New Subject', + content: '

Content

', + type: 'html', + from: { + name: 'Sender', + email: 'sender@example.com', + }, + batch_size_per_hour: 100, }, - batch_size_per_hour: 100 - }]); + ]); expect(result).toHaveLength(1); // @ts-ignore expect(result[0].attributes.name).toBe('New Broadcast'); }); + test('uses correct /batch/broadcasts endpoint for POST', async () => { + setupMockFetch({ data: [] }); + + await analytics.V1.Broadcasts.createBroadcast([ + { + name: 'Test Broadcast', + subject: 'Test Subject', + content: '

Test

', + type: 'html', + from: { + name: 'Sender', + email: 'sender@example.com', + }, + batch_size_per_hour: 100, + }, + ]); + + expect(lastFetchUrl).toContain('/batch/broadcasts'); + expect(lastFetchMethod).toBe('POST'); + }); + test('handles broadcast with segments and tags', async () => { const mockResponse = { data: [ @@ -165,34 +221,36 @@ describe('BentoBroadcasts', () => { type: 'plain', from: { name: 'Sender', - email: 'sender@example.com' + 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' - } - } - ] + created_at: '2024-01-01T00:00:00Z', + }, + }, + ], }; setupMockFetch(mockResponse); - const result = await analytics.V1.Broadcasts.createBroadcast([{ - name: 'Segmented Broadcast', - subject: 'For Segment', - content: 'Content', - type: 'plain', - from: { - name: 'Sender', - email: 'sender@example.com' + const result = await analytics.V1.Broadcasts.createBroadcast([ + { + 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, }, - inclusive_tags: 'tag1,tag2', - exclusive_tags: 'tag3', - segment_id: 'segment-123', - batch_size_per_hour: 50 - }]); + ]); expect(result).toHaveLength(1); // @ts-ignore @@ -201,4 +259,4 @@ describe('BentoBroadcasts', () => { expect(result[0].attributes.inclusive_tags).toBe('tag1,tag2'); }); }); -}); \ No newline at end of file +}); diff --git a/__tests__/client/client.test.ts b/__tests__/client/client.test.ts index 5b18502..694431b 100644 --- a/__tests__/client/client.test.ts +++ b/__tests__/client/client.test.ts @@ -1,8 +1,8 @@ -import { expect, test, describe, beforeEach, mock } from 'bun:test'; +import { expect, test, describe, beforeEach, afterEach, mock } from 'bun:test'; import { Analytics } from '../../src'; import { mockOptions } from '../helpers/mockClient'; -import { setupMockFetch } from '../helpers/mockFetch'; -import { NotAuthorizedError, RateLimitedError } from '../../src'; +import { setupMockFetch, lastFetchSignal, resetMockFetchTracking } from '../helpers/mockFetch'; +import { NotAuthorizedError, RateLimitedError, RequestTimeoutError } from '../../src'; describe('BentoClient', () => { let analytics: Analytics; @@ -12,6 +12,9 @@ describe('BentoClient', () => { analytics = new Analytics(mockOptions); globalScope = global; }); + afterEach(() => { + resetMockFetchTracking(); + }); describe('Base64 Encoding', () => { test('uses btoa when available', () => { @@ -29,8 +32,8 @@ describe('BentoClient', () => { const mockBuffer = { from: mock(() => ({ - toString: () => 'mocked-base64' - })) + toString: () => 'mocked-base64', + })), }; globalScope.Buffer = mockBuffer; @@ -38,8 +41,8 @@ describe('BentoClient', () => { ...mockOptions, authentication: { publishableKey: 'test', - secretKey: 'test' - } + secretKey: 'test', + }, }); const headers = (analytics.V1 as any)._client._headers; @@ -60,7 +63,7 @@ describe('BentoClient', () => { // Test basic ASCII encoding // Create a test string that we can predict the base64 output for const testStr = 'test:test'; - const expectedBase64 = 'dGVzdDp0ZXN0'; // 'test:test' in base64 + const expectedBase64 = 'dGVzdDp0ZXN0'; // 'test:test' in base64 const result = (analytics.V1 as any)._client._extractHeaders( { publishableKey: 'test', secretKey: 'test' }, 'test' @@ -84,7 +87,9 @@ describe('BentoClient', () => { { publishableKey: nonLatin1Str, secretKey: '' }, 'test' ); - }).toThrow("'btoa' failed: The string to be encoded contains characters outside of the Latin1 range."); + }).toThrow( + "'btoa' failed: The string to be encoded contains characters outside of the Latin1 range." + ); globalScope.btoa = originalBtoa; globalScope.Buffer = originalBuffer; @@ -104,23 +109,13 @@ describe('BentoClient', () => { test('handles text/plain error response', async () => { const errorMessage = 'Plain text error message'; - setupMockFetch( - { error: errorMessage }, - 500, - 'text/plain' - ); + setupMockFetch({ error: errorMessage }, 500, 'text/plain'); - expect(analytics.V1.Tags.getTags()).rejects.toThrow( - `[500] - ${errorMessage}` - ); + expect(analytics.V1.Tags.getTags()).rejects.toThrow(`[500] - ${errorMessage}`); }); test('handles unknown content-type error response', async () => { - setupMockFetch( - { error: 'Unknown error' }, - 500, - 'application/unknown' - ); + setupMockFetch({ error: 'Unknown error' }, 500, 'application/unknown'); expect(analytics.V1.Tags.getTags()).rejects.toThrow( '[500] - Unknown response from the Bento API.' @@ -151,8 +146,8 @@ describe('BentoClient', () => { const customOptions = { ...mockOptions, clientOptions: { - baseUrl: 'https://custom.api.com' - } + baseUrl: 'https://custom.api.com', + }, }; const customAnalytics = new Analytics(customOptions); const client = (customAnalytics.V1 as any)._client; @@ -171,10 +166,10 @@ describe('BentoClient', () => { ok: true, json: () => Promise.resolve({ data: null }), headers: new Headers({ - 'Content-Type': 'application/json' - }) + 'Content-Type': 'application/json', + }), }); - } + }, })); await analytics.V1.Forms.getResponses('test-form'); @@ -185,5 +180,117 @@ describe('BentoClient', () => { expect(url.searchParams.get('id')).toBe('test-form'); expect(url.searchParams.get('site_uuid')).toBe(mockOptions.siteUuid); }); + + test('converts non-string query parameter values to strings', async () => { + let capturedUrl: string | null = null; + + // Setup mock that captures the URL + mock.module('cross-fetch', () => ({ + default: (url: string, _options: RequestInit) => { + capturedUrl = url; + return Promise.resolve({ + status: 200, + ok: true, + json: () => Promise.resolve({ data: null }), + headers: new Headers({ + 'Content-Type': 'application/json', + }), + }); + }, + })); + + // Test with a parameter that would have a non-string value + await analytics.V1.Forms.getResponses('test-form'); + + // Verify URL query parameters are properly stringified + expect(capturedUrl).not.toBeNull(); + const url = new URL(capturedUrl!); + + // All query parameters should be strings, not undefined or null + expect(typeof url.searchParams.get('site_uuid')).toBe('string'); + expect(url.searchParams.get('site_uuid')).not.toBe('undefined'); + expect(url.searchParams.get('site_uuid')).not.toBe('null'); + + // Verify site_uuid is properly set + expect(url.searchParams.get('site_uuid')).toBe(mockOptions.siteUuid); + }); + + test('initializes with custom timeout', () => { + const customOptions = { + ...mockOptions, + clientOptions: { + timeout: 5000, + }, + }; + const customAnalytics = new Analytics(customOptions); + const client = (customAnalytics.V1 as any)._client; + expect(client._timeout).toBe(5000); + }); + + test('uses default timeout of 30000ms', () => { + const client = (analytics.V1 as any)._client; + expect(client._timeout).toBe(30000); + }); + + test('attaches AbortSignal to standard requests', async () => { + setupMockFetch({ data: [] }); + + await analytics.V1.Fields.getFields(); + + expect(lastFetchSignal).not.toBeNull(); + }); + }); + + describe('Timeout Handling', () => { + test('throws RequestTimeoutError on timeout', async () => { + // Mock fetch to simulate a timeout by using AbortController + mock.module('cross-fetch', () => ({ + default: (_url: string, options: RequestInit) => { + return new Promise((_resolve, reject) => { + // Simulate the abort being triggered + if (options.signal) { + const abortHandler = () => { + const error = new Error('The operation was aborted'); + error.name = 'AbortError'; + reject(error); + }; + options.signal.addEventListener('abort', abortHandler); + // Don't resolve - let the timeout happen + } + }); + }, + })); + + // Create analytics with very short timeout + const timeoutAnalytics = new Analytics({ + ...mockOptions, + clientOptions: { + timeout: 1, // 1ms timeout to trigger quickly + }, + }); + + await expect(timeoutAnalytics.V1.Tags.getTags()).rejects.toThrow(RequestTimeoutError); + }); + }); + + describe('JSON Response Handling', () => { + test('throws error on invalid JSON response with success status', async () => { + mock.module('cross-fetch', () => ({ + default: () => { + return Promise.resolve({ + status: 200, + ok: true, + json: () => Promise.reject(new SyntaxError('Unexpected end of JSON input')), + headers: new Headers({ + 'Content-Type': 'application/json', + }), + }); + }, + })); + + await expect(analytics.V1.Tags.getTags()).rejects.toThrow( + 'Invalid JSON response from server' + ); + }); }); -}); \ No newline at end of file +}); diff --git a/__tests__/email-templates/email-templates.test.ts b/__tests__/email-templates/email-templates.test.ts new file mode 100644 index 0000000..bbea122 --- /dev/null +++ b/__tests__/email-templates/email-templates.test.ts @@ -0,0 +1,236 @@ +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 { EntityType } from '../../src/sdk/enums'; + +describe('BentoEmailTemplates', () => { + let analytics: Analytics; + + beforeEach(() => { + analytics = new Analytics(mockOptions); + }); + afterEach(() => { + resetMockFetchTracking(); + }); + + describe('getEmailTemplate', () => { + test('successfully retrieves an email template by ID', async () => { + const mockTemplate = { + data: { + id: '123', + type: EntityType.EMAIL_TEMPLATES, + attributes: { + name: 'Welcome Email', + subject: 'Welcome to our service!', + html: '

Welcome!

Thank you for signing up.

', + created_at: '2024-01-01T00:00:00Z', + stats: { opened: 100, clicked: 50 }, + }, + }, + }; + + setupMockFetch(mockTemplate); + + const result = await analytics.V1.EmailTemplates.getEmailTemplate({ id: 123 }); + + expect(result).not.toBeNull(); + expect(result!.id).toBe('123'); + expect(result!.attributes.name).toBe('Welcome Email'); + expect(result!.attributes.subject).toBe('Welcome to our service!'); + expect(result!.attributes.html).toBe('

Welcome!

Thank you for signing up.

'); + }); + + test('uses correct endpoint for GET request', async () => { + setupMockFetch({ data: { id: '123', type: EntityType.EMAIL_TEMPLATES, attributes: {} } }); + + await analytics.V1.EmailTemplates.getEmailTemplate({ id: 456 }); + + expect(lastFetchUrl).toContain('/fetch/emails/templates/456'); + expect(lastFetchMethod).toBe('GET'); + }); + + test('returns null when response is empty object', async () => { + setupMockFetch({}); + + const result = await analytics.V1.EmailTemplates.getEmailTemplate({ id: 999 }); + + expect(result).toBeNull(); + }); + + test('returns null when response has no data', async () => { + setupMockFetch({ data: null }); + + const result = await analytics.V1.EmailTemplates.getEmailTemplate({ id: 999 }); + + expect(result).toBeNull(); + }); + + test('returns template with null stats', async () => { + const mockTemplate = { + data: { + id: '789', + type: EntityType.EMAIL_TEMPLATES, + attributes: { + name: 'No Stats Template', + subject: 'Test Subject', + html: '

Test

', + created_at: '2024-01-01T00:00:00Z', + stats: null, + }, + }, + }; + + setupMockFetch(mockTemplate); + + const result = await analytics.V1.EmailTemplates.getEmailTemplate({ id: 789 }); + + expect(result).not.toBeNull(); + expect(result!.attributes.stats).toBeNull(); + }); + + test('handles server error gracefully', async () => { + setupMockFetch({ error: 'Server Error' }, 500); + + await expect(analytics.V1.EmailTemplates.getEmailTemplate({ id: 123 })).rejects.toThrow(); + }); + }); + + describe('updateEmailTemplate', () => { + test('successfully updates email template subject', async () => { + const mockUpdatedTemplate = { + data: { + id: '123', + type: EntityType.EMAIL_TEMPLATES, + attributes: { + name: 'Welcome Email', + subject: 'New Subject Line', + html: '

Welcome!

', + created_at: '2024-01-01T00:00:00Z', + stats: null, + }, + }, + }; + + setupMockFetch(mockUpdatedTemplate); + + const result = await analytics.V1.EmailTemplates.updateEmailTemplate({ + id: 123, + subject: 'New Subject Line', + }); + + expect(result).not.toBeNull(); + expect(result!.attributes.subject).toBe('New Subject Line'); + }); + + test('successfully updates email template HTML', async () => { + const mockUpdatedTemplate = { + data: { + id: '123', + type: EntityType.EMAIL_TEMPLATES, + attributes: { + name: 'Welcome Email', + subject: 'Welcome!', + html: '

Updated Content

', + created_at: '2024-01-01T00:00:00Z', + stats: null, + }, + }, + }; + + setupMockFetch(mockUpdatedTemplate); + + const result = await analytics.V1.EmailTemplates.updateEmailTemplate({ + id: 123, + html: '

Updated Content

', + }); + + expect(result).not.toBeNull(); + expect(result!.attributes.html).toBe('

Updated Content

'); + }); + + test('successfully updates both subject and HTML', async () => { + const mockUpdatedTemplate = { + data: { + id: '123', + type: EntityType.EMAIL_TEMPLATES, + attributes: { + name: 'Welcome Email', + subject: 'Brand New Subject', + html: '

Brand New HTML

', + created_at: '2024-01-01T00:00:00Z', + stats: { opened: 200 }, + }, + }, + }; + + setupMockFetch(mockUpdatedTemplate); + + const result = await analytics.V1.EmailTemplates.updateEmailTemplate({ + id: 123, + subject: 'Brand New Subject', + html: '

Brand New HTML

', + }); + + expect(result).not.toBeNull(); + expect(result!.attributes.subject).toBe('Brand New Subject'); + expect(result!.attributes.html).toBe('

Brand New HTML

'); + }); + + test('uses correct endpoint for PATCH request', async () => { + setupMockFetch({ data: { id: '123', type: EntityType.EMAIL_TEMPLATES, attributes: {} } }); + + await analytics.V1.EmailTemplates.updateEmailTemplate({ + id: 456, + subject: 'Test Subject', + }); + + expect(lastFetchUrl).toContain('/fetch/emails/templates/456'); + expect(lastFetchMethod).toBe('PATCH'); + }); + + test('returns null when response is empty object', async () => { + setupMockFetch({}); + + const result = await analytics.V1.EmailTemplates.updateEmailTemplate({ + id: 999, + subject: 'Test', + }); + + expect(result).toBeNull(); + }); + + test('returns null when response has no data', async () => { + setupMockFetch({ data: null }); + + const result = await analytics.V1.EmailTemplates.updateEmailTemplate({ + id: 999, + html: '

Test

', + }); + + expect(result).toBeNull(); + }); + + test('handles server error gracefully', async () => { + setupMockFetch({ error: 'Server Error' }, 500); + + await expect( + analytics.V1.EmailTemplates.updateEmailTemplate({ + id: 123, + subject: 'Test', + }) + ).rejects.toThrow(); + }); + + test('handles 404 not found error', async () => { + setupMockFetch({ error: 'Not Found' }, 404); + + await expect( + analytics.V1.EmailTemplates.updateEmailTemplate({ + id: 999999, + subject: 'Test', + }) + ).rejects.toThrow(); + }); + }); +}); diff --git a/__tests__/experimental/experimental.test.ts b/__tests__/experimental/experimental.test.ts index 9625da0..f9e3852 100644 --- a/__tests__/experimental/experimental.test.ts +++ b/__tests__/experimental/experimental.test.ts @@ -15,7 +15,7 @@ describe('BentoExperimental', () => { setupMockFetch({ valid: true }); const result = await analytics.V1.Experimental.validateEmail({ - email: 'test@example.com' + email: 'test@example.com', }); expect(result).toBe(true); @@ -28,7 +28,7 @@ describe('BentoExperimental', () => { email: 'test@example.com', ip: '192.168.1.1', name: 'John Doe', - userAgent: 'Mozilla/5.0' + userAgent: 'Mozilla/5.0', }); expect(result).toBe(true); @@ -38,7 +38,7 @@ describe('BentoExperimental', () => { setupMockFetch({ valid: false }); const result = await analytics.V1.Experimental.validateEmail({ - email: 'invalid@email' + email: 'invalid@email', }); expect(result).toBe(false); @@ -49,7 +49,7 @@ describe('BentoExperimental', () => { await expect( analytics.V1.Experimental.validateEmail({ - email: 'test@example.com' + email: 'test@example.com', }) ).rejects.toThrow(); }); @@ -59,11 +59,11 @@ describe('BentoExperimental', () => { test('successfully guesses gender with high confidence', async () => { setupMockFetch({ gender: 'female', - confidence: 0.95 + confidence: 0.95, }); const result = await analytics.V1.Experimental.guessGender({ - name: 'Elizabeth' + name: 'Elizabeth', }); expect(result.gender).toBe('female'); @@ -73,11 +73,11 @@ describe('BentoExperimental', () => { test('handles unknown gender', async () => { setupMockFetch({ gender: null, - confidence: null + confidence: null, }); const result = await analytics.V1.Experimental.guessGender({ - name: 'Unknown' + name: 'Unknown', }); expect(result.gender).toBeNull(); @@ -87,11 +87,11 @@ describe('BentoExperimental', () => { test('handles low confidence result', async () => { setupMockFetch({ gender: 'male', - confidence: 0.3 + confidence: 0.3, }); const result = await analytics.V1.Experimental.guessGender({ - name: 'Pat' + name: 'Pat', }); expect(result.gender).toBe('male'); @@ -106,14 +106,14 @@ describe('BentoExperimental', () => { query: 'example.com', results: { 'blacklist1.com': false, - 'blacklist2.com': true - } + 'blacklist2.com': true, + }, }; setupMockFetch(mockResponse); const result = await analytics.V1.Experimental.getBlacklistStatus({ - domain: 'example.com' + domain: 'example.com', }); expect(result.query).toBe('example.com'); @@ -127,14 +127,14 @@ describe('BentoExperimental', () => { query: '192.168.1.1', results: { 'blacklist1.com': false, - 'blacklist2.com': false - } + 'blacklist2.com': false, + }, }; setupMockFetch(mockResponse); const result = await analytics.V1.Experimental.getBlacklistStatus({ - ipAddress: '192.168.1.1' + ipAddress: '192.168.1.1', }); expect(result.query).toBe('192.168.1.1'); @@ -145,13 +145,13 @@ describe('BentoExperimental', () => { const mockResponse = { description: 'Empty check result', query: 'example.com', - results: {} + results: {}, }; setupMockFetch(mockResponse); const result = await analytics.V1.Experimental.getBlacklistStatus({ - domain: 'example.com' + domain: 'example.com', }); expect(result.results).toEqual({}); @@ -162,7 +162,7 @@ describe('BentoExperimental', () => { await expect( analytics.V1.Experimental.getBlacklistStatus({ - domain: 'example.com' + domain: 'example.com', }) ).rejects.toThrow(); }); @@ -179,7 +179,7 @@ describe('BentoExperimental', () => { sexual: false, 'sexual/minors': false, violence: false, - 'violence/graphic': false + 'violence/graphic': false, }, category_scores: { hate: 0.01, @@ -188,8 +188,8 @@ describe('BentoExperimental', () => { sexual: 0.01, 'sexual/minors': 0.01, violence: 0.01, - 'violence/graphic': 0.01 - } + 'violence/graphic': 0.01, + }, }; setupMockFetch(mockResponse); @@ -213,7 +213,7 @@ describe('BentoExperimental', () => { sexual: false, 'sexual/minors': false, violence: false, - 'violence/graphic': false + 'violence/graphic': false, }, category_scores: { hate: 0.92, @@ -222,8 +222,8 @@ describe('BentoExperimental', () => { sexual: 0.01, 'sexual/minors': 0.01, violence: 0.01, - 'violence/graphic': 0.01 - } + 'violence/graphic': 0.01, + }, }; setupMockFetch(mockResponse); @@ -246,6 +246,87 @@ describe('BentoExperimental', () => { }); }); + describe('geolocate', () => { + test('successfully geolocates IP address', async () => { + const mockResponse = { + city_name: 'San Francisco', + continent_code: 'NA', + country_code2: 'US', + country_code3: 'USA', + country_name: 'United States', + ip: '192.168.1.1', + latitude: 37.7749, + longitude: -122.4194, + postal_code: '94105', + real_region_name: 'California', + region_name: 'CA', + request: '192.168.1.1', + }; + + setupMockFetch(mockResponse); + + const result = await analytics.V1.Experimental.geolocate({ + ip: '192.168.1.1', + }); + + expect(result).toBeDefined(); + expect(result?.city_name).toBe('San Francisco'); + expect(result?.country_name).toBe('United States'); + }); + + test('returns null for empty response', async () => { + setupMockFetch({}); + + const result = await analytics.V1.Experimental.geolocate({ + ip: '192.168.1.1', + }); + + expect(result).toBeNull(); + }); + + test('handles server error gracefully', async () => { + setupMockFetch({ error: 'Server Error' }, 500); + + await expect( + analytics.V1.Experimental.geolocate({ + ip: '192.168.1.1', + }) + ).rejects.toThrow(); + }); + }); + + describe('checkBlacklist', () => { + test('successfully checks blacklist status', async () => { + const mockResponse = { + description: 'Blacklist check', + query: 'example.com', + results: { + 'blacklist1.com': false, + 'blacklist2.com': true, + }, + }; + + setupMockFetch(mockResponse); + + const result = await analytics.V1.Experimental.checkBlacklist({ + ip: '192.0.2.1', + }); + + expect(result.query).toBe('example.com'); + expect(result.results['blacklist2.com']).toBe(true); + }); + + test('handles server error gracefully', async () => { + setupMockFetch({ error: 'Server Error' }, 500); + + await expect( + analytics.V1.Experimental.checkBlacklist({ + ip: '192.168.1.1', + }) + ).rejects.toThrow(); + }); + }); + describe('geoLocateIP', () => { test('successfully geolocates IP address', async () => { const mockResponse = { @@ -260,7 +341,7 @@ describe('BentoExperimental', () => { postal_code: '94105', real_region_name: 'California', region_name: 'CA', - request: '192.168.1.1' + request: '192.168.1.1', }; setupMockFetch(mockResponse); @@ -276,17 +357,13 @@ describe('BentoExperimental', () => { test('handles invalid IP address', async () => { setupMockFetch({ error: 'Invalid IP' }, 400); - await expect( - analytics.V1.Experimental.geoLocateIP('invalid-ip') - ).rejects.toThrow(); + await expect(analytics.V1.Experimental.geoLocateIP('invalid-ip')).rejects.toThrow(); }); test('handles server error gracefully', async () => { setupMockFetch({ error: 'Server Error' }, 500); - await expect( - analytics.V1.Experimental.geoLocateIP('192.168.1.1') - ).rejects.toThrow(); + await expect(analytics.V1.Experimental.geoLocateIP('192.168.1.1')).rejects.toThrow(); }); }); -}); \ No newline at end of file +}); diff --git a/__tests__/helpers/mockFetch.test.ts b/__tests__/helpers/mockFetch.test.ts new file mode 100644 index 0000000..fd9b252 --- /dev/null +++ b/__tests__/helpers/mockFetch.test.ts @@ -0,0 +1,84 @@ +import { expect, test, describe } from 'bun:test'; +import { getEndpointFromUrl, setupMockFetch } from './mockFetch'; + +describe('mockFetch helpers', () => { + describe('getEndpointFromUrl', () => { + test('extracts endpoint from full URL', () => { + const url = 'https://app.bentonow.com/api/v1/fetch/broadcasts'; + const endpoint = getEndpointFromUrl(url); + expect(endpoint).toBe('/fetch/broadcasts'); + }); + + test('extracts endpoint with query parameters', () => { + const url = 'https://app.bentonow.com/api/v1/fetch/subscribers?site_uuid=123&email=test@example.com'; + const endpoint = getEndpointFromUrl(url); + expect(endpoint).toBe('/fetch/subscribers'); + }); + + test('handles batch endpoints', () => { + const url = 'https://app.bentonow.com/api/v1/batch/events'; + const endpoint = getEndpointFromUrl(url); + expect(endpoint).toBe('/batch/events'); + }); + + test('returns null for empty URL', () => { + const endpoint = getEndpointFromUrl(''); + expect(endpoint).toBeNull(); + }); + + test('returns null for URLs without /api/v1', () => { + const url = 'https://example.com/some/path'; + const endpoint = getEndpointFromUrl(url); + expect(endpoint).toBeNull(); + }); + + test('extracts endpoint from experimental endpoint', () => { + const url = 'https://app.bentonow.com/api/v1/experimental/validation'; + const endpoint = getEndpointFromUrl(url); + expect(endpoint).toBe('/experimental/validation'); + }); + }); + + describe('setupMockFetch text handling', () => { + test('returns raw string for text/plain body', async () => { + setupMockFetch('plain text', 200, 'text/plain'); + const fetchModule = await import('cross-fetch'); + const response = await fetchModule.default('https://app.bentonow.com/api/v1/test'); + const text = await response.text(); + expect(text).toBe('plain text'); + }); + + test('returns error field when provided', async () => { + setupMockFetch({ error: 'Server Error' }, 500, 'text/plain'); + const fetchModule = await import('cross-fetch'); + const response = await fetchModule.default('https://app.bentonow.com/api/v1/test'); + const text = await response.text(); + expect(text).toBe('Server Error'); + }); + + test('stringifies non-string text bodies without error', async () => { + setupMockFetch({ value: 123 }, 200, 'text/plain'); + const fetchModule = await import('cross-fetch'); + const response = await fetchModule.default('https://app.bentonow.com/api/v1/test'); + const text = await response.text(); + expect(text).toBe('[object Object]'); + }); + + test('uses queued responses and falls back to last entry', async () => { + setupMockFetch([ + { body: { value: 'first' } }, + { body: { value: 'second' } }, + ]); + const fetchModule = await import('cross-fetch'); + const fetchFn = fetchModule.default; + + const first = await fetchFn('https://app.bentonow.com/api/v1/test'); + const second = await fetchFn('https://app.bentonow.com/api/v1/test'); + const third = await fetchFn('https://app.bentonow.com/api/v1/test'); + + expect(await first.json()).toEqual({ value: 'first' }); + expect(await second.json()).toEqual({ value: 'second' }); + expect(await third.json()).toEqual({ value: 'second' }); + }); + }); +}); diff --git a/__tests__/helpers/mockFetch.ts b/__tests__/helpers/mockFetch.ts index e15c023..5c8c181 100644 --- a/__tests__/helpers/mockFetch.ts +++ b/__tests__/helpers/mockFetch.ts @@ -1,22 +1,99 @@ import { mock } from 'bun:test'; -export const setupMockFetch = (response: any, status = 200, contentType = 'application/json') => { +type MockResponseEntry = { + body: any; + status?: number; + contentType?: string; +}; + +const normalizeEntry = ( + value: MockResponseEntry | any, + defaultStatus: number, + defaultContentType: string +): MockResponseEntry => { + if (value && typeof value === 'object' && 'body' in value) { + return { + body: value.body, + status: value.status ?? defaultStatus, + contentType: value.contentType ?? defaultContentType, + }; + } + + return { + body: value, + status: defaultStatus, + contentType: defaultContentType, + }; +}; + +// Store the last URL, method, and signal for verification in tests +export let lastFetchUrl: string | null = null; +export let lastFetchMethod: string | null = null; +export let lastFetchSignal: AbortSignal | null = null; + +export const resetMockFetchTracking = (): void => { + lastFetchUrl = null; + lastFetchMethod = null; + lastFetchSignal = null; +}; + +export const setupMockFetch = ( + response: any | MockResponseEntry[], + status = 200, + contentType = 'application/json' +) => { + resetMockFetchTracking(); + + const isQueue = Array.isArray(response); + const queue = isQueue + ? (response as MockResponseEntry[]).map((entry) => normalizeEntry(entry, 200, 'application/json')) + : null; + const singleEntry = normalizeEntry(response, status, contentType); + const fallbackEntry = queue && queue.length > 0 ? queue[queue.length - 1] : singleEntry; + mock.module('cross-fetch', () => ({ - default: (_url: string, _options: RequestInit) => { + default: (url: string, options: RequestInit = {}) => { + // Capture URL, method, and AbortSignal for test verification + lastFetchUrl = url; + lastFetchMethod = options.method || 'GET'; + lastFetchSignal = options.signal ?? null; + + const entry = queue ? queue.shift() ?? fallbackEntry : singleEntry; + const currentStatus = entry.status ?? status; + const currentContentType = entry.contentType ?? contentType; + const body = entry.body; + return Promise.resolve({ - status, - ok: status >= 200 && status < 300, - json: () => Promise.resolve(response), + status: currentStatus, + ok: currentStatus >= 200 && currentStatus < 300, + json: () => Promise.resolve(body), text: () => { - if (contentType === 'text/plain') { - return Promise.resolve(response.error); + if (currentContentType === 'text/plain') { + if (typeof body === 'string') { + return Promise.resolve(body); + } + if (body?.error) { + return Promise.resolve(body.error); + } + return Promise.resolve(String(body)); } - return Promise.resolve(JSON.stringify(response)); + + return Promise.resolve(JSON.stringify(body)); }, headers: new Headers({ - 'Content-Type': contentType - }) + 'Content-Type': currentContentType, + }), }); - } + }, })); -}; \ No newline at end of file +}; + +/** + * Helper function to extract the endpoint from a full URL + * e.g., "https://app.bentonow.com/api/v1/fetch/broadcasts" => "/fetch/broadcasts" + */ +export const getEndpointFromUrl = (url: string): string | null => { + if (!url) return null; + const match = url.match(/\/api\/v1(.+?)(\?|$)/); + return match ? match[1] : null; +}; diff --git a/__tests__/sequences/sequences.test.ts b/__tests__/sequences/sequences.test.ts new file mode 100644 index 0000000..534dea6 --- /dev/null +++ b/__tests__/sequences/sequences.test.ts @@ -0,0 +1,122 @@ +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 { EntityType } from '../../src/sdk/enums'; + +describe('BentoSequences', () => { + let analytics: Analytics; + + beforeEach(() => { + analytics = new Analytics(mockOptions); + }); + afterEach(() => { + resetMockFetchTracking(); + }); + + describe('getSequences', () => { + test('successfully retrieves sequences with email templates', async () => { + const mockSequences = { + data: [ + { + id: 'seq-1', + type: EntityType.SEQUENCES, + attributes: { + name: 'Onboarding Sequence', + created_at: '2024-01-01T00:00:00Z', + email_templates: [ + { id: 1, subject: 'Welcome Email', stats: { opened: 100, clicked: 50 } }, + { id: 2, subject: 'Getting Started', stats: { opened: 80, clicked: 40 } }, + ], + }, + }, + { + id: 'seq-2', + type: EntityType.SEQUENCES, + attributes: { + name: 'Re-engagement Sequence', + created_at: '2024-02-01T00:00:00Z', + email_templates: [{ id: 3, subject: 'We Miss You', stats: null }], + }, + }, + ], + }; + + setupMockFetch(mockSequences); + + const result = await analytics.V1.Sequences.getSequences(); + + expect(result).toHaveLength(2); + expect(result[0].attributes.name).toBe('Onboarding Sequence'); + expect(result[0].attributes.email_templates).toHaveLength(2); + expect(result[1].attributes.name).toBe('Re-engagement Sequence'); + }); + + test('uses correct endpoint for GET request', async () => { + setupMockFetch({ data: [] }); + + await analytics.V1.Sequences.getSequences(); + + expect(lastFetchUrl).toContain('/fetch/sequences'); + expect(lastFetchMethod).toBe('GET'); + }); + + test('returns empty array when response is empty object', async () => { + setupMockFetch({}); + + const result = await analytics.V1.Sequences.getSequences(); + + expect(result).toEqual([]); + }); + + test('returns empty array when response has no data', async () => { + setupMockFetch({ data: null }); + + const result = await analytics.V1.Sequences.getSequences(); + + expect(result).toEqual([]); + }); + + test('returns empty array when no sequences exist', async () => { + setupMockFetch({ data: [] }); + + const result = await analytics.V1.Sequences.getSequences(); + + expect(result).toHaveLength(0); + }); + + test('handles sequence with empty email templates', async () => { + const mockSequences = { + data: [ + { + id: 'seq-1', + type: EntityType.SEQUENCES, + attributes: { + name: 'Empty Sequence', + created_at: '2024-01-01T00:00:00Z', + email_templates: [], + }, + }, + ], + }; + + setupMockFetch(mockSequences); + + const result = await analytics.V1.Sequences.getSequences(); + + expect(result[0].attributes.email_templates).toHaveLength(0); + }); + + test('handles server error gracefully', async () => { + setupMockFetch({ error: 'Server Error' }, 500); + + await expect(analytics.V1.Sequences.getSequences()).rejects.toThrow(); + }); + + test('handles 401 unauthorized error', async () => { + setupMockFetch({ error: 'Unauthorized' }, 401); + + await expect(analytics.V1.Sequences.getSequences()).rejects.toThrow(); + }); + }); +}); diff --git a/__tests__/versions/v1/index.test.ts b/__tests__/versions/v1/index.test.ts index 2642c8c..9a9920d 100644 --- a/__tests__/versions/v1/index.test.ts +++ b/__tests__/versions/v1/index.test.ts @@ -11,7 +11,8 @@ import { BentoExperimental, BentoFields, BentoForms, - BentoSubscribers, BentoTags, + BentoSubscribers, + BentoTags, } from '../../../src/sdk'; // Define interface for custom fields @@ -77,8 +78,8 @@ describe('BentoAPIV1', () => { const customOptions = { ...mockOptions, clientOptions: { - baseUrl: 'https://custom.api.com' - } + baseUrl: 'https://custom.api.com', + }, }; const customApi = new BentoAPIV1(customOptions); const client = (customApi as any)._client; @@ -95,11 +96,11 @@ describe('BentoAPIV1', () => { type: '$custom', email: 'test@example.com', fields: { - customField: 'value' + customField: 'value', }, details: { - someDetail: 'value' - } + someDetail: 'value', + }, }); }); }); @@ -116,9 +117,9 @@ describe('BentoAPIV1', () => { email: 'test@example.com', fields: null, cached_tag_ids: [], - unsubscribed_at: null - } - } + unsubscribed_at: null, + }, + }, }; setupMockFetch(mockResponse); @@ -136,9 +137,9 @@ describe('BentoAPIV1', () => { email: 'test@example.com', fields: null, cached_tag_ids: [], - unsubscribed_at: new Date().toISOString() - } - } + unsubscribed_at: new Date().toISOString(), + }, + }, }; setupMockFetch(mockResponse); @@ -156,15 +157,15 @@ describe('BentoAPIV1', () => { email: 'test@example.com', fields: null, cached_tag_ids: ['tag-1'], - unsubscribed_at: null - } - } + unsubscribed_at: null, + }, + }, }; setupMockFetch(mockResponse); const result = await api.Commands.addTag({ email: 'test@example.com', - tagName: 'TestTag' + tagName: 'TestTag', }); expect(result?.attributes.cached_tag_ids).toContain('tag-1'); }); @@ -179,15 +180,15 @@ describe('BentoAPIV1', () => { email: 'test@example.com', fields: null, cached_tag_ids: [], - unsubscribed_at: null - } - } + unsubscribed_at: null, + }, + }, }; setupMockFetch(mockResponse); const result = await api.Commands.removeTag({ email: 'test@example.com', - tagName: 'TestTag' + tagName: 'TestTag', }); expect(result?.attributes.cached_tag_ids).toHaveLength(0); }); @@ -205,10 +206,10 @@ describe('BentoAPIV1', () => { key: 'testField', name: 'Test Field', created_at: new Date().toISOString(), - whitelisted: true - } - } - ] + whitelisted: true, + }, + }, + ], }; setupMockFetch(mockResponse); @@ -230,8 +231,8 @@ describe('BentoAPIV1', () => { const customOptions = { ...mockOptions, clientOptions: { - baseUrl: 'https://custom.api.com' - } + baseUrl: 'https://custom.api.com', + }, }; const customApi = new BentoAPIV1(customOptions); const client = (customApi as any)._client; @@ -249,11 +250,11 @@ describe('BentoAPIV1', () => { email: 'test@example.com', fields: { firstName: 'John', - lastName: 'Doe' + lastName: 'Doe', }, details: { - someDetail: 'value' - } + someDetail: 'value', + }, }); }); @@ -261,13 +262,15 @@ describe('BentoAPIV1', () => { setupMockFetch({ results: 1 }); const result = await api.Batch.importEvents({ - events: [{ - type: '$custom', - email: 'test@example.com', - details: { - firstName: 'John' - } - }] + events: [ + { + type: '$custom', + email: 'test@example.com', + details: { + firstName: 'John', + }, + }, + ], }); expect(result).toBe(1); @@ -280,13 +283,13 @@ describe('BentoAPIV1', () => { type: '$pageview', email: 'test@example.com', fields: { - firstName: 'John' + firstName: 'John', }, details: { url: 'https://example.com', title: 'Test Page', - referrer: 'https://google.com' - } + referrer: 'https://google.com', + }, }); }); @@ -297,13 +300,13 @@ describe('BentoAPIV1', () => { type: '$browser', email: 'test@example.com', fields: { - firstName: 'John' + firstName: 'John', }, details: { userAgent: 'Mozilla/5.0', language: 'en-US', - platform: 'MacOS' - } + platform: 'MacOS', + }, }); }); @@ -315,109 +318,17 @@ describe('BentoAPIV1', () => { type: '$custom', email: 'test@example.com', fields: { - firstName: 'John' + firstName: 'John', }, details: { - someDetail: 'value' + someDetail: 'value', }, - date: eventDate - }); - }); - }); - - // 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); - - const result = await api.Commands.subscribe({ email: 'test@example.com' }); - expect(result?.attributes.email).toBe('test@example.com'); - }); - - 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); - - const result = await api.Commands.unsubscribe({ email: 'test@example.com' }); - expect(result?.attributes.unsubscribed_at).not.toBeNull(); - }); - - 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); - - const result = await api.Commands.addTag({ - email: 'test@example.com', - tagName: 'TestTag' + date: eventDate, }); - expect(result?.attributes.cached_tag_ids).toContain('tag-1'); }); }); - // Field registration tests - describe('field registration', () => { - test('successfully registers fields', async () => { - const mockResponse = { - data: [ - { - id: 'field-1', - type: EntityType.VISITORS_FIELDS, - attributes: { - key: 'testField', - name: 'Test Field', - created_at: new Date().toISOString(), - whitelisted: true - } - } - ] - }; - setupMockFetch(mockResponse); - - const result = await api.Fields.createField({ key: 'testField' }); - expect(result).toBeDefined(); - expect(result?.[0]?.attributes.key).toBe('testField'); - }); - }); - - - describe('track method variations', () => { + describe('track method variations', () => { test('tracks event with all possible fields', async () => { setupMockFetch({ results: 1 }); @@ -428,14 +339,14 @@ describe('BentoAPIV1', () => { fields: { firstName: 'John', lastName: 'Doe', - customField: 'value' + customField: 'value', }, details: { detailField: 'value', nestedDetail: { - key: 'value' - } - } + key: 'value', + }, + }, }); }); @@ -445,7 +356,7 @@ describe('BentoAPIV1', () => { await api.track({ type: '$custom', email: 'test@example.com', - fields: {} + fields: {}, }); }); @@ -456,7 +367,7 @@ describe('BentoAPIV1', () => { type: '$custom', email: 'test@example.com', date: new Date('invalid date'), // Should trigger date validation - fields: {} + fields: {}, }); }); @@ -467,7 +378,7 @@ describe('BentoAPIV1', () => { type: '$custom', email: 'test@example.com', date: new Date('2025-12-31'), - fields: {} + fields: {}, }); }); @@ -478,7 +389,7 @@ describe('BentoAPIV1', () => { type: '$custom', email: 'test@example.com', date: new Date('1970-01-01'), - fields: {} + fields: {}, }); }); }); @@ -489,29 +400,33 @@ describe('BentoAPIV1', () => { setupMockFetch({ results: 1 }); await api.Batch.importEvents({ - events: [{ - type: BentoEvents.PURCHASE, - email: 'test@example.com', - date: new Date(), - details: { - unique: { - key: 'order_123' - }, - value: { - currency: 'USD', - amount: 99.99 + events: [ + { + type: BentoEvents.PURCHASE, + email: 'test@example.com', + date: new Date(), + details: { + unique: { + key: 'order_123', + }, + value: { + currency: 'USD', + amount: 99.99, + }, + cart: { + abandoned_checkout_url: 'https://example.com/cart', + items: [ + { + product_id: 'prod_123', + product_name: 'Test Product', + product_sku: 'SKU123', + custom_field: 'custom value', // Added to show string indexer + }, + ], + }, }, - cart: { - abandoned_checkout_url: 'https://example.com/cart', - items: [{ - product_id: 'prod_123', - product_name: 'Test Product', - product_sku: 'SKU123', - custom_field: 'custom value' // Added to show string indexer - }] - } - } - }] + }, + ], }); }); @@ -519,19 +434,21 @@ describe('BentoAPIV1', () => { setupMockFetch({ results: 1 }); await api.Batch.importEvents({ - events: [{ - type: BentoEvents.PURCHASE, - email: 'test@example.com', - details: { - unique: { - key: 'order_123' + events: [ + { + type: BentoEvents.PURCHASE, + email: 'test@example.com', + details: { + unique: { + key: 'order_123', + }, + value: { + currency: 'USD', + amount: 99.99, + }, }, - value: { - currency: 'USD', - amount: 99.99 - } - } - }] + }, + ], }); }); }); @@ -545,7 +462,7 @@ describe('BentoAPIV1', () => { type: '$custom', email: 'test@example.com', fields: {}, - details: {} + details: {}, }); }); @@ -560,11 +477,11 @@ describe('BentoAPIV1', () => { level1: { level2: { level3: { - value: 'deeply nested' - } - } - } - } + value: 'deeply nested', + }, + }, + }, + }, }); }); @@ -577,8 +494,8 @@ describe('BentoAPIV1', () => { fields: {}, details: { arrayField: ['value1', 'value2', 'value3'], - mixedArray: [1, 'string', { key: 'value' }] - } + mixedArray: [1, 'string', { key: 'value' }], + }, }); }); }); @@ -594,24 +511,24 @@ describe('BentoAPIV1', () => { type: '$custom', email: 'test1@example.com', details: { - customField: 'value1' - } + customField: 'value1', + }, }, { type: '$pageview', email: 'test2@example.com', details: { - url: 'https://example.com' - } + url: 'https://example.com', + }, }, { type: '$browser', email: 'test3@example.com', details: { - userAgent: 'Mozilla/5.0' - } - } - ] + userAgent: 'Mozilla/5.0', + }, + }, + ], }); }); @@ -624,22 +541,95 @@ describe('BentoAPIV1', () => { type: '$custom', email: 'test1@example.com', date: new Date(), - details: {} + details: {}, }, { type: '$custom', email: 'test2@example.com', date: new Date('2024-01-08T12:00:00Z'), - details: {} + details: {}, }, { type: '$custom', email: 'test3@example.com', date: new Date('2024-01-08'), - details: {} - } - ] + details: {}, + }, + ], }); }); }); -}); \ No newline at end of file + + // API V1 Helper Methods Testing + describe('helper methods', () => { + test('successfully tags subscriber through tagSubscriber method', async () => { + setupMockFetch({ results: 1 }); + + const result = await api.tagSubscriber({ + email: 'test@example.com', + tagName: 'premium', + }); + + expect(result).toBe(true); + }); + + test('successfully removes subscriber through removeSubscriber method', async () => { + setupMockFetch({ results: 1 }); + + const result = await api.removeSubscriber({ + email: 'test@example.com', + }); + + expect(result).toBe(true); + }); + + test('successfully adds subscriber through addSubscriber method', async () => { + setupMockFetch({ results: 1 }); + + const result = await api.addSubscriber({ + email: 'new@example.com', + }); + + expect(result).toBe(true); + }); + + test('successfully updates fields through updateFields method', async () => { + setupMockFetch({ results: 1 }); + + const result = await api.updateFields({ + email: 'test@example.com', + fields: { + firstName: 'Updated', + lastName: 'Name', + }, + }); + + expect(result).toBe(true); + }); + + test('successfully tracks purchase through trackPurchase method', async () => { + setupMockFetch({ results: 1 }); + + const result = await api.trackPurchase({ + email: 'test@example.com', + purchaseDetails: { + unique: { key: 'order-123' }, + value: { currency: 'USD', amount: 9999 }, + }, + }); + + expect(result).toBe(true); + }); + + test('returns false when helper method fails', async () => { + setupMockFetch({ results: 0 }); + + const result = await api.tagSubscriber({ + email: 'test@example.com', + tagName: 'premium', + }); + + expect(result).toBe(false); + }); + }); +}); diff --git a/__tests__/versions/v1/upsert.test.ts b/__tests__/versions/v1/upsert.test.ts index 0e7f5ac..9bf7934 100644 --- a/__tests__/versions/v1/upsert.test.ts +++ b/__tests__/versions/v1/upsert.test.ts @@ -12,10 +12,6 @@ describe('BentoAPIV1 - upsertSubscriber', () => { }); test('successfully creates new subscriber', async () => { - // Mock the import response - setupMockFetch({ results: 1 }); - - // Mock the get subscriber response for a new subscriber const mockSubscriber = { data: { id: 'new-sub-1', @@ -25,35 +21,33 @@ describe('BentoAPIV1 - upsertSubscriber', () => { email: 'new@example.com', fields: { firstName: 'John', - lastName: 'Doe' + lastName: 'Doe', }, cached_tag_ids: [], - unsubscribed_at: null - } - } + unsubscribed_at: null, + }, + }, }; - // Setup the second fetch call to return the subscriber - setupMockFetch(mockSubscriber); + setupMockFetch([ + { body: { results: 1 } }, + { body: mockSubscriber }, + ]); const result = await analytics.V1.upsertSubscriber({ email: 'new@example.com', fields: { firstName: 'John', - lastName: 'Doe' - } + lastName: 'Doe', + }, }); - expect(result).toBeDefined(); + expect(result).not.toBeNull(); expect(result?.attributes.email).toBe('new@example.com'); expect(result?.attributes.fields?.firstName).toBe('John'); }); test('successfully updates existing subscriber', async () => { - // Mock the import response - setupMockFetch({ results: 1 }); - - // Mock the get subscriber response for an existing subscriber const mockSubscriber = { data: { id: 'existing-sub-1', @@ -64,75 +58,74 @@ describe('BentoAPIV1 - upsertSubscriber', () => { fields: { firstName: 'Jane', lastName: 'Smith', - company: 'Updated Corp' + company: 'Updated Corp', }, cached_tag_ids: ['existing-tag'], - unsubscribed_at: null - } - } + unsubscribed_at: null, + }, + }, }; - // Setup the second fetch call to return the updated subscriber - setupMockFetch(mockSubscriber); + setupMockFetch([ + { body: { results: 1 } }, + { body: mockSubscriber }, + ]); const result = await analytics.V1.upsertSubscriber({ email: 'existing@example.com', fields: { firstName: 'Jane', lastName: 'Smith', - company: 'Updated Corp' - } + company: 'Updated Corp', + }, }); - expect(result).toBeDefined(); - expect(result?.attributes.email).toBe('existing@example.com'); + expect(result).not.toBeNull(); expect(result?.attributes.fields?.company).toBe('Updated Corp'); }); test('handles error during import', async () => { - setupMockFetch({ error: 'Import failed' }, 500); + setupMockFetch([{ body: { error: 'Import failed' }, status: 500 }]); await expect( analytics.V1.upsertSubscriber({ email: 'test@example.com', fields: { - firstName: 'Test' - } + firstName: 'Test', + }, }) ).rejects.toThrow(); }); test('handles error during subscriber fetch', async () => { - // First call succeeds (import) - setupMockFetch({ results: 1 }); - - // Second call fails (get subscriber) - setupMockFetch({ error: 'Fetch failed' }, 500); + setupMockFetch([ + { body: { results: 1 } }, + { body: { error: 'Fetch failed' }, status: 500 }, + ]); await expect( analytics.V1.upsertSubscriber({ email: 'test@example.com', fields: { - firstName: 'Test' - } + firstName: 'Test', + }, }) ).rejects.toThrow(); }); test('handles subscriber not found after import', async () => { - // First call succeeds (import) - setupMockFetch({ results: 1 }); - - // Second call returns null subscriber - setupMockFetch({ data: null }); + setupMockFetch([ + { body: { results: 1 } }, + { body: { data: null } }, + ]); const result = await analytics.V1.upsertSubscriber({ - email: 'test@example.com', + email: 'missing@example.com', fields: { - firstName: 'Test' - } + firstName: 'Missing', + }, }); expect(result).toBeNull(); }); -}); \ No newline at end of file +}); diff --git a/__tests__/workflows/workflows.test.ts b/__tests__/workflows/workflows.test.ts new file mode 100644 index 0000000..c37e72a --- /dev/null +++ b/__tests__/workflows/workflows.test.ts @@ -0,0 +1,128 @@ +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 { EntityType } from '../../src/sdk/enums'; + +describe('BentoWorkflows', () => { + let analytics: Analytics; + + beforeEach(() => { + analytics = new Analytics(mockOptions); + }); + afterEach(() => { + resetMockFetchTracking(); + }); + + describe('getWorkflows', () => { + test('successfully retrieves workflows with email templates', async () => { + const mockWorkflows = { + data: [ + { + id: 'wf-1', + type: EntityType.WORKFLOWS, + attributes: { + name: 'Welcome Workflow', + created_at: '2024-01-01T00:00:00Z', + email_templates: [ + { id: 1, subject: 'Welcome!', stats: { opened: 150, clicked: 75 } }, + { id: 2, subject: 'Next Steps', stats: { opened: 120, clicked: 60 } }, + ], + }, + }, + { + id: 'wf-2', + type: EntityType.WORKFLOWS, + attributes: { + name: 'Abandoned Cart Workflow', + created_at: '2024-02-01T00:00:00Z', + email_templates: [{ id: 3, subject: 'You left something behind', stats: null }], + }, + }, + ], + }; + + setupMockFetch(mockWorkflows); + + const result = await analytics.V1.Workflows.getWorkflows(); + + expect(result).toHaveLength(2); + expect(result[0].attributes.name).toBe('Welcome Workflow'); + expect(result[0].attributes.email_templates).toHaveLength(2); + expect(result[1].attributes.name).toBe('Abandoned Cart Workflow'); + }); + + test('uses correct endpoint for GET request', async () => { + setupMockFetch({ data: [] }); + + await analytics.V1.Workflows.getWorkflows(); + + expect(lastFetchUrl).toContain('/fetch/workflows'); + expect(lastFetchMethod).toBe('GET'); + }); + + test('returns empty array when response is empty object', async () => { + setupMockFetch({}); + + const result = await analytics.V1.Workflows.getWorkflows(); + + expect(result).toEqual([]); + }); + + test('returns empty array when response has no data', async () => { + setupMockFetch({ data: null }); + + const result = await analytics.V1.Workflows.getWorkflows(); + + expect(result).toEqual([]); + }); + + test('returns empty array when no workflows exist', async () => { + setupMockFetch({ data: [] }); + + const result = await analytics.V1.Workflows.getWorkflows(); + + expect(result).toHaveLength(0); + }); + + test('handles workflow with empty email templates', async () => { + const mockWorkflows = { + data: [ + { + id: 'wf-1', + type: EntityType.WORKFLOWS, + attributes: { + name: 'Empty Workflow', + created_at: '2024-01-01T00:00:00Z', + email_templates: [], + }, + }, + ], + }; + + setupMockFetch(mockWorkflows); + + const result = await analytics.V1.Workflows.getWorkflows(); + + expect(result[0].attributes.email_templates).toHaveLength(0); + }); + + test('handles server error gracefully', async () => { + setupMockFetch({ error: 'Server Error' }, 500); + + await expect(analytics.V1.Workflows.getWorkflows()).rejects.toThrow(); + }); + + test('handles 401 unauthorized error', async () => { + setupMockFetch({ error: 'Unauthorized' }, 401); + + await expect(analytics.V1.Workflows.getWorkflows()).rejects.toThrow(); + }); + + test('handles 429 rate limited error', async () => { + setupMockFetch({ error: 'Rate Limited' }, 429); + + await expect(analytics.V1.Workflows.getWorkflows()).rejects.toThrow(); + }); + }); +}); diff --git a/src/sdk/batch/index.ts b/src/sdk/batch/index.ts index 8abccab..84bf781 100644 --- a/src/sdk/batch/index.ts +++ b/src/sdk/batch/index.ts @@ -54,7 +54,8 @@ export class BentoBatch { `${this._url}/subscribers`, { subscribers: parameters.subscribers, - } + }, + { timeout: null } ); return result.results; @@ -80,9 +81,13 @@ export class BentoBatch { throw new TooManyEventsError(`You must send between 1 and 1,000 events.`); } - const result = await this._client.post(`${this._url}/events`, { - events: parameters.events, - }); + const result = await this._client.post( + `${this._url}/events`, + { + events: parameters.events, + }, + { timeout: null } + ); return result.results; } @@ -94,7 +99,7 @@ export class BentoBatch { * Each email must have a `to` address, a `from` address, a `subject`, an `html_body` * and `transactional: true`. * In addition you can add a `personalizations` object to provide - * liquid tsags that will be injected into the email. + * liquid tags that will be injected into the email. * * Returns the number of events that were imported. * diff --git a/src/sdk/broadcasts/index.ts b/src/sdk/broadcasts/index.ts index 4cbf61b..ca770ee 100644 --- a/src/sdk/broadcasts/index.ts +++ b/src/sdk/broadcasts/index.ts @@ -3,8 +3,9 @@ import type { DataResponse } from '../client/types'; import type { Broadcast, CreateBroadcastInput, EmailData } from './types'; export class BentoBroadcasts { - private readonly _url = '/broadcasts'; - private readonly _emailsUrl = '/emails'; + private readonly _fetchUrl = '/fetch/broadcasts'; + private readonly _batchUrl = '/batch/broadcasts'; + private readonly _emailsUrl = '/batch/emails'; constructor(private readonly _client: BentoClient) {} @@ -15,7 +16,7 @@ export class BentoBroadcasts { */ public async createEmails(emails: EmailData[]): Promise { const result = await this._client.post<{ results: number }>(this._emailsUrl, { - emails + emails, }); return result.results; } @@ -25,7 +26,7 @@ export class BentoBroadcasts { * @returns Promise */ public async getBroadcasts(): Promise { - const result = await this._client.get>(this._url); + const result = await this._client.get>(this._fetchUrl); return result.data ?? []; } @@ -35,9 +36,9 @@ export class BentoBroadcasts { * @returns Promise */ public async createBroadcast(broadcasts: CreateBroadcastInput[]): Promise { - const result = await this._client.post>(this._url, { - broadcasts + const result = await this._client.post>(this._batchUrl, { + broadcasts, }); return result.data ?? []; } -} \ No newline at end of file +} diff --git a/src/sdk/broadcasts/types.ts b/src/sdk/broadcasts/types.ts index 79f76a7..6315c85 100644 --- a/src/sdk/broadcasts/types.ts +++ b/src/sdk/broadcasts/types.ts @@ -24,6 +24,10 @@ export type Broadcast = BaseEntity; export type CreateBroadcastInput = Omit; +/** + * Email data for transactional emails. + * Note: This is the same structure as TransactionalEmail in batch/types.ts + */ export type EmailData = { to: string; from: string; @@ -31,4 +35,4 @@ export type EmailData = { html_body: string; transactional: boolean; personalizations?: Record; -}; \ No newline at end of file +}; diff --git a/src/sdk/client/errors.ts b/src/sdk/client/errors.ts index e2289c2..ee3fc9f 100644 --- a/src/sdk/client/errors.ts +++ b/src/sdk/client/errors.ts @@ -18,3 +18,10 @@ export class AuthorNotAuthorizedError extends Error { this.name = 'AuthorNotAuthorizedError'; } } + +export class RequestTimeoutError extends Error { + constructor(message = 'Request timed out') { + super(message); + this.name = 'RequestTimeoutError'; + } +} diff --git a/src/sdk/client/index.ts b/src/sdk/client/index.ts index 09f38d0..3556f1e 100644 --- a/src/sdk/client/index.ts +++ b/src/sdk/client/index.ts @@ -1,6 +1,15 @@ import fetch from 'cross-fetch'; import type { AnalyticsOptions, AuthenticationOptions } from '../interfaces'; -import { NotAuthorizedError, RateLimitedError, AuthorNotAuthorizedError } from './errors'; +import { + NotAuthorizedError, + RateLimitedError, + AuthorNotAuthorizedError, + RequestTimeoutError, +} from './errors'; + +interface RequestOptions { + timeout?: number | null; +} function encodeBase64(str: string): string { if (typeof btoa === 'function') { @@ -29,17 +38,20 @@ function encodeBase64(str: string): string { return output; } } + export class BentoClient { private readonly _headers: HeadersInit = {}; private readonly _baseUrl: string = 'https://app.bentonow.com/api/v1'; private readonly _siteUuid: string = ''; private readonly _logErrors: boolean = false; + private readonly _timeout: number = 30000; // 30 seconds default constructor(options: AnalyticsOptions) { this._baseUrl = options.clientOptions?.baseUrl || this._baseUrl; this._siteUuid = options.siteUuid; this._headers = this._extractHeaders(options.authentication, options.siteUuid); this._logErrors = options.logErrors || false; + this._timeout = options.clientOptions?.timeout ?? this._timeout; } /** @@ -50,24 +62,26 @@ export class BentoClient { * @param payload object * @returns Promise\ * */ - public get(endpoint: string, payload: Record = {}): Promise { - return new Promise((resolve, reject) => { - const queryParameters = this._getQueryParameters(payload); + public async get( + endpoint: string, + payload: Record = {}, + requestOptions: RequestOptions = {} + ): Promise { + const queryParameters = this._getQueryParameters(payload); + const url = `${this._baseUrl}${endpoint}?${queryParameters}`; - fetch(`${this._baseUrl}${endpoint}?${queryParameters}`, { + const timeoutMs = + requestOptions.timeout === undefined ? this._timeout : requestOptions.timeout; + const response = await this._fetchWithTimeout( + url, + { method: 'GET', headers: this._headers, - }) - .then(async (result) => { - if (this._isSuccessfulStatus(result.status)) { - return result.json(); - } - - throw await this._getErrorForResponse(result); - }) - .then((data) => resolve(data)) - .catch((error) => reject(error)); - }); + }, + timeoutMs + ); + + return this._handleResponse(response); } /** @@ -78,28 +92,30 @@ export class BentoClient { * @param payload object * @returns Promise\ * */ - public post(endpoint: string, payload: Record = {}): Promise { - return new Promise((resolve, reject) => { - const body = this._getBody(payload); + public async post( + endpoint: string, + payload: Record = {}, + requestOptions: RequestOptions = {} + ): Promise { + const body = this._getBody(payload); + const url = `${this._baseUrl}${endpoint}`; - fetch(`${this._baseUrl}${endpoint}`, { + const timeoutMs = + requestOptions.timeout === undefined ? this._timeout : requestOptions.timeout; + const response = await this._fetchWithTimeout( + url, + { method: 'POST', headers: { ...this._headers, 'Content-Type': 'application/json', }, body, - }) - .then(async (result) => { - if (this._isSuccessfulStatus(result.status)) { - return result.json(); - } - - throw await this._getErrorForResponse(result); - }) - .then((data) => resolve(data)) - .catch((error) => reject(error)); - }); + }, + timeoutMs + ); + + return this._handleResponse(response); } /** @@ -110,28 +126,85 @@ export class BentoClient { * @param payload object * @returns Promise\ * */ - public patch(endpoint: string, payload: Record = {}): Promise { - return new Promise((resolve, reject) => { - const body = this._getBody(payload); + public async patch( + endpoint: string, + payload: Record = {}, + requestOptions: RequestOptions = {} + ): Promise { + const body = this._getBody(payload); + const url = `${this._baseUrl}${endpoint}`; - fetch(`${this._baseUrl}${endpoint}`, { + const timeoutMs = + requestOptions.timeout === undefined ? this._timeout : requestOptions.timeout; + const response = await this._fetchWithTimeout( + url, + { method: 'PATCH', headers: { ...this._headers, 'Content-Type': 'application/json', }, body, - }) - .then(async (result) => { - if (this._isSuccessfulStatus(result.status)) { - return result.json(); - } - - throw await this._getErrorForResponse(result); - }) - .then((data) => resolve(data)) - .catch((error) => reject(error)); - }); + }, + timeoutMs + ); + + return this._handleResponse(response); + } + + /** + * Performs a fetch request with a configurable timeout. + * + * @param url The URL to fetch + * @param options Fetch options + * @returns Promise + */ + private async _fetchWithTimeout( + url: string, + options: RequestInit, + timeout: number | null + ): Promise { + if (timeout === null) { + return fetch(url, options); + } + + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), timeout); + + try { + const response = await fetch(url, { + ...options, + signal: controller.signal, + }); + return response; + } catch (error: unknown) { + if (error instanceof Error && error.name === 'AbortError') { + throw new RequestTimeoutError(`Request timed out after ${timeout}ms`); + } + throw error; + } finally { + clearTimeout(timeoutId); + } + } + + /** + * Handles the response from a fetch request, parsing JSON or throwing appropriate errors. + * + * @param response The fetch Response object + * @returns Promise The parsed response data + */ + private async _handleResponse(response: Response): Promise { + if (this._isSuccessfulStatus(response.status)) { + try { + const data = await response.json(); + return data as T; + } catch { + // If JSON parsing fails on a successful response, throw a descriptive error + throw new Error(`[${response.status}] - Invalid JSON response from server`); + } + } + + throw await this._getErrorForResponse(response); } /** @@ -182,7 +255,7 @@ export class BentoClient { const queryParameters = new URLSearchParams(); for (const [key, value] of Object.entries(body)) { - queryParameters.append(key, value); + queryParameters.append(key, String(value)); } return queryParameters.toString(); @@ -216,7 +289,7 @@ export class BentoClient { const contentType = response.headers.get('Content-Type'); let responseMessage = ''; - let json: any = null; + let json: Record | null = null; // Try to parse the response body based on content type try { @@ -244,7 +317,7 @@ export class BentoClient { // Check for author not authorized error in JSON response if (json && json.error === 'Author not authorized to send on this account') { - return new AuthorNotAuthorizedError(json.error); + return new AuthorNotAuthorizedError(json.error as string); } // If we have JSON but no specific error match, use the JSON string diff --git a/src/sdk/interfaces.ts b/src/sdk/interfaces.ts index bc5f081..39b0366 100644 --- a/src/sdk/interfaces.ts +++ b/src/sdk/interfaces.ts @@ -12,4 +12,8 @@ export interface AuthenticationOptions { export interface ClientOptions { baseUrl?: string; + /** + * Request timeout in milliseconds. Defaults to 30000 (30 seconds). + */ + timeout?: number; } diff --git a/src/sdk/sequences/index.ts b/src/sdk/sequences/index.ts index ebb9381..c7af586 100644 --- a/src/sdk/sequences/index.ts +++ b/src/sdk/sequences/index.ts @@ -10,12 +10,12 @@ export class BentoSequences { /** * Returns all of the sequences for the site, including their email templates. * - * @returns Promise\ + * @returns Promise\ */ - public async getSequences(): Promise { + public async getSequences(): Promise { const result = await this._client.get>(this._url); - if (Object.keys(result).length === 0 || !result.data) return null; - return result.data; + if (!result || Object.keys(result).length === 0) return []; + return result.data ?? []; } } diff --git a/src/sdk/workflows/index.ts b/src/sdk/workflows/index.ts index cf287be..6d8b8a3 100644 --- a/src/sdk/workflows/index.ts +++ b/src/sdk/workflows/index.ts @@ -10,12 +10,12 @@ export class BentoWorkflows { /** * Returns all of the workflows for the site, including their email templates. * - * @returns Promise\ + * @returns Promise\ */ - public async getWorkflows(): Promise { + public async getWorkflows(): Promise { const result = await this._client.get>(this._url); - if (Object.keys(result).length === 0 || !result.data) return null; - return result.data; + if (!result || Object.keys(result).length === 0) return []; + return result.data ?? []; } } diff --git a/src/versions/v1/index.ts b/src/versions/v1/index.ts index abcd1c4..2053767 100644 --- a/src/versions/v1/index.ts +++ b/src/versions/v1/index.ts @@ -23,7 +23,7 @@ import type { } from './types'; import { BentoBroadcasts } from '../../sdk/broadcasts'; import { BentoStats } from '../../sdk/stats'; -import { Subscriber } from '../../sdk/subscribers/types'; +import type { Subscriber } from '../../sdk/subscribers/types'; export class BentoAPIV1 { private readonly _client: BentoClient; @@ -255,9 +255,13 @@ export class BentoAPIV1 & { @@ -278,16 +282,18 @@ export class BentoAPIV1