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

+133
View File
@@ -0,0 +1,133 @@
import { describe, it, expect } from 'vitest';
import { MatrixClient, MatrixRateLimitError } from '../src/services/chat/matrix/client.js';
import { createMockLogger } from './mockLogger.js';
const creds = {
homeserver: 'https://matrix.example.test',
accessToken: 'tok',
userId: '@me:example.test',
capturedAt: Date.now(),
};
describe('MatrixClient HTTP timeout', () => {
it('aborts a hanging fetch and surfaces a timeout error rather than blocking forever', async () => {
// A fetch impl that never resolves on its own — only the AbortSignal can
// end it. If the client lacked an AbortSignal, this test would hang.
const fetchImpl = ((_url: string, init?: RequestInit) =>
new Promise<Response>((_resolve, reject) => {
init?.signal?.addEventListener('abort', () => {
const err = new Error('aborted');
(err as Error & { name: string }).name = 'AbortError';
reject(err);
});
})) as typeof fetch;
const client = new MatrixClient(creds, createMockLogger(), {
requestTimeoutMs: 25,
minRequestIntervalMs: 0,
fetchImpl,
});
await expect(client.whoami()).rejects.toThrow(/timed out/);
}, 30_000);
it('retries after a single timeout when the next attempt succeeds', async () => {
let calls = 0;
const fetchImpl = ((_url: string, init?: RequestInit) => {
calls += 1;
if (calls === 1) {
return new Promise<Response>((_resolve, reject) => {
init?.signal?.addEventListener('abort', () => {
const err = new Error('aborted');
(err as Error & { name: string }).name = 'AbortError';
reject(err);
});
});
}
return Promise.resolve(
new Response(JSON.stringify({ user_id: '@me:example.test' }), {
status: 200,
headers: { 'content-type': 'application/json' },
}),
);
}) as typeof fetch;
const client = new MatrixClient(creds, createMockLogger(), {
requestTimeoutMs: 25,
minRequestIntervalMs: 0,
fetchImpl,
});
const userId = await client.whoami();
expect(userId).toBe('@me:example.test');
expect(calls).toBe(2);
}, 10_000);
});
describe('MatrixClient 429 handling', () => {
it('throws MatrixRateLimitError on a long retry_after rather than sleeping it out', async () => {
const fetchImpl = (() =>
Promise.resolve(
new Response(
JSON.stringify({
errcode: 'M_LIMIT_EXCEEDED',
error: 'Limit exceeded for number of invites attempted',
'com.reddit.error.code': 'rate.invitation_limit_mature_account',
retry_after_ms: 591_528,
}),
{ status: 429, headers: { 'content-type': 'application/json' } },
),
)) as typeof fetch;
const client = new MatrixClient(creds, createMockLogger(), {
minRequestIntervalMs: 0,
fetchImpl,
});
const start = Date.now();
await expect(client.whoami()).rejects.toMatchObject({
name: 'MatrixRateLimitError',
retryAfterMs: 591_528,
redditCode: 'rate.invitation_limit_mature_account',
});
// Should fail near-instantly, NOT sleep for 591s.
expect(Date.now() - start).toBeLessThan(2_000);
});
it('still sleeps + retries on short 429 retry_after (normal flood control)', async () => {
let calls = 0;
const fetchImpl = (() => {
calls += 1;
if (calls === 1) {
return Promise.resolve(
new Response(JSON.stringify({ retry_after_ms: 50 }), {
status: 429,
headers: { 'content-type': 'application/json' },
}),
);
}
return Promise.resolve(
new Response(JSON.stringify({ user_id: '@me:example.test' }), {
status: 200,
headers: { 'content-type': 'application/json' },
}),
);
}) as typeof fetch;
const client = new MatrixClient(creds, createMockLogger(), {
minRequestIntervalMs: 0,
fetchImpl,
});
const userId = await client.whoami();
expect(userId).toBe('@me:example.test');
expect(calls).toBe(2);
});
it('exposes MatrixRateLimitError as a typed export', () => {
const err = new MatrixRateLimitError('test', 1000, 'M_LIMIT_EXCEEDED', 'rate.foo');
expect(err).toBeInstanceOf(Error);
expect(err.name).toBe('MatrixRateLimitError');
expect(err.retryAfterMs).toBe(1000);
});
});