redis-csc
Version:
A client-side caching wrapper for ioredis utilizing Redis 6+ CLIENT TRACKING feature.
627 lines (513 loc) • 30.5 kB
JavaScript
// redisClientSideCache.test.js
const Redis = require('ioredis');
const { RedisClientSideCache, _waitUntilReady } = require('./RedisClientSideCache'); // Adjust path
// Helper function for delays
const delay = (ms) => new Promise(resolve => setTimeout(resolve, ms));
// --- Test Suite Configuration ---
jest.setTimeout(30000); // Increase timeout for async operations + Redis
describe('RedisClientSideCache Integration Tests ', () => {
let mainClient; // Client passed to RedisClientSideCache instance
let invalidationClient; // Separate client for external modifications
let cacheInstance; // Instance under test
// --- Constants for Tests ---
const trackedPrefix1 = 'ioredis:p1:';
const trackedPrefix2 = 'ioredis:p2:';
const untrackedPrefix = 'ioredis:other:';
const testPrefixes = [trackedPrefix1, trackedPrefix2];
const defaultExpiry = 5; // 5 seconds default Redis expiry for tests
const localCacheTTLShort = 1; // 1 second local TTL (in ms) for specific tests
const localCacheTTLDefault = 300; // 5 seconds default local TTL (in ms)
const redisReadyTimeout = 15000; // Timeout for waiting for Redis connections
function __log(...args) {
if (process.env.DEBUG) {
console.log(...args);
}
}
// --- Setup and Teardown ---
beforeAll(async () => {
// Create external clients once
mainClient = new Redis({ enableAutoPipelining: true, db: 15 });
invalidationClient = new Redis({ enableAutoPipelining: true, db: 15 });
__log("Waiting for mainClient and invalidationClient to connect...");
try {
await Promise.all([
_waitUntilReady(mainClient, redisReadyTimeout),
_waitUntilReady(invalidationClient, redisReadyTimeout)
]);
__log("External Redis clients connected.");
} catch (err) {
console.error("Failed to connect prerequisite Redis clients:", err);
throw new Error(`Failed to connect prerequisite Redis clients: ${err.message}`);
}
});
afterAll(async () => {
__log("Disconnecting external Redis clients...");
if (mainClient && mainClient.status !== 'end') await mainClient.quit();
if (invalidationClient && invalidationClient.status !== 'end') await invalidationClient.quit();
__log("External Redis clients disconnected.");
});
beforeEach(async () => {
// Clean Redis DB before each test
// Ensure clients are connected before flushdb if previous test failed badly
if (mainClient.status !== 'ready') {
await mainClient.connect().catch(()=>{}); // Ignore error, try to proceed
await _waitUntilReady(mainClient, 5000).catch(() => console.warn("Main client reconnection failed in beforeEach"));
}
if (invalidationClient.status !== 'ready') {
await invalidationClient.connect().catch(()=>{});
await _waitUntilReady(invalidationClient, 5000).catch(() => console.warn("Invalidation client reconnection failed in beforeEach"));
}
// Flush only if connected
if (invalidationClient.status === 'ready') {
await invalidationClient.flushdb();
__log("Redis DB flushed.");
} else {
console.warn("Skipping flushdb as invalidation client not ready.");
}
// Create a new cache instance for each test
try {
cacheInstance = await RedisClientSideCache.create(
mainClient,
testPrefixes,
defaultExpiry,
null, // No listener callback needed here
redisReadyTimeout,
localCacheTTLDefault // Use default local TTL
);
__log("New RedisClientSideCache instance created.");
} catch (err) {
console.error("Failed to create RedisClientSideCache instance in beforeEach:", err);
// Allow tests to proceed and potentially fail if instance is null
cacheInstance = null;
// Throw if essential for all tests
// throw new Error(`Failed cacheInstance creation: ${err.message}`);
}
});
afterEach(async () => {
// Disconnect the instance's internal subscriber
if (cacheInstance && typeof cacheInstance.disconnect === 'function') {
__log("Disconnecting cacheInstance internal resources...");
await cacheInstance.disconnect();
__log("cacheInstance internal resources disconnected.");
}
cacheInstance = null;
});
// --- _waitUntilReady Tests (Unaffected by Sinon removal) ---
describe('_waitUntilReady Helper', () => {
let tempClient;
afterEach(async () => {
if (tempClient && tempClient.status !== 'end') {
await tempClient.quit().catch(() => tempClient.disconnect());
}
tempClient = null;
});
it('should resolve immediately if client is already ready', async () => {
await expect(_waitUntilReady(mainClient)).resolves.toBeUndefined();
});
it('should resolve when client becomes ready', async () => {
tempClient = new Redis({ lazyConnect: true });
const readyPromise = _waitUntilReady(tempClient);
await expect(readyPromise).resolves.toBeUndefined();
});
it('should reject if client emits error while waiting', async () => {
tempClient = new Redis({ lazyConnect: true });
const waitPromise = _waitUntilReady(tempClient, 5000);
const error = new Error('Fake connection error');
setImmediate(() => tempClient.emit('error', error));
await expect(waitPromise).rejects.toThrow(`ioredis client connection error while waiting for ready: ${error.message}`);
});
it('should reject on timeout', async () => {
tempClient = new Redis({ port: 9999, lazyConnect: true, reconnectOnError: () => false, maxRetriesPerRequest: 0, connectTimeout: 150 }); // Force quick failure
tempClient.connect().catch(()=>{}); // Prevent unhandled rejection
await expect(_waitUntilReady(tempClient, 200)) // Very short timeout
.rejects.toThrow(/Timeout \(200ms\)|connection error while waiting/i); // Can be timeout or connection error
});
it('should reject if client ends while waiting', async () => {
tempClient = new Redis({ lazyConnect: true });
const waitPromise = _waitUntilReady(tempClient, 5000);
setImmediate(() => tempClient.emit('end'));
await expect(waitPromise).rejects.toThrow("ioredis client ended unexpectedly");
});
it('should reject if client has already ended', async () => {
tempClient = new Redis({ lazyConnect: true });
await tempClient.quit();
await expect(_waitUntilReady(tempClient)).rejects.toThrow("ioredis client ended unexpectedly while waiting for ready status.");
});
it('should reject with invalid client object', async () => {
await expect(_waitUntilReady(null)).rejects.toThrow("Invalid ioredis client");
await expect(_waitUntilReady({})).rejects.toThrow("Invalid ioredis client");
});
});
// --- RedisClientSideCache Functionality Tests ---
describe('Initialization (create)', () => {
// Assumption: cacheInstance is successfully created in beforeEach unless specific test needs otherwise
it('should fail if client is invalid', async () => {
await expect(RedisClientSideCache.create({}, testPrefixes))
.rejects.toThrow("A valid ioredis client instance must be provided.");
});
// Test failure if _waitUntilReady fails (e.g., timeout)
it('should fail if waiting for subscriber readiness times out', async () => {
if(cacheInstance) await cacheInstance.disconnect(); // Clean up default instance
// Use a client config that will fail to connect
const failingClient = new Redis({ port: 9999, lazyConnect: true, reconnectOnError: () => false, maxRetriesPerRequest: 0, connectTimeout: 150 });
const originalDuplicate = mainClient.duplicate; // Store original
mainClient.duplicate = () => failingClient; // Override duplicate temporarily
failingClient.connect().catch(() => {}); // Initiate connection attempt and ignore error in test runner
await expect(RedisClientSideCache.create(mainClient, testPrefixes, defaultExpiry, null, 500 /* short timeout */))
.rejects.toThrow(/Timeout \(500ms\)|connection error while waiting/i); // Match timeout or connection error message
mainClient.duplicate = originalDuplicate; // Restore original method
await failingClient.quit().catch(()=>{}); // Clean up the failing client
});
// Cannot reliably test CLIENT TRACKING command failure without stubbing/mocking
// it('should fail and cleanup if CLIENT TRACKING command fails', async () => { ... });
});
describe('Caching Methods (get/set)', () => {
// Assumption: cacheInstance is successfully created in beforeEach unless specific test needs otherwise
beforeEach(() => {
if (!cacheInstance) {
throw new Error("Cache instance was not created in beforeEach, skipping test block.");
}
});
const key1 = trackedPrefix1 + 'key1';
const val1 = 'value1';
const key2Untracked = untrackedPrefix + 'key2';
const val2 = 'value2';
it('getCached: should return null and miss cache for non-existent key', async () => {
const result = await cacheInstance.getCached(key1);
expect(result.data).toBeNull();
expect(result.cacheHits).toBe(0);
expect(result.cacheMisses).toBe(1);
// Verify state: key should not be in local cache
expect(cacheInstance.getLocalValue(key1)).toBeNull();
});
it('setCached: should set value in Redis and local cache (if prefix matches)', async () => {
const setResult = await cacheInstance.setCached(key1, val1);
expect(setResult).toBe('OK');
// Verify local cache
expect(cacheInstance.getLocalValue(key1)).toBe(val1);
// Verify Redis state
expect(await mainClient.get(key1)).toBe(val1);
expect(await mainClient.ttl(key1)).toBeGreaterThan(0); // Check expiry was set
// Set untracked key
const setResult2 = await cacheInstance.setCached(key2Untracked, val2, 0); // No expiry
expect(setResult2).toBe('OK');
// Verify local cache (should not be present)
expect(cacheInstance.getLocalValue(key2Untracked)).toBeUndefined();
// Verify Redis state
expect(await mainClient.get(key2Untracked)).toBe(val2);
expect(await mainClient.ttl(key2Untracked)).toBe(-1); // No expiry
});
it('getCached: should hit local cache after setting', async () => {
await cacheInstance.setCached(key1, val1);
// Now get it - check hit stats and data
const result = await cacheInstance.getCached(key1);
expect(result.data).toBe(val1);
expect(result.cacheHits).toBe(1);
expect(result.cacheMisses).toBe(0);
// No need to check redis call count without spies
});
it('getCached: should miss local cache, fetch from Redis, and store locally (if prefix matches)', async () => {
// Set value using external client
await invalidationClient.set(key1, val1);
await invalidationClient.set(key2Untracked, val2);
// Get tracked key
const result1 = await cacheInstance.getCached(key1);
expect(result1.data).toBe(val1);
expect(result1.cacheHits).toBe(0);
expect(result1.cacheMisses).toBe(1);
expect(cacheInstance.getLocalValue(key1)).toBe(val1); // Should be cached locally now
// Get untracked key
const result2 = await cacheInstance.getCached(key2Untracked);
expect(result2.data).toBe(val2);
expect(result2.cacheHits).toBe(0);
expect(result2.cacheMisses).toBe(1);
expect(cacheInstance.getLocalValue(key2Untracked)).toBeUndefined(); // Should NOT be cached locally
});
it('setCached: should use specified expiry', async () => {
const specificExpiry = 60;
await cacheInstance.setCached(key1, val1, specificExpiry);
// Verify Redis TTL (approximate)
const ttl = await mainClient.ttl(key1);
expect(ttl).toBeGreaterThan(1); // Should be close to 60 initially
expect(ttl).toBeLessThanOrEqual(specificExpiry);
});
//doing this test causes the main client to disconnect, and leads to an unwanted state which causes other tests to fail. Skipping for now
//it('should reject get/set operations if client is not ready', async () => {
// // Simulate main client disconnect AFTER instance creation
// await mainClient.quit(); // Disconnect the client
// await expect(cacheInstance.getCached(key1)).rejects.toThrow(/Failed to fetch key from Redis/);
// await expect(cacheInstance.setCached(key1, val1)).rejects.toThrow(/Redis client is not connected or ready/);
// // Reconnect mainClient for subsequent tests (important!)
// mainClient = new Redis({ enableAutoPipelining: true });
// await _waitUntilReady(mainClient, redisReadyTimeout);
//});
it('setCached: should reject invalid input', async () => {
await expect(cacheInstance.setCached(null, val1)).rejects.toThrow("Input 'key' must be a string.");
await expect(cacheInstance.setCached(key1, null)).rejects.toThrow("Input 'value' must be a non-null");
await expect(cacheInstance.setCached(key1, undefined)).rejects.toThrow("Input 'value' must be a non-null");
});
// Cannot reliably test error handling for specific commands without stubs
// it('getCached: should handle Redis errors during fetch', async () => { ... });
// it('setCached: should handle Redis errors during set', async () => { ... });
});
describe('Caching Methods (mget/mset)', () => {
// Assumption: cacheInstance is successfully created in beforeEach unless specific test needs otherwise
beforeEach(() => {
if (!cacheInstance) {
throw new Error("Cache instance was not created in beforeEach, skipping test block.");
}
});
const keys = [trackedPrefix1 + 'mk1', trackedPrefix2 + 'mk2', trackedPrefix1 + 'mk3', untrackedPrefix + 'mk4'];
const data = {
[keys[0]]: 'mval1',
[keys[1]]: 'mval2',
[keys[2]]: 'mval3',
[keys[3]]: 'mval4',
};
it('mSetCached: should set multiple values in Redis and local cache (for matching prefixes)', async () => {
const result = await cacheInstance.mSetCached(data);
expect(result).toBe('OK');
// Verify local cache
expect(cacheInstance.getLocalValue(keys[0])).toBe(data[keys[0]]);
expect(cacheInstance.getLocalValue(keys[1])).toBe(data[keys[1]]);
expect(cacheInstance.getLocalValue(keys[2])).toBe(data[keys[2]]);
expect(cacheInstance.getLocalValue(keys[3])).toBeUndefined(); // Untracked prefix
// Verify Redis content
const redisValues = await mainClient.mget(...keys);
expect(redisValues).toEqual([data[keys[0]], data[keys[1]], data[keys[2]], data[keys[3]]]);
// Verify Redis TTLs (check one tracked key)
expect(await mainClient.ttl(keys[0])).toBeGreaterThan(0);
expect(await mainClient.ttl(keys[0])).toBeLessThanOrEqual(defaultExpiry);
// Verify untracked key TTL (implementation sets expiry for all if > 0)
expect(await mainClient.ttl(keys[3])).toBeGreaterThan(0);
expect(await mainClient.ttl(keys[3])).toBeLessThanOrEqual(defaultExpiry);
});
it('mGetCached: should fetch from local cache and Redis, respecting prefixes', async () => {
// 1. Set some initial data (mix of tracked/untracked)
await cacheInstance.setCached(keys[0], data[keys[0]]); // Tracked, will be in local cache
await invalidationClient.set(keys[1], data[keys[1]]); // Tracked, not yet in local cache
await invalidationClient.set(keys[3], data[keys[3]]); // Untracked, not yet in local cache
// keys[2] does not exist anywhere yet
// 2. Perform mGetCached
const result = await cacheInstance.mGetCached(keys);
//console.log("mGetCached result:", result.data);
//console.log({[keys[0]]: data[keys[0]], [keys[1]]: data[keys[1]], [keys[2]]: null, [keys[3]]: data[keys[3]]});
// 4. Verify cache stats
expect(result.cacheHits).toBe(1); // keys[0]
expect(result.cacheMisses).toBe(3); // keys[1], keys[2], keys[3]
// 5. Verify local cache state AFTER mGet
expect(cacheInstance.getLocalValue(keys[0])).toBe(data[keys[0]]); // Still there
expect(cacheInstance.getLocalValue(keys[1])).toBe(data[keys[1]]); // Now cached
expect(cacheInstance.getLocalValue(keys[2])).toBeNull(); // Null from Redis, not cached
expect(cacheInstance.getLocalValue(keys[3])).toBeUndefined(); // Untracked prefix, not cached
});
// Cannot reliably test error handling for specific commands without stubs
// it('mGetCached: should handle Redis errors during mget fetch', async () => { ... });
// it('mSetCached: should handle Redis errors during mset', async () => { ... });
});
describe('Deletion (delCached)', () => {
// Assumption: cacheInstance is successfully created in beforeEach unless specific test needs otherwise
beforeEach(() => {
if (!cacheInstance) {
throw new Error("Cache instance was not created in beforeEach, skipping test block.");
}
});
const key1 = trackedPrefix1 + 'delKey1';
const key2 = trackedPrefix2 + 'delKey2';
const val1 = 'delVal1';
const val2 = 'delVal2';
beforeEach(async () => {
// Pre-populate for deletion tests
await cacheInstance.setCached(key1, val1);
await cacheInstance.setCached(key2, val2);
});
it('should delete a single key from Redis and local cache', async () => {
expect(cacheInstance.getLocalValue(key1)).toBe(val1); // Pre-check local
expect(await mainClient.exists(key1)).toBe(1); // Pre-check Redis
const delCount = await cacheInstance.delCached(key1);
expect(delCount).toBe(1);
expect(cacheInstance.getLocalValue(key1)).toBeUndefined(); // Verify local removal
expect(await mainClient.exists(key1)).toBe(0); // Verify Redis removal
});
it('should delete multiple keys from Redis and local cache', async () => {
expect(cacheInstance.getLocalValue(key1)).toBe(val1);
expect(cacheInstance.getLocalValue(key2)).toBe(val2);
expect(await mainClient.exists(key1, key2)).toBe(2);
const delCount = await cacheInstance.delCached([key1, key2]);
expect(delCount).toBe(2);
expect(cacheInstance.getLocalValue(key1)).toBeUndefined();
expect(cacheInstance.getLocalValue(key2)).toBeUndefined();
expect(await mainClient.exists(key1, key2)).toBe(0);
});
it('should return 0 if deleting non-existent keys', async () => {
const nonExistentKey = trackedPrefix1 + 'nonExistent';
expect(cacheInstance.getLocalValue(nonExistentKey)).toBeUndefined();
expect(await mainClient.exists(nonExistentKey)).toBe(0);
const delCount = await cacheInstance.delCached(nonExistentKey);
expect(delCount).toBe(0);
expect(cacheInstance.getLocalValue(nonExistentKey)).toBeUndefined();
});
// Cannot reliably test error handling for specific commands without stubs
// it('should handle Redis errors during del', async () => { ... });
});
// --- Invalidation and TTL Tests (Rely on Real Interactions) ---
describe('Invalidation Handling', () => {
// Assumption: cacheInstance is successfully created in beforeEach unless specific test needs otherwise
beforeEach(() => {
if (!cacheInstance) {
throw new Error("Cache instance was not created in beforeEach, skipping test block.");
}
});
const key1 = trackedPrefix1 + 'invKey1';
const val1 = 'invValue1';
const key2 = trackedPrefix2 + 'invKey2';
const val2 = 'invValue2';
const keyUntracked = untrackedPrefix + 'invKeyUntracked';
const valUntracked = 'invValueUntracked';
const waitForInvalidation = () => delay(150); // Wait for pub/sub propagation
it('should invalidate local cache when a tracked key is changed externally (SET)', async () => {
await cacheInstance.setCached(key1, val1);
expect(cacheInstance.getLocalValue(key1)).toBe(val1);
await invalidationClient.set(key1, 'newValue');
await waitForInvalidation();
expect(cacheInstance.getLocalValue(key1)).toBeUndefined(); // Should be gone
});
it('should invalidate local cache when a tracked key is changed externally (DEL)', async () => {
await cacheInstance.setCached(key1, val1);
expect(cacheInstance.getLocalValue(key1)).toBe(val1);
await invalidationClient.del(key1);
await waitForInvalidation();
__log("Local cache after invalidation:", cacheInstance.getLocalCacheStats());
expect(cacheInstance.getLocalValue(key1)).toBeUndefined();
});
it('should invalidate multiple local keys when changed externally (MSET)', async () => {
await cacheInstance.setCached(key1, val1);
await cacheInstance.setCached(key2, val2);
expect(cacheInstance.getLocalValue(key1)).toBe(val1);
expect(cacheInstance.getLocalValue(key2)).toBe(val2);
await invalidationClient.mset({ [key1]: 'new1', [key2]: 'new2' });
await waitForInvalidation();
expect(cacheInstance.getLocalValue(key1)).toBeUndefined();
expect(cacheInstance.getLocalValue(key2)).toBeUndefined();
});
it('should NOT invalidate local cache for untracked prefixes', async () => {
await invalidationClient.set(keyUntracked, valUntracked);
await cacheInstance.getCached(keyUntracked); // Fetch - miss local, get from Redis, don't store local
expect(cacheInstance.getLocalValue(keyUntracked)).toBeUndefined();
await invalidationClient.set(keyUntracked, 'newValueUntracked');
await waitForInvalidation();
expect(cacheInstance.getLocalValue(keyUntracked)).toBeUndefined(); // Still undefined
});
it('should NOT invalidate local cache due to NOLOOP for changes made by the *same* client instance', async () => {
await cacheInstance.setCached(key1, val1);
expect(cacheInstance.getLocalValue(key1)).toBe(val1);
// Modify AGAIN via the SAME instance
await cacheInstance.setCached(key1, 'newValueViaInstance');
await delay(200); // Wait longer than typical invalidation
// The value should be the *updated* value from the second set, NOT undefined
expect(cacheInstance.getLocalValue(key1)).toBe('newValueViaInstance');
expect(await mainClient.get(key1)).toBe('newValueViaInstance'); // Check Redis too
});
});
describe('Local Cache TTL', () => {
let ttlCacheInstance; // Use a separate instance with short TTL
const keyTTL = trackedPrefix1 + 'ttlKey';
const valTTL = 'ttlValue';
beforeEach(async () => {
if (cacheInstance) await cacheInstance.disconnect(); // Disconnect default instance
ttlCacheInstance = await RedisClientSideCache.create(
mainClient,
testPrefixes,
defaultExpiry, // Redis expiry remains longer
null,
redisReadyTimeout,
localCacheTTLShort // Use SHORT local TTL (milliseconds)
);
if (!ttlCacheInstance) {
throw new Error("Failed to create TTL test instance in beforeEach.");
}
});
afterEach(async () => {
if (ttlCacheInstance) await ttlCacheInstance.disconnect();
ttlCacheInstance = null;
// Restore default instance by letting main beforeEach run next
});
it('should expire item from local cache after local TTL, requiring refetch from Redis', async () => {
// 1. Set value
await ttlCacheInstance.setCached(keyTTL, valTTL, defaultExpiry);
expect(ttlCacheInstance.getLocalValue(keyTTL)).toBe(valTTL); // Check locally present
// 2. Wait for local TTL to expire
await delay(localCacheTTLShort*1000 + 100);
// 3. Check local cache directly - should be gone
expect(ttlCacheInstance.getLocalValue(keyTTL)).toBeNull();
// 4. Call getCached - should miss locally, fetch from Redis
const result = await ttlCacheInstance.getCached(keyTTL);
expect(result.data).toBe(valTTL); // Data still exists in Redis
expect(result.cacheHits).toBe(0); // Local MISS
expect(result.cacheMisses).toBe(1); // Fetched from Redis
// 5. Check local cache again - should be repopulated
expect(ttlCacheInstance.getLocalValue(keyTTL)).toBe(valTTL);
});
it('local TTL should be capped by Redis TTL if Redis TTL is shorter', async () => {
const redisExpiryShort = 1; // 1 second Redis expiry
const longLocalTTLms = 5000; // 5 seconds local TTL
// Create instance with long local TTL
if (ttlCacheInstance) await ttlCacheInstance.disconnect();
ttlCacheInstance = await RedisClientSideCache.create(
mainClient, testPrefixes, redisExpiryShort, null, redisReadyTimeout, longLocalTTLms
);
if (!ttlCacheInstance) {
throw new Error("Failed to create long TTL test instance in beforeEach.");
}
// 1. Set with short Redis expiry
await ttlCacheInstance.setCached(keyTTL, valTTL, redisExpiryShort);
expect(ttlCacheInstance.getLocalValue(keyTTL)).toBe(valTTL); // Initially cached
// 2. Wait longer than Redis expiry
await delay((redisExpiryShort * 1000) + 200); // e.g., 1.2 seconds
// 3. Check local cache - should be expired based on the shorter Redis expiry used during setLocalKey
expect(ttlCacheInstance.getLocalValue(keyTTL)).toBeUndefined();
// 4. Fetching again should miss locally and get null from Redis
const result = await ttlCacheInstance.getCached(keyTTL);
expect(result.data).toBeNull(); // Expired in Redis too
expect(result.cacheHits).toBe(0);
expect(result.cacheMisses).toBe(1);
expect(ttlCacheInstance.getLocalValue(keyTTL)).toBeNull(); // Still undefined
});
});
describe('Utility Methods', () => {
// Assumption: cacheInstance is successfully created in beforeEach unless specific test needs otherwise
beforeEach(() => {
if (!cacheInstance) {
throw new Error("Cache instance was not created in beforeEach, skipping test block.");
}
});
it('clearLocalCache: should empty the local cache store', async () => {
await cacheInstance.setCached(trackedPrefix1 + 'util1', 'v1');
await cacheInstance.setCached(trackedPrefix2 + 'util2', 'v2');
expect(cacheInstance.getLocalCacheStats().size).toBe(2);
cacheInstance.clearLocalCache();
expect(cacheInstance.getLocalCacheStats().size).toBe(0);
expect(cacheInstance.getLocalCacheStats().keys).toEqual([]);
expect(cacheInstance.getLocalValue(trackedPrefix1 + 'util1')).toBeUndefined();
});
it('getLocalCacheStats: should return correct size and keys', async () => {
expect(cacheInstance.getLocalCacheStats()).toEqual({ size: 0, keys: [] });
await cacheInstance.setCached(trackedPrefix1 + 'stat1', 'v1');
await cacheInstance.setCached(trackedPrefix2 + 'stat2', 'v2');
await cacheInstance.setCached(untrackedPrefix + 'stat3', 'v3'); // Untracked
const stats = cacheInstance.getLocalCacheStats();
expect(stats.size).toBe(2); // Only tracked keys count
expect(stats.keys).toEqual(expect.arrayContaining([trackedPrefix1 + 'stat1', trackedPrefix2 + 'stat2']));
expect(stats.keys).not.toContain(untrackedPrefix + 'stat3');
});
it('getLocalValue: should return local value or undefined', async () => {
const key = trackedPrefix1 + 'localKey';
const value = 'localValue';
expect(cacheInstance.getLocalValue(key)).toBeUndefined(); // Before set
await cacheInstance.setCached(key, value);
expect(cacheInstance.getLocalValue(key)).toBe(value); // After set
cacheInstance.clearLocalCache();
expect(cacheInstance.getLocalValue(key)).toBeUndefined(); // After clear
});
});
});