import { beforeEach, describe, expect, it, mock } from 'bun:test'; import { QueueRateLimiter } from '../src/rate-limiter'; import type { RateLimitRule } from '../src/types'; describe('QueueRateLimiter', () => { const mockRedisClient = { host: 'localhost', port: 6379, }; const mockLogger = { info: mock(() => {}), error: mock(() => {}), warn: mock(() => {}), debug: mock(() => {}), }; beforeEach(() => { mockLogger.info = mock(() => {}); mockLogger.error = mock(() => {}); mockLogger.warn = mock(() => {}); mockLogger.debug = mock(() => {}); }); describe('constructor', () => { it('should create rate limiter', () => { const limiter = new QueueRateLimiter(mockRedisClient, mockLogger); expect(limiter).toBeDefined(); }); }); describe('addRule', () => { it('should add a rate limit rule', () => { const limiter = new QueueRateLimiter(mockRedisClient, mockLogger); const rule: RateLimitRule = { level: 'queue', queueName: 'test-queue', config: { points: 100, duration: 60, }, }; limiter.addRule(rule); expect(mockLogger.info).toHaveBeenCalledWith( 'Rate limit rule added', expect.objectContaining({ level: 'queue', queueName: 'test-queue', }) ); }); it('should add operation-level rule', () => { const limiter = new QueueRateLimiter(mockRedisClient, mockLogger); const rule: RateLimitRule = { level: 'operation', queueName: 'test-queue', handler: 'user-service', operation: 'process-user', config: { points: 10, duration: 60, blockDuration: 300, }, }; limiter.addRule(rule); const rules = limiter.getRules(); expect(rules).toHaveLength(1); expect(rules[0]).toEqual(rule); }); }); describe('checkLimit', () => { it('should allow when no rules apply', async () => { const limiter = new QueueRateLimiter(mockRedisClient, mockLogger); const result = await limiter.checkLimit('test-queue', 'handler', 'operation'); expect(result.allowed).toBe(true); expect(result.appliedRule).toBeUndefined(); }); it('should check against global rule', async () => { const limiter = new QueueRateLimiter(mockRedisClient, mockLogger); const globalRule: RateLimitRule = { level: 'global', config: { points: 1000, duration: 60 }, }; limiter.addRule(globalRule); const result = await limiter.checkLimit('any-queue', 'any-handler', 'any-op'); // In test environment without real Redis, it returns allowed: true on error expect(result.allowed).toBe(true); // Check that error was logged expect(mockLogger.error).toHaveBeenCalledWith( 'Rate limit check failed', expect.objectContaining({ queueName: 'any-queue', handler: 'any-handler', operation: 'any-op', }) ); }); it('should prefer more specific rules', async () => { const limiter = new QueueRateLimiter(mockRedisClient, mockLogger); // Add rules from least to most specific const globalRule: RateLimitRule = { level: 'global', config: { points: 1000, duration: 60 }, }; const queueRule: RateLimitRule = { level: 'queue', queueName: 'test-queue', config: { points: 100, duration: 60 }, }; const handlerRule: RateLimitRule = { level: 'handler', queueName: 'test-queue', handler: 'test-handler', config: { points: 50, duration: 60 }, }; const operationRule: RateLimitRule = { level: 'operation', queueName: 'test-queue', handler: 'test-handler', operation: 'test-op', config: { points: 10, duration: 60 }, }; limiter.addRule(globalRule); limiter.addRule(queueRule); limiter.addRule(handlerRule); limiter.addRule(operationRule); // Operation level should take precedence const result = await limiter.checkLimit('test-queue', 'test-handler', 'test-op'); expect(result.allowed).toBe(true); // Check that the most specific rule was attempted (operation level) expect(mockLogger.error).toHaveBeenCalledWith( 'Rate limit check failed', expect.objectContaining({ queueName: 'test-queue', handler: 'test-handler', operation: 'test-op', }) ); }); }); describe('getStatus', () => { it('should get rate limit status', async () => { const limiter = new QueueRateLimiter(mockRedisClient, mockLogger); const rule: RateLimitRule = { level: 'queue', queueName: 'test-queue', config: { points: 100, duration: 60 }, }; limiter.addRule(rule); const status = await limiter.getStatus('test-queue', 'handler', 'operation'); expect(status.queueName).toBe('test-queue'); expect(status.handler).toBe('handler'); expect(status.operation).toBe('operation'); expect(status.appliedRule).toEqual(rule); }); it('should return status without rule when none apply', async () => { const limiter = new QueueRateLimiter(mockRedisClient, mockLogger); const status = await limiter.getStatus('test-queue', 'handler', 'operation'); expect(status.queueName).toBe('test-queue'); expect(status.appliedRule).toBeUndefined(); expect(status.limit).toBeUndefined(); }); }); describe('reset', () => { it('should reset rate limits for specific operation', async () => { const limiter = new QueueRateLimiter(mockRedisClient, mockLogger); const rule: RateLimitRule = { level: 'operation', queueName: 'test-queue', handler: 'test-handler', operation: 'test-op', config: { points: 10, duration: 60 }, }; limiter.addRule(rule); try { await limiter.reset('test-queue', 'test-handler', 'test-op'); } catch (error) { // In test environment, limiter.delete will fail due to no Redis connection // That's expected, just ensure the method can be called } // The method should at least attempt to reset expect(limiter.getRules()).toContain(rule); }); it('should warn about broad reset', async () => { const limiter = new QueueRateLimiter(mockRedisClient, mockLogger); await limiter.reset('test-queue'); expect(mockLogger.warn).toHaveBeenCalledWith( 'Broad reset not implemented yet', expect.objectContaining({ queueName: 'test-queue' }) ); }); }); describe('removeRule', () => { it('should remove a rule', () => { const limiter = new QueueRateLimiter(mockRedisClient, mockLogger); const rule: RateLimitRule = { level: 'queue', queueName: 'test-queue', config: { points: 100, duration: 60 }, }; limiter.addRule(rule); expect(limiter.getRules()).toHaveLength(1); const removed = limiter.removeRule('queue', 'test-queue'); expect(removed).toBe(true); expect(limiter.getRules()).toHaveLength(0); }); it('should return false when rule not found', () => { const limiter = new QueueRateLimiter(mockRedisClient, mockLogger); const removed = limiter.removeRule('queue', 'non-existent'); expect(removed).toBe(false); }); }); describe('getRules', () => { it('should return all configured rules', () => { const limiter = new QueueRateLimiter(mockRedisClient, mockLogger); const rule1: RateLimitRule = { level: 'global', config: { points: 1000, duration: 60 }, }; const rule2: RateLimitRule = { level: 'queue', queueName: 'test-queue', config: { points: 100, duration: 60 }, }; limiter.addRule(rule1); limiter.addRule(rule2); const rules = limiter.getRules(); expect(rules).toHaveLength(2); expect(rules).toContain(rule1); expect(rules).toContain(rule2); }); }); describe('error handling', () => { it('should allow on rate limiter error', async () => { const limiter = new QueueRateLimiter(mockRedisClient, mockLogger); // Add a rule but don't set up the actual limiter to cause an error const rule: RateLimitRule = { level: 'queue', queueName: 'test-queue', config: { points: 100, duration: 60 }, }; limiter.addRule(rule); // Force the limiter map to be empty to simulate error (limiter as any).limiters.clear(); const result = await limiter.checkLimit('test-queue', 'handler', 'operation'); expect(result.allowed).toBe(true); // Should allow on error expect(mockLogger.warn).toHaveBeenCalledWith( 'Rate limiter not found for rule', expect.any(Object) ); }); }); describe('hierarchical rule precedence', () => { it('should correctly apply rule hierarchy', () => { const limiter = new QueueRateLimiter(mockRedisClient, mockLogger); // Add multiple rules at different levels const rules: RateLimitRule[] = [ { level: 'global', config: { points: 10000, duration: 60 }, }, { level: 'queue', queueName: 'email-queue', config: { points: 1000, duration: 60 }, }, { level: 'handler', queueName: 'email-queue', handler: 'email-service', config: { points: 100, duration: 60 }, }, { level: 'operation', queueName: 'email-queue', handler: 'email-service', operation: 'send-bulk', config: { points: 10, duration: 60 }, }, ]; rules.forEach(rule => limiter.addRule(rule)); // Test that getMostSpecificRule works correctly const specificRule = (limiter as any).getMostSpecificRule( 'email-queue', 'email-service', 'send-bulk' ); expect(specificRule?.level).toBe('operation'); expect(specificRule?.config.points).toBe(10); }); }); });