@dizzlkheinz/ynab-mcpb 0.12.1

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 (435) hide show
  1. package/.chunkhound.json +11 -0
  2. package/.code/agents/0427d95e-edca-431f-a214-5e53264e29c4/error.txt +8 -0
  3. package/.code/agents/0d675174-d1e1-41c3-9975-4c2e275819a9/error.txt +3 -0
  4. package/.code/agents/0d8c5afd-4787-422b-abf8-2e5943fc7e67/error.txt +3 -0
  5. package/.code/agents/0ec34a70-ed5d-4b9e-bee4-bb0e4cccbc4b/error.txt +1 -0
  6. package/.code/agents/0ef51a21-1ab1-49d7-9561-0eaa43875ebc/error.txt +12 -0
  7. package/.code/agents/15db95d7-abad-4b4d-9c3b-8446089cb61d/error.txt +1 -0
  8. package/.code/agents/19ab9acb-f675-4ff0-902a-09a5476f8149/error.txt +1 -0
  9. package/.code/agents/1ef7e12d-f6ff-4897-8a9b-152d523d898e/error.txt +5 -0
  10. package/.code/agents/2465/exec-call_lroN9KKzJVWC7t5423DK1nT9.txt +1453 -0
  11. package/.code/agents/28edb6fe-95a9-41a0-ae69-aa0100d26c0c/error.txt +8 -0
  12. package/.code/agents/2ae40cf5-b4bf-42e2-92bf-7ea350a7755e/error.txt +9 -0
  13. package/.code/agents/2bfc4e1f-ac4b-45a5-b6df-bf89d4dbb54c/error.txt +1 -0
  14. package/.code/agents/2e2e1134-eff0-49be-ba25-8e2c3468a564/error.txt +5 -0
  15. package/.code/agents/3/exec-call_203OC4TNVkLxW7z2HCVEQ1cM.txt +81 -0
  16. package/.code/agents/3/exec-call_SS5T0XSiXB4LSNzUKTl75wkh.txt +610 -0
  17. package/.code/agents/3322c003-ce5e-48e3-a342-f5049c5bf9a2/error.txt +1 -0
  18. package/.code/agents/391e9b08-1ebc-468c-9bcd-6d0cc3193b37/error.txt +1 -0
  19. package/.code/agents/3ab0aa84-b7bb-4054-afa3-40b8fd7d3be0/error.txt +1 -0
  20. package/.code/agents/3bed368d-50fe-477e-aee3-a6707eaa1ab9/error.txt +3 -0
  21. package/.code/agents/3e40b925-db12-442f-8d7a-a25fc69a6672/error.txt +8 -0
  22. package/.code/agents/414d5776-cf58-41f3-9328-a6daed503a50/error.txt +5 -0
  23. package/.code/agents/42687751-4565-4610-b240-67835b17d861/error.txt +1 -0
  24. package/.code/agents/46b98876-1a39-43c9-9e2f-507ca6d47335/error.txt +9 -0
  25. package/.code/agents/4a7d9491-b26f-43dd-850d-2ecdc49b5d1b/error.txt +1 -0
  26. package/.code/agents/4e60f00a-1b3e-447f-87f3-7faf9deddec3/error.txt +13 -0
  27. package/.code/agents/5138fc1c-4d49-4b74-a7da-ccdb3a8e44e7/error.txt +14 -0
  28. package/.code/agents/521cff39-a7a3-42e5-a557-134f0f7daaa0/error.txt +5 -0
  29. package/.code/agents/53302dc5-3857-4413-9a47-9e0f64a51dc4/error.txt +5 -0
  30. package/.code/agents/567c7c2e-6a6f-4761-a08d-d36deeb2e0ac/error.txt +5 -0
  31. package/.code/agents/57b00845-80dc-47c9-953c-3028d16275d6/error.txt +3 -0
  32. package/.code/agents/593d9005-c2a5-48fd-8813-ece0d3f2de96/error.txt +1 -0
  33. package/.code/agents/5a112e66-0e1a-42f9-877c-53af56ea3551/error.txt +1 -0
  34. package/.code/agents/5b05e8ed-7788-4738-b7ee-9faa8180f992/error.txt +5 -0
  35. package/.code/agents/5f888d6f-d7ca-4ac8-be23-9ea1bf753951/error.txt +5 -0
  36. package/.code/agents/607db3ab-e4b0-435b-b497-93e9aa525549/error.txt +8 -0
  37. package/.code/agents/67dcb2a2-900f-4c78-b3fc-80b5213e0ddf/error.txt +8 -0
  38. package/.code/agents/69ad848c-4e98-49b3-b16c-0094ac2d1759/error.txt +5 -0
  39. package/.code/agents/6c9cfc5f-0d0b-445c-b121-9f60082c4f70/error.txt +1 -0
  40. package/.code/agents/6f6f8f77-4ab0-4f6e-9f30-40e8be0bd8f5/error.txt +1 -0
  41. package/.code/agents/72a7cde4-fa8a-4024-9038-27faa550539b/error.txt +1 -0
  42. package/.code/agents/7b48335c-8247-43aa-9949-5f820ba8e199/error.txt +1 -0
  43. package/.code/agents/80944249-bea9-4ac5-87de-a666c4df306e/error.txt +1 -0
  44. package/.code/agents/826099df-1b66-4186-a915-7eb59f9db19d/error.txt +5 -0
  45. package/.code/agents/8291d158-18a8-4a92-b799-4e9a4d9cce88/error.txt +1 -0
  46. package/.code/agents/82fb71a3-20fb-4341-804a-a2fc900f95bc/error.txt +1 -0
  47. package/.code/agents/855790ea-54ee-43e4-8209-a66994e37590/error.txt +1 -0
  48. package/.code/agents/88ce3a2e-04f2-42be-9062-bf97aa798da0/error.txt +3 -0
  49. package/.code/agents/9a17e398-b6ed-4218-bb55-bc64a8d38ce8/error.txt +8 -0
  50. package/.code/agents/9a4f4bfc-a2a6-4f40-a896-9335b41a7ed1/error.txt +1 -0
  51. package/.code/agents/9b633e55-ef84-47d6-94bb-fd3dd172ad97/error.txt +1 -0
  52. package/.code/agents/9b81f3ab-c72b-4a81-9a8f-28a49ddba84a/error.txt +8 -0
  53. package/.code/agents/a35daf29-b2d1-4aef-9b42-dad63a76bd47/error.txt +3 -0
  54. package/.code/agents/a81990cc-69ee-44d2-b907-17403c9bc5d7/error.txt +5 -0
  55. package/.code/agents/ab56260a-4a83-4ad4-9410-f88a23d6520a/error.txt +1 -0
  56. package/.code/agents/ad722c31-2d1d-45f7-bae2-3f02ca455b60/error.txt +1 -0
  57. package/.code/agents/b62e8690-3324-4b97-9309-731bee79416b/error.txt +5 -0
  58. package/.code/agents/baf60a3a-752b-4ad8-99d6-df32423ed2eb/error.txt +1 -0
  59. package/.code/agents/be049042-7dcb-4ac8-9beb-c8f1aea67742/error.txt +14 -0
  60. package/.code/agents/bed1dcb4-bfce-4a9f-8594-0f994962aafd/error.txt +1 -0
  61. package/.code/agents/c324a6cf-e935-4ede-9529-b3ebc18e8d6b/error.txt +5 -0
  62. package/.code/agents/c37c06ff-dfe3-43f2-9bbc-3ec73ec8f41d/error.txt +5 -0
  63. package/.code/agents/c8cd6671-433a-456b-9f88-e51cb2df6bfc/error.txt +11 -0
  64. package/.code/agents/ca2ccb67-2f24-428e-b27d-9365beadd140/error.txt +1 -0
  65. package/.code/agents/cf08c0c8-e7f0-423e-93ba-547e8e818340/error.txt +8 -0
  66. package/.code/agents/d579c74f-874b-40a4-9d56-ced1eb6a701d/error.txt +1 -0
  67. package/.code/agents/df412c98-7378-4deb-8e1e-76c416931181/error.txt +3 -0
  68. package/.code/agents/e5134eb3-2af4-45b0-8998-051cb4afdb45/error.txt +3 -0
  69. package/.code/agents/e6308471-aa45-4e9e-9496-2e9404164d97/error.txt +8 -0
  70. package/.code/agents/e7bd8bc7-23fb-4f46-98dc-b0dcf11b75a1/error.txt +1 -0
  71. package/.code/agents/e92bec35-378d-4fe1-8ac0-6e1bb3c86911/error.txt +5 -0
  72. package/.code/agents/ed918fbf-2dc4-4aa2-bfc5-04b65d9471ea/error.txt +1 -0
  73. package/.code/agents/ef1d756f-b272-48fc-8729-f05c494674f7/error.txt +1 -0
  74. package/.code/agents/ef359853-0249-4e41-a804-c0fc459fe456/error.txt +1 -0
  75. package/.code/agents/effc7b4a-4b90-40a0-8c86-a7a99d2d5fd2/error.txt +1 -0
  76. package/.code/agents/fa15f8d5-8359-4a8b-83a3-2f2056b3ff40/error.txt +3 -0
  77. package/.code/agents/fbef4193-eadf-4c8a-83ff-4878a6310f25/error.txt +8 -0
  78. package/.code/agents/fd0a4b4a-fda4-4964-a6d6-2b8a2da387c6/error.txt +1 -0
  79. package/.dxtignore +57 -0
  80. package/.env.example +44 -0
  81. package/.gemini/settings.json +8 -0
  82. package/.github/ISSUE_TEMPLATE/bug_report.md +41 -0
  83. package/.github/ISSUE_TEMPLATE/config.yml +5 -0
  84. package/.github/ISSUE_TEMPLATE/feature_request.md +24 -0
  85. package/.github/ISSUE_TEMPLATE/release_checklist.md +31 -0
  86. package/.github/pull_request_template.md +41 -0
  87. package/.github/workflows/ci-tests.yml +41 -0
  88. package/.github/workflows/claude-code-review.yml +57 -0
  89. package/.github/workflows/claude.yml +50 -0
  90. package/.github/workflows/full-integration.yml +22 -0
  91. package/.github/workflows/pr-description-check.yml +88 -0
  92. package/.github/workflows/publish.yml +33 -0
  93. package/.github/workflows/release.yml +89 -0
  94. package/.mcpbignore +58 -0
  95. package/.prettierignore +10 -0
  96. package/.prettierrc.json +10 -0
  97. package/ADOS-2-Module-1-Complete-Manual.md +757 -0
  98. package/AGENTS.md +36 -0
  99. package/CHANGELOG.md +187 -0
  100. package/CLAUDE.md +414 -0
  101. package/CODEREVIEW_RESPONSE.md +128 -0
  102. package/LICENSE +17 -0
  103. package/NUL +1 -0
  104. package/README.md +222 -0
  105. package/SCHEMA_IMPROVEMENT_SUMMARY.md +120 -0
  106. package/TESTING_NOTES.md +217 -0
  107. package/WARP.md +245 -0
  108. package/accountactivity-merged.csv +149 -0
  109. package/bin/ynab-mcp-server.cjs +4 -0
  110. package/bin/ynab-mcp-server.js +8 -0
  111. package/bundle-analysis.html +13110 -0
  112. package/dist/bundle/index.cjs +124 -0
  113. package/dist/index.d.ts +2 -0
  114. package/dist/index.js +85 -0
  115. package/dist/server/YNABMCPServer.d.ts +264 -0
  116. package/dist/server/YNABMCPServer.js +845 -0
  117. package/dist/server/budgetResolver.d.ts +15 -0
  118. package/dist/server/budgetResolver.js +99 -0
  119. package/dist/server/cacheManager.d.ts +74 -0
  120. package/dist/server/cacheManager.js +306 -0
  121. package/dist/server/config.d.ts +3 -0
  122. package/dist/server/config.js +19 -0
  123. package/dist/server/deltaCache.d.ts +61 -0
  124. package/dist/server/deltaCache.js +206 -0
  125. package/dist/server/deltaCache.merge.d.ts +9 -0
  126. package/dist/server/deltaCache.merge.js +111 -0
  127. package/dist/server/diagnostics.d.ts +90 -0
  128. package/dist/server/diagnostics.js +163 -0
  129. package/dist/server/errorHandler.d.ts +69 -0
  130. package/dist/server/errorHandler.js +524 -0
  131. package/dist/server/prompts.d.ts +31 -0
  132. package/dist/server/prompts.js +205 -0
  133. package/dist/server/rateLimiter.d.ts +27 -0
  134. package/dist/server/rateLimiter.js +82 -0
  135. package/dist/server/requestLogger.d.ts +62 -0
  136. package/dist/server/requestLogger.js +190 -0
  137. package/dist/server/resources.d.ts +39 -0
  138. package/dist/server/resources.js +85 -0
  139. package/dist/server/responseFormatter.d.ts +14 -0
  140. package/dist/server/responseFormatter.js +42 -0
  141. package/dist/server/securityMiddleware.d.ts +87 -0
  142. package/dist/server/securityMiddleware.js +117 -0
  143. package/dist/server/serverKnowledgeStore.d.ts +11 -0
  144. package/dist/server/serverKnowledgeStore.js +42 -0
  145. package/dist/server/toolRegistry.d.ts +85 -0
  146. package/dist/server/toolRegistry.js +272 -0
  147. package/dist/tools/__tests__/deltaTestUtils.d.ts +18 -0
  148. package/dist/tools/__tests__/deltaTestUtils.js +26 -0
  149. package/dist/tools/accountTools.d.ts +37 -0
  150. package/dist/tools/accountTools.js +175 -0
  151. package/dist/tools/budgetTools.d.ts +10 -0
  152. package/dist/tools/budgetTools.js +68 -0
  153. package/dist/tools/categoryTools.d.ts +27 -0
  154. package/dist/tools/categoryTools.js +232 -0
  155. package/dist/tools/compareTransactions/formatter.d.ts +71 -0
  156. package/dist/tools/compareTransactions/formatter.js +97 -0
  157. package/dist/tools/compareTransactions/index.d.ts +30 -0
  158. package/dist/tools/compareTransactions/index.js +160 -0
  159. package/dist/tools/compareTransactions/matcher.d.ts +12 -0
  160. package/dist/tools/compareTransactions/matcher.js +140 -0
  161. package/dist/tools/compareTransactions/parser.d.ts +14 -0
  162. package/dist/tools/compareTransactions/parser.js +430 -0
  163. package/dist/tools/compareTransactions/types.d.ts +27 -0
  164. package/dist/tools/compareTransactions/types.js +1 -0
  165. package/dist/tools/compareTransactions.d.ts +1 -0
  166. package/dist/tools/compareTransactions.js +1 -0
  167. package/dist/tools/deltaFetcher.d.ts +22 -0
  168. package/dist/tools/deltaFetcher.js +137 -0
  169. package/dist/tools/deltaSupport.d.ts +20 -0
  170. package/dist/tools/deltaSupport.js +176 -0
  171. package/dist/tools/exportTransactions.d.ts +17 -0
  172. package/dist/tools/exportTransactions.js +191 -0
  173. package/dist/tools/monthTools.d.ts +16 -0
  174. package/dist/tools/monthTools.js +107 -0
  175. package/dist/tools/payeeTools.d.ts +17 -0
  176. package/dist/tools/payeeTools.js +82 -0
  177. package/dist/tools/reconcileAdapter.d.ts +25 -0
  178. package/dist/tools/reconcileAdapter.js +167 -0
  179. package/dist/tools/reconciliation/analyzer.d.ts +3 -0
  180. package/dist/tools/reconciliation/analyzer.js +567 -0
  181. package/dist/tools/reconciliation/executor.d.ts +94 -0
  182. package/dist/tools/reconciliation/executor.js +611 -0
  183. package/dist/tools/reconciliation/index.d.ts +54 -0
  184. package/dist/tools/reconciliation/index.js +249 -0
  185. package/dist/tools/reconciliation/matcher.d.ts +3 -0
  186. package/dist/tools/reconciliation/matcher.js +160 -0
  187. package/dist/tools/reconciliation/payeeNormalizer.d.ts +6 -0
  188. package/dist/tools/reconciliation/payeeNormalizer.js +77 -0
  189. package/dist/tools/reconciliation/recommendationEngine.d.ts +2 -0
  190. package/dist/tools/reconciliation/recommendationEngine.js +273 -0
  191. package/dist/tools/reconciliation/reportFormatter.d.ts +13 -0
  192. package/dist/tools/reconciliation/reportFormatter.js +214 -0
  193. package/dist/tools/reconciliation/types.d.ts +172 -0
  194. package/dist/tools/reconciliation/types.js +7 -0
  195. package/dist/tools/schemas/outputs/accountOutputs.d.ts +58 -0
  196. package/dist/tools/schemas/outputs/accountOutputs.js +24 -0
  197. package/dist/tools/schemas/outputs/budgetOutputs.d.ts +48 -0
  198. package/dist/tools/schemas/outputs/budgetOutputs.js +15 -0
  199. package/dist/tools/schemas/outputs/categoryOutputs.d.ts +93 -0
  200. package/dist/tools/schemas/outputs/categoryOutputs.js +37 -0
  201. package/dist/tools/schemas/outputs/comparisonOutputs.d.ts +269 -0
  202. package/dist/tools/schemas/outputs/comparisonOutputs.js +181 -0
  203. package/dist/tools/schemas/outputs/index.d.ts +14 -0
  204. package/dist/tools/schemas/outputs/index.js +14 -0
  205. package/dist/tools/schemas/outputs/monthOutputs.d.ts +122 -0
  206. package/dist/tools/schemas/outputs/monthOutputs.js +51 -0
  207. package/dist/tools/schemas/outputs/payeeOutputs.d.ts +34 -0
  208. package/dist/tools/schemas/outputs/payeeOutputs.js +16 -0
  209. package/dist/tools/schemas/outputs/reconciliationOutputs.d.ts +1275 -0
  210. package/dist/tools/schemas/outputs/reconciliationOutputs.js +377 -0
  211. package/dist/tools/schemas/outputs/transactionMutationOutputs.d.ts +717 -0
  212. package/dist/tools/schemas/outputs/transactionMutationOutputs.js +260 -0
  213. package/dist/tools/schemas/outputs/transactionOutputs.d.ts +98 -0
  214. package/dist/tools/schemas/outputs/transactionOutputs.js +49 -0
  215. package/dist/tools/schemas/outputs/utilityOutputs.d.ts +219 -0
  216. package/dist/tools/schemas/outputs/utilityOutputs.js +120 -0
  217. package/dist/tools/schemas/shared/commonOutputs.d.ts +24 -0
  218. package/dist/tools/schemas/shared/commonOutputs.js +27 -0
  219. package/dist/tools/toolCategories.d.ts +32 -0
  220. package/dist/tools/toolCategories.js +32 -0
  221. package/dist/tools/transactionTools.d.ts +315 -0
  222. package/dist/tools/transactionTools.js +1722 -0
  223. package/dist/tools/utilityTools.d.ts +10 -0
  224. package/dist/tools/utilityTools.js +56 -0
  225. package/dist/types/index.d.ts +20 -0
  226. package/dist/types/index.js +16 -0
  227. package/dist/types/toolAnnotations.d.ts +7 -0
  228. package/dist/types/toolAnnotations.js +1 -0
  229. package/dist/utils/amountUtils.d.ts +3 -0
  230. package/dist/utils/amountUtils.js +10 -0
  231. package/dist/utils/dateUtils.d.ts +9 -0
  232. package/dist/utils/dateUtils.js +43 -0
  233. package/dist/utils/money.d.ts +21 -0
  234. package/dist/utils/money.js +51 -0
  235. package/docs/README.md +72 -0
  236. package/docs/assets/examples/reconciliation-with-recommendations.json +68 -0
  237. package/docs/assets/schemas/reconciliation-v2.json +338 -0
  238. package/docs/getting-started/CONFIGURATION.md +175 -0
  239. package/docs/getting-started/INSTALLATION.md +333 -0
  240. package/docs/getting-started/QUICKSTART.md +282 -0
  241. package/docs/guides/ARCHITECTURE.md +650 -0
  242. package/docs/guides/DEPLOYMENT.md +189 -0
  243. package/docs/guides/INTEGRATION_TESTING.md +730 -0
  244. package/docs/guides/TESTING.md +591 -0
  245. package/docs/reconciliation-flow.md +83 -0
  246. package/docs/reference/API.md +1450 -0
  247. package/docs/reference/EXAMPLES.md +946 -0
  248. package/docs/reference/TOOLS.md +348 -0
  249. package/docs/reference/TROUBLESHOOTING.md +481 -0
  250. package/esbuild.config.mjs +68 -0
  251. package/eslint.config.js +49 -0
  252. package/fix-types.sh +17 -0
  253. package/meta.json +12550 -0
  254. package/package.json +105 -0
  255. package/package.json.tmp +105 -0
  256. package/scripts/analyze-bundle.mjs +41 -0
  257. package/scripts/create-pr-description.js +203 -0
  258. package/scripts/generate-mcpb.ps1 +96 -0
  259. package/scripts/run-domain-integration-tests.js +33 -0
  260. package/scripts/run-generate-mcpb.js +29 -0
  261. package/scripts/run-throttled-integration-tests.js +116 -0
  262. package/scripts/test-delta-params.mjs +140 -0
  263. package/scripts/test-recommendations.ts +53 -0
  264. package/scripts/tmpTransaction.ts +48 -0
  265. package/scripts/validate-env.js +122 -0
  266. package/scripts/verify-build.js +105 -0
  267. package/scripts/watch-and-restart.ps1 +50 -0
  268. package/src/__tests__/comprehensive.integration.test.ts +1196 -0
  269. package/src/__tests__/delta.performance.test.ts +80 -0
  270. package/src/__tests__/performance.test.ts +725 -0
  271. package/src/__tests__/setup.ts +449 -0
  272. package/src/__tests__/testRunner.ts +444 -0
  273. package/src/__tests__/testUtils.ts +563 -0
  274. package/src/__tests__/workflows.e2e.test.ts +1675 -0
  275. package/src/index.ts +124 -0
  276. package/src/server/.gitkeep +1 -0
  277. package/src/server/YNABMCPServer.ts +1188 -0
  278. package/src/server/__tests__/YNABMCPServer.integration.test.ts +903 -0
  279. package/src/server/__tests__/YNABMCPServer.test.ts +894 -0
  280. package/src/server/__tests__/budgetResolver.test.ts +425 -0
  281. package/src/server/__tests__/cacheManager.test.ts +880 -0
  282. package/src/server/__tests__/config.test.ts +166 -0
  283. package/src/server/__tests__/deltaCache.merge.test.ts +724 -0
  284. package/src/server/__tests__/deltaCache.swr.test.ts +168 -0
  285. package/src/server/__tests__/deltaCache.test.ts +774 -0
  286. package/src/server/__tests__/diagnostics.test.ts +823 -0
  287. package/src/server/__tests__/errorHandler.integration.test.ts +466 -0
  288. package/src/server/__tests__/errorHandler.test.ts +416 -0
  289. package/src/server/__tests__/prompts.test.ts +354 -0
  290. package/src/server/__tests__/rateLimiter.test.ts +314 -0
  291. package/src/server/__tests__/requestLogger.test.ts +408 -0
  292. package/src/server/__tests__/resources.test.ts +299 -0
  293. package/src/server/__tests__/security.integration.test.ts +426 -0
  294. package/src/server/__tests__/securityMiddleware.test.ts +449 -0
  295. package/src/server/__tests__/server-startup.integration.test.ts +477 -0
  296. package/src/server/__tests__/serverKnowledgeStore.test.ts +174 -0
  297. package/src/server/__tests__/toolRegistry.test.ts +855 -0
  298. package/src/server/budgetResolver.ts +235 -0
  299. package/src/server/cacheManager.ts +503 -0
  300. package/src/server/config.ts +41 -0
  301. package/src/server/deltaCache.merge.ts +149 -0
  302. package/src/server/deltaCache.ts +341 -0
  303. package/src/server/diagnostics.ts +338 -0
  304. package/src/server/errorHandler.ts +756 -0
  305. package/src/server/prompts.ts +291 -0
  306. package/src/server/rateLimiter.ts +156 -0
  307. package/src/server/requestLogger.ts +344 -0
  308. package/src/server/resources.ts +168 -0
  309. package/src/server/responseFormatter.ts +51 -0
  310. package/src/server/securityMiddleware.ts +236 -0
  311. package/src/server/serverKnowledgeStore.ts +91 -0
  312. package/src/server/toolRegistry.ts +489 -0
  313. package/src/tools/.gitkeep +1 -0
  314. package/src/tools/__tests__/accountTools.delta.integration.test.ts +128 -0
  315. package/src/tools/__tests__/accountTools.integration.test.ts +117 -0
  316. package/src/tools/__tests__/accountTools.test.ts +653 -0
  317. package/src/tools/__tests__/budgetTools.delta.integration.test.ts +90 -0
  318. package/src/tools/__tests__/budgetTools.integration.test.ts +134 -0
  319. package/src/tools/__tests__/budgetTools.test.ts +423 -0
  320. package/src/tools/__tests__/categoryTools.delta.integration.test.ts +80 -0
  321. package/src/tools/__tests__/categoryTools.integration.test.ts +295 -0
  322. package/src/tools/__tests__/categoryTools.test.ts +622 -0
  323. package/src/tools/__tests__/compareTransactions/formatter.test.ts +486 -0
  324. package/src/tools/__tests__/compareTransactions/index.test.ts +383 -0
  325. package/src/tools/__tests__/compareTransactions/matcher.test.ts +410 -0
  326. package/src/tools/__tests__/compareTransactions/parser.test.ts +764 -0
  327. package/src/tools/__tests__/compareTransactions.test.ts +342 -0
  328. package/src/tools/__tests__/compareTransactions.window.test.ts +147 -0
  329. package/src/tools/__tests__/deltaFetcher.scheduled.integration.test.ts +76 -0
  330. package/src/tools/__tests__/deltaFetcher.test.ts +270 -0
  331. package/src/tools/__tests__/deltaSupport.test.ts +188 -0
  332. package/src/tools/__tests__/deltaTestUtils.ts +46 -0
  333. package/src/tools/__tests__/exportTransactions.test.ts +213 -0
  334. package/src/tools/__tests__/monthTools.delta.integration.test.ts +80 -0
  335. package/src/tools/__tests__/monthTools.integration.test.ts +174 -0
  336. package/src/tools/__tests__/monthTools.test.ts +523 -0
  337. package/src/tools/__tests__/payeeTools.delta.integration.test.ts +80 -0
  338. package/src/tools/__tests__/payeeTools.integration.test.ts +150 -0
  339. package/src/tools/__tests__/payeeTools.test.ts +445 -0
  340. package/src/tools/__tests__/transactionTools.integration.test.ts +762 -0
  341. package/src/tools/__tests__/transactionTools.test.ts +3521 -0
  342. package/src/tools/__tests__/utilityTools.integration.test.ts +128 -0
  343. package/src/tools/__tests__/utilityTools.test.ts +205 -0
  344. package/src/tools/accountTools.ts +283 -0
  345. package/src/tools/budgetTools.ts +112 -0
  346. package/src/tools/categoryTools.ts +366 -0
  347. package/src/tools/compareTransactions/formatter.ts +163 -0
  348. package/src/tools/compareTransactions/index.ts +228 -0
  349. package/src/tools/compareTransactions/matcher.ts +240 -0
  350. package/src/tools/compareTransactions/parser.ts +557 -0
  351. package/src/tools/compareTransactions/types.ts +60 -0
  352. package/src/tools/compareTransactions.ts +3 -0
  353. package/src/tools/deltaFetcher.ts +278 -0
  354. package/src/tools/deltaSupport.ts +293 -0
  355. package/src/tools/exportTransactions.ts +273 -0
  356. package/src/tools/monthTools.ts +164 -0
  357. package/src/tools/payeeTools.ts +140 -0
  358. package/src/tools/reconcileAdapter.ts +312 -0
  359. package/src/tools/reconciliation/__tests__/adapter.causes.test.ts +122 -0
  360. package/src/tools/reconciliation/__tests__/adapter.test.ts +234 -0
  361. package/src/tools/reconciliation/__tests__/analyzer.test.ts +406 -0
  362. package/src/tools/reconciliation/__tests__/executor.integration.test.ts +366 -0
  363. package/src/tools/reconciliation/__tests__/executor.test.ts +779 -0
  364. package/src/tools/reconciliation/__tests__/matcher.test.ts +650 -0
  365. package/src/tools/reconciliation/__tests__/payeeNormalizer.test.ts +278 -0
  366. package/src/tools/reconciliation/__tests__/recommendationEngine.integration.test.ts +658 -0
  367. package/src/tools/reconciliation/__tests__/recommendationEngine.test.ts +1000 -0
  368. package/src/tools/reconciliation/__tests__/reconciliation.delta.integration.test.ts +151 -0
  369. package/src/tools/reconciliation/__tests__/reportFormatter.test.ts +573 -0
  370. package/src/tools/reconciliation/__tests__/scenarios/adapterCurrency.scenario.test.ts +78 -0
  371. package/src/tools/reconciliation/__tests__/scenarios/extremes.scenario.test.ts +47 -0
  372. package/src/tools/reconciliation/__tests__/scenarios/repeatAmount.scenario.test.ts +61 -0
  373. package/src/tools/reconciliation/__tests__/schemaUrl.test.ts +49 -0
  374. package/src/tools/reconciliation/analyzer.ts +824 -0
  375. package/src/tools/reconciliation/executor.ts +880 -0
  376. package/src/tools/reconciliation/index.ts +400 -0
  377. package/src/tools/reconciliation/matcher.ts +269 -0
  378. package/src/tools/reconciliation/payeeNormalizer.ts +167 -0
  379. package/src/tools/reconciliation/recommendationEngine.ts +506 -0
  380. package/src/tools/reconciliation/reportFormatter.ts +363 -0
  381. package/src/tools/reconciliation/types.ts +314 -0
  382. package/src/tools/schemas/outputs/__tests__/accountOutputs.test.ts +424 -0
  383. package/src/tools/schemas/outputs/__tests__/budgetOutputs.test.ts +310 -0
  384. package/src/tools/schemas/outputs/__tests__/categoryOutputs.test.ts +448 -0
  385. package/src/tools/schemas/outputs/__tests__/comparisonOutputs.test.ts +519 -0
  386. package/src/tools/schemas/outputs/__tests__/dateValidation.test.ts +155 -0
  387. package/src/tools/schemas/outputs/__tests__/discrepancyDirection.test.ts +288 -0
  388. package/src/tools/schemas/outputs/__tests__/monthOutputs.test.ts +478 -0
  389. package/src/tools/schemas/outputs/__tests__/payeeOutputs.test.ts +370 -0
  390. package/src/tools/schemas/outputs/__tests__/reconciliationOutputs.test.ts +401 -0
  391. package/src/tools/schemas/outputs/__tests__/transactionMutationSchemas.test.ts +213 -0
  392. package/src/tools/schemas/outputs/__tests__/transactionOutputs.test.ts +474 -0
  393. package/src/tools/schemas/outputs/__tests__/utilityOutputs.test.ts +333 -0
  394. package/src/tools/schemas/outputs/accountOutputs.ts +137 -0
  395. package/src/tools/schemas/outputs/budgetOutputs.ts +86 -0
  396. package/src/tools/schemas/outputs/categoryOutputs.ts +194 -0
  397. package/src/tools/schemas/outputs/comparisonOutputs.ts +600 -0
  398. package/src/tools/schemas/outputs/index.ts +270 -0
  399. package/src/tools/schemas/outputs/monthOutputs.ts +243 -0
  400. package/src/tools/schemas/outputs/payeeOutputs.ts +105 -0
  401. package/src/tools/schemas/outputs/reconciliationOutputs.ts +796 -0
  402. package/src/tools/schemas/outputs/transactionMutationOutputs.ts +758 -0
  403. package/src/tools/schemas/outputs/transactionOutputs.ts +243 -0
  404. package/src/tools/schemas/outputs/utilityOutputs.ts +411 -0
  405. package/src/tools/schemas/shared/commonOutputs.ts +140 -0
  406. package/src/tools/toolCategories.ts +140 -0
  407. package/src/tools/transactionTools.ts +2509 -0
  408. package/src/tools/utilityTools.ts +90 -0
  409. package/src/types/.gitkeep +1 -0
  410. package/src/types/__tests__/index.test.ts +52 -0
  411. package/src/types/index.ts +67 -0
  412. package/src/types/integration-tests.d.ts +35 -0
  413. package/src/types/toolAnnotations.ts +44 -0
  414. package/src/utils/__tests__/dateUtils.test.ts +170 -0
  415. package/src/utils/__tests__/money.test.ts +189 -0
  416. package/src/utils/amountUtils.ts +32 -0
  417. package/src/utils/dateUtils.ts +108 -0
  418. package/src/utils/money.ts +123 -0
  419. package/test-csv-sample.csv +28 -0
  420. package/test-exports/sample_bank_statement.csv +7 -0
  421. package/test-exports/ynab_account_e9ddc2a6_minimal_1items_2025-11-19_09-04-53.json +23 -0
  422. package/test-exports/ynab_account_e9ddc2a6_minimal_1items_2025-11-19_10-37-42.json +23 -0
  423. package/test-exports/ynab_account_e9ddc2a6_minimal_4items_2025-11-19_09-02-09.json +44 -0
  424. package/test-exports/ynab_account_e9ddc2a6_minimal_6items_2025-11-19_10-37-52.json +58 -0
  425. package/test-exports/ynab_since_2025-11-01_account_4c18e9f0_minimal_14items_2025-11-16_10-07-10.json +115 -0
  426. package/test-reconcile-autodetect.js +40 -0
  427. package/test-reconcile-tool.js +152 -0
  428. package/test-reconcile-with-csv.cjs +89 -0
  429. package/test-statement.csv +8 -0
  430. package/test_debug.js +47 -0
  431. package/test_simple.mjs +16 -0
  432. package/tsconfig.json +31 -0
  433. package/tsconfig.prod.json +18 -0
  434. package/vitest-reporters/split-json-reporter.ts +211 -0
  435. package/vitest.config.ts +96 -0
