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