import { describe, it, expect } from 'vitest'; import { ChatService } from '../src/services/chat/service.js'; 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(), }; function buildService() { const logger = createMockLogger(); // Fetch impl never matters here — we drive cooldowns directly via the // public API, which is how MatrixClient surfaces them at runtime. const fetchImpl = (() => Promise.resolve( new Response('{}', { status: 200, headers: { 'content-type': 'application/json' } }), )) as typeof fetch; const matrix = new MatrixClient(creds, logger, { minRequestIntervalMs: 0, fetchImpl }); const svc = new ChatService(matrix, logger); return { svc, logger }; } describe('ChatService invitation cooldown', () => { it('is null by default', () => { const { svc } = buildService(); expect(svc.getInvitationCooldown()).toBeNull(); }); it('records a cooldown from a MatrixRateLimitError', () => { const { svc } = buildService(); const err = new MatrixRateLimitError( '429', 60_000, 'M_LIMIT_EXCEEDED', 'rate.invitation_limit_mature_account', ); svc.setInvitationCooldown(err); const active = svc.getInvitationCooldown(); expect(active).not.toBeNull(); expect(active!.redditCode).toBe('rate.invitation_limit_mature_account'); expect(active!.until).toBeGreaterThan(Date.now() + 50_000); expect(active!.reason).toMatch(/throttling new chat invitations/); }); it('auto-expires once the until timestamp has passed', () => { const { svc } = buildService(); const err = new MatrixRateLimitError('429', 10, 'M_LIMIT_EXCEEDED', 'rate.foo'); svc.setInvitationCooldown(err); expect(svc.getInvitationCooldown()).not.toBeNull(); // Force the clock past the cooldown window. const cooldown = svc.getInvitationCooldown()!; const realNow = Date.now; Date.now = () => cooldown.until + 1; try { expect(svc.getInvitationCooldown()).toBeNull(); } finally { Date.now = realNow; } }); it('startConversation short-circuits when a cooldown is active', async () => { const { svc } = buildService(); svc.setInvitationCooldown( new MatrixRateLimitError('429', 60_000, 'M_LIMIT_EXCEEDED', 'rate.foo'), ); const result = await svc.startConversation('someuser', 'hi'); expect(result).toBeNull(); }); it('sendChatMessage short-circuits when a cooldown is active', async () => { const { svc } = buildService(); svc.setInvitationCooldown( new MatrixRateLimitError('429', 60_000, 'M_LIMIT_EXCEEDED', null), ); const result = await svc.sendChatMessage('!room:example.test', 'hi'); expect(result).toBe(false); }); it('sendChatMessage records a cooldown when the homeserver rate-limits a reply', async () => { const logger = createMockLogger(); const matrix = new MatrixClient(creds, logger, { minRequestIntervalMs: 0 }); // Force sendTextMessage to throw a rate-limit error — simulates Reddit // returning M_LIMIT_EXCEEDED on a reply into an existing room. matrix.sendTextMessage = async () => { throw new MatrixRateLimitError('429', 90_000, 'M_LIMIT_EXCEEDED', null); }; const svc = new ChatService(matrix, logger); const result = await svc.sendChatMessage('!room:example.test', 'hi'); expect(result).toBe(false); const active = svc.getInvitationCooldown(); expect(active).not.toBeNull(); expect(active!.until).toBeGreaterThan(Date.now() + 80_000); }); });