import { describe, it, expect, vi } from 'vitest'; import os from 'os'; import path from 'path'; import Database from 'better-sqlite3'; import { runMigrations } from '../src/database/client.js'; import { RedditUsersRepository } from '../src/database/repositories.js'; import { ChatService } from '../src/services/chat/service.js'; import { ChatCache } from '../src/services/chat/cache.js'; import { MatrixAuthError, MatrixClient, MatrixFeatureDisabledError, } from '../src/services/chat/matrix/client.js'; import { createMockLogger } from './mockLogger.js'; import { authenticatedAccountAuth } from './testUtils.js'; const creds = { homeserver: 'https://matrix.example.test', accessToken: 'tok', userId: '@me:example.test', capturedAt: Date.now(), }; function buildMatrix() { const logger = createMockLogger(); const fetchImpl = (() => Promise.resolve( new Response('{}', { status: 200, headers: { 'content-type': 'application/json' } }), )) as typeof fetch; return new MatrixClient(creds, logger, { minRequestIntervalMs: 0, fetchImpl }); } describe('ChatService username persistence', () => { function tmpCache(logger: Parameters[0]) { return new ChatCache( logger, path.join(os.tmpdir(), `chat-cache-${Date.now()}-${Math.random()}.json`), ); } it('seeds the in-memory cache from RedditUsersRepository on construction', () => { const db = new Database(':memory:'); runMigrations(db); const repo = new RedditUsersRepository(db); repo.upsert({ t2Id: 't2_abc', username: 'alice' }); const logger = createMockLogger(); const svc = new ChatService( buildMatrix(), logger, undefined, undefined, undefined, undefined, tmpCache(logger), undefined, repo, authenticatedAccountAuth, ); expect(svc.resolveSenderDisplay('@t2_abc:reddit.com')).toBe('alice'); expect(svc.resolveSenderDisplay('t2_abc')).toBe('alice'); db.close(); }); it('falls back to t2 form when no mapping is known', () => { const logger = createMockLogger(); const svc = new ChatService( buildMatrix(), logger, undefined, undefined, undefined, undefined, tmpCache(logger), undefined, undefined, authenticatedAccountAuth, ); expect(svc.resolveSenderDisplay('@t2_unknown:reddit.com')).toBe('t2_unknown'); }); it('starts a conversation with the t2 ID supplied by the Reddit listing', async () => { const logger = createMockLogger(); const matrix = buildMatrix(); matrix.sendInitialDirectMessage = vi.fn().mockResolvedValue({ roomId: '!room:reddit.com', eventId: '$event', }); const usernameResolver = { resolveT2Ids: vi.fn().mockResolvedValue({}), resolveUsernameToT2: vi.fn().mockRejectedValue(new Error('profile lookup returned 403')), }; const svc = new ChatService( matrix, logger, undefined, undefined, undefined, usernameResolver, tmpCache(logger), undefined, undefined, authenticatedAccountAuth, ); const chat = await svc.startConversation('alice', 'hello', 't2_abc123'); expect(chat?.username).toBe('alice'); expect(chat?.messages).toEqual([ expect.objectContaining({ eventId: '$event', text: 'hello', isSelf: true }), ]); expect(usernameResolver.resolveUsernameToT2).not.toHaveBeenCalled(); expect(matrix.sendInitialDirectMessage).toHaveBeenCalledWith( '@t2_abc123:reddit.com', 'hello', ); }); it('uses the durable username mapping before a live profile lookup', async () => { const db = new Database(':memory:'); runMigrations(db); const repo = new RedditUsersRepository(db); repo.upsert({ t2Id: 't2_cached', username: 'alice' }); const logger = createMockLogger(); const matrix = buildMatrix(); matrix.sendInitialDirectMessage = vi.fn().mockResolvedValue({ roomId: '!room:reddit.com', eventId: '$event', }); const usernameResolver = { resolveT2Ids: vi.fn().mockResolvedValue({}), resolveUsernameToT2: vi.fn().mockRejectedValue(new Error('profile lookup returned 403')), }; const svc = new ChatService( matrix, logger, undefined, undefined, undefined, usernameResolver, tmpCache(logger), undefined, repo, authenticatedAccountAuth, ); const chat = await svc.startConversation('ALICE', 'hello'); expect(chat?.username).toBe('ALICE'); expect(usernameResolver.resolveUsernameToT2).not.toHaveBeenCalled(); expect(matrix.sendInitialDirectMessage).toHaveBeenCalledWith( '@t2_cached:reddit.com', 'hello', ); db.close(); }); it('upgrades cached chats with t2_ usernames from the db map on loadCache', async () => { const db = new Database(':memory:'); runMigrations(db); const repo = new RedditUsersRepository(db); repo.upsert({ t2Id: 't2_abc', username: 'alice' }); const logger = createMockLogger(); const tmpFile = path.join(os.tmpdir(), `chat-cache-${Date.now()}-${Math.random()}.json`); const cache = new ChatCache(logger, tmpFile); cache.set([ { username: 't2_abc', roomName: 't2_abc', lastMessageTime: Date.now(), lastMessageSnippet: 'hi', isFromUs: false, unread: false, url: 'https://chat.reddit.com/room/x', roomId: '!room:reddit.com', messages: [], }, ]); const svc = new ChatService( buildMatrix(), logger, undefined, undefined, undefined, undefined, cache, undefined, repo, authenticatedAccountAuth, ); await svc.loadCache(); const chats = await svc.getCachedConversations(); expect(chats[0].username).toBe('alice'); expect(chats[0].roomName).toBe('alice'); db.close(); }); it('aborts username enrichment after one failed token refresh', async () => { const logger = createMockLogger(); const cache = tmpCache(logger); cache.set([ { username: 'unknown', roomName: 'unknown', lastMessageTime: Date.now(), lastMessageSnippet: '', isFromUs: false, unread: false, roomId: '!first:reddit.com', messages: [], }, { username: 'unknown', roomName: 'unknown', lastMessageTime: Date.now(), lastMessageSnippet: '', isFromUs: false, unread: false, roomId: '!second:reddit.com', messages: [], }, ]); const matrix = buildMatrix(); matrix.getRoomFullState = vi.fn().mockRejectedValue(new MatrixAuthError('expired')); matrix.getRoomMessages = vi.fn().mockRejectedValue(new MatrixAuthError('expired')); const tokenRefresher = { refresh: vi.fn().mockRejectedValue(new Error('interactive login required')), }; const usernameResolver = { resolveT2Ids: vi.fn().mockResolvedValue({}) }; const svc = new ChatService( matrix, logger, undefined, undefined, tokenRefresher, usernameResolver, cache, undefined, undefined, authenticatedAccountAuth, ); await svc.enrichUsernames(); expect(tokenRefresher.refresh).toHaveBeenCalledOnce(); expect(matrix.getRoomFullState).toHaveBeenCalledOnce(); expect(matrix.getRoomMessages).not.toHaveBeenCalled(); }); it('stops room state lookups after the homeserver reports the feature is disabled', async () => { const logger = createMockLogger(); const cache = tmpCache(logger); cache.set(['first', 'second'].map((name) => ({ username: 'unknown', roomName: 'unknown', lastMessageTime: Date.now(), lastMessageSnippet: '', isFromUs: false, unread: false, roomId: `!${name}:reddit.com`, messages: [], }))); const matrix = buildMatrix(); matrix.getRoomFullState = vi.fn().mockRejectedValue( new MatrixFeatureDisabledError('disabled by configuration'), ); matrix.getRoomMessages = vi.fn().mockImplementation(async (roomId: string) => ({ start: 'start', chunk: [{ event_id: `$${roomId}`, type: 'm.room.message', sender: `@t2_${roomId.includes('first') ? 'first' : 'second'}:reddit.com`, origin_server_ts: Date.now(), content: { msgtype: 'm.text', body: 'hello' }, }], })); const usernameResolver = { resolveT2Ids: vi.fn().mockResolvedValue({}) }; const svc = new ChatService( matrix, logger, undefined, undefined, undefined, usernameResolver, cache, undefined, undefined, authenticatedAccountAuth, ); await svc.enrichUsernames(); expect(matrix.getRoomFullState).toHaveBeenCalledOnce(); expect(matrix.getRoomMessages).toHaveBeenCalledTimes(2); expect(logger.logs.filter((entry) => entry.message.includes('room state lookup is disabled upstream'), )).toHaveLength(1); expect((await svc.getCachedConversations()).map((chat) => chat.username)) .toEqual(['t2_first', 't2_second']); }); });