import { describe, test, expect, beforeEach, afterEach } from 'bun:test'; import { QueueRateLimiter } from '../src/rate-limiter'; import { getRedisConnection } from '../src/utils'; import Redis from 'ioredis'; // Suppress Redis connection errors in tests process.on('unhandledRejection', (reason, promise) => { if (reason && typeof reason === 'object' && 'message' in reason) { const message = (reason as Error).message; if (message.includes('Connection is closed') || message.includes('Connection is in monitoring mode')) { return; } } console.error('Unhandled Rejection at:', promise, 'reason:', reason); }); describe('QueueRateLimiter', () => { let redisClient: Redis; let rateLimiter: QueueRateLimiter; const redisConfig = { host: 'localhost', port: 6379, password: '', db: 0, }; beforeEach(async () => { // Create Redis client redisClient = new Redis(getRedisConnection(redisConfig)); // Clear Redis keys for tests try { const keys = await redisClient.keys('rl:*'); if (keys.length > 0) { await redisClient.del(...keys); } } catch (error) { // Ignore cleanup errors } rateLimiter = new QueueRateLimiter(redisClient); }); afterEach(async () => { if (redisClient) { try { await redisClient.quit(); } catch (error) { // Ignore cleanup errors } } await new Promise(resolve => setTimeout(resolve, 50)); }); describe('Rate Limit Rules', () => { test('should add and enforce global rate limit', async () => { rateLimiter.addRule({ level: 'global', config: { points: 5, duration: 1, // 1 second }, }); // Consume 5 points for (let i = 0; i < 5; i++) { const result = await rateLimiter.checkLimit('any-handler', 'any-operation'); expect(result.allowed).toBe(true); } // 6th request should be blocked const blocked = await rateLimiter.checkLimit('any-handler', 'any-operation'); expect(blocked.allowed).toBe(false); expect(blocked.retryAfter).toBeGreaterThan(0); }); test('should add and enforce handler-level rate limit', async () => { rateLimiter.addRule({ level: 'handler', handler: 'api-handler', config: { points: 3, duration: 1, }, }); // api-handler should be limited for (let i = 0; i < 3; i++) { const result = await rateLimiter.checkLimit('api-handler', 'any-operation'); expect(result.allowed).toBe(true); } const blocked = await rateLimiter.checkLimit('api-handler', 'any-operation'); expect(blocked.allowed).toBe(false); // Other handlers should not be limited const otherHandler = await rateLimiter.checkLimit('other-handler', 'any-operation'); expect(otherHandler.allowed).toBe(true); }); test('should add and enforce operation-level rate limit', async () => { rateLimiter.addRule({ level: 'operation', handler: 'data-handler', operation: 'fetch-prices', config: { points: 2, duration: 1, }, }); // Specific operation should be limited for (let i = 0; i < 2; i++) { const result = await rateLimiter.checkLimit('data-handler', 'fetch-prices'); expect(result.allowed).toBe(true); } const blocked = await rateLimiter.checkLimit('data-handler', 'fetch-prices'); expect(blocked.allowed).toBe(false); // Other operations on same handler should work const otherOp = await rateLimiter.checkLimit('data-handler', 'fetch-volume'); expect(otherOp.allowed).toBe(true); }); test('should enforce multiple rate limits (most restrictive wins)', async () => { // Global: 10/sec rateLimiter.addRule({ level: 'global', config: { points: 10, duration: 1 }, }); // Handler: 5/sec rateLimiter.addRule({ level: 'handler', handler: 'test-handler', config: { points: 5, duration: 1 }, }); // Operation: 2/sec rateLimiter.addRule({ level: 'operation', handler: 'test-handler', operation: 'test-op', config: { points: 2, duration: 1 }, }); // Should be limited by operation level (most restrictive) for (let i = 0; i < 2; i++) { const result = await rateLimiter.checkLimit('test-handler', 'test-op'); expect(result.allowed).toBe(true); } const blocked = await rateLimiter.checkLimit('test-handler', 'test-op'); expect(blocked.allowed).toBe(false); }); }); describe('Rate Limit Status', () => { test('should get rate limit status', async () => { rateLimiter.addRule({ level: 'handler', handler: 'status-test', config: { points: 10, duration: 60 }, }); // Consume some points await rateLimiter.checkLimit('status-test', 'operation'); await rateLimiter.checkLimit('status-test', 'operation'); const status = await rateLimiter.getStatus('status-test', 'operation'); expect(status.handler).toBe('status-test'); expect(status.operation).toBe('operation'); expect(status.limits.length).toBe(1); expect(status.limits[0].points).toBe(10); expect(status.limits[0].remaining).toBe(8); }); test('should show multiple applicable limits in status', async () => { rateLimiter.addRule({ level: 'global', config: { points: 100, duration: 60 }, }); rateLimiter.addRule({ level: 'handler', handler: 'multi-test', config: { points: 50, duration: 60 }, }); const status = await rateLimiter.getStatus('multi-test', 'operation'); expect(status.limits.length).toBe(2); const globalLimit = status.limits.find(l => l.level === 'global'); const handlerLimit = status.limits.find(l => l.level === 'handler'); expect(globalLimit?.points).toBe(100); expect(handlerLimit?.points).toBe(50); }); }); describe('Rate Limit Management', () => { test('should reset rate limits', async () => { rateLimiter.addRule({ level: 'handler', handler: 'reset-test', config: { points: 1, duration: 60 }, }); // Consume the limit await rateLimiter.checkLimit('reset-test', 'operation'); let blocked = await rateLimiter.checkLimit('reset-test', 'operation'); expect(blocked.allowed).toBe(false); // Reset limits await rateLimiter.reset('reset-test'); // Should be allowed again const afterReset = await rateLimiter.checkLimit('reset-test', 'operation'); expect(afterReset.allowed).toBe(true); }); test('should get all rules', async () => { rateLimiter.addRule({ level: 'global', config: { points: 100, duration: 60 }, }); rateLimiter.addRule({ level: 'handler', handler: 'test', config: { points: 50, duration: 60 }, }); const rules = rateLimiter.getRules(); expect(rules.length).toBe(2); expect(rules[0].level).toBe('global'); expect(rules[1].level).toBe('handler'); }); test('should remove specific rule', async () => { rateLimiter.addRule({ level: 'handler', handler: 'remove-test', config: { points: 1, duration: 1 }, }); // Verify rule exists await rateLimiter.checkLimit('remove-test', 'op'); let blocked = await rateLimiter.checkLimit('remove-test', 'op'); expect(blocked.allowed).toBe(false); // Remove rule const removed = rateLimiter.removeRule('handler', 'remove-test'); expect(removed).toBe(true); // Should not be limited anymore const afterRemove = await rateLimiter.checkLimit('remove-test', 'op'); expect(afterRemove.allowed).toBe(true); }); }); describe('Block Duration', () => { test('should block for specified duration after limit exceeded', async () => { rateLimiter.addRule({ level: 'handler', handler: 'block-test', config: { points: 1, duration: 1, blockDuration: 2, // Block for 2 seconds }, }); // Consume limit await rateLimiter.checkLimit('block-test', 'op'); // Should be blocked const blocked = await rateLimiter.checkLimit('block-test', 'op'); expect(blocked.allowed).toBe(false); expect(blocked.retryAfter).toBeGreaterThanOrEqual(1000); // At least 1 second }); }); describe('Error Handling', () => { test('should allow requests when rate limiter fails', async () => { // Create a rate limiter with invalid redis client const badRedis = new Redis({ host: 'invalid-host', port: 9999, retryStrategy: () => null, // Disable retries }); const failingLimiter = new QueueRateLimiter(badRedis); failingLimiter.addRule({ level: 'global', config: { points: 1, duration: 1 }, }); // Should allow even though Redis is not available const result = await failingLimiter.checkLimit('test', 'test'); expect(result.allowed).toBe(true); badRedis.disconnect(); }); }); });