Skip to content
Open
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
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 1 addition & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

23 changes: 23 additions & 0 deletions docs/sdk-integration.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
63 changes: 63 additions & 0 deletions src/lib/api-client.transactions.test.ts
Original file line number Diff line number Diff line change
@@ -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<ynab.API> {
return new ynab.API('synthetic-test-token');
}
}

describe('transaction-list SDK request contracts', () => {
const fetchMock = vi.fn<typeof fetch>();

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);
});
});
54 changes: 29 additions & 25 deletions src/lib/api-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand Down
23 changes: 22 additions & 1 deletion src/lib/auth.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof import('conf')>();
return {
default: class extends Conf {
constructor(options: ConstructorParameters<typeof Conf>[0]) {
super({ ...options, cwd: configDirectory });
}
},
};
});

afterAll(() => rmSync(configDirectory, { recursive: true, force: true }));

const mockEntry = {
getPassword: vi.fn(),
Expand Down
Loading