@dizzlkheinz/ynab-mcpb 0.18.2 → 0.18.4

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 (34) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/dist/bundle/index.cjs +40 -40
  3. package/dist/tools/reconcileAdapter.js +3 -0
  4. package/dist/tools/reconciliation/analyzer.js +72 -7
  5. package/dist/tools/reconciliation/reportFormatter.js +26 -2
  6. package/dist/tools/reconciliation/types.d.ts +3 -0
  7. package/dist/tools/transactionSchemas.d.ts +309 -0
  8. package/dist/tools/transactionSchemas.js +215 -0
  9. package/dist/tools/transactionTools.d.ts +3 -281
  10. package/dist/tools/transactionTools.js +36 -568
  11. package/dist/tools/transactionUtils.d.ts +31 -0
  12. package/dist/tools/transactionUtils.js +349 -0
  13. package/docs/plans/2025-12-25-transaction-tools-refactor-design.md +211 -0
  14. package/docs/plans/2025-12-25-transaction-tools-refactor.md +905 -0
  15. package/package.json +4 -2
  16. package/scripts/run-all-tests.js +196 -0
  17. package/src/tools/__tests__/transactionSchemas.test.ts +1188 -0
  18. package/src/tools/__tests__/transactionTools.test.ts +83 -0
  19. package/src/tools/__tests__/transactionUtils.test.ts +989 -0
  20. package/src/tools/reconcileAdapter.ts +6 -0
  21. package/src/tools/reconciliation/__tests__/adapter.causes.test.ts +22 -8
  22. package/src/tools/reconciliation/__tests__/adapter.test.ts +3 -0
  23. package/src/tools/reconciliation/__tests__/analyzer.test.ts +65 -0
  24. package/src/tools/reconciliation/__tests__/recommendationEngine.test.ts +3 -0
  25. package/src/tools/reconciliation/__tests__/reportFormatter.test.ts +4 -1
  26. package/src/tools/reconciliation/__tests__/scenarios/adapterCurrency.scenario.test.ts +3 -0
  27. package/src/tools/reconciliation/__tests__/scenarios/extremes.scenario.test.ts +5 -1
  28. package/src/tools/reconciliation/__tests__/schemaUrl.test.ts +22 -8
  29. package/src/tools/reconciliation/analyzer.ts +127 -11
  30. package/src/tools/reconciliation/reportFormatter.ts +39 -2
  31. package/src/tools/reconciliation/types.ts +6 -0
  32. package/src/tools/transactionSchemas.ts +453 -0
  33. package/src/tools/transactionTools.ts +152 -835
  34. package/src/tools/transactionUtils.ts +536 -0
