@dizzlkheinz/ynab-mcpb 0.18.3 → 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 (346) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/CLAUDE.md +87 -8
  3. package/bin/ynab-mcp-server.cjs +2 -2
  4. package/bin/ynab-mcp-server.js +3 -3
  5. package/biome.json +39 -0
  6. package/dist/bundle/index.cjs +67 -67
  7. package/dist/index.d.ts +1 -1
  8. package/dist/index.js +27 -27
  9. package/dist/server/YNABMCPServer.d.ts +3 -4
  10. package/dist/server/YNABMCPServer.js +111 -116
  11. package/dist/server/budgetResolver.d.ts +6 -5
  12. package/dist/server/budgetResolver.js +46 -36
  13. package/dist/server/cacheKeys.js +6 -6
  14. package/dist/server/cacheManager.js +14 -11
  15. package/dist/server/completions.d.ts +2 -2
  16. package/dist/server/completions.js +20 -15
  17. package/dist/server/config.d.ts +10 -5
  18. package/dist/server/config.js +24 -7
  19. package/dist/server/deltaCache.d.ts +2 -2
  20. package/dist/server/deltaCache.js +22 -16
  21. package/dist/server/deltaCache.merge.d.ts +2 -2
  22. package/dist/server/diagnostics.d.ts +4 -4
  23. package/dist/server/diagnostics.js +38 -32
  24. package/dist/server/errorHandler.d.ts +5 -12
  25. package/dist/server/errorHandler.js +219 -217
  26. package/dist/server/prompts.d.ts +2 -2
  27. package/dist/server/prompts.js +45 -45
  28. package/dist/server/rateLimiter.js +4 -4
  29. package/dist/server/requestLogger.d.ts +1 -1
  30. package/dist/server/requestLogger.js +40 -35
  31. package/dist/server/resources.d.ts +3 -3
  32. package/dist/server/resources.js +55 -52
  33. package/dist/server/responseFormatter.js +6 -6
  34. package/dist/server/securityMiddleware.d.ts +2 -2
  35. package/dist/server/securityMiddleware.js +22 -20
  36. package/dist/server/serverKnowledgeStore.js +1 -1
  37. package/dist/server/toolRegistry.d.ts +3 -3
  38. package/dist/server/toolRegistry.js +47 -40
  39. package/dist/tools/__tests__/deltaTestUtils.d.ts +3 -3
  40. package/dist/tools/__tests__/deltaTestUtils.js +2 -2
  41. package/dist/tools/accountTools.d.ts +9 -8
  42. package/dist/tools/accountTools.js +47 -47
  43. package/dist/tools/adapters.d.ts +13 -8
  44. package/dist/tools/adapters.js +21 -11
  45. package/dist/tools/budgetTools.d.ts +8 -7
  46. package/dist/tools/budgetTools.js +22 -22
  47. package/dist/tools/categoryTools.d.ts +9 -8
  48. package/dist/tools/categoryTools.js +68 -59
  49. package/dist/tools/compareTransactions/formatter.d.ts +3 -3
  50. package/dist/tools/compareTransactions/formatter.js +9 -9
  51. package/dist/tools/compareTransactions/index.d.ts +6 -6
  52. package/dist/tools/compareTransactions/index.js +58 -43
  53. package/dist/tools/compareTransactions/matcher.d.ts +1 -1
  54. package/dist/tools/compareTransactions/matcher.js +28 -15
  55. package/dist/tools/compareTransactions/parser.d.ts +2 -2
  56. package/dist/tools/compareTransactions/parser.js +144 -138
  57. package/dist/tools/compareTransactions/types.d.ts +4 -4
  58. package/dist/tools/compareTransactions.d.ts +1 -1
  59. package/dist/tools/compareTransactions.js +1 -1
  60. package/dist/tools/deltaFetcher.d.ts +2 -2
  61. package/dist/tools/deltaFetcher.js +16 -15
  62. package/dist/tools/deltaSupport.d.ts +4 -4
  63. package/dist/tools/deltaSupport.js +35 -41
  64. package/dist/tools/exportTransactions.d.ts +5 -4
  65. package/dist/tools/exportTransactions.js +61 -59
  66. package/dist/tools/monthTools.d.ts +7 -6
  67. package/dist/tools/monthTools.js +31 -29
  68. package/dist/tools/payeeTools.d.ts +7 -6
  69. package/dist/tools/payeeTools.js +28 -28
  70. package/dist/tools/reconcileAdapter.d.ts +2 -2
  71. package/dist/tools/reconcileAdapter.js +21 -11
  72. package/dist/tools/reconciliation/analyzer.d.ts +4 -4
  73. package/dist/tools/reconciliation/analyzer.js +136 -57
  74. package/dist/tools/reconciliation/csvParser.d.ts +3 -3
  75. package/dist/tools/reconciliation/csvParser.js +128 -104
  76. package/dist/tools/reconciliation/executor.d.ts +4 -4
  77. package/dist/tools/reconciliation/executor.js +148 -109
  78. package/dist/tools/reconciliation/index.d.ts +10 -10
  79. package/dist/tools/reconciliation/index.js +96 -83
  80. package/dist/tools/reconciliation/matcher.d.ts +3 -3
  81. package/dist/tools/reconciliation/matcher.js +17 -16
  82. package/dist/tools/reconciliation/payeeNormalizer.js +19 -8
  83. package/dist/tools/reconciliation/recommendationEngine.d.ts +1 -1
  84. package/dist/tools/reconciliation/recommendationEngine.js +40 -40
  85. package/dist/tools/reconciliation/reportFormatter.d.ts +2 -2
  86. package/dist/tools/reconciliation/reportFormatter.js +79 -54
  87. package/dist/tools/reconciliation/signDetector.d.ts +1 -1
  88. package/dist/tools/reconciliation/types.d.ts +19 -16
  89. package/dist/tools/reconciliation/ynabAdapter.d.ts +2 -2
  90. package/dist/tools/schemas/common.d.ts +1 -1
  91. package/dist/tools/schemas/common.js +1 -1
  92. package/dist/tools/schemas/outputs/accountOutputs.d.ts +1 -1
  93. package/dist/tools/schemas/outputs/accountOutputs.js +24 -18
  94. package/dist/tools/schemas/outputs/budgetOutputs.d.ts +1 -1
  95. package/dist/tools/schemas/outputs/budgetOutputs.js +14 -11
  96. package/dist/tools/schemas/outputs/categoryOutputs.d.ts +1 -1
  97. package/dist/tools/schemas/outputs/categoryOutputs.js +49 -29
  98. package/dist/tools/schemas/outputs/comparisonOutputs.d.ts +1 -1
  99. package/dist/tools/schemas/outputs/comparisonOutputs.js +12 -12
  100. package/dist/tools/schemas/outputs/index.d.ts +14 -14
  101. package/dist/tools/schemas/outputs/index.js +14 -14
  102. package/dist/tools/schemas/outputs/monthOutputs.d.ts +1 -1
  103. package/dist/tools/schemas/outputs/monthOutputs.js +56 -41
  104. package/dist/tools/schemas/outputs/payeeOutputs.d.ts +1 -1
  105. package/dist/tools/schemas/outputs/payeeOutputs.js +10 -10
  106. package/dist/tools/schemas/outputs/reconciliationOutputs.d.ts +2 -2
  107. package/dist/tools/schemas/outputs/reconciliationOutputs.js +45 -45
  108. package/dist/tools/schemas/outputs/transactionMutationOutputs.d.ts +1 -1
  109. package/dist/tools/schemas/outputs/transactionMutationOutputs.js +28 -22
  110. package/dist/tools/schemas/outputs/transactionOutputs.d.ts +1 -1
  111. package/dist/tools/schemas/outputs/transactionOutputs.js +43 -35
  112. package/dist/tools/schemas/outputs/utilityOutputs.d.ts +1 -1
  113. package/dist/tools/schemas/outputs/utilityOutputs.js +5 -3
  114. package/dist/tools/schemas/shared/commonOutputs.d.ts +1 -1
  115. package/dist/tools/schemas/shared/commonOutputs.js +15 -9
  116. package/dist/tools/transactionReadTools.d.ts +11 -0
  117. package/dist/tools/transactionReadTools.js +202 -0
  118. package/dist/tools/transactionSchemas.d.ts +309 -0
  119. package/dist/tools/transactionSchemas.js +235 -0
  120. package/dist/tools/transactionTools.d.ts +6 -302
  121. package/dist/tools/transactionTools.js +7 -2054
  122. package/dist/tools/transactionUtils.d.ts +31 -0
  123. package/dist/tools/transactionUtils.js +364 -0
  124. package/dist/tools/transactionWriteTools.d.ts +20 -0
  125. package/dist/tools/transactionWriteTools.js +1342 -0
  126. package/dist/tools/utilityTools.d.ts +5 -4
  127. package/dist/tools/utilityTools.js +11 -11
  128. package/dist/types/index.d.ts +7 -7
  129. package/dist/types/index.js +6 -6
  130. package/dist/types/reconciliation.d.ts +1 -1
  131. package/dist/types/toolRegistration.d.ts +14 -12
  132. package/dist/utils/amountUtils.js +1 -1
  133. package/dist/utils/dateUtils.js +4 -4
  134. package/dist/utils/errors.d.ts +3 -3
  135. package/dist/utils/errors.js +4 -4
  136. package/dist/utils/money.d.ts +2 -2
  137. package/dist/utils/money.js +8 -8
  138. package/dist/utils/validationError.d.ts +1 -1
  139. package/dist/utils/validationError.js +1 -1
  140. package/docs/assets/examples/reconciliation-with-recommendations.json +66 -66
  141. package/docs/assets/schemas/reconciliation-v2.json +360 -336
  142. package/docs/plans/2025-12-25-transaction-tools-refactor-design.md +211 -0
  143. package/docs/plans/2025-12-25-transaction-tools-refactor.md +905 -0
  144. package/esbuild.config.mjs +53 -50
  145. package/meta.json +12548 -12548
  146. package/package.json +98 -109
  147. package/scripts/analyze-bundle.mjs +33 -30
  148. package/scripts/create-pr-description.js +169 -120
  149. package/scripts/run-all-tests.js +205 -0
  150. package/scripts/run-domain-integration-tests.js +28 -18
  151. package/scripts/run-generate-mcpb.js +19 -17
  152. package/scripts/run-throttled-integration-tests.js +92 -83
  153. package/scripts/test-delta-params.mjs +149 -120
  154. package/scripts/test-recommendations.ts +36 -32
  155. package/scripts/tmpTransaction.ts +80 -43
  156. package/scripts/validate-env.js +98 -91
  157. package/scripts/verify-build.js +78 -76
  158. package/src/__tests__/comprehensive.integration.test.ts +1281 -1154
  159. package/src/__tests__/performance.test.ts +723 -671
  160. package/src/__tests__/setup.ts +442 -395
  161. package/src/__tests__/smoke.e2e.test.ts +41 -39
  162. package/src/__tests__/testRunner.ts +314 -295
  163. package/src/__tests__/testUtils.ts +456 -364
  164. package/src/__tests__/tools/reconciliation/csvParser.integration.test.ts +109 -107
  165. package/src/__tests__/tools/reconciliation/real-world.integration.test.ts +41 -41
  166. package/src/index.ts +68 -59
  167. package/src/server/CLAUDE.md +480 -0
  168. package/src/server/YNABMCPServer.ts +821 -794
  169. package/src/server/__tests__/YNABMCPServer.integration.test.ts +929 -893
  170. package/src/server/__tests__/YNABMCPServer.test.ts +903 -899
  171. package/src/server/__tests__/budgetResolver.test.ts +466 -423
  172. package/src/server/__tests__/cacheManager.test.ts +891 -874
  173. package/src/server/__tests__/completions.integration.test.ts +115 -106
  174. package/src/server/__tests__/completions.test.ts +334 -313
  175. package/src/server/__tests__/config.test.ts +98 -86
  176. package/src/server/__tests__/deltaCache.merge.test.ts +774 -703
  177. package/src/server/__tests__/deltaCache.swr.test.ts +198 -153
  178. package/src/server/__tests__/deltaCache.test.ts +946 -759
  179. package/src/server/__tests__/diagnostics.test.ts +825 -792
  180. package/src/server/__tests__/errorHandler.integration.test.ts +512 -462
  181. package/src/server/__tests__/errorHandler.test.ts +402 -397
  182. package/src/server/__tests__/prompts.test.ts +424 -347
  183. package/src/server/__tests__/rateLimiter.test.ts +313 -309
  184. package/src/server/__tests__/requestLogger.test.ts +443 -403
  185. package/src/server/__tests__/resources.template.test.ts +196 -185
  186. package/src/server/__tests__/resources.test.ts +294 -288
  187. package/src/server/__tests__/security.integration.test.ts +487 -421
  188. package/src/server/__tests__/securityMiddleware.test.ts +519 -444
  189. package/src/server/__tests__/server-startup.integration.test.ts +509 -490
  190. package/src/server/__tests__/serverKnowledgeStore.test.ts +174 -173
  191. package/src/server/__tests__/toolRegistration.test.ts +239 -210
  192. package/src/server/__tests__/toolRegistry.test.ts +907 -845
  193. package/src/server/budgetResolver.ts +221 -181
  194. package/src/server/cacheKeys.ts +6 -6
  195. package/src/server/cacheManager.ts +498 -484
  196. package/src/server/completions.ts +267 -243
  197. package/src/server/config.ts +35 -14
  198. package/src/server/deltaCache.merge.ts +146 -128
  199. package/src/server/deltaCache.ts +352 -309
  200. package/src/server/diagnostics.ts +257 -242
  201. package/src/server/errorHandler.ts +747 -744
  202. package/src/server/prompts.ts +181 -176
  203. package/src/server/rateLimiter.ts +131 -129
  204. package/src/server/requestLogger.ts +350 -322
  205. package/src/server/resources.ts +442 -374
  206. package/src/server/responseFormatter.ts +41 -37
  207. package/src/server/securityMiddleware.ts +223 -205
  208. package/src/server/serverKnowledgeStore.ts +67 -67
  209. package/src/server/toolRegistry.ts +508 -474
  210. package/src/tools/CLAUDE.md +604 -0
  211. package/src/tools/__tests__/accountTools.delta.integration.test.ts +128 -111
  212. package/src/tools/__tests__/accountTools.integration.test.ts +129 -111
  213. package/src/tools/__tests__/accountTools.test.ts +685 -638
  214. package/src/tools/__tests__/adapters.test.ts +142 -108
  215. package/src/tools/__tests__/budgetTools.delta.integration.test.ts +73 -73
  216. package/src/tools/__tests__/budgetTools.integration.test.ts +132 -124
  217. package/src/tools/__tests__/budgetTools.test.ts +442 -413
  218. package/src/tools/__tests__/categoryTools.delta.integration.test.ts +76 -68
  219. package/src/tools/__tests__/categoryTools.integration.test.ts +314 -288
  220. package/src/tools/__tests__/categoryTools.test.ts +656 -625
  221. package/src/tools/__tests__/compareTransactions/formatter.test.ts +535 -462
  222. package/src/tools/__tests__/compareTransactions/index.test.ts +378 -358
  223. package/src/tools/__tests__/compareTransactions/matcher.test.ts +497 -398
  224. package/src/tools/__tests__/compareTransactions/parser.test.ts +765 -747
  225. package/src/tools/__tests__/compareTransactions.test.ts +352 -332
  226. package/src/tools/__tests__/compareTransactions.window.test.ts +150 -146
  227. package/src/tools/__tests__/deltaFetcher.scheduled.integration.test.ts +69 -65
  228. package/src/tools/__tests__/deltaFetcher.test.ts +325 -265
  229. package/src/tools/__tests__/deltaSupport.test.ts +211 -184
  230. package/src/tools/__tests__/deltaTestUtils.ts +37 -33
  231. package/src/tools/__tests__/exportTransactions.test.ts +205 -200
  232. package/src/tools/__tests__/monthTools.delta.integration.test.ts +68 -68
  233. package/src/tools/__tests__/monthTools.integration.test.ts +178 -166
  234. package/src/tools/__tests__/monthTools.test.ts +561 -512
  235. package/src/tools/__tests__/payeeTools.delta.integration.test.ts +68 -68
  236. package/src/tools/__tests__/payeeTools.integration.test.ts +158 -142
  237. package/src/tools/__tests__/payeeTools.test.ts +486 -434
  238. package/src/tools/__tests__/transactionSchemas.test.ts +1204 -0
  239. package/src/tools/__tests__/transactionTools.integration.test.ts +875 -825
  240. package/src/tools/__tests__/transactionTools.test.ts +4923 -4366
  241. package/src/tools/__tests__/transactionUtils.test.ts +1016 -0
  242. package/src/tools/__tests__/utilityTools.integration.test.ts +32 -32
  243. package/src/tools/__tests__/utilityTools.test.ts +68 -58
  244. package/src/tools/accountTools.ts +293 -271
  245. package/src/tools/adapters.ts +120 -63
  246. package/src/tools/budgetTools.ts +121 -116
  247. package/src/tools/categoryTools.ts +379 -339
  248. package/src/tools/compareTransactions/formatter.ts +131 -119
  249. package/src/tools/compareTransactions/index.ts +249 -214
  250. package/src/tools/compareTransactions/matcher.ts +259 -209
  251. package/src/tools/compareTransactions/parser.ts +517 -487
  252. package/src/tools/compareTransactions/types.ts +38 -38
  253. package/src/tools/compareTransactions.ts +1 -1
  254. package/src/tools/deltaFetcher.ts +281 -260
  255. package/src/tools/deltaSupport.ts +264 -259
  256. package/src/tools/exportTransactions.ts +230 -218
  257. package/src/tools/monthTools.ts +180 -165
  258. package/src/tools/payeeTools.ts +152 -140
  259. package/src/tools/reconcileAdapter.ts +297 -246
  260. package/src/tools/reconciliation/CLAUDE.md +506 -0
  261. package/src/tools/reconciliation/__tests__/adapter.causes.test.ts +135 -112
  262. package/src/tools/reconciliation/__tests__/adapter.test.ts +249 -227
  263. package/src/tools/reconciliation/__tests__/analyzer.test.ts +408 -335
  264. package/src/tools/reconciliation/__tests__/csvParser.test.ts +71 -69
  265. package/src/tools/reconciliation/__tests__/executor.integration.test.ts +348 -323
  266. package/src/tools/reconciliation/__tests__/executor.progress.test.ts +503 -457
  267. package/src/tools/reconciliation/__tests__/executor.test.ts +898 -831
  268. package/src/tools/reconciliation/__tests__/matcher.test.ts +667 -663
  269. package/src/tools/reconciliation/__tests__/payeeNormalizer.test.ts +296 -276
  270. package/src/tools/reconciliation/__tests__/recommendationEngine.integration.test.ts +692 -624
  271. package/src/tools/reconciliation/__tests__/recommendationEngine.test.ts +1008 -986
  272. package/src/tools/reconciliation/__tests__/reconciliation.delta.integration.test.ts +187 -146
  273. package/src/tools/reconciliation/__tests__/reportFormatter.test.ts +583 -530
  274. package/src/tools/reconciliation/__tests__/scenarios/adapterCurrency.scenario.test.ts +75 -71
  275. package/src/tools/reconciliation/__tests__/scenarios/extremes.scenario.test.ts +70 -58
  276. package/src/tools/reconciliation/__tests__/scenarios/repeatAmount.scenario.test.ts +102 -88
  277. package/src/tools/reconciliation/__tests__/schemaUrl.test.ts +58 -43
  278. package/src/tools/reconciliation/__tests__/signDetector.test.ts +209 -206
  279. package/src/tools/reconciliation/__tests__/ynabAdapter.test.ts +66 -60
  280. package/src/tools/reconciliation/analyzer.ts +582 -406
  281. package/src/tools/reconciliation/csvParser.ts +656 -609
  282. package/src/tools/reconciliation/executor.ts +1290 -1128
  283. package/src/tools/reconciliation/index.ts +580 -528
  284. package/src/tools/reconciliation/matcher.ts +256 -240
  285. package/src/tools/reconciliation/payeeNormalizer.ts +92 -78
  286. package/src/tools/reconciliation/recommendationEngine.ts +357 -345
  287. package/src/tools/reconciliation/reportFormatter.ts +349 -276
  288. package/src/tools/reconciliation/signDetector.ts +89 -83
  289. package/src/tools/reconciliation/types.ts +164 -153
  290. package/src/tools/reconciliation/ynabAdapter.ts +17 -15
  291. package/src/tools/schemas/CLAUDE.md +546 -0
  292. package/src/tools/schemas/common.ts +1 -1
  293. package/src/tools/schemas/outputs/__tests__/accountOutputs.test.ts +410 -409
  294. package/src/tools/schemas/outputs/__tests__/budgetOutputs.test.ts +305 -299
  295. package/src/tools/schemas/outputs/__tests__/categoryOutputs.test.ts +431 -430
  296. package/src/tools/schemas/outputs/__tests__/comparisonOutputs.test.ts +510 -495
  297. package/src/tools/schemas/outputs/__tests__/dateValidation.test.ts +179 -153
  298. package/src/tools/schemas/outputs/__tests__/discrepancyDirection.test.ts +293 -254
  299. package/src/tools/schemas/outputs/__tests__/monthOutputs.test.ts +457 -457
  300. package/src/tools/schemas/outputs/__tests__/payeeOutputs.test.ts +362 -356
  301. package/src/tools/schemas/outputs/__tests__/reconciliationOutputs.test.ts +402 -399
  302. package/src/tools/schemas/outputs/__tests__/transactionMutationSchemas.test.ts +225 -211
  303. package/src/tools/schemas/outputs/__tests__/transactionOutputs.test.ts +457 -454
  304. package/src/tools/schemas/outputs/__tests__/utilityOutputs.test.ts +316 -315
  305. package/src/tools/schemas/outputs/accountOutputs.ts +40 -34
  306. package/src/tools/schemas/outputs/budgetOutputs.ts +24 -19
  307. package/src/tools/schemas/outputs/categoryOutputs.ts +76 -56
  308. package/src/tools/schemas/outputs/comparisonOutputs.ts +192 -169
  309. package/src/tools/schemas/outputs/index.ts +163 -163
  310. package/src/tools/schemas/outputs/monthOutputs.ts +95 -80
  311. package/src/tools/schemas/outputs/payeeOutputs.ts +18 -18
  312. package/src/tools/schemas/outputs/reconciliationOutputs.ts +386 -373
  313. package/src/tools/schemas/outputs/transactionMutationOutputs.ts +259 -231
  314. package/src/tools/schemas/outputs/transactionOutputs.ts +81 -71
  315. package/src/tools/schemas/outputs/utilityOutputs.ts +90 -84
  316. package/src/tools/schemas/shared/commonOutputs.ts +27 -19
  317. package/src/tools/toolCategories.ts +114 -114
  318. package/src/tools/transactionReadTools.ts +327 -0
  319. package/src/tools/transactionSchemas.ts +484 -0
  320. package/src/tools/transactionTools.ts +107 -2990
  321. package/src/tools/transactionUtils.ts +621 -0
  322. package/src/tools/transactionWriteTools.ts +2110 -0
  323. package/src/tools/utilityTools.ts +46 -41
  324. package/src/types/CLAUDE.md +477 -0
  325. package/src/types/__tests__/index.test.ts +51 -51
  326. package/src/types/index.ts +43 -39
  327. package/src/types/integration-tests.d.ts +26 -26
  328. package/src/types/reconciliation.ts +29 -29
  329. package/src/types/toolAnnotations.ts +30 -30
  330. package/src/types/toolRegistration.ts +43 -32
  331. package/src/utils/CLAUDE.md +508 -0
  332. package/src/utils/__tests__/dateUtils.test.ts +174 -168
  333. package/src/utils/__tests__/money.test.ts +193 -187
  334. package/src/utils/amountUtils.ts +5 -5
  335. package/src/utils/baseError.ts +5 -5
  336. package/src/utils/dateUtils.ts +29 -26
  337. package/src/utils/errors.ts +14 -14
  338. package/src/utils/money.ts +66 -52
  339. package/src/utils/validationError.ts +1 -1
  340. package/tsconfig.json +29 -29
  341. package/tsconfig.prod.json +16 -16
  342. package/vitest-reporters/split-json-reporter.ts +247 -204
  343. package/vitest.config.ts +99 -95
  344. package/.prettierignore +0 -10
  345. package/.prettierrc.json +0 -10
  346. package/eslint.config.js +0 -49
@@ -0,0 +1,1016 @@
1
+ /**
2
+ * Unit tests for transactionUtils.ts
3
+ */
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";
9
+ import type {
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
+
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(() =>
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
+ });
1016
+ });