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,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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user