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: '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('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: '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