import { describe, it, expect } from 'vitest'; import fs from 'fs/promises'; import path from 'path'; import os from 'os'; import { ChatService } from '../src/services/chat/service.js'; import { ChatCache } from '../src/services/chat/cache.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 }); // Point the cache at a tmp path; without this, the default falls back to // ./data/chat-cache.json and the test would clobber the production cache. const cache = new ChatCache( logger, path.join(os.tmpdir(), `chat-cache-${Date.now()}-${Math.random()}.json`), ); const svc = new ChatService(matrix, logger, undefined, undefined, undefined, undefined, cache); 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('persists the cooldown to disk and restores it after a fresh service load', async () => { const logger = createMockLogger(); const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'chat-cooldown-')); const cacheFile = path.join(tmpDir, 'chat-cache.json'); try { const matrix = new MatrixClient(creds, logger, { minRequestIntervalMs: 0 }); const cache1 = new ChatCache(logger, cacheFile); const svc1 = new ChatService(matrix, logger, undefined, undefined, undefined, undefined, cache1); svc1.setInvitationCooldown( new MatrixRateLimitError('429', 120_000, 'M_LIMIT_EXCEEDED', null), ); // setInvitationCooldown kicks off an async save; flush the queue. await cache1.save(); const cache2 = new ChatCache(logger, cacheFile); const svc2 = new ChatService(matrix, logger, undefined, undefined, undefined, undefined, cache2); await svc2.loadCache(); const restored = svc2.getInvitationCooldown(); expect(restored).not.toBeNull(); expect(restored!.until).toBeGreaterThan(Date.now() + 100_000); } finally { await fs.rm(tmpDir, { recursive: true, force: true }); } }); it('preserves on-disk chats and lastFullSyncAt when a cooldown fires before loadCache', async () => { const logger = createMockLogger(); const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'chat-cooldown-preserve-')); const cacheFile = path.join(tmpDir, 'chat-cache.json'); try { // Pre-seed an existing cache with a completed full sync and known chats. const existing = { version: 1, savedAt: Date.now() - 60_000, chats: [ { username: 'alice', roomName: 'alice', lastMessageTime: Date.now() - 30_000, lastMessageSnippet: 'hi', isFromUs: false, unread: false, roomId: '!room:example.test', }, ], nextBatch: 's42_1', lastFullSyncAt: Date.now() - 120_000, lastDeltaSyncAt: Date.now() - 30_000, cooldown: null, }; await fs.writeFile(cacheFile, JSON.stringify(existing), 'utf-8'); const matrix = new MatrixClient(creds, logger, { minRequestIntervalMs: 0 }); const cache = new ChatCache(logger, cacheFile); const svc = new ChatService(matrix, logger, undefined, undefined, undefined, undefined, cache); // Cooldown fires before loadCache() — the in-memory chats are still []. svc.setInvitationCooldown( new MatrixRateLimitError('429', 60_000, 'M_LIMIT_EXCEEDED', null), ); // Flush the async persist (read + write). await cache.persistCooldown(); const raw = await fs.readFile(cacheFile, 'utf-8'); const after = JSON.parse(raw); expect(after.chats).toHaveLength(1); expect(after.chats[0].username).toBe('alice'); expect(after.lastFullSyncAt).toBe(existing.lastFullSyncAt); expect(after.nextBatch).toBe('s42_1'); expect(after.cooldown).not.toBeNull(); expect(after.cooldown.reason).toMatch(/M_LIMIT_EXCEEDED/); } finally { await fs.rm(tmpDir, { recursive: true, force: true }); } }); 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 cache = new ChatCache( logger, path.join(os.tmpdir(), `chat-cache-${Date.now()}-${Math.random()}.json`), ); const svc = new ChatService(matrix, logger, undefined, undefined, undefined, undefined, cache); 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); }); });