1
+ /**
2
+ * Unit tests for NetworkDataCache
3
+ * Tests caching functionality, LRU eviction, TTL expiration, and metrics
4
+ */
5
+
6
+ import { Logger } from '@graphprotocol/common-ts'
7
+ import { NetworkDataCache , CacheOptions } from '../network-cache'
8
+ import { CacheError , CacheEvictionError } from '../errors'
9
+
10
+ const createMockLogger = ( ) : Logger =>
11
+ ( {
12
+ child : jest . fn ( ) . mockReturnThis ( ) ,
13
+ info : jest . fn ( ) ,
14
+ warn : jest . fn ( ) ,
15
+ error : jest . fn ( ) ,
16
+ debug : jest . fn ( ) ,
17
+ trace : jest . fn ( ) ,
18
+ } ) as unknown as Logger
19
+
20
+ describe ( 'NetworkDataCache' , ( ) => {
21
+ let logger : Logger
22
+ let cache : NetworkDataCache
23
+ let options : CacheOptions
24
+
25
+ beforeEach ( ( ) => {
26
+ logger = createMockLogger ( )
27
+ options = {
28
+ ttl : 1000 ,
29
+ maxSize : 3 ,
30
+ enableMetrics : true ,
31
+ }
32
+ cache = new NetworkDataCache ( logger , options )
33
+ } )
34
+
35
+ afterEach ( ( ) => {
36
+ cache . dispose ( )
37
+ } )
38
+
39
+ describe ( 'Basic Cache Operations' , ( ) => {
40
+ it ( 'should store and retrieve values' , async ( ) => {
41
+ const testValue = { data : 'test-data' }
42
+
43
+ cache . set ( 'test-key' , testValue )
44
+ const retrieved = await cache . get ( 'test-key' )
45
+
46
+ expect ( retrieved ) . toEqual ( testValue )
47
+ } )
48
+
49
+ it ( 'should return undefined for non-existent keys' , async ( ) => {
50
+ const result = await cache . get ( 'non-existent-key' )
51
+ expect ( result ) . toBeUndefined ( )
52
+ } )
53
+
54
+ it ( 'should handle multiple concurrent gets of same key' , async ( ) => {
55
+ const testValue = { data : 'concurrent-test' }
56
+ cache . set ( 'concurrent-key' , testValue )
57
+
58
+ const promises = Array . from ( { length : 10 } , ( ) =>
59
+ cache . get ( 'concurrent-key' )
60
+ )
61
+
62
+ const results = await Promise . all ( promises )
63
+ results . forEach ( result => {
64
+ expect ( result ) . toEqual ( testValue )
65
+ } )
66
+ } )
67
+
68
+ it ( 'should handle cache hits and misses correctly' , async ( ) => {
69
+ const testValue = { id : 'test' , value : 'data' }
70
+ cache . set ( 'hit-key' , testValue )
71
+
72
+ // Cache hit
73
+ const hit = await cache . get ( 'hit-key' )
74
+ expect ( hit ) . toEqual ( testValue )
75
+
76
+ // Cache miss
77
+ const miss = await cache . get ( 'miss-key' )
78
+ expect ( miss ) . toBeUndefined ( )
79
+
80
+ const metrics = cache . getMetrics ( )
81
+ expect ( metrics . hits ) . toBe ( 1 )
82
+ expect ( metrics . misses ) . toBe ( 1 )
83
+ } )
84
+ } )
85
+
86
+ describe ( 'LRU Eviction' , ( ) => {
87
+ it ( 'should evict least recently used items when max size exceeded' , async ( ) => {
88
+ // Fill cache to max capacity
89
+ cache . set ( 'key1' , { data : 'value1' } )
90
+ cache . set ( 'key2' , { data : 'value2' } )
91
+ cache . set ( 'key3' , { data : 'value3' } )
92
+
93
+ // Access key1 to make it more recently used
94
+ await cache . get ( 'key1' )
95
+
96
+ // Add new item, should evict key2 (least recently used)
97
+ cache . set ( 'key4' , { data : 'value4' } )
98
+
99
+ expect ( await cache . get ( 'key1' ) ) . toBeDefined ( ) // Most recently used
100
+ expect ( await cache . get ( 'key2' ) ) . toBeUndefined ( ) // Should be evicted
101
+ expect ( await cache . get ( 'key3' ) ) . toBeDefined ( ) // Still present
102
+ expect ( await cache . get ( 'key4' ) ) . toBeDefined ( ) // Newly added
103
+ } )
104
+
105
+ it ( 'should track eviction metrics' , async ( ) => {
106
+ // Fill beyond capacity to trigger evictions
107
+ for ( let i = 0 ; i < 5 ; i ++ ) {
108
+ cache . set ( `key${ i } ` , { data : `value${ i } ` } )
109
+ }
110
+
111
+ const metrics = cache . getMetrics ( )
112
+ expect ( metrics . evictions ) . toBe ( 2 ) // 5 items - 3 max size = 2 evictions
113
+ } )
114
+
115
+ it ( 'should handle eviction errors gracefully' , async ( ) => {
116
+ // Mock eviction failure
117
+ const originalDelete = cache [ 'cache' ] . delete
118
+ cache [ 'cache' ] . delete = jest . fn ( ) . mockReturnValue ( false )
119
+
120
+ expect ( ( ) => {
121
+ for ( let i = 0 ; i < 5 ; i ++ ) {
122
+ cache . set ( `key${ i } ` , { data : `value${ i } ` } )
123
+ }
124
+ } ) . not . toThrow ( )
125
+
126
+ // Restore original method
127
+ cache [ 'cache' ] . delete = originalDelete
128
+ } )
129
+ } )
130
+
131
+ describe ( 'TTL Expiration' , ( ) => {
132
+ it ( 'should expire items after TTL' , async ( ) => {
133
+ const shortTTLCache = new NetworkDataCache ( logger , {
134
+ ttl : 50 , // 50ms TTL
135
+ maxSize : 10 ,
136
+ } )
137
+
138
+ shortTTLCache . set ( 'expire-key' , { data : 'will-expire' } )
139
+
140
+ // Should be available immediately
141
+ expect ( await shortTTLCache . get ( 'expire-key' ) ) . toBeDefined ( )
142
+
143
+ // Wait for expiration
144
+ await new Promise ( resolve => setTimeout ( resolve , 100 ) )
145
+
146
+ // Should be expired
147
+ expect ( await shortTTLCache . get ( 'expire-key' ) ) . toBeUndefined ( )
148
+
149
+ shortTTLCache . dispose ( )
150
+ } )
151
+
152
+ it ( 'should clean up expired items automatically' , async ( ) => {
153
+ const cleanupCache = new NetworkDataCache ( logger , {
154
+ ttl : 50 ,
155
+ maxSize : 10 ,
156
+ cleanupInterval : 25 , // Clean every 25ms
157
+ } )
158
+
159
+ cleanupCache . set ( 'cleanup-key' , { data : 'test' } )
160
+
161
+ // Wait for cleanup cycle
162
+ await new Promise ( resolve => setTimeout ( resolve , 100 ) )
163
+
164
+ const metrics = cleanupCache . getMetrics ( )
165
+ expect ( metrics . cleanupRuns ) . toBeGreaterThan ( 0 )
166
+
167
+ cleanupCache . dispose ( )
168
+ } )
169
+
170
+ it ( 'should update TTL on cache hit' , async ( ) => {
171
+ const testValue = { data : 'refresh-ttl' }
172
+ cache . set ( 'refresh-key' , testValue )
173
+
174
+ // Access the key to refresh TTL
175
+ await cache . get ( 'refresh-key' )
176
+
177
+ // Value should still be available
178
+ expect ( await cache . get ( 'refresh-key' ) ) . toEqual ( testValue )
179
+ } )
180
+ } )
181
+
182
+ describe ( 'Cache with Fetcher' , ( ) => {
183
+ it ( 'should fetch and cache on miss' , async ( ) => {
184
+ const fetchedValue = { data : 'fetched-data' }
185
+ const fetcher = jest . fn ( ) . mockResolvedValue ( fetchedValue )
186
+
187
+ const result = await cache . getCachedOrFetch ( 'fetch-key' , fetcher )
188
+
189
+ expect ( result ) . toEqual ( fetchedValue )
190
+ expect ( fetcher ) . toHaveBeenCalledTimes ( 1 )
191
+
192
+ // Second call should use cache
193
+ const cachedResult = await cache . getCachedOrFetch ( 'fetch-key' , fetcher )
194
+ expect ( cachedResult ) . toEqual ( fetchedValue )
195
+ expect ( fetcher ) . toHaveBeenCalledTimes ( 1 ) // No additional calls
196
+ } )
197
+
198
+ it ( 'should handle fetcher errors' , async ( ) => {
199
+ const fetchError = new Error ( 'Fetch failed' )
200
+ const fetcher = jest . fn ( ) . mockRejectedValue ( fetchError )
201
+
202
+ await expect ( cache . getCachedOrFetch ( 'error-key' , fetcher ) )
203
+ . rejects . toThrow ( CacheError )
204
+ } )
205
+
206
+ it ( 'should not cache failed fetch results' , async ( ) => {
207
+ const fetcher = jest . fn ( )
208
+ . mockRejectedValueOnce ( new Error ( 'First failure' ) )
209
+ . mockResolvedValueOnce ( { data : 'success' } )
210
+
211
+ // First call fails
212
+ await expect ( cache . getCachedOrFetch ( 'retry-key' , fetcher ) )
213
+ . rejects . toThrow ( )
214
+
215
+ // Second call succeeds and caches
216
+ const result = await cache . getCachedOrFetch ( 'retry-key' , fetcher )
217
+ expect ( result ) . toEqual ( { data : 'success' } )
218
+
219
+ expect ( fetcher ) . toHaveBeenCalledTimes ( 2 )
220
+ } )
221
+ } )
222
+
223
+ describe ( 'Metrics and Monitoring' , ( ) => {
224
+ it ( 'should track comprehensive metrics' , async ( ) => {
225
+ // Generate various cache operations
226
+ cache . set ( 'metrics1' , { data : 'value1' } )
227
+ cache . set ( 'metrics2' , { data : 'value2' } )
228
+
229
+ await cache . get ( 'metrics1' ) // Hit
230
+ await cache . get ( 'nonexistent' ) // Miss
231
+
232
+ // Trigger eviction
233
+ cache . set ( 'metrics3' , { data : 'value3' } )
234
+ cache . set ( 'metrics4' , { data : 'value4' } ) // Should evict metrics2
235
+
236
+ const metrics = cache . getMetrics ( )
237
+
238
+ expect ( metrics . hits ) . toBe ( 1 )
239
+ expect ( metrics . misses ) . toBe ( 1 )
240
+ expect ( metrics . sets ) . toBe ( 4 )
241
+ expect ( metrics . size ) . toBe ( 3 ) // Max size
242
+ expect ( metrics . evictions ) . toBe ( 1 )
243
+ expect ( metrics . hitRate ) . toBeCloseTo ( 0.5 ) // 1 hit / 2 total
244
+ } )
245
+
246
+ it ( 'should reset metrics on demand' , async ( ) => {
247
+ cache . set ( 'reset-key' , { data : 'test' } )
248
+ await cache . get ( 'reset-key' )
249
+
250
+ let metrics = cache . getMetrics ( )
251
+ expect ( metrics . hits ) . toBe ( 1 )
252
+
253
+ cache . resetMetrics ( )
254
+
255
+ metrics = cache . getMetrics ( )
256
+ expect ( metrics . hits ) . toBe ( 0 )
257
+ expect ( metrics . misses ) . toBe ( 0 )
258
+ expect ( metrics . sets ) . toBe ( 0 )
259
+ } )
260
+
261
+ it ( 'should calculate hit rate correctly' , async ( ) => {
262
+ // Create known hit/miss pattern
263
+ cache . set ( 'hit1' , { data : 'value1' } )
264
+ cache . set ( 'hit2' , { data : 'value2' } )
265
+
266
+ // 2 hits, 3 misses = 2/5 = 0.4 hit rate
267
+ await cache . get ( 'hit1' ) // Hit
268
+ await cache . get ( 'hit2' ) // Hit
269
+ await cache . get ( 'miss1' ) // Miss
270
+ await cache . get ( 'miss2' ) // Miss
271
+ await cache . get ( 'miss3' ) // Miss
272
+
273
+ const metrics = cache . getMetrics ( )
274
+ expect ( metrics . hitRate ) . toBeCloseTo ( 0.4 )
275
+ } )
276
+ } )
277
+
278
+ describe ( 'Cache Management' , ( ) => {
279
+ it ( 'should clear all entries' , async ( ) => {
280
+ cache . set ( 'clear1' , { data : 'value1' } )
281
+ cache . set ( 'clear2' , { data : 'value2' } )
282
+
283
+ expect ( cache . getMetrics ( ) . size ) . toBe ( 2 )
284
+
285
+ cache . clear ( )
286
+
287
+ expect ( cache . getMetrics ( ) . size ) . toBe ( 0 )
288
+ expect ( await cache . get ( 'clear1' ) ) . toBeUndefined ( )
289
+ expect ( await cache . get ( 'clear2' ) ) . toBeUndefined ( )
290
+ } )
291
+
292
+ it ( 'should delete specific entries' , async ( ) => {
293
+ cache . set ( 'delete-key' , { data : 'to-delete' } )
294
+ cache . set ( 'keep-key' , { data : 'to-keep' } )
295
+
296
+ const deleted = cache . delete ( 'delete-key' )
297
+ expect ( deleted ) . toBe ( true )
298
+
299
+ expect ( await cache . get ( 'delete-key' ) ) . toBeUndefined ( )
300
+ expect ( await cache . get ( 'keep-key' ) ) . toBeDefined ( )
301
+
302
+ // Deleting non-existent key
303
+ const notDeleted = cache . delete ( 'non-existent' )
304
+ expect ( notDeleted ) . toBe ( false )
305
+ } )
306
+
307
+ it ( 'should check key existence' , ( ) => {
308
+ cache . set ( 'exists-key' , { data : 'exists' } )
309
+
310
+ expect ( cache . has ( 'exists-key' ) ) . toBe ( true )
311
+ expect ( cache . has ( 'non-existent' ) ) . toBe ( false )
312
+ } )
313
+ } )
314
+
315
+ describe ( 'Error Handling' , ( ) => {
316
+ it ( 'should handle memory pressure gracefully' , ( ) => {
317
+ // Simulate memory-intensive cache operations
318
+ const largeData = { data : 'x' . repeat ( 10000 ) }
319
+
320
+ expect ( ( ) => {
321
+ for ( let i = 0 ; i < 100 ; i ++ ) {
322
+ cache . set ( `large-${ i } ` , { ...largeData , id : i } )
323
+ }
324
+ } ) . not . toThrow ( )
325
+ } )
326
+
327
+ it ( 'should handle concurrent modifications safely' , async ( ) => {
328
+ const operations = [ ]
329
+
330
+ // Concurrent sets
331
+ for ( let i = 0 ; i < 50 ; i ++ ) {
332
+ operations . push (
333
+ new Promise < void > ( resolve => {
334
+ cache . set ( `concurrent-${ i } ` , { data : `value-${ i } ` } )
335
+ resolve ( )
336
+ } )
337
+ )
338
+ }
339
+
340
+ // Concurrent gets
341
+ for ( let i = 0 ; i < 50 ; i ++ ) {
342
+ operations . push ( cache . get ( `concurrent-${ i } ` ) )
343
+ }
344
+
345
+ await expect ( Promise . all ( operations ) ) . resolves . not . toThrow ( )
346
+ } )
347
+
348
+ it ( 'should dispose resources properly' , ( ) => {
349
+ const disposeSpy = jest . spyOn ( cache , 'dispose' )
350
+
351
+ cache . dispose ( )
352
+
353
+ expect ( disposeSpy ) . toHaveBeenCalled ( )
354
+ expect ( logger . info ) . toHaveBeenCalledWith ( 'NetworkDataCache disposed' )
355
+ } )
356
+ } )
357
+
358
+ describe ( 'Configuration Options' , ( ) => {
359
+ it ( 'should respect custom TTL settings' , async ( ) => {
360
+ const customCache = new NetworkDataCache ( logger , {
361
+ ttl : 100 ,
362
+ maxSize : 5 ,
363
+ } )
364
+
365
+ customCache . set ( 'ttl-key' , { data : 'test' } )
366
+
367
+ // Should be available within TTL
368
+ expect ( await customCache . get ( 'ttl-key' ) ) . toBeDefined ( )
369
+
370
+ customCache . dispose ( )
371
+ } )
372
+
373
+ it ( 'should work without metrics when disabled' , ( ) => {
374
+ const noMetricsCache = new NetworkDataCache ( logger , {
375
+ ttl : 1000 ,
376
+ maxSize : 10 ,
377
+ enableMetrics : false ,
378
+ } )
379
+
380
+ noMetricsCache . set ( 'no-metrics' , { data : 'test' } )
381
+
382
+ const metrics = noMetricsCache . getMetrics ( )
383
+ expect ( typeof metrics ) . toBe ( 'object' )
384
+
385
+ noMetricsCache . dispose ( )
386
+ } )
387
+ } )
388
+ } )
0 commit comments