Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,9 @@ jobs:
- name: Update package version
run: npm version $VERSION --no-git-tag-version

- name: Update CHANGELOG version
run: sed -i "s/^## [0-9]\+\.[0-9]\+\.[0-9]\+/## $VERSION/" CHANGELOG.md

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Limit changelog version bump to the latest entry

The sed command currently replaces every matching ## x.y.z heading in CHANGELOG.md, so once the changelog has more than one release section, running this workflow rewrites historical headings to the new $VERSION as well. This silently corrupts version history (e.g., multiple sections end up labeled with the same version) and can produce misleading changelog/release metadata; the substitution should target only the first matching heading.

Useful? React with 👍 / 👎.


- name: Generate Release Notes
id: release_notes
uses: actions/github-script@v6
Expand All @@ -61,7 +64,7 @@ jobs:
run: |
git config --local user.email "action@github.com"
git config --local user.name "GitHub Action"
git add package.json
git add package.json CHANGELOG.md
git commit -m "chore: bump version to $VERSION [skip ci]"
git tag v$VERSION
git push && git push --tags
Expand Down
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,5 @@ scratchpad.js
Bun.lockb
.idea
.env
docs/
scripts/
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# Changelog

## 1.0.5 - 2026-02-25

### Added
- Documented the Email Templates GET and PATCH helpers in the README, including code samples, error-handling guidance, and references to the public Email Templates API.
- Documented the Workflows helper, added pagination support for `getWorkflows`, and extended tests to cover query parameters and automation stats.
- Expanded the Sequences section with API references, pagination for `getSequences`, create/update examples, and regression tests around pagination.
124 changes: 68 additions & 56 deletions __tests__/client/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@ import { Analytics } from '../../src';
import { mockOptions } from '../helpers/mockClient';
import {
setupMockFetch,
cleanupMockFetch,
lastFetchSignal,
lastFetchUrl,
resetMockFetchTracking,
} from '../helpers/mockFetch';
import { NotAuthorizedError, RateLimitedError, RequestTimeoutError } from '../../src';

Expand All @@ -18,7 +18,7 @@ describe('BentoClient', () => {
globalScope = global;
});
afterEach(() => {
resetMockFetchTracking();
cleanupMockFetch();
});

