193 lines
6.4 KiB
TypeScript
193 lines
6.4 KiB
TypeScript
import { describe, it, expect, vi } from 'vitest';
|
|
import {
|
|
MatrixAuthError,
|
|
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('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 authentication classification', () => {
|
|
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);
|
|
});
|
|
});
|