@@ -0,0 +1,989 @@
1
+ /**
2
+ * Unit tests for transactionUtils.ts
3
+ */
4
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
5
+ import * as ynab from 'ynab';
6
+ import { ValidationError } from '../../types/index.js';
7
+ import { responseFormatter } from '../../server/responseFormatter.js';
8
+ import {
9
+ ensureTransaction,
10
+ appendCategoryIds,
11
+ collectCategoryIdsFromSources,
12
+ setsEqual,
13
+ toMonthKey,
14
+ generateCorrelationKey,
15
+ toCorrelationPayload,
16
+ correlateResults,
17
+ estimatePayloadSize,
18
+ finalizeResponse,
19
+ finalizeBulkUpdateResponse,
20
+ handleTransactionError,
21
+ } from '../transactionUtils.js';
22
+ import type {
23
+ CategorySource,
24
+ BulkTransactionInput,
25
+ BulkCreateResponse,
26
+ BulkUpdateResponse,
27
+ CorrelationPayloadInput,
28
+ } from '../transactionSchemas.js';
29
+ import type { SaveTransactionsResponseData } from 'ynab/dist/models/SaveTransactionsResponseData.js';
30
+
31
+ // Mock the responseFormatter module
32
+ vi.mock('../../server/responseFormatter.js', () => ({
33
+ responseFormatter: {
34
+ format: vi.fn((data) => JSON.stringify(data)),
35
+ },
36
+ }));
37
+
38
+ // Mock the global request logger
39
+ vi.mock('../../server/requestLogger.js', () => ({
40
+ globalRequestLogger: {
41
+ logError: vi.fn(),
42
+ },
43
+ }));
44
+
45
+ describe('transactionUtils', () => {
46
+ describe('ensureTransaction', () => {
47
+ it('should return transaction when it is defined', () => {
48
+ const transaction = { id: '123', amount: 1000 };
49
+ const result = ensureTransaction(transaction, 'Transaction not found');
50
+ expect(result).toBe(transaction);
51
+ });
52
+
53
+ it('should throw error when transaction is undefined', () => {
54
+ expect(() => ensureTransaction(undefined, 'Transaction not found')).toThrow(
55
+ 'Transaction not found',
56
+ );
57
+ });
58
+
59
+ it('should throw error when transaction is null', () => {
60
+ expect(() => ensureTransaction(null, 'Transaction is missing')).toThrow(
61
+ 'Transaction is missing',
62
+ );
63
+ });
64
+
65
+ it('should handle objects with falsy properties', () => {
66
+ const transaction = { id: '', amount: 0 };
67
+ const result = ensureTransaction(transaction, 'Error message');
68
+ expect(result).toBe(transaction);
69
+ });
70
+ });
71
+
72
+ describe('appendCategoryIds', () => {
73
+ it('should not add anything when source is undefined', () => {
74
+ const target = new Set<string>();
75
+ appendCategoryIds(undefined, target);
76
+ expect(target.size).toBe(0);
77
+ });
78
+
79
+ it('should add category_id from source', () => {
80
+ const source: CategorySource = { category_id: 'cat-123' };
81
+ const target = new Set<string>();
82
+ appendCategoryIds(source, target);
83
+ expect(target).toEqual(new Set(['cat-123']));
84
+ });
85
+
86
+ it('should add category IDs from subtransactions', () => {
87
+ const source: CategorySource = {
88
+ subtransactions: [{ category_id: 'cat-1' }, { category_id: 'cat-2' }],
89
+ };
90
+ const target = new Set<string>();
91
+ appendCategoryIds(source, target);
92
+ expect(target).toEqual(new Set(['cat-1', 'cat-2']));
93
+ });
94
+
95
+ it('should add both main category and subtransaction categories', () => {
96
+ const source: CategorySource = {
97
+ category_id: 'cat-main',
98
+ subtransactions: [{ category_id: 'cat-1' }, { category_id: 'cat-2' }],
99
+ };
100
+ const target = new Set<string>();
101
+ appendCategoryIds(source, target);
102
+ expect(target).toEqual(new Set(['cat-main', 'cat-1', 'cat-2']));
103
+ });
104
+
105
+ it('should handle null category_id values', () => {
106
+ const source: CategorySource = {
107
+ category_id: null,
108
+ subtransactions: [{ category_id: null }, { category_id: 'cat-1' }],
109
+ };
110
+ const target = new Set<string>();
111
+ appendCategoryIds(source, target);
112
+ expect(target).toEqual(new Set(['cat-1']));
113
+ });
114
+
115
+ it('should handle empty subtransactions array', () => {
116
+ const source: CategorySource = {
117
+ category_id: 'cat-123',
118
+ subtransactions: [],
119
+ };
120
+ const target = new Set<string>();
121
+ appendCategoryIds(source, target);
122
+ expect(target).toEqual(new Set(['cat-123']));
123
+ });
124
+
125
+ it('should not add duplicates to existing set', () => {
126
+ const source: CategorySource = {
127
+ category_id: 'cat-1',
128
+ subtransactions: [{ category_id: 'cat-1' }],
129
+ };
130
+ const target = new Set(['cat-1']);
131
+ appendCategoryIds(source, target);
132
+ expect(target).toEqual(new Set(['cat-1']));
133
+ });
134
+ });
135
+
136
+ describe('collectCategoryIdsFromSources', () => {
137
+ it('should collect from no sources', () => {
138
+ const result = collectCategoryIdsFromSources();
139
+ expect(result).toEqual(new Set());
140
+ });
141
+
142
+ it('should collect from single source', () => {
143
+ const source: CategorySource = { category_id: 'cat-1' };
144
+ const result = collectCategoryIdsFromSources(source);
145
+ expect(result).toEqual(new Set(['cat-1']));
146
+ });
147
+
148
+ it('should collect from multiple sources', () => {
149
+ const source1: CategorySource = { category_id: 'cat-1' };
150
+ const source2: CategorySource = {
151
+ subtransactions: [{ category_id: 'cat-2' }, { category_id: 'cat-3' }],
152
+ };
153
+ const result = collectCategoryIdsFromSources(source1, source2);
154
+ expect(result).toEqual(new Set(['cat-1', 'cat-2', 'cat-3']));
155
+ });
156
+
157
+ it('should handle undefined sources', () => {
158
+ const source1: CategorySource = { category_id: 'cat-1' };
159
+ const result = collectCategoryIdsFromSources(source1, undefined, undefined);
160
+ expect(result).toEqual(new Set(['cat-1']));
161
+ });
162
+
163
+ it('should deduplicate category IDs across sources', () => {
164
+ const source1: CategorySource = { category_id: 'cat-1' };
165
+ const source2: CategorySource = { category_id: 'cat-1' };
166
+ const result = collectCategoryIdsFromSources(source1, source2);
167
+ expect(result).toEqual(new Set(['cat-1']));
168
+ });
169
+ });
170
+
171
+ describe('setsEqual', () => {
172
+ it('should return true for two empty sets', () => {
173
+ expect(setsEqual(new Set(), new Set())).toBe(true);
174
+ });
175
+
176
+ it('should return true for identical sets', () => {
177
+ expect(setsEqual(new Set([1, 2, 3]), new Set([1, 2, 3]))).toBe(true);
178
+ });
179
+
180
+ it('should return true regardless of insertion order', () => {
181
+ expect(setsEqual(new Set([1, 2, 3]), new Set([3, 2, 1]))).toBe(true);
182
+ });
183
+
184
+ it('should return false for sets with different sizes', () => {
185
+ expect(setsEqual(new Set([1, 2]), new Set([1, 2, 3]))).toBe(false);
186
+ });
187
+
188
+ it('should return false for sets with different elements', () => {
189
+ expect(setsEqual(new Set([1, 2, 3]), new Set([1, 2, 4]))).toBe(false);
190
+ });
191
+
192
+ it('should work with string sets', () => {
193
+ expect(setsEqual(new Set(['a', 'b']), new Set(['b', 'a']))).toBe(true);
194
+ });
195
+
196
+ it('should return false for sets with partial overlap', () => {
197
+ expect(setsEqual(new Set([1, 2]), new Set([2, 3]))).toBe(false);
198
+ });
199
+ });
200
+
201
+ describe('toMonthKey', () => {
202
+ it('should convert date to month key', () => {
203
+ expect(toMonthKey('2024-03-15')).toBe('2024-03-01');
204
+ });
205
+
206
+ it('should handle first day of month', () => {
207
+ expect(toMonthKey('2024-01-01')).toBe('2024-01-01');
208
+ });
209
+
210
+ it('should handle last day of month', () => {
211
+ expect(toMonthKey('2024-12-31')).toBe('2024-12-01');
212
+ });
213
+
214
+ it('should handle different years', () => {
215
+ expect(toMonthKey('2025-06-20')).toBe('2025-06-01');
216
+ });
217
+ });
218
+
219
+ describe('generateCorrelationKey', () => {
220
+ it('should use import_id when available', () => {
221
+ const transaction = {
222
+ account_id: 'acc-123',
223
+ date: '2024-03-15',
224
+ amount: 5000,
225
+ import_id: 'YNAB:12345:2024-03-15:1',
226
+ };
227
+ const key = generateCorrelationKey(transaction);
228
+ expect(key).toBe('YNAB:12345:2024-03-15:1');
229
+ });
230
+
231
+ it('should generate hash when no import_id', () => {
232
+ const transaction = {
233
+ account_id: 'acc-123',
234
+ date: '2024-03-15',
235
+ amount: 5000,
236
+ payee_name: 'Test Payee',
237
+ };
238
+ const key = generateCorrelationKey(transaction);
239
+ expect(key).toMatch(/^hash:[a-f0-9]{16}$/);
240
+ });
241
+
242
+ it('should generate same hash for identical transactions', () => {
243
+ const transaction1 = {
244
+ account_id: 'acc-123',
245
+ date: '2024-03-15',
246
+ amount: 5000,
247
+ payee_name: 'Test',
248
+ category_id: 'cat-1',
249
+ };
250
+ const transaction2 = {
251
+ account_id: 'acc-123',
252
+ date: '2024-03-15',
253
+ amount: 5000,
254
+ payee_name: 'Test',
255
+ category_id: 'cat-1',
256
+ };
257
+ expect(generateCorrelationKey(transaction1)).toBe(generateCorrelationKey(transaction2));
258
+ });
259
+
260
+ it('should generate different hash for different transactions', () => {
261
+ const transaction1 = {
262
+ account_id: 'acc-123',
263
+ date: '2024-03-15',
264
+ amount: 5000,
265
+ };
266
+ const transaction2 = {
267
+ account_id: 'acc-123',
268
+ date: '2024-03-15',
269
+ amount: 6000,
270
+ };
271
+ expect(generateCorrelationKey(transaction1)).not.toBe(generateCorrelationKey(transaction2));
272
+ });
273
+
274
+ it('should handle all optional fields', () => {
275
+ const transaction = {
276
+ account_id: 'acc-123',
277
+ date: '2024-03-15',
278
+ amount: 5000,
279
+ payee_id: 'payee-1',
280
+ payee_name: 'Test Payee',
281
+ category_id: 'cat-1',
282
+ memo: 'Test memo',
283
+ cleared: 'cleared' as ynab.TransactionClearedStatus,
284
+ approved: true,
285
+ flag_color: 'red' as ynab.TransactionFlagColor,
286
+ };
287
+ const key = generateCorrelationKey(transaction);
288
+ expect(key).toMatch(/^hash:[a-f0-9]{16}$/);
289
+ });
290
+
291
+ it('should handle null values', () => {
292
+ const transaction = {
293
+ account_id: 'acc-123',
294
+ date: '2024-03-15',
295
+ amount: 5000,
296
+ payee_id: null,
297
+ payee_name: null,
298
+ category_id: null,
299
+ memo: null,
300
+ flag_color: null,
301
+ };
302
+ const key = generateCorrelationKey(transaction);
303
+ expect(key).toMatch(/^hash:[a-f0-9]{16}$/);
304
+ });
305
+ });
306
+
307
+ describe('toCorrelationPayload', () => {
308
+ it('should convert full transaction input to payload', () => {
309
+ const input: CorrelationPayloadInput = {
310
+ account_id: 'acc-123',
311
+ date: '2024-03-15',
312
+ amount: 5000,
313
+ payee_id: 'payee-1',
314
+ payee_name: 'Test Payee',
315
+ category_id: 'cat-1',
316
+ memo: 'Test memo',
317
+ cleared: 'cleared' as ynab.TransactionClearedStatus,
318
+ approved: true,
319
+ flag_color: 'red' as ynab.TransactionFlagColor,
320
+ import_id: 'YNAB:12345',
321
+ };
322
+ const payload = toCorrelationPayload(input);
323
+ expect(payload).toEqual(input);
324
+ });
325
+
326
+ it('should handle minimal transaction input', () => {
327
+ const input: CorrelationPayloadInput = {};
328
+ const payload = toCorrelationPayload(input);
329
+ expect(payload).toEqual({
330
+ payee_id: null,
331
+ payee_name: null,
332
+ category_id: null,
333
+ memo: null,
334
+ import_id: null,
335
+ });
336
+ });
337
+
338
+ it('should convert undefined to null for nullable fields', () => {
339
+ const input: CorrelationPayloadInput = {
340
+ account_id: 'acc-123',
341
+ payee_id: undefined,
342
+ payee_name: undefined,
343
+ category_id: undefined,
344
+ memo: undefined,
345
+ import_id: undefined,
346
+ };
347
+ const payload = toCorrelationPayload(input);
348
+ expect(payload.payee_id).toBe(null);
349
+ expect(payload.payee_name).toBe(null);
350
+ expect(payload.category_id).toBe(null);
351
+ expect(payload.memo).toBe(null);
352
+ expect(payload.import_id).toBe(null);
353
+ });
354
+
355
+ it('should preserve null values', () => {
356
+ const input: CorrelationPayloadInput = {
357
+ payee_id: null,
358
+ payee_name: null,
359
+ category_id: null,
360
+ memo: null,
361
+ import_id: null,
362
+ };
363
+ const payload = toCorrelationPayload(input);
364
+ expect(payload.payee_id).toBe(null);
365
+ expect(payload.payee_name).toBe(null);
366
+ expect(payload.category_id).toBe(null);
367
+ expect(payload.memo).toBe(null);
368
+ expect(payload.import_id).toBe(null);
369
+ });
370
+ });
371
+
372
+ describe('correlateResults', () => {
373
+ it('should correlate transactions by import_id', () => {
374
+ const requests: BulkTransactionInput[] = [
375
+ {
376
+ account_id: 'acc-1',
377
+ date: '2024-03-15',
378
+ amount: 5000,
379
+ import_id: 'YNAB:1',
380
+ },
381
+ ];
382
+ const responseData: SaveTransactionsResponseData = {
383
+ transactions: [
384
+ {
385
+ id: 'txn-1',
386
+ account_id: 'acc-1',
387
+ date: '2024-03-15',
388
+ amount: 5000,
389
+ import_id: 'YNAB:1',
390
+ } as ynab.TransactionDetail,
391
+ ],
392
+ duplicate_import_ids: [],
393
+ server_knowledge: 100,
394
+ };
395
+ const duplicates = new Set<string>();
396
+
397
+ const results = correlateResults(requests, responseData, duplicates);
398
+
399
+ expect(results).toHaveLength(1);
400
+ expect(results[0]).toMatchObject({
401
+ request_index: 0,
402
+ status: 'created',
403
+ transaction_id: 'txn-1',
404
+ correlation_key: 'YNAB:1',
405
+ });
406
+ });
407
+
408
+ it('should correlate transactions by hash when no import_id', () => {
409
+ const requests: BulkTransactionInput[] = [
410
+ {
411
+ account_id: 'acc-1',
412
+ date: '2024-03-15',
413
+ amount: 5000,
414
+ payee_name: 'Test',
415
+ },
416
+ ];
417
+ const responseData: SaveTransactionsResponseData = {
418
+ transactions: [
419
+ {
420
+ id: 'txn-1',
421
+ account_id: 'acc-1',
422
+ date: '2024-03-15',
423
+ amount: 5000,
424
+ payee_name: 'Test',
425
+ } as ynab.TransactionDetail,
426
+ ],
427
+ duplicate_import_ids: [],
428
+ server_knowledge: 100,
429
+ };
430
+ const duplicates = new Set<string>();
431
+
432
+ const results = correlateResults(requests, responseData, duplicates);
433
+
434
+ expect(results).toHaveLength(1);
435
+ expect(results[0].status).toBe('created');
436
+ expect(results[0].transaction_id).toBe('txn-1');
437
+ expect(results[0].correlation_key).toMatch(/^hash:[a-f0-9]{16}$/);
438
+ });
439
+
440
+ it('should mark duplicates based on duplicateImportIds', () => {
441
+ const requests: BulkTransactionInput[] = [
442
+ {
443
+ account_id: 'acc-1',
444
+ date: '2024-03-15',
445
+ amount: 5000,
446
+ import_id: 'YNAB:1',
447
+ },
448
+ ];
449
+ const responseData: SaveTransactionsResponseData = {
450
+ transactions: [],
451
+ duplicate_import_ids: ['YNAB:1'],
452
+ server_knowledge: 100,
453
+ };
454
+ const duplicates = new Set(['YNAB:1']);
455
+
456
+ const results = correlateResults(requests, responseData, duplicates);
457
+
458
+ expect(results).toHaveLength(1);
459
+ expect(results[0]).toMatchObject({
460
+ request_index: 0,
461
+ status: 'duplicate',
462
+ correlation_key: 'YNAB:1',
463
+ });
464
+ });
465
+
466
+ it('should mark as failed when correlation fails', () => {
467
+ const requests: BulkTransactionInput[] = [
468
+ {
469
+ account_id: 'acc-1',
470
+ date: '2024-03-15',
471
+ amount: 5000,
472
+ import_id: 'YNAB:1',
473
+ },
474
+ ];
475
+ const responseData: SaveTransactionsResponseData = {
476
+ transactions: [
477
+ {
478
+ id: 'txn-2',
479
+ account_id: 'acc-2',
480
+ date: '2024-03-16',
481
+ amount: 6000,
482
+ import_id: 'YNAB:2',
483
+ } as ynab.TransactionDetail,
484
+ ],
485
+ duplicate_import_ids: [],
486
+ server_knowledge: 100,
487
+ };
488
+ const duplicates = new Set<string>();
489
+
490
+ const results = correlateResults(requests, responseData, duplicates);
491
+
492
+ expect(results).toHaveLength(1);
493
+ expect(results[0]).toMatchObject({
494
+ request_index: 0,
495
+ status: 'failed',
496
+ error_code: 'correlation_failed',
497
+ error: 'Unable to correlate request transaction with YNAB response',
498
+ });
499
+ });
500
+
501
+ it('should handle multiple transactions', () => {
502
+ const requests: BulkTransactionInput[] = [
503
+ { account_id: 'acc-1', date: '2024-03-15', amount: 5000, import_id: 'YNAB:1' },
504
+ { account_id: 'acc-2', date: '2024-03-16', amount: 6000, import_id: 'YNAB:2' },
505
+ ];
506
+ const responseData: SaveTransactionsResponseData = {
507
+ transactions: [
508
+ {
509
+ id: 'txn-1',
510
+ account_id: 'acc-1',
511
+ date: '2024-03-15',
512
+ amount: 5000,
513
+ import_id: 'YNAB:1',
514
+ } as ynab.TransactionDetail,
515
+ {
516
+ id: 'txn-2',
517
+ account_id: 'acc-2',
518
+ date: '2024-03-16',
519
+ amount: 6000,
520
+ import_id: 'YNAB:2',
521
+ } as ynab.TransactionDetail,
522
+ ],
523
+ duplicate_import_ids: [],
524
+ server_knowledge: 100,
525
+ };
526
+ const duplicates = new Set<string>();
527
+
528
+ const results = correlateResults(requests, responseData, duplicates);
529
+
530
+ expect(results).toHaveLength(2);
531
+ expect(results[0].transaction_id).toBe('txn-1');
532
+ expect(results[1].transaction_id).toBe('txn-2');
533
+ });
534
+
535
+ it('should fall back to hash correlation when import_id fails', () => {
536
+ const requests: BulkTransactionInput[] = [
537
+ {
538
+ account_id: 'acc-1',
539
+ date: '2024-03-15',
540
+ amount: 5000,
541
+ payee_name: 'Test',
542
+ import_id: 'YNAB:1',
543
+ },
544
+ ];
545
+ const responseData: SaveTransactionsResponseData = {
546
+ transactions: [
547
+ {
548
+ id: 'txn-1',
549
+ account_id: 'acc-1',
550
+ date: '2024-03-15',
551
+ amount: 5000,
552
+ payee_name: 'Test',
553
+ // No import_id in response
554
+ } as ynab.TransactionDetail,
555
+ ],
556
+ duplicate_import_ids: [],
557
+ server_knowledge: 100,
558
+ };
559
+ const duplicates = new Set<string>();
560
+
561
+ const results = correlateResults(requests, responseData, duplicates);
562
+
563
+ expect(results).toHaveLength(1);
564
+ expect(results[0].status).toBe('created');
565
+ expect(results[0].transaction_id).toBe('txn-1');
566
+ });
567
+
568
+ it('should handle empty response transactions', () => {
569
+ const requests: BulkTransactionInput[] = [
570
+ { account_id: 'acc-1', date: '2024-03-15', amount: 5000 },
571
+ ];
572
+ const responseData: SaveTransactionsResponseData = {
573
+ transactions: [],
574
+ duplicate_import_ids: [],
575
+ server_knowledge: 100,
576
+ };
577
+ const duplicates = new Set<string>();
578
+
579
+ const results = correlateResults(requests, responseData, duplicates);
580
+
581
+ expect(results).toHaveLength(1);
582
+ expect(results[0].status).toBe('failed');
583
+ });
584
+ });
585
+
586
+ describe('estimatePayloadSize', () => {
587
+ it('should estimate size of bulk create response', () => {
588
+ const response: BulkCreateResponse = {
589
+ success: true,
590
+ summary: {
591
+ total_requested: 1,
592
+ created: 1,
593
+ duplicates: 0,
594
+ failed: 0,
595
+ },
596
+ results: [
597
+ {
598
+ request_index: 0,
599
+ status: 'created',
600
+ transaction_id: 'txn-123',
601
+ correlation_key: 'YNAB:1',
602
+ },
603
+ ],
604
+ };
605
+ const size = estimatePayloadSize(response);
606
+ expect(size).toBeGreaterThan(0);
607
+ expect(size).toBe(Buffer.byteLength(JSON.stringify(response), 'utf8'));
608
+ });
609
+
610
+ it('should estimate size of bulk update response', () => {
611
+ const response: BulkUpdateResponse = {
612
+ success: true,
613
+ summary: {
614
+ total_requested: 1,
615
+ updated: 1,
616
+ failed: 0,
617
+ },
618
+ results: [
619
+ {
620
+ request_index: 0,
621
+ status: 'updated',
622
+ transaction_id: 'txn-123',
623
+ correlation_key: 'key-1',
624
+ },
625
+ ],
626
+ };
627
+ const size = estimatePayloadSize(response);
628
+ expect(size).toBeGreaterThan(0);
629
+ expect(size).toBe(Buffer.byteLength(JSON.stringify(response), 'utf8'));
630
+ });
631
+ });
632
+
633
+ describe('finalizeResponse', () => {
634
+ it('should return full response when under threshold', () => {
635
+ const response: BulkCreateResponse = {
636
+ success: true,
637
+ summary: {
638
+ total_requested: 1,
639
+ created: 1,
640
+ duplicates: 0,
641
+ failed: 0,
642
+ },
643
+ results: [
644
+ {
645
+ request_index: 0,
646
+ status: 'created',
647
+ transaction_id: 'txn-123',
648
+ correlation_key: 'YNAB:1',
649
+ },
650
+ ],
651
+ transactions: [
652
+ {
653
+ id: 'txn-123',
654
+ account_id: 'acc-1',
655
+ date: '2024-03-15',
656
+ amount: 5000,
657
+ } as ynab.TransactionDetail,
658
+ ],
659
+ };
660
+
661
+ const result = finalizeResponse(response);
662
+ expect(result.mode).toBe('full');
663
+ expect(result.transactions).toBeDefined();
664
+ });
665
+
666
+ it('should downgrade to summary when full response exceeds threshold', () => {
667
+ // Create a large response by adding many transactions
668
+ const transactions: ynab.TransactionDetail[] = [];
669
+ for (let i = 0; i < 1000; i++) {
670
+ transactions.push({
671
+ id: `txn-${i}`,
672
+ account_id: 'acc-1',
673
+ date: '2024-03-15',
674
+ amount: 5000,
675
+ payee_name: `Very Long Payee Name for Transaction Number ${i} to increase size`,
676
+ memo: `This is a very long memo with lots of text to make the payload larger ${i}`,
677
+ category_name: `Category with a very long name ${i}`,
678
+ } as ynab.TransactionDetail);
679
+ }
680
+
681
+ const response: BulkCreateResponse = {
682
+ success: true,
683
+ summary: {
684
+ total_requested: 1000,
685
+ created: 1000,
686
+ duplicates: 0,
687
+ failed: 0,
688
+ },
689
+ results: transactions.map((t, i) => ({
690
+ request_index: i,
691
+ status: 'created' as const,
692
+ transaction_id: t.id,
693
+ correlation_key: `key-${i}`,
694
+ })),
695
+ transactions,
696
+ };
697
+
698
+ const result = finalizeResponse(response);
699
+ expect(result.mode).toBe('summary');
700
+ expect(result.transactions).toBeUndefined();
701
+ expect(result.message).toContain('Response downgraded to summary');
702
+ });
703
+
704
+ it('should downgrade to ids_only when summary exceeds threshold', () => {
705
+ // Create a response with enough data to exceed summary threshold (96KB) but not ids_only threshold (100KB)
706
+ // The 4KB window between thresholds is very narrow - test the downgrade logic
707
+ const results = [];
708
+ for (let i = 0; i < 800; i++) {
709
+ results.push({
710
+ request_index: i,
711
+ status: 'created' as const,
712
+ transaction_id: `t-${i}`,
713
+ correlation_key: `Y:${i}`,
714
+ });
715
+ }
716
+
717
+ const response: BulkCreateResponse = {
718
+ success: true,
719
+ summary: {
720
+ total_requested: 800,
721
+ created: 800,
722
+ duplicates: 0,
723
+ failed: 0,
724
+ },
725
+ results,
726
+ };
727
+
728
+ const result = finalizeResponse(response);
729
+ // Due to narrow window, may be summary or ids_only
730
+ expect(['summary', 'ids_only']).toContain(result.mode);
731
+ if (result.mode === 'ids_only') {
732
+ expect(result.message).toContain('Response downgraded to ids_only');
733
+ }
734
+ });
735
+
736
+ it('should throw ValidationError when response is too large', () => {
737
+ // Create an extremely large response that cannot fit even with ids_only
738
+ const results = [];
739
+ for (let i = 0; i < 10000; i++) {
740
+ results.push({
741
+ request_index: i,
742
+ status: 'created' as const,
743
+ transaction_id: `txn-very-long-transaction-id-with-lots-of-characters-${i}`,
744
+ correlation_key: `YNAB:extremely-long-import-id-with-many-characters-to-exceed-limit-${i}`,
745
+ });
746
+ }
747
+
748
+ const response: BulkCreateResponse = {
749
+ success: true,
750
+ summary: {
751
+ total_requested: 10000,
752
+ created: 10000,
753
+ duplicates: 0,
754
+ failed: 0,
755
+ },
756
+ results,
757
+ };
758
+
759
+ expect(() => finalizeResponse(response)).toThrow(ValidationError);
760
+ expect(() => finalizeResponse(response)).toThrow(/RESPONSE_TOO_LARGE/);
761
+ });
762
+
763
+ it('should preserve existing message and not duplicate', () => {
764
+ const response: BulkCreateResponse = {
765
+ success: true,
766
+ summary: {
767
+ total_requested: 1,
768
+ created: 1,
769
+ duplicates: 0,
770
+ failed: 0,
771
+ },
772
+ results: [
773
+ {
774
+ request_index: 0,
775
+ status: 'created',
776
+ transaction_id: 'txn-123',
777
+ correlation_key: 'YNAB:1',
778
+ },
779
+ ],
780
+ message: 'Custom message',
781
+ };
782
+
783
+ const result = finalizeResponse(response);
784
+ expect(result.message).toBe('Custom message');
785
+ });
786
+ });
787
+
788
+ describe('finalizeBulkUpdateResponse', () => {
789
+ it('should return full response when under threshold', () => {
790
+ const response: BulkUpdateResponse = {
791
+ success: true,
792
+ summary: {
793
+ total_requested: 1,
794
+ updated: 1,
795
+ failed: 0,
796
+ },
797
+ results: [
798
+ {
799
+ request_index: 0,
800
+ status: 'updated',
801
+ transaction_id: 'txn-123',
802
+ correlation_key: 'key-1',
803
+ },
804
+ ],
805
+ transactions: [
806
+ {
807
+ id: 'txn-123',
808
+ account_id: 'acc-1',
809
+ date: '2024-03-15',
810
+ amount: 5000,
811
+ } as ynab.TransactionDetail,
812
+ ],
813
+ };
814
+
815
+ const result = finalizeBulkUpdateResponse(response);
816
+ expect(result.mode).toBe('full');
817
+ expect(result.transactions).toBeDefined();
818
+ });
819
+
820
+ it('should downgrade to summary when full response exceeds threshold', () => {
821
+ const transactions: ynab.TransactionDetail[] = [];
822
+ for (let i = 0; i < 1000; i++) {
823
+ transactions.push({
824
+ id: `txn-${i}`,
825
+ account_id: 'acc-1',
826
+ date: '2024-03-15',
827
+ amount: 5000,
828
+ payee_name: `Very Long Payee Name for Transaction Number ${i} to increase size`,
829
+ memo: `This is a very long memo with lots of text to make the payload larger ${i}`,
830
+ } as ynab.TransactionDetail);
831
+ }
832
+
833
+ const response: BulkUpdateResponse = {
834
+ success: true,
835
+ summary: {
836
+ total_requested: 1000,
837
+ updated: 1000,
838
+ failed: 0,
839
+ },
840
+ results: transactions.map((t, i) => ({
841
+ request_index: i,
842
+ status: 'updated' as const,
843
+ transaction_id: t.id,
844
+ correlation_key: `key-${i}`,
845
+ })),
846
+ transactions,
847
+ };
848
+
849
+ const result = finalizeBulkUpdateResponse(response);
850
+ expect(result.mode).toBe('summary');
851
+ expect(result.transactions).toBeUndefined();
852
+ expect(result.message).toContain('Response downgraded to summary');
853
+ });
854
+
855
+ it('should preserve error_code in ids_only mode when response is large', () => {
856
+ // Create a large response - test that error_code is preserved regardless of mode
857
+ const results = [];
858
+ for (let i = 0; i < 800; i++) {
859
+ results.push({
860
+ request_index: i,
861
+ status: 'failed' as const,
862
+ transaction_id: `t-${i}`,
863
+ correlation_key: `k-${i}`,
864
+ error_code: 'ERR',
865
+ error: 'Error',
866
+ });
867
+ }
868
+
869
+ const response: BulkUpdateResponse = {
870
+ success: false,
871
+ summary: {
872
+ total_requested: 800,
873
+ updated: 0,
874
+ failed: 800,
875
+ },
876
+ results,
877
+ };
878
+
879
+ const result = finalizeBulkUpdateResponse(response);
880
+ // Should be summary or ids_only mode
881
+ expect(['summary', 'ids_only']).toContain(result.mode);
882
+ // Error code should be preserved in results
883
+ expect(result.results[0].error_code).toBe('ERR');
884
+ expect(result.results[0].error).toBeDefined();
885
+ });
886
+
887
+ it('should throw ValidationError when response is too large', () => {
888
+ const results = [];
889
+ for (let i = 0; i < 10000; i++) {
890
+ results.push({
891
+ request_index: i,
892
+ status: 'updated' as const,
893
+ transaction_id: `txn-very-long-transaction-id-with-lots-of-characters-${i}`,
894
+ correlation_key: `key-extremely-long-correlation-key-with-many-characters-${i}`,
895
+ });
896
+ }
897
+
898
+ const response: BulkUpdateResponse = {
899
+ success: true,
900
+ summary: {
901
+ total_requested: 10000,
902
+ updated: 10000,
903
+ failed: 0,
904
+ },
905
+ results,
906
+ };
907
+
908
+ expect(() => finalizeBulkUpdateResponse(response)).toThrow(ValidationError);
909
+ expect(() => finalizeBulkUpdateResponse(response)).toThrow(/RESPONSE_TOO_LARGE/);
910
+ });
911
+ });
912
+
913
+ describe('handleTransactionError', () => {
914
+ beforeEach(() => {
915
+ vi.clearAllMocks();
916
+ });
917
+
918
+ it('should return default message for unknown error', () => {
919
+ const result = handleTransactionError(new Error('Unknown error'), 'Default message');
920
+ expect(result.isError).toBe(true);
921
+ expect(result.content[0].type).toBe('text');
922
+ expect(responseFormatter.format).toHaveBeenCalledWith({
923
+ error: { message: 'Default message' },
924
+ });
925
+ });
926
+
927
+ it('should handle 401 Unauthorized error', () => {
928
+ const error = new Error('401 Unauthorized: Invalid token');
929
+ handleTransactionError(error, 'Default message');
930
+ expect(responseFormatter.format).toHaveBeenCalledWith({
931
+ error: { message: 'Invalid or expired YNAB access token' },
932
+ });
933
+ });
934
+
935
+ it('should handle 403 Forbidden error', () => {
936
+ const error = new Error('403 Forbidden');
937
+ handleTransactionError(error, 'Default message');
938
+ expect(responseFormatter.format).toHaveBeenCalledWith({
939
+ error: { message: 'Insufficient permissions to access YNAB data' },
940
+ });
941
+ });
942
+
943
+ it('should handle 404 Not Found error', () => {
944
+ const error = new Error('404 Not Found');
945
+ handleTransactionError(error, 'Default message');
946
+ expect(responseFormatter.format).toHaveBeenCalledWith({
947
+ error: { message: 'Budget, account, category, or transaction not found' },
948
+ });
949
+ });
950
+
951
+ it('should handle 429 Rate Limit error', () => {
952
+ const error = new Error('429 Too Many Requests');
953
+ handleTransactionError(error, 'Default message');
954
+ expect(responseFormatter.format).toHaveBeenCalledWith({
955
+ error: { message: 'Rate limit exceeded. Please try again later' },
956
+ });
957
+ });
958
+
959
+ it('should handle 500 Internal Server Error', () => {
960
+ const error = new Error('500 Internal Server Error');
961
+ handleTransactionError(error, 'Default message');
962
+ expect(responseFormatter.format).toHaveBeenCalledWith({
963
+ error: { message: 'YNAB service is currently unavailable' },
964
+ });
965
+ });
966
+
967
+ it('should handle error with "Unauthorized" keyword', () => {
968
+ const error = new Error('Request failed: Unauthorized access');
969
+ handleTransactionError(error, 'Default message');
970
+ expect(responseFormatter.format).toHaveBeenCalledWith({
971
+ error: { message: 'Invalid or expired YNAB access token' },
972
+ });
973
+ });
974
+
975
+ it('should handle non-Error objects', () => {
976
+ handleTransactionError('string error', 'Default message');
977
+ expect(responseFormatter.format).toHaveBeenCalledWith({
978
+ error: { message: 'Default message' },
979
+ });
980
+ });
981
+
982
+ it('should handle null error', () => {
983
+ handleTransactionError(null, 'Default message');
984
+ expect(responseFormatter.format).toHaveBeenCalledWith({
985
+ error: { message: 'Default message' },
986
+ });
987
+ });
988
+ });
989
+ });