@@ -0,0 +1,880 @@
1
+ /**
2
+ * Unit tests for enhanced CacheManager
3
+ * Tests all new functionality including observability, LRU eviction, and concurrent deduplication
4
+ */
5
+
6
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
7
+ import { CacheManager } from '../cacheManager.js';
8
+
9
+ describe('CacheManager', () => {
10
+ let cache: CacheManager;
11
+
12
+ beforeEach(() => {
13
+ vi.clearAllMocks();
14
+ vi.useFakeTimers({ now: 0 }); // Start fake timers at timestamp 0
15
+ // Clear environment variables
16
+ delete process.env.YNAB_MCP_CACHE_MAX_ENTRIES;
17
+ delete process.env.YNAB_MCP_CACHE_STALE_MS;
18
+ delete process.env.YNAB_MCP_CACHE_DEFAULT_TTL_MS;
19
+ cache = new CacheManager();
20
+ });
21
+
22
+ afterEach(() => {
23
+ vi.useRealTimers();
24
+ });
25
+
26
+ describe('Basic Functionality', () => {
27
+ it('should store and retrieve data', () => {
28
+ cache.set('key1', 'value1');
29
+ expect(cache.get('key1')).toBe('value1');
30
+ });
31
+
32
+ it('should return null for non-existent keys', () => {
33
+ expect(cache.get('nonexistent')).toBeNull();
34
+ });
35
+
36
+ it('should delete entries', () => {
37
+ cache.set('key1', 'value1');
38
+ expect(cache.delete('key1')).toBe(true);
39
+ expect(cache.get('key1')).toBeNull();
40
+ expect(cache.delete('nonexistent')).toBe(false);
41
+ });
42
+
43
+ it('should clear all entries', () => {
44
+ cache.set('key1', 'value1');
45
+ cache.set('key2', 'value2');
46
+ cache.clear();
47
+ expect(cache.get('key1')).toBeNull();
48
+ expect(cache.get('key2')).toBeNull();
49
+ });
50
+
51
+ it('should handle TTL expiration', () => {
52
+ cache.set('key1', 'value1', 1000); // 1 second TTL
53
+ expect(cache.get('key1')).toBe('value1');
54
+
55
+ vi.advanceTimersByTime(1100);
56
+ expect(cache.get('key1')).toBeNull();
57
+ });
58
+
59
+ it('should generate consistent cache keys', () => {
60
+ const key1 = CacheManager.generateKey('prefix', 'param1', 2, true);
61
+ const key2 = CacheManager.generateKey('prefix', 'param1', 2, true);
62
+ expect(key1).toBe(key2);
63
+ expect(key1).toBe('prefix:param1:2:true');
64
+ });
65
+
66
+ it('should filter undefined parameters in key generation', () => {
67
+ const key = CacheManager.generateKey('prefix', 'param1', undefined, 'param3');
68
+ expect(key).toBe('prefix:param1:param3');
69
+ });
70
+ });
71
+
72
+ describe('Hit/Miss Counters', () => {
73
+ it('should track cache hits', () => {
74
+ cache.set('key1', 'value1');
75
+ cache.get('key1');
76
+ cache.get('key1');
77
+
78
+ const stats = cache.getStats();
79
+ expect(stats.hits).toBe(2);
80
+ expect(stats.misses).toBe(0);
81
+ expect(stats.hitRate).toBe(1);
82
+ });
83
+
84
+ it('should track cache misses', () => {
85
+ cache.get('nonexistent1');
86
+ cache.get('nonexistent2');
87
+
88
+ const stats = cache.getStats();
89
+ expect(stats.hits).toBe(0);
90
+ expect(stats.misses).toBe(2);
91
+ expect(stats.hitRate).toBe(0);
92
+ });
93
+
94
+ it('should track expired entries as misses', () => {
95
+ cache.set('key1', 'value1', 1000);
96
+ vi.advanceTimersByTime(1100);
97
+ cache.get('key1');
98
+
99
+ const stats = cache.getStats();
100
+ expect(stats.hits).toBe(0);
101
+ expect(stats.misses).toBe(1);
102
+ });
103
+
104
+ it('should calculate hit rate correctly', () => {
105
+ cache.set('key1', 'value1');
106
+ cache.get('key1'); // hit
107
+ cache.get('key1'); // hit
108
+ cache.get('nonexistent'); // miss
109
+
110
+ const stats = cache.getStats();
111
+ expect(stats.hits).toBe(2);
112
+ expect(stats.misses).toBe(1);
113
+ expect(stats.hitRate).toBeCloseTo(2 / 3);
114
+ });
115
+
116
+ it('should reset counters on clear', () => {
117
+ cache.set('key1', 'value1');
118
+ cache.get('key1');
119
+ cache.get('nonexistent');
120
+
121
+ cache.clear();
122
+ const stats = cache.getStats();
123
+ expect(stats.hits).toBe(0);
124
+ expect(stats.misses).toBe(0);
125
+ expect(stats.hitRate).toBe(0);
126
+ });
127
+
128
+ it('should handle zero requests for hit rate', () => {
129
+ const stats = cache.getStats();
130
+ expect(stats.hitRate).toBe(0);
131
+ });
132
+ });
133
+
134
+ describe('LRU Eviction', () => {
135
+ beforeEach(() => {
136
+ process.env.YNAB_MCP_CACHE_MAX_ENTRIES = '3';
137
+ cache = new CacheManager();
138
+ });
139
+
140
+ it('should not evict when under limit', () => {
141
+ cache.set('key1', 'value1');
142
+ cache.set('key2', 'value2');
143
+
144
+ const stats = cache.getStats();
145
+ expect(stats.size).toBe(2);
146
+ expect(stats.evictions).toBe(0);
147
+ expect(cache.get('key1')).toBe('value1');
148
+ expect(cache.get('key2')).toBe('value2');
149
+ });
150
+
151
+ it('should evict LRU entry when maxEntries is exceeded', () => {
152
+ cache.set('key1', 'value1');
153
+ cache.set('key2', 'value2');
154
+ cache.set('key3', 'value3');
155
+ cache.set('key4', 'value4'); // Should evict key1
156
+
157
+ const stats = cache.getStats();
158
+ expect(stats.size).toBe(3);
159
+ expect(stats.evictions).toBe(1);
160
+ expect(cache.get('key1')).toBeNull(); // Evicted
161
+ expect(cache.get('key2')).toBe('value2');
162
+ expect(cache.get('key3')).toBe('value3');
163
+ expect(cache.get('key4')).toBe('value4');
164
+ });
165
+
166
+ it('should update access order on get', () => {
167
+ cache.set('key1', 'value1');
168
+ cache.set('key2', 'value2');
169
+ cache.set('key3', 'value3');
170
+
171
+ // Access key1 to make it most recently used
172
+ cache.get('key1');
173
+
174
+ cache.set('key4', 'value4'); // Should evict key2 (oldest)
175
+
176
+ expect(cache.get('key1')).toBe('value1'); // Still there
177
+ expect(cache.get('key2')).toBeNull(); // Evicted
178
+ expect(cache.get('key3')).toBe('value3');
179
+ expect(cache.get('key4')).toBe('value4');
180
+ });
181
+
182
+ it('should handle zero maxEntries (no caching)', () => {
183
+ process.env.YNAB_MCP_CACHE_MAX_ENTRIES = '0';
184
+ cache = new CacheManager();
185
+
186
+ cache.set('key1', 'value1');
187
+ expect(cache.get('key1')).toBeNull();
188
+ expect(cache.getStats().size).toBe(0);
189
+ });
190
+
191
+ it('should evict multiple entries if needed', () => {
192
+ // Fill cache
193
+ cache.set('key1', 'value1');
194
+ cache.set('key2', 'value2');
195
+ cache.set('key3', 'value3');
196
+
197
+ // Change maxEntries to 1 by creating a new cache manager
198
+ process.env.YNAB_MCP_CACHE_MAX_ENTRIES = '1';
199
+ const smallCache = new CacheManager();
200
+
201
+ // Add entries that should trigger multiple evictions
202
+ smallCache.set('key1', 'value1');
203
+ smallCache.set('key2', 'value2');
204
+
205
+ expect(smallCache.getStats().size).toBe(1);
206
+ expect(smallCache.getStats().evictions).toBe(1);
207
+ expect(smallCache.get('key2')).toBe('value2'); // Most recent
208
+ });
209
+
210
+ it('should not evict when updating existing key at maxEntries limit', () => {
211
+ // Fill cache to capacity
212
+ cache.set('key1', 'value1');
213
+ cache.set('key2', 'value2');
214
+ cache.set('key3', 'value3');
215
+
216
+ const initialStats = cache.getStats();
217
+ expect(initialStats.size).toBe(3);
218
+ expect(initialStats.evictions).toBe(0);
219
+
220
+ // Update an existing key - should not trigger eviction
221
+ cache.set('key2', 'updated-value2');
222
+
223
+ const updatedStats = cache.getStats();
224
+ expect(updatedStats.size).toBe(3); // Same size
225
+ expect(updatedStats.evictions).toBe(0); // No evictions
226
+ expect(cache.get('key1')).toBe('value1'); // Other keys still present
227
+ expect(cache.get('key2')).toBe('updated-value2'); // Updated value
228
+ expect(cache.get('key3')).toBe('value3'); // Other keys still present
229
+ });
230
+ });
231
+
232
+ describe('Per-Entry Options', () => {
233
+ it('should use custom TTL from options', () => {
234
+ cache.set('key1', 'value1', { ttl: 500, staleWhileRevalidate: 0 });
235
+ cache.set('key2', 'value2', { ttl: 1500, staleWhileRevalidate: 0 });
236
+
237
+ vi.advanceTimersByTime(1000);
238
+ expect(cache.get('key1')).toBeNull(); // Expired
239
+ expect(cache.get('key2')).toBe('value2'); // Still valid
240
+ });
241
+
242
+ it('should use default TTL when no options provided', () => {
243
+ cache.set('key1', 'value1');
244
+
245
+ // Advance to just before expiration (5 minutes is default TTL)
246
+ vi.advanceTimersByTime(299000); // Just under 5 minutes - should still be valid
247
+ expect(cache.get('key1')).toBe('value1');
248
+
249
+ // Advance past the TTL (using simple set should have NO stale window)
250
+ vi.advanceTimersByTime(2000); // Total ~5 minutes - should be expired
251
+ expect(cache.get('key1')).toBeNull();
252
+ });
253
+
254
+ it('should support staleWhileRevalidate', () => {
255
+ cache.set('key1', 'value1', { ttl: 1000, staleWhileRevalidate: 2000 });
256
+
257
+ vi.advanceTimersByTime(1500); // Within stale window
258
+ const result = cache.get('key1');
259
+
260
+ expect(result).toBe('value1'); // Should return stale data
261
+ const stats = cache.getStats();
262
+ expect(stats.hits).toBe(1); // Counted as hit
263
+ });
264
+
265
+ it('should not return data outside stale window', () => {
266
+ cache.set('key1', 'value1', { ttl: 1000, staleWhileRevalidate: 2000 });
267
+
268
+ vi.advanceTimersByTime(3500); // Outside stale window
269
+ const result = cache.get('key1');
270
+
271
+ expect(result).toBeNull();
272
+ const stats = cache.getStats();
273
+ expect(stats.misses).toBe(1);
274
+ });
275
+
276
+ it('should maintain backward compatibility with number TTL', () => {
277
+ cache.set('key1', 'value1', 2000);
278
+ vi.advanceTimersByTime(1000);
279
+ expect(cache.get('key1')).toBe('value1');
280
+
281
+ vi.advanceTimersByTime(1500);
282
+ expect(cache.get('key1')).toBeNull();
283
+ });
284
+ });
285
+
286
+ describe('wrap() Helper', () => {
287
+ it('should return cached data immediately on hit', async () => {
288
+ const loader = vi.fn().mockResolvedValue('loaded-value');
289
+ cache.set('key1', 'cached-value');
290
+
291
+ const result = await cache.wrap('key1', { loader });
292
+
293
+ expect(result).toBe('cached-value');
294
+ expect(loader).not.toHaveBeenCalled();
295
+ });
296
+
297
+ it('should call loader and cache result on miss', async () => {
298
+ const loader = vi.fn().mockResolvedValue('loaded-value');
299
+
300
+ const result = await cache.wrap('key1', { loader });
301
+
302
+ expect(result).toBe('loaded-value');
303
+ expect(loader).toHaveBeenCalledTimes(1);
304
+ expect(cache.get('key1')).toBe('loaded-value');
305
+ });
306
+
307
+ it('should deduplicate concurrent requests', async () => {
308
+ const loader = vi
309
+ .fn()
310
+ .mockImplementation(
311
+ () => new Promise((resolve) => setTimeout(() => resolve('loaded-value'), 100)),
312
+ );
313
+
314
+ // Start two concurrent requests
315
+ const promise1 = cache.wrap('key1', { loader });
316
+ const promise2 = cache.wrap('key1', { loader });
317
+
318
+ // Advance time to resolve promises
319
+ vi.advanceTimersByTime(100);
320
+
321
+ const [result1, result2] = await Promise.all([promise1, promise2]);
322
+
323
+ expect(result1).toBe('loaded-value');
324
+ expect(result2).toBe('loaded-value');
325
+ expect(loader).toHaveBeenCalledTimes(1); // Only called once
326
+ });
327
+
328
+ it('should handle loader errors gracefully', async () => {
329
+ const loader = vi.fn().mockRejectedValue(new Error('Load failed'));
330
+
331
+ await expect(cache.wrap('key1', { loader })).rejects.toThrow('Load failed');
332
+ expect(cache.get('key1')).toBeNull(); // Should not cache error
333
+ });
334
+
335
+ it('should serve stale data and trigger background refresh', async () => {
336
+ const loader1 = vi.fn().mockResolvedValue('initial-value');
337
+ const loader2 = vi.fn().mockResolvedValue('refreshed-value');
338
+
339
+ // Initial load
340
+ await cache.wrap('key1', { loader: loader1, ttl: 1000, staleWhileRevalidate: 2000 });
341
+
342
+ // Move to stale period
343
+ vi.advanceTimersByTime(1500);
344
+
345
+ // Second call should return stale data immediately and refresh in background
346
+ const result = await cache.wrap('key1', { loader: loader2 });
347
+ expect(result).toBe('initial-value'); // Stale data returned
348
+
349
+ // Advance time to allow background refresh
350
+ vi.advanceTimersByTime(100);
351
+ await vi.runAllTimersAsync();
352
+
353
+ expect(loader2).toHaveBeenCalledTimes(1);
354
+ });
355
+
356
+ it('should apply cache options from wrap call', async () => {
357
+ const loader = vi.fn().mockResolvedValue('loaded-value');
358
+
359
+ await cache.wrap('key1', {
360
+ loader,
361
+ ttl: 500,
362
+ staleWhileRevalidate: 1000,
363
+ });
364
+
365
+ // Verify custom TTL
366
+ vi.advanceTimersByTime(400);
367
+ expect(cache.get('key1')).toBe('loaded-value');
368
+
369
+ vi.advanceTimersByTime(200); // Total 600ms, past TTL but within stale window
370
+ const staleResult = cache.get('key1');
371
+ expect(staleResult).toBe('loaded-value'); // Should return stale data
372
+ });
373
+
374
+ it('should clean up pending operations on completion', async () => {
375
+ const loader = vi.fn().mockResolvedValue('loaded-value');
376
+
377
+ await cache.wrap('key1', { loader });
378
+
379
+ // Start another request after first completes
380
+ const loader2 = vi.fn().mockResolvedValue('loaded-value-2');
381
+ await cache.wrap('key1', { loader: loader2 });
382
+
383
+ // Should use cached value, not call loader2
384
+ expect(loader2).not.toHaveBeenCalled();
385
+ });
386
+
387
+ it('should preserve existing TTL/SWR when options omitted in background refresh', async () => {
388
+ const loader1 = vi.fn().mockResolvedValue('initial-value');
389
+ const loader2 = vi.fn().mockResolvedValue('refreshed-value');
390
+
391
+ // Initial load with specific TTL/SWR
392
+ await cache.wrap('key1', {
393
+ loader: loader1,
394
+ ttl: 2000,
395
+ staleWhileRevalidate: 3000,
396
+ });
397
+
398
+ // Move to stale period
399
+ vi.advanceTimersByTime(2500);
400
+
401
+ // Background refresh with no TTL/SWR specified - should preserve original values
402
+ await cache.wrap('key1', { loader: loader2 });
403
+
404
+ // Advance time to allow background refresh
405
+ vi.advanceTimersByTime(100);
406
+ await vi.runAllTimersAsync();
407
+
408
+ // Now check if the refreshed entry still has the original TTL
409
+ vi.advanceTimersByTime(1800); // Should still be within original TTL (2000ms)
410
+ const result = cache.get('key1');
411
+ expect(result).toBe('refreshed-value');
412
+
413
+ // Move past original TTL but within stale window
414
+ vi.advanceTimersByTime(300); // Total 2300ms past refresh, should be in stale window
415
+ const staleResult = cache.get('key1');
416
+ expect(staleResult).toBe('refreshed-value'); // Should still be available due to preserved SWR
417
+
418
+ // Move beyond original TTL + stale window (5000ms) from the initial load to ensure expiry
419
+ vi.advanceTimersByTime(3000); // Total elapsed time ~7700ms from first load
420
+ await vi.runAllTimersAsync();
421
+ expect(cache.get('key1')).toBeNull(); // Entry should be expired after preserved TTL/SWR window
422
+ });
423
+ });
424
+
425
+ describe('Cleanup Enhancement', () => {
426
+ it('should update lastCleanup timestamp', () => {
427
+ const startTime = Date.now();
428
+ vi.advanceTimersByTime(1000);
429
+
430
+ cache.set('key1', 'value1', 500);
431
+ vi.advanceTimersByTime(600);
432
+ cache.cleanup();
433
+
434
+ const stats = cache.getStats();
435
+ expect(stats.lastCleanup).toBeGreaterThan(startTime);
436
+ });
437
+
438
+ it('should include cleanup removals in eviction count', () => {
439
+ cache.set('key1', 'value1', 500);
440
+ cache.set('key2', 'value2', 1000);
441
+
442
+ vi.advanceTimersByTime(600);
443
+ const cleaned = cache.cleanup();
444
+
445
+ expect(cleaned).toBe(1);
446
+ const stats = cache.getStats();
447
+ expect(stats.evictions).toBe(1);
448
+ expect(cache.get('key1')).toBeNull();
449
+ expect(cache.get('key2')).toBe('value2');
450
+ });
451
+
452
+ it('should return zero when no cleanup needed', () => {
453
+ cache.set('key1', 'value1', 5000);
454
+ const cleaned = cache.cleanup();
455
+
456
+ expect(cleaned).toBe(0);
457
+ const stats = cache.getStats();
458
+ expect(stats.evictions).toBe(0);
459
+ });
460
+
461
+ it('should provide detailed cleanup information', () => {
462
+ cache.set('key1', 'value1', 500);
463
+ cache.set('key2', 'value2', 1000);
464
+ cache.set('key3', 'value3', 5000);
465
+
466
+ vi.advanceTimersByTime(600);
467
+ const result = cache.cleanupDetailed();
468
+
469
+ expect(result.cleaned).toBe(1);
470
+ expect(result.evictions).toBe(1);
471
+ expect(cache.get('key1')).toBeNull();
472
+ expect(cache.get('key2')).toBe('value2');
473
+ expect(cache.get('key3')).toBe('value3');
474
+ });
475
+
476
+ it('should maintain backward compatibility with cleanup() method', () => {
477
+ cache.set('key1', 'value1', 500);
478
+ cache.set('key2', 'value2', 1000);
479
+
480
+ vi.advanceTimersByTime(600);
481
+ const cleaned = cache.cleanup();
482
+
483
+ expect(cleaned).toBe(1); // Should still return number of cleaned entries
484
+ const stats = cache.getStats();
485
+ expect(stats.evictions).toBe(1);
486
+ });
487
+ });
488
+
489
+ describe('Environment Variable Configuration', () => {
490
+ it('should use environment variable for maxEntries', () => {
491
+ process.env.YNAB_MCP_CACHE_MAX_ENTRIES = '5';
492
+ const configuredCache = new CacheManager();
493
+
494
+ const stats = configuredCache.getStats();
495
+ expect(stats.maxEntries).toBe(5);
496
+ });
497
+
498
+ it('should use environment variable for stale window', () => {
499
+ process.env.YNAB_MCP_CACHE_STALE_MS = '30000';
500
+ const configuredCache = new CacheManager();
501
+
502
+ configuredCache.set('key1', 'value1', { ttl: 1000, staleWhileRevalidate: undefined });
503
+
504
+ // The default stale window should be used from env var
505
+ vi.advanceTimersByTime(15000); // Within default stale window from env
506
+ expect(configuredCache.get('key1')).toBe('value1'); // Served as stale data
507
+
508
+ vi.advanceTimersByTime(16100); // Beyond stale window now (total 31100ms > 31000ms)
509
+ expect(configuredCache.get('key1')).toBeNull();
510
+ });
511
+
512
+ it('should fall back to defaults for invalid environment values', () => {
513
+ process.env.YNAB_MCP_CACHE_MAX_ENTRIES = 'invalid';
514
+ process.env.YNAB_MCP_CACHE_STALE_MS = 'not-a-number';
515
+ process.env.YNAB_MCP_CACHE_DEFAULT_TTL_MS = 'invalid-ttl';
516
+
517
+ // Reset timers for new cache instance
518
+ vi.useRealTimers();
519
+ vi.useFakeTimers({ now: 0 });
520
+
521
+ const configuredCache = new CacheManager();
522
+ const stats = configuredCache.getStats();
523
+
524
+ expect(stats.maxEntries).toBe(1000); // Default value
525
+
526
+ // Test that invalid default TTL falls back to 300000ms (5 minutes)
527
+ configuredCache.set('key1', 'value1');
528
+ vi.advanceTimersByTime(299000); // Just under 5 minutes - should be valid
529
+ expect(configuredCache.get('key1')).toBe('value1');
530
+
531
+ vi.advanceTimersByTime(2000); // ~5 minutes total - should expire
532
+ expect(configuredCache.get('key1')).toBeNull();
533
+ });
534
+
535
+ it('should use environment variable for default TTL', () => {
536
+ process.env.YNAB_MCP_CACHE_DEFAULT_TTL_MS = '60000'; // 1 minute
537
+
538
+ // Reset timers for new cache instance
539
+ vi.useRealTimers();
540
+ vi.useFakeTimers({ now: 0 });
541
+
542
+ const configuredCache = new CacheManager();
543
+
544
+ configuredCache.set('key1', 'value1'); // Use default TTL
545
+ vi.advanceTimersByTime(59000); // Just under 1 minute - should be valid
546
+ expect(configuredCache.get('key1')).toBe('value1');
547
+
548
+ vi.advanceTimersByTime(2000); // ~1 minute total - should expire
549
+ expect(configuredCache.get('key1')).toBeNull();
550
+ });
551
+
552
+ it('should fall back to defaults when environment variables are missing', () => {
553
+ delete process.env.YNAB_MCP_CACHE_MAX_ENTRIES;
554
+ delete process.env.YNAB_MCP_CACHE_STALE_MS;
555
+ delete process.env.YNAB_MCP_CACHE_DEFAULT_TTL_MS;
556
+
557
+ // Reset timers for new cache instance
558
+ vi.useRealTimers();
559
+ vi.useFakeTimers({ now: 0 });
560
+
561
+ const configuredCache = new CacheManager();
562
+ const stats = configuredCache.getStats();
563
+
564
+ expect(stats.maxEntries).toBe(1000); // Default value
565
+
566
+ // Test default TTL (300000ms = 5 minutes)
567
+ configuredCache.set('key1', 'value1');
568
+ vi.advanceTimersByTime(299000); // Just under 5 minutes - should be valid
569
+ expect(configuredCache.get('key1')).toBe('value1');
570
+
571
+ vi.advanceTimersByTime(2000); // ~5 minutes total - should expire
572
+ expect(configuredCache.get('key1')).toBeNull();
573
+ });
574
+ });
575
+
576
+ describe('Enhanced Statistics', () => {
577
+ it('should return comprehensive stats', () => {
578
+ cache.set('key1', 'value1');
579
+ cache.get('key1');
580
+ cache.get('nonexistent');
581
+
582
+ const stats = cache.getStats();
583
+
584
+ expect(stats).toEqual({
585
+ size: 1,
586
+ keys: ['key1'],
587
+ hits: 1,
588
+ misses: 1,
589
+ evictions: 0,
590
+ lastCleanup: null,
591
+ maxEntries: 1000,
592
+ hitRate: 0.5,
593
+ });
594
+ });
595
+
596
+ it('should maintain backward compatibility with basic stats', () => {
597
+ cache.set('key1', 'value1');
598
+ cache.set('key2', 'value2');
599
+
600
+ const stats = cache.getStats();
601
+
602
+ // Basic fields should always be present
603
+ expect(stats).toHaveProperty('size', 2);
604
+ expect(stats).toHaveProperty('keys');
605
+ expect(stats.keys).toEqual(['key1', 'key2']);
606
+ });
607
+
608
+ it('should handle getEntriesForSizeEstimation correctly', () => {
609
+ cache.set('key1', 'value1', 1000);
610
+ cache.set('key2', 'value2', 2000);
611
+
612
+ vi.advanceTimersByTime(1500);
613
+
614
+ const entries = cache.getEntriesForSizeEstimation();
615
+ expect(entries).toHaveLength(1); // Only non-expired entry
616
+ expect(entries[0][0]).toBe('key2');
617
+ });
618
+
619
+ it('should provide lightweight cache metadata without full entry data', () => {
620
+ cache.set('key1', 'string-value', 1000);
621
+ cache.set('key2', { prop: 'object' }, 2000);
622
+ cache.set('key3', 42, { ttl: 3000, staleWhileRevalidate: 1000 });
623
+
624
+ vi.advanceTimersByTime(1500); // key1 should be expired
625
+
626
+ const metadata = cache.getCacheMetadata();
627
+ expect(metadata).toHaveLength(3);
628
+
629
+ // Check expired entry
630
+ const key1Meta = metadata.find((m) => m.key === 'key1');
631
+ expect(key1Meta).toEqual({
632
+ key: 'key1',
633
+ timestamp: expect.any(Number),
634
+ ttl: 1000,
635
+ staleWhileRevalidate: undefined,
636
+ dataType: 'string',
637
+ isExpired: true,
638
+ });
639
+
640
+ // Check non-expired entry
641
+ const key2Meta = metadata.find((m) => m.key === 'key2');
642
+ expect(key2Meta).toEqual({
643
+ key: 'key2',
644
+ timestamp: expect.any(Number),
645
+ ttl: 2000,
646
+ staleWhileRevalidate: undefined,
647
+ dataType: 'object',
648
+ isExpired: false,
649
+ });
650
+
651
+ // Check entry with staleWhileRevalidate
652
+ const key3Meta = metadata.find((m) => m.key === 'key3');
653
+ expect(key3Meta).toEqual({
654
+ key: 'key3',
655
+ timestamp: expect.any(Number),
656
+ ttl: 3000,
657
+ staleWhileRevalidate: 1000,
658
+ dataType: 'number',
659
+ isExpired: false,
660
+ });
661
+ });
662
+ });
663
+
664
+ describe('Edge Cases and Error Handling', () => {
665
+ it('should handle circular references in cache values', () => {
666
+ const circular: any = { name: 'test' };
667
+ circular.self = circular;
668
+
669
+ expect(() => cache.set('key1', circular)).not.toThrow();
670
+ expect(cache.get('key1')).toBe(circular);
671
+ });
672
+
673
+ it('should handle very large cache sizes', () => {
674
+ process.env.YNAB_MCP_CACHE_MAX_ENTRIES = '10000';
675
+ const largeCache = new CacheManager();
676
+
677
+ // Add many entries
678
+ for (let i = 0; i < 5000; i++) {
679
+ largeCache.set(`key${i}`, `value${i}`);
680
+ }
681
+
682
+ expect(largeCache.getStats().size).toBe(5000);
683
+ expect(largeCache.get('key0')).toBe('value0');
684
+ expect(largeCache.get('key4999')).toBe('value4999');
685
+ });
686
+
687
+ it('should handle concurrent wrap calls with different keys independently', async () => {
688
+ const loader1 = vi.fn().mockResolvedValue('value1');
689
+ const loader2 = vi.fn().mockResolvedValue('value2');
690
+
691
+ const [result1, result2] = await Promise.all([
692
+ cache.wrap('key1', { loader: loader1 }),
693
+ cache.wrap('key2', { loader: loader2 }),
694
+ ]);
695
+
696
+ expect(result1).toBe('value1');
697
+ expect(result2).toBe('value2');
698
+ expect(loader1).toHaveBeenCalledTimes(1);
699
+ expect(loader2).toHaveBeenCalledTimes(1);
700
+ });
701
+
702
+ it('should clean up failed operations', async () => {
703
+ const loader = vi.fn().mockRejectedValue(new Error('Failed'));
704
+
705
+ await expect(cache.wrap('key1', { loader })).rejects.toThrow('Failed');
706
+
707
+ // Subsequent call should try again
708
+ const loader2 = vi.fn().mockResolvedValue('success');
709
+ const result = await cache.wrap('key1', { loader: loader2 });
710
+
711
+ expect(result).toBe('success');
712
+ expect(loader2).toHaveBeenCalledTimes(1);
713
+ });
714
+ });
715
+
716
+ describe('Prefix and Budget-based Deletion', () => {
717
+ describe('deleteByPrefix', () => {
718
+ it('should delete entries matching prefix and return count', () => {
719
+ cache.set('transactions:list:budget-123', 'list');
720
+ cache.set('transactions:get:budget-123', 'detail');
721
+ cache.set('accounts:list:budget-123', 'accounts');
722
+
723
+ const removed = cache.deleteByPrefix('transactions:');
724
+ expect(removed).toBe(2);
725
+ expect(cache.getKeys()).toEqual(['accounts:list:budget-123']);
726
+ });
727
+
728
+ it('should return 0 when no matches found', () => {
729
+ cache.set('accounts:list:budget-123', 'accounts');
730
+ const removed = cache.deleteByPrefix('payments:');
731
+ expect(removed).toBe(0);
732
+ expect(cache.getKeys()).toEqual(['accounts:list:budget-123']);
733
+ });
734
+
735
+ it('should handle empty prefix safely', () => {
736
+ cache.set('transactions:list:budget-123', 'list');
737
+ cache.set('accounts:list:budget-123', 'accounts');
738
+
739
+ const removed = cache.deleteByPrefix('');
740
+ expect(removed).toBe(0);
741
+ expect(cache.getKeys()).toEqual([
742
+ 'transactions:list:budget-123',
743
+ 'accounts:list:budget-123',
744
+ ]);
745
+ });
746
+
747
+ it("should not delete when prefix only partially matches a resource's namespace", () => {
748
+ cache.set('transactions:list:budget-123', 'list');
749
+ cache.set('accounts:list:budget-123', 'accounts');
750
+
751
+ const removed = cache.deleteByPrefix('trans');
752
+ expect(removed).toBe(0);
753
+ expect(cache.getKeys()).toEqual([
754
+ 'transactions:list:budget-123',
755
+ 'accounts:list:budget-123',
756
+ ]);
757
+ });
758
+
759
+ it('should not affect cache hit or miss counters', () => {
760
+ cache.set('transactions:list:budget-123', 'list');
761
+ cache.set('transactions:list:budget-456', 'list');
762
+
763
+ const before = cache.getStats();
764
+ cache.deleteByPrefix('transactions:');
765
+ const after = cache.getStats();
766
+
767
+ expect(after.hits).toBe(before.hits);
768
+ expect(after.misses).toBe(before.misses);
769
+ });
770
+ });
771
+
772
+ describe('deleteByBudgetId', () => {
773
+ it('should delete entries containing the provided budget ID', () => {
774
+ cache.set('transactions:list:budget-123', 'txn');
775
+ cache.set('accounts:list:budget-123', 'acct');
776
+ cache.set('transactions:list:budget-456', 'other');
777
+
778
+ const removed = cache.deleteByBudgetId('budget-123');
779
+ expect(removed).toBe(2);
780
+ expect(cache.getKeys()).toEqual(['transactions:list:budget-456']);
781
+ });
782
+
783
+ it('should return 0 when budget ID does not exist in cache', () => {
784
+ cache.set('transactions:list:budget-123', 'txn');
785
+
786
+ const removed = cache.deleteByBudgetId('budget-999');
787
+ expect(removed).toBe(0);
788
+ expect(cache.getKeys()).toEqual(['transactions:list:budget-123']);
789
+ });
790
+
791
+ it('should not match budget IDs that are substrings of other IDs', () => {
792
+ cache.set('transactions:list:budget-123', 'txn');
793
+ cache.set('transactions:list:budget-1234', 'txn2');
794
+
795
+ const removed = cache.deleteByBudgetId('budget-1');
796
+ expect(removed).toBe(0);
797
+ expect(cache.getKeys()).toEqual([
798
+ 'transactions:list:budget-123',
799
+ 'transactions:list:budget-1234',
800
+ ]);
801
+ });
802
+
803
+ it('should handle UUID formatted budget identifiers', () => {
804
+ const uuid = '123e4567-e89b-12d3-a456-426614174000';
805
+ cache.set(`transactions:list:${uuid}`, 'txn');
806
+ cache.set(`accounts:list:${uuid}`, 'acct');
807
+ cache.set('transactions:list:budget-456', 'other');
808
+
809
+ const removed = cache.deleteByBudgetId(uuid);
810
+ expect(removed).toBe(2);
811
+ expect(cache.getKeys()).toEqual(['transactions:list:budget-456']);
812
+ });
813
+
814
+ it('should not affect cache stats when deleting by budget ID', () => {
815
+ cache.set('transactions:list:budget-123', 'txn');
816
+ cache.set('transactions:list:budget-456', 'other');
817
+
818
+ const before = cache.getStats();
819
+ cache.deleteByBudgetId('budget-123');
820
+ const after = cache.getStats();
821
+
822
+ expect(after.hits).toBe(before.hits);
823
+ expect(after.misses).toBe(before.misses);
824
+ });
825
+ });
826
+
827
+ describe('getKeys', () => {
828
+ it('should return an empty array when cache is empty', () => {
829
+ expect(cache.getKeys()).toEqual([]);
830
+ });
831
+
832
+ it('should return all cache keys', () => {
833
+ cache.set('accounts:list:budget-123', 'acct');
834
+ cache.set('transactions:list:budget-123', 'txn');
835
+ cache.set('payees:list:budget-123', 'payees');
836
+
837
+ expect(cache.getKeys()).toEqual([
838
+ 'accounts:list:budget-123',
839
+ 'transactions:list:budget-123',
840
+ 'payees:list:budget-123',
841
+ ]);
842
+ });
843
+
844
+ it('should preserve insertion order of cache keys', () => {
845
+ cache.set('key-a', 'a');
846
+ cache.set('key-b', 'b');
847
+ cache.set('key-c', 'c');
848
+
849
+ expect(cache.getKeys()).toEqual(['key-a', 'key-b', 'key-c']);
850
+ });
851
+ });
852
+ });
853
+
854
+ describe('Integration with Existing Patterns', () => {
855
+ it('should work with existing tool usage patterns', () => {
856
+ // Simulate existing usage pattern from tools
857
+ const key = CacheManager.generateKey('budgets', 'user123');
858
+ cache.set(key, { budgets: ['budget1', 'budget2'] }, 10 * 60 * 1000);
859
+
860
+ const cached = cache.get(key);
861
+ expect(cached).toEqual({ budgets: ['budget1', 'budget2'] });
862
+
863
+ const stats = cache.getStats();
864
+ expect(stats.hits).toBe(1);
865
+ expect(stats.size).toBe(1);
866
+ });
867
+
868
+ it('should maintain singleton behavior', async () => {
869
+ // The imported singleton should work consistently
870
+ const { cacheManager } = await import('../cacheManager.js');
871
+
872
+ cacheManager.set('singleton-test', 'value');
873
+ expect(cacheManager.get('singleton-test')).toBe('value');
874
+
875
+ const stats = cacheManager.getStats();
876
+ expect(stats).toHaveProperty('hits');
877
+ expect(stats).toHaveProperty('misses');
878
+ });
879
+ });
880
+ });