@dizzlkheinz/ynab-mcpb 0.16.0 → 0.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.code/agents/0098661e-0fa3-4990-beb9-c0cbf3f123aa/status.txt +1 -0
- package/.code/agents/1324/exec-call_tIpx9uV1TpARbAMZonRQm8AO.txt +757 -0
- package/.code/agents/1572/exec-call_GjVFBFOWcY7lE0idc5nWlLNh.txt +781 -0
- package/.code/agents/1846/exec-call_1YNAVD18RjrMN7JnfkkQhUP3.txt +766 -0
- package/.code/agents/1846/exec-call_lh3lDzE4WJAh1lFiomiiZ73D.txt +766 -0
- package/.code/agents/2038/exec-call_DYwOukaYsL8VCONWmV2rUW5u.txt +766 -0
- package/.code/agents/2038/exec-call_c7fOQ7UrpVcTtvdfGBRM146V.txt +652 -0
- package/.code/agents/2038/exec-call_ySNyq9Mm55jWE480s54r5QcA.txt +766 -0
- package/.code/agents/2256/exec-call_AtPcRWPmFPMcmX6qOFm1fCEY.txt +766 -0
- package/.code/agents/2454/exec-call_aFJpupwjfZeOBm7ixI5Vc8z2.txt +766 -0
- package/.code/agents/2454/exec-call_wogZ4HfXTodTEXvdgXlVUBpv.txt +766 -0
- package/.code/agents/2e905864-aa07-4314-bcf9-c5b32277e4ac/result.txt +36 -0
- package/.code/agents/3073/exec-call_Peeagc9DxGYLgE6pNdMZhqIE.txt +766 -0
- package/.code/agents/3073/exec-call_d2YSE3hXF08KRSoUM3qd8Z3x.txt +766 -0
- package/.code/agents/335aa031-466d-4fb7-925f-3cd864e264d0/result.txt +191 -0
- package/.code/agents/3364/exec-call_NbhIrsM5HhyDZDmJZG5CuCYL.txt +766 -0
- package/.code/agents/3364/exec-call_cKtJg0NrXiwXEFwlsE3uPZRA.txt +766 -0
- package/.code/agents/36d98414-5cde-4d9d-9a67-a240a18c1f07/result.txt +189 -0
- package/.code/agents/4604e866-b7b8-44f5-992f-2f683b0a523b/status.txt +1 -0
- package/.code/agents/5f8dc01c-47b3-4163-b0b3-aa31be89fcdc/status.txt +1 -0
- package/.code/agents/7/exec-call_HltHpkDox0Zm1vGEjdksUgpE.txt +1120 -0
- package/.code/agents/7/exec-call_LCATrOPPAgbxW9Q1z0XaVi2E.txt +2646 -0
- package/.code/agents/7/exec-call_W8DeRfNG9hvbgVFvf0clBf6R.txt +2646 -0
- package/.code/agents/94a0ddf3-a304-4ec3-913e-3cceef509948/error.txt +1 -0
- package/.code/agents/e2c752b7-711d-423a-af57-f53c809deb84/result.txt +160 -0
- package/.code/agents/e6601719-c31f-4a0e-8c71-d70787d0ab71/status.txt +1 -0
- package/.code/agents/f250b7ed-5bd5-4036-aa8c-ce63caee7d61/result.txt +20 -0
- package/AGENTS.md +1 -36
- package/CLAUDE.md +131 -51
- package/NUL +0 -1
- package/README.md +27 -14
- package/dist/bundle/index.cjs +41 -41
- package/dist/server/YNABMCPServer.js +28 -381
- package/dist/server/config.d.ts +2 -0
- package/dist/server/config.js +1 -0
- package/dist/tools/accountTools.d.ts +2 -0
- package/dist/tools/accountTools.js +45 -0
- package/dist/tools/adapters.d.ts +12 -0
- package/dist/tools/adapters.js +25 -0
- package/dist/tools/budgetTools.d.ts +2 -0
- package/dist/tools/budgetTools.js +30 -0
- package/dist/tools/categoryTools.d.ts +2 -0
- package/dist/tools/categoryTools.js +45 -0
- package/dist/tools/monthTools.d.ts +2 -0
- package/dist/tools/monthTools.js +32 -0
- package/dist/tools/payeeTools.d.ts +2 -0
- package/dist/tools/payeeTools.js +32 -0
- package/dist/tools/reconciliation/index.d.ts +2 -0
- package/dist/tools/reconciliation/index.js +33 -0
- package/dist/tools/schemas/common.d.ts +3 -0
- package/dist/tools/schemas/common.js +3 -0
- package/dist/tools/schemas/outputs/comparisonOutputs.d.ts +1 -1
- package/dist/tools/transactionTools.d.ts +2 -0
- package/dist/tools/transactionTools.js +129 -0
- package/dist/tools/utilityTools.d.ts +3 -1
- package/dist/tools/utilityTools.js +32 -2
- package/dist/types/index.d.ts +1 -0
- package/dist/types/toolRegistration.d.ts +27 -0
- package/dist/types/toolRegistration.js +1 -0
- package/package.json +2 -2
- package/scripts/run-domain-integration-tests.js +4 -1
- package/src/__tests__/workflows.e2e.test.ts +1 -7
- package/src/server/YNABMCPServer.ts +33 -519
- package/src/server/__tests__/toolRegistration.test.ts +236 -0
- package/src/server/config.ts +1 -0
- package/src/tools/__tests__/adapters.test.ts +113 -0
- package/src/tools/__tests__/transactionTools.test.ts +90 -17
- package/src/tools/__tests__/utilityTools.test.ts +7 -7
- package/src/tools/accountTools.ts +53 -0
- package/src/tools/adapters.ts +74 -0
- package/src/tools/budgetTools.ts +37 -0
- package/src/tools/categoryTools.ts +53 -0
- package/src/tools/monthTools.ts +39 -0
- package/src/tools/payeeTools.ts +39 -0
- package/src/tools/reconciliation/index.ts +45 -0
- package/src/tools/schemas/common.ts +18 -0
- package/src/tools/transactionTools.ts +150 -0
- package/src/tools/utilityTools.ts +42 -2
- package/src/types/index.ts +3 -0
- package/src/types/toolRegistration.ts +88 -0
- package/.dxtignore +0 -57
- package/.github/workflows/pr-description-check.yml +0 -88
- package/CODEREVIEW_RESPONSE.md +0 -128
- package/SCHEMA_IMPROVEMENT_SUMMARY.md +0 -120
- package/TESTING_NOTES.md +0 -217
- package/accountactivity-merged.csv +0 -149
- package/bundle-analysis.html +0 -13110
- package/docs/README.md +0 -72
- package/docs/getting-started/CONFIGURATION.md +0 -175
- package/docs/getting-started/INSTALLATION.md +0 -333
- package/docs/getting-started/QUICKSTART.md +0 -282
- package/docs/guides/ARCHITECTURE.md +0 -533
- package/docs/guides/DEPLOYMENT.md +0 -189
- package/docs/guides/INTEGRATION_TESTING.md +0 -730
- package/docs/guides/TESTING.md +0 -591
- package/docs/plans/2025-11-20-reloadable-config-token-validation.md +0 -93
- package/docs/plans/2025-11-21-fix-transaction-cached-property.md +0 -362
- package/docs/plans/2025-11-21-reconciliation-error-handling.md +0 -90
- package/docs/plans/2025-11-21-v014-hardening.md +0 -153
- package/docs/plans/reconciliation-v2-redesign.md +0 -1571
- package/docs/reconciliation-flow.md +0 -83
- package/docs/reference/EXAMPLES.md +0 -946
- package/docs/reference/TOOLS.md +0 -348
- package/docs/reference/TROUBLESHOOTING.md +0 -481
- package/fix-types.sh +0 -17
- package/test-csv-sample.csv +0 -28
- package/test-exports/sample_bank_statement.csv +0 -7
- package/test-reconcile-autodetect.js +0 -40
- package/test-reconcile-tool.js +0 -152
- package/test-reconcile-with-csv.cjs +0 -89
- package/test-statement.csv +0 -8
- package/test_debug.js +0 -47
- package/test_mcp_tools.mjs +0 -75
- package/test_simple.mjs +0 -16
|
@@ -0,0 +1,1120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* End-to-end workflow tests for YNAB MCP Server
|
|
3
|
+
* These tests require a real YNAB API key and test budget
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
|
|
7
|
+
import { YNABMCPServer } from '../server/YNABMCPServer.js';
|
|
8
|
+
import { getCurrentMonth } from '../utils/dateUtils.js';
|
|
9
|
+
import {
|
|
10
|
+
getTestConfig,
|
|
11
|
+
createTestServer,
|
|
12
|
+
executeToolCall,
|
|
13
|
+
parseToolResult,
|
|
14
|
+
isErrorResult,
|
|
15
|
+
getErrorMessage,
|
|
16
|
+
skipIfRateLimitedResult,
|
|
17
|
+
TestData,
|
|
18
|
+
TestDataCleanup,
|
|
19
|
+
YNABAssertions,
|
|
20
|
+
validateOutputSchema,
|
|
21
|
+
} from './testUtils.js';
|
|
22
|
+
import { testEnv } from './setup.js';
|
|
23
|
+
|
|
24
|
+
const runE2ETests = process.env['SKIP_E2E_TESTS'] !== 'true';
|
|
25
|
+
const describeE2E = runE2ETests ? describe : describe.skip;
|
|
26
|
+
|
|
27
|
+
describeE2E('YNAB MCP Server - End-to-End Workflows', () => {
|
|
28
|
+
let server: YNABMCPServer;
|
|
29
|
+
let testConfig: ReturnType<typeof getTestConfig>;
|
|
30
|
+
let cleanup: TestDataCleanup;
|
|
31
|
+
let testBudgetId: string;
|
|
32
|
+
let testAccountId: string;
|
|
33
|
+
|
|
34
|
+
beforeAll(async () => {
|
|
35
|
+
testConfig = getTestConfig();
|
|
36
|
+
|
|
37
|
+
if (testConfig.skipE2ETests) {
|
|
38
|
+
console.warn('Skipping E2E tests - no real API key or SKIP_E2E_TESTS=true');
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
server = await createTestServer();
|
|
43
|
+
cleanup = new TestDataCleanup();
|
|
44
|
+
|
|
45
|
+
// Get the first budget for testing
|
|
46
|
+
const budgetsResult = await executeToolCall(server, 'ynab:list_budgets');
|
|
47
|
+
const budgets = parseToolResult(budgetsResult);
|
|
48
|
+
const budgetList = budgets.data?.budgets ?? [];
|
|
49
|
+
|
|
50
|
+
if (!budgetList.length && !testConfig.testBudgetId) {
|
|
51
|
+
throw new Error('No budgets found for testing. Please create a test budget in YNAB.');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
testBudgetId = testConfig.testBudgetId ?? budgetList[0]?.id;
|
|
55
|
+
|
|
56
|
+
// Get the first account for testing
|
|
57
|
+
const accountsResult = await executeToolCall(server, 'ynab:list_accounts', {
|
|
58
|
+
budget_id: testBudgetId,
|
|
59
|
+
});
|
|
60
|
+
const accounts = parseToolResult(accountsResult);
|
|
61
|
+
const accountList = accounts.data?.accounts ?? [];
|
|
62
|
+
|
|
63
|
+
if (!accountList.length) {
|
|
64
|
+
if (testConfig.testAccountId) {
|
|
65
|
+
testAccountId = testConfig.testAccountId;
|
|
66
|
+
} else {
|
|
67
|
+
throw new Error('No accounts found for testing. Please create a test account in YNAB.');
|
|
68
|
+
}
|
|
69
|
+
} else {
|
|
70
|
+
testAccountId = testConfig.testAccountId ?? accountList[0].id;
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
afterAll(async () => {
|
|
75
|
+
if (testConfig.skipE2ETests) return;
|
|
76
|
+
|
|
77
|
+
if (cleanup && server && testBudgetId) {
|
|
78
|
+
await cleanup.cleanup(server, testBudgetId);
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
beforeEach(() => {
|
|
83
|
+
if (testConfig.skipE2ETests) {
|
|
84
|
+
// Skip individual tests if E2E tests are disabled
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
describe('Complete Budget Management Workflow', () => {
|
|
90
|
+
it('should retrieve and validate budget information', async () => {
|
|
91
|
+
if (testConfig.skipE2ETests) return;
|
|
92
|
+
|
|
93
|
+
// List all budgets
|
|
94
|
+
const budgetsResult = await executeToolCall(server, 'ynab:list_budgets');
|
|
95
|
+
|
|
96
|
+
// Validate output schema
|
|
97
|
+
const budgetsValidation = validateOutputSchema(server, 'list_budgets', budgetsResult);
|
|
98
|
+
expect(budgetsValidation.valid).toBe(true);
|
|
99
|
+
if (!budgetsValidation.valid) {
|
|
100
|
+
console.error('list_budgets schema validation errors:', budgetsValidation.errors);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const budgets = parseToolResult(budgetsResult);
|
|
104
|
+
|
|
105
|
+
// Verify backward compatibility contract: parseToolResult returns {success: true, data: ...}
|
|
106
|
+
expect(budgets).toHaveProperty('success');
|
|
107
|
+
expect(budgets.success).toBe(true);
|
|
108
|
+
expect(budgets).toHaveProperty('data');
|
|
109
|
+
|
|
110
|
+
expect(budgets.data).toBeDefined();
|
|
111
|
+
expect(budgets.data.budgets).toBeDefined();
|
|
112
|
+
expect(Array.isArray(budgets.data.budgets)).toBe(true);
|
|
113
|
+
expect(budgets.data.budgets.length).toBeGreaterThan(0);
|
|
114
|
+
|
|
115
|
+
// Validate budget structure
|
|
116
|
+
budgets.data.budgets.forEach(YNABAssertions.assertBudget);
|
|
117
|
+
|
|
118
|
+
// Get specific budget details
|
|
119
|
+
const budgetResult = await executeToolCall(server, 'ynab:get_budget', {
|
|
120
|
+
budget_id: testBudgetId,
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
// Validate output schema
|
|
124
|
+
const budgetValidation = validateOutputSchema(server, 'get_budget', budgetResult);
|
|
125
|
+
expect(budgetValidation.valid).toBe(true);
|
|
126
|
+
if (!budgetValidation.valid) {
|
|
127
|
+
console.error('get_budget schema validation errors:', budgetValidation.errors);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const budget = parseToolResult(budgetResult);
|
|
131
|
+
|
|
132
|
+
expect(budget.data).toBeDefined();
|
|
133
|
+
expect(budget.data.budget).toBeDefined();
|
|
134
|
+
YNABAssertions.assertBudget(budget.data.budget);
|
|
135
|
+
expect(budget.data.budget.id).toBe(testBudgetId);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('should retrieve user information', async () => {
|
|
139
|
+
if (testConfig.skipE2ETests) return;
|
|
140
|
+
|
|
141
|
+
const userResult = await executeToolCall(server, 'ynab:get_user');
|
|
142
|
+
const user = parseToolResult(userResult);
|
|
143
|
+
|
|
144
|
+
expect(user.data).toBeDefined();
|
|
145
|
+
expect(user.data.user).toBeDefined();
|
|
146
|
+
expect(typeof user.data.user.id).toBe('string');
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
describe('Complete Account Management Workflow', () => {
|
|
151
|
+
it('should list and retrieve account information', async () => {
|
|
152
|
+
if (testConfig.skipE2ETests) return;
|
|
153
|
+
|
|
154
|
+
// List all accounts
|
|
155
|
+
const accountsResult = await executeToolCall(server, 'ynab:list_accounts', {
|
|
156
|
+
budget_id: testBudgetId,
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
// Validate output schema
|
|
160
|
+
const accountsValidation = validateOutputSchema(server, 'list_accounts', accountsResult);
|
|
161
|
+
expect(accountsValidation.valid).toBe(true);
|
|
162
|
+
if (!accountsValidation.valid) {
|
|
163
|
+
console.error('list_accounts schema validation errors:', accountsValidation.errors);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const accounts = parseToolResult(accountsResult);
|
|
167
|
+
|
|
168
|
+
// Verify backward compatibility contract: parseToolResult returns {success: true, data: ...}
|
|
169
|
+
expect(accounts).toHaveProperty('success');
|
|
170
|
+
expect(accounts.success).toBe(true);
|
|
171
|
+
expect(accounts).toHaveProperty('data');
|
|
172
|
+
|
|
173
|
+
expect(accounts.data).toBeDefined();
|
|
174
|
+
expect(accounts.data.accounts).toBeDefined();
|
|
175
|
+
expect(Array.isArray(accounts.data.accounts)).toBe(true);
|
|
176
|
+
expect(accounts.data.accounts.length).toBeGreaterThan(0);
|
|
177
|
+
|
|
178
|
+
// Validate account structures
|
|
179
|
+
accounts.data.accounts.forEach(YNABAssertions.assertAccount);
|
|
180
|
+
|
|
181
|
+
// Get specific account details
|
|
182
|
+
const accountResult = await executeToolCall(server, 'ynab:get_account', {
|
|
183
|
+
budget_id: testBudgetId,
|
|
184
|
+
account_id: testAccountId,
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
// Validate output schema
|
|
188
|
+
const accountValidation = validateOutputSchema(server, 'get_account', accountResult);
|
|
189
|
+
expect(accountValidation.valid).toBe(true);
|
|
190
|
+
if (!accountValidation.valid) {
|
|
191
|
+
console.error('get_account schema validation errors:', accountValidation.errors);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const account = parseToolResult(accountResult);
|
|
195
|
+
|
|
196
|
+
expect(account.data).toBeDefined();
|
|
197
|
+
expect(account.data.account).toBeDefined();
|
|
198
|
+
YNABAssertions.assertAccount(account.data.account);
|
|
199
|
+
expect(account.data.account.id).toBe(testAccountId);
|
|
200
|
+
|
|
201
|
+
// Reconcile account as part of account management workflow
|
|
202
|
+
const reconcileResult = await executeToolCall(server, 'ynab:reconcile_account', {
|
|
203
|
+
budget_id: testBudgetId,
|
|
204
|
+
account_id: testAccountId,
|
|
205
|
+
cleared_balance: account.data.account.cleared_balance,
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
// Validate reconcile_account output schema
|
|
209
|
+
const reconcileValidation = validateOutputSchema(
|
|
210
|
+
server,
|
|
211
|
+
'reconcile_account',
|
|
212
|
+
reconcileResult,
|
|
213
|
+
);
|
|
214
|
+
expect(reconcileValidation.valid).toBe(true);
|
|
215
|
+
if (!reconcileValidation.valid) {
|
|
216
|
+
console.error('reconcile_account schema validation errors:', reconcileValidation.errors);
|
|
217
|
+
}
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it('should create a new account', async () => {
|
|
221
|
+
if (testConfig.skipE2ETests) return;
|
|
222
|
+
|
|
223
|
+
const accountName = TestData.generateAccountName();
|
|
224
|
+
|
|
225
|
+
const createResult = await executeToolCall(server, 'ynab:create_account', {
|
|
226
|
+
budget_id: testBudgetId,
|
|
227
|
+
name: accountName,
|
|
228
|
+
type: 'checking',
|
|
229
|
+
balance: 10000, // $10.00
|
|
230
|
+
});
|
|
231
|
+
if (skipIfRateLimitedResult(createResult)) return;
|
|
232
|
+
|
|
233
|
+
// Validate output schema
|
|
234
|
+
const createValidation = validateOutputSchema(server, 'create_account', createResult);
|
|
235
|
+
expect(createValidation.valid).toBe(true);
|
|
236
|
+
if (!createValidation.valid) {
|
|
237
|
+
console.error('create_account schema validation errors:', createValidation.errors);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const createdAccount = parseToolResult(createResult);
|
|
241
|
+
|
|
242
|
+
expect(createdAccount.data).toBeDefined();
|
|
243
|
+
expect(createdAccount.data.account).toBeDefined();
|
|
244
|
+
YNABAssertions.assertAccount(createdAccount.data.account);
|
|
245
|
+
expect(createdAccount.data.account.name).toBe(accountName);
|
|
246
|
+
expect(createdAccount.data.account.type).toBe('checking');
|
|
247
|
+
|
|
248
|
+
// Track for cleanup
|
|
249
|
+
cleanup.trackAccount(createdAccount.data.account.id);
|
|
250
|
+
|
|
251
|
+
// Verify account appears in list
|
|
252
|
+
const accountsResult = await executeToolCall(server, 'ynab:list_accounts', {
|
|
253
|
+
budget_id: testBudgetId,
|
|
254
|
+
});
|
|
255
|
+
if (skipIfRateLimitedResult(accountsResult)) return;
|
|
256
|
+
const accounts = parseToolResult(accountsResult);
|
|
257
|
+
|
|
258
|
+
const foundAccount = accounts.data.accounts.find(
|
|
259
|
+
(acc: any) => acc.id === createdAccount.data.account.id,
|
|
260
|
+
);
|
|
261
|
+
expect(foundAccount).toBeDefined();
|
|
262
|
+
expect(foundAccount.name).toBe(accountName);
|
|
263
|
+
});
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
describe('Complete Transaction Management Workflow', () => {
|
|
267
|
+
let testTransactionId: string;
|
|
268
|
+
|
|
269
|
+
it('should create, retrieve, update, and delete a transaction', async () => {
|
|
270
|
+
if (testConfig.skipE2ETests) return;
|
|
271
|
+
|
|
272
|
+
// Get categories for transaction creation
|
|
273
|
+
const categoriesResult = await executeToolCall(server, 'ynab:list_categories', {
|
|
274
|
+
budget_id: testBudgetId,
|
|
275
|
+
});
|
|
276
|
+
const categories = parseToolResult(categoriesResult);
|
|
277
|
+
|
|
278
|
+
expect(categories.data.category_groups).toBeDefined();
|
|
279
|
+
expect(Array.isArray(categories.data.category_groups)).toBe(true);
|
|
280
|
+
|
|
281
|
+
// Find a non-hidden category
|
|
282
|
+
let testCategoryId: string | undefined;
|
|
283
|
+
for (const group of categories.data.category_groups) {
|
|
284
|
+
const availableCategory = group.categories?.find((cat: any) => !cat.hidden);
|
|
285
|
+
if (availableCategory) {
|
|
286
|
+
testCategoryId = availableCategory.id;
|
|
287
|
+
break;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// Create a transaction
|
|
292
|
+
const transactionData = TestData.generateTransaction(testAccountId, testCategoryId);
|
|
293
|
+
|
|
294
|
+
const createResult = await executeToolCall(server, 'ynab:create_transaction', {
|
|
295
|
+
budget_id: testBudgetId,
|
|
296
|
+
...transactionData,
|
|
297
|
+
});
|
|
298
|
+
if (skipIfRateLimitedResult(createResult)) return;
|
|
299
|
+
|
|
300
|
+
// Validate create_transaction output schema
|
|
301
|
+
const createValidation = validateOutputSchema(server, 'create_transaction', createResult);
|
|
302
|
+
expect(createValidation.valid).toBe(true);
|
|
303
|
+
if (!createValidation.valid) {
|
|
304
|
+
console.error('create_transaction schema validation errors:', createValidation.errors);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
const createdTransaction = parseToolResult(createResult);
|
|
308
|
+
|
|
309
|
+
if (!createdTransaction?.data?.transaction) {
|
|
310
|
+
console.warn(
|
|
311
|
+
'[rate-limit] Skipping transaction workflow because create_transaction returned no transaction data',
|
|
312
|
+
);
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// Verify backward compatibility contract: parseToolResult returns {success: true, data: ...}
|
|
317
|
+
expect(createdTransaction).toHaveProperty('success');
|
|
318
|
+
expect(createdTransaction.success).toBe(true);
|
|
319
|
+
expect(createdTransaction).toHaveProperty('data');
|
|
320
|
+
|
|
321
|
+
expect(createdTransaction.data).toBeDefined();
|
|
322
|
+
expect(createdTransaction.data.transaction).toBeDefined();
|
|
323
|
+
YNABAssertions.assertTransaction(createdTransaction.data.transaction);
|
|
324
|
+
|
|
325
|
+
testTransactionId = createdTransaction.data.transaction.id;
|
|
326
|
+
cleanup.trackTransaction(testTransactionId);
|
|
327
|
+
|
|
328
|
+
// Retrieve the transaction
|
|
329
|
+
const getResult = await executeToolCall(server, 'ynab:get_transaction', {
|
|
330
|
+
budget_id: testBudgetId,
|
|
331
|
+
transaction_id: testTransactionId,
|
|
332
|
+
});
|
|
333
|
+
if (skipIfRateLimitedResult(getResult)) return;
|
|
334
|
+
|
|
335
|
+
// Validate get_transaction output schema
|
|
336
|
+
const getValidation = validateOutputSchema(server, 'get_transaction', getResult);
|
|
337
|
+
expect(getValidation.valid).toBe(true);
|
|
338
|
+
if (!getValidation.valid) {
|
|
339
|
+
console.error('get_transaction schema validation errors:', getValidation.errors);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
const retrievedTransaction = parseToolResult(getResult);
|
|
343
|
+
|
|
344
|
+
expect(retrievedTransaction.data).toBeDefined();
|
|
345
|
+
expect(retrievedTransaction.data.transaction).toBeDefined();
|
|
346
|
+
expect(retrievedTransaction.data.transaction.id).toBe(testTransactionId);
|
|
347
|
+
YNABAssertions.assertTransaction(retrievedTransaction.data.transaction);
|
|
348
|
+
|
|
349
|
+
// Update the transaction
|
|
350
|
+
const updatedMemo = `Updated memo ${Date.now()}`;
|
|
351
|
+
const updateResult = await executeToolCall(server, 'ynab:update_transaction', {
|
|
352
|
+
budget_id: testBudgetId,
|
|
353
|
+
transaction_id: testTransactionId,
|
|
354
|
+
memo: updatedMemo,
|
|
355
|
+
});
|
|
356
|
+
if (skipIfRateLimitedResult(updateResult)) return;
|
|
357
|
+
|
|
358
|
+
// Validate update_transaction output schema
|
|
359
|
+
const updateValidation = validateOutputSchema(server, 'update_transaction', updateResult);
|
|
360
|
+
expect(updateValidation.valid).toBe(true);
|
|
361
|
+
if (!updateValidation.valid) {
|
|
362
|
+
console.error('update_transaction schema validation errors:', updateValidation.errors);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
const updatedTransaction = parseToolResult(updateResult);
|
|
366
|
+
|
|
367
|
+
expect(updatedTransaction.data).toBeDefined();
|
|
368
|
+
expect(updatedTransaction.data.transaction).toBeDefined();
|
|
369
|
+
expect(updatedTransaction.data.transaction.memo).toBe(updatedMemo);
|
|
370
|
+
|
|
371
|
+
// List transactions and verify our transaction is included
|
|
372
|
+
const listResult = await executeToolCall(server, 'ynab:list_transactions', {
|
|
373
|
+
budget_id: testBudgetId,
|
|
374
|
+
account_id: testAccountId,
|
|
375
|
+
});
|
|
376
|
+
if (skipIfRateLimitedResult(listResult)) return;
|
|
377
|
+
|
|
378
|
+
// Validate list_transactions output schema
|
|
379
|
+
const listValidation = validateOutputSchema(server, 'list_transactions', listResult);
|
|
380
|
+
expect(listValidation.valid).toBe(true);
|
|
381
|
+
if (!listValidation.valid) {
|
|
382
|
+
console.error('list_transactions schema validation errors:', listValidation.errors);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
const transactions = parseToolResult(listResult);
|
|
386
|
+
|
|
387
|
+
expect(transactions.data).toBeDefined();
|
|
388
|
+
expect(transactions.data.transactions).toBeDefined();
|
|
389
|
+
expect(Array.isArray(transactions.data.transactions)).toBe(true);
|
|
390
|
+
|
|
391
|
+
const foundTransaction = transactions.data.transactions.find(
|
|
392
|
+
(txn: any) => txn.id === testTransactionId,
|
|
393
|
+
);
|
|
394
|
+
expect(foundTransaction).toBeDefined();
|
|
395
|
+
expect(foundTransaction.memo).toBe(updatedMemo);
|
|
396
|
+
|
|
397
|
+
// Delete the transaction
|
|
398
|
+
const deleteResult = await executeToolCall(server, 'ynab:delete_transaction', {
|
|
399
|
+
budget_id: testBudgetId,
|
|
400
|
+
transaction_id: testTransactionId,
|
|
401
|
+
});
|
|
402
|
+
if (skipIfRateLimitedResult(deleteResult)) return;
|
|
403
|
+
|
|
404
|
+
// Validate delete_transaction output schema
|
|
405
|
+
const deleteValidation = validateOutputSchema(server, 'delete_transaction', deleteResult);
|
|
406
|
+
expect(deleteValidation.valid).toBe(true);
|
|
407
|
+
if (!deleteValidation.valid) {
|
|
408
|
+
console.error('delete_transaction schema validation errors:', deleteValidation.errors);
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
const deleteResponse = parseToolResult(deleteResult);
|
|
412
|
+
|
|
413
|
+
expect(deleteResponse.data).toBeDefined();
|
|
414
|
+
|
|
415
|
+
// Verify transaction is deleted (should return error when trying to retrieve)
|
|
416
|
+
const getDeletedResult = await executeToolCall(server, 'ynab:get_transaction', {
|
|
417
|
+
budget_id: testBudgetId,
|
|
418
|
+
transaction_id: testTransactionId,
|
|
419
|
+
});
|
|
420
|
+
expect(isErrorResult(getDeletedResult)).toBe(true);
|
|
421
|
+
// Expected - transaction should not be found
|
|
422
|
+
expect(getDeletedResult.content).toBeDefined();
|
|
423
|
+
expect(getDeletedResult.content.length).toBeGreaterThan(0);
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
it('should filter transactions by date and account', async () => {
|
|
427
|
+
if (testConfig.skipE2ETests) return;
|
|
428
|
+
|
|
429
|
+
const lastMonth = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString().split('T')[0];
|
|
430
|
+
|
|
431
|
+
// List transactions since last month
|
|
432
|
+
const recentResult = await executeToolCall(server, 'ynab:list_transactions', {
|
|
433
|
+
budget_id: testBudgetId,
|
|
434
|
+
since_date: lastMonth,
|
|
435
|
+
});
|
|
436
|
+
if (skipIfRateLimitedResult(recentResult)) return;
|
|
437
|
+
const recentTransactions = parseToolResult(recentResult);
|
|
438
|
+
|
|
439
|
+
expect(recentTransactions.data).toBeDefined();
|
|
440
|
+
expect(recentTransactions.data.transactions).toBeDefined();
|
|
441
|
+
expect(Array.isArray(recentTransactions.data.transactions)).toBe(true);
|
|
442
|
+
|
|
443
|
+
// List transactions for specific account
|
|
444
|
+
const accountResult = await executeToolCall(server, 'ynab:list_transactions', {
|
|
445
|
+
budget_id: testBudgetId,
|
|
446
|
+
account_id: testAccountId,
|
|
447
|
+
});
|
|
448
|
+
if (skipIfRateLimitedResult(accountResult)) return;
|
|
449
|
+
const accountTransactions = parseToolResult(accountResult);
|
|
450
|
+
|
|
451
|
+
expect(accountTransactions.data).toBeDefined();
|
|
452
|
+
expect(accountTransactions.data.transactions).toBeDefined();
|
|
453
|
+
expect(Array.isArray(accountTransactions.data.transactions)).toBe(true);
|
|
454
|
+
|
|
455
|
+
// All transactions should be for the specified account
|
|
456
|
+
accountTransactions.data.transactions.forEach((txn: any) => {
|
|
457
|
+
expect(txn.account_id).toBe(testAccountId);
|
|
458
|
+
});
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
it('should export and compare transactions', async () => {
|
|
462
|
+
if (testConfig.skipE2ETests) return;
|
|
463
|
+
|
|
464
|
+
// Export transactions as part of transaction management workflow
|
|
465
|
+
const exportResult = await executeToolCall(server, 'ynab:export_transactions', {
|
|
466
|
+
budget_id: testBudgetId,
|
|
467
|
+
account_id: testAccountId,
|
|
468
|
+
});
|
|
469
|
+
if (skipIfRateLimitedResult(exportResult)) return;
|
|
470
|
+
|
|
471
|
+
// Validate export_transactions output schema
|
|
472
|
+
const exportValidation = validateOutputSchema(server, 'export_transactions', exportResult);
|
|
473
|
+
expect(exportValidation.valid).toBe(true);
|
|
474
|
+
if (!exportValidation.valid) {
|
|
475
|
+
console.error('export_transactions schema validation errors:', exportValidation.errors);
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
const exportData = parseToolResult(exportResult);
|
|
479
|
+
expect(exportData.data).toBeDefined();
|
|
480
|
+
|
|
481
|
+
// Compare transactions as part of transaction management workflow
|
|
482
|
+
const csvData = `Date,Payee,Amount\n2025-01-15,Test Comparison Payee,-25.00`;
|
|
483
|
+
const compareResult = await executeToolCall(server, 'ynab:compare_transactions', {
|
|
484
|
+
budget_id: testBudgetId,
|
|
485
|
+
account_id: testAccountId,
|
|
486
|
+
csv_data: csvData,
|
|
487
|
+
start_date: '2025-01-01',
|
|
488
|
+
end_date: '2025-01-31',
|
|
489
|
+
});
|
|
490
|
+
if (skipIfRateLimitedResult(compareResult)) return;
|
|
491
|
+
|
|
492
|
+
// Validate compare_transactions output schema
|
|
493
|
+
const compareValidation = validateOutputSchema(server, 'compare_transactions', compareResult);
|
|
494
|
+
expect(compareValidation.valid).toBe(true);
|
|
495
|
+
if (!compareValidation.valid) {
|
|
496
|
+
console.error('compare_transactions schema validation errors:', compareValidation.errors);
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
const compareData = parseToolResult(compareResult);
|
|
500
|
+
expect(compareData.data).toBeDefined();
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
it('should create and update transactions in bulk', async () => {
|
|
504
|
+
if (testConfig.skipE2ETests) return;
|
|
505
|
+
|
|
506
|
+
// Create multiple transactions as part of bulk workflow
|
|
507
|
+
const transactions = [
|
|
508
|
+
{
|
|
509
|
+
account_id: testAccountId,
|
|
510
|
+
date: new Date().toISOString().split('T')[0],
|
|
511
|
+
amount: -1500,
|
|
512
|
+
payee_name: `Bulk Workflow Payee 1 ${Date.now()}`,
|
|
513
|
+
memo: 'Bulk workflow test 1',
|
|
514
|
+
cleared: 'uncleared' as const,
|
|
515
|
+
},
|
|
516
|
+
{
|
|
517
|
+
account_id: testAccountId,
|
|
518
|
+
date: new Date().toISOString().split('T')[0],
|
|
519
|
+
amount: -2500,
|
|
520
|
+
payee_name: `Bulk Workflow Payee 2 ${Date.now()}`,
|
|
521
|
+
memo: 'Bulk workflow test 2',
|
|
522
|
+
cleared: 'uncleared' as const,
|
|
523
|
+
},
|
|
524
|
+
];
|
|
525
|
+
|
|
526
|
+
const createBulkResult = await executeToolCall(server, 'ynab:create_transactions', {
|
|
527
|
+
budget_id: testBudgetId,
|
|
528
|
+
transactions,
|
|
529
|
+
});
|
|
530
|
+
if (skipIfRateLimitedResult(createBulkResult)) return;
|
|
531
|
+
|
|
532
|
+
// Validate create_transactions (bulk) output schema
|
|
533
|
+
const createBulkValidation = validateOutputSchema(
|
|
534
|
+
server,
|
|
535
|
+
'create_transactions',
|
|
536
|
+
createBulkResult,
|
|
537
|
+
);
|
|
538
|
+
expect(createBulkValidation.valid).toBe(true);
|
|
539
|
+
if (!createBulkValidation.valid) {
|
|
540
|
+
console.error('create_transactions schema validation errors:', createBulkValidation.errors);
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
const createdBulk = parseToolResult(createBulkResult);
|
|
544
|
+
expect(createdBulk.data?.transactions).toBeDefined();
|
|
545
|
+
expect(Array.isArray(createdBulk.data.transactions)).toBe(true);
|
|
546
|
+
expect(createdBulk.data.transactions.length).toBe(2);
|
|
547
|
+
|
|
548
|
+
// Track for cleanup
|
|
549
|
+
const transactionIds = createdBulk.data.transactions.map((txn: any) => txn.id);
|
|
550
|
+
transactionIds.forEach((id: string) => cleanup.trackTransaction(id));
|
|
551
|
+
|
|
552
|
+
// Update transactions in bulk as part of workflow
|
|
553
|
+
const updateBulkResult = await executeToolCall(server, 'ynab:update_transactions', {
|
|
554
|
+
budget_id: testBudgetId,
|
|
555
|
+
transactions: transactionIds.map((id: string, index: number) => ({
|
|
556
|
+
id,
|
|
557
|
+
memo: `Updated bulk memo ${index + 1}`,
|
|
558
|
+
})),
|
|
559
|
+
});
|
|
560
|
+
if (skipIfRateLimitedResult(updateBulkResult)) return;
|
|
561
|
+
|
|
562
|
+
// Validate update_transactions (bulk) output schema
|
|
563
|
+
const updateBulkValidation = validateOutputSchema(
|
|
564
|
+
server,
|
|
565
|
+
'update_transactions',
|
|
566
|
+
updateBulkResult,
|
|
567
|
+
);
|
|
568
|
+
expect(updateBulkValidation.valid).toBe(true);
|
|
569
|
+
if (!updateBulkValidation.valid) {
|
|
570
|
+
console.error('update_transactions schema validation errors:', updateBulkValidation.errors);
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
const updatedBulk = parseToolResult(updateBulkResult);
|
|
574
|
+
expect(updatedBulk.data?.transactions).toBeDefined();
|
|
575
|
+
expect(Array.isArray(updatedBulk.data.transactions)).toBe(true);
|
|
576
|
+
});
|
|
577
|
+
|
|
578
|
+
it('should create receipt split transaction', async () => {
|
|
579
|
+
if (testConfig.skipE2ETests) return;
|
|
580
|
+
|
|
581
|
+
// Get categories for the receipt split
|
|
582
|
+
const categoriesResult = await executeToolCall(server, 'ynab:list_categories', {
|
|
583
|
+
budget_id: testBudgetId,
|
|
584
|
+
});
|
|
585
|
+
const categories = parseToolResult(categoriesResult);
|
|
586
|
+
|
|
587
|
+
// Find a non-hidden category
|
|
588
|
+
let testCategoryName: string | undefined;
|
|
589
|
+
for (const group of categories.data.category_groups) {
|
|
590
|
+
const availableCategory = group.categories?.find((cat: any) => !cat.hidden);
|
|
591
|
+
if (availableCategory) {
|
|
592
|
+
testCategoryName = availableCategory.name;
|
|
593
|
+
break;
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
if (!testCategoryName) {
|
|
598
|
+
console.warn('No available categories found for receipt split test');
|
|
599
|
+
return;
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
// Create receipt split transaction as part of transaction workflow
|
|
603
|
+
const receiptResult = await executeToolCall(server, 'ynab:create_receipt_split_transaction', {
|
|
604
|
+
budget_id: testBudgetId,
|
|
605
|
+
account_id: testAccountId,
|
|
606
|
+
date: new Date().toISOString().split('T')[0],
|
|
607
|
+
payee_name: `Receipt Workflow ${Date.now()}`,
|
|
608
|
+
tax_amount: 150,
|
|
609
|
+
receipt_items: [
|
|
610
|
+
{
|
|
611
|
+
category_name: testCategoryName,
|
|
612
|
+
amount: 2000,
|
|
613
|
+
},
|
|
614
|
+
],
|
|
615
|
+
});
|
|
616
|
+
|
|
617
|
+
// Validate create_receipt_split_transaction output schema
|
|
618
|
+
const receiptValidation = validateOutputSchema(
|
|
619
|
+
server,
|
|
620
|
+
'create_receipt_split_transaction',
|
|
621
|
+
receiptResult,
|
|
622
|
+
);
|
|
623
|
+
expect(receiptValidation.valid).toBe(true);
|
|
624
|
+
if (!receiptValidation.valid) {
|
|
625
|
+
console.error(
|
|
626
|
+
'create_receipt_split_transaction schema validation errors:',
|
|
627
|
+
receiptValidation.errors,
|
|
628
|
+
);
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
const receiptData = parseToolResult(receiptResult);
|
|
632
|
+
expect(receiptData.data?.transaction).toBeDefined();
|
|
633
|
+
|
|
634
|
+
// Track for cleanup
|
|
635
|
+
if (receiptData.data.transaction.id) {
|
|
636
|
+
cleanup.trackTransaction(receiptData.data.transaction.id);
|
|
637
|
+
}
|
|
638
|
+
});
|
|
639
|
+
});
|
|
640
|
+
|
|
641
|
+
describe('Complete Category Management Workflow', () => {
|
|
642
|
+
it('should list categories and update category budget', async () => {
|
|
643
|
+
if (testConfig.skipE2ETests) return;
|
|
644
|
+
|
|
645
|
+
// List all categories
|
|
646
|
+
const categoriesResult = await executeToolCall(server, 'ynab:list_categories', {
|
|
647
|
+
budget_id: testBudgetId,
|
|
648
|
+
});
|
|
649
|
+
|
|
650
|
+
// Validate list_categories output schema
|
|
651
|
+
const listValidation = validateOutputSchema(server, 'list_categories', categoriesResult);
|
|
652
|
+
expect(listValidation.valid).toBe(true);
|
|
653
|
+
if (!listValidation.valid) {
|
|
654
|
+
console.error('list_categories schema validation errors:', listValidation.errors);
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
const categories = parseToolResult(categoriesResult);
|
|
658
|
+
|
|
659
|
+
expect(categories.data).toBeDefined();
|
|
660
|
+
expect(categories.data.category_groups).toBeDefined();
|
|
661
|
+
expect(Array.isArray(categories.data.category_groups)).toBe(true);
|
|
662
|
+
|
|
663
|
+
// Find a category to test with
|
|
664
|
+
let testCategoryId: string | undefined;
|
|
665
|
+
let testCategory: any;
|
|
666
|
+
|
|
667
|
+
for (const group of categories.data.category_groups) {
|
|
668
|
+
if (group.categories && group.categories.length > 0) {
|
|
669
|
+
testCategory = group.categories.find((cat: any) => !cat.hidden);
|
|
670
|
+
if (testCategory) {
|
|
671
|
+
testCategoryId = testCategory.id;
|
|
672
|
+
break;
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
if (!testCategoryId) {
|
|
678
|
+
console.warn('No available categories found for testing');
|
|
679
|
+
return;
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
// Get specific category details
|
|
683
|
+
const categoryResult = await executeToolCall(server, 'ynab:get_category', {
|
|
684
|
+
budget_id: testBudgetId,
|
|
685
|
+
category_id: testCategoryId,
|
|
686
|
+
});
|
|
687
|
+
|
|
688
|
+
// Validate get_category output schema
|
|
689
|
+
const getValidation = validateOutputSchema(server, 'get_category', categoryResult);
|
|
690
|
+
expect(getValidation.valid).toBe(true);
|
|
691
|
+
if (!getValidation.valid) {
|
|
692
|
+
console.error('get_category schema validation errors:', getValidation.errors);
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
const category = parseToolResult(categoryResult);
|
|
696
|
+
|
|
697
|
+
expect(category.data).toBeDefined();
|
|
698
|
+
expect(category.data.category).toBeDefined();
|
|
699
|
+
YNABAssertions.assertCategory(category.data.category);
|
|
700
|
+
expect(category.data.category.id).toBe(testCategoryId);
|
|
701
|
+
|
|
702
|
+
// Update category budget
|
|
703
|
+
const newBudgetAmount = TestData.generateAmount(50); // $50.00
|
|
704
|
+
const updateResult = await executeToolCall(server, 'ynab:update_category', {
|
|
705
|
+
budget_id: testBudgetId,
|
|
706
|
+
category_id: testCategoryId,
|
|
707
|
+
budgeted: newBudgetAmount,
|
|
708
|
+
});
|
|
709
|
+
|
|
710
|
+
// Validate update_category output schema
|
|
711
|
+
const updateValidation = validateOutputSchema(server, 'update_category', updateResult);
|
|
712
|
+
expect(updateValidation.valid).toBe(true);
|
|
713
|
+
if (!updateValidation.valid) {
|
|
714
|
+
console.error('update_category schema validation errors:', updateValidation.errors);
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
const updatedCategory = parseToolResult(updateResult);
|
|
718
|
+
|
|
719
|
+
expect(updatedCategory.data).toBeDefined();
|
|
720
|
+
expect(updatedCategory.data.category).toBeDefined();
|
|
721
|
+
expect(updatedCategory.data.category.budgeted).toBe(newBudgetAmount);
|
|
722
|
+
});
|
|
723
|
+
});
|
|
724
|
+
|
|
725
|
+
describe('Complete Payee Management Workflow', () => {
|
|
726
|
+
it('should list and retrieve payee information', async () => {
|
|
727
|
+
if (testConfig.skipE2ETests) return;
|
|
728
|
+
|
|
729
|
+
// List all payees
|
|
730
|
+
const payeesResult = await executeToolCall(server, 'ynab:list_payees', {
|
|
731
|
+
budget_id: testBudgetId,
|
|
732
|
+
});
|
|
733
|
+
|
|
734
|
+
// Validate list_payees output schema
|
|
735
|
+
const listValidation = validateOutputSchema(server, 'list_payees', payeesResult);
|
|
736
|
+
expect(listValidation.valid).toBe(true);
|
|
737
|
+
if (!listValidation.valid) {
|
|
738
|
+
console.error('list_payees schema validation errors:', listValidation.errors);
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
const payees = parseToolResult(payeesResult);
|
|
742
|
+
|
|
743
|
+
expect(payees.data).toBeDefined();
|
|
744
|
+
expect(payees.data.payees).toBeDefined();
|
|
745
|
+
expect(Array.isArray(payees.data.payees)).toBe(true);
|
|
746
|
+
|
|
747
|
+
if (payees.data.payees.length > 0) {
|
|
748
|
+
// Validate payee structures
|
|
749
|
+
payees.data.payees.forEach(YNABAssertions.assertPayee);
|
|
750
|
+
|
|
751
|
+
// Get specific payee details
|
|
752
|
+
const testPayeeId = payees.data.payees[0].id;
|
|
753
|
+
const payeeResult = await executeToolCall(server, 'ynab:get_payee', {
|
|
754
|
+
budget_id: testBudgetId,
|
|
755
|
+
payee_id: testPayeeId,
|
|
756
|
+
});
|
|
757
|
+
|
|
758
|
+
// Validate get_payee output schema
|
|
759
|
+
const getValidation = validateOutputSchema(server, 'get_payee', payeeResult);
|
|
760
|
+
expect(getValidation.valid).toBe(true);
|
|
761
|
+
if (!getValidation.valid) {
|
|
762
|
+
console.error('get_payee schema validation errors:', getValidation.errors);
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
const payee = parseToolResult(payeeResult);
|
|
766
|
+
|
|
767
|
+
expect(payee.data).toBeDefined();
|
|
768
|
+
expect(payee.data.payee).toBeDefined();
|
|
769
|
+
YNABAssertions.assertPayee(payee.data.payee);
|
|
770
|
+
expect(payee.data.payee.id).toBe(testPayeeId);
|
|
771
|
+
}
|
|
772
|
+
});
|
|
773
|
+
});
|
|
774
|
+
|
|
775
|
+
describe('Complete Monthly Data Workflow', () => {
|
|
776
|
+
it('should retrieve monthly budget data', async () => {
|
|
777
|
+
if (testConfig.skipE2ETests) return;
|
|
778
|
+
|
|
779
|
+
// List all months
|
|
780
|
+
const monthsResult = await executeToolCall(server, 'ynab:list_months', {
|
|
781
|
+
budget_id: testBudgetId,
|
|
782
|
+
});
|
|
783
|
+
|
|
784
|
+
// Validate list_months output schema
|
|
785
|
+
const listValidation = validateOutputSchema(server, 'list_months', monthsResult);
|
|
786
|
+
expect(listValidation.valid).toBe(true);
|
|
787
|
+
if (!listValidation.valid) {
|
|
788
|
+
console.error('list_months schema validation errors:', listValidation.errors);
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
const months = parseToolResult(monthsResult);
|
|
792
|
+
|
|
793
|
+
expect(months.data).toBeDefined();
|
|
794
|
+
expect(months.data.months).toBeDefined();
|
|
795
|
+
expect(Array.isArray(months.data.months)).toBe(true);
|
|
796
|
+
expect(months.data.months.length).toBeGreaterThan(0);
|
|
797
|
+
|
|
798
|
+
// Get current month data
|
|
799
|
+
const currentMonth = getCurrentMonth();
|
|
800
|
+
const monthResult = await executeToolCall(server, 'ynab:get_month', {
|
|
801
|
+
budget_id: testBudgetId,
|
|
802
|
+
month: currentMonth,
|
|
803
|
+
});
|
|
804
|
+
|
|
805
|
+
// Validate get_month output schema
|
|
806
|
+
const getValidation = validateOutputSchema(server, 'get_month', monthResult);
|
|
807
|
+
expect(getValidation.valid).toBe(true);
|
|
808
|
+
if (!getValidation.valid) {
|
|
809
|
+
console.error('get_month schema validation errors:', getValidation.errors);
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
const month = parseToolResult(monthResult);
|
|
813
|
+
|
|
814
|
+
expect(month.data).toBeDefined();
|
|
815
|
+
expect(month.data.month).toBeDefined();
|
|
816
|
+
expect(typeof month.data.month.month).toBe('string');
|
|
817
|
+
expect(typeof month.data.month.income).toBe('number');
|
|
818
|
+
expect(typeof month.data.month.budgeted).toBe('number');
|
|
819
|
+
expect(typeof month.data.month.activity).toBe('number');
|
|
820
|
+
expect(typeof month.data.month.to_be_budgeted).toBe('number');
|
|
821
|
+
});
|
|
822
|
+
});
|
|
823
|
+
|
|
824
|
+
describe('Utility Tools Workflow', () => {
|
|
825
|
+
it('should convert amounts between dollars and milliunits', async () => {
|
|
826
|
+
if (testConfig.skipE2ETests) return;
|
|
827
|
+
|
|
828
|
+
// Convert dollars to milliunits
|
|
829
|
+
const toMilliunitsResult = await executeToolCall(server, 'ynab:convert_amount', {
|
|
830
|
+
amount: 25.5,
|
|
831
|
+
to_milliunits: true,
|
|
832
|
+
});
|
|
833
|
+
const milliunits = parseToolResult(toMilliunitsResult);
|
|
834
|
+
|
|
835
|
+
expect(milliunits.data?.conversion?.converted_amount).toBe(25500);
|
|
836
|
+
expect(milliunits.data?.conversion?.description).toContain('25500');
|
|
837
|
+
expect(milliunits.data?.conversion?.to_milliunits).toBe(true);
|
|
838
|
+
|
|
839
|
+
// Convert milliunits to dollars
|
|
840
|
+
const toDollarsResult = await executeToolCall(server, 'ynab:convert_amount', {
|
|
841
|
+
amount: 25500,
|
|
842
|
+
to_milliunits: false,
|
|
843
|
+
});
|
|
844
|
+
const dollars = parseToolResult(toDollarsResult);
|
|
845
|
+
|
|
846
|
+
expect(dollars.data?.conversion?.converted_amount).toBe(25.5);
|
|
847
|
+
expect(dollars.data?.conversion?.description).toContain('$25.50');
|
|
848
|
+
expect(dollars.data?.conversion?.to_milliunits).toBe(false);
|
|
849
|
+
});
|
|
850
|
+
});
|
|
851
|
+
|
|
852
|
+
describe('v0.8.x Architecture Integration Tests', () => {
|
|
853
|
+
describe('Cache System Verification', () => {
|
|
854
|
+
it('should demonstrate cache warming after default budget set', async () => {
|
|
855
|
+
if (testConfig.skipE2ETests) return;
|
|
856
|
+
|
|
857
|
+
// Enable caching for this test
|
|
858
|
+
testEnv.enableCache();
|
|
859
|
+
|
|
860
|
+
try {
|
|
861
|
+
// Get initial cache stats
|
|
862
|
+
const initialStatsResult = await executeToolCall(server, 'ynab:diagnostic_info');
|
|
863
|
+
const initialStats = parseToolResult(initialStatsResult);
|
|
864
|
+
const initialCacheStats = initialStats.data?.cache;
|
|
865
|
+
|
|
866
|
+
// Set default budget (should trigger cache warming)
|
|
867
|
+
await executeToolCall(server, 'ynab:set_default_budget', {
|
|
868
|
+
budget_id: testBudgetId,
|
|
869
|
+
});
|
|
870
|
+
|
|
871
|
+
// Allow time for cache warming (fire-and-forget)
|
|
872
|
+
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
873
|
+
|
|
874
|
+
// Get updated cache stats
|
|
875
|
+
const finalStatsResult = await executeToolCall(server, 'ynab:diagnostic_info');
|
|
876
|
+
const finalStats = parseToolResult(finalStatsResult);
|
|
877
|
+
const finalCacheStats = finalStats.data?.cache;
|
|
878
|
+
|
|
879
|
+
// Verify cache warming occurred
|
|
880
|
+
expect(finalCacheStats?.entries).toBeGreaterThan(initialCacheStats?.entries || 0);
|
|
881
|
+
expect(finalCacheStats?.hits).toBeGreaterThanOrEqual(0);
|
|
882
|
+
} finally {
|
|
883
|
+
// Restore original NODE_ENV
|
|
884
|
+
testEnv.restoreEnv();
|
|
885
|
+
}
|
|
886
|
+
});
|
|
887
|
+
|
|
888
|
+
it('should demonstrate LRU eviction and observability metrics', async () => {
|
|
889
|
+
if (testConfig.skipE2ETests) return;
|
|
890
|
+
|
|
891
|
+
// Enable caching for this test (bypass NODE_ENV='test' check)
|
|
892
|
+
testEnv.enableCache();
|
|
893
|
+
|
|
894
|
+
try {
|
|
895
|
+
// Get initial cache stats
|
|
896
|
+
const initialStatsResult = await executeToolCall(server, 'ynab:diagnostic_info');
|
|
897
|
+
const initialStats = parseToolResult(initialStatsResult);
|
|
898
|
+
const initialCacheStats = initialStats.data?.cache;
|
|
899
|
+
|
|
900
|
+
// Perform operations that should hit cache
|
|
901
|
+
await executeToolCall(server, 'ynab:list_accounts', { budget_id: testBudgetId });
|
|
902
|
+
await executeToolCall(server, 'ynab:list_categories', { budget_id: testBudgetId });
|
|
903
|
+
await executeToolCall(server, 'ynab:list_payees', { budget_id: testBudgetId });
|
|
904
|
+
|
|
905
|
+
// Perform same operations again (should hit cache)
|
|
906
|
+
await executeToolCall(server, 'ynab:list_accounts', { budget_id: testBudgetId });
|
|
907
|
+
await executeToolCall(server, 'ynab:list_categories', { budget_id: testBudgetId });
|
|
908
|
+
await executeToolCall(server, 'ynab:list_payees', { budget_id: testBudgetId });
|
|
909
|
+
|
|
910
|
+
// Get final cache stats
|
|
911
|
+
const finalStatsResult = await executeToolCall(server, 'ynab:diagnostic_info');
|
|
912
|
+
const finalStats = parseToolResult(finalStatsResult);
|
|
913
|
+
const finalCacheStats = finalStats.data?.cache;
|
|
914
|
+
|
|
915
|
+
// Verify cache behavior
|
|
916
|
+
expect(finalCacheStats?.hits).toBeGreaterThan(initialCacheStats?.hits || 0);
|
|
917
|
+
expect(finalCacheStats?.misses).toBeGreaterThan(initialCacheStats?.misses || 0);
|
|
918
|
+
expect(finalCacheStats?.hits).toBeGreaterThan(0);
|
|
919
|
+
expect(finalCacheStats?.entries).toBeGreaterThan(0);
|
|
920
|
+
} finally {
|
|
921
|
+
// Restore original NODE_ENV
|
|
922
|
+
testEnv.restoreEnv();
|
|
923
|
+
}
|
|
924
|
+
});
|
|
925
|
+
|
|
926
|
+
it('should demonstrate cache invalidation on write operations', async () => {
|
|
927
|
+
if (testConfig.skipE2ETests) return;
|
|
928
|
+
|
|
929
|
+
// Enable caching for this test
|
|
930
|
+
testEnv.enableCache();
|
|
931
|
+
|
|
932
|
+
try {
|
|
933
|
+
// Prime cache by listing accounts
|
|
934
|
+
await executeToolCall(server, 'ynab:list_accounts', { budget_id: testBudgetId });
|
|
935
|
+
|
|
936
|
+
// Create new account (should invalidate accounts cache)
|
|
937
|
+
const accountName = TestData.generateAccountName();
|
|
938
|
+
const createResult = await executeToolCall(server, 'ynab:create_account', {
|
|
939
|
+
budget_id: testBudgetId,
|
|
940
|
+
name: accountName,
|
|
941
|
+
type: 'checking',
|
|
942
|
+
balance: 10000,
|
|
943
|
+
});
|
|
944
|
+
|
|
945
|
+
// Validate output schema
|
|
946
|
+
const createValidation = validateOutputSchema(server, 'create_account', createResult);
|
|
947
|
+
expect(createValidation.valid).toBe(true);
|
|
948
|
+
if (!createValidation.valid) {
|
|
949
|
+
console.error('create_account schema validation errors:', createValidation.errors);
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
const createdAccount = parseToolResult(createResult);
|
|
953
|
+
cleanup.trackAccount(createdAccount.data.account.id);
|
|
954
|
+
|
|
955
|
+
// List accounts again (should show new account due to cache invalidation)
|
|
956
|
+
const accountsResult = await executeToolCall(server, 'ynab:list_accounts', {
|
|
957
|
+
budget_id: testBudgetId,
|
|
958
|
+
});
|
|
959
|
+
const accounts = parseToolResult(accountsResult);
|
|
960
|
+
|
|
961
|
+
const foundAccount = accounts.data.accounts.find(
|
|
962
|
+
(acc: any) => acc.id === createdAccount.data.account.id,
|
|
963
|
+
);
|
|
964
|
+
expect(foundAccount).toBeDefined();
|
|
965
|
+
expect(foundAccount.name).toBe(accountName);
|
|
966
|
+
} finally {
|
|
967
|
+
// Restore original NODE_ENV
|
|
968
|
+
testEnv.restoreEnv();
|
|
969
|
+
}
|
|
970
|
+
});
|
|
971
|
+
});
|
|
972
|
+
|
|
973
|
+
describe('Budget Resolution Consistency', () => {
|
|
974
|
+
it('should provide consistent error messages for missing budget ID', async () => {
|
|
975
|
+
if (testConfig.skipE2ETests) return;
|
|
976
|
+
|
|
977
|
+
// Clear default budget first
|
|
978
|
+
server.clearDefaultBudget();
|
|
979
|
+
|
|
980
|
+
// Test multiple tools for consistent error handling
|
|
981
|
+
const toolsToTest = [
|
|
982
|
+
'ynab:list_accounts',
|
|
983
|
+
'ynab:list_categories',
|
|
984
|
+
'ynab:list_payees',
|
|
985
|
+
'ynab:list_transactions',
|
|
986
|
+
];
|
|
987
|
+
|
|
988
|
+
for (const toolName of toolsToTest) {
|
|
989
|
+
const result = await executeToolCall(server, toolName, {});
|
|
990
|
+
expect(isErrorResult(result)).toBe(true);
|
|
991
|
+
const errorMessage = getErrorMessage(result);
|
|
992
|
+
expect(errorMessage).toContain('No budget ID provided and no default budget set');
|
|
993
|
+
expect(errorMessage).toContain('set_default_budget');
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
// Restore default budget for other tests
|
|
997
|
+
await executeToolCall(server, 'ynab:set_default_budget', { budget_id: testBudgetId });
|
|
998
|
+
});
|
|
999
|
+
|
|
1000
|
+
it('should handle invalid budget ID format consistently', async () => {
|
|
1001
|
+
if (testConfig.skipE2ETests) return;
|
|
1002
|
+
|
|
1003
|
+
const invalidBudgetId = 'invalid-format';
|
|
1004
|
+
const toolsToTest = ['ynab:list_accounts', 'ynab:list_categories', 'ynab:list_payees'];
|
|
1005
|
+
|
|
1006
|
+
for (const toolName of toolsToTest) {
|
|
1007
|
+
const result = await executeToolCall(server, toolName, { budget_id: invalidBudgetId });
|
|
1008
|
+
expect(isErrorResult(result)).toBe(true);
|
|
1009
|
+
// All tools should provide similar error handling
|
|
1010
|
+
expect(result.content).toBeDefined();
|
|
1011
|
+
expect(result.content.length).toBeGreaterThan(0);
|
|
1012
|
+
}
|
|
1013
|
+
});
|
|
1014
|
+
});
|
|
1015
|
+
|
|
1016
|
+
describe('Month Data Integration', () => {
|
|
1017
|
+
it('should execute month data tools', async () => {
|
|
1018
|
+
if (testConfig.skipE2ETests) return;
|
|
1019
|
+
|
|
1020
|
+
// Test get_month tool
|
|
1021
|
+
const currentMonth = new Date().toISOString().substring(0, 8) + '01';
|
|
1022
|
+
const monthResult = await executeToolCall(server, 'ynab:get_month', {
|
|
1023
|
+
budget_id: testBudgetId,
|
|
1024
|
+
month: currentMonth,
|
|
1025
|
+
});
|
|
1026
|
+
const monthData = parseToolResult(monthResult);
|
|
1027
|
+
|
|
1028
|
+
expect(monthData.data, 'Month data should return data object').toBeDefined();
|
|
1029
|
+
expect(monthData.data.month || monthData.data, 'Should contain month info').toBeDefined();
|
|
1030
|
+
|
|
1031
|
+
// Test list_months tool
|
|
1032
|
+
const monthsResult = await executeToolCall(server, 'ynab:list_months', {
|
|
1033
|
+
budget_id: testBudgetId,
|
|
1034
|
+
});
|
|
1035
|
+
const monthsData = parseToolResult(monthsResult);
|
|
1036
|
+
|
|
1037
|
+
expect(monthsData.data).toBeDefined();
|
|
1038
|
+
expect(Array.isArray(monthsData.data.months), 'Should return months array').toBe(true);
|
|
1039
|
+
});
|
|
1040
|
+
});
|
|
1041
|
+
|
|
1042
|
+
describe('Tool Registry Integration', () => {
|
|
1043
|
+
it('should demonstrate tool registry functionality', async () => {
|
|
1044
|
+
if (testConfig.skipE2ETests) return;
|
|
1045
|
+
|
|
1046
|
+
// Test that tool listing includes all expected tools
|
|
1047
|
+
const toolsResult = await server.handleListTools();
|
|
1048
|
+
expect(toolsResult.tools).toBeDefined();
|
|
1049
|
+
expect(Array.isArray(toolsResult.tools)).toBe(true);
|
|
1050
|
+
expect(toolsResult.tools.length).toBeGreaterThan(20);
|
|
1051
|
+
|
|
1052
|
+
// Verify key v0.8.x tools are present (tools are registered without ynab: prefix)
|
|
1053
|
+
const toolNames = toolsResult.tools.map((tool: any) => tool.name);
|
|
1054
|
+
expect(toolNames, 'Should contain list_budgets tool').toContain('list_budgets');
|
|
1055
|
+
expect(toolNames, 'Should contain get_month tool').toContain('get_month');
|
|
1056
|
+
expect(toolNames, 'Should contain list_months tool').toContain('list_months');
|
|
1057
|
+
expect(toolNames, 'Should contain compare_transactions tool').toContain(
|
|
1058
|
+
'compare_transactions',
|
|
1059
|
+
);
|
|
1060
|
+
expect(toolNames, 'Should contain diagnostic_info tool').toContain('diagnostic_info');
|
|
1061
|
+
|
|
1062
|
+
// Test that each tool has proper schema validation
|
|
1063
|
+
for (const tool of toolsResult.tools) {
|
|
1064
|
+
expect(tool.name).toBeDefined();
|
|
1065
|
+
expect(tool.description).toBeDefined();
|
|
1066
|
+
expect(tool.inputSchema).toBeDefined();
|
|
1067
|
+
|
|
1068
|
+
// Verify that all tools define outputSchema (as guaranteed by CHANGELOG.md and docs/reference/TOOLS.md)
|
|
1069
|
+
// Note: Some utility tools like diagnostic_info or clear_cache may not define structured outputs,
|
|
1070
|
+
// but most data-retrieval and CRUD tools should have output schemas.
|
|
1071
|
+
expect(
|
|
1072
|
+
tool.outputSchema,
|
|
1073
|
+
`Tool '${tool.name}' should define an outputSchema`,
|
|
1074
|
+
).toBeDefined();
|
|
1075
|
+
}
|
|
1076
|
+
});
|
|
1077
|
+
});
|
|
1078
|
+
|
|
1079
|
+
describe('Module Integration Tests', () => {
|
|
1080
|
+
it('should verify resource manager integration', async () => {
|
|
1081
|
+
if (testConfig.skipE2ETests) return;
|
|
1082
|
+
|
|
1083
|
+
// Test resource listing
|
|
1084
|
+
const resourcesResult = await server.handleListResources();
|
|
1085
|
+
expect(resourcesResult.resources).toBeDefined();
|
|
1086
|
+
expect(Array.isArray(resourcesResult.resources)).toBe(true);
|
|
1087
|
+
|
|
1088
|
+
// Test reading a specific resource
|
|
1089
|
+
if (resourcesResult.resources.length > 0) {
|
|
1090
|
+
const resource = resourcesResult.resources[0];
|
|
1091
|
+
const readResult = await server.handleReadResource({
|
|
1092
|
+
uri: resource.uri,
|
|
1093
|
+
});
|
|
1094
|
+
expect(readResult.contents).toBeDefined();
|
|
1095
|
+
}
|
|
1096
|
+
});
|
|
1097
|
+
|
|
1098
|
+
it('should verify prompt manager integration', async () => {
|
|
1099
|
+
if (testConfig.skipE2ETests) return;
|
|
1100
|
+
|
|
1101
|
+
// Test prompt listing
|
|
1102
|
+
const promptsResult = await server.handleListPrompts();
|
|
1103
|
+
expect(promptsResult.prompts).toBeDefined();
|
|
1104
|
+
expect(Array.isArray(promptsResult.prompts)).toBe(true);
|
|
1105
|
+
|
|
1106
|
+
// Test getting a specific prompt
|
|
1107
|
+
if (promptsResult.prompts.length > 0) {
|
|
1108
|
+
const prompt = promptsResult.prompts[0];
|
|
1109
|
+
const getResult = await server.handleGetPrompt({
|
|
1110
|
+
name: prompt.name,
|
|
1111
|
+
arguments: {},
|
|
1112
|
+
});
|
|
1113
|
+
expect(getResult.messages).toBeDefined();
|
|
1114
|
+
}
|
|
1115
|
+
});
|
|
1116
|
+
|
|
1117
|
+
it('should verify diagnostic manager integration', async () => {
|
|
1118
|
+
if (testConfig.skipE2ETests) return;
|
|
1119
|
+
|
|
1120
|
+
// Test diagnostic info tool
|