All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 15m51s
350 lines
11 KiB
TypeScript
350 lines
11 KiB
TypeScript
// src/services/cacheService.server.test.ts
|
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
|
|
// Use vi.hoisted to ensure mockRedis is available before vi.mock runs
|
|
const { mockRedis } = vi.hoisted(() => ({
|
|
mockRedis: {
|
|
get: vi.fn(),
|
|
set: vi.fn(),
|
|
del: vi.fn(),
|
|
scan: vi.fn(),
|
|
},
|
|
}));
|
|
|
|
vi.mock('./redis.server', () => ({
|
|
connection: mockRedis,
|
|
}));
|
|
|
|
// Mock logger
|
|
vi.mock('./logger.server', async () => ({
|
|
logger: (await import('../tests/utils/mockLogger')).mockLogger,
|
|
}));
|
|
|
|
import { cacheService, CACHE_TTL, CACHE_PREFIX } from './cacheService.server';
|
|
import { logger } from './logger.server';
|
|
|
|
describe('cacheService', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
describe('CACHE_TTL constants', () => {
|
|
it('should have BRANDS TTL of 1 hour', () => {
|
|
expect(CACHE_TTL.BRANDS).toBe(60 * 60);
|
|
});
|
|
|
|
it('should have FLYERS TTL of 5 minutes', () => {
|
|
expect(CACHE_TTL.FLYERS).toBe(5 * 60);
|
|
});
|
|
|
|
it('should have FLYER TTL of 10 minutes', () => {
|
|
expect(CACHE_TTL.FLYER).toBe(10 * 60);
|
|
});
|
|
|
|
it('should have FLYER_ITEMS TTL of 10 minutes', () => {
|
|
expect(CACHE_TTL.FLYER_ITEMS).toBe(10 * 60);
|
|
});
|
|
|
|
it('should have STATS TTL of 5 minutes', () => {
|
|
expect(CACHE_TTL.STATS).toBe(5 * 60);
|
|
});
|
|
|
|
it('should have FREQUENT_SALES TTL of 15 minutes', () => {
|
|
expect(CACHE_TTL.FREQUENT_SALES).toBe(15 * 60);
|
|
});
|
|
|
|
it('should have CATEGORIES TTL of 1 hour', () => {
|
|
expect(CACHE_TTL.CATEGORIES).toBe(60 * 60);
|
|
});
|
|
});
|
|
|
|
describe('CACHE_PREFIX constants', () => {
|
|
it('should have correct prefix values', () => {
|
|
expect(CACHE_PREFIX.BRANDS).toBe('cache:brands');
|
|
expect(CACHE_PREFIX.FLYERS).toBe('cache:flyers');
|
|
expect(CACHE_PREFIX.FLYER).toBe('cache:flyer');
|
|
expect(CACHE_PREFIX.FLYER_ITEMS).toBe('cache:flyer-items');
|
|
expect(CACHE_PREFIX.STATS).toBe('cache:stats');
|
|
expect(CACHE_PREFIX.FREQUENT_SALES).toBe('cache:frequent-sales');
|
|
expect(CACHE_PREFIX.CATEGORIES).toBe('cache:categories');
|
|
});
|
|
});
|
|
|
|
describe('get', () => {
|
|
it('should return parsed JSON on cache hit', async () => {
|
|
const testData = { foo: 'bar', count: 42 };
|
|
mockRedis.get.mockResolvedValue(JSON.stringify(testData));
|
|
|
|
const result = await cacheService.get<typeof testData>('test-key');
|
|
|
|
expect(result).toEqual(testData);
|
|
expect(mockRedis.get).toHaveBeenCalledWith('test-key');
|
|
expect(logger.debug).toHaveBeenCalledWith({ cacheKey: 'test-key' }, 'Cache hit');
|
|
});
|
|
|
|
it('should return null on cache miss', async () => {
|
|
mockRedis.get.mockResolvedValue(null);
|
|
|
|
const result = await cacheService.get('test-key');
|
|
|
|
expect(result).toBeNull();
|
|
expect(logger.debug).toHaveBeenCalledWith({ cacheKey: 'test-key' }, 'Cache miss');
|
|
});
|
|
|
|
it('should return null and log warning on Redis error', async () => {
|
|
const error = new Error('Redis connection failed');
|
|
mockRedis.get.mockRejectedValue(error);
|
|
|
|
const result = await cacheService.get('test-key');
|
|
|
|
expect(result).toBeNull();
|
|
expect(logger.warn).toHaveBeenCalledWith(
|
|
{ err: error, cacheKey: 'test-key' },
|
|
'Redis GET failed, proceeding without cache',
|
|
);
|
|
});
|
|
|
|
it('should use provided logger', async () => {
|
|
const customLogger = {
|
|
debug: vi.fn(),
|
|
warn: vi.fn(),
|
|
} as any;
|
|
mockRedis.get.mockResolvedValue(null);
|
|
|
|
await cacheService.get('test-key', customLogger);
|
|
|
|
expect(customLogger.debug).toHaveBeenCalledWith({ cacheKey: 'test-key' }, 'Cache miss');
|
|
});
|
|
});
|
|
|
|
describe('set', () => {
|
|
it('should store JSON stringified value with TTL', async () => {
|
|
const testData = { foo: 'bar' };
|
|
mockRedis.set.mockResolvedValue('OK');
|
|
|
|
await cacheService.set('test-key', testData, 300);
|
|
|
|
expect(mockRedis.set).toHaveBeenCalledWith('test-key', JSON.stringify(testData), 'EX', 300);
|
|
expect(logger.debug).toHaveBeenCalledWith({ cacheKey: 'test-key', ttl: 300 }, 'Value cached');
|
|
});
|
|
|
|
it('should log warning on Redis error', async () => {
|
|
const error = new Error('Redis write failed');
|
|
mockRedis.set.mockRejectedValue(error);
|
|
|
|
await cacheService.set('test-key', { data: 'value' }, 300);
|
|
|
|
expect(logger.warn).toHaveBeenCalledWith(
|
|
{ err: error, cacheKey: 'test-key' },
|
|
'Redis SET failed, value not cached',
|
|
);
|
|
});
|
|
|
|
it('should use provided logger', async () => {
|
|
const customLogger = {
|
|
debug: vi.fn(),
|
|
warn: vi.fn(),
|
|
} as any;
|
|
mockRedis.set.mockResolvedValue('OK');
|
|
|
|
await cacheService.set('test-key', 'value', 300, customLogger);
|
|
|
|
expect(customLogger.debug).toHaveBeenCalledWith(
|
|
{ cacheKey: 'test-key', ttl: 300 },
|
|
'Value cached',
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('del', () => {
|
|
it('should delete key from cache', async () => {
|
|
mockRedis.del.mockResolvedValue(1);
|
|
|
|
await cacheService.del('test-key');
|
|
|
|
expect(mockRedis.del).toHaveBeenCalledWith('test-key');
|
|
expect(logger.debug).toHaveBeenCalledWith({ cacheKey: 'test-key' }, 'Cache key deleted');
|
|
});
|
|
|
|
it('should log warning on Redis error', async () => {
|
|
const error = new Error('Redis delete failed');
|
|
mockRedis.del.mockRejectedValue(error);
|
|
|
|
await cacheService.del('test-key');
|
|
|
|
expect(logger.warn).toHaveBeenCalledWith(
|
|
{ err: error, cacheKey: 'test-key' },
|
|
'Redis DEL failed',
|
|
);
|
|
});
|
|
|
|
it('should use provided logger', async () => {
|
|
const customLogger = {
|
|
debug: vi.fn(),
|
|
warn: vi.fn(),
|
|
} as any;
|
|
mockRedis.del.mockResolvedValue(1);
|
|
|
|
await cacheService.del('test-key', customLogger);
|
|
|
|
expect(customLogger.debug).toHaveBeenCalledWith(
|
|
{ cacheKey: 'test-key' },
|
|
'Cache key deleted',
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('invalidatePattern', () => {
|
|
it('should scan and delete keys matching pattern', async () => {
|
|
// First scan returns some keys, second scan returns cursor '0' to stop
|
|
mockRedis.scan
|
|
.mockResolvedValueOnce(['1', ['cache:test:1', 'cache:test:2']])
|
|
.mockResolvedValueOnce(['0', ['cache:test:3']]);
|
|
mockRedis.del.mockResolvedValue(2).mockResolvedValueOnce(2).mockResolvedValueOnce(1);
|
|
|
|
const result = await cacheService.invalidatePattern('cache:test:*');
|
|
|
|
expect(result).toBe(3);
|
|
expect(mockRedis.scan).toHaveBeenCalledWith('0', 'MATCH', 'cache:test:*', 'COUNT', 100);
|
|
expect(mockRedis.del).toHaveBeenCalledTimes(2);
|
|
expect(logger.info).toHaveBeenCalledWith(
|
|
{ pattern: 'cache:test:*', totalDeleted: 3 },
|
|
'Cache invalidation completed',
|
|
);
|
|
});
|
|
|
|
it('should handle empty scan results', async () => {
|
|
mockRedis.scan.mockResolvedValue(['0', []]);
|
|
|
|
const result = await cacheService.invalidatePattern('cache:empty:*');
|
|
|
|
expect(result).toBe(0);
|
|
expect(mockRedis.del).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should throw and log error on Redis failure', async () => {
|
|
const error = new Error('Redis scan failed');
|
|
mockRedis.scan.mockRejectedValue(error);
|
|
|
|
await expect(cacheService.invalidatePattern('cache:test:*')).rejects.toThrow(error);
|
|
expect(logger.error).toHaveBeenCalledWith(
|
|
{ err: error, pattern: 'cache:test:*' },
|
|
'Cache invalidation failed',
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('getOrSet', () => {
|
|
it('should return cached value on cache hit', async () => {
|
|
const cachedData = { id: 1, name: 'Test' };
|
|
mockRedis.get.mockResolvedValue(JSON.stringify(cachedData));
|
|
const fetcher = vi.fn();
|
|
|
|
const result = await cacheService.getOrSet('test-key', fetcher, { ttl: 300 });
|
|
|
|
expect(result).toEqual(cachedData);
|
|
expect(fetcher).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should call fetcher and cache result on cache miss', async () => {
|
|
mockRedis.get.mockResolvedValue(null);
|
|
mockRedis.set.mockResolvedValue('OK');
|
|
const freshData = { id: 2, name: 'Fresh' };
|
|
const fetcher = vi.fn().mockResolvedValue(freshData);
|
|
|
|
const result = await cacheService.getOrSet('test-key', fetcher, { ttl: 300 });
|
|
|
|
expect(result).toEqual(freshData);
|
|
expect(fetcher).toHaveBeenCalled();
|
|
// set is fire-and-forget, but we can verify it was called
|
|
await vi.waitFor(() => {
|
|
expect(mockRedis.set).toHaveBeenCalledWith(
|
|
'test-key',
|
|
JSON.stringify(freshData),
|
|
'EX',
|
|
300,
|
|
);
|
|
});
|
|
});
|
|
|
|
it('should use provided logger from options', async () => {
|
|
const customLogger = {
|
|
debug: vi.fn(),
|
|
warn: vi.fn(),
|
|
} as any;
|
|
mockRedis.get.mockResolvedValue(null);
|
|
mockRedis.set.mockResolvedValue('OK');
|
|
const fetcher = vi.fn().mockResolvedValue({ data: 'value' });
|
|
|
|
await cacheService.getOrSet('test-key', fetcher, { ttl: 300, logger: customLogger });
|
|
|
|
expect(customLogger.debug).toHaveBeenCalledWith({ cacheKey: 'test-key' }, 'Cache miss');
|
|
});
|
|
|
|
it('should not throw if set fails after fetching', async () => {
|
|
mockRedis.get.mockResolvedValue(null);
|
|
mockRedis.set.mockRejectedValue(new Error('Redis write failed'));
|
|
const freshData = { id: 3, name: 'Data' };
|
|
const fetcher = vi.fn().mockResolvedValue(freshData);
|
|
|
|
// Should not throw - set failures are caught internally
|
|
const result = await cacheService.getOrSet('test-key', fetcher, { ttl: 300 });
|
|
|
|
expect(result).toEqual(freshData);
|
|
});
|
|
});
|
|
|
|
describe('invalidateBrands', () => {
|
|
it('should invalidate all brand cache entries', async () => {
|
|
mockRedis.scan.mockResolvedValue(['0', ['cache:brands:1', 'cache:brands:2']]);
|
|
mockRedis.del.mockResolvedValue(2);
|
|
|
|
const result = await cacheService.invalidateBrands();
|
|
|
|
expect(mockRedis.scan).toHaveBeenCalledWith('0', 'MATCH', 'cache:brands*', 'COUNT', 100);
|
|
expect(result).toBe(2);
|
|
});
|
|
});
|
|
|
|
describe('invalidateFlyers', () => {
|
|
it('should invalidate all flyer-related cache entries', async () => {
|
|
// Mock scan for each pattern
|
|
mockRedis.scan
|
|
.mockResolvedValueOnce(['0', ['cache:flyers:list']])
|
|
.mockResolvedValueOnce(['0', ['cache:flyer:1', 'cache:flyer:2']])
|
|
.mockResolvedValueOnce(['0', ['cache:flyer-items:1']]);
|
|
mockRedis.del.mockResolvedValueOnce(1).mockResolvedValueOnce(2).mockResolvedValueOnce(1);
|
|
|
|
const result = await cacheService.invalidateFlyers();
|
|
|
|
expect(result).toBe(4);
|
|
expect(mockRedis.scan).toHaveBeenCalledTimes(3);
|
|
});
|
|
});
|
|
|
|
describe('invalidateFlyer', () => {
|
|
it('should invalidate specific flyer and its items', async () => {
|
|
mockRedis.del.mockResolvedValue(1);
|
|
mockRedis.scan.mockResolvedValue(['0', []]);
|
|
|
|
await cacheService.invalidateFlyer(123);
|
|
|
|
expect(mockRedis.del).toHaveBeenCalledWith('cache:flyer:123');
|
|
expect(mockRedis.del).toHaveBeenCalledWith('cache:flyer-items:123');
|
|
expect(mockRedis.scan).toHaveBeenCalledWith('0', 'MATCH', 'cache:flyers*', 'COUNT', 100);
|
|
});
|
|
});
|
|
|
|
describe('invalidateStats', () => {
|
|
it('should invalidate all stats cache entries', async () => {
|
|
mockRedis.scan.mockResolvedValue(['0', ['cache:stats:daily', 'cache:stats:weekly']]);
|
|
mockRedis.del.mockResolvedValue(2);
|
|
|
|
const result = await cacheService.invalidateStats();
|
|
|
|
expect(mockRedis.scan).toHaveBeenCalledWith('0', 'MATCH', 'cache:stats*', 'COUNT', 100);
|
|
expect(result).toBe(2);
|
|
});
|
|
});
|
|
});
|