@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,428 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Component tests for CacheConnector
5
+ * Tests cache operations with in-memory Redis simulation
6
+ */
7
+
8
+ const CacheConnector = require('../../src/index');
9
+
10
+ // In-memory Redis simulation for component testing
11
+ class RedisSimulator {
12
+ constructor() {
13
+ this.store = new Map();
14
+ this.expiry = new Map();
15
+ this.connected = false;
16
+ }
17
+
18
+ async connect() {
19
+ this.connected = true;
20
+ return 'OK';
21
+ }
22
+
23
+ async disconnect() {
24
+ this.connected = false;
25
+ this.store.clear();
26
+ this.expiry.clear();
27
+ return 'OK';
28
+ }
29
+
30
+ async get(key) {
31
+ if (!this.connected) throw new Error('Not connected');
32
+
33
+ // Check if key expired
34
+ if (this.expiry.has(key)) {
35
+ const expiryTime = this.expiry.get(key);
36
+ if (Date.now() > expiryTime) {
37
+ this.store.delete(key);
38
+ this.expiry.delete(key);
39
+ return null;
40
+ }
41
+ }
42
+
43
+ return this.store.get(key) || null;
44
+ }
45
+
46
+ async setex(key, ttl, value) {
47
+ if (!this.connected) throw new Error('Not connected');
48
+
49
+ this.store.set(key, value);
50
+ this.expiry.set(key, Date.now() + (ttl * 1000));
51
+ return 'OK';
52
+ }
53
+
54
+ async del(...keys) {
55
+ if (!this.connected) throw new Error('Not connected');
56
+
57
+ let deleted = 0;
58
+ for (const key of keys) {
59
+ if (this.store.has(key)) {
60
+ this.store.delete(key);
61
+ this.expiry.delete(key);
62
+ deleted++;
63
+ }
64
+ }
65
+ return deleted;
66
+ }
67
+
68
+ async exists(key) {
69
+ if (!this.connected) throw new Error('Not connected');
70
+ return this.store.has(key) ? 1 : 0;
71
+ }
72
+
73
+ async ttl(key) {
74
+ if (!this.connected) throw new Error('Not connected');
75
+
76
+ if (!this.store.has(key)) return -2;
77
+ if (!this.expiry.has(key)) return -1;
78
+
79
+ const remaining = Math.floor((this.expiry.get(key) - Date.now()) / 1000);
80
+ return remaining > 0 ? remaining : -2;
81
+ }
82
+
83
+ async keys(pattern) {
84
+ if (!this.connected) throw new Error('Not connected');
85
+
86
+ const regex = new RegExp(pattern.replace(/\*/g, '.*'));
87
+ return Array.from(this.store.keys()).filter(key => regex.test(key));
88
+ }
89
+
90
+ async mget(...keys) {
91
+ if (!this.connected) throw new Error('Not connected');
92
+ return keys.map(key => this.store.get(key) || null);
93
+ }
94
+
95
+ async ping() {
96
+ if (!this.connected) throw new Error('Not connected');
97
+ return 'PONG';
98
+ }
99
+
100
+ on(event, handler) {
101
+ // Simulate event handling
102
+ if (event === 'connect' && this.connected) {
103
+ setTimeout(() => handler(), 0);
104
+ }
105
+ }
106
+
107
+ async quit() {
108
+ return this.disconnect();
109
+ }
110
+ }
111
+
112
+ describe('CacheConnector - Component Tests', () => {
113
+ let cacheConnector;
114
+ let redisSimulator;
115
+
116
+ beforeEach(() => {
117
+ redisSimulator = new RedisSimulator();
118
+
119
+ // Inject Redis simulator
120
+ cacheConnector = new CacheConnector({
121
+ host: 'localhost',
122
+ port: 6379,
123
+ namespace: 'test-service',
124
+ defaultTTL: 60
125
+ });
126
+
127
+ // Replace Redis client with simulator
128
+ cacheConnector.client = redisSimulator;
129
+ });
130
+
131
+ afterEach(async () => {
132
+ if (cacheConnector.connected) {
133
+ await cacheConnector.disconnect();
134
+ }
135
+ });
136
+
137
+ describe('Basic Cache Operations', () => {
138
+ beforeEach(async () => {
139
+ await redisSimulator.connect();
140
+ cacheConnector.connected = true;
141
+ });
142
+
143
+ it('should perform set and get operations', async () => {
144
+ const testData = {
145
+ id: 123,
146
+ name: 'Test User',
147
+ email: 'test@example.com'
148
+ };
149
+
150
+ await cacheConnector.set('user:123', testData, 300);
151
+ const retrieved = await cacheConnector.get('user:123');
152
+
153
+ expect(retrieved).toEqual(testData);
154
+ });
155
+
156
+ it('should handle different data types', async () => {
157
+ // String
158
+ await cacheConnector.set('string', 'test string');
159
+ expect(await cacheConnector.get('string')).toBe('test string');
160
+
161
+ // Number
162
+ await cacheConnector.set('number', 42);
163
+ expect(await cacheConnector.get('number')).toBe(42);
164
+
165
+ // Boolean
166
+ await cacheConnector.set('boolean', true);
167
+ expect(await cacheConnector.get('boolean')).toBe(true);
168
+
169
+ // Array
170
+ await cacheConnector.set('array', [1, 2, 3]);
171
+ expect(await cacheConnector.get('array')).toEqual([1, 2, 3]);
172
+
173
+ // Object
174
+ await cacheConnector.set('object', { nested: { value: 'test' } });
175
+ expect(await cacheConnector.get('object')).toEqual({ nested: { value: 'test' } });
176
+ });
177
+
178
+ it('should respect TTL settings', async () => {
179
+ await cacheConnector.set('ttl-test', 'value', 1); // 1 second TTL
180
+
181
+ // Should exist immediately
182
+ expect(await cacheConnector.get('ttl-test')).toBe('value');
183
+
184
+ // Wait for expiry
185
+ await new Promise(resolve => setTimeout(resolve, 1100));
186
+
187
+ // Should be expired
188
+ expect(await cacheConnector.get('ttl-test')).toBe(null);
189
+ });
190
+ });
191
+
192
+ describe('Namespace Management', () => {
193
+ beforeEach(async () => {
194
+ await redisSimulator.connect();
195
+ cacheConnector.connected = true;
196
+ });
197
+
198
+ it('should isolate data by namespace', async () => {
199
+ // First service
200
+ await cacheConnector.set('key', 'value1');
201
+
202
+ // Second service with different namespace
203
+ const cache2 = new CacheConnector({
204
+ namespace: 'other-service'
205
+ });
206
+ cache2.client = redisSimulator;
207
+ cache2.connected = true;
208
+
209
+ await cache2.set('key', 'value2');
210
+
211
+ // Values should be isolated
212
+ expect(await cacheConnector.get('key')).toBe('value1');
213
+ expect(await cache2.get('key')).toBe('value2');
214
+ });
215
+
216
+ it('should clear only namespace-specific keys', async () => {
217
+ await cacheConnector.set('key1', 'value1');
218
+ await cacheConnector.set('key2', 'value2');
219
+
220
+ // Add key from different namespace directly to simulator
221
+ await redisSimulator.setex('other:key', 60, 'other');
222
+
223
+ // Delete all keys in our namespace
224
+ let cleared = 0;
225
+ if (await cacheConnector.delete('key1')) cleared++;
226
+ if (await cacheConnector.delete('key2')) cleared++;
227
+
228
+ expect(cleared).toBe(2); // Only namespace keys
229
+ expect(await redisSimulator.get('other:key')).toBe('other');
230
+ });
231
+ });
232
+
233
+ describe('Batch Operations', () => {
234
+ beforeEach(async () => {
235
+ await redisSimulator.connect();
236
+ cacheConnector.connected = true;
237
+ });
238
+
239
+ it('should delete multiple keys', async () => {
240
+ await cacheConnector.set('del1', 'value1');
241
+ await cacheConnector.set('del2', 'value2');
242
+ await cacheConnector.set('del3', 'value3');
243
+
244
+ // Delete multiple keys - implementation doesn't have bulk delete
245
+ let deleted = 0;
246
+ if (await cacheConnector.delete('del1')) deleted++;
247
+ if (await cacheConnector.delete('del2')) deleted++;
248
+
249
+ expect(deleted).toBe(2);
250
+ expect(await cacheConnector.get('del1')).toBe(null);
251
+ expect(await cacheConnector.get('del2')).toBe(null);
252
+ expect(await cacheConnector.get('del3')).toBe('value3');
253
+ });
254
+
255
+ it('should get multiple values', async () => {
256
+ await cacheConnector.set('multi1', 'value1');
257
+ await cacheConnector.set('multi2', 'value2');
258
+ await cacheConnector.set('multi3', 'value3');
259
+
260
+ const values = await cacheConnector.mget(['multi1', 'multi2', 'missing']);
261
+
262
+ expect(values).toEqual({
263
+ multi1: 'value1',
264
+ multi2: 'value2'
265
+ // missing key is not included in result
266
+ });
267
+ });
268
+ });
269
+
270
+ describe('Cache Patterns', () => {
271
+ beforeEach(async () => {
272
+ await redisSimulator.connect();
273
+ cacheConnector.connected = true;
274
+ });
275
+
276
+ it('should implement cache-aside pattern', async () => {
277
+ const fetchData = jest.fn().mockResolvedValue({ expensive: 'data' });
278
+
279
+ // Function to get with cache
280
+ const getWithCache = async (key) => {
281
+ let data = await cacheConnector.get(key);
282
+ if (!data) {
283
+ data = await fetchData(key);
284
+ await cacheConnector.set(key, data, 300);
285
+ }
286
+ return data;
287
+ };
288
+
289
+ // First call - fetches from source
290
+ const result1 = await getWithCache('expensive');
291
+ expect(fetchData).toHaveBeenCalledTimes(1);
292
+ expect(result1).toEqual({ expensive: 'data' });
293
+
294
+ // Second call - from cache
295
+ const result2 = await getWithCache('expensive');
296
+ expect(fetchData).toHaveBeenCalledTimes(1); // Not called again
297
+ expect(result2).toEqual({ expensive: 'data' });
298
+ });
299
+
300
+ it('should handle cache stampede prevention', async () => {
301
+ const locks = new Set();
302
+
303
+ const getWithLock = async (key, fetchFn) => {
304
+ if (locks.has(key)) {
305
+ // Wait for lock to release
306
+ await new Promise(resolve => setTimeout(resolve, 10));
307
+ return cacheConnector.get(key);
308
+ }
309
+
310
+ locks.add(key);
311
+ try {
312
+ let data = await cacheConnector.get(key);
313
+ if (!data) {
314
+ data = await fetchFn();
315
+ await cacheConnector.set(key, data);
316
+ }
317
+ return data;
318
+ } finally {
319
+ locks.delete(key);
320
+ }
321
+ };
322
+
323
+ const fetchFn = jest.fn().mockResolvedValue('fetched');
324
+
325
+ // Simulate concurrent requests
326
+ const results = await Promise.all([
327
+ getWithLock('stampede', fetchFn),
328
+ getWithLock('stampede', fetchFn),
329
+ getWithLock('stampede', fetchFn)
330
+ ]);
331
+
332
+ // Only one fetch should occur
333
+ expect(fetchFn).toHaveBeenCalledTimes(1);
334
+ expect(results).toEqual(['fetched', 'fetched', 'fetched']);
335
+ });
336
+ });
337
+
338
+ describe('Error Handling', () => {
339
+ it('should handle connection failures gracefully', async () => {
340
+ // Simulate disconnected Redis client
341
+ redisSimulator.connected = false;
342
+
343
+ await expect(
344
+ cacheConnector.set('key', 'value')
345
+ ).rejects.toThrow('Not connected');
346
+
347
+ await expect(
348
+ cacheConnector.get('key')
349
+ ).rejects.toThrow('Not connected');
350
+ });
351
+
352
+ it('should handle serialization errors', async () => {
353
+ await redisSimulator.connect();
354
+ cacheConnector.connected = true;
355
+
356
+ // Create circular reference
357
+ const circular = { a: 1 };
358
+ circular.self = circular;
359
+
360
+ await expect(
361
+ cacheConnector.set('circular', circular)
362
+ ).rejects.toThrow();
363
+
364
+ expect(cacheConnector.stats.errors).toBe(1);
365
+ });
366
+
367
+ it('should handle non-JSON data as string', async () => {
368
+ await redisSimulator.connect();
369
+ cacheConnector.connected = true;
370
+
371
+ // Insert non-JSON data using the correct key
372
+ await redisSimulator.setex('test-service:text', 60, 'plain text');
373
+
374
+ const result = await cacheConnector.get('text');
375
+
376
+ // Should return as plain string
377
+ expect(result).toBe('plain text');
378
+ expect(cacheConnector.stats.hits).toBe(1);
379
+ });
380
+ });
381
+
382
+ describe('Statistics and Monitoring', () => {
383
+ beforeEach(async () => {
384
+ await redisSimulator.connect();
385
+ cacheConnector.connected = true;
386
+ });
387
+
388
+ it('should track cache performance metrics', async () => {
389
+ // Generate some activity
390
+ await cacheConnector.set('stat1', 'value1');
391
+ await cacheConnector.set('stat2', 'value2');
392
+ await cacheConnector.get('stat1'); // hit
393
+ await cacheConnector.get('stat2'); // hit
394
+ await cacheConnector.get('missing'); // miss
395
+ await cacheConnector.delete('stat1');
396
+
397
+ const stats = cacheConnector.getStats();
398
+
399
+ expect(stats.sets).toBe(2);
400
+ expect(stats.hits).toBe(2);
401
+ expect(stats.misses).toBe(1);
402
+ expect(stats.deletes).toBe(1);
403
+ expect(stats.hitRate).toBe('66.67%');
404
+ // totalOperations is not returned by getStats()
405
+ });
406
+
407
+ it('should reset statistics', () => {
408
+ cacheConnector.stats = {
409
+ hits: 10,
410
+ misses: 5,
411
+ sets: 15,
412
+ deletes: 3,
413
+ errors: 1
414
+ };
415
+
416
+ cacheConnector.resetStats();
417
+
418
+ expect(cacheConnector.getStats()).toEqual({
419
+ hits: 0,
420
+ misses: 0,
421
+ sets: 0,
422
+ deletes: 0,
423
+ errors: 0,
424
+ hitRate: '0%'
425
+ });
426
+ });
427
+ });
428
+ });