297 lines
11 KiB
TypeScript
297 lines
11 KiB
TypeScript
import { describe, it, expect, vi } from 'vitest';
|
|
import {
|
|
MatrixAuthError,
|
|
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('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);
|
|
});
|
|
});
|