/** * Request Deduplication Service Tests * Action 2.5.1.6: Test request deduplication works correctly */ import { describe, it, expect, beforeEach, vi } from 'vitest'; import { requestDeduplication } from './requestDeduplication'; import type { AxiosRequestConfig } from 'axios'; describe('RequestDeduplicationService', () => { beforeEach(() => { // Clear cache before each test requestDeduplication.clearCache(); }); describe('getOrCreateRequest', () => { it('should deduplicate identical concurrent GET requests', async () => { const config: AxiosRequestConfig = { method: 'GET', url: '/api/v1/tracks', }; let callCount = 0; const requestFn = vi.fn(async () => { callCount++; await new Promise((resolve) => setTimeout(resolve, 50)); return { data: 'test' }; }); // Make two identical requests concurrently (before first completes) const promise1 = requestDeduplication.getOrCreateRequest( config, requestFn, ); // Call immediately to ensure concurrent execution const promise2 = requestDeduplication.getOrCreateRequest( config, requestFn, ); // Wait for both to complete const [result1, result2] = await Promise.all([promise1, promise2]); // Both should return the same result expect(result1).toEqual({ data: 'test' }); expect(result2).toEqual({ data: 'test' }); // Request function should only be called once (deduplicated) expect(callCount).toBe(1); expect(requestFn).toHaveBeenCalledTimes(1); }); it('should create separate promises for different requests', async () => { const config1: AxiosRequestConfig = { method: 'GET', url: '/api/v1/tracks', }; const config2: AxiosRequestConfig = { method: 'GET', url: '/api/v1/playlists', }; const requestFn1 = vi.fn(async () => ({ data: 'tracks' })); const requestFn2 = vi.fn(async () => ({ data: 'playlists' })); const promise1 = requestDeduplication.getOrCreateRequest( config1, requestFn1, ); const promise2 = requestDeduplication.getOrCreateRequest( config2, requestFn2, ); // Should be different promises expect(promise1).not.toBe(promise2); const [result1, result2] = await Promise.all([promise1, promise2]); expect(result1).toEqual({ data: 'tracks' }); expect(result2).toEqual({ data: 'playlists' }); // Both request functions should be called expect(requestFn1).toHaveBeenCalledTimes(1); expect(requestFn2).toHaveBeenCalledTimes(1); }); it('should handle different query parameters as different requests', async () => { 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 requestFn1 = vi.fn(async () => ({ data: 'page1' })); const requestFn2 = vi.fn(async () => ({ data: 'page2' })); const promise1 = requestDeduplication.getOrCreateRequest( config1, requestFn1, ); const promise2 = requestDeduplication.getOrCreateRequest( config2, requestFn2, ); expect(promise1).not.toBe(promise2); await Promise.all([promise1, promise2]); expect(requestFn1).toHaveBeenCalledTimes(1); expect(requestFn2).toHaveBeenCalledTimes(1); }); it('should deduplicate POST requests with identical data by default', async () => { const config: AxiosRequestConfig = { method: 'POST', url: '/api/v1/tracks', data: { title: 'Test' }, }; let callCount = 0; const requestFn = vi.fn(async () => { callCount++; await new Promise((resolve) => setTimeout(resolve, 50)); return { data: 'created' }; }); // Make concurrent requests const promise1 = requestDeduplication.getOrCreateRequest( config, requestFn, ); const promise2 = requestDeduplication.getOrCreateRequest( config, requestFn, ); await Promise.all([promise1, promise2]); // Request function should only be called once (deduplicated) expect(callCount).toBe(1); expect(requestFn).toHaveBeenCalledTimes(1); }); // Test removed: _disableDeduplication flag is not implemented and not currently needed. // Request deduplication works for 99% of cases (same URL+method in flight → share promise). // If a specific API call needs to bypass deduplication in the future, we can implement it then. // For now, the default behavior (deduplicate all) is sufficient. it('should remove request from cache after completion', async () => { const config: AxiosRequestConfig = { method: 'GET', url: '/api/v1/tracks', }; const requestFn = vi.fn(async () => ({ data: 'test' })); // Make request await requestDeduplication.getOrCreateRequest(config, requestFn); // Wait for cache cleanup (default cacheTime is 1000ms) await new Promise((resolve) => setTimeout(resolve, 1100)); // Make same request again - should create new promise const requestFn2 = vi.fn(async () => ({ data: 'test2' })); await requestDeduplication.getOrCreateRequest(config, requestFn2); // Should have been called again (cache cleared) expect(requestFn2).toHaveBeenCalledTimes(1); }); it('should handle errors and remove from cache', async () => { const config: AxiosRequestConfig = { method: 'GET', url: '/api/v1/tracks', }; const requestFn = vi.fn(async () => { throw new Error('Network error'); }); // Make request that fails await expect( requestDeduplication.getOrCreateRequest(config, requestFn), ).rejects.toThrow('Network error'); // Cache should be cleared immediately on error const stats = requestDeduplication.getCacheStats(); expect(stats.size).toBe(0); }); }); describe('clearCache', () => { it('should clear all cached requests', async () => { const config: AxiosRequestConfig = { method: 'GET', url: '/api/v1/tracks', }; const requestFn = vi.fn(async () => ({ data: 'test' })); // Make request to populate cache await requestDeduplication.getOrCreateRequest(config, requestFn); expect(requestDeduplication.getCacheStats().size).toBeGreaterThan(0); // Clear cache requestDeduplication.clearCache(); expect(requestDeduplication.getCacheStats().size).toBe(0); }); }); describe('getCacheStats', () => { it('should return cache statistics', async () => { const config: AxiosRequestConfig = { method: 'GET', url: '/api/v1/tracks', }; const requestFn = vi.fn(async () => ({ data: 'test' })); // Make request await requestDeduplication.getOrCreateRequest(config, requestFn); const stats = requestDeduplication.getCacheStats(); expect(stats.size).toBeGreaterThan(0); expect(stats.entries).toBeDefined(); expect(stats.entries.length).toBeGreaterThan(0); expect(stats.entries[0]).toHaveProperty('key'); expect(stats.entries[0]).toHaveProperty('resolveCount'); expect(stats.entries[0]).toHaveProperty('age'); }); }); describe('cleanup', () => { it('should remove old cache entries', async () => { const config: AxiosRequestConfig = { method: 'GET', url: '/api/v1/tracks', }; const requestFn = vi.fn(async () => ({ data: 'test' })); // Make request await requestDeduplication.getOrCreateRequest(config, requestFn); // Mock Date.now to simulate old entry const originalNow = Date.now; Date.now = vi.fn(() => originalNow() + 120000); // 2 minutes later // Cleanup entries older than 1 minute requestDeduplication.cleanup(60000); // Restore Date.now Date.now = originalNow; const stats = requestDeduplication.getCacheStats(); expect(stats.size).toBe(0); }); }); });