From 252b8303c2a4f6d6a80a6ea4c982f539ffc232d7 Mon Sep 17 00:00:00 2001 From: d-klotz Date: Mon, 8 Jun 2026 11:49:17 -0300 Subject: [PATCH] fix(hubspot): listContacts omits null `after` so it can paginate past page 1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit listContacts always put `after` in the query string, defaulting it to null when no cursor was supplied. HubSpot's GET /crm/v3/objects/contacts rejects `after=null` (and `after=`) with a 400, so callers could never page past the first page — forcing them onto the search endpoint, which is hard-capped at 10,000 results and unusable for full-contact enumeration on large portals. Only include `after` in the query once a cursor is actually present (second page onward). First-page calls now send just limit + properties. Adds unit tests for first page (no after), subsequent pages, and null after. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/v1-ready/hubspot/api.js | 16 ++++-- .../hubspot/tests/listContacts.test.js | 50 +++++++++++++++++++ 2 files changed, 61 insertions(+), 5 deletions(-) create mode 100644 packages/v1-ready/hubspot/tests/listContacts.test.js diff --git a/packages/v1-ready/hubspot/api.js b/packages/v1-ready/hubspot/api.js index d0c0eda..9276811 100644 --- a/packages/v1-ready/hubspot/api.js +++ b/packages/v1-ready/hubspot/api.js @@ -213,13 +213,19 @@ class Api extends OAuth2Requester { properties = await this._propertiesList('contact'); } + const query = { limit, properties }; + // HubSpot rejects `after=null`/`after=` with a 400, so only send the + // paging cursor once we actually have one (i.e. from the second page + // onward). Without this guard, paging past the first page is impossible + // and callers are forced onto the search endpoint, which is hard-capped + // at 10,000 results. + if (after !== null && after !== undefined && after !== '') { + query.after = after; + } + const options = { url: this.baseUrl + this.URLs.contacts, - query: { - limit, - after, - properties, - } + query, }; return this._get(options); diff --git a/packages/v1-ready/hubspot/tests/listContacts.test.js b/packages/v1-ready/hubspot/tests/listContacts.test.js new file mode 100644 index 0000000..9a2dd53 --- /dev/null +++ b/packages/v1-ready/hubspot/tests/listContacts.test.js @@ -0,0 +1,50 @@ +/** + * Unit tests for Api.listContacts pagination query-building. + * + * The module's other tests (api.test.js) are live-OAuth integration tests that + * require real HubSpot credentials. These are fast unit tests that stub the + * underlying `_get` so we can assert the request query without a network call. + */ +const { Api } = require('../api'); + +describe('Api.listContacts pagination', () => { + function makeApi() { + const api = new Api({}); + api._get = jest.fn().mockResolvedValue({ results: [], paging: {} }); + return api; + } + + it('omits `after` from the query on the first page (no cursor)', async () => { + const api = makeApi(); + await api.listContacts({ limit: 100, properties: ['email'] }); + + expect(api._get).toHaveBeenCalledTimes(1); + const { query } = api._get.mock.calls[0][0]; + // HubSpot 400s on `after=null`; the first page must not send it at all. + expect(query).not.toHaveProperty('after'); + expect(query.limit).toBe(100); + expect(query.properties).toEqual(['email']); + }); + + it('sends `after` from the second page onward', async () => { + const api = makeApi(); + await api.listContacts({ + after: '12345', + limit: 100, + properties: ['email'], + }); + + expect(api._get.mock.calls[0][0].query.after).toBe('12345'); + }); + + it('treats null `after` as "no cursor"', async () => { + const api = makeApi(); + await api.listContacts({ + after: null, + limit: 100, + properties: ['email'], + }); + + expect(api._get.mock.calls[0][0].query).not.toHaveProperty('after'); + }); +});