rate-limiting.test.ts•14.9 kB
import { describe, it, expect, beforeEach, afterEach, jest } from '@jest/globals';
import { OpenFoodFactsClient } from '../src/client.js';
import { OpenFoodFactsConfig } from '../src/types.js';
import axios from 'axios';
import MockAdapter from 'axios-mock-adapter';
import { mockNutellaProductResponse, mockSearchResponse } from './fixtures/products.js';
describe('Rate Limiting', () => {
let client: OpenFoodFactsClient;
let mockAxios: MockAdapter;
let config: OpenFoodFactsConfig;
beforeEach(() => {
jest.useFakeTimers();
config = {
baseUrl: 'https://test.openfoodfacts.net',
userAgent: 'OpenFoodFactsMCP/1.0 (test)',
rateLimits: {
products: 3, // Strict limits for testing
search: 2, // Strict limits for testing
facets: 1, // Strict limits for testing
},
};
client = new OpenFoodFactsClient(config);
mockAxios = new MockAdapter(axios);
// Mock successful responses for all endpoints
mockAxios.onGet(/\/api\/v2\/product/).reply(200, mockNutellaProductResponse);
mockAxios.onGet(/\/cgi\/search\.pl/).reply(200, mockSearchResponse);
});
afterEach(() => {
mockAxios.restore();
jest.useRealTimers();
jest.clearAllMocks();
});
describe('Product Rate Limiting', () => {
it('should allow requests within rate limit', async () => {
// Should allow up to 3 requests
await client.getProduct('123456789');
await client.getProduct('123456790');
await client.getProduct('123456791');
// All should succeed without throwing
expect(mockAxios.history.get).toHaveLength(3);
});
it('should enforce rate limit after exceeding threshold', async () => {
// Use up the rate limit
await client.getProduct('123456789');
await client.getProduct('123456790');
await client.getProduct('123456791');
// Fourth request should fail
await expect(client.getProduct('123456792'))
.rejects.toThrow('Rate limit exceeded for products');
// Verify only 3 requests were made
expect(mockAxios.history.get).toHaveLength(3);
});
it('should reset rate limit after time window', async () => {
// Exhaust rate limit
await client.getProduct('123456789');
await client.getProduct('123456790');
await client.getProduct('123456791');
// Should fail
await expect(client.getProduct('123456792'))
.rejects.toThrow('Rate limit exceeded for products');
// Advance time by 61 seconds (past the 60-second window)
jest.advanceTimersByTime(61000);
// Should now succeed
await client.getProduct('123456793');
expect(mockAxios.history.get).toHaveLength(4);
});
it('should provide accurate wait time in error message', async () => {
const startTime = Date.now();
// Exhaust rate limit
await client.getProduct('123456789');
await client.getProduct('123456790');
await client.getProduct('123456791');
// Advance time by 30 seconds
jest.advanceTimersByTime(30000);
try {
await client.getProduct('123456792');
fail('Should have thrown rate limit error');
} catch (error) {
expect(error).toBeInstanceOf(Error);
const errorMessage = (error as Error).message;
expect(errorMessage).toContain('Rate limit exceeded for products');
expect(errorMessage).toContain('Wait 30 seconds');
}
});
it('should handle partial rate limit reset correctly', async () => {
// Make 2 requests
await client.getProduct('123456789');
await client.getProduct('123456790');
// Advance time by 30 seconds (half the window)
jest.advanceTimersByTime(30000);
// Make 1 more request (should succeed, total = 3)
await client.getProduct('123456791');
// Next request should fail (would be 4th)
await expect(client.getProduct('123456792'))
.rejects.toThrow('Rate limit exceeded for products');
// Advance time by another 31 seconds (past original window)
jest.advanceTimersByTime(31000);
// Should now succeed (new window)
await client.getProduct('123456793');
});
it('should track requests accurately with concurrent calls', async () => {
// Start multiple requests simultaneously
const promises = [
client.getProduct('123456789'),
client.getProduct('123456790'),
client.getProduct('123456791'),
];
await Promise.all(promises);
// Fourth request should fail
await expect(client.getProduct('123456792'))
.rejects.toThrow('Rate limit exceeded for products');
});
});
describe('Search Rate Limiting', () => {
it('should allow requests within search rate limit', async () => {
await client.searchProducts({ search: 'test1' });
await client.searchProducts({ search: 'test2' });
expect(mockAxios.history.get.filter(req => req.url?.includes('/cgi/search.pl'))).toHaveLength(2);
});
it('should enforce search rate limit', async () => {
// Use up search rate limit (2 requests)
await client.searchProducts({ search: 'test1' });
await client.searchProducts({ search: 'test2' });
// Third request should fail
await expect(client.searchProducts({ search: 'test3' }))
.rejects.toThrow('Rate limit exceeded for search');
});
it('should reset search rate limit after time window', async () => {
// Exhaust search rate limit
await client.searchProducts({ search: 'test1' });
await client.searchProducts({ search: 'test2' });
// Should fail
await expect(client.searchProducts({ search: 'test3' }))
.rejects.toThrow('Rate limit exceeded for search');
// Advance time past window
jest.advanceTimersByTime(61000);
// Should now succeed
await client.searchProducts({ search: 'test4' });
});
});
describe('Independent Rate Limit Tracking', () => {
it('should track different endpoints independently', async () => {
// Use up product rate limit
await client.getProduct('123456789');
await client.getProduct('123456790');
await client.getProduct('123456791');
// Products should be blocked
await expect(client.getProduct('123456792'))
.rejects.toThrow('Rate limit exceeded for products');
// But search should still work (independent limit)
await client.searchProducts({ search: 'test' });
await client.searchProducts({ search: 'test2' });
// Now search should be blocked too
await expect(client.searchProducts({ search: 'test3' }))
.rejects.toThrow('Rate limit exceeded for search');
// Verify correct number of requests per endpoint
const productRequests = mockAxios.history.get.filter(req => req.url?.includes('/api/v2/product'));
const searchRequests = mockAxios.history.get.filter(req => req.url?.includes('/cgi/search.pl'));
expect(productRequests).toHaveLength(3);
expect(searchRequests).toHaveLength(2);
});
it('should reset different endpoints independently', async () => {
// Exhaust both limits at different times
await client.getProduct('123456789');
// Advance time by 30 seconds
jest.advanceTimersByTime(30000);
await client.searchProducts({ search: 'test1' });
await client.getProduct('123456790');
await client.getProduct('123456791');
await client.searchProducts({ search: 'test2' });
// Both should be blocked
await expect(client.getProduct('123456792'))
.rejects.toThrow('Rate limit exceeded for products');
await expect(client.searchProducts({ search: 'test3' }))
.rejects.toThrow('Rate limit exceeded for search');
// Advance time by 31 more seconds (61 total from first product request)
jest.advanceTimersByTime(31000);
// Products should be unblocked (past 60s from first request)
await client.getProduct('123456793');
// But search should still be blocked (only 31s since first search)
await expect(client.searchProducts({ search: 'test4' }))
.rejects.toThrow('Rate limit exceeded for search');
// Advance time by 30 more seconds (61s from first search)
jest.advanceTimersByTime(30000);
// Now search should also be unblocked
await client.searchProducts({ search: 'test5' });
});
});
describe('Rate Limit Configuration', () => {
it('should respect custom rate limits', async () => {
// Create client with very strict limits
const strictConfig = {
...config,
rateLimits: { products: 1, search: 1, facets: 1 },
};
const strictClient = new OpenFoodFactsClient(strictConfig);
// First request should succeed
await strictClient.getProduct('123456789');
// Second request should immediately fail
await expect(strictClient.getProduct('123456790'))
.rejects.toThrow('Rate limit exceeded for products');
});
it('should handle zero rate limits', async () => {
const zeroConfig = {
...config,
rateLimits: { products: 0, search: 0, facets: 0 },
};
const zeroClient = new OpenFoodFactsClient(zeroConfig);
// First request should fail immediately
await expect(zeroClient.getProduct('123456789'))
.rejects.toThrow('Rate limit exceeded for products');
});
it('should handle very high rate limits', async () => {
const highConfig = {
...config,
rateLimits: { products: 1000, search: 1000, facets: 1000 },
};
const highClient = new OpenFoodFactsClient(highConfig);
// Should allow many requests
for (let i = 0; i < 10; i++) {
await highClient.getProduct(`12345678${i}`);
}
expect(mockAxios.history.get).toHaveLength(10);
});
});
describe('Edge Cases and Error Conditions', () => {
it('should handle system time changes gracefully', async () => {
// Exhaust rate limit
await client.getProduct('123456789');
await client.getProduct('123456790');
await client.getProduct('123456791');
// Mock system time going backwards (shouldn't happen, but test resilience)
const mockDateNow = jest.spyOn(Date, 'now');
mockDateNow.mockReturnValue(Date.now() - 120000); // 2 minutes ago
// Should still respect rate limit (use max of current and stored time)
await expect(client.getProduct('123456792'))
.rejects.toThrow('Rate limit exceeded for products');
mockDateNow.mockRestore();
});
it('should handle concurrent rate limit checks correctly', async () => {
// Start requests simultaneously that would exceed limit
const promises = [];
for (let i = 0; i < 5; i++) {
promises.push(client.getProduct(`12345678${i}`).catch(error => error));
}
const results = await Promise.all(promises);
// Should have 3 successes and 2 failures
const successes = results.filter(result => !(result instanceof Error));
const failures = results.filter(result => result instanceof Error);
expect(successes).toHaveLength(3);
expect(failures).toHaveLength(2);
failures.forEach(error => {
expect(error.message).toContain('Rate limit exceeded');
});
});
it('should maintain rate limit state across different request types', async () => {
// Mix different types of requests
await client.getProduct('123456789');
await client.searchProducts({ search: 'test1' });
await client.getProduct('123456790');
await client.searchProducts({ search: 'test2' });
await client.getProduct('123456791');
// Next product request should fail
await expect(client.getProduct('123456792'))
.rejects.toThrow('Rate limit exceeded for products');
// Next search request should fail
await expect(client.searchProducts({ search: 'test3' }))
.rejects.toThrow('Rate limit exceeded for search');
});
it('should handle rate limit errors without affecting successful requests', async () => {
// Make successful requests
await client.getProduct('123456789');
await client.getProduct('123456790');
// This should succeed
const successfulResponse = await client.getProduct('123456791');
expect(successfulResponse).toEqual(mockNutellaProductResponse);
// This should fail but not affect future valid requests
await expect(client.getProduct('123456792'))
.rejects.toThrow('Rate limit exceeded for products');
// After time passes, requests should work again
jest.advanceTimersByTime(61000);
const futureResponse = await client.getProduct('123456793');
expect(futureResponse).toEqual(mockNutellaProductResponse);
});
});
describe('Memory Management', () => {
it('should clean up old rate limit trackers', async () => {
// Create many different endpoint calls to simulate memory usage
const client1 = new OpenFoodFactsClient({
...config,
rateLimits: { products: 1000, search: 1000, facets: 1000 },
});
// Make requests for different "endpoints" (in real implementation, this would be different internal tracking)
for (let i = 0; i < 100; i++) {
await client1.getProduct(`12345678${i % 10}`);
}
// Advance time significantly
jest.advanceTimersByTime(300000); // 5 minutes
// Should still work without memory issues
await client1.getProduct('999999999');
expect(mockAxios.history.get.length).toBeGreaterThan(100);
});
it('should handle multiple clients independently', async () => {
const client1 = new OpenFoodFactsClient(config);
const client2 = new OpenFoodFactsClient(config);
// Exhaust rate limit for client1
await client1.getProduct('123456789');
await client1.getProduct('123456790');
await client1.getProduct('123456791');
// client1 should be blocked
await expect(client1.getProduct('123456792'))
.rejects.toThrow('Rate limit exceeded for products');
// But client2 should still work (independent state)
await client2.getProduct('123456793');
await client2.getProduct('123456794');
await client2.getProduct('123456795');
// Now client2 should also be blocked
await expect(client2.getProduct('123456796'))
.rejects.toThrow('Rate limit exceeded for products');
});
});
});