513 lines
13 KiB
TypeScript
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();
|
|
});
|
|
});
|
|
});
|