@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.
- package/API.md +692 -0
- package/README.md +383 -0
- package/coverage/clover.xml +205 -0
- package/coverage/coverage-final.json +2 -0
- package/coverage/lcov-report/base.css +224 -0
- package/coverage/lcov-report/block-navigation.js +87 -0
- package/coverage/lcov-report/favicon.png +0 -0
- package/coverage/lcov-report/index.html +116 -0
- package/coverage/lcov-report/index.js.html +2851 -0
- package/coverage/lcov-report/prettify.css +1 -0
- package/coverage/lcov-report/prettify.js +2 -0
- package/coverage/lcov-report/sort-arrow-sprite.png +0 -0
- package/coverage/lcov-report/sorter.js +210 -0
- package/coverage/lcov.info +370 -0
- package/package.json +36 -0
- package/src/index.js +923 -0
- package/test/component/cache.component.test.js +428 -0
- package/test/integration/cache.integration.test.js +356 -0
- package/test/unit/CacheConnector.edge.test.js +532 -0
- package/test/unit/CacheConnector.test.js +811 -0
|
@@ -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
|
+
});
|