Files
RedditBot/tests/matrix-client.test.ts

312 lines
11 KiB
TypeScript

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