diff --git a/src/baseApi.ts b/src/baseApi.ts index a2e36d2..36837ad 100644 --- a/src/baseApi.ts +++ b/src/baseApi.ts @@ -194,9 +194,17 @@ export class BaseApi { return '' } + // Values must be URL-encoded: cursors are base64 and carry '+', '/' and '=', and an unencoded + // '+' reaches the server as a space. Array items are encoded one by one and joined with a + // literal comma, which is how the API expects list params such as `ids`. + const encode = (value: number | string | boolean): string => encodeURIComponent(String(value)) const query = Object.keys(params) .filter(key => params[key] !== undefined && params[key] !== null) - .map(key => key + '=' + params[key]) + .map(key => { + const value = params[key] + const encoded = Array.isArray(value) ? value.map(encode).join(',') : encode(value) + return `${encodeURIComponent(key)}=${encoded}` + }) .join('&') return `?${query}` } diff --git a/tests/transactions.test.ts b/tests/transactions.test.ts index 98f3025..5d22cc7 100644 --- a/tests/transactions.test.ts +++ b/tests/transactions.test.ts @@ -279,3 +279,35 @@ describe('creditCardMetadata deserialization', () => { expect(second.billPostDate).toBeNull() }) }) + +describe('query string encoding', () => { + beforeEach(() => { + nock.cleanAll() + setupAuth() + }) + + it('URL-encodes a base64 cursor so "+", "/" and "=" reach the API intact', async () => { + const cursor = 'MjAyNi0xMC0wNFQxMjowMDowMC4wMDBafGE+Yi9j==' + const mock = nock(API_URL) + .get('/v2/transactions') + .query(actual => actual.after === cursor && actual.accountId === ACCOUNT_ID) + .reply(200, mockCursorPage(['tx-1'], null)) + + const client = new PluggyClient({ clientId: '123', clientSecret: '456' }) + await client.fetchTransactionsCursor(ACCOUNT_ID, { after: cursor }) + + expect(mock.isDone()).toBeTruthy() + }) + + it('keeps list params comma-separated', async () => { + const mock = nock(API_URL) + .get('/v2/transactions') + .query(actual => actual.ids === 'a,b' && actual.accountId === ACCOUNT_ID) + .reply(200, mockCursorPage([], null)) + + const client = new PluggyClient({ clientId: '123', clientSecret: '456' }) + await client.fetchTransactionsCursor(ACCOUNT_ID, { ids: ['a', 'b'] }) + + expect(mock.isDone()).toBeTruthy() + }) +})