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