diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f36c3a6..8103b0e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,5 +19,5 @@ jobs: - run: bun install - run: bun run typecheck - run: bun run lint - - run: bun test + - run: bun run test --run - run: bun run build diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a0b908c..7e69f89 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -18,7 +18,7 @@ jobs: - run: bun install - run: bun run typecheck - run: bun run lint - - run: bun test + - run: bun run test --run publish: needs: test diff --git a/bun.lock b/bun.lock index 0dda7d8..130d148 100644 --- a/bun.lock +++ b/bun.lock @@ -10,7 +10,7 @@ "commander": "^15.0.0", "conf": "^15.0.2", "dayjs": "^1.11.19", - "ynab": "^4.0.0", + "ynab": "^4.5.0", "zod": "^4.3.6", }, "devDependencies": { @@ -579,7 +579,7 @@ "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], - "ynab": ["ynab@4.4.0", "", { "dependencies": { "fetch-ponyfill": "^7.1.0" } }, "sha512-LCl8+8MafsyVumSFBMoUYyNsyHecBosUFJVQSo/MwNmAf/odrdkOFoP7VeE0vHVCkKMAtmxZyg7Bj+vF6a+hvA=="], + "ynab": ["ynab@4.5.0", "", { "dependencies": { "fetch-ponyfill": "^7.1.0" } }, "sha512-Rmz081alMkDjhfkh5UTfgXeU4XVWfau5EOHdZXTy0wby1i+TeGtrC25FM5bVjRxaMNz5nXwa5LMwlB+pizd8Kg=="], "zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], diff --git a/docs/sdk-integration.md b/docs/sdk-integration.md new file mode 100644 index 0000000..371b30b --- /dev/null +++ b/docs/sdk-integration.md @@ -0,0 +1,23 @@ +# YNAB SDK integration + +Transaction-list methods in `src/lib/api-client.ts` use the SDK's typed named +request objects through `getTransactionsRaw` and its account, category, and +payee variants. Call `.value()` on the returned SDK response to retain its +normal JSON decoding. Optional filters must be mapped by name: positional SDK +signatures can insert optional parameters between existing arguments. + +The dependency floor and `bun.lock` select SDK 4.5.0 or later. Keep the lockfile +aligned with the supported SDK so typechecking and tests exercise its actual +request interfaces. + +`src/lib/api-client.transactions.test.ts` exercises all four transaction-list +paths through the real SDK with only global fetch mocked. A synthetic API token +is supplied by a test subclass to avoid credential lookup. Assertions cover the +outgoing endpoint and complete query, including type filtering, delta knowledge, +and omission of `until_date`. SDK method mocks cannot detect argument-order +errors in the SDK's URL serialization. + +Run the full suite with `bun run test --run`, which uses Vitest's isolated module +mocks. Bun's native test runner does not implement the same Vitest APIs or mock +isolation. Authentication tests keep the real config implementation but redirect +its storage to a temporary directory so they cannot modify user preferences. diff --git a/package.json b/package.json index 102c10f..d5fbf75 100644 --- a/package.json +++ b/package.json @@ -50,7 +50,7 @@ "commander": "^15.0.0", "conf": "^15.0.2", "dayjs": "^1.11.19", - "ynab": "^4.0.0", + "ynab": "^4.5.0", "zod": "^4.3.6" }, "devDependencies": { diff --git a/src/lib/api-client.transactions.test.ts b/src/lib/api-client.transactions.test.ts new file mode 100644 index 0000000..621046d --- /dev/null +++ b/src/lib/api-client.transactions.test.ts @@ -0,0 +1,63 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import * as ynab from 'ynab'; +import { YnabClient } from './api-client.js'; + +class SyntheticCredentialClient extends YnabClient { + override async getApi(): Promise { + return new ynab.API('synthetic-test-token'); + } +} + +describe('transaction-list SDK request contracts', () => { + const fetchMock = vi.fn(); + + beforeEach(() => { + fetchMock.mockReset(); + fetchMock.mockResolvedValue( + new Response(JSON.stringify({ data: { transactions: [], server_knowledge: 43 } }), { + headers: { 'Content-Type': 'application/json' }, + }) + ); + vi.stubGlobal('fetch', fetchMock); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + const paths = [ + ['plan', '/v1/plans/test-plan/transactions'], + ['account', '/v1/plans/test-plan/accounts/test-account/transactions'], + ['category', '/v1/plans/test-plan/categories/test-category/transactions'], + ['payee', '/v1/plans/test-plan/payees/test-payee/transactions'], + ] as const; + + it.each(paths)('sends named filters for %s transactions', async (scope, path) => { + const client = new SyntheticCredentialClient(); + const params = { + budgetId: 'test-plan', + sinceDate: '2015-12-01', + type: 'unapproved', + lastKnowledgeOfServer: 42, + }; + const result = await (scope === 'plan' + ? client.getTransactions(params) + : scope === 'account' + ? client.getTransactionsByAccount('test-account', params) + : scope === 'category' + ? client.getTransactionsByCategory('test-category', params) + : client.getTransactionsByPayee('test-payee', params)); + + expect(result).toEqual({ transactions: [], server_knowledge: 43 }); + expect(fetchMock).toHaveBeenCalledTimes(1); + const url = new URL(String(fetchMock.mock.calls[0][0])); + expect(url.origin).toBe('https://api.ynab.com'); + expect(url.pathname).toBe(path); + expect(Object.fromEntries(url.searchParams)).toEqual({ + since_date: '2015-12-01', + type: 'unapproved', + last_knowledge_of_server: '42', + }); + expect(url.searchParams.has('until_date')).toBe(false); + }); +}); diff --git a/src/lib/api-client.ts b/src/lib/api-client.ts index 028aba0..d5fde47 100644 --- a/src/lib/api-client.ts +++ b/src/lib/api-client.ts @@ -3,7 +3,7 @@ import { config } from './config.js'; import { YnabCliError, sanitizeApiError } from './errors.js'; import { auth, type ResolvedCredential } from './auth.js'; -type TransactionTypeFilter = 'uncategorized' | 'unapproved' | undefined; +type TransactionTypeFilter = ynab.GetTransactionsRequest['type']; function isUnauthorizedError(error: unknown): boolean { if (typeof error !== 'object' || error === null) { @@ -243,12 +243,13 @@ export class YnabClient { }) { const api = await this.getApi(); const id = await this.getBudgetId(params.budgetId); - const response = await api.transactions.getTransactions( - id, - params.sinceDate, - params.type as TransactionTypeFilter, - params.lastKnowledgeOfServer - ); + const request = await api.transactions.getTransactionsRaw({ + planId: id, + sinceDate: params.sinceDate, + type: params.type as TransactionTypeFilter, + lastKnowledgeOfServer: params.lastKnowledgeOfServer, + }); + const response = await request.value(); return { transactions: response.data.transactions, server_knowledge: response.data.server_knowledge, @@ -266,13 +267,14 @@ export class YnabClient { ) { const api = await this.getApi(); const id = await this.getBudgetId(params.budgetId); - const response = await api.transactions.getTransactionsByAccount( - id, + const request = await api.transactions.getTransactionsByAccountRaw({ + planId: id, accountId, - params.sinceDate, - params.type as TransactionTypeFilter, - params.lastKnowledgeOfServer - ); + sinceDate: params.sinceDate, + type: params.type as TransactionTypeFilter, + lastKnowledgeOfServer: params.lastKnowledgeOfServer, + }); + const response = await request.value(); return { transactions: response.data.transactions, server_knowledge: response.data.server_knowledge, @@ -290,13 +292,14 @@ export class YnabClient { ) { const api = await this.getApi(); const id = await this.getBudgetId(params.budgetId); - const response = await api.transactions.getTransactionsByCategory( - id, + const request = await api.transactions.getTransactionsByCategoryRaw({ + planId: id, categoryId, - params.sinceDate, - params.type as TransactionTypeFilter, - params.lastKnowledgeOfServer - ); + sinceDate: params.sinceDate, + type: params.type as TransactionTypeFilter, + lastKnowledgeOfServer: params.lastKnowledgeOfServer, + }); + const response = await request.value(); return { transactions: response.data.transactions, server_knowledge: response.data.server_knowledge, @@ -314,13 +317,14 @@ export class YnabClient { ) { const api = await this.getApi(); const id = await this.getBudgetId(params.budgetId); - const response = await api.transactions.getTransactionsByPayee( - id, + const request = await api.transactions.getTransactionsByPayeeRaw({ + planId: id, payeeId, - params.sinceDate, - params.type as TransactionTypeFilter, - params.lastKnowledgeOfServer - ); + sinceDate: params.sinceDate, + type: params.type as TransactionTypeFilter, + lastKnowledgeOfServer: params.lastKnowledgeOfServer, + }); + const response = await request.value(); return { transactions: response.data.transactions, server_knowledge: response.data.server_knowledge, diff --git a/src/lib/auth.test.ts b/src/lib/auth.test.ts index 108c379..b826332 100644 --- a/src/lib/auth.test.ts +++ b/src/lib/auth.test.ts @@ -1,4 +1,25 @@ -import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach, afterAll, vi } from 'vitest'; +import { rmSync } from 'node:fs'; + +const configDirectory = await vi.hoisted(async () => { + const { mkdtempSync } = await import('node:fs'); + const { tmpdir } = await import('node:os'); + const { join } = await import('node:path'); + return mkdtempSync(join(tmpdir(), 'ynab-cli-auth-test-')); +}); + +vi.mock('conf', async (importOriginal) => { + const { default: Conf } = await importOriginal(); + return { + default: class extends Conf { + constructor(options: ConstructorParameters[0]) { + super({ ...options, cwd: configDirectory }); + } + }, + }; +}); + +afterAll(() => rmSync(configDirectory, { recursive: true, force: true })); const mockEntry = { getPassword: vi.fn(),