/** * Response Cache Service Tests * Action 2.5.1.7: Test response cache works correctly */ import { describe, it, expect, beforeEach, vi } from 'vitest'; import { responseCache } from './responseCache'; import type { AxiosRequestConfig, AxiosResponse } from 'axios'; describe('ResponseCacheService', () => { beforeEach(() => { // Clear cache before each test responseCache.clear(); }); describe('get', () => { it('should return null for non-GET requests', () => { const config: AxiosRequestConfig = { method: 'POST', url: '/api/v1/tracks', }; const result = responseCache.get(config); expect(result).toBeNull(); }); it('should return null when cache is empty', () => { const config: AxiosRequestConfig = { method: 'GET', url: '/api/v1/tracks', }; const result = responseCache.get(config); expect(result).toBeNull(); }); it('should return cached response for GET request', () => { const config: AxiosRequestConfig = { method: 'GET', url: '/api/v1/tracks', }; const response: AxiosResponse = { data: { tracks: [] }, status: 200, statusText: 'OK', headers: {}, config: config as any, request: {}, }; // Store response responseCache.set(config, response); // Retrieve cached response const cached = responseCache.get(config); expect(cached).not.toBeNull(); expect(cached?.data).toEqual({ tracks: [] }); expect(cached?.status).toBe(200); }); it('should return null for expired cache entries', async () => { const config: AxiosRequestConfig = { method: 'GET', url: '/api/v1/tracks', }; const response: AxiosResponse = { data: { tracks: [] }, status: 200, statusText: 'OK', headers: { 'cache-control': 'max-age=1' }, // 1 second TTL config: config as any, request: {}, }; // Store response responseCache.set(config, response); // Wait for cache to expire await new Promise((resolve) => setTimeout(resolve, 1100)); // Should return null (expired) const cached = responseCache.get(config); expect(cached).toBeNull(); }); it('should respect _disableCache flag', () => { const config: AxiosRequestConfig = { method: 'GET', url: '/api/v1/tracks', _disableCache: true, } as any; const response: AxiosResponse = { data: { tracks: [] }, status: 200, statusText: 'OK', headers: {}, config: config as any, request: {}, }; // Try to store (should be ignored) responseCache.set(config, response); // Should return null (caching disabled) const cached = responseCache.get(config); expect(cached).toBeNull(); }); it('should handle different query parameters as different cache keys', () => { const config1: AxiosRequestConfig = { method: 'GET', url: '/api/v1/tracks', params: { page: 1 }, }; const config2: AxiosRequestConfig = { method: 'GET', url: '/api/v1/tracks', params: { page: 2 }, }; const response1: AxiosResponse = { data: { tracks: [], page: 1 }, status: 200, statusText: 'OK', headers: {}, config: config1 as any, request: {}, }; const response2: AxiosResponse = { data: { tracks: [], page: 2 }, status: 200, statusText: 'OK', headers: {}, config: config2 as any, request: {}, }; responseCache.set(config1, response1); responseCache.set(config2, response2); const cached1 = responseCache.get(config1); const cached2 = responseCache.get(config2); expect(cached1?.data.page).toBe(1); expect(cached2?.data.page).toBe(2); }); }); describe('set', () => { it('should cache GET request responses', () => { const config: AxiosRequestConfig = { method: 'GET', url: '/api/v1/tracks', }; const response: AxiosResponse = { data: { tracks: [] }, status: 200, statusText: 'OK', headers: {}, config: config as any, request: {}, }; responseCache.set(config, response); const cached = responseCache.get(config); expect(cached).not.toBeNull(); expect(cached?.data).toEqual({ tracks: [] }); }); it('should not cache non-GET requests', () => { const config: AxiosRequestConfig = { method: 'POST', url: '/api/v1/tracks', }; const response: AxiosResponse = { data: { id: '123' }, status: 201, statusText: 'Created', headers: {}, config: config as any, request: {}, }; responseCache.set(config, response); const cached = responseCache.get(config); expect(cached).toBeNull(); }); it('should respect Cache-Control no-store directive', () => { const config: AxiosRequestConfig = { method: 'GET', url: '/api/v1/tracks', }; const response: AxiosResponse = { data: { tracks: [] }, status: 200, statusText: 'OK', headers: { 'cache-control': 'no-store' }, config: config as any, request: {}, }; responseCache.set(config, response); const cached = responseCache.get(config); expect(cached).toBeNull(); }); it('should respect Cache-Control no-cache directive', () => { const config: AxiosRequestConfig = { method: 'GET', url: '/api/v1/tracks', }; const response: AxiosResponse = { data: { tracks: [] }, status: 200, statusText: 'OK', headers: { 'cache-control': 'no-cache' }, config: config as any, request: {}, }; responseCache.set(config, response); const cached = responseCache.get(config); expect(cached).toBeNull(); }); it('should respect Cache-Control max-age directive', async () => { const config: AxiosRequestConfig = { method: 'GET', url: '/api/v1/tracks', }; const response: AxiosResponse = { data: { tracks: [] }, status: 200, statusText: 'OK', headers: { 'cache-control': 'max-age=1' }, // 1 second config: config as any, request: {}, }; responseCache.set(config, response); // Should be cached immediately let cached = responseCache.get(config); expect(cached).not.toBeNull(); // Wait for expiration await new Promise((resolve) => setTimeout(resolve, 1100)); // Should be expired cached = responseCache.get(config); expect(cached).toBeNull(); }); it('should store ETag and Last-Modified headers', () => { const config: AxiosRequestConfig = { method: 'GET', url: '/api/v1/tracks', }; const response: AxiosResponse = { data: { tracks: [] }, status: 200, statusText: 'OK', headers: { etag: '"abc123"', 'last-modified': 'Wed, 21 Oct 2015 07:28:00 GMT', }, config: config as any, request: {}, }; responseCache.set(config, response); const cached = responseCache.get(config); expect(cached).not.toBeNull(); // Note: ETag and Last-Modified are stored internally but not exposed in get() return }); }); describe('invalidate', () => { it('should invalidate cache entries matching string pattern', () => { const config1: AxiosRequestConfig = { method: 'GET', url: '/api/v1/tracks', }; const config2: AxiosRequestConfig = { method: 'GET', url: '/api/v1/playlists', }; const response1: AxiosResponse = { data: { tracks: [] }, status: 200, statusText: 'OK', headers: {}, config: config1 as any, request: {}, }; const response2: AxiosResponse = { data: { playlists: [] }, status: 200, statusText: 'OK', headers: {}, config: config2 as any, request: {}, }; responseCache.set(config1, response1); responseCache.set(config2, response2); // Invalidate tracks pattern const invalidated = responseCache.invalidate('/tracks'); expect(invalidated).toBeGreaterThan(0); expect(responseCache.get(config1)).toBeNull(); expect(responseCache.get(config2)).not.toBeNull(); }); it('should invalidate cache entries matching regex pattern', () => { const config1: AxiosRequestConfig = { method: 'GET', url: '/api/v1/tracks/123', }; const config2: AxiosRequestConfig = { method: 'GET', url: '/api/v1/tracks/456', }; const response1: AxiosResponse = { data: { id: '123' }, status: 200, statusText: 'OK', headers: {}, config: config1 as any, request: {}, }; const response2: AxiosResponse = { data: { id: '456' }, status: 200, statusText: 'OK', headers: {}, config: config2 as any, request: {}, }; responseCache.set(config1, response1); responseCache.set(config2, response2); // Invalidate all tracks/* entries const invalidated = responseCache.invalidate(/\/tracks\/\d+/); expect(invalidated).toBeGreaterThan(0); expect(responseCache.get(config1)).toBeNull(); expect(responseCache.get(config2)).toBeNull(); }); }); describe('clear', () => { it('should clear all cache entries', () => { const config1: AxiosRequestConfig = { method: 'GET', url: '/api/v1/tracks', }; const config2: AxiosRequestConfig = { method: 'GET', url: '/api/v1/playlists', }; const response1: AxiosResponse = { data: { tracks: [] }, status: 200, statusText: 'OK', headers: {}, config: config1 as any, request: {}, }; const response2: AxiosResponse = { data: { playlists: [] }, status: 200, statusText: 'OK', headers: {}, config: config2 as any, request: {}, }; responseCache.set(config1, response1); responseCache.set(config2, response2); responseCache.clear(); expect(responseCache.get(config1)).toBeNull(); expect(responseCache.get(config2)).toBeNull(); expect(responseCache.getStats().size).toBe(0); }); }); describe('getStats', () => { it('should return cache statistics', () => { const config: AxiosRequestConfig = { method: 'GET', url: '/api/v1/tracks', }; const response: AxiosResponse = { data: { tracks: [] }, status: 200, statusText: 'OK', headers: {}, config: config as any, request: {}, }; responseCache.set(config, response); const stats = responseCache.getStats(); expect(stats.size).toBeGreaterThan(0); expect(stats.maxSize).toBe(100); expect(stats.entries).toBeDefined(); expect(stats.entries.length).toBeGreaterThan(0); expect(stats.entries[0]).toHaveProperty('key'); expect(stats.entries[0]).toHaveProperty('age'); }); }); describe('cleanup', () => { it('should remove expired cache entries', async () => { const config: AxiosRequestConfig = { method: 'GET', url: '/api/v1/tracks', }; const response: AxiosResponse = { data: { tracks: [] }, status: 200, statusText: 'OK', headers: { 'cache-control': 'max-age=1' }, // 1 second TTL config: config as any, request: {}, }; responseCache.set(config, response); // Wait for expiration await new Promise((resolve) => setTimeout(resolve, 1100)); // Cleanup should remove expired entries const removed = responseCache.cleanup(); expect(removed).toBeGreaterThan(0); expect(responseCache.get(config)).toBeNull(); }); }); describe('cache size limits', () => { it('should evict oldest entries when cache size limit is reached', () => { // Create more than maxSize (100) entries const entries: Array<{ config: AxiosRequestConfig; response: AxiosResponse; }> = []; for (let i = 0; i < 105; i++) { const config: AxiosRequestConfig = { method: 'GET', url: `/api/v1/tracks/${i}`, }; const response: AxiosResponse = { data: { id: i }, status: 200, statusText: 'OK', headers: {}, config: config as any, request: {}, }; entries.push({ config, response }); responseCache.set(config, response); } // Cache size should be limited to maxSize const stats = responseCache.getStats(); expect(stats.size).toBeLessThanOrEqual(100); // Oldest entries should be evicted // First few entries should be missing expect(responseCache.get(entries[0].config)).toBeNull(); }); }); });