271 lines
8.2 KiB
TypeScript
271 lines
8.2 KiB
TypeScript
/**
|
|
* 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);
|
|
});
|
|
});
|
|
});
|