1- import { CachingService } from './caching.service' ;
1+ import { CachingService , deriveCacheType , buildCounterKeys } from './caching.service' ;
22import { MetricsCollectionService } from '../monitoring/metrics/metrics-collection.service' ;
33
4+ // ── Helpers ──────────────────────────────────────────────────────────────────
5+
6+ /**
7+ * Build a jest-mocked ioredis-like client with just the methods the
8+ * CachingService now uses (incr, mget, scan, del). We intentionally keep this
9+ * minimal so the surface area that the tests exercise matches production.
10+ */
11+ function createMockRedis ( ) {
12+ const store = new Map < string , number > ( ) ;
13+ const mget = jest . fn ( async ( ...keys : string [ ] ) =>
14+ keys . map ( ( k ) => ( store . has ( k ) ? String ( store . get ( k ) ) : null ) ) ,
15+ ) ;
16+ const incr = jest . fn ( async ( key : string ) => {
17+ const next = ( store . get ( key ) ?? 0 ) + 1 ;
18+ store . set ( key , next ) ;
19+ return next ;
20+ } ) ;
21+ const del = jest . fn ( async ( ...keys : string [ ] ) => {
22+ let removed = 0 ;
23+ for ( const k of keys ) {
24+ if ( store . delete ( k ) ) removed += 1 ;
25+ }
26+ return removed ;
27+ } ) ;
28+ const scan = jest . fn ( async ( _cursor : string , _match : string , pattern : string ) => {
29+ const re = new RegExp ( '^' + pattern . replace ( / \* / g, '.*' ) + '$' ) ;
30+ const matches = Array . from ( store . keys ( ) ) . filter ( ( k ) => re . test ( k ) ) ;
31+ return [ '0' , matches ] as [ string , string [ ] ] ;
32+ } ) ;
33+ return { store, incr, mget, scan, del } ;
34+ }
35+
436describe ( 'CachingService' , ( ) => {
537 let service : CachingService ;
638 let cacheManager : {
@@ -10,6 +42,7 @@ describe('CachingService', () => {
1042 clear : jest . Mock ;
1143 } ;
1244 let metrics : { updateCacheHitRate : jest . Mock } ;
45+ let redis : ReturnType < typeof createMockRedis > ;
1346
1447 beforeEach ( ( ) => {
1548 cacheManager = {
@@ -22,12 +55,46 @@ describe('CachingService', () => {
2255 ( cacheManager as any ) . store = {
2356 keys : jest . fn ( ) . mockResolvedValue ( [ 'cache:test:1' , 'cache:test:2' ] ) ,
2457 } ;
58+ redis = createMockRedis ( ) ;
59+
2560 service = new CachingService (
2661 cacheManager as never ,
2762 metrics as unknown as MetricsCollectionService ,
63+ undefined ,
64+ redis as never ,
2865 ) ;
2966 } ) ;
3067
68+ // ── deriveCacheType / buildCounterKeys ──────────────────────────────────────
69+
70+ describe ( 'deriveCacheType' , ( ) => {
71+ it ( 'returns the second segment for cache:{type}:... keys' , ( ) => {
72+ expect ( deriveCacheType ( 'cache:test:1' ) ) . toBe ( 'test' ) ;
73+ expect ( deriveCacheType ( 'cache:user:42' ) ) . toBe ( 'user' ) ;
74+ expect ( deriveCacheType ( 'cache:course:popular' ) ) . toBe ( 'course' ) ;
75+ } ) ;
76+
77+ it ( 'returns "default" for keys with no cache: prefix' , ( ) => {
78+ expect ( deriveCacheType ( 'hit-key' ) ) . toBe ( 'default' ) ;
79+ expect ( deriveCacheType ( 'foo:bar' ) ) . toBe ( 'default' ) ;
80+ } ) ;
81+
82+ it ( 'returns "default" for empty / invalid input' , ( ) => {
83+ expect ( deriveCacheType ( '' ) ) . toBe ( 'default' ) ;
84+ } ) ;
85+ } ) ;
86+
87+ describe ( 'buildCounterKeys' , ( ) => {
88+ it ( 'produces namespaced hit/miss keys' , ( ) => {
89+ expect ( buildCounterKeys ( 'application' ) ) . toEqual ( {
90+ hits : 'cache:hits:application' ,
91+ misses : 'cache:misses:application' ,
92+ } ) ;
93+ } ) ;
94+ } ) ;
95+
96+ // ── getOrSet ───────────────────────────────────────────────────────────────
97+
3198 describe ( 'getOrSet' , ( ) => {
3299 it ( 'returns cached value without calling factory on hit' , async ( ) => {
33100 cacheManager . get . mockResolvedValue ( { id : '1' } ) ;
@@ -37,8 +104,13 @@ describe('CachingService', () => {
37104
38105 expect ( result ) . toEqual ( { id : '1' } ) ;
39106 expect ( factory ) . not . toHaveBeenCalled ( ) ;
40- expect ( service . getStats ( ) . hits ) . toBe ( 1 ) ;
41- expect ( service . getStats ( ) . misses ) . toBe ( 0 ) ;
107+
108+ const stats = await service . getStats ( 'test' ) ;
109+ expect ( stats . hits ) . toBe ( 1 ) ;
110+ expect ( stats . misses ) . toBe ( 0 ) ;
111+
112+ // INCR must have been called against the correct cluster-wide key
113+ expect ( redis . incr ) . toHaveBeenCalledWith ( 'cache:hits:test' ) ;
42114 } ) ;
43115
44116 it ( 'populates cache from factory on miss' , async ( ) => {
@@ -50,10 +122,16 @@ describe('CachingService', () => {
50122 expect ( result ) . toEqual ( { id : '2' } ) ;
51123 expect ( factory ) . toHaveBeenCalledTimes ( 1 ) ;
52124 expect ( cacheManager . set ) . toHaveBeenCalledWith ( 'cache:test:2' , { id : '2' } , 120000 ) ;
53- expect ( service . getStats ( ) . misses ) . toBe ( 1 ) ;
125+ expect ( redis . incr ) . toHaveBeenCalledWith ( 'cache:misses:test' ) ;
126+
127+ const stats = await service . getStats ( 'test' ) ;
128+ expect ( stats . hits ) . toBe ( 0 ) ;
129+ expect ( stats . misses ) . toBe ( 1 ) ;
54130 } ) ;
55131 } ) ;
56132
133+ // ── deleteByPattern ────────────────────────────────────────────────────────
134+
57135 describe ( 'deleteByPattern' , ( ) => {
58136 it ( 'uses store.keys to delete matching keys when client scan is unavailable' , async ( ) => {
59137 await service . deleteByPattern ( 'cache:test:*' ) ;
@@ -63,16 +141,147 @@ describe('CachingService', () => {
63141 } ) ;
64142 } ) ;
65143
66- describe ( 'hit rate metrics' , ( ) => {
67- it ( 'calculates hit rate and publishes to metrics' , async ( ) => {
144+ // ── Cluster-wide hit rate (Issue #811) ─────────────────────────────────────
145+
146+ describe ( 'distributed hit rate metrics' , ( ) => {
147+ it ( 'publishes aggregated cluster-wide hit rate to Prometheus' , async ( ) => {
148+ // Simulate three pods: each has independently INCRemented the shared
149+ // Redis counter. The reported hit rate must reflect what Redis holds,
150+ // NOT just what this service instance has seen locally.
151+ redis . store . set ( 'cache:hits:application' , 7 ) ;
152+ redis . store . set ( 'cache:misses:application' , 3 ) ;
153+
154+ await service . publishHitRateMetrics ( 'application' ) ;
155+
156+ expect ( metrics . updateCacheHitRate ) . toHaveBeenCalledWith ( 'application' , 70 ) ;
157+
158+ // The read path must use MGET against the CRedis keys, not local state.
159+ expect ( redis . mget ) . toHaveBeenCalledWith (
160+ 'cache:hits:application' ,
161+ 'cache:misses:application' ,
162+ ) ;
163+ } ) ;
164+
165+ it ( 'uses literal cache:hits:{type} / cache:misses:{type} keys (issue #811)' , async ( ) => {
68166 cacheManager . get . mockResolvedValueOnce ( 'cached' ) . mockResolvedValueOnce ( undefined ) ;
69167 await service . get ( 'hit-key' ) ;
70168 await service . get ( 'miss-key' ) ;
71169
72- service . publishHitRateMetrics ( 'application' ) ;
170+ // Issue spec: keys must be exactly cache:hits:{type} and cache:misses:{type}
171+ expect ( redis . incr ) . toHaveBeenCalledWith ( 'cache:hits:default' ) ;
172+ expect ( redis . incr ) . toHaveBeenCalledWith ( 'cache:misses:default' ) ;
173+ } ) ;
174+
175+ it ( 'aggregates hits/misses per cache type independently' , async ( ) => {
176+ redis . store . set ( 'cache:hits:test' , 8 ) ;
177+ redis . store . set ( 'cache:misses:test' , 2 ) ;
178+ redis . store . set ( 'cache:hits:course' , 1 ) ;
179+ redis . store . set ( 'cache:misses:course' , 4 ) ;
180+
181+ const testStats = await service . getStats ( 'test' ) ;
182+ expect ( testStats . hits ) . toBe ( 8 ) ;
183+ expect ( testStats . misses ) . toBe ( 2 ) ;
184+ expect ( testStats . hitRate ) . toBe ( 80 ) ;
185+
186+ const courseStats = await service . getStats ( 'course' ) ;
187+ expect ( courseStats . hits ) . toBe ( 1 ) ;
188+ expect ( courseStats . misses ) . toBe ( 4 ) ;
189+ expect ( courseStats . hitRate ) . toBe ( 20 ) ;
190+ } ) ;
191+
192+ it ( 'returns zero hit rate when no counters have been recorded' , async ( ) => {
193+ const stats = await service . getStats ( 'application' ) ;
194+ expect ( stats . hits ) . toBe ( 0 ) ;
195+ expect ( stats . misses ) . toBe ( 0 ) ;
196+ expect ( stats . hitRate ) . toBe ( 0 ) ;
197+ } ) ;
198+
199+ it ( 'returns aggregate stats across every type' , async ( ) => {
200+ redis . store . set ( 'cache:hits:test' , 5 ) ;
201+ redis . store . set ( 'cache:misses:test' , 5 ) ;
202+ redis . store . set ( 'cache:hits:course' , 2 ) ;
203+ redis . store . set ( 'cache:misses:course' , 8 ) ;
204+
205+ const aggregate = await service . getAggregateStats ( ) ;
206+ expect ( aggregate . hits ) . toBe ( 7 ) ;
207+ expect ( aggregate . misses ) . toBe ( 13 ) ;
208+ // 7 / 20 = 35
209+ expect ( aggregate . hitRate ) . toBeCloseTo ( 35 , 1 ) ;
210+ } ) ;
211+ } ) ;
212+
213+ // ── Counter reset mechanism ─────────────────────────────────────────────────
214+
215+ describe ( 'resetStats' , ( ) => {
216+ it ( 'deletes cluster-wide counter keys for a single type' , async ( ) => {
217+ redis . store . set ( 'cache:hits:test' , 10 ) ;
218+ redis . store . set ( 'cache:misses:test' , 4 ) ;
219+
220+ await service . resetStats ( 'test' ) ;
221+
222+ expect ( redis . del ) . toHaveBeenCalledWith ( 'cache:hits:test' , 'cache:misses:test' ) ;
223+
224+ const stats = await service . getStats ( 'test' ) ;
225+ expect ( stats . hits ) . toBe ( 0 ) ;
226+ expect ( stats . misses ) . toBe ( 0 ) ;
227+ } ) ;
228+
229+ it ( 'deletes all cluster-wide counter keys when called without an argument' , async ( ) => {
230+ redis . store . set ( 'cache:hits:test' , 1 ) ;
231+ redis . store . set ( 'cache:misses:test' , 2 ) ;
232+ redis . store . set ( 'cache:hits:course' , 3 ) ;
233+ redis . store . set ( 'cache:misses:course' , 4 ) ;
234+
235+ await service . resetStats ( ) ;
236+
237+ expect ( redis . scan ) . toHaveBeenCalled ( ) ;
238+ expect ( redis . del ) . toHaveBeenCalled ( ) ;
239+
240+ const aggregate = await service . getAggregateStats ( ) ;
241+ expect ( aggregate . hits ) . toBe ( 0 ) ;
242+ expect ( aggregate . misses ) . toBe ( 0 ) ;
243+ } ) ;
244+ } ) ;
245+
246+ // ── Graceful degradation ───────────────────────────────────────────────────
247+
248+ describe ( 'fallback behaviour when Redis is unavailable' , ( ) => {
249+ it ( 'falls back to local counters and still reports stats' , async ( ) => {
250+ // Simulate a broken Redis by throwing on every read.
251+ const brokenMget = jest . fn ( ) . mockRejectedValue ( new Error ( 'ECONNREFUSED' ) ) ;
252+ const brokenIncr = jest . fn ( ) . mockRejectedValue ( new Error ( 'ECONNREFUSED' ) ) ;
253+
254+ // Direct construction: opt in to local fallback explicitly (default is
255+ // already enabled in production but we make it explicit here).
256+ const localOnly = new CachingService (
257+ cacheManager as never ,
258+ metrics as unknown as MetricsCollectionService ,
259+ { get : ( key : string , fallback ?: any ) => ( key === 'CACHE_COUNTER_FALLBACK_LOCAL' ? true : fallback ) } as any ,
260+ { incr : brokenIncr , mget : brokenMget , scan : jest . fn ( ) , del : jest . fn ( ) } as never ,
261+ ) ;
262+
263+ cacheManager . get . mockResolvedValueOnce ( undefined ) . mockResolvedValue ( { id : 'x' } ) ;
264+ await localOnly . get ( 'miss-key' ) ;
265+ await localOnly . get ( 'hit-key' ) ;
266+
267+ const stats = await localOnly . getStats ( ) ;
268+ expect ( stats . hits ) . toBe ( 1 ) ;
269+ expect ( stats . misses ) . toBe ( 1 ) ;
270+ expect ( stats . hitRate ) . toBe ( 50 ) ;
271+ } ) ;
272+
273+ it ( 'publishes zero hit rate when Redis is unavailable and fallback disabled' , async ( ) => {
274+ const brokenMget = jest . fn ( ) . mockRejectedValue ( new Error ( 'ECONNREFUSED' ) ) ;
275+ const brokenIncr = jest . fn ( ) . mockRejectedValue ( new Error ( 'ECONNREFUSED' ) ) ;
276+
277+ const noFallback = new CachingService (
278+ cacheManager as never ,
279+ metrics as unknown as MetricsCollectionService ,
280+ { get : ( key : string , fallback ?: any ) => ( key === 'CACHE_COUNTER_FALLBACK_LOCAL' ? false : fallback ) } as any ,
281+ { incr : brokenIncr , mget : brokenMget , scan : jest . fn ( ) , del : jest . fn ( ) } as never ,
282+ ) ;
73283
74- expect ( service . getStats ( ) . hitRate ) . toBe ( 50 ) ;
75- expect ( metrics . updateCacheHitRate ) . toHaveBeenCalledWith ( 'application' , 50 ) ;
284+ await expect ( noFallback . getStats ( 'application' ) ) . rejects . toThrow ( 'ECONNREFUSED' ) ;
76285 } ) ;
77286 } ) ;
78287} ) ;
0 commit comments