Restructure chat service, web routes, and add test suite
Split monolithic chat service into modular structure with Matrix client support, extract web server into per-resource route modules, introduce bootstrap/main-loop separation, and add comprehensive vitest coverage across services, repositories, and utilities. Adds project docs and CLAUDE.md agent instructions.
This commit is contained in:
1 parent
dfa0580859
commit
633bb0ec04
84 files changed
+19278
-3888
No files matched your search
@@ -0,0 +1,195 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { RateLimiter } from '../src/utils/rate-limiter.js';
|
||||
import { createMockLogger } from '../tests/mockLogger.js';
|
||||
import { getTestConfig } from '../tests/testUtils.js';
|
||||
|
||||
describe('RateLimiter', () => {
|
||||
let limiter: RateLimiter;
|
||||
let logger: ReturnType<typeof createMockLogger>;
|
||||
|
||||
beforeEach(() => {
|
||||
logger = createMockLogger();
|
||||
const config = getTestConfig({
|
||||
rateLimit: { minDelayBetweenMessagesMs: 100, maxMessagesPerHour: 10, jitterMs: 50 },
|
||||
});
|
||||
limiter = new RateLimiter(config, logger);
|
||||
});
|
||||
|
||||
it('allows immediate first call', async () => {
|
||||
const start = Date.now();
|
||||
await limiter.throttle(() => Promise.resolve('done'));
|
||||
const elapsed = Date.now() - start;
|
||||
expect(elapsed).toBeLessThan(50);
|
||||
});
|
||||
|
||||
it('enforces minimum delay between messages', async () => {
|
||||
await limiter.throttle(() => Promise.resolve('first'));
|
||||
|
||||
const start = Date.now();
|
||||
await limiter.throttle(() => Promise.resolve('second'));
|
||||
const elapsed = Date.now() - start;
|
||||
|
||||
expect(elapsed).toBeGreaterThanOrEqual(100);
|
||||
expect(elapsed).toBeLessThan(200); // 100ms delay + 50ms max jitter
|
||||
});
|
||||
|
||||
it('adds jitter to the delay', async () => {
|
||||
// Run a message first to set lastMessageTime
|
||||
await limiter.throttle(() => Promise.resolve('first'));
|
||||
|
||||
const delays: number[] = [];
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const start = Date.now();
|
||||
await limiter.throttle(() => Promise.resolve(`msg-${i}`));
|
||||
delays.push(Date.now() - start);
|
||||
}
|
||||
|
||||
// At least one delay should differ significantly from the base delay
|
||||
const uniqueDelays = new Set(delays.map(d => Math.floor(d / 10)));
|
||||
expect(uniqueDelays.size).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
it('resets hourly counter', async () => {
|
||||
const config = getTestConfig({
|
||||
rateLimit: { minDelayBetweenMessagesMs: 10, maxMessagesPerHour: 3, jitterMs: 10 },
|
||||
});
|
||||
// 500ms "hour" so we can actually observe the reset wait inside the test budget.
|
||||
limiter = new RateLimiter(config, logger, 500);
|
||||
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await limiter.throttle(() => Promise.resolve(`msg-${i}`));
|
||||
}
|
||||
|
||||
// 4th should be blocked by hourly limit and wait for the reset.
|
||||
const start = Date.now();
|
||||
await limiter.throttle(() => Promise.resolve('blocked'));
|
||||
const elapsed = Date.now() - start;
|
||||
|
||||
expect(elapsed).toBeGreaterThanOrEqual(350);
|
||||
});
|
||||
|
||||
it('counts only when shouldCount returns true', async () => {
|
||||
const config = getTestConfig({
|
||||
rateLimit: { minDelayBetweenMessagesMs: 10, maxMessagesPerHour: 2, jitterMs: 10 },
|
||||
});
|
||||
limiter = new RateLimiter(config, logger, 500);
|
||||
|
||||
// These should NOT be counted
|
||||
await limiter.throttle(() => Promise.resolve('skip'), () => false);
|
||||
await limiter.throttle(() => Promise.resolve('skip'), () => false);
|
||||
|
||||
// These SHOULD be counted
|
||||
await limiter.throttle(() => Promise.resolve('count'), () => true);
|
||||
await limiter.throttle(() => Promise.resolve('count'), () => true);
|
||||
|
||||
// Next counted one should be delayed
|
||||
const start = Date.now();
|
||||
await limiter.throttle(() => Promise.resolve('blocked'), () => true);
|
||||
const elapsed = Date.now() - start;
|
||||
expect(elapsed).toBeGreaterThanOrEqual(10);
|
||||
});
|
||||
|
||||
it('returns the function result', async () => {
|
||||
const result = await limiter.throttle(() => Promise.resolve({ value: 42 }));
|
||||
expect(result).toEqual({ value: 42 });
|
||||
});
|
||||
|
||||
it('constrains to concurrency of 1', async () => {
|
||||
const order: number[] = [];
|
||||
const promises = [
|
||||
limiter.throttle(() => { order.push(1); return Promise.resolve('a'); }),
|
||||
limiter.throttle(() => { order.push(2); return Promise.resolve('b'); }),
|
||||
limiter.throttle(() => { order.push(3); return Promise.resolve('c'); }),
|
||||
];
|
||||
await Promise.all(promises);
|
||||
expect(order).toEqual([1, 2, 3]);
|
||||
});
|
||||
|
||||
describe('getStats', () => {
|
||||
it('returns initial stats', () => {
|
||||
const stats = limiter.getStats();
|
||||
expect(stats.messagesThisHour).toBe(0);
|
||||
// minutesSinceLastMessage should be large initially
|
||||
expect(stats.minutesSinceLastMessage).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('updates stats after messages', async () => {
|
||||
await limiter.throttle(() => Promise.resolve('msg'));
|
||||
const stats = limiter.getStats();
|
||||
expect(stats.messagesThisHour).toBe(1);
|
||||
expect(stats.minutesSinceLastMessage).toBeLessThan(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getTimers', () => {
|
||||
it('returns null nextMessageAvailableAt initially', () => {
|
||||
const timers = limiter.getTimers();
|
||||
expect(timers.nextMessageAvailableAt).toBeNull();
|
||||
expect(timers.isHourlyLimitReached).toBe(false);
|
||||
expect(timers.messagesRemaining).toBe(10);
|
||||
});
|
||||
|
||||
it('returns nextMessageAvailableAt after a message', async () => {
|
||||
await limiter.throttle(() => Promise.resolve('msg'));
|
||||
const timers = limiter.getTimers();
|
||||
expect(timers.nextMessageAvailableAt).toBeDefined();
|
||||
expect(timers.nextMessageAvailableAt!).toBeGreaterThan(Date.now());
|
||||
});
|
||||
|
||||
it('shows hourly limit reached when max messages sent', async () => {
|
||||
const config = getTestConfig({
|
||||
rateLimit: { minDelayBetweenMessagesMs: 10, maxMessagesPerHour: 2, jitterMs: 10 },
|
||||
});
|
||||
limiter = new RateLimiter(config, logger);
|
||||
|
||||
await limiter.throttle(() => Promise.resolve('msg1'));
|
||||
await limiter.throttle(() => Promise.resolve('msg2'));
|
||||
|
||||
const timers = limiter.getTimers();
|
||||
expect(timers.isHourlyLimitReached).toBe(true);
|
||||
expect(timers.messagesRemaining).toBe(0);
|
||||
});
|
||||
|
||||
it('calculates messages remaining', async () => {
|
||||
const config = getTestConfig({
|
||||
rateLimit: { minDelayBetweenMessagesMs: 10, maxMessagesPerHour: 5, jitterMs: 10 },
|
||||
});
|
||||
limiter = new RateLimiter(config, logger);
|
||||
|
||||
await limiter.throttle(() => Promise.resolve('msg1'));
|
||||
await limiter.throttle(() => Promise.resolve('msg2'));
|
||||
|
||||
const timers = limiter.getTimers();
|
||||
expect(timers.messagesRemaining).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('handles function that throws', async () => {
|
||||
await expect(limiter.throttle(() => { throw new Error('fail'); })).rejects.toThrow('fail');
|
||||
});
|
||||
|
||||
it('handles async function errors', async () => {
|
||||
await expect(limiter.throttle(() => Promise.reject(new Error('async fail')))).rejects.toThrow('async fail');
|
||||
});
|
||||
|
||||
it('handles synchronous return values', async () => {
|
||||
const result = await limiter.throttle(() => 'sync value');
|
||||
expect(result).toBe('sync value');
|
||||
});
|
||||
|
||||
it('handles zero minDelay', async () => {
|
||||
const config = getTestConfig({
|
||||
rateLimit: { minDelayBetweenMessagesMs: 0, maxMessagesPerHour: 100, jitterMs: 0 },
|
||||
});
|
||||
limiter = new RateLimiter(config, logger);
|
||||
|
||||
const start = Date.now();
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await limiter.throttle(() => Promise.resolve(`msg-${i}`));
|
||||
}
|
||||
const elapsed = Date.now() - start;
|
||||
expect(elapsed).toBeLessThan(100);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user