@dizzlkheinz/ynab-mcpb 0.17.1 → 0.18.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.
Files changed (54) hide show
  1. package/.github/workflows/ci-tests.yml +4 -4
  2. package/.github/workflows/full-integration.yml +2 -2
  3. package/.github/workflows/publish.yml +1 -1
  4. package/.github/workflows/release.yml +2 -2
  5. package/CHANGELOG.md +10 -1
  6. package/CLAUDE.md +9 -6
  7. package/README.md +6 -1
  8. package/dist/bundle/index.cjs +52 -52
  9. package/dist/server/YNABMCPServer.d.ts +7 -2
  10. package/dist/server/YNABMCPServer.js +42 -11
  11. package/dist/server/cacheManager.js +6 -5
  12. package/dist/server/completions.d.ts +25 -0
  13. package/dist/server/completions.js +160 -0
  14. package/dist/server/config.d.ts +2 -2
  15. package/dist/server/errorHandler.js +1 -0
  16. package/dist/server/rateLimiter.js +3 -1
  17. package/dist/server/resources.d.ts +1 -0
  18. package/dist/server/resources.js +33 -16
  19. package/dist/server/securityMiddleware.d.ts +2 -1
  20. package/dist/server/securityMiddleware.js +1 -0
  21. package/dist/server/toolRegistry.d.ts +9 -0
  22. package/dist/server/toolRegistry.js +11 -0
  23. package/dist/tools/adapters.d.ts +3 -1
  24. package/dist/tools/adapters.js +1 -0
  25. package/dist/tools/reconciliation/executor.d.ts +2 -0
  26. package/dist/tools/reconciliation/executor.js +26 -1
  27. package/dist/tools/reconciliation/index.d.ts +3 -2
  28. package/dist/tools/reconciliation/index.js +4 -3
  29. package/docs/reference/API.md +68 -27
  30. package/package.json +2 -2
  31. package/src/__tests__/comprehensive.integration.test.ts +4 -4
  32. package/src/__tests__/performance.test.ts +1 -2
  33. package/src/__tests__/smoke.e2e.test.ts +70 -0
  34. package/src/__tests__/testUtils.ts +2 -113
  35. package/src/server/YNABMCPServer.ts +64 -10
  36. package/src/server/__tests__/completions.integration.test.ts +117 -0
  37. package/src/server/__tests__/completions.test.ts +319 -0
  38. package/src/server/__tests__/resources.template.test.ts +3 -3
  39. package/src/server/__tests__/resources.test.ts +3 -3
  40. package/src/server/__tests__/toolRegistration.test.ts +1 -1
  41. package/src/server/cacheManager.ts +7 -6
  42. package/src/server/completions.ts +279 -0
  43. package/src/server/errorHandler.ts +1 -0
  44. package/src/server/rateLimiter.ts +4 -1
  45. package/src/server/resources.ts +49 -13
  46. package/src/server/securityMiddleware.ts +1 -0
  47. package/src/server/toolRegistry.ts +42 -0
  48. package/src/tools/adapters.ts +22 -1
  49. package/src/tools/reconciliation/__tests__/executor.progress.test.ts +462 -0
  50. package/src/tools/reconciliation/executor.ts +55 -1
  51. package/src/tools/reconciliation/index.ts +7 -3
  52. package/vitest.config.ts +2 -0
  53. package/src/__tests__/delta.performance.test.ts +0 -80
  54. package/src/__tests__/workflows.e2e.test.ts +0 -1658
