import { describe, it, expect, vi } from 'vitest'; import { MatrixAuthError, MatrixAccountSuspendedError, MatrixClient, MatrixFeatureDisabledError, 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 outbound image media', () => { it('uploads raw bytes and sends an m.image event with a stable transaction ID', async () => { const calls: Array<{ url: string; init?: RequestInit }> = []; const fetchImpl = ((url: string | URL | Request, init?: RequestInit) => { calls.push({ url: String(url), init }); if (String(url).includes('/upload')) { return Promise.resolve(new Response(JSON.stringify({ content_uri: 'mxc://reddit.com/image-one' }), { status: 200, headers: { 'content-type': 'application/json' }, })); } return Promise.resolve(new Response(JSON.stringify({ event_id: '$image-event' }), { status: 200, headers: { 'content-type': 'application/json' }, })); }) as typeof fetch; const client = new MatrixClient(creds, createMockLogger(), { minRequestIntervalMs: 0, fetchImpl }); const mxc = await client.uploadImage(Buffer.from([1, 2, 3]), 'image/jpeg', 'portrait.jpg'); await expect(client.sendImageMessage( '!room:reddit.com', 'portrait.jpg', mxc, { mimeType: 'image/jpeg', size: 3, width: 10, height: 20 }, 'stable-image-txn', )).resolves.toBe('$image-event'); expect(calls[0].url).toContain('/_matrix/media/v3/upload?filename=portrait.jpg'); expect(new Headers(calls[0].init?.headers).get('content-type')).toBe('image/jpeg'); expect(calls[1].url).toContain('/stable-image-txn'); expect(JSON.parse(String(calls[1].init?.body))).toEqual({ msgtype: 'm.image', body: 'portrait.jpg', url: 'mxc://reddit.com/image-one', info: { mimetype: 'image/jpeg', size: 3, w: 10, h: 20 }, }); }); }); describe('MatrixClient HTTP timeout', () => { it('loads an encoded Matrix user profile with bearer authentication', async () => { let requestedUrl = ''; let authorization = ''; const fetchImpl = ((url: string | URL | Request, init?: RequestInit) => { requestedUrl = String(url); authorization = new Headers(init?.headers).get('Authorization') ?? ''; return Promise.resolve(new Response(JSON.stringify({ displayname: 'MilkyBabe_10' }), { status: 200, headers: { 'content-type': 'application/json' }, })); }) as typeof fetch; const client = new MatrixClient(creds, createMockLogger(), { minRequestIntervalMs: 0, fetchImpl, }); await expect(client.getUserProfile('@t2_26q364ua7n:reddit.com')).resolves.toEqual({ displayname: 'MilkyBabe_10', }); expect(requestedUrl).toBe( 'https://matrix.example.test/_matrix/client/v3/profile/%40t2_26q364ua7n%3Areddit.com', ); expect(authorization).toBe('Bearer tok'); }); 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 direct room registration', () => { it('creates rooms with the direct-message flag', async () => { let requestBody: unknown; const client = new MatrixClient(creds, createMockLogger(), { minRequestIntervalMs: 0, fetchImpl: vi.fn(async (_url, init) => { requestBody = JSON.parse(String(init?.body)); return new Response(JSON.stringify({ room_id: '!new:example.test' }), { status: 200, headers: { 'content-type': 'application/json' }, }); }), }); await expect(client.createDirectRoom('@them:example.test')) .resolves.toBe('!new:example.test'); expect(requestBody).toEqual({ preset: 'reddit_dm', invite: ['@them:example.test'], is_direct: true, }); }); it('additively repairs m.direct account data without removing existing rooms', async () => { const requests: Array<{ method: string; url: string; body?: unknown }> = []; const client = new MatrixClient(creds, createMockLogger(), { minRequestIntervalMs: 0, fetchImpl: vi.fn(async (url, init) => { requests.push({ method: init?.method ?? 'GET', url: String(url), ...(init?.body ? { body: JSON.parse(String(init.body)) } : {}), }); if (init?.method === 'GET') { return new Response(JSON.stringify({ '@existing:example.test': ['!old:example.test'], '@them:example.test': ['!already:example.test'], }), { status: 200, headers: { 'content-type': 'application/json' } }); } return new Response('{}', { status: 200, headers: { 'content-type': 'application/json' } }); }), }); await expect(client.repairDirectRoomMappings([ { recipientMxid: '@them:example.test', roomId: '!already:example.test' }, { recipientMxid: '@them:example.test', roomId: '!new:example.test' }, ])).resolves.toBe(1); expect(requests).toHaveLength(2); expect(requests[1]).toMatchObject({ method: 'PUT', body: { '@existing:example.test': ['!old:example.test'], '@them:example.test': ['!already:example.test', '!new:example.test'], }, }); }); }); describe('MatrixClient authentication classification', () => { it('calls the account pause hook and throws a typed error for M_FORBIDDEN', async () => { const onAccountSuspended = vi.fn(); const client = new MatrixClient(creds, createMockLogger(), { minRequestIntervalMs: 0, onAccountSuspended, fetchImpl: vi.fn().mockResolvedValue(new Response(JSON.stringify({ errcode: 'M_FORBIDDEN', error: 'User is suspended', }), { status: 403, headers: { 'content-type': 'application/json' } })), }); await expect(client.whoami()).rejects.toBeInstanceOf(MatrixAccountSuspendedError); expect(onAccountSuspended).toHaveBeenCalledOnce(); }); it('classifies an explicitly disabled endpoint as an unavailable feature', async () => { const client = new MatrixClient(creds, createMockLogger(), { minRequestIntervalMs: 0, fetchImpl: vi.fn().mockResolvedValue(new Response(JSON.stringify({ message: 'disabled by configuration', }), { status: 403, headers: { 'content-type': 'application/json' } })), }); await expect(client.getRoomFullState('!room:example.test')) .rejects.toBeInstanceOf(MatrixFeatureDisabledError); }); it('does not classify a room-level 403 as an expired account token', async () => { const client = new MatrixClient(creds, createMockLogger(), { minRequestIntervalMs: 0, fetchImpl: vi.fn().mockResolvedValue(new Response('', { status: 403 })), }); const error = await client.getRoomFullState('!left-room:example.test').catch((cause) => cause); expect(error).toBeInstanceOf(Error); expect(error).not.toBeInstanceOf(MatrixAuthError); expect((error as Error).message).toMatch(/403/); }); it('still classifies an explicit unknown-token 403 as authentication failure', async () => { const client = new MatrixClient(creds, createMockLogger(), { minRequestIntervalMs: 0, fetchImpl: vi.fn().mockResolvedValue(new Response(JSON.stringify({ errcode: 'M_UNKNOWN_TOKEN', error: 'Access token has expired', }), { status: 403, headers: { 'content-type': 'application/json' }, })), }); await expect(client.whoami()).rejects.toBeInstanceOf(MatrixAuthError); }); }); 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); }); });