@dizzlkheinz/ynab-mcpb 0.18.4 → 0.19.0

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