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

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);
});
});
});