@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
@@ -3,878 +3,895 @@
3
3
  * Tests all new functionality including observability, LRU eviction, and concurrent deduplication
4
4
  */
5
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
- });
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
+ process.env.YNAB_MCP_CACHE_MAX_ENTRIES = undefined;
17
+ process.env.YNAB_MCP_CACHE_STALE_MS = undefined;
18
+ process.env.YNAB_MCP_CACHE_DEFAULT_TTL_MS = undefined;
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(
68
+ "prefix",
69
+ "param1",
70
+ undefined,
71
+ "param3",
72
+ );
73
+ expect(key).toBe("prefix:param1:param3");
74
+ });
75
+ });
76
+
77
+ describe("Hit/Miss Counters", () => {
78
+ it("should track cache hits", () => {
79
+ cache.set("key1", "value1");
80
+ cache.get("key1");
81
+ cache.get("key1");
82
+
83
+ const stats = cache.getStats();
84
+ expect(stats.hits).toBe(2);
85
+ expect(stats.misses).toBe(0);
86
+ expect(stats.hitRate).toBe(1);
87
+ });
88
+
89
+ it("should track cache misses", () => {
90
+ cache.get("nonexistent1");
91
+ cache.get("nonexistent2");
92
+
93
+ const stats = cache.getStats();
94
+ expect(stats.hits).toBe(0);
95
+ expect(stats.misses).toBe(2);
96
+ expect(stats.hitRate).toBe(0);
97
+ });
98
+
99
+ it("should track expired entries as misses", () => {
100
+ cache.set("key1", "value1", 1000);
101
+ vi.advanceTimersByTime(1100);
102
+ cache.get("key1");
103
+
104
+ const stats = cache.getStats();
105
+ expect(stats.hits).toBe(0);
106
+ expect(stats.misses).toBe(1);
107
+ });
108
+
109
+ it("should calculate hit rate correctly", () => {
110
+ cache.set("key1", "value1");
111
+ cache.get("key1"); // hit
112
+ cache.get("key1"); // hit
113
+ cache.get("nonexistent"); // miss
114
+
115
+ const stats = cache.getStats();
116
+ expect(stats.hits).toBe(2);
117
+ expect(stats.misses).toBe(1);
118
+ expect(stats.hitRate).toBeCloseTo(2 / 3);
119
+ });
120
+
121
+ it("should reset counters on clear", () => {
122
+ cache.set("key1", "value1");
123
+ cache.get("key1");
124
+ cache.get("nonexistent");
125
+
126
+ cache.clear();
127
+ const stats = cache.getStats();
128
+ expect(stats.hits).toBe(0);
129
+ expect(stats.misses).toBe(0);
130
+ expect(stats.hitRate).toBe(0);
131
+ });
132
+
133
+ it("should handle zero requests for hit rate", () => {
134
+ const stats = cache.getStats();
135
+ expect(stats.hitRate).toBe(0);
136
+ });
137
+ });
138
+
139
+ describe("LRU Eviction", () => {
140
+ beforeEach(() => {
141
+ process.env.YNAB_MCP_CACHE_MAX_ENTRIES = "3";
142
+ cache = new CacheManager();
143
+ });
144
+
145
+ it("should not evict when under limit", () => {
146
+ cache.set("key1", "value1");
147
+ cache.set("key2", "value2");
148
+
149
+ const stats = cache.getStats();
150
+ expect(stats.size).toBe(2);
151
+ expect(stats.evictions).toBe(0);
152
+ expect(cache.get("key1")).toBe("value1");
153
+ expect(cache.get("key2")).toBe("value2");
154
+ });
155
+
156
+ it("should evict LRU entry when maxEntries is exceeded", () => {
157
+ cache.set("key1", "value1");
158
+ cache.set("key2", "value2");
159
+ cache.set("key3", "value3");
160
+ cache.set("key4", "value4"); // Should evict key1
161
+
162
+ const stats = cache.getStats();
163
+ expect(stats.size).toBe(3);
164
+ expect(stats.evictions).toBe(1);
165
+ expect(cache.get("key1")).toBeNull(); // Evicted
166
+ expect(cache.get("key2")).toBe("value2");
167
+ expect(cache.get("key3")).toBe("value3");
168
+ expect(cache.get("key4")).toBe("value4");
169
+ });
170
+
171
+ it("should update access order on get", () => {
172
+ cache.set("key1", "value1");
173
+ cache.set("key2", "value2");
174
+ cache.set("key3", "value3");
175
+
176
+ // Access key1 to make it most recently used
177
+ cache.get("key1");
178
+
179
+ cache.set("key4", "value4"); // Should evict key2 (oldest)
180
+
181
+ expect(cache.get("key1")).toBe("value1"); // Still there
182
+ expect(cache.get("key2")).toBeNull(); // Evicted
183
+ expect(cache.get("key3")).toBe("value3");
184
+ expect(cache.get("key4")).toBe("value4");
185
+ });
186
+
187
+ it("should handle zero maxEntries (no caching)", () => {
188
+ process.env.YNAB_MCP_CACHE_MAX_ENTRIES = "0";
189
+ cache = new CacheManager();
190
+
191
+ cache.set("key1", "value1");
192
+ expect(cache.get("key1")).toBeNull();
193
+ expect(cache.getStats().size).toBe(0);
194
+ });
195
+
196
+ it("should evict multiple entries if needed", () => {
197
+ // Fill cache
198
+ cache.set("key1", "value1");
199
+ cache.set("key2", "value2");
200
+ cache.set("key3", "value3");
201
+
202
+ // Change maxEntries to 1 by creating a new cache manager
203
+ process.env.YNAB_MCP_CACHE_MAX_ENTRIES = "1";
204
+ const smallCache = new CacheManager();
205
+
206
+ // Add entries that should trigger multiple evictions
207
+ smallCache.set("key1", "value1");
208
+ smallCache.set("key2", "value2");
209
+
210
+ expect(smallCache.getStats().size).toBe(1);
211
+ expect(smallCache.getStats().evictions).toBe(1);
212
+ expect(smallCache.get("key2")).toBe("value2"); // Most recent
213
+ });
214
+
215
+ it("should not evict when updating existing key at maxEntries limit", () => {
216
+ // Fill cache to capacity
217
+ cache.set("key1", "value1");
218
+ cache.set("key2", "value2");
219
+ cache.set("key3", "value3");
220
+
221
+ const initialStats = cache.getStats();
222
+ expect(initialStats.size).toBe(3);
223
+ expect(initialStats.evictions).toBe(0);
224
+
225
+ // Update an existing key - should not trigger eviction
226
+ cache.set("key2", "updated-value2");
227
+
228
+ const updatedStats = cache.getStats();
229
+ expect(updatedStats.size).toBe(3); // Same size
230
+ expect(updatedStats.evictions).toBe(0); // No evictions
231
+ expect(cache.get("key1")).toBe("value1"); // Other keys still present
232
+ expect(cache.get("key2")).toBe("updated-value2"); // Updated value
233
+ expect(cache.get("key3")).toBe("value3"); // Other keys still present
234
+ });
235
+ });
236
+
237
+ describe("Per-Entry Options", () => {
238
+ it("should use custom TTL from options", () => {
239
+ cache.set("key1", "value1", { ttl: 500, staleWhileRevalidate: 0 });
240
+ cache.set("key2", "value2", { ttl: 1500, staleWhileRevalidate: 0 });
241
+
242
+ vi.advanceTimersByTime(1000);
243
+ expect(cache.get("key1")).toBeNull(); // Expired
244
+ expect(cache.get("key2")).toBe("value2"); // Still valid
245
+ });
246
+
247
+ it("should use default TTL when no options provided", () => {
248
+ cache.set("key1", "value1");
249
+
250
+ // Advance to just before expiration (5 minutes is default TTL)
251
+ vi.advanceTimersByTime(299000); // Just under 5 minutes - should still be valid
252
+ expect(cache.get("key1")).toBe("value1");
253
+
254
+ // Advance past the TTL (using simple set should have NO stale window)
255
+ vi.advanceTimersByTime(2000); // Total ~5 minutes - should be expired
256
+ expect(cache.get("key1")).toBeNull();
257
+ });
258
+
259
+ it("should support staleWhileRevalidate", () => {
260
+ cache.set("key1", "value1", { ttl: 1000, staleWhileRevalidate: 2000 });
261
+
262
+ vi.advanceTimersByTime(1500); // Within stale window
263
+ const result = cache.get("key1");
264
+
265
+ expect(result).toBe("value1"); // Should return stale data
266
+ const stats = cache.getStats();
267
+ expect(stats.hits).toBe(1); // Counted as hit
268
+ });
269
+
270
+ it("should not return data outside stale window", () => {
271
+ cache.set("key1", "value1", { ttl: 1000, staleWhileRevalidate: 2000 });
272
+
273
+ vi.advanceTimersByTime(3500); // Outside stale window
274
+ const result = cache.get("key1");
275
+
276
+ expect(result).toBeNull();
277
+ const stats = cache.getStats();
278
+ expect(stats.misses).toBe(1);
279
+ });
280
+
281
+ it("should maintain backward compatibility with number TTL", () => {
282
+ cache.set("key1", "value1", 2000);
283
+ vi.advanceTimersByTime(1000);
284
+ expect(cache.get("key1")).toBe("value1");
285
+
286
+ vi.advanceTimersByTime(1500);
287
+ expect(cache.get("key1")).toBeNull();
288
+ });
289
+ });
290
+
291
+ describe("wrap() Helper", () => {
292
+ it("should return cached data immediately on hit", async () => {
293
+ const loader = vi.fn().mockResolvedValue("loaded-value");
294
+ cache.set("key1", "cached-value");
295
+
296
+ const result = await cache.wrap("key1", { loader });
297
+
298
+ expect(result).toBe("cached-value");
299
+ expect(loader).not.toHaveBeenCalled();
300
+ });
301
+
302
+ it("should call loader and cache result on miss", async () => {
303
+ const loader = vi.fn().mockResolvedValue("loaded-value");
304
+
305
+ const result = await cache.wrap("key1", { loader });
306
+
307
+ expect(result).toBe("loaded-value");
308
+ expect(loader).toHaveBeenCalledTimes(1);
309
+ expect(cache.get("key1")).toBe("loaded-value");
310
+ });
311
+
312
+ it("should deduplicate concurrent requests", async () => {
313
+ const loader = vi
314
+ .fn()
315
+ .mockImplementation(
316
+ () =>
317
+ new Promise((resolve) =>
318
+ setTimeout(() => resolve("loaded-value"), 100),
319
+ ),
320
+ );
321
+
322
+ // Start two concurrent requests
323
+ const promise1 = cache.wrap("key1", { loader });
324
+ const promise2 = cache.wrap("key1", { loader });
325
+
326
+ // Advance time to resolve promises
327
+ vi.advanceTimersByTime(100);
328
+
329
+ const [result1, result2] = await Promise.all([promise1, promise2]);
330
+
331
+ expect(result1).toBe("loaded-value");
332
+ expect(result2).toBe("loaded-value");
333
+ expect(loader).toHaveBeenCalledTimes(1); // Only called once
334
+ });
335
+
336
+ it("should handle loader errors gracefully", async () => {
337
+ const loader = vi.fn().mockRejectedValue(new Error("Load failed"));
338
+
339
+ await expect(cache.wrap("key1", { loader })).rejects.toThrow(
340
+ "Load failed",
341
+ );
342
+ expect(cache.get("key1")).toBeNull(); // Should not cache error
343
+ });
344
+
345
+ it("should serve stale data and trigger background refresh", async () => {
346
+ const loader1 = vi.fn().mockResolvedValue("initial-value");
347
+ const loader2 = vi.fn().mockResolvedValue("refreshed-value");
348
+
349
+ // Initial load
350
+ await cache.wrap("key1", {
351
+ loader: loader1,
352
+ ttl: 1000,
353
+ staleWhileRevalidate: 2000,
354
+ });
355
+
356
+ // Move to stale period
357
+ vi.advanceTimersByTime(1500);
358
+
359
+ // Second call should return stale data immediately and refresh in background
360
+ const result = await cache.wrap("key1", { loader: loader2 });
361
+ expect(result).toBe("initial-value"); // Stale data returned
362
+
363
+ // Advance time to allow background refresh
364
+ vi.advanceTimersByTime(100);
365
+ await vi.runAllTimersAsync();
366
+
367
+ expect(loader2).toHaveBeenCalledTimes(1);
368
+ });
369
+
370
+ it("should apply cache options from wrap call", async () => {
371
+ const loader = vi.fn().mockResolvedValue("loaded-value");
372
+
373
+ await cache.wrap("key1", {
374
+ loader,
375
+ ttl: 500,
376
+ staleWhileRevalidate: 1000,
377
+ });
378
+
379
+ // Verify custom TTL
380
+ vi.advanceTimersByTime(400);
381
+ expect(cache.get("key1")).toBe("loaded-value");
382
+
383
+ vi.advanceTimersByTime(200); // Total 600ms, past TTL but within stale window
384
+ const staleResult = cache.get("key1");
385
+ expect(staleResult).toBe("loaded-value"); // Should return stale data
386
+ });
387
+
388
+ it("should clean up pending operations on completion", async () => {
389
+ const loader = vi.fn().mockResolvedValue("loaded-value");
390
+
391
+ await cache.wrap("key1", { loader });
392
+
393
+ // Start another request after first completes
394
+ const loader2 = vi.fn().mockResolvedValue("loaded-value-2");
395
+ await cache.wrap("key1", { loader: loader2 });
396
+
397
+ // Should use cached value, not call loader2
398
+ expect(loader2).not.toHaveBeenCalled();
399
+ });
400
+
401
+ it("should preserve existing TTL/SWR when options omitted in background refresh", async () => {
402
+ const loader1 = vi.fn().mockResolvedValue("initial-value");
403
+ const loader2 = vi.fn().mockResolvedValue("refreshed-value");
404
+
405
+ // Initial load with specific TTL/SWR
406
+ await cache.wrap("key1", {
407
+ loader: loader1,
408
+ ttl: 2000,
409
+ staleWhileRevalidate: 3000,
410
+ });
411
+
412
+ // Move to stale period
413
+ vi.advanceTimersByTime(2500);
414
+
415
+ // Background refresh with no TTL/SWR specified - should preserve original values
416
+ await cache.wrap("key1", { loader: loader2 });
417
+
418
+ // Advance time to allow background refresh
419
+ vi.advanceTimersByTime(100);
420
+ await vi.runAllTimersAsync();
421
+
422
+ // Now check if the refreshed entry still has the original TTL
423
+ vi.advanceTimersByTime(1800); // Should still be within original TTL (2000ms)
424
+ const result = cache.get("key1");
425
+ expect(result).toBe("refreshed-value");
426
+
427
+ // Move past original TTL but within stale window
428
+ vi.advanceTimersByTime(300); // Total 2300ms past refresh, should be in stale window
429
+ const staleResult = cache.get("key1");
430
+ expect(staleResult).toBe("refreshed-value"); // Should still be available due to preserved SWR
431
+
432
+ // Move beyond original TTL + stale window (5000ms) from the initial load to ensure expiry
433
+ vi.advanceTimersByTime(3000); // Total elapsed time ~7700ms from first load
434
+ await vi.runAllTimersAsync();
435
+ expect(cache.get("key1")).toBeNull(); // Entry should be expired after preserved TTL/SWR window
436
+ });
437
+ });
438
+
439
+ describe("Cleanup Enhancement", () => {
440
+ it("should update lastCleanup timestamp", () => {
441
+ const startTime = Date.now();
442
+ vi.advanceTimersByTime(1000);
443
+
444
+ cache.set("key1", "value1", 500);
445
+ vi.advanceTimersByTime(600);
446
+ cache.cleanup();
447
+
448
+ const stats = cache.getStats();
449
+ expect(stats.lastCleanup).toBeGreaterThan(startTime);
450
+ });
451
+
452
+ it("should include cleanup removals in eviction count", () => {
453
+ cache.set("key1", "value1", 500);
454
+ cache.set("key2", "value2", 1000);
455
+
456
+ vi.advanceTimersByTime(600);
457
+ const cleaned = cache.cleanup();
458
+
459
+ expect(cleaned).toBe(1);
460
+ const stats = cache.getStats();
461
+ expect(stats.evictions).toBe(1);
462
+ expect(cache.get("key1")).toBeNull();
463
+ expect(cache.get("key2")).toBe("value2");
464
+ });
465
+
466
+ it("should return zero when no cleanup needed", () => {
467
+ cache.set("key1", "value1", 5000);
468
+ const cleaned = cache.cleanup();
469
+
470
+ expect(cleaned).toBe(0);
471
+ const stats = cache.getStats();
472
+ expect(stats.evictions).toBe(0);
473
+ });
474
+
475
+ it("should provide detailed cleanup information", () => {
476
+ cache.set("key1", "value1", 500);
477
+ cache.set("key2", "value2", 1000);
478
+ cache.set("key3", "value3", 5000);
479
+
480
+ vi.advanceTimersByTime(600);
481
+ const result = cache.cleanupDetailed();
482
+
483
+ expect(result.cleaned).toBe(1);
484
+ expect(result.evictions).toBe(1);
485
+ expect(cache.get("key1")).toBeNull();
486
+ expect(cache.get("key2")).toBe("value2");
487
+ expect(cache.get("key3")).toBe("value3");
488
+ });
489
+
490
+ it("should maintain backward compatibility with cleanup() method", () => {
491
+ cache.set("key1", "value1", 500);
492
+ cache.set("key2", "value2", 1000);
493
+
494
+ vi.advanceTimersByTime(600);
495
+ const cleaned = cache.cleanup();
496
+
497
+ expect(cleaned).toBe(1); // Should still return number of cleaned entries
498
+ const stats = cache.getStats();
499
+ expect(stats.evictions).toBe(1);
500
+ });
501
+ });
502
+
503
+ describe("Environment Variable Configuration", () => {
504
+ it("should use environment variable for maxEntries", () => {
505
+ process.env.YNAB_MCP_CACHE_MAX_ENTRIES = "5";
506
+ const configuredCache = new CacheManager();
507
+
508
+ const stats = configuredCache.getStats();
509
+ expect(stats.maxEntries).toBe(5);
510
+ });
511
+
512
+ it("should use environment variable for stale window", () => {
513
+ process.env.YNAB_MCP_CACHE_STALE_MS = "30000";
514
+ const configuredCache = new CacheManager();
515
+
516
+ configuredCache.set("key1", "value1", {
517
+ ttl: 1000,
518
+ staleWhileRevalidate: undefined,
519
+ });
520
+
521
+ // The default stale window should be used from env var
522
+ vi.advanceTimersByTime(15000); // Within default stale window from env
523
+ expect(configuredCache.get("key1")).toBe("value1"); // Served as stale data
524
+
525
+ vi.advanceTimersByTime(16100); // Beyond stale window now (total 31100ms > 31000ms)
526
+ expect(configuredCache.get("key1")).toBeNull();
527
+ });
528
+
529
+ it("should fall back to defaults for invalid environment values", () => {
530
+ process.env.YNAB_MCP_CACHE_MAX_ENTRIES = "invalid";
531
+ process.env.YNAB_MCP_CACHE_STALE_MS = "not-a-number";
532
+ process.env.YNAB_MCP_CACHE_DEFAULT_TTL_MS = "invalid-ttl";
533
+
534
+ // Reset timers for new cache instance
535
+ vi.useRealTimers();
536
+ vi.useFakeTimers({ now: 0 });
537
+
538
+ const configuredCache = new CacheManager();
539
+ const stats = configuredCache.getStats();
540
+
541
+ expect(stats.maxEntries).toBe(1000); // Default value
542
+
543
+ // Test that invalid default TTL falls back to 300000ms (5 minutes)
544
+ configuredCache.set("key1", "value1");
545
+ vi.advanceTimersByTime(299000); // Just under 5 minutes - should be valid
546
+ expect(configuredCache.get("key1")).toBe("value1");
547
+
548
+ vi.advanceTimersByTime(2000); // ~5 minutes total - should expire
549
+ expect(configuredCache.get("key1")).toBeNull();
550
+ });
551
+
552
+ it("should use environment variable for default TTL", () => {
553
+ process.env.YNAB_MCP_CACHE_DEFAULT_TTL_MS = "60000"; // 1 minute
554
+
555
+ // Reset timers for new cache instance
556
+ vi.useRealTimers();
557
+ vi.useFakeTimers({ now: 0 });
558
+
559
+ const configuredCache = new CacheManager();
560
+
561
+ configuredCache.set("key1", "value1"); // Use default TTL
562
+ vi.advanceTimersByTime(59000); // Just under 1 minute - should be valid
563
+ expect(configuredCache.get("key1")).toBe("value1");
564
+
565
+ vi.advanceTimersByTime(2000); // ~1 minute total - should expire
566
+ expect(configuredCache.get("key1")).toBeNull();
567
+ });
568
+
569
+ it("should fall back to defaults when environment variables are missing", () => {
570
+ process.env.YNAB_MCP_CACHE_MAX_ENTRIES = undefined;
571
+ process.env.YNAB_MCP_CACHE_STALE_MS = undefined;
572
+ process.env.YNAB_MCP_CACHE_DEFAULT_TTL_MS = undefined;
573
+
574
+ // Reset timers for new cache instance
575
+ vi.useRealTimers();
576
+ vi.useFakeTimers({ now: 0 });
577
+
578
+ const configuredCache = new CacheManager();
579
+ const stats = configuredCache.getStats();
580
+
581
+ expect(stats.maxEntries).toBe(1000); // Default value
582
+
583
+ // Test default TTL (300000ms = 5 minutes)
584
+ configuredCache.set("key1", "value1");
585
+ vi.advanceTimersByTime(299000); // Just under 5 minutes - should be valid
586
+ expect(configuredCache.get("key1")).toBe("value1");
587
+
588
+ vi.advanceTimersByTime(2000); // ~5 minutes total - should expire
589
+ expect(configuredCache.get("key1")).toBeNull();
590
+ });
591
+ });
592
+
593
+ describe("Enhanced Statistics", () => {
594
+ it("should return comprehensive stats", () => {
595
+ cache.set("key1", "value1");
596
+ cache.get("key1");
597
+ cache.get("nonexistent");
598
+
599
+ const stats = cache.getStats();
600
+
601
+ expect(stats).toEqual({
602
+ size: 1,
603
+ keys: ["key1"],
604
+ hits: 1,
605
+ misses: 1,
606
+ evictions: 0,
607
+ lastCleanup: null,
608
+ maxEntries: 1000,
609
+ hitRate: 0.5,
610
+ });
611
+ });
612
+
613
+ it("should maintain backward compatibility with basic stats", () => {
614
+ cache.set("key1", "value1");
615
+ cache.set("key2", "value2");
616
+
617
+ const stats = cache.getStats();
618
+
619
+ // Basic fields should always be present
620
+ expect(stats).toHaveProperty("size", 2);
621
+ expect(stats).toHaveProperty("keys");
622
+ expect(stats.keys).toEqual(["key1", "key2"]);
623
+ });
624
+
625
+ it("should handle getEntriesForSizeEstimation correctly", () => {
626
+ cache.set("key1", "value1", 1000);
627
+ cache.set("key2", "value2", 2000);
628
+
629
+ vi.advanceTimersByTime(1500);
630
+
631
+ const entries = cache.getEntriesForSizeEstimation();
632
+ expect(entries).toHaveLength(1); // Only non-expired entry
633
+ expect(entries[0][0]).toBe("key2");
634
+ });
635
+
636
+ it("should provide lightweight cache metadata without full entry data", () => {
637
+ cache.set("key1", "string-value", 1000);
638
+ cache.set("key2", { prop: "object" }, 2000);
639
+ cache.set("key3", 42, { ttl: 3000, staleWhileRevalidate: 1000 });
640
+
641
+ vi.advanceTimersByTime(1500); // key1 should be expired
642
+
643
+ const metadata = cache.getCacheMetadata();
644
+ expect(metadata).toHaveLength(3);
645
+
646
+ // Check expired entry
647
+ const key1Meta = metadata.find((m) => m.key === "key1");
648
+ expect(key1Meta).toEqual({
649
+ key: "key1",
650
+ timestamp: expect.any(Number),
651
+ ttl: 1000,
652
+ staleWhileRevalidate: undefined,
653
+ dataType: "string",
654
+ isExpired: true,
655
+ });
656
+
657
+ // Check non-expired entry
658
+ const key2Meta = metadata.find((m) => m.key === "key2");
659
+ expect(key2Meta).toEqual({
660
+ key: "key2",
661
+ timestamp: expect.any(Number),
662
+ ttl: 2000,
663
+ staleWhileRevalidate: undefined,
664
+ dataType: "object",
665
+ isExpired: false,
666
+ });
667
+
668
+ // Check entry with staleWhileRevalidate
669
+ const key3Meta = metadata.find((m) => m.key === "key3");
670
+ expect(key3Meta).toEqual({
671
+ key: "key3",
672
+ timestamp: expect.any(Number),
673
+ ttl: 3000,
674
+ staleWhileRevalidate: 1000,
675
+ dataType: "number",
676
+ isExpired: false,
677
+ });
678
+ });
679
+ });
680
+
681
+ describe("Edge Cases and Error Handling", () => {
682
+ it("should handle circular references in cache values", () => {
683
+ const circular: any = { name: "test" };
684
+ circular.self = circular;
685
+
686
+ expect(() => cache.set("key1", circular)).not.toThrow();
687
+ expect(cache.get("key1")).toBe(circular);
688
+ });
689
+
690
+ it("should handle very large cache sizes", () => {
691
+ process.env.YNAB_MCP_CACHE_MAX_ENTRIES = "10000";
692
+ const largeCache = new CacheManager();
693
+
694
+ // Add many entries
695
+ for (let i = 0; i < 5000; i++) {
696
+ largeCache.set(`key${i}`, `value${i}`);
697
+ }
698
+
699
+ expect(largeCache.getStats().size).toBe(5000);
700
+ expect(largeCache.get("key0")).toBe("value0");
701
+ expect(largeCache.get("key4999")).toBe("value4999");
702
+ });
703
+
704
+ it("should handle concurrent wrap calls with different keys independently", async () => {
705
+ const loader1 = vi.fn().mockResolvedValue("value1");
706
+ const loader2 = vi.fn().mockResolvedValue("value2");
707
+
708
+ const [result1, result2] = await Promise.all([
709
+ cache.wrap("key1", { loader: loader1 }),
710
+ cache.wrap("key2", { loader: loader2 }),
711
+ ]);
712
+
713
+ expect(result1).toBe("value1");
714
+ expect(result2).toBe("value2");
715
+ expect(loader1).toHaveBeenCalledTimes(1);
716
+ expect(loader2).toHaveBeenCalledTimes(1);
717
+ });
718
+
719
+ it("should clean up failed operations", async () => {
720
+ const loader = vi.fn().mockRejectedValue(new Error("Failed"));
721
+
722
+ await expect(cache.wrap("key1", { loader })).rejects.toThrow("Failed");
723
+
724
+ // Subsequent call should try again
725
+ const loader2 = vi.fn().mockResolvedValue("success");
726
+ const result = await cache.wrap("key1", { loader: loader2 });
727
+
728
+ expect(result).toBe("success");
729
+ expect(loader2).toHaveBeenCalledTimes(1);
730
+ });
731
+ });
732
+
733
+ describe("Prefix and Budget-based Deletion", () => {
734
+ describe("deleteByPrefix", () => {
735
+ it("should delete entries matching prefix and return count", () => {
736
+ cache.set("transactions:list:budget-123", "list");
737
+ cache.set("transactions:get:budget-123", "detail");
738
+ cache.set("accounts:list:budget-123", "accounts");
739
+
740
+ const removed = cache.deleteByPrefix("transactions:");
741
+ expect(removed).toBe(2);
742
+ expect(cache.getKeys()).toEqual(["accounts:list:budget-123"]);
743
+ });
744
+
745
+ it("should return 0 when no matches found", () => {
746
+ cache.set("accounts:list:budget-123", "accounts");
747
+ const removed = cache.deleteByPrefix("payments:");
748
+ expect(removed).toBe(0);
749
+ expect(cache.getKeys()).toEqual(["accounts:list:budget-123"]);
750
+ });
751
+
752
+ it("should handle empty prefix safely", () => {
753
+ cache.set("transactions:list:budget-123", "list");
754
+ cache.set("accounts:list:budget-123", "accounts");
755
+
756
+ const removed = cache.deleteByPrefix("");
757
+ expect(removed).toBe(0);
758
+ expect(cache.getKeys()).toEqual([
759
+ "transactions:list:budget-123",
760
+ "accounts:list:budget-123",
761
+ ]);
762
+ });
763
+
764
+ it("should not delete when prefix only partially matches a resource's namespace", () => {
765
+ cache.set("transactions:list:budget-123", "list");
766
+ cache.set("accounts:list:budget-123", "accounts");
767
+
768
+ const removed = cache.deleteByPrefix("trans");
769
+ expect(removed).toBe(0);
770
+ expect(cache.getKeys()).toEqual([
771
+ "transactions:list:budget-123",
772
+ "accounts:list:budget-123",
773
+ ]);
774
+ });
775
+
776
+ it("should not affect cache hit or miss counters", () => {
777
+ cache.set("transactions:list:budget-123", "list");
778
+ cache.set("transactions:list:budget-456", "list");
779
+
780
+ const before = cache.getStats();
781
+ cache.deleteByPrefix("transactions:");
782
+ const after = cache.getStats();
783
+
784
+ expect(after.hits).toBe(before.hits);
785
+ expect(after.misses).toBe(before.misses);
786
+ });
787
+ });
788
+
789
+ describe("deleteByBudgetId", () => {
790
+ it("should delete entries containing the provided budget ID", () => {
791
+ cache.set("transactions:list:budget-123", "txn");
792
+ cache.set("accounts:list:budget-123", "acct");
793
+ cache.set("transactions:list:budget-456", "other");
794
+
795
+ const removed = cache.deleteByBudgetId("budget-123");
796
+ expect(removed).toBe(2);
797
+ expect(cache.getKeys()).toEqual(["transactions:list:budget-456"]);
798
+ });
799
+
800
+ it("should return 0 when budget ID does not exist in cache", () => {
801
+ cache.set("transactions:list:budget-123", "txn");
802
+
803
+ const removed = cache.deleteByBudgetId("budget-999");
804
+ expect(removed).toBe(0);
805
+ expect(cache.getKeys()).toEqual(["transactions:list:budget-123"]);
806
+ });
807
+
808
+ it("should not match budget IDs that are substrings of other IDs", () => {
809
+ cache.set("transactions:list:budget-123", "txn");
810
+ cache.set("transactions:list:budget-1234", "txn2");
811
+
812
+ const removed = cache.deleteByBudgetId("budget-1");
813
+ expect(removed).toBe(0);
814
+ expect(cache.getKeys()).toEqual([
815
+ "transactions:list:budget-123",
816
+ "transactions:list:budget-1234",
817
+ ]);
818
+ });
819
+
820
+ it("should handle UUID formatted budget identifiers", () => {
821
+ const uuid = "123e4567-e89b-12d3-a456-426614174000";
822
+ cache.set(`transactions:list:${uuid}`, "txn");
823
+ cache.set(`accounts:list:${uuid}`, "acct");
824
+ cache.set("transactions:list:budget-456", "other");
825
+
826
+ const removed = cache.deleteByBudgetId(uuid);
827
+ expect(removed).toBe(2);
828
+ expect(cache.getKeys()).toEqual(["transactions:list:budget-456"]);
829
+ });
830
+
831
+ it("should not affect cache stats when deleting by budget ID", () => {
832
+ cache.set("transactions:list:budget-123", "txn");
833
+ cache.set("transactions:list:budget-456", "other");
834
+
835
+ const before = cache.getStats();
836
+ cache.deleteByBudgetId("budget-123");
837
+ const after = cache.getStats();
838
+
839
+ expect(after.hits).toBe(before.hits);
840
+ expect(after.misses).toBe(before.misses);
841
+ });
842
+ });
843
+
844
+ describe("getKeys", () => {
845
+ it("should return an empty array when cache is empty", () => {
846
+ expect(cache.getKeys()).toEqual([]);
847
+ });
848
+
849
+ it("should return all cache keys", () => {
850
+ cache.set("accounts:list:budget-123", "acct");
851
+ cache.set("transactions:list:budget-123", "txn");
852
+ cache.set("payees:list:budget-123", "payees");
853
+
854
+ expect(cache.getKeys()).toEqual([
855
+ "accounts:list:budget-123",
856
+ "transactions:list:budget-123",
857
+ "payees:list:budget-123",
858
+ ]);
859
+ });
860
+
861
+ it("should preserve insertion order of cache keys", () => {
862
+ cache.set("key-a", "a");
863
+ cache.set("key-b", "b");
864
+ cache.set("key-c", "c");
865
+
866
+ expect(cache.getKeys()).toEqual(["key-a", "key-b", "key-c"]);
867
+ });
868
+ });
869
+ });
870
+
871
+ describe("Integration with Existing Patterns", () => {
872
+ it("should work with existing tool usage patterns", () => {
873
+ // Simulate existing usage pattern from tools
874
+ const key = CacheManager.generateKey("budgets", "user123");
875
+ cache.set(key, { budgets: ["budget1", "budget2"] }, 10 * 60 * 1000);
876
+
877
+ const cached = cache.get(key);
878
+ expect(cached).toEqual({ budgets: ["budget1", "budget2"] });
879
+
880
+ const stats = cache.getStats();
881
+ expect(stats.hits).toBe(1);
882
+ expect(stats.size).toBe(1);
883
+ });
884
+
885
+ it("should maintain singleton behavior", async () => {
886
+ // The imported singleton should work consistently
887
+ const { cacheManager } = await import("../cacheManager.js");
888
+
889
+ cacheManager.set("singleton-test", "value");
890
+ expect(cacheManager.get("singleton-test")).toBe("value");
891
+
892
+ const stats = cacheManager.getStats();
893
+ expect(stats).toHaveProperty("hits");
894
+ expect(stats).toHaveProperty("misses");
895
+ });
896
+ });
880
897
  });