describe('Base64 Encoding', () => {
Expand Down Expand Up @@ -161,11 +161,11 @@ describe('BentoClient', () => {

test('handles requests with query parameters', async () => {
let capturedUrl: string | null = null;
const savedFetch = globalThis.fetch;

// Setup mock that captures the URL
mock.module('cross-fetch', () => ({
default: (url: string, _options: RequestInit) => {
capturedUrl = url;
try {
globalThis.fetch = ((url: string | URL | Request) => {
capturedUrl = typeof url === 'string' ? url : url.toString();
return Promise.resolve({
status: 200,
ok: true,
Expand All @@ -174,25 +174,27 @@ describe('BentoClient', () => {
'Content-Type': 'application/json',
}),
});
},
}));

await analytics.V1.Forms.getResponses('test-form');

// Verify URL contains expected query parameters
expect(capturedUrl).not.toBeNull();
const url = new URL(capturedUrl!);
expect(url.searchParams.get('id')).toBe('test-form');
expect(url.searchParams.get('site_uuid')).toBe(mockOptions.siteUuid);
}) as typeof globalThis.fetch;

await analytics.V1.Forms.getResponses('test-form');

// Verify URL contains expected query parameters
expect(capturedUrl).not.toBeNull();
const url = new URL(capturedUrl!);
expect(url.searchParams.get('id')).toBe('test-form');
expect(url.searchParams.get('site_uuid')).toBe(mockOptions.siteUuid);
} finally {
globalThis.fetch = savedFetch;
}
});

test('converts non-string query parameter values to strings', async () => {
let capturedUrl: string | null = null;
const savedFetch = globalThis.fetch;

// Setup mock that captures the URL
mock.module('cross-fetch', () => ({
default: (url: string, _options: RequestInit) => {
capturedUrl = url;
try {
globalThis.fetch = ((url: string | URL | Request) => {
capturedUrl = typeof url === 'string' ? url : url.toString();
return Promise.resolve({
status: 200,
ok: true,
Expand All @@ -201,23 +203,25 @@ describe('BentoClient', () => {
'Content-Type': 'application/json',
}),
});
},
}));
}) as typeof globalThis.fetch;

// Test with a parameter that would have a non-string value
await analytics.V1.Forms.getResponses('test-form');
// 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!);
// 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');
// 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);
// Verify site_uuid is properly set
expect(url.searchParams.get('site_uuid')).toBe(mockOptions.siteUuid);
} finally {
globalThis.fetch = savedFetch;
}
});

test('omits undefined query parameters when forwarding SDK options', async () => {
Expand Down Expand Up @@ -259,12 +263,14 @@ describe('BentoClient', () => {

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) => {
const savedFetch = globalThis.fetch;

try {
// Mock fetch to simulate a timeout by using AbortController
globalThis.fetch = ((_url: string | URL | Request, options?: RequestInit) => {
return new Promise((_resolve, reject) => {
// Simulate the abort being triggered
if (options.signal) {
if (options?.signal) {
const abortHandler = () => {
const error = new Error('The operation was aborted');
error.name = 'AbortError';
Expand All @@ -274,25 +280,29 @@ describe('BentoClient', () => {
// 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);
}) as typeof globalThis.fetch;

// 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);
} finally {
globalThis.fetch = savedFetch;
}
});
});

describe('JSON Response Handling', () => {
test('throws error on invalid JSON response with success status', async () => {
mock.module('cross-fetch', () => ({
default: () => {
const savedFetch = globalThis.fetch;

try {
globalThis.fetch = (() => {
return Promise.resolve({
status: 200,
ok: true,
Expand All @@ -301,12 +311,14 @@ describe('BentoClient', () => {
'Content-Type': 'application/json',
}),
});
},
}));
}) as typeof globalThis.fetch;

await expect(analytics.V1.Tags.getTags()).rejects.toThrow(
'Invalid JSON response from server'
);
await expect(analytics.V1.Tags.getTags()).rejects.toThrow(
'Invalid JSON response from server'
);
} finally {
globalThis.fetch = savedFetch;
}
});
});
});
25 changes: 12 additions & 13 deletions __tests__/helpers/mockFetch.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import { expect, test, describe } from 'bun:test';
import { getEndpointFromUrl, setupMockFetch } from './mockFetch';
import { expect, test, describe, afterEach } from 'bun:test';
import { getEndpointFromUrl, setupMockFetch, cleanupMockFetch } from './mockFetch';

describe('mockFetch helpers', () => {
afterEach(() => {
cleanupMockFetch();
});

describe('getEndpointFromUrl', () => {
test('extracts endpoint from full URL', () => {
const url = 'https://app.bentonow.com/api/v1/fetch/broadcasts';
Expand Down Expand Up @@ -42,24 +46,21 @@ describe('mockFetch helpers', () => {
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 response = await fetch('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 response = await fetch('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 response = await fetch('https://app.bentonow.com/api/v1/test');
const text = await response.text();
expect(text).toBe('[object Object]');
});
Expand All @@ -69,12 +70,10 @@ describe('mockFetch helpers', () => {
{ 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');
const first = await fetch('https://app.bentonow.com/api/v1/test');
const second = await fetch('https://app.bentonow.com/api/v1/test');
const third = await fetch('https://app.bentonow.com/api/v1/test');

expect(await first.json()).toEqual({ value: 'first' });
expect(await second.json()).toEqual({ value: 'second' });
Expand Down
73 changes: 40 additions & 33 deletions __tests__/helpers/mockFetch.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
import { mock } from 'bun:test';

type MockResponseEntry = {
body: any;
status?: number;
Expand Down Expand Up @@ -31,12 +29,22 @@ export let lastFetchUrl: string | null = null;
export let lastFetchMethod: string | null = null;
export let lastFetchSignal: AbortSignal | null = null;

let originalFetch: typeof globalThis.fetch | undefined;

export const resetMockFetchTracking = (): void => {
lastFetchUrl = null;
lastFetchMethod = null;
lastFetchSignal = null;
};

export const cleanupMockFetch = (): void => {
if (originalFetch !== undefined) {
globalThis.fetch = originalFetch;
originalFetch = undefined;
}
resetMockFetchTracking();
};

export const setupMockFetch = (
response: any | MockResponseEntry[],
status = 200,
Expand All @@ -51,41 +59,40 @@ export const setupMockFetch = (
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 = {}) => {
// Capture URL, method, and AbortSignal for test verification
lastFetchUrl = url;
lastFetchMethod = options.method || 'GET';
lastFetchSignal = options.signal ?? null;
if (originalFetch === undefined) originalFetch = globalThis.fetch;
globalThis.fetch = ((url: string | URL | Request, options?: RequestInit) => {
// Capture URL, method, and AbortSignal for test verification
lastFetchUrl = typeof url === 'string' ? url : url.toString();
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;
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: currentStatus,
ok: currentStatus >= 200 && currentStatus < 300,
json: () => Promise.resolve(body),
text: () => {
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({
status: currentStatus,
ok: currentStatus >= 200 && currentStatus < 300,
json: () => Promise.resolve(body),
text: () => {
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(body));
},
headers: new Headers({
'Content-Type': currentContentType,
}),
});
},
}));
return Promise.resolve(JSON.stringify(body));
},
headers: new Headers({
'Content-Type': currentContentType,
}),
});
}) as typeof globalThis.fetch;
};

/**
Expand Down
Binary file modified bun.lockb
Binary file not shown.
Loading
Loading