@onlineapps/conn-base-cache 1.0.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.
@@ -0,0 +1,811 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Unit tests for CacheConnector
5
+ * Tests individual methods in isolation with mocked Redis client
6
+ */
7
+
8
+ jest.mock('ioredis');
9
+ const Redis = require('ioredis');
10
+ const CacheConnector = require('../../src/index');
11
+
12
+ describe('CacheConnector - Unit Tests', () => {
13
+ let cacheConnector;
14
+ let mockRedisClient;
15
+
16
+ beforeEach(() => {
17
+ // Create mock Redis client with all methods
18
+ mockRedisClient = {
19
+ get: jest.fn(),
20
+ set: jest.fn(),
21
+ setex: jest.fn(),
22
+ del: jest.fn(),
23
+ exists: jest.fn(),
24
+ expire: jest.fn(),
25
+ ttl: jest.fn(),
26
+ keys: jest.fn(),
27
+ scan: jest.fn(),
28
+ mget: jest.fn(),
29
+ pipeline: jest.fn(),
30
+ incr: jest.fn(),
31
+ decr: jest.fn(),
32
+ incrby: jest.fn(),
33
+ decrby: jest.fn(),
34
+ on: jest.fn(),
35
+ connect: jest.fn().mockResolvedValue(),
36
+ ping: jest.fn().mockResolvedValue('PONG'),
37
+ quit: jest.fn().mockResolvedValue('OK')
38
+ };
39
+
40
+ // Mock Redis constructor
41
+ Redis.mockImplementation(() => mockRedisClient);
42
+ });
43
+
44
+ afterEach(() => {
45
+ jest.clearAllMocks();
46
+ });
47
+
48
+ describe('constructor', () => {
49
+ it('should initialize with default configuration', () => {
50
+ const cache = new CacheConnector();
51
+ expect(cache.config.host).toBe('localhost');
52
+ expect(cache.config.port).toBe(6379);
53
+ expect(cache.config.keyPrefix).toBe('cache:');
54
+ expect(cache.config.defaultTTL).toBe(3600);
55
+ expect(cache.namespace).toBe('');
56
+ expect(cache.connected).toBe(false);
57
+ });
58
+
59
+ it('should use custom configuration', () => {
60
+ const cache = new CacheConnector({
61
+ host: 'redis.example.com',
62
+ port: 6380,
63
+ namespace: 'my-service',
64
+ defaultTTL: 1800
65
+ });
66
+
67
+ expect(cache.config.host).toBe('redis.example.com');
68
+ expect(cache.config.port).toBe(6380);
69
+ expect(cache.namespace).toBe('my-service');
70
+ expect(cache.config.defaultTTL).toBe(1800);
71
+ });
72
+
73
+ it('should use environment variables', () => {
74
+ process.env.REDIS_HOST = 'env-redis';
75
+ process.env.REDIS_PORT = '6381';
76
+
77
+ const cache = new CacheConnector();
78
+
79
+ expect(cache.config.host).toBe('env-redis');
80
+ expect(cache.config.port).toBe('6381');
81
+
82
+ delete process.env.REDIS_HOST;
83
+ delete process.env.REDIS_PORT;
84
+ });
85
+
86
+ it('should initialize statistics', () => {
87
+ const cache = new CacheConnector();
88
+ expect(cache.stats).toEqual({
89
+ hits: 0,
90
+ misses: 0,
91
+ sets: 0,
92
+ deletes: 0,
93
+ errors: 0
94
+ });
95
+ });
96
+ });
97
+
98
+ describe('connect', () => {
99
+ beforeEach(() => {
100
+ cacheConnector = new CacheConnector({
101
+ namespace: 'test-service'
102
+ });
103
+ });
104
+
105
+ it('should establish Redis connection', async () => {
106
+ await cacheConnector.connect();
107
+
108
+ expect(Redis).toHaveBeenCalledWith(
109
+ expect.objectContaining({
110
+ host: 'localhost',
111
+ port: 6379,
112
+ keyPrefix: 'cache:'
113
+ })
114
+ );
115
+ expect(mockRedisClient.connect).toHaveBeenCalled();
116
+ expect(cacheConnector.connected).toBe(true);
117
+ });
118
+
119
+ it('should setup event listeners', async () => {
120
+ await cacheConnector.connect();
121
+
122
+ expect(mockRedisClient.on).toHaveBeenCalledWith('connect', expect.any(Function));
123
+ expect(mockRedisClient.on).toHaveBeenCalledWith('error', expect.any(Function));
124
+ expect(mockRedisClient.on).toHaveBeenCalledWith('close', expect.any(Function));
125
+ });
126
+
127
+ it('should not reconnect if already connected', async () => {
128
+ await cacheConnector.connect();
129
+ jest.clearAllMocks();
130
+
131
+ const result = await cacheConnector.connect();
132
+
133
+ expect(Redis).not.toHaveBeenCalled();
134
+ expect(result).toBe(true);
135
+ });
136
+
137
+ it('should handle connection errors', async () => {
138
+ mockRedisClient.connect.mockRejectedValueOnce(new Error('Connection refused'));
139
+
140
+ await expect(cacheConnector.connect()).rejects.toThrow('Failed to connect to Redis');
141
+ });
142
+ });
143
+
144
+ describe('disconnect', () => {
145
+ beforeEach(async () => {
146
+ cacheConnector = new CacheConnector({
147
+ namespace: 'test-service'
148
+ });
149
+ await cacheConnector.connect();
150
+ });
151
+
152
+ it('should close Redis connection', async () => {
153
+ await cacheConnector.disconnect();
154
+
155
+ expect(mockRedisClient.quit).toHaveBeenCalled();
156
+ expect(cacheConnector.connected).toBe(false);
157
+ expect(cacheConnector.client).toBe(null);
158
+ });
159
+
160
+ it('should handle disconnect when not connected', async () => {
161
+ cacheConnector.connected = false;
162
+ cacheConnector.client = null;
163
+
164
+ await cacheConnector.disconnect();
165
+
166
+ expect(mockRedisClient.quit).not.toHaveBeenCalled();
167
+ });
168
+ });
169
+
170
+ describe('get', () => {
171
+ beforeEach(async () => {
172
+ cacheConnector = new CacheConnector({
173
+ namespace: 'test-service'
174
+ });
175
+ await cacheConnector.connect();
176
+ });
177
+
178
+ it('should get and deserialize JSON value', async () => {
179
+ mockRedisClient.get.mockResolvedValue(JSON.stringify({ data: 'test' }));
180
+
181
+ const result = await cacheConnector.get('key1');
182
+
183
+ expect(mockRedisClient.get).toHaveBeenCalledWith('test-service:key1');
184
+ expect(result).toEqual({ data: 'test' });
185
+ expect(cacheConnector.stats.hits).toBe(1);
186
+ });
187
+
188
+ it('should return string value as-is', async () => {
189
+ mockRedisClient.get.mockResolvedValue('plain string');
190
+
191
+ const result = await cacheConnector.get('key2');
192
+
193
+ expect(result).toBe('plain string');
194
+ expect(cacheConnector.stats.hits).toBe(1);
195
+ });
196
+
197
+ it('should return null for missing key', async () => {
198
+ mockRedisClient.get.mockResolvedValue(null);
199
+
200
+ const result = await cacheConnector.get('missing');
201
+
202
+ expect(result).toBe(null);
203
+ expect(cacheConnector.stats.misses).toBe(1);
204
+ });
205
+
206
+ it('should handle get errors', async () => {
207
+ mockRedisClient.get.mockRejectedValue(new Error('Redis error'));
208
+
209
+ await expect(cacheConnector.get('key')).rejects.toThrow('Cache get failed');
210
+ expect(cacheConnector.stats.errors).toBe(1);
211
+ });
212
+
213
+ it('should work without namespace', async () => {
214
+ const cache = new CacheConnector();
215
+ await cache.connect();
216
+ cache.client = mockRedisClient;
217
+
218
+ mockRedisClient.get.mockResolvedValue('"value"');
219
+ await cache.get('key');
220
+
221
+ expect(mockRedisClient.get).toHaveBeenCalledWith('key');
222
+ });
223
+ });
224
+
225
+ describe('set', () => {
226
+ beforeEach(async () => {
227
+ cacheConnector = new CacheConnector({
228
+ namespace: 'test-service',
229
+ defaultTTL: 3600
230
+ });
231
+ await cacheConnector.connect();
232
+ });
233
+
234
+ it('should set value with TTL', async () => {
235
+ mockRedisClient.setex.mockResolvedValue('OK');
236
+
237
+ const result = await cacheConnector.set('key1', { data: 'test' }, 300);
238
+
239
+ expect(mockRedisClient.setex).toHaveBeenCalledWith(
240
+ 'test-service:key1',
241
+ 300,
242
+ JSON.stringify({ data: 'test' })
243
+ );
244
+ expect(result).toBe(true);
245
+ expect(cacheConnector.stats.sets).toBe(1);
246
+ });
247
+
248
+ it('should use default TTL if not provided', async () => {
249
+ mockRedisClient.setex.mockResolvedValue('OK');
250
+
251
+ await cacheConnector.set('key2', 'value');
252
+
253
+ expect(mockRedisClient.setex).toHaveBeenCalledWith(
254
+ 'test-service:key2',
255
+ 3600,
256
+ 'value' // strings are not JSON serialized
257
+ );
258
+ });
259
+
260
+ it('should use default TTL with TTL 0', async () => {
261
+ mockRedisClient.setex.mockResolvedValue('OK');
262
+
263
+ await cacheConnector.set('permanent', 'forever', 0);
264
+
265
+ expect(mockRedisClient.setex).toHaveBeenCalledWith(
266
+ 'test-service:permanent',
267
+ 3600, // uses default TTL
268
+ 'forever' // strings are not JSON serialized
269
+ );
270
+ });
271
+
272
+ it('should handle string values', async () => {
273
+ mockRedisClient.setex.mockResolvedValue('OK');
274
+
275
+ await cacheConnector.set('string', 'plain text', 100);
276
+
277
+ expect(mockRedisClient.setex).toHaveBeenCalledWith(
278
+ 'test-service:string',
279
+ 100,
280
+ 'plain text'
281
+ );
282
+ });
283
+
284
+ it('should handle set errors', async () => {
285
+ mockRedisClient.setex.mockRejectedValue(new Error('Redis error'));
286
+
287
+ await expect(cacheConnector.set('key', 'value')).rejects.toThrow('Cache set failed');
288
+ expect(cacheConnector.stats.errors).toBe(1);
289
+ });
290
+ });
291
+
292
+ describe('delete', () => {
293
+ beforeEach(async () => {
294
+ cacheConnector = new CacheConnector({
295
+ namespace: 'test-service'
296
+ });
297
+ await cacheConnector.connect();
298
+ });
299
+
300
+ it('should delete single key', async () => {
301
+ mockRedisClient.del.mockResolvedValue(1);
302
+
303
+ const result = await cacheConnector.delete('key1');
304
+
305
+ expect(mockRedisClient.del).toHaveBeenCalledWith('test-service:key1');
306
+ expect(result).toBe(true);
307
+ expect(cacheConnector.stats.deletes).toBe(1);
308
+ });
309
+
310
+ it('should return false for non-existent key', async () => {
311
+ mockRedisClient.del.mockResolvedValue(0);
312
+
313
+ const result = await cacheConnector.delete('missing');
314
+
315
+ expect(result).toBe(false);
316
+ expect(cacheConnector.stats.deletes).toBe(1);
317
+ });
318
+
319
+ it('should handle delete errors', async () => {
320
+ mockRedisClient.del.mockRejectedValue(new Error('Redis error'));
321
+
322
+ await expect(cacheConnector.delete('key')).rejects.toThrow('Cache delete failed');
323
+ expect(cacheConnector.stats.errors).toBe(1);
324
+ });
325
+ });
326
+
327
+ describe('deleteByPattern', () => {
328
+ beforeEach(async () => {
329
+ cacheConnector = new CacheConnector({
330
+ namespace: 'test-service'
331
+ });
332
+ await cacheConnector.connect();
333
+ });
334
+
335
+ it('should delete keys matching pattern', async () => {
336
+ mockRedisClient.keys.mockResolvedValue([
337
+ 'cache:test-service:user:1',
338
+ 'cache:test-service:user:2'
339
+ ]);
340
+ mockRedisClient.del.mockResolvedValue(2);
341
+
342
+ const result = await cacheConnector.deleteByPattern('user:*');
343
+
344
+ expect(mockRedisClient.keys).toHaveBeenCalledWith('cache:test-service:user:*');
345
+ expect(mockRedisClient.del).toHaveBeenCalledWith(
346
+ 'test-service:user:1',
347
+ 'test-service:user:2'
348
+ );
349
+ expect(result).toBe(2);
350
+ expect(cacheConnector.stats.deletes).toBe(2);
351
+ });
352
+
353
+ it('should handle no matching keys', async () => {
354
+ mockRedisClient.keys.mockResolvedValue([]);
355
+
356
+ const result = await cacheConnector.deleteByPattern('nomatch:*');
357
+
358
+ expect(result).toBe(0);
359
+ expect(mockRedisClient.del).not.toHaveBeenCalled();
360
+ });
361
+
362
+ it('should handle pattern errors', async () => {
363
+ mockRedisClient.keys.mockRejectedValue(new Error('Redis error'));
364
+
365
+ await expect(cacheConnector.deleteByPattern('*')).rejects.toThrow('Pattern delete failed');
366
+ expect(cacheConnector.stats.errors).toBe(1);
367
+ });
368
+ });
369
+
370
+ describe('exists', () => {
371
+ beforeEach(async () => {
372
+ cacheConnector = new CacheConnector({
373
+ namespace: 'test-service'
374
+ });
375
+ await cacheConnector.connect();
376
+ });
377
+
378
+ it('should check if key exists', async () => {
379
+ mockRedisClient.exists.mockResolvedValue(1);
380
+
381
+ const result = await cacheConnector.exists('key1');
382
+
383
+ expect(mockRedisClient.exists).toHaveBeenCalledWith('test-service:key1');
384
+ expect(result).toBe(true);
385
+ });
386
+
387
+ it('should return false for non-existent key', async () => {
388
+ mockRedisClient.exists.mockResolvedValue(0);
389
+
390
+ const result = await cacheConnector.exists('missing');
391
+
392
+ expect(result).toBe(false);
393
+ });
394
+
395
+ it('should handle exists errors', async () => {
396
+ mockRedisClient.exists.mockRejectedValue(new Error('Redis error'));
397
+
398
+ await expect(cacheConnector.exists('key')).rejects.toThrow('Cache exists check failed');
399
+ expect(cacheConnector.stats.errors).toBe(1);
400
+ });
401
+ });
402
+
403
+ describe('ttl', () => {
404
+ beforeEach(async () => {
405
+ cacheConnector = new CacheConnector({
406
+ namespace: 'test-service'
407
+ });
408
+ await cacheConnector.connect();
409
+ });
410
+
411
+ it('should get TTL for key', async () => {
412
+ mockRedisClient.ttl.mockResolvedValue(300);
413
+
414
+ const result = await cacheConnector.ttl('key1');
415
+
416
+ expect(mockRedisClient.ttl).toHaveBeenCalledWith('test-service:key1');
417
+ expect(result).toBe(300);
418
+ });
419
+
420
+ it('should return -1 for non-expiring key', async () => {
421
+ mockRedisClient.ttl.mockResolvedValue(-1);
422
+
423
+ const result = await cacheConnector.ttl('permanent');
424
+
425
+ expect(result).toBe(-1);
426
+ });
427
+
428
+ it('should return -2 for non-existent key', async () => {
429
+ mockRedisClient.ttl.mockResolvedValue(-2);
430
+
431
+ const result = await cacheConnector.ttl('missing');
432
+
433
+ expect(result).toBe(-2);
434
+ });
435
+
436
+ it('should handle ttl errors', async () => {
437
+ mockRedisClient.ttl.mockRejectedValue(new Error('Redis error'));
438
+
439
+ await expect(cacheConnector.ttl('key')).rejects.toThrow('TTL check failed');
440
+ expect(cacheConnector.stats.errors).toBe(1);
441
+ });
442
+ });
443
+
444
+ describe('expire', () => {
445
+ beforeEach(async () => {
446
+ cacheConnector = new CacheConnector({
447
+ namespace: 'test-service'
448
+ });
449
+ await cacheConnector.connect();
450
+ });
451
+
452
+ it('should set expiration on key', async () => {
453
+ mockRedisClient.expire.mockResolvedValue(1);
454
+
455
+ const result = await cacheConnector.expire('key1', 600);
456
+
457
+ expect(mockRedisClient.expire).toHaveBeenCalledWith('test-service:key1', 600);
458
+ expect(result).toBe(true);
459
+ });
460
+
461
+ it('should return false for non-existent key', async () => {
462
+ mockRedisClient.expire.mockResolvedValue(0);
463
+
464
+ const result = await cacheConnector.expire('missing', 600);
465
+
466
+ expect(result).toBe(false);
467
+ });
468
+
469
+ it('should handle expire errors', async () => {
470
+ mockRedisClient.expire.mockRejectedValue(new Error('Redis error'));
471
+
472
+ await expect(cacheConnector.expire('key', 100)).rejects.toThrow('Expire failed');
473
+ expect(cacheConnector.stats.errors).toBe(1);
474
+ });
475
+ });
476
+
477
+ describe('incr/decr', () => {
478
+ beforeEach(async () => {
479
+ cacheConnector = new CacheConnector({
480
+ namespace: 'test-service'
481
+ });
482
+ await cacheConnector.connect();
483
+ });
484
+
485
+ it('should increment value by 1', async () => {
486
+ mockRedisClient.incr.mockResolvedValue(11);
487
+
488
+ const result = await cacheConnector.incr('counter');
489
+
490
+ expect(mockRedisClient.incr).toHaveBeenCalledWith('test-service:counter');
491
+ expect(result).toBe(11);
492
+ });
493
+
494
+ it('should increment value by custom amount', async () => {
495
+ mockRedisClient.incrby.mockResolvedValue(15);
496
+
497
+ const result = await cacheConnector.incr('counter', 5);
498
+
499
+ expect(mockRedisClient.incrby).toHaveBeenCalledWith('test-service:counter', 5);
500
+ expect(result).toBe(15);
501
+ });
502
+
503
+ it('should decrement value by 1', async () => {
504
+ mockRedisClient.decr.mockResolvedValue(9);
505
+
506
+ const result = await cacheConnector.decr('counter');
507
+
508
+ expect(mockRedisClient.decr).toHaveBeenCalledWith('test-service:counter');
509
+ expect(result).toBe(9);
510
+ });
511
+
512
+ it('should decrement value by custom amount', async () => {
513
+ mockRedisClient.decrby.mockResolvedValue(5);
514
+
515
+ const result = await cacheConnector.decr('counter', 3);
516
+
517
+ expect(mockRedisClient.decrby).toHaveBeenCalledWith('test-service:counter', 3);
518
+ expect(result).toBe(5);
519
+ });
520
+
521
+ it('should handle increment by default 1', async () => {
522
+ mockRedisClient.incr.mockResolvedValue(1);
523
+
524
+ const result = await cacheConnector.incr('new');
525
+
526
+ expect(mockRedisClient.incr).toHaveBeenCalledWith('test-service:new');
527
+ expect(result).toBe(1);
528
+ });
529
+
530
+ it('should handle incr errors', async () => {
531
+ mockRedisClient.incr.mockRejectedValue(new Error('Redis error'));
532
+
533
+ await expect(cacheConnector.incr('key')).rejects.toThrow('Increment failed');
534
+ expect(cacheConnector.stats.errors).toBe(1);
535
+ });
536
+
537
+ it('should handle decr errors', async () => {
538
+ mockRedisClient.decr.mockRejectedValue(new Error('Redis error'));
539
+
540
+ await expect(cacheConnector.decr('key')).rejects.toThrow('Decrement failed');
541
+ expect(cacheConnector.stats.errors).toBe(1);
542
+ });
543
+ });
544
+
545
+ describe('mget/mset', () => {
546
+ beforeEach(async () => {
547
+ cacheConnector = new CacheConnector({
548
+ namespace: 'test-service'
549
+ });
550
+ await cacheConnector.connect();
551
+ });
552
+
553
+ it('should get multiple values', async () => {
554
+ mockRedisClient.mget.mockResolvedValue([
555
+ JSON.stringify({ data: 'value1' }),
556
+ null,
557
+ JSON.stringify('value3')
558
+ ]);
559
+
560
+ const result = await cacheConnector.mget(['key1', 'key2', 'key3']);
561
+
562
+ expect(mockRedisClient.mget).toHaveBeenCalledWith(
563
+ 'test-service:key1',
564
+ 'test-service:key2',
565
+ 'test-service:key3'
566
+ );
567
+ expect(result).toEqual({
568
+ key1: { data: 'value1' },
569
+ key3: 'value3'
570
+ });
571
+ });
572
+
573
+ it('should set multiple values', async () => {
574
+ const mockPipeline = {
575
+ setex: jest.fn().mockReturnThis(),
576
+ exec: jest.fn().mockResolvedValue([])
577
+ };
578
+ mockRedisClient.pipeline = jest.fn().mockReturnValue(mockPipeline);
579
+
580
+ const result = await cacheConnector.mset({
581
+ key1: 'value1',
582
+ key2: { data: 'value2' }
583
+ }, 300);
584
+
585
+ expect(mockRedisClient.pipeline).toHaveBeenCalled();
586
+ expect(mockPipeline.setex).toHaveBeenCalledWith(
587
+ 'test-service:key1', 300, 'value1'
588
+ );
589
+ expect(mockPipeline.setex).toHaveBeenCalledWith(
590
+ 'test-service:key2', 300, JSON.stringify({ data: 'value2' })
591
+ );
592
+ expect(mockPipeline.exec).toHaveBeenCalled();
593
+ expect(result).toBe(true);
594
+ expect(cacheConnector.stats.sets).toBe(2);
595
+ });
596
+
597
+ it('should handle mget errors', async () => {
598
+ mockRedisClient.mget.mockRejectedValue(new Error('Redis error'));
599
+
600
+ await expect(cacheConnector.mget(['key'])).rejects.toThrow('Multi-get failed');
601
+ expect(cacheConnector.stats.errors).toBe(1);
602
+ });
603
+
604
+ it('should handle mset errors', async () => {
605
+ const mockPipeline = {
606
+ setex: jest.fn().mockReturnThis(),
607
+ exec: jest.fn().mockRejectedValue(new Error('Redis error'))
608
+ };
609
+ mockRedisClient.pipeline = jest.fn().mockReturnValue(mockPipeline);
610
+
611
+ await expect(cacheConnector.mset({ key: 'value' })).rejects.toThrow('Multi-set failed');
612
+ expect(cacheConnector.stats.errors).toBe(1);
613
+ });
614
+ });
615
+
616
+ describe('flush', () => {
617
+ beforeEach(async () => {
618
+ cacheConnector = new CacheConnector({
619
+ namespace: 'test-service'
620
+ });
621
+ await cacheConnector.connect();
622
+ });
623
+
624
+ it('should flush database with confirmation', async () => {
625
+ mockRedisClient.keys.mockResolvedValue([
626
+ 'cache:test-service:key1',
627
+ 'cache:test-service:key2'
628
+ ]);
629
+ mockRedisClient.del.mockResolvedValue(2);
630
+
631
+ const result = await cacheConnector.flush(true);
632
+
633
+ expect(mockRedisClient.keys).toHaveBeenCalledWith('cache:*');
634
+ expect(mockRedisClient.del).toHaveBeenCalledWith(
635
+ 'test-service:key1',
636
+ 'test-service:key2'
637
+ );
638
+ expect(result).toBe(true);
639
+ });
640
+
641
+ it('should throw without confirmation', async () => {
642
+ await expect(cacheConnector.flush(false)).rejects.toThrow('Flush requires confirmation parameter to be true');
643
+ });
644
+
645
+ it('should throw without any parameter', async () => {
646
+ await expect(cacheConnector.flush()).rejects.toThrow('Flush requires confirmation parameter to be true');
647
+ });
648
+
649
+ it('should handle flush errors', async () => {
650
+ mockRedisClient.keys.mockRejectedValue(new Error('Redis error'));
651
+
652
+ await expect(cacheConnector.flush(true)).rejects.toThrow('Flush failed');
653
+ expect(cacheConnector.stats.errors).toBe(1);
654
+ });
655
+ });
656
+
657
+ describe('getStats', () => {
658
+ it('should return cache statistics', () => {
659
+ const cache = new CacheConnector();
660
+ cache.stats = {
661
+ hits: 10,
662
+ misses: 5,
663
+ sets: 15,
664
+ deletes: 3,
665
+ errors: 1
666
+ };
667
+
668
+ const stats = cache.getStats();
669
+
670
+ expect(stats.hits).toBe(10);
671
+ expect(stats.misses).toBe(5);
672
+ expect(stats.sets).toBe(15);
673
+ expect(stats.deletes).toBe(3);
674
+ expect(stats.errors).toBe(1);
675
+ // hitRate should be formatted as percentage string
676
+ expect(stats.hitRate).toBe('66.67%');
677
+ });
678
+
679
+ it('should handle zero operations', () => {
680
+ const cache = new CacheConnector();
681
+
682
+ const stats = cache.getStats();
683
+
684
+ expect(stats.hitRate).toBe('0%');
685
+ });
686
+
687
+ it('should handle only hits', () => {
688
+ const cache = new CacheConnector();
689
+ cache.stats.hits = 10;
690
+
691
+ const stats = cache.getStats();
692
+
693
+ expect(stats.hitRate).toBe('100.00%');
694
+ });
695
+ });
696
+
697
+ describe('resetStats', () => {
698
+ it('should reset all statistics', () => {
699
+ const cache = new CacheConnector();
700
+ cache.stats = {
701
+ hits: 10,
702
+ misses: 5,
703
+ sets: 15,
704
+ deletes: 3,
705
+ errors: 1
706
+ };
707
+
708
+ cache.resetStats();
709
+
710
+ expect(cache.stats).toEqual({
711
+ hits: 0,
712
+ misses: 0,
713
+ sets: 0,
714
+ deletes: 0,
715
+ errors: 0
716
+ });
717
+ });
718
+ });
719
+
720
+ describe('healthCheck', () => {
721
+ beforeEach(async () => {
722
+ cacheConnector = new CacheConnector();
723
+ await cacheConnector.connect();
724
+ });
725
+
726
+ it('should return true when healthy', async () => {
727
+ mockRedisClient.ping.mockResolvedValue('PONG');
728
+
729
+ const result = await cacheConnector.healthCheck();
730
+
731
+ expect(result).toBe(true);
732
+ });
733
+
734
+ it('should return false when unhealthy', async () => {
735
+ mockRedisClient.ping.mockRejectedValue(new Error('Connection lost'));
736
+
737
+ const result = await cacheConnector.healthCheck();
738
+
739
+ expect(result).toBe(false);
740
+ });
741
+
742
+ it('should not throw when client is null', async () => {
743
+ cacheConnector.client = null;
744
+
745
+ const result = await cacheConnector.healthCheck();
746
+
747
+ expect(result).toBe(false);
748
+ });
749
+ });
750
+
751
+ describe('static methods', () => {
752
+ it('should create instance with create method', () => {
753
+ const cache = CacheConnector.create({
754
+ namespace: 'static-test'
755
+ });
756
+
757
+ expect(cache).toBeInstanceOf(CacheConnector);
758
+ expect(cache.namespace).toBe('static-test');
759
+ });
760
+ });
761
+
762
+ describe('withNamespace', () => {
763
+ it('should create new instance with combined namespace', async () => {
764
+ const cache1 = new CacheConnector({ namespace: 'service1' });
765
+ const cache2 = cache1.withNamespace('subsection');
766
+
767
+ expect(cache2).toBeInstanceOf(CacheConnector);
768
+ expect(cache2.namespace).toBe('service1:subsection');
769
+ expect(cache1.namespace).toBe('service1');
770
+ expect(cache2).not.toBe(cache1);
771
+ });
772
+ });
773
+
774
+ describe('wrap function', () => {
775
+ beforeEach(async () => {
776
+ cacheConnector = new CacheConnector({ namespace: 'test' });
777
+ await cacheConnector.connect();
778
+ });
779
+
780
+ it('should cache function results', async () => {
781
+ const expensiveFn = jest.fn().mockResolvedValue({ data: 'result' });
782
+ const wrapped = cacheConnector.wrap(expensiveFn, {
783
+ keyPrefix: 'fn',
784
+ ttl: 300
785
+ });
786
+
787
+ mockRedisClient.get.mockResolvedValueOnce(null);
788
+ mockRedisClient.setex.mockResolvedValue('OK');
789
+
790
+ // First call - cache miss
791
+ const result1 = await wrapped('arg1', 'arg2');
792
+ expect(expensiveFn).toHaveBeenCalledWith('arg1', 'arg2');
793
+ expect(result1).toEqual({ data: 'result' });
794
+
795
+ // Second call - cache hit
796
+ mockRedisClient.get.mockResolvedValueOnce(JSON.stringify({ data: 'result' }));
797
+ const result2 = await wrapped('arg1', 'arg2');
798
+ expect(expensiveFn).toHaveBeenCalledTimes(1);
799
+ expect(result2).toEqual({ data: 'result' });
800
+ });
801
+
802
+ it('should handle wrap errors', async () => {
803
+ const failingFn = jest.fn().mockRejectedValue(new Error('Function failed'));
804
+ const wrapped = cacheConnector.wrap(failingFn);
805
+
806
+ mockRedisClient.get.mockResolvedValue(null);
807
+
808
+ await expect(wrapped('arg')).rejects.toThrow('Function failed');
809
+ });
810
+ });
811
+ });