stock-bot/libs/core/queue/test/rate-limiter.test.ts
2025-06-25 11:38:23 -04:00

349 lines
10 KiB
TypeScript

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