import express from 'express'; import type { AddressInfo } from 'node:net'; import { afterEach, describe, expect, it, vi } from 'vitest'; import type { WebContext } from '../../src/web/context.js'; import { createConversationsRouter } from '../../src/web/routes/conversations.js'; import { buildConversationState, conversationMessageFingerprint } from '../../src/services/conversation-state.js'; const servers: Array> = []; async function request( app: express.Express, path: string, init?: RequestInit, ): Promise { const server = app.listen(0); servers.push(server); await new Promise((resolve) => server.once('listening', resolve)); const { port } = server.address() as AddressInfo; return fetch(`http://127.0.0.1:${port}${path}`, init); } afterEach(async () => { await Promise.all(servers.splice(0).map((server) => new Promise((resolve) => server.close(() => resolve())))); }); describe('conversations route', () => { it('aggregates simultaneous account runtimes and labels identical room IDs with their owner', async () => { const chat = (username: string) => ({ username, roomName: username, roomId: 'same-room', lastMessageTime: 1, lastMessageSnippet: `from ${username}`, isFromUs: false, unread: false, }); const runtime = (accountId: string, redditUsername: string, conversationUsername: string) => ({ session: { accountId, redditUsername }, resources: { chatService: { getCachedConversations: vi.fn().mockResolvedValue([chat(conversationUsername)]) } }, }); const runtimes = new Map([ ['@t2_one:reddit.com', runtime('@t2_one:reddit.com', 'owner-one', 'alice')], ['@t2_two:reddit.com', runtime('@t2_two:reddit.com', 'owner-two', 'bob')], ]); const context = { logger: { error: vi.fn() }, services: { chat: { getCachedConversations: vi.fn() }, accountRuntimes: { list: () => [...runtimes].map(([accountId, item]) => ({ accountId, redditUsername: item.session.redditUsername, state: 'running' })), get: (accountId: string) => runtimes.get(accountId) ?? null, }, }, } as unknown as WebContext; const app = express().use('/api', createConversationsRouter(context)); const response = await request(app, '/api/conversations'); const body = await response.json(); expect(body).toHaveLength(2); expect(body).toEqual(expect.arrayContaining([ expect.objectContaining({ roomId: 'same-room', username: 'alice', owner: { accountId: '@t2_one:reddit.com', redditUsername: 'owner-one' } }), expect.objectContaining({ roomId: 'same-room', username: 'bob', owner: { accountId: '@t2_two:reddit.com', redditUsername: 'owner-two' } }), ])); }); it('sends an owned conversation reply only through its account runtime', async () => { const firstAccountId = '@t2_one:reddit.com'; const secondAccountId = '@t2_two:reddit.com'; const conversation = { username: 'alice', roomName: 'alice', roomId: 'same-room', url: '/room/same-room', lastMessageTime: 1, lastMessageSnippet: 'hello', isFromUs: false, unread: false, messages: [{ sender: 'alice', text: 'hello', timestamp: 1, isSelf: false }], }; const runtime = (accountId: string, redditUsername: string) => ({ session: { accountId, redditUsername }, snapshot: () => ({ state: 'running' }), resources: { chatService: { getCachedConversations: vi.fn().mockResolvedValue([conversation]), sendChatMessage: vi.fn().mockResolvedValue(true), refreshTopChats: vi.fn().mockResolvedValue(undefined), }, conversationStateRepo: { get: vi.fn(), save: vi.fn() }, activityLogRepo: { insert: vi.fn() }, }, }); const first = runtime(firstAccountId, 'owner-one'); const second = runtime(secondAccountId, 'owner-two'); const singletonSend = vi.fn(); const runtimes = new Map([[firstAccountId, first], [secondAccountId, second]]); const context = { logger: { error: vi.fn() }, activity: { push: vi.fn() }, broadcaster: { push: vi.fn() }, services: { chat: { sendChatMessage: singletonSend }, accountRuntimes: { list: () => [...runtimes.values()].map((item) => ({ accountId: item.session.accountId, redditUsername: item.session.redditUsername, state: 'running', })), get: (accountId: string) => runtimes.get(accountId) ?? null, }, }, } as unknown as WebContext; const app = express() .use(express.json()) .use('/api', createConversationsRouter(context)); const ownedId = `${encodeURIComponent(secondAccountId)}~${encodeURIComponent('same-room')}`; const response = await request(app, `/api/conversations/${encodeURIComponent(ownedId)}/send-reply`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ message: 'reply from account two' }), }); expect(response.status).toBe(200); expect(second.resources.chatService.sendChatMessage).toHaveBeenCalledWith( '/room/same-room', 'reply from account two', ); expect(first.resources.chatService.sendChatMessage).not.toHaveBeenCalled(); expect(singletonSend).not.toHaveBeenCalled(); expect(second.resources.activityLogRepo.insert).toHaveBeenCalledWith(expect.objectContaining({ type: 'sent', recipient: 'alice', })); expect(second.resources.conversationStateRepo.save).toHaveBeenCalledWith( 'same-room', expect.objectContaining({ automation: expect.objectContaining({ status: 'user-overridden', handoffReason: 'A manual reply was sent in this conversation.', }), }), ); expect(first.resources.activityLogRepo.insert).not.toHaveBeenCalled(); }); it('rejects an unowned conversation ID while account runtimes are active', async () => { const context = { logger: { error: vi.fn() }, services: { chat: { getCachedConversations: vi.fn().mockResolvedValue([]) }, accountRuntimes: { list: () => [{ accountId: '@t2_one:reddit.com', redditUsername: 'owner-one', state: 'running' }], get: vi.fn(), }, }, } as unknown as WebContext; const app = express().use('/api', createConversationsRouter(context)); const response = await request(app, '/api/conversations/same-room'); expect(response.status).toBe(404); expect(context.services.chat.getCachedConversations).not.toHaveBeenCalled(); }); it('reports an unavailable chat service instead of presenting it as an empty inbox', async () => { const context = { logger: { error: vi.fn() }, services: {}, } as unknown as WebContext; const app = express().use('/api', createConversationsRouter(context)); const response = await request(app, '/api/conversations'); expect(response.status).toBe(503); expect(await response.json()).toEqual({ error: 'Chat service not initialized' }); }); it('reports a cache read failure instead of presenting it as an empty inbox', async () => { const logger = { error: vi.fn() }; const context = { logger, services: { chat: { getCachedConversations: vi.fn().mockRejectedValue(new Error('cache unavailable')), }, }, } as unknown as WebContext; const app = express().use('/api', createConversationsRouter(context)); const response = await request(app, '/api/conversations'); expect(response.status).toBe(500); expect(await response.json()).toEqual({ error: 'Failed to fetch conversations' }); expect(logger.error).toHaveBeenCalledWith('Failed to fetch conversations:', expect.any(Error)); }); it('marks conversations that have received images in the durable media index', async () => { const conversation = { username: 'alice', roomName: 'alice', roomId: 'room-alice', lastMessageTime: 1_786_000_000_000, lastMessageSnippet: '[Image]', isFromUs: false, unread: true, }; const context = { logger: { error: vi.fn() }, services: { chat: { getCachedConversations: vi.fn().mockResolvedValue([conversation]) }, media: { getMediaItems: vi.fn().mockResolvedValue([ { id: 'image-one', chatUsername: 'alice', chatRoomId: 'room-alice', sender: 'alice', timestamp: 1_786_000_000_000, originalUrl: 'mxc://reddit.com/image-one', fileName: 'image-one.jpg', relativePath: 'image-one.jpg', createdAt: 1_786_000_000_100, }, ]), }, }, } as unknown as WebContext; const app = express().use('/api', createConversationsRouter(context)); const legacyResponse = await request(app, '/api/conversations'); const response = await request(app, '/api/conversations?includeImageMetadata=true'); expect(legacyResponse.status).toBe(200); expect(await legacyResponse.json()).toEqual([conversation]); expect(response.status).toBe(200); expect(await response.json()).toEqual([{ ...conversation, hasReceivedImages: true }]); }); it('marks conversations with a durable seller detection', async () => { const conversation = { username: 'seller', roomName: 'seller', roomId: 'room-seller', lastMessageTime: 1, lastMessageSnippet: 'my page', isFromUs: false, unread: true, }; const context = { logger: { error: vi.fn() }, services: { chat: { getCachedConversations: vi.fn().mockResolvedValue([conversation]) }, conversationState: { get: vi.fn(() => ({ sellerDetected: true })), }, }, } as unknown as WebContext; const app = express().use('/api', createConversationsRouter(context)); const response = await request(app, '/api/conversations'); expect(response.status).toBe(200); expect(await response.json()).toEqual([{ ...conversation, sellerDetected: true }]); }); it('serves conversation details without starting a delta sync', async () => { const conversation = { username: 'alice', roomName: 'alice', roomId: 'room-alice', lastMessageTime: 1_786_000_000_000, lastMessageSnippet: 'Hello', isFromUs: false, unread: false, messages: [ { sender: 'me', text: 'Automated hello', timestamp: 1_785_999_900_000, isSelf: true }, { sender: 'alice', text: 'Hello', timestamp: 1_786_000_000_000, isSelf: false }, ], }; const savedState = buildConversationState(conversation.messages); savedState.automation.turnsUsed = 1; savedState.automation.aiMessageFingerprints = [conversationMessageFingerprint('Automated hello')]; const refreshTopChats = vi.fn(); const loadConversationDetails = vi.fn(); const context = { logger: { error: vi.fn(), warn: vi.fn() }, repos: { messages: { getLatestByRecipient: vi.fn().mockReturnValue(undefined) }, }, services: { conversationState: { get: vi.fn().mockReturnValue(savedState), save: vi.fn() }, chat: { getSyncStatus: vi.fn().mockReturnValue({ syncing: false, syncingKind: null, progress: null, }), getCachedConversations: vi.fn().mockResolvedValue([conversation]), refreshTopChats, loadConversationDetails, saveCacheExternal: vi.fn(), }, }, } as unknown as WebContext; const app = express().use('/api', createConversationsRouter(context)); const response = await request(app, '/api/conversations/room-alice'); expect(response.status).toBe(200); const body = await response.json(); expect(body.username).toBe('alice'); expect(body.automation).toEqual({ turnsUsed: 1, remainingTurns: 2, status: 'active', handoffReason: null, }); expect(body.messages[0]).toMatchObject({ text: 'Automated hello', isAi: true }); expect(body.messages[1].isAi).toBeUndefined(); expect(refreshTopChats).not.toHaveBeenCalled(); expect(loadConversationDetails).not.toHaveBeenCalled(); }); it('attaches the exact room origin with its saved URL, title, and image', async () => { const conversation = { username: 'alice', roomName: 'alice', roomId: 'room-alice', lastMessageTime: 10, lastMessageSnippet: 'Hello', isFromUs: true, unread: false, messages: [{ sender: 'me', text: 'Hello', timestamp: 10, isSelf: true }], }; const roomOrigin = { id: 7, postId: 'post-exact', recipient: 'alice', subject: 'alice', message: 'Hello', sentAt: 9, gotResponse: false, responseCheckedAt: null, strategyId: null, chatRoomId: 'room-alice', subreddit: 'example', postTitle: 'Exact source post', postUrl: 'https://www.reddit.com/r/example/comments/post-exact/exact_source_post/', postImageUrl: 'https://i.redd.it/exact-source.jpg', postCreatedAt: 8, }; const latestRecipientOrigin = { ...roomOrigin, postId: 'wrong-latest-post', postTitle: 'Wrong post' }; const context = { logger: { error: vi.fn(), warn: vi.fn() }, repos: { messages: { getByChatRoomId: vi.fn().mockReturnValue(roomOrigin), getLatestByRecipient: vi.fn().mockReturnValue(latestRecipientOrigin), } }, services: { chat: { getSyncStatus: vi.fn().mockReturnValue({ syncing: false, syncingKind: null, progress: null }), getCachedConversations: vi.fn().mockResolvedValue([conversation]), loadConversationDetails: vi.fn(), saveCacheExternal: vi.fn(), } }, } as unknown as WebContext; const app = express().use('/api', createConversationsRouter(context)); const response = await request(app, '/api/conversations/room-alice'); expect(response.status).toBe(200); const body = await response.json(); expect(body.triggeringPost).toEqual({ id: 'post-exact', subreddit: 'example', title: 'Exact source post', url: roomOrigin.postUrl, imageUrl: roomOrigin.postImageUrl, postedAt: 8, sentAt: 9, }); expect(body.messages[0]).toMatchObject({ text: 'Hello', isAi: true }); expect(context.repos.messages.getLatestByRecipient).not.toHaveBeenCalled(); }); it('waits for the conversation delta sync after sending a reply', async () => { const conversation = { username: 'alice', roomName: 'alice', roomId: 'room-alice', url: 'https://chat.reddit.com/room/room-alice', lastMessageTime: 10, lastMessageSnippet: 'Hello', isFromUs: false, unread: false, messages: [{ sender: 'alice', text: 'Hello', timestamp: 10, isSelf: false }], }; let completeSync: (() => void) | undefined; const refreshTopChats = vi.fn(() => new Promise((resolve) => { completeSync = resolve; })); const context = { logger: { error: vi.fn() }, activity: { push: vi.fn() }, broadcaster: { push: vi.fn() }, services: { chat: { getCachedConversations: vi.fn().mockResolvedValue([conversation]), sendChatMessage: vi.fn().mockResolvedValue(true), refreshTopChats, } }, } as unknown as WebContext; const app = express() .use(express.json()) .use('/api', createConversationsRouter(context)); let responseSettled = false; const responsePromise = request(app, '/api/conversations/room-alice/send-reply', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ message: 'AI-assisted reply' }), }).then((response) => { responseSettled = true; return response; }); await vi.waitFor(() => expect(refreshTopChats).toHaveBeenCalledWith(5)); expect(responseSettled).toBe(false); completeSync?.(); const response = await responsePromise; expect(response.status).toBe(200); expect(await response.json()).toEqual({ success: true }); }); });