veza/apps/web/src/services/responseCache.test.ts

513 lines
13 KiB
TypeScript

/**
* 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();
});
});
});