diff --git a/package.json b/package.json index 28dfa6c..f2db428 100644 --- a/package.json +++ b/package.json @@ -19,8 +19,8 @@ } }, "scripts": { - "test": "jest --testPathIgnorePatterns=tests/integration.test.ts", - "test:integration": "jest --testPathPatterns=integration --runInBand", + "test": "jest --testPathIgnorePatterns=tests/integration", + "test:integration": "jest --testPathPatterns=tests/integration --runInBand", "build": "tsc -p tsconfig.build.json", "prepare": "pnpm build", "prettier-fix": "prettier --write \"src/**/*.ts\"", diff --git a/tests/integration.test.ts b/tests/integration.test.ts deleted file mode 100644 index f75510b..0000000 --- a/tests/integration.test.ts +++ /dev/null @@ -1,316 +0,0 @@ -/** - * Integration tests that validate SDK endpoints against Pluggy's sandbox environment. - * These tests create a real sandbox item, validate all endpoints, and clean up. - * - * Required environment variables: - * - PLUGGY_CLIENT_ID: Your Pluggy API client ID - * - PLUGGY_CLIENT_SECRET: Your Pluggy API client secret - * - * Run with: npm run test:integration - */ - -import { PluggyClient } from '../src/client' -import { Item, ItemStatus } from '../src/types' - -// Skip these tests if credentials are not provided -const runIntegrationTests = process.env.PLUGGY_CLIENT_ID && process.env.PLUGGY_CLIENT_SECRET - -const describeIf = runIntegrationTests ? describe : describe.skip - -// Pluggy Sandbox connector ID (Pluggy Bank) -const SANDBOX_CONNECTOR_ID = 0 - -// Sandbox credentials -const SANDBOX_USER = 'user-ok' -const SANDBOX_PASSWORD = 'password-ok' - -// Increase timeout for integration tests -jest.setTimeout(300000) // 5 minutes - -describeIf('Integration Tests', () => { - let client: PluggyClient - let item: Item | null = null - - beforeAll(async () => { - client = new PluggyClient({ - clientId: process.env.PLUGGY_CLIENT_ID!, - clientSecret: process.env.PLUGGY_CLIENT_SECRET!, - }) - - console.log('Creating sandbox item...') - - // Create item - item = await client.createItem(SANDBOX_CONNECTOR_ID, { - user: SANDBOX_USER, - password: SANDBOX_PASSWORD, - }) - - console.log(`Item created with ID: ${item.id}`) - - // Wait for item to finish syncing - const maxWaitTime = 5 * 60 * 1000 // 5 minutes - const startTime = Date.now() - - while (item.status !== 'UPDATED' && item.status !== 'LOGIN_ERROR') { - if (Date.now() - startTime > maxWaitTime) { - throw new Error('Item sync timed out after 5 minutes') - } - - await new Promise(resolve => setTimeout(resolve, 3000)) - item = await client.fetchItem(item.id) - console.log(`Item status: ${item.status}`) - } - - if (item.status !== 'UPDATED') { - throw new Error(`Item sync failed with status: ${item.status}`) - } - - console.log('Item sync completed successfully') - }) - - afterAll(async () => { - if (item) { - console.log(`Cleaning up - deleting item ${item.id}`) - await client.deleteItem(item.id) - console.log('Item deleted successfully') - } - }) - - describe('Connectors', () => { - it('fetchConnectors returns connectors', async () => { - const connectors = await client.fetchConnectors({ sandbox: true }) - - expect(connectors).toBeDefined() - expect(connectors.results.length).toBeGreaterThan(0) - console.log(`Found ${connectors.total} sandbox connectors`) - }) - - it('fetchConnector returns sandbox connector', async () => { - const connector = await client.fetchConnector(SANDBOX_CONNECTOR_ID) - - expect(connector).toBeDefined() - expect(connector.isSandbox).toBe(true) - console.log(`Connector: ${connector.name}`) - }) - }) - - describe('Items', () => { - it('fetchItem returns the created item', async () => { - expect(item).not.toBeNull() - - const fetchedItem = await client.fetchItem(item!.id) - - expect(fetchedItem).toBeDefined() - expect(fetchedItem.id).toBe(item!.id) - expect(fetchedItem.status).toBe('UPDATED') - console.log(`Item fetched: ${fetchedItem.id}, Status: ${fetchedItem.status}`) - }) - }) - - describe('Accounts', () => { - it('fetchAccounts returns accounts', async () => { - expect(item).not.toBeNull() - - const accounts = await client.fetchAccounts(item!.id) - - expect(accounts).toBeDefined() - expect(accounts.results.length).toBeGreaterThan(0) - - for (const account of accounts.results) { - console.log(`Account: ${account.id}, Number: ${account.number}, Balance: ${account.balance}`) - - // Verify we can fetch individual account - const fetchedAccount = await client.fetchAccount(account.id) - expect(fetchedAccount).toBeDefined() - expect(fetchedAccount.id).toBe(account.id) - } - }) - }) - - describe('Transactions', () => { - it('fetchTransactionsCursor returns cursor-paged transactions', async () => { - expect(item).not.toBeNull() - - const accounts = await client.fetchAccounts(item!.id) - expect(accounts.results.length).toBeGreaterThan(0) - - const account = accounts.results[0] - const oneYearAgo = new Date() - oneYearAgo.setFullYear(oneYearAgo.getFullYear() - 1) - - const page = await client.fetchTransactionsCursor(account.id, { - dateFrom: oneYearAgo.toISOString().split('T')[0], - }) - - expect(page).toBeDefined() - expect(Array.isArray(page.results)).toBe(true) - expect('next' in page).toBe(true) - console.log( - `fetchTransactionsCursor: ${page.results.length} results, next=${page.next ?? 'null'}` - ) - - if (page.results.length > 0) { - const tx = page.results[0] - const fetchedTx = await client.fetchTransaction(tx.id) - expect(fetchedTx).toBeDefined() - expect(fetchedTx.id).toBe(tx.id) - } - }) - - it('fetchAllTransactions returns all transactions via cursor pagination', async () => { - expect(item).not.toBeNull() - - const accounts = await client.fetchAccounts(item!.id) - expect(accounts.results.length).toBeGreaterThan(0) - - const account = accounts.results[0] - const oneYearAgo = new Date() - oneYearAgo.setFullYear(oneYearAgo.getFullYear() - 1) - - const allTransactions = await client.fetchAllTransactions(account.id, { - dateFrom: oneYearAgo.toISOString().split('T')[0], - }) - - expect(Array.isArray(allTransactions)).toBe(true) - console.log(`fetchAllTransactions (cursor): ${allTransactions.length} total transactions`) - - if (allTransactions.length > 0) { - const ids = allTransactions.map(t => t.id) - const uniqueIds = new Set(ids) - expect(uniqueIds.size).toBe(ids.length) // no duplicates - } - }) - }) - - describe('Investments', () => { - it('fetchInvestments returns investments', async () => { - expect(item).not.toBeNull() - - const investments = await client.fetchInvestments(item!.id) - - expect(investments).toBeDefined() - console.log(`Found ${investments.total} investments`) - - for (const investment of investments.results) { - console.log(`Investment: ${investment.id}, Name: ${investment.name}, Balance: ${investment.balance}`) - - // Verify we can fetch individual investment - const fetchedInvestment = await client.fetchInvestment(investment.id) - expect(fetchedInvestment).toBeDefined() - expect(fetchedInvestment.id).toBe(investment.id) - - // Fetch investment transactions - const investmentTxs = await client.fetchInvestmentTransactions(investment.id) - console.log(` Investment transactions: ${investmentTxs.total}`) - } - }) - }) - - describe('Identity', () => { - it('fetchIdentityByItemId returns identity', async () => { - expect(item).not.toBeNull() - - const identity = await client.fetchIdentityByItemId(item!.id) - - expect(identity).toBeDefined() - console.log(`Identity: ${identity.fullName}, Document: ${identity.document}`) - - // Verify we can fetch by identity ID - const fetchedIdentity = await client.fetchIdentity(identity.id) - expect(fetchedIdentity).toBeDefined() - expect(fetchedIdentity.id).toBe(identity.id) - }) - }) - - describe('Consents', () => { - it('fetchConsents returns consents', async () => { - expect(item).not.toBeNull() - - const consents = await client.fetchConsents(item!.id) - - expect(consents).toBeDefined() - console.log(`Found ${consents.total} consents for item`) - - for (const consent of consents.results) { - console.log(`Consent: ${consent.id}, Created: ${consent.createdAt}, Expires: ${consent.expiresAt}`) - } - }) - }) - - describe('Loans', () => { - it('fetchLoans returns loans', async () => { - expect(item).not.toBeNull() - - const loans = await client.fetchLoans(item!.id) - - expect(loans).toBeDefined() - console.log(`Found ${loans.total} loans for item`) - - for (const loan of loans.results) { - console.log(`Loan: ${loan.id}, Product: ${loan.productName}, Amount: ${loan.contractAmount}`) - } - }) - }) - - describe('Categories', () => { - it('fetchCategories returns categories', async () => { - const categories = await client.fetchCategories() - - expect(categories).toBeDefined() - expect(categories.results.length).toBeGreaterThan(0) - console.log(`Found ${categories.total} categories`) - - const category = categories.results[0] - const fetchedCategory = await client.fetchCategory(category.id) - expect(fetchedCategory).toBeDefined() - expect(fetchedCategory.id).toBe(category.id) - }) - }) - - describe('Connect Token', () => { - it('createConnectToken returns token', async () => { - const response = await client.createConnectToken(undefined, { clientUserId: 'integration-test' }) - - expect(response).toBeDefined() - expect(response.accessToken).toBeDefined() - expect(response.accessToken.length).toBeGreaterThan(0) - console.log(`Connect token created successfully (length: ${response.accessToken.length})`) - }) - }) - - describe('Webhooks', () => { - it('webhook operations work correctly', async () => { - // Create webhook - const webhook = await client.createWebhook( - 'item/updated', - 'https://example.com/webhook-test' - ) - - expect(webhook).toBeDefined() - console.log(`Webhook created: ${webhook.id}`) - - try { - // Fetch webhooks - const webhooks = await client.fetchWebhooks() - expect(webhooks).toBeDefined() - expect(webhooks.results.some(w => w.id === webhook.id)).toBe(true) - - // Fetch single webhook - const fetchedWebhook = await client.fetchWebhook(webhook.id) - expect(fetchedWebhook).toBeDefined() - expect(fetchedWebhook.id).toBe(webhook.id) - - // Update webhook - const updatedWebhook = await client.updateWebhook(webhook.id, { - url: 'https://example.com/webhook-test-updated', - event: 'all', - }) - expect(updatedWebhook).toBeDefined() - } finally { - // Clean up - delete webhook - await client.deleteWebhook(webhook.id) - console.log(`Webhook deleted: ${webhook.id}`) - } - }) - }) -}) diff --git a/tests/integration/README.md b/tests/integration/README.md new file mode 100644 index 0000000..8ad8564 --- /dev/null +++ b/tests/integration/README.md @@ -0,0 +1,59 @@ +# Integration tests + +These specs run the SDK against the real Pluggy API (sandbox connector) to catch drift between the SDK and the live service. Patterned after [`plaid/plaid-node`'s test suite](https://github.com/plaid/plaid-node/tree/master/test) — one spec per resource, each owning its own fixture lifecycle. + +## Running locally + +Set credentials in `.env.test` at the repo root: + +``` +PLUGGY_CLIENT_ID=... +PLUGGY_CLIENT_SECRET=... +# Optional override; defaults to https://api.pluggy.ai +PLUGGY_API_URL=https://api.pluggy.ai +``` + +Then: + +```bash +pnpm test:integration +``` + +Without credentials each spec auto-skips via `describe.skip`, so the suite is safe to run in forks. + +## Layout + +| File | What it covers | Creates an item? | +|------|----------------|------------------| +| `helpers.ts` | Client factory, sandbox item lifecycle, retry helper, error-capture helper | — | +| `connectors.test.ts` | `fetchConnectors`, `fetchConnector`, `validateParameters` | No | +| `categories.test.ts` | `fetchCategories`, `fetchCategory` | No | +| `connectToken.test.ts` | `createConnectToken` | No | +| `webhooks.test.ts` | Webhook CRUD (always cleans up its own webhook) | No | +| `items.test.ts` | `fetchItem` against a freshly-created sandbox item | Yes | +| `accounts.test.ts` | `fetchAccounts`, `fetchAccount`, `fetchAccountStatements` | Yes | +| `transactions.test.ts` | Cursor pagination, `fetchTransaction`, `updateTransactionCategory` | Yes | +| `investments.test.ts` | Investments + `fetchAllInvestmentTransactions` dedup | Yes | +| `identity.test.ts` | `fetchIdentityByItemId`, `fetchIdentity` | Yes | +| `consents.test.ts` | `fetchConsents`, `fetchConsent` | Yes | +| `loans.test.ts` | `fetchLoans`, `fetchLoan` | Yes | +| `bills.test.ts` | `fetchCreditCardBills`, `fetchCreditCardBill` (skip if sandbox has no CREDIT account) | Yes | +| `errors.test.ts` | 4xx responses for known-bad ids on `fetchItem` / `fetchAccount` / `fetchTransaction` / `fetchConnector` | No | + +## Fixture pattern + +Each spec that needs server state calls `createSandboxItem(client)` in `beforeAll` — this creates a sandbox item, polls until status is `UPDATED`, and returns it. The matching `afterAll` calls `deleteItemSafely` so cleanup never masks the real failure (and orphans are surfaced to stderr). + +Plaid's suite does not clean up — it relies on the sandbox being cheap. Pluggy items are heavier, so cleanup is mandatory. + +## Resource tagging + +Every test run gets a `RUN_ID` derived from `GITHUB_RUN_ID` (in CI) or `local-` (locally). Resources created during the run that accept a free-form identifier (`createConnectToken`'s `clientUserId`, webhook URLs) are tagged `integration-test-`. If a future failure leaks resources, they can be identified and swept by the tag. + +## CI + +The `.github/workflows/integration-tests.yml` workflow runs the suite nightly (cron) and on manual `workflow_dispatch`. Credentials come from repo secrets `PLUGGY_CLIENT_ID` / `PLUGGY_CLIENT_SECRET`. + +## Wall time + +Per-suite item creation costs one full sandbox sync (~2–4 minutes). With `--runInBand` the suite takes roughly `count(suites with items) × sync_time`. This is intentional: per-suite isolation guarantees one broken test does not poison the next. diff --git a/tests/integration/accounts.test.ts b/tests/integration/accounts.test.ts new file mode 100644 index 0000000..e9b5324 --- /dev/null +++ b/tests/integration/accounts.test.ts @@ -0,0 +1,60 @@ +import { PluggyClient } from '../../src/client' +import { Item } from '../../src/types' +import { + createClient, + createSandboxItem, + deleteItemSafely, + describeIntegration, + INTEGRATION_TIMEOUT_MS, +} from './helpers' + +jest.setTimeout(INTEGRATION_TIMEOUT_MS) + +describeIntegration('Accounts (integration)', () => { + let client: PluggyClient + let item: Item + + beforeAll(async () => { + client = createClient() + item = await createSandboxItem(client) + }) + + afterAll(async () => { + if (item) { + await deleteItemSafely(client, item.id) + } + }) + + it('fetchAccounts returns accounts linked to the item', async () => { + const page = await client.fetchAccounts(item.id) + + expect(page.results.length).toBeGreaterThan(0) + for (const account of page.results) { + expect(account.id).toBeTruthy() + expect(account.itemId).toBe(item.id) + expect(typeof account.balance).toBe('number') + expect(account.type).toBeTruthy() + expect(account.currencyCode).toBeTruthy() + } + }) + + it('fetchAccount returns the same account when fetched by id', async () => { + const page = await client.fetchAccounts(item.id) + const first = page.results[0] + + const fetched = await client.fetchAccount(first.id) + expect(fetched.id).toBe(first.id) + expect(fetched.itemId).toBe(first.itemId) + expect(fetched.type).toBe(first.type) + }) + + it('fetchAccountStatements does not throw for a valid account', async () => { + const page = await client.fetchAccounts(item.id) + const first = page.results[0] + + // Sandbox may not produce statements; we only verify the call succeeds + // and returns a well-formed page response. + const statements = await client.fetchAccountStatements(first.id) + expect(Array.isArray(statements.results)).toBe(true) + }) +}) diff --git a/tests/integration/bills.test.ts b/tests/integration/bills.test.ts new file mode 100644 index 0000000..93a49a4 --- /dev/null +++ b/tests/integration/bills.test.ts @@ -0,0 +1,57 @@ +import { PluggyClient } from '../../src/client' +import { Item } from '../../src/types' +import { + createClient, + createSandboxItem, + deleteItemSafely, + describeIntegration, + INTEGRATION_TIMEOUT_MS, +} from './helpers' + +jest.setTimeout(INTEGRATION_TIMEOUT_MS) + +describeIntegration('Credit card bills (integration)', () => { + let client: PluggyClient + let item: Item + + beforeAll(async () => { + client = createClient() + item = await createSandboxItem(client) + }) + + afterAll(async () => { + if (item) { + await deleteItemSafely(client, item.id) + } + }) + + it('fetchCreditCardBills returns bills for a credit card account if any exist', async () => { + const accounts = await client.fetchAccounts(item.id) + const creditCard = accounts.results.find(a => a.type === 'CREDIT') + if (!creditCard) { + // Sandbox returns a credit card account in some configurations but + // not all. If absent, skip silently rather than fail — coverage of + // the endpoint shape happens whenever a CREDIT account is present. + return + } + + const bills = await client.fetchCreditCardBills(creditCard.id) + expect(Array.isArray(bills.results)).toBe(true) + for (const bill of bills.results) { + expect(bill.id).toBeTruthy() + } + }) + + it('fetchCreditCardBill returns the same bill when fetched by id', async () => { + const accounts = await client.fetchAccounts(item.id) + const creditCard = accounts.results.find(a => a.type === 'CREDIT') + if (!creditCard) return + + const bills = await client.fetchCreditCardBills(creditCard.id) + if (bills.results.length === 0) return + + const first = bills.results[0] + const fetched = await client.fetchCreditCardBill(first.id) + expect(fetched.id).toBe(first.id) + }) +}) diff --git a/tests/integration/categories.test.ts b/tests/integration/categories.test.ts new file mode 100644 index 0000000..442871d --- /dev/null +++ b/tests/integration/categories.test.ts @@ -0,0 +1,31 @@ +import { PluggyClient } from '../../src/client' +import { createClient, describeIntegration, INTEGRATION_TIMEOUT_MS } from './helpers' + +jest.setTimeout(INTEGRATION_TIMEOUT_MS) + +describeIntegration('Categories (integration)', () => { + let client: PluggyClient + + beforeAll(() => { + client = createClient() + }) + + it('fetchCategories returns the catalog with required fields', async () => { + const page = await client.fetchCategories() + + expect(page.results.length).toBeGreaterThan(0) + for (const category of page.results) { + expect(category.id).toBeTruthy() + expect(typeof category.description).toBe('string') + } + }) + + it('fetchCategory returns the same category when fetched by id', async () => { + const page = await client.fetchCategories() + const sample = page.results[0] + + const fetched = await client.fetchCategory(sample.id) + expect(fetched.id).toBe(sample.id) + expect(fetched.description).toBe(sample.description) + }) +}) diff --git a/tests/integration/connectToken.test.ts b/tests/integration/connectToken.test.ts new file mode 100644 index 0000000..b8bc58a --- /dev/null +++ b/tests/integration/connectToken.test.ts @@ -0,0 +1,24 @@ +import { PluggyClient } from '../../src/client' +import { + createClient, + describeIntegration, + INTEGRATION_TIMEOUT_MS, + TEST_TAG, +} from './helpers' + +jest.setTimeout(INTEGRATION_TIMEOUT_MS) + +describeIntegration('Connect Token (integration)', () => { + let client: PluggyClient + + beforeAll(() => { + client = createClient() + }) + + it('createConnectToken returns a non-empty accessToken', async () => { + const response = await client.createConnectToken(undefined, { clientUserId: TEST_TAG }) + + expect(typeof response.accessToken).toBe('string') + expect(response.accessToken.length).toBeGreaterThan(0) + }) +}) diff --git a/tests/integration/connectors.test.ts b/tests/integration/connectors.test.ts new file mode 100644 index 0000000..cf49cc5 --- /dev/null +++ b/tests/integration/connectors.test.ts @@ -0,0 +1,56 @@ +import { PluggyClient } from '../../src/client' +import { + createClient, + describeIntegration, + INTEGRATION_TIMEOUT_MS, + SANDBOX_CONNECTOR_ID, + SANDBOX_CREDENTIALS, +} from './helpers' + +jest.setTimeout(INTEGRATION_TIMEOUT_MS) + +describeIntegration('Connectors (integration)', () => { + let client: PluggyClient + + beforeAll(() => { + client = createClient() + }) + + describe('fetchConnectors', () => { + it('returns the sandbox connector when filtered by sandbox=true', async () => { + const page = await client.fetchConnectors({ sandbox: true }) + + expect(page.results.length).toBeGreaterThan(0) + const sandbox = page.results.find(c => c.id === SANDBOX_CONNECTOR_ID) + expect(sandbox).toBeDefined() + expect(sandbox!.isSandbox).toBe(true) + }) + + it('returns at least one production connector when sandbox filter omitted', async () => { + const page = await client.fetchConnectors() + expect(page.results.length).toBeGreaterThan(0) + // Without the filter we should see real connectors (isSandbox=false). + expect(page.results.some(c => c.isSandbox === false)).toBe(true) + }) + }) + + describe('fetchConnector', () => { + it('returns the sandbox connector with the expected shape', async () => { + const connector = await client.fetchConnector(SANDBOX_CONNECTOR_ID) + + expect(connector.id).toBe(SANDBOX_CONNECTOR_ID) + expect(connector.isSandbox).toBe(true) + expect(typeof connector.name).toBe('string') + expect(connector.name.length).toBeGreaterThan(0) + expect(Array.isArray(connector.credentials)).toBe(true) + }) + }) + + describe('validateParameters', () => { + it('accepts valid sandbox credentials', async () => { + const result = await client.validateParameters(SANDBOX_CONNECTOR_ID, SANDBOX_CREDENTIALS) + expect(Array.isArray(result.errors)).toBe(true) + expect(result.errors).toEqual([]) + }) + }) +}) diff --git a/tests/integration/consents.test.ts b/tests/integration/consents.test.ts new file mode 100644 index 0000000..2926e71 --- /dev/null +++ b/tests/integration/consents.test.ts @@ -0,0 +1,45 @@ +import { PluggyClient } from '../../src/client' +import { Item } from '../../src/types' +import { + createClient, + createSandboxItem, + deleteItemSafely, + describeIntegration, + INTEGRATION_TIMEOUT_MS, +} from './helpers' + +jest.setTimeout(INTEGRATION_TIMEOUT_MS) + +describeIntegration('Consents (integration)', () => { + let client: PluggyClient + let item: Item + + beforeAll(async () => { + client = createClient() + item = await createSandboxItem(client) + }) + + afterAll(async () => { + if (item) { + await deleteItemSafely(client, item.id) + } + }) + + it('fetchConsents returns a well-formed page', async () => { + const page = await client.fetchConsents(item.id) + expect(Array.isArray(page.results)).toBe(true) + + for (const consent of page.results) { + expect(consent.id).toBeTruthy() + } + }) + + it('fetchConsent returns the same record when fetched by id', async () => { + const page = await client.fetchConsents(item.id) + if (page.results.length === 0) return + + const first = page.results[0] + const fetched = await client.fetchConsent(first.id) + expect(fetched.id).toBe(first.id) + }) +}) diff --git a/tests/integration/errors.test.ts b/tests/integration/errors.test.ts new file mode 100644 index 0000000..b23a107 --- /dev/null +++ b/tests/integration/errors.test.ts @@ -0,0 +1,45 @@ +import { PluggyClient } from '../../src/client' +import { + captureRejection, + createClient, + describeIntegration, + INTEGRATION_TIMEOUT_MS, + NON_EXISTENT_UUID, + PluggyErrorBody, +} from './helpers' + +jest.setTimeout(INTEGRATION_TIMEOUT_MS) + +describeIntegration('Error paths (integration)', () => { + let client: PluggyClient + + beforeAll(() => { + client = createClient() + }) + + it('fetchItem rejects when the id does not exist', async () => { + const body = await captureRejection(client.fetchItem(NON_EXISTENT_UUID)) + expect(body).toBeDefined() + expect(body.message || body.code).toBeTruthy() + }) + + it('fetchAccount rejects when the id does not exist', async () => { + const body = await captureRejection(client.fetchAccount(NON_EXISTENT_UUID)) + expect(body).toBeDefined() + expect(body.message || body.code).toBeTruthy() + }) + + it('fetchTransaction rejects when the id does not exist', async () => { + const body = await captureRejection( + client.fetchTransaction(NON_EXISTENT_UUID) + ) + expect(body).toBeDefined() + expect(body.message || body.code).toBeTruthy() + }) + + it('fetchConnector rejects when the connector id does not exist', async () => { + const body = await captureRejection(client.fetchConnector(99_999_999)) + expect(body).toBeDefined() + expect(body.message || body.code).toBeTruthy() + }) +}) diff --git a/tests/integration/helpers.ts b/tests/integration/helpers.ts new file mode 100644 index 0000000..0facb57 --- /dev/null +++ b/tests/integration/helpers.ts @@ -0,0 +1,150 @@ +/** + * Shared helpers for integration tests. + * + * Patterned after plaid-node's test/clientHelper.ts: a client factory, a + * fixture builder that creates a sandbox item and waits for it to sync, + * a defensive cleanup helper, and a small retry utility for endpoints + * that are eventually consistent in sandbox. + */ +import { PluggyClient } from '../../src/client' +import { Item } from '../../src/types' + +/** Pluggy Bank — the sandbox connector. */ +export const SANDBOX_CONNECTOR_ID = 0 + +/** Sandbox login that produces an UPDATED item with full aggregation data. */ +export const SANDBOX_CREDENTIALS = { user: 'user-ok', password: 'password-ok' } + +/** + * Identifier shared across all resources created in a single test run. + * Lets us spot orphans from failed runs (`integration-test-` shows + * up in connectToken's clientUserId, webhook URLs, etc.). + */ +export const RUN_ID = + process.env.GITHUB_RUN_ID || process.env.GITHUB_RUN_NUMBER || `local-${Date.now()}` + +/** Tag every test-owned resource so orphans can be identified later. */ +export const TEST_TAG = `integration-test-${RUN_ID}` + +/** Max time we wait for an item to leave the pending sync states. */ +const ITEM_SYNC_TIMEOUT_MS = 5 * 60 * 1000 +const ITEM_SYNC_POLL_INTERVAL_MS = 3_000 + +/** True when credentials are present — drives `describeIntegration`. */ +export const HAS_CREDENTIALS = Boolean( + process.env.PLUGGY_CLIENT_ID && process.env.PLUGGY_CLIENT_SECRET +) + +/** `describe` that skips when credentials are missing (local dev, forks). */ +export const describeIntegration = HAS_CREDENTIALS ? describe : describe.skip + +export function createClient(): PluggyClient { + return new PluggyClient({ + clientId: process.env.PLUGGY_CLIENT_ID!, + clientSecret: process.env.PLUGGY_CLIENT_SECRET!, + baseUrl: process.env.PLUGGY_API_URL, + }) +} + +/** + * Create a sandbox item and poll until it reaches a terminal status. + * Throws on timeout or on any non-UPDATED terminal state. + */ +export async function createSandboxItem(client: PluggyClient): Promise { + let item = await client.createItem(SANDBOX_CONNECTOR_ID, SANDBOX_CREDENTIALS) + const deadline = Date.now() + ITEM_SYNC_TIMEOUT_MS + + while (item.status !== 'UPDATED' && item.status !== 'LOGIN_ERROR') { + if (Date.now() > deadline) { + // Best-effort cleanup before bubbling the error. + await deleteItemSafely(client, item.id) + throw new Error(`Item ${item.id} sync timed out after ${ITEM_SYNC_TIMEOUT_MS}ms`) + } + await sleep(ITEM_SYNC_POLL_INTERVAL_MS) + item = await client.fetchItem(item.id) + } + + if (item.status !== 'UPDATED') { + await deleteItemSafely(client, item.id) + throw new Error(`Item ${item.id} reached terminal status ${item.status}, expected UPDATED`) + } + + return item +} + +/** + * Delete an item without throwing — used in `afterAll` so cleanup never + * masks the real failure. Logs to stderr so leaks are visible in CI. + */ +export async function deleteItemSafely(client: PluggyClient, itemId: string): Promise { + try { + await client.deleteItem(itemId) + } catch (err) { + // eslint-disable-next-line no-console + console.error(`[integration] failed to delete item ${itemId}:`, err) + } +} + +export function sleep(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)) +} + +/** + * Retry `fn` up to `attempts` times with a fixed delay between attempts. + * Pluggy sandbox is eventually consistent for some endpoints (e.g. + * transactions surface after the item flips UPDATED but may lag a beat). + */ +export async function retry( + fn: () => Promise, + options: { attempts?: number; delayMs?: number; description?: string } = {} +): Promise { + const { attempts = 10, delayMs = 1_000, description = 'operation' } = options + let lastError: unknown + for (let i = 0; i < attempts; i++) { + try { + return await fn() + } catch (err) { + lastError = err + if (i < attempts - 1) { + await sleep(delayMs) + } + } + } + throw new Error( + `${description} failed after ${attempts} attempts: ${(lastError as Error)?.message ?? lastError}` + ) +} + +/** + * Assert that a promise rejects with an API error body. Pluggy's BaseApi + * rejects with `error.response.body` — the parsed JSON error body, NOT a + * thrown Error with statusCode — so we can't assert HTTP status directly. + * Instead we capture the rejection value and let the caller inspect it. + * + * Returns the rejection body for further assertions. + */ +export async function captureRejection(promise: Promise): Promise { + try { + await promise + } catch (err) { + return err as T + } + throw new Error('expected promise to reject, but it resolved') +} + +/** + * Pluggy error bodies typically include at least a `message` field and + * sometimes a `code`. Used to sanity-check error-path tests without + * over-fitting to one specific shape. + */ +export type PluggyErrorBody = { + message?: string + code?: string | number + [key: string]: unknown +} + +/** Fake but well-formed UUID — guaranteed not to match a real resource. */ +export const NON_EXISTENT_UUID = '00000000-0000-0000-0000-000000000000' + +/** Default Jest timeout for integration files. Item sync alone can be ~3 min. */ +export const INTEGRATION_TIMEOUT_MS = 6 * 60 * 1000 diff --git a/tests/integration/identity.test.ts b/tests/integration/identity.test.ts new file mode 100644 index 0000000..8e6fe12 --- /dev/null +++ b/tests/integration/identity.test.ts @@ -0,0 +1,43 @@ +import { PluggyClient } from '../../src/client' +import { Item } from '../../src/types' +import { + createClient, + createSandboxItem, + deleteItemSafely, + describeIntegration, + INTEGRATION_TIMEOUT_MS, +} from './helpers' + +jest.setTimeout(INTEGRATION_TIMEOUT_MS) + +describeIntegration('Identity (integration)', () => { + let client: PluggyClient + let item: Item + + beforeAll(async () => { + client = createClient() + item = await createSandboxItem(client) + }) + + afterAll(async () => { + if (item) { + await deleteItemSafely(client, item.id) + } + }) + + it('fetchIdentityByItemId returns the identity record', async () => { + const identity = await client.fetchIdentityByItemId(item.id) + + expect(identity.id).toBeTruthy() + // PF (individual) or PJ (legal entity) — at least one identifier + // must be present. + expect(identity.document || identity.fullName).toBeTruthy() + }) + + it('fetchIdentity returns the same record when fetched by id', async () => { + const byItem = await client.fetchIdentityByItemId(item.id) + const byId = await client.fetchIdentity(byItem.id) + + expect(byId.id).toBe(byItem.id) + }) +}) diff --git a/tests/integration/investments.test.ts b/tests/integration/investments.test.ts new file mode 100644 index 0000000..e6120b6 --- /dev/null +++ b/tests/integration/investments.test.ts @@ -0,0 +1,59 @@ +import { PluggyClient } from '../../src/client' +import { Item } from '../../src/types' +import { + createClient, + createSandboxItem, + deleteItemSafely, + describeIntegration, + INTEGRATION_TIMEOUT_MS, +} from './helpers' + +jest.setTimeout(INTEGRATION_TIMEOUT_MS) + +describeIntegration('Investments (integration)', () => { + let client: PluggyClient + let item: Item + + beforeAll(async () => { + client = createClient() + item = await createSandboxItem(client) + }) + + afterAll(async () => { + if (item) { + await deleteItemSafely(client, item.id) + } + }) + + it('fetchInvestments returns investments tied to the item', async () => { + const page = await client.fetchInvestments(item.id) + expect(Array.isArray(page.results)).toBe(true) + + for (const investment of page.results) { + expect(investment.id).toBeTruthy() + expect(investment.itemId).toBe(item.id) + expect(typeof investment.balance).toBe('number') + } + }) + + it('fetchInvestment returns the same investment when fetched by id', async () => { + const page = await client.fetchInvestments(item.id) + if (page.results.length === 0) return + + const first = page.results[0] + const fetched = await client.fetchInvestment(first.id) + expect(fetched.id).toBe(first.id) + expect(fetched.itemId).toBe(first.itemId) + }) + + it('fetchAllInvestmentTransactions paginates without duplicates', async () => { + const page = await client.fetchInvestments(item.id) + if (page.results.length === 0) return + + const first = page.results[0] + const txs = await client.fetchAllInvestmentTransactions(first.id) + expect(Array.isArray(txs)).toBe(true) + const uniqueIds = new Set(txs.map(t => t.id)) + expect(uniqueIds.size).toBe(txs.length) + }) +}) diff --git a/tests/integration/items.test.ts b/tests/integration/items.test.ts new file mode 100644 index 0000000..c0af157 --- /dev/null +++ b/tests/integration/items.test.ts @@ -0,0 +1,37 @@ +import { PluggyClient } from '../../src/client' +import { Item } from '../../src/types' +import { + createClient, + createSandboxItem, + deleteItemSafely, + describeIntegration, + INTEGRATION_TIMEOUT_MS, +} from './helpers' + +jest.setTimeout(INTEGRATION_TIMEOUT_MS) + +describeIntegration('Items (integration)', () => { + let client: PluggyClient + let item: Item + + beforeAll(async () => { + client = createClient() + item = await createSandboxItem(client) + }) + + afterAll(async () => { + if (item) { + await deleteItemSafely(client, item.id) + } + }) + + it('fetchItem returns the created item with UPDATED status', async () => { + const fetched = await client.fetchItem(item.id) + + expect(fetched.id).toBe(item.id) + expect(fetched.connector.id).toBe(item.connector.id) + expect(fetched.status).toBe('UPDATED') + expect(fetched.createdAt).toBeInstanceOf(Date) + expect(fetched.updatedAt).toBeInstanceOf(Date) + }) +}) diff --git a/tests/integration/loans.test.ts b/tests/integration/loans.test.ts new file mode 100644 index 0000000..b930e7a --- /dev/null +++ b/tests/integration/loans.test.ts @@ -0,0 +1,47 @@ +import { PluggyClient } from '../../src/client' +import { Item } from '../../src/types' +import { + createClient, + createSandboxItem, + deleteItemSafely, + describeIntegration, + INTEGRATION_TIMEOUT_MS, +} from './helpers' + +jest.setTimeout(INTEGRATION_TIMEOUT_MS) + +describeIntegration('Loans (integration)', () => { + let client: PluggyClient + let item: Item + + beforeAll(async () => { + client = createClient() + item = await createSandboxItem(client) + }) + + afterAll(async () => { + if (item) { + await deleteItemSafely(client, item.id) + } + }) + + it('fetchLoans returns a well-formed page', async () => { + const page = await client.fetchLoans(item.id) + expect(Array.isArray(page.results)).toBe(true) + + for (const loan of page.results) { + expect(loan.id).toBeTruthy() + expect(loan.itemId).toBe(item.id) + } + }) + + it('fetchLoan returns the same record when fetched by id', async () => { + const page = await client.fetchLoans(item.id) + if (page.results.length === 0) return + + const first = page.results[0] + const fetched = await client.fetchLoan(first.id) + expect(fetched.id).toBe(first.id) + expect(fetched.itemId).toBe(first.itemId) + }) +}) diff --git a/tests/integration/transactions.test.ts b/tests/integration/transactions.test.ts new file mode 100644 index 0000000..84a07d3 --- /dev/null +++ b/tests/integration/transactions.test.ts @@ -0,0 +1,95 @@ +import { PluggyClient } from '../../src/client' +import { Item } from '../../src/types' +import { + createClient, + createSandboxItem, + deleteItemSafely, + describeIntegration, + INTEGRATION_TIMEOUT_MS, + retry, +} from './helpers' + +jest.setTimeout(INTEGRATION_TIMEOUT_MS) + +const oneYearAgo = (): string => { + const d = new Date() + d.setFullYear(d.getFullYear() - 1) + return d.toISOString().split('T')[0] +} + +describeIntegration('Transactions (integration, cursor-based)', () => { + let client: PluggyClient + let item: Item + let accountId: string + + beforeAll(async () => { + client = createClient() + item = await createSandboxItem(client) + + // Transactions can lag behind the item flipping UPDATED by a beat, + // so the first lookup is retried. + accountId = await retry( + async () => { + const page = await client.fetchAccounts(item.id) + if (page.results.length === 0) throw new Error('no accounts yet') + return page.results[0].id + }, + { attempts: 10, delayMs: 1_000, description: 'fetchAccounts initial' } + ) + }) + + afterAll(async () => { + if (item) { + await deleteItemSafely(client, item.id) + } + }) + + it('fetchTransactionsCursor returns a page with a next cursor or null', async () => { + const page = await client.fetchTransactionsCursor(accountId, { dateFrom: oneYearAgo() }) + + expect(Array.isArray(page.results)).toBe(true) + expect('next' in page).toBe(true) + + for (const tx of page.results) { + expect(tx.id).toBeTruthy() + expect(tx.accountId).toBe(accountId) + expect(typeof tx.amount).toBe('number') + expect(tx.date).toBeInstanceOf(Date) + } + }) + + it('fetchAllTransactions returns the full deduplicated list', async () => { + const all = await client.fetchAllTransactions(accountId, { dateFrom: oneYearAgo() }) + + expect(Array.isArray(all)).toBe(true) + const uniqueIds = new Set(all.map(t => t.id)) + expect(uniqueIds.size).toBe(all.length) + }) + + it('fetchTransaction returns the same transaction when fetched by id', async () => { + const page = await client.fetchTransactionsCursor(accountId, { dateFrom: oneYearAgo() }) + if (page.results.length === 0) { + // Nothing to look up — skip silently. Sandbox sometimes returns 0 + // transactions for a fresh item. + return + } + const tx = page.results[0] + const fetched = await client.fetchTransaction(tx.id) + expect(fetched.id).toBe(tx.id) + expect(fetched.accountId).toBe(tx.accountId) + }) + + it('updateTransactionCategory persists a new category on a transaction', async () => { + const page = await client.fetchTransactionsCursor(accountId, { dateFrom: oneYearAgo() }) + if (page.results.length === 0) return + + const tx = page.results[0] + const categories = await client.fetchCategories() + const newCategory = categories.results.find(c => c.id !== tx.category) + if (!newCategory) return + + const updated = await client.updateTransactionCategory(tx.id, newCategory.id) + expect(updated.id).toBe(tx.id) + expect(updated.categoryId).toBe(newCategory.id) + }) +}) diff --git a/tests/integration/webhooks.test.ts b/tests/integration/webhooks.test.ts new file mode 100644 index 0000000..65036bd --- /dev/null +++ b/tests/integration/webhooks.test.ts @@ -0,0 +1,41 @@ +import { PluggyClient } from '../../src/client' +import { createClient, describeIntegration, INTEGRATION_TIMEOUT_MS, TEST_TAG } from './helpers' + +jest.setTimeout(INTEGRATION_TIMEOUT_MS) + +describeIntegration('Webhooks (integration)', () => { + let client: PluggyClient + + beforeAll(() => { + client = createClient() + }) + + it('supports the full CRUD lifecycle', async () => { + const createUrl = `https://example.com/webhooks/${TEST_TAG}` + const updateUrl = `https://example.com/webhooks/${TEST_TAG}-updated` + + const created = await client.createWebhook('item/updated', createUrl) + expect(created.id).toBeTruthy() + expect(created.url).toBe(createUrl) + expect(created.event).toBe('item/updated') + + try { + const list = await client.fetchWebhooks() + expect(list.results.some(w => w.id === created.id)).toBe(true) + + const fetched = await client.fetchWebhook(created.id) + expect(fetched.id).toBe(created.id) + expect(fetched.url).toBe(createUrl) + + const updated = await client.updateWebhook(created.id, { + url: updateUrl, + event: 'all', + }) + expect(updated.id).toBe(created.id) + expect(updated.url).toBe(updateUrl) + expect(updated.event).toBe('all') + } finally { + await client.deleteWebhook(created.id) + } + }) +})