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:
jjbrunton committed 2026-05-21 01:32:10 +01:00
1 parent dfa0580859
commit 633bb0ec04
84 files changed
+19278 -3888

No files matched your search

+195
View File
@@ -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);
});
});
});