diff --git a/src/constants/app.constants.ts b/src/constants/app.constants.ts index bc48b2cc..280d5eaf 100644 --- a/src/constants/app.constants.ts +++ b/src/constants/app.constants.ts @@ -51,6 +51,8 @@ export const API_TIMEOUT_UPLOAD = 60000; export const API_TIMEOUT_DOWNLOAD = 60000; export const API_TIMEOUT_SEARCH = 15000; export const API_CACHE_TTL_DEFAULT = 300000; // 5 minutes +export const API_CACHE_MAX_ENTRIES_DEFAULT = 100; +export const API_CACHE_MAX_SIZE_DEFAULT = 100; // API URLs & Endpoints export const DEFAULT_SOCKET_URL = 'http://localhost:3001'; diff --git a/src/lib/__tests__/api.test.ts b/src/lib/__tests__/api.test.ts index 0baa9fee..fb25a845 100644 --- a/src/lib/__tests__/api.test.ts +++ b/src/lib/__tests__/api.test.ts @@ -1,5 +1,6 @@ -import { afterEach, describe, expect, it, vi } from 'vitest'; -import { getRetryDelay } from '../api'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { ApiClientImpl, getRetryDelay } from '../api'; +import { API_CACHE_MAX_ENTRIES_DEFAULT } from '@/constants/app.constants'; describe('getRetryDelay', () => { afterEach(() => { @@ -26,3 +27,155 @@ describe('getRetryDelay', () => { expect(getRetryDelay(3, 100)).toBe(400); }); }); + +describe('ApiClient LRU Response Cache', () => { + let originalFetch: typeof global.fetch; + + beforeEach(() => { + originalFetch = global.fetch; + }); + + afterEach(() => { + global.fetch = originalFetch; + vi.restoreAllMocks(); + }); + + it('caches GET responses when useCache is true', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ message: 'hello' }), + }); + global.fetch = fetchMock as any; + + const client = new ApiClientImpl({ baseURL: 'https://api.example.com' }); + + const res1 = await client.get('/test', { useCache: true, dedupe: false }); + const res2 = await client.get('/test', { useCache: true, dedupe: false }); + + expect(res1).toEqual({ message: 'hello' }); + expect(res2).toEqual({ message: 'hello' }); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(client.getCacheSize()).toBe(1); + }); + + it('evicts the least recently used entry when maxCacheSize is exceeded', async () => { + let callCount = 0; + const fetchMock = vi.fn().mockImplementation((url: string) => { + callCount++; + return Promise.resolve({ + ok: true, + json: async () => ({ url, count: callCount }), + }); + }); + global.fetch = fetchMock as any; + + const client = new ApiClientImpl({ + baseURL: 'https://api.example.com', + maxCacheSize: 3, + }); + + // Populate cache up to cap (entries: 1, 2, 3) + await client.get('/item1', { useCache: true, dedupe: false }); + await client.get('/item2', { useCache: true, dedupe: false }); + await client.get('/item3', { useCache: true, dedupe: false }); + expect(client.getCacheSize()).toBe(3); + expect(fetchMock).toHaveBeenCalledTimes(3); + + // Access item4: should evict item1 (oldest / LRU) + await client.get('/item4', { useCache: true, dedupe: false }); + expect(client.getCacheSize()).toBe(3); + expect(fetchMock).toHaveBeenCalledTimes(4); + + // item2, item3, item4 should still be cached + await client.get('/item2', { useCache: true, dedupe: false }); + await client.get('/item3', { useCache: true, dedupe: false }); + await client.get('/item4', { useCache: true, dedupe: false }); + expect(fetchMock).toHaveBeenCalledTimes(4); // No additional network requests + + // item1 was evicted, so accessing it should trigger a fetch + await client.get('/item1', { useCache: true, dedupe: false }); + expect(fetchMock).toHaveBeenCalledTimes(5); + }); + + it('promotes an accessed item to MRU so older unaccessed items are evicted first', async () => { + const fetchMock = vi.fn().mockImplementation((url: string) => + Promise.resolve({ + ok: true, + json: async () => ({ url }), + }), + ); + global.fetch = fetchMock as any; + + const client = new ApiClientImpl({ + baseURL: 'https://api.example.com', + maxCacheSize: 2, + }); + + // Add item1 and item2 (Order: item1 [oldest], item2 [newest]) + await client.get('/item1', { useCache: true, dedupe: false }); + await client.get('/item2', { useCache: true, dedupe: false }); + + // Read item1 from cache -> refreshes item1 to MRU (Order: item2 [oldest], item1 [newest]) + await client.get('/item1', { useCache: true, dedupe: false }); + expect(fetchMock).toHaveBeenCalledTimes(2); + + // Insert item3 -> should evict item2, NOT item1 + await client.get('/item3', { useCache: true, dedupe: false }); + expect(fetchMock).toHaveBeenCalledTimes(3); + + // item1 should still be cached + await client.get('/item1', { useCache: true, dedupe: false }); + expect(fetchMock).toHaveBeenCalledTimes(3); + + // item2 was evicted -> triggers a fetch + await client.get('/item2', { useCache: true, dedupe: false }); + expect(fetchMock).toHaveBeenCalledTimes(4); + }); + + it('supports maxCacheEntries alias in configuration', () => { + const client = new ApiClientImpl({ + maxCacheEntries: 50, + }); + expect(client['config'].maxCacheSize).toBe(50); + }); + + it('defaults to API_CACHE_MAX_ENTRIES_DEFAULT when not configured', () => { + const client = new ApiClientImpl({}); + expect(client['config'].maxCacheSize).toBe(API_CACHE_MAX_ENTRIES_DEFAULT); + }); + + it('does not cache when maxCacheSize is 0', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ value: 123 }), + }); + global.fetch = fetchMock as any; + + const client = new ApiClientImpl({ + baseURL: 'https://api.example.com', + maxCacheSize: 0, + }); + + await client.get('/test', { useCache: true, dedupe: false }); + await client.get('/test', { useCache: true, dedupe: false }); + + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(client.getCacheSize()).toBe(0); + }); + + it('invalidates cache properly', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ data: 'ok' }), + }); + global.fetch = fetchMock as any; + + const client = new ApiClientImpl({ baseURL: 'https://api.example.com' }); + + await client.get('/resource', { useCache: true, dedupe: false }); + expect(client.getCacheSize()).toBe(1); + + client.invalidateCache(); + expect(client.getCacheSize()).toBe(0); + }); +}); diff --git a/src/lib/api.ts b/src/lib/api.ts index f49364b4..1b75707c 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -11,6 +11,7 @@ import { RECONNECT_DELAY_MS, STORAGE_KEYS, API_CACHE_TTL_DEFAULT, + API_CACHE_MAX_ENTRIES_DEFAULT, } from '@/constants/app.constants'; import { logContextStorage } from './logging/context'; import { tokenManager } from '@/lib/auth/tokenManager'; @@ -27,6 +28,7 @@ const DEFAULT_TIMEOUT_MS = API_TIMEOUT_DEFAULT; const API_MAX_RETRIES = MAX_RETRIES; const RETRY_DELAY_MS = RECONNECT_DELAY_MS; const DEFAULT_TTL_MS = API_CACHE_TTL_DEFAULT; +const DEFAULT_MAX_CACHE_SIZE = API_CACHE_MAX_ENTRIES_DEFAULT; // --------------------------------------------------------------------------- // Types @@ -60,6 +62,8 @@ export interface ApiClientConfig { retryDelay?: number; apiVersion?: string; defaultTTL?: number; + maxCacheSize?: number; + maxCacheEntries?: number; } // --------------------------------------------------------------------------- @@ -104,6 +108,7 @@ class ApiClientImpl { private errorInterceptors: ErrorInterceptor[] = []; constructor(config: ApiClientConfig = {}) { + const maxCache = config.maxCacheSize ?? config.maxCacheEntries ?? DEFAULT_MAX_CACHE_SIZE; this.config = { baseURL: config.baseURL || process.env.NEXT_PUBLIC_API_URL || '', timeout: config.timeout || DEFAULT_TIMEOUT_MS, @@ -111,6 +116,8 @@ class ApiClientImpl { retryDelay: config.retryDelay || RETRY_DELAY_MS, apiVersion: config.apiVersion || DEFAULT_API_VERSION, defaultTTL: config.defaultTTL || DEFAULT_TTL_MS, + maxCacheSize: maxCache, + maxCacheEntries: maxCache, }; } @@ -125,6 +132,36 @@ class ApiClientImpl { return localStorage.getItem(STORAGE_KEYS.AUTH_TOKEN); } + private getFromCache(key: string): CacheEntry | undefined { + const cached = this.cache.get(key); + if (cached) { + // LRU refresh: re-insert so it becomes the most recently used entry + this.cache.delete(key); + this.cache.set(key, cached); + } + return cached; + } + + private setInCache(key: string, data: T): void { + if (this.config.maxCacheSize <= 0) return; + + if (this.cache.has(key)) { + this.cache.delete(key); + } + + while (this.cache.size >= this.config.maxCacheSize) { + const oldestKey = this.cache.keys().next().value; + if (oldestKey === undefined) break; + this.cache.delete(oldestKey); + } + + this.cache.set(key, { data, timestamp: Date.now() }); + } + + getCacheSize(): number { + return this.cache.size; + } + invalidateCache(url?: string) { if (url) { this.cache.delete(url); @@ -163,7 +200,7 @@ class ApiClientImpl { // CACHE if (config.method === 'GET' && config.useCache && !config._bypassCacheRead) { - const cached = this.cache.get(cacheKey); + const cached = this.getFromCache(cacheKey); if (cached) { const ttl = config.ttl ?? this.config.defaultTTL; if (Date.now() - cached.timestamp < ttl) return cached.data; @@ -228,7 +265,7 @@ class ApiClientImpl { const data = await response.json(); if (config.method === 'GET' && config.useCache) { - this.cache.set(cacheKey, { data, timestamp: Date.now() }); + this.setInCache(cacheKey, data); } if (['POST', 'PUT', 'PATCH', 'DELETE'].includes(config.method || '')) { @@ -327,4 +364,4 @@ class ApiClientImpl { // Singleton export const apiClient = new ApiClientImpl(); -export type { ApiClientImpl }; +export { ApiClientImpl };