@@ -1,1658 +0,0 @@
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('v0.8.x Architecture Integration Tests', () => {
825
- describe('Cache System Verification', () => {
826
- it('should demonstrate cache warming after default budget set', async () => {
827
- if (testConfig.skipE2ETests) return;
828
-
829
- // Enable caching for this test
830
- testEnv.enableCache();
831
-
832
- try {
833
- // Get initial cache stats
834
- const initialStatsResult = await executeToolCall(server, 'ynab:diagnostic_info');
835
- const initialStats = parseToolResult(initialStatsResult);
836
- const initialCacheStats = initialStats.data?.cache;
837
-
838
- // Set default budget (should trigger cache warming)
839
- await executeToolCall(server, 'ynab:set_default_budget', {
840
- budget_id: testBudgetId,
841
- });
842
-
843
- // Allow time for cache warming (fire-and-forget)
844
- await new Promise((resolve) => setTimeout(resolve, 1000));
845
-
846
- // Get updated cache stats
847
- const finalStatsResult = await executeToolCall(server, 'ynab:diagnostic_info');
848
- const finalStats = parseToolResult(finalStatsResult);
849
- const finalCacheStats = finalStats.data?.cache;
850
-
851
- // Verify cache warming occurred
852
- expect(finalCacheStats?.entries).toBeGreaterThan(initialCacheStats?.entries || 0);
853
- expect(finalCacheStats?.hits).toBeGreaterThanOrEqual(0);
854
- } finally {
855
- // Restore original NODE_ENV
856
- testEnv.restoreEnv();
857
- }
858
- });
859
-
860
- it('should demonstrate LRU eviction and observability metrics', async () => {
861
- if (testConfig.skipE2ETests) return;
862
-
863
- // Enable caching for this test (bypass NODE_ENV='test' check)
864
- testEnv.enableCache();
865
-
866
- try {
867
- // Get initial cache stats
868
- const initialStatsResult = await executeToolCall(server, 'ynab:diagnostic_info');
869
- const initialStats = parseToolResult(initialStatsResult);
870
- const initialCacheStats = initialStats.data?.cache;
871
-
872
- // Perform operations that should hit cache
873
- await executeToolCall(server, 'ynab:list_accounts', { budget_id: testBudgetId });
874
- await executeToolCall(server, 'ynab:list_categories', { budget_id: testBudgetId });
875
- await executeToolCall(server, 'ynab:list_payees', { budget_id: testBudgetId });
876
-
877
- // Perform same operations again (should hit cache)
878
- await executeToolCall(server, 'ynab:list_accounts', { budget_id: testBudgetId });
879
- await executeToolCall(server, 'ynab:list_categories', { budget_id: testBudgetId });
880
- await executeToolCall(server, 'ynab:list_payees', { budget_id: testBudgetId });
881
-
882
- // Get final cache stats
883
- const finalStatsResult = await executeToolCall(server, 'ynab:diagnostic_info');
884
- const finalStats = parseToolResult(finalStatsResult);
885
- const finalCacheStats = finalStats.data?.cache;
886
-
887
- // Verify cache behavior
888
- expect(finalCacheStats?.hits).toBeGreaterThan(initialCacheStats?.hits || 0);
889
- expect(finalCacheStats?.misses).toBeGreaterThan(initialCacheStats?.misses || 0);
890
- expect(finalCacheStats?.hits).toBeGreaterThan(0);
891
- expect(finalCacheStats?.entries).toBeGreaterThan(0);
892
- } finally {
893
- // Restore original NODE_ENV
894
- testEnv.restoreEnv();
895
- }
896
- });
897
-
898
- it('should demonstrate cache invalidation on write operations', async () => {
899
- if (testConfig.skipE2ETests) return;
900
-
901
- // Enable caching for this test
902
- testEnv.enableCache();
903
-
904
- try {
905
- // Prime cache by listing accounts
906
- await executeToolCall(server, 'ynab:list_accounts', { budget_id: testBudgetId });
907
-
908
- // Create new account (should invalidate accounts cache)
909
- const accountName = TestData.generateAccountName();
910
- const createResult = await executeToolCall(server, 'ynab:create_account', {
911
- budget_id: testBudgetId,
912
- name: accountName,
913
- type: 'checking',
914
- balance: 10000,
915
- });
916
-
917
- // Validate output schema
918
- const createValidation = validateOutputSchema(server, 'create_account', createResult);
919
- expect(createValidation.valid).toBe(true);
920
- if (!createValidation.valid) {
921
- console.error('create_account schema validation errors:', createValidation.errors);
922
- }
923
-
924
- const createdAccount = parseToolResult(createResult);
925
- cleanup.trackAccount(createdAccount.data.account.id);
926
-
927
- // List accounts again (should show new account due to cache invalidation)
928
- const accountsResult = await executeToolCall(server, 'ynab:list_accounts', {
929
- budget_id: testBudgetId,
930
- });
931
- const accounts = parseToolResult(accountsResult);
932
-
933
- const foundAccount = accounts.data.accounts.find(
934
- (acc: any) => acc.id === createdAccount.data.account.id,
935
- );
936
- expect(foundAccount).toBeDefined();
937
- expect(foundAccount.name).toBe(accountName);
938
- } finally {
939
- // Restore original NODE_ENV
940
- testEnv.restoreEnv();
941
- }
942
- });
943
- });
944
-
945
- describe('Budget Resolution Consistency', () => {
946
- it('should provide consistent error messages for missing budget ID', async () => {
947
- if (testConfig.skipE2ETests) return;
948
-
949
- // Clear default budget first
950
- server.clearDefaultBudget();
951
-
952
- // Test multiple tools for consistent error handling
953
- const toolsToTest = [
954
- 'ynab:list_accounts',
955
- 'ynab:list_categories',
956
- 'ynab:list_payees',
957
- 'ynab:list_transactions',
958
- ];
959
-
960
- for (const toolName of toolsToTest) {
961
- const result = await executeToolCall(server, toolName, {});
962
- expect(isErrorResult(result)).toBe(true);
963
- const errorMessage = getErrorMessage(result);
964
- expect(errorMessage).toContain('No budget ID provided and no default budget set');
965
- expect(errorMessage).toContain('set_default_budget');
966
- }
967
-
968
- // Restore default budget for other tests
969
- await executeToolCall(server, 'ynab:set_default_budget', { budget_id: testBudgetId });
970
- });
971
-
972
- it('should handle invalid budget ID format consistently', async () => {
973
- if (testConfig.skipE2ETests) return;
974
-
975
- const invalidBudgetId = 'invalid-format';
976
- const toolsToTest = ['ynab:list_accounts', 'ynab:list_categories', 'ynab:list_payees'];
977
-
978
- for (const toolName of toolsToTest) {
979
- const result = await executeToolCall(server, toolName, { budget_id: invalidBudgetId });
980
- expect(isErrorResult(result)).toBe(true);
981
- // All tools should provide similar error handling
982
- expect(result.content).toBeDefined();
983
- expect(result.content.length).toBeGreaterThan(0);
984
- }
985
- });
986
- });
987
-
988
- describe('Month Data Integration', () => {
989
- it('should execute month data tools', async () => {
990
- if (testConfig.skipE2ETests) return;
991
-
992
- // Test get_month tool
993
- const currentMonth = new Date().toISOString().substring(0, 8) + '01';
994
- const monthResult = await executeToolCall(server, 'ynab:get_month', {
995
- budget_id: testBudgetId,
996
- month: currentMonth,
997
- });
998
- const monthData = parseToolResult(monthResult);
999
-
1000
- expect(monthData.data, 'Month data should return data object').toBeDefined();
1001
- expect(monthData.data.month || monthData.data, 'Should contain month info').toBeDefined();
1002
-
1003
- // Test list_months tool
1004
- const monthsResult = await executeToolCall(server, 'ynab:list_months', {
1005
- budget_id: testBudgetId,
1006
- });
1007
- const monthsData = parseToolResult(monthsResult);
1008
-
1009
- expect(monthsData.data).toBeDefined();
1010
- expect(Array.isArray(monthsData.data.months), 'Should return months array').toBe(true);
1011
- });
1012
- });
1013
-
1014
- describe('Tool Registry Integration', () => {
1015
- it('should demonstrate tool registry functionality', async () => {
1016
- if (testConfig.skipE2ETests) return;
1017
-
1018
- // Test that tool listing includes all expected tools
1019
- const toolsResult = await server.handleListTools();
1020
- expect(toolsResult.tools).toBeDefined();
1021
- expect(Array.isArray(toolsResult.tools)).toBe(true);
1022
- expect(toolsResult.tools.length).toBeGreaterThan(20);
1023
-
1024
- // Verify key v0.8.x tools are present (tools are registered without ynab: prefix)
1025
- const toolNames = toolsResult.tools.map((tool: any) => tool.name);
1026
- expect(toolNames, 'Should contain list_budgets tool').toContain('list_budgets');
1027
- expect(toolNames, 'Should contain get_month tool').toContain('get_month');
1028
- expect(toolNames, 'Should contain list_months tool').toContain('list_months');
1029
- expect(toolNames, 'Should contain compare_transactions tool').toContain(
1030
- 'compare_transactions',
1031
- );
1032
- expect(toolNames, 'Should contain diagnostic_info tool').toContain('diagnostic_info');
1033
-
1034
- // Test that each tool has proper schema validation
1035
- for (const tool of toolsResult.tools) {
1036
- expect(tool.name).toBeDefined();
1037
- expect(tool.description).toBeDefined();
1038
- expect(tool.inputSchema).toBeDefined();
1039
-
1040
- // Output schemas are optional; tools may omit them.
1041
- }
1042
- });
1043
- });
1044
-
1045
- describe('Module Integration Tests', () => {
1046
- it('should verify resource manager integration', async () => {
1047
- if (testConfig.skipE2ETests) return;
1048
-
1049
- // Test resource listing
1050
- const resourcesResult = await server.handleListResources();
1051
- expect(resourcesResult.resources).toBeDefined();
1052
- expect(Array.isArray(resourcesResult.resources)).toBe(true);
1053
-
1054
- // Test reading a specific resource
1055
- if (resourcesResult.resources.length > 0) {
1056
- const resource = resourcesResult.resources[0];
1057
- const readResult = await server.handleReadResource({
1058
- uri: resource.uri,
1059
- });
1060
- expect(readResult.contents).toBeDefined();
1061
- }
1062
- });
1063
-
1064
- it('should verify prompt manager integration', async () => {
1065
- if (testConfig.skipE2ETests) return;
1066
-
1067
- // Test prompt listing
1068
- const promptsResult = await server.handleListPrompts();
1069
- expect(promptsResult.prompts).toBeDefined();
1070
- expect(Array.isArray(promptsResult.prompts)).toBe(true);
1071
-
1072
- // Test getting a specific prompt
1073
- if (promptsResult.prompts.length > 0) {
1074
- const prompt = promptsResult.prompts[0];
1075
- const getResult = await server.handleGetPrompt({
1076
- name: prompt.name,
1077
- arguments: {},
1078
- });
1079
- expect(getResult.messages).toBeDefined();
1080
- }
1081
- });
1082
-
1083
- it('should verify diagnostic manager integration', async () => {
1084
- if (testConfig.skipE2ETests) return;
1085
-
1086
- // Test diagnostic info tool
1087
- const diagnosticResult = await executeToolCall(server, 'ynab:diagnostic_info');
1088
- const diagnostic = parseToolResult(diagnosticResult);
1089
-
1090
- expect(diagnostic.data, 'Diagnostic should return data object').toBeDefined();
1091
-
1092
- // The diagnostic data is in the root of data, not under diagnostics
1093
- expect(diagnostic.data.timestamp, 'Should contain timestamp').toBeDefined();
1094
- expect(diagnostic.data.server, 'Should contain server info').toBeDefined();
1095
- expect(diagnostic.data.memory, 'Should contain memory info').toBeDefined();
1096
- expect(diagnostic.data.environment, 'Should contain environment info').toBeDefined();
1097
- expect(diagnostic.data.cache, 'Should contain cache info').toBeDefined();
1098
- });
1099
- });
1100
-
1101
- describe('Backward Compatibility Verification', () => {
1102
- it('should maintain v0.7.x API compatibility', async () => {
1103
- if (testConfig.skipE2ETests) return;
1104
-
1105
- // Test that all existing tool calls work identically
1106
- const v7Tools = [
1107
- { name: 'ynab:list_budgets', args: {} },
1108
- { name: 'ynab:list_accounts', args: { budget_id: testBudgetId } },
1109
- { name: 'ynab:list_categories', args: { budget_id: testBudgetId } },
1110
- { name: 'ynab:list_payees', args: { budget_id: testBudgetId } },
1111
- { name: 'ynab:get_user', args: {} },
1112
- ];
1113
-
1114
- for (const tool of v7Tools) {
1115
- const result = await executeToolCall(server, tool.name, tool.args);
1116
- const parsed = parseToolResult(result);
1117
-
1118
- // Verify response structure is consistent with v0.7.x
1119
- expect(parsed.data).toBeDefined();
1120
- expect(parsed.success).toBe(true);
1121
- }
1122
- });
1123
-
1124
- it('should maintain response format consistency', async () => {
1125
- if (testConfig.skipE2ETests) return;
1126
-
1127
- // Test that response formats match expected v0.7.x structure
1128
- const budgetsResult = await executeToolCall(server, 'ynab:list_budgets');
1129
- const budgets = parseToolResult(budgetsResult);
1130
-
1131
- // Verify standard response wrapper
1132
- expect(budgets).toHaveProperty('success');
1133
- expect(budgets).toHaveProperty('data');
1134
- expect(budgets.success).toBe(true);
1135
- expect(budgets.data).toHaveProperty('budgets');
1136
- });
1137
- });
1138
-
1139
- describe('Performance Regression Tests', () => {
1140
- it('should not introduce performance regressions', async () => {
1141
- if (testConfig.skipE2ETests) return;
1142
-
1143
- // Test response times for common operations
1144
- const operations = [
1145
- { name: 'ynab:list_budgets', args: {} },
1146
- { name: 'ynab:list_accounts', args: { budget_id: testBudgetId } },
1147
- { name: 'ynab:list_categories', args: { budget_id: testBudgetId } },
1148
- ];
1149
-
1150
- for (const operation of operations) {
1151
- const startTime = Date.now();
1152
- await executeToolCall(server, operation.name, operation.args);
1153
- const endTime = Date.now();
1154
- const duration = endTime - startTime;
1155
-
1156
- // Response should be reasonably fast (under 5 seconds for E2E)
1157
- expect(duration).toBeLessThan(5000);
1158
- }
1159
- });
1160
-
1161
- it('should demonstrate cache performance improvements', async () => {
1162
- if (testConfig.skipE2ETests) return;
1163
-
1164
- // Enable caching for this test
1165
- testEnv.enableCache();
1166
-
1167
- try {
1168
- // First call (cache miss)
1169
- const startTime1 = Date.now();
1170
- await executeToolCall(server, 'ynab:list_accounts', { budget_id: testBudgetId });
1171
- const duration1 = Date.now() - startTime1;
1172
-
1173
- // Second call (cache hit)
1174
- const startTime2 = Date.now();
1175
- await executeToolCall(server, 'ynab:list_accounts', { budget_id: testBudgetId });
1176
- const duration2 = Date.now() - startTime2;
1177
-
1178
- // Cached call should be faster (allowing for some variance in E2E environment)
1179
- expect(duration2).toBeLessThanOrEqual(duration1 + 500); // Allow 500ms tolerance for E2E environment
1180
- } finally {
1181
- // Restore original NODE_ENV
1182
- testEnv.restoreEnv();
1183
- }
1184
- });
1185
- });
1186
-
1187
- describe('Enhanced Error Handling', () => {
1188
- it('should provide improved error messages with actionable suggestions', async () => {
1189
- if (testConfig.skipE2ETests) return;
1190
-
1191
- // Clear default budget
1192
- server.clearDefaultBudget();
1193
-
1194
- const result = await executeToolCall(server, 'ynab:list_accounts', {});
1195
- expect(isErrorResult(result)).toBe(true);
1196
- const errorMessage = getErrorMessage(result);
1197
-
1198
- // Error should provide actionable guidance
1199
- expect(errorMessage).toContain('No budget ID provided and no default budget set');
1200
- expect(errorMessage).toContain('set_default_budget');
1201
- expect(errorMessage).toContain('budget_id parameter');
1202
-
1203
- // Restore default budget
1204
- await executeToolCall(server, 'ynab:set_default_budget', { budget_id: testBudgetId });
1205
- });
1206
- });
1207
- });
1208
-
1209
- describe('Error Handling Workflow', () => {
1210
- it('should handle invalid budget ID gracefully', async () => {
1211
- if (testConfig.skipE2ETests) return;
1212
-
1213
- const result = await executeToolCall(server, 'ynab:get_budget', {
1214
- budget_id: 'invalid-budget-id',
1215
- });
1216
- expect(isErrorResult(result)).toBe(true);
1217
- expect(result.content).toBeDefined();
1218
- expect(result.content.length).toBeGreaterThan(0);
1219
-
1220
- // Verify error response contract: error responses should not have success: true
1221
- const textContent = result.content.find((c) => c.type === 'text');
1222
- if (textContent && textContent.type === 'text') {
1223
- const parsed = JSON.parse(textContent.text);
1224
- expect(parsed).toHaveProperty('error');
1225
- // If success property exists, it should be false for errors
1226
- if ('success' in parsed) {
1227
- expect(parsed.success).toBe(false);
1228
- }
1229
- }
1230
- });
1231
-
1232
- it('should handle invalid account ID gracefully', async () => {
1233
- if (testConfig.skipE2ETests) return;
1234
-
1235
- const result = await executeToolCall(server, 'ynab:get_account', {
1236
- budget_id: testBudgetId,
1237
- account_id: 'invalid-account-id',
1238
- });
1239
- expect(isErrorResult(result)).toBe(true);
1240
- expect(result.content).toBeDefined();
1241
- expect(result.content.length).toBeGreaterThan(0);
1242
-
1243
- // Verify error response contract
1244
- const textContent = result.content.find((c) => c.type === 'text');
1245
- if (textContent && textContent.type === 'text') {
1246
- const parsed = JSON.parse(textContent.text);
1247
- expect(parsed).toHaveProperty('error');
1248
- if ('success' in parsed) {
1249
- expect(parsed.success).toBe(false);
1250
- }
1251
- }
1252
- });
1253
-
1254
- it('should handle invalid transaction ID gracefully', async () => {
1255
- if (testConfig.skipE2ETests) return;
1256
-
1257
- const result = await executeToolCall(server, 'ynab:get_transaction', {
1258
- budget_id: testBudgetId,
1259
- transaction_id: 'invalid-transaction-id',
1260
- });
1261
- expect(isErrorResult(result)).toBe(true);
1262
- expect(result.content).toBeDefined();
1263
- expect(result.content.length).toBeGreaterThan(0);
1264
-
1265
- // Verify error response contract
1266
- const textContent = result.content.find((c) => c.type === 'text');
1267
- if (textContent && textContent.type === 'text') {
1268
- const parsed = JSON.parse(textContent.text);
1269
- expect(parsed).toHaveProperty('error');
1270
- if ('success' in parsed) {
1271
- expect(parsed.success).toBe(false);
1272
- }
1273
- }
1274
- });
1275
- });
1276
-
1277
- describe('Output Schema Validation', () => {
1278
- it('should validate list_budgets output schema', async () => {
1279
- if (testConfig.skipE2ETests) return;
1280
-
1281
- const result = await executeToolCall(server, 'ynab:list_budgets');
1282
- const validation = validateOutputSchema(server, 'list_budgets', result);
1283
- expect(validation.hasSchema).toBe(true);
1284
- expect(validation.valid).toBe(true);
1285
- if (!validation.valid) {
1286
- console.error('Schema validation errors:', validation.errors);
1287
- }
1288
- });
1289
-
1290
- it('should validate list_accounts output schema', async () => {
1291
- if (testConfig.skipE2ETests) return;
1292
-
1293
- const result = await executeToolCall(server, 'ynab:list_accounts', {
1294
- budget_id: testBudgetId,
1295
- });
1296
- const validation = validateOutputSchema(server, 'list_accounts', result);
1297
- expect(validation.hasSchema).toBe(true);
1298
- expect(validation.valid).toBe(true);
1299
- if (!validation.valid) {
1300
- console.error('Schema validation errors:', validation.errors);
1301
- }
1302
- });
1303
-
1304
- it('should validate list_transactions output schema', async () => {
1305
- if (testConfig.skipE2ETests) return;
1306
-
1307
- const result = await executeToolCall(server, 'ynab:list_transactions', {
1308
- budget_id: testBudgetId,
1309
- since_date: '2025-01-01',
1310
- });
1311
- const validation = validateOutputSchema(server, 'list_transactions', result);
1312
- expect(validation.hasSchema).toBe(true);
1313
- expect(validation.valid).toBe(true);
1314
- if (!validation.valid) {
1315
- console.error('Schema validation errors:', validation.errors);
1316
- }
1317
- });
1318
-
1319
- it('should validate list_categories output schema', async () => {
1320
- if (testConfig.skipE2ETests) return;
1321
-
1322
- const result = await executeToolCall(server, 'ynab:list_categories', {
1323
- budget_id: testBudgetId,
1324
- });
1325
- const validation = validateOutputSchema(server, 'list_categories', result);
1326
- expect(validation.hasSchema).toBe(true);
1327
- expect(validation.valid).toBe(true);
1328
- if (!validation.valid) {
1329
- console.error('Schema validation errors:', validation.errors);
1330
- }
1331
- });
1332
-
1333
- it('should validate list_payees output schema', async () => {
1334
- if (testConfig.skipE2ETests) return;
1335
-
1336
- const result = await executeToolCall(server, 'ynab:list_payees', {
1337
- budget_id: testBudgetId,
1338
- });
1339
- const validation = validateOutputSchema(server, 'list_payees', result);
1340
- expect(validation.hasSchema).toBe(true);
1341
- expect(validation.valid).toBe(true);
1342
- if (!validation.valid) {
1343
- console.error('Schema validation errors:', validation.errors);
1344
- }
1345
- });
1346
-
1347
- it('should validate list_months output schema', async () => {
1348
- if (testConfig.skipE2ETests) return;
1349
-
1350
- const result = await executeToolCall(server, 'ynab:list_months', {
1351
- budget_id: testBudgetId,
1352
- });
1353
- const validation = validateOutputSchema(server, 'list_months', result);
1354
- expect(validation.hasSchema).toBe(true);
1355
- expect(validation.valid).toBe(true);
1356
- if (!validation.valid) {
1357
- console.error('Schema validation errors:', validation.errors);
1358
- }
1359
- });
1360
-
1361
- it('should validate get_month output schema', async () => {
1362
- if (testConfig.skipE2ETests) return;
1363
-
1364
- const currentMonth = getCurrentMonth();
1365
- const result = await executeToolCall(server, 'ynab:get_month', {
1366
- budget_id: testBudgetId,
1367
- month: currentMonth,
1368
- });
1369
- const validation = validateOutputSchema(server, 'get_month', result);
1370
- expect(validation.hasSchema).toBe(true);
1371
- expect(validation.valid).toBe(true);
1372
- if (!validation.valid) {
1373
- console.error('Schema validation errors:', validation.errors);
1374
- }
1375
- });
1376
-
1377
- it('should validate get_user output schema', async () => {
1378
- if (testConfig.skipE2ETests) return;
1379
-
1380
- const result = await executeToolCall(server, 'ynab:get_user');
1381
- const validation = validateOutputSchema(server, 'get_user', result);
1382
- expect(validation.hasSchema).toBe(true);
1383
- expect(validation.valid).toBe(true);
1384
- if (!validation.valid) {
1385
- console.error('Schema validation errors:', validation.errors);
1386
- }
1387
- });
1388
-
1389
- it('should validate diagnostic_info output schema', async () => {
1390
- if (testConfig.skipE2ETests) return;
1391
-
1392
- const result = await executeToolCall(server, 'ynab:diagnostic_info');
1393
- const validation = validateOutputSchema(server, 'diagnostic_info', result);
1394
- expect(validation.hasSchema).toBe(true);
1395
- expect(validation.valid).toBe(true);
1396
- if (!validation.valid) {
1397
- console.error('Schema validation errors:', validation.errors);
1398
- }
1399
- });
1400
-
1401
- it('should validate set_default_budget output schema', async () => {
1402
- if (testConfig.skipE2ETests) return;
1403
-
1404
- const result = await executeToolCall(server, 'ynab:set_default_budget', {
1405
- budget_id: testBudgetId,
1406
- });
1407
- const validation = validateOutputSchema(server, 'set_default_budget', result);
1408
- expect(validation.hasSchema).toBe(true);
1409
- expect(validation.valid).toBe(true);
1410
- if (!validation.valid) {
1411
- console.error('Schema validation errors:', validation.errors);
1412
- }
1413
- });
1414
-
1415
- it('should validate get_default_budget output schema', async () => {
1416
- if (testConfig.skipE2ETests) return;
1417
-
1418
- // Ensure default budget is set
1419
- await executeToolCall(server, 'ynab:set_default_budget', {
1420
- budget_id: testBudgetId,
1421
- });
1422
-
1423
- const result = await executeToolCall(server, 'ynab:get_default_budget');
1424
- const validation = validateOutputSchema(server, 'get_default_budget', result);
1425
- expect(validation.hasSchema).toBe(true);
1426
- expect(validation.valid).toBe(true);
1427
- if (!validation.valid) {
1428
- console.error('Schema validation errors:', validation.errors);
1429
- }
1430
- });
1431
-
1432
- it('should validate clear_cache output schema', async () => {
1433
- if (testConfig.skipE2ETests) return;
1434
-
1435
- const result = await executeToolCall(server, 'ynab:clear_cache');
1436
- const validation = validateOutputSchema(server, 'clear_cache', result);
1437
- expect(validation.hasSchema).toBe(true);
1438
- expect(validation.valid).toBe(true);
1439
- if (!validation.valid) {
1440
- console.error('Schema validation errors:', validation.errors);
1441
- }
1442
- });
1443
-
1444
- it('should validate set_output_format output schema', async () => {
1445
- if (testConfig.skipE2ETests) return;
1446
-
1447
- const result = await executeToolCall(server, 'ynab:set_output_format', {
1448
- minify: false,
1449
- });
1450
- const validation = validateOutputSchema(server, 'set_output_format', result);
1451
- expect(validation.hasSchema).toBe(true);
1452
- expect(validation.valid).toBe(true);
1453
- if (!validation.valid) {
1454
- console.error('Schema validation errors:', validation.errors);
1455
- }
1456
-
1457
- // Reset to default
1458
- await executeToolCall(server, 'ynab:set_output_format', {
1459
- minify: true,
1460
- });
1461
- });
1462
-
1463
- it('should validate reconcile_account output schema', async () => {
1464
- if (testConfig.skipE2ETests) return;
1465
-
1466
- const result = await executeToolCall(server, 'ynab:reconcile_account', {
1467
- budget_id: testBudgetId,
1468
- account_id: testAccountId,
1469
- cleared_balance: 0,
1470
- });
1471
- const validation = validateOutputSchema(server, 'reconcile_account', result);
1472
- expect(validation.hasSchema).toBe(true);
1473
- expect(validation.valid).toBe(true);
1474
- if (!validation.valid) {
1475
- console.error('Schema validation errors:', validation.errors);
1476
- }
1477
- });
1478
-
1479
- it('should validate create_transactions (bulk) output schema', async () => {
1480
- if (testConfig.skipE2ETests) return;
1481
-
1482
- // Create multiple transactions
1483
- const transactions = [
1484
- {
1485
- account_id: testAccountId,
1486
- date: new Date().toISOString().split('T')[0],
1487
- amount: -1000,
1488
- payee_name: `Test Payee 1 ${Date.now()}`,
1489
- memo: 'Bulk test 1',
1490
- cleared: 'uncleared' as const,
1491
- },
1492
- {
1493
- account_id: testAccountId,
1494
- date: new Date().toISOString().split('T')[0],
1495
- amount: -2000,
1496
- payee_name: `Test Payee 2 ${Date.now()}`,
1497
- memo: 'Bulk test 2',
1498
- cleared: 'uncleared' as const,
1499
- },
1500
- ];
1501
-
1502
- const result = await executeToolCall(server, 'ynab:create_transactions', {
1503
- budget_id: testBudgetId,
1504
- transactions,
1505
- });
1506
- if (skipIfRateLimitedResult(result)) return;
1507
- const validation = validateOutputSchema(server, 'create_transactions', result);
1508
- expect(validation.hasSchema).toBe(true);
1509
- expect(validation.valid).toBe(true);
1510
- if (!validation.valid) {
1511
- console.error('Schema validation errors:', validation.errors);
1512
- }
1513
-
1514
- // Track transactions for cleanup
1515
- const parsed = parseToolResult(result);
1516
- if (parsed.data?.transactions) {
1517
- parsed.data.transactions.forEach((txn: any) => {
1518
- cleanup.trackTransaction(txn.id);
1519
- });
1520
- }
1521
- });
1522
-
1523
- it('should validate update_transactions (bulk) output schema', async () => {
1524
- if (testConfig.skipE2ETests) return;
1525
-
1526
- // First create a transaction to update
1527
- const createResult = await executeToolCall(server, 'ynab:create_transaction', {
1528
- budget_id: testBudgetId,
1529
- account_id: testAccountId,
1530
- date: new Date().toISOString().split('T')[0],
1531
- amount: -3000,
1532
- payee_name: `Test Update Payee ${Date.now()}`,
1533
- memo: 'Before update',
1534
- cleared: 'uncleared',
1535
- });
1536
- if (skipIfRateLimitedResult(createResult)) return;
1537
- const created = parseToolResult(createResult);
1538
- if (!created?.data?.transaction?.id) {
1539
- console.warn(
1540
- '[rate-limit] Skipping update_transactions schema check because create_transaction returned no transaction data',
1541
- );
1542
- return;
1543
- }
1544
-
1545
- const transactionId = created.data.transaction.id;
1546
- cleanup.trackTransaction(transactionId);
1547
-
1548
- // Update the transaction
1549
- const result = await executeToolCall(server, 'ynab:update_transactions', {
1550
- budget_id: testBudgetId,
1551
- transactions: [
1552
- {
1553
- id: transactionId,
1554
- memo: 'After update',
1555
- },
1556
- ],
1557
- });
1558
- if (skipIfRateLimitedResult(result)) return;
1559
- const validation = validateOutputSchema(server, 'update_transactions', result);
1560
- expect(validation.hasSchema).toBe(true);
1561
- expect(validation.valid).toBe(true);
1562
- if (!validation.valid) {
1563
- console.error('Schema validation errors:', validation.errors);
1564
- }
1565
- });
1566
-
1567
- it('should validate compare_transactions output schema', async () => {
1568
- if (testConfig.skipE2ETests) return;
1569
-
1570
- // Create a minimal CSV for comparison
1571
- const csvData = `Date,Payee,Amount\n2025-01-15,Test Payee,-10.00`;
1572
-
1573
- const result = await executeToolCall(server, 'ynab:compare_transactions', {
1574
- budget_id: testBudgetId,
1575
- account_id: testAccountId,
1576
- csv_data: csvData,
1577
- start_date: '2025-01-01',
1578
- end_date: '2025-01-31',
1579
- });
1580
- if (skipIfRateLimitedResult(result)) return;
1581
- const validation = validateOutputSchema(server, 'compare_transactions', result);
1582
- expect(validation.hasSchema).toBe(true);
1583
- expect(validation.valid).toBe(true);
1584
- if (!validation.valid) {
1585
- console.error('Schema validation errors:', validation.errors);
1586
- }
1587
- });
1588
-
1589
- it('should validate export_transactions output schema', async () => {
1590
- if (testConfig.skipE2ETests) return;
1591
-
1592
- const result = await executeToolCall(server, 'ynab:export_transactions', {
1593
- budget_id: testBudgetId,
1594
- account_id: testAccountId,
1595
- });
1596
- if (skipIfRateLimitedResult(result)) return;
1597
- const validation = validateOutputSchema(server, 'export_transactions', result);
1598
- expect(validation.hasSchema).toBe(true);
1599
- expect(validation.valid).toBe(true);
1600
- if (!validation.valid) {
1601
- console.error('Schema validation errors:', validation.errors);
1602
- }
1603
- });
1604
-
1605
- it('should validate create_receipt_split_transaction output schema', async () => {
1606
- if (testConfig.skipE2ETests) return;
1607
-
1608
- // Get categories to find a valid one
1609
- const categoriesResult = await executeToolCall(server, 'ynab:list_categories', {
1610
- budget_id: testBudgetId,
1611
- });
1612
- const categories = parseToolResult(categoriesResult);
1613
-
1614
- // Find a non-hidden category
1615
- let testCategoryName: string | undefined;
1616
- for (const group of categories.data.category_groups) {
1617
- const availableCategory = group.categories?.find((cat: any) => !cat.hidden);
1618
- if (availableCategory) {
1619
- testCategoryName = availableCategory.name;
1620
- break;
1621
- }
1622
- }
1623
-
1624
- if (!testCategoryName) {
1625
- console.warn('No available categories found for create_receipt_split_transaction test');
1626
- return;
1627
- }
1628
-
1629
- // Create a minimal receipt split transaction
1630
- const result = await executeToolCall(server, 'ynab:create_receipt_split_transaction', {
1631
- budget_id: testBudgetId,
1632
- account_id: testAccountId,
1633
- date: new Date().toISOString().split('T')[0],
1634
- payee_name: `Test Receipt ${Date.now()}`,
1635
- tax_amount: 100,
1636
- receipt_items: [
1637
- {
1638
- category_name: testCategoryName,
1639
- amount: 1000,
1640
- },
1641
- ],
1642
- });
1643
-
1644
- const validation = validateOutputSchema(server, 'create_receipt_split_transaction', result);
1645
- expect(validation.hasSchema).toBe(true);
1646
- expect(validation.valid).toBe(true);
1647
- if (!validation.valid) {
1648
- console.error('Schema validation errors:', validation.errors);
1649
- }
1650
-
1651
- // Track the created transaction for cleanup
1652
- const parsed = parseToolResult(result);
1653
- if (parsed.data?.transaction?.id) {
1654
- cleanup.trackTransaction(parsed.data.transaction.id);
1655
- }
1656
- });
1657
- });
1658
- });