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> = []; /** * Triage fields the route projects onto every conversation when no operator * read state has been recorded yet: an inbound newest message is unread and * needs a reply, and the conversation is still open. */ const UNREAD_INBOUND_TRIAGE = { hasUnreadInbound: true, needsReply: true, triageStatus: 'open', snoozedUntil: null, lastReadMessageTs: 0, } as const; 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('lists every enabled image and shares the operator-selected one through the owning runtime', async () => { const accountId = '@t2_one:reddit.com'; const assetId = '11111111-1111-4111-8111-111111111111'; const conversation = { username: 'alice', roomName: 'alice', roomId: 'room-one', url: '/room/room-one', lastMessageTime: 2, lastMessageSnippet: 'send a selfie', isFromUs: false, unread: true, messages: [ { sender: 'me', text: 'hello', timestamp: 1, isSelf: true }, { eventId: '$request', sender: 'alice', text: 'send a selfie', timestamp: 2, isSelf: false }, ], }; const asset = { id: assetId, tags: ['selfie'], safety: 'sfw', classificationStatus: 'ready', description: 'selfie', confidence: 1, fileName: 'selfie.jpg', relativePath: 'nested/selfie.jpg', mimeType: 'image/jpeg', sizeBytes: 4, width: 10, height: 10, enabled: true, consentConfirmedAt: 1, createdAt: 1, updatedAt: 1, sendCount: 0, lastSentAt: null, }; const mediaExchange = { listShareableManual: vi.fn().mockReturnValue([asset]), detect: vi.fn().mockReturnValue('selfie'), shareManual: vi.fn().mockResolvedValue({ kind: 'sent', assetId, requestKind: 'generic' }), }; const runtime = { snapshot: () => ({ state: 'running' }), resources: { chatService: { getCachedConversations: vi.fn().mockResolvedValue([conversation]), loadConversationDetails: vi.fn().mockResolvedValue(undefined), refreshTopChats: vi.fn().mockResolvedValue(undefined), }, conversationStateRepo: { get: vi.fn(), save: vi.fn() }, activityLogRepo: { insert: vi.fn() }, mediaExchange, rateLimiter: { throttle: vi.fn((work: () => Promise) => work()) }, }, }; const context = { logger: { error: vi.fn() }, activity: { push: vi.fn() }, broadcaster: { push: vi.fn() }, services: { chat: {}, accountRuntimes: { list: () => [{ accountId, redditUsername: 'owner-one', state: 'running' }], get: (requested: string) => requested === accountId ? runtime : null, }, }, } as unknown as WebContext; const app = express().use(express.json()).use('/api', createConversationsRouter(context)); const ownedId = `${encodeURIComponent(accountId)}~room-one`; const optionsResponse = await request(app, `/api/conversations/${encodeURIComponent(ownedId)}/shareable-media`); expect(optionsResponse.status).toBe(200); expect(await optionsResponse.json()).toEqual({ requestKind: 'selfie', reason: null, warning: null, assets: [expect.objectContaining({ id: assetId, urlPath: '/library-media/nested/selfie.jpg' })], }); expect(mediaExchange.listShareableManual).toHaveBeenCalledWith('room-one'); const sendResponse = await request(app, `/api/conversations/${encodeURIComponent(ownedId)}/share-media`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ assetId }), }); expect(sendResponse.status).toBe(200); expect(await sendResponse.json()).toEqual({ success: true }); expect(mediaExchange.shareManual).toHaveBeenCalledWith('room-one', assetId); expect(runtime.resources.conversationStateRepo.save).toHaveBeenCalledWith('room-one', expect.objectContaining({ automation: expect.objectContaining({ status: 'user-overridden' }), })); }); 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, ...UNREAD_INBOUND_TRIAGE }]); expect(response.status).toBe(200); expect(await response.json()).toEqual([ { ...conversation, ...UNREAD_INBOUND_TRIAGE, 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, ...UNREAD_INBOUND_TRIAGE, 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 }); }); describe('operator read state', () => { const conversation = { username: 'alice', roomName: 'alice', roomId: 'room-alice', lastMessageTime: 500, lastMessageSnippet: 'Hello', isFromUs: false, unread: false, messages: [{ sender: 'alice', text: 'Hello', timestamp: 500, isSelf: false }], }; function contextWithReadState(readState: Record) { return { logger: { error: vi.fn(), warn: vi.fn() }, repos: { messages: { getByChatRoomId: vi.fn(), getLatestByRecipient: vi.fn() } }, services: { chat: { getCachedConversations: vi.fn().mockResolvedValue([conversation]), getSyncStatus: vi.fn(() => ({ syncing: false, progress: null, syncingKind: null })), loadConversationDetails: vi.fn(), saveCacheExternal: vi.fn(), }, conversationReadState: readState, }, } as unknown as WebContext; } function readStateStub(record?: Record) { return { get: vi.fn(() => record), list: vi.fn(() => (record ? [record] : [])), expireSnoozes: vi.fn(() => []), markRead: vi.fn(), markUnread: vi.fn(), setStatus: vi.fn(), }; } it('reports an inbound message newer than the watermark as unread and needing a reply', async () => { const readState = readStateStub({ conversationId: 'room-alice', lastReadMessageTs: 100, lastReadAt: 100, status: 'open', snoozedUntil: null, }); const app = express().use('/api', createConversationsRouter(contextWithReadState(readState))); const body = await (await request(app, '/api/conversations')).json(); expect(body[0]).toMatchObject({ hasUnreadInbound: true, needsReply: true, triageStatus: 'open', lastReadMessageTs: 100, }); expect(readState.expireSnoozes).toHaveBeenCalled(); }); it('clears unread once the watermark reaches the newest message', async () => { const readState = readStateStub({ conversationId: 'room-alice', lastReadMessageTs: 500, lastReadAt: 600, status: 'open', snoozedUntil: null, }); const app = express().use('/api', createConversationsRouter(contextWithReadState(readState))); const body = await (await request(app, '/api/conversations')).json(); expect(body[0]).toMatchObject({ hasUnreadInbound: false, needsReply: true }); }); it('stops treating a done conversation as needing a reply', async () => { const readState = readStateStub({ conversationId: 'room-alice', lastReadMessageTs: 500, lastReadAt: 600, status: 'done', snoozedUntil: null, }); const app = express().use('/api', createConversationsRouter(contextWithReadState(readState))); const body = await (await request(app, '/api/conversations')).json(); expect(body[0]).toMatchObject({ needsReply: false, triageStatus: 'done' }); }); it('marks read through the newest message when no timestamp is supplied', async () => { const readState = readStateStub(); const app = express() .use(express.json()) .use('/api', createConversationsRouter(contextWithReadState(readState))); const response = await request(app, '/api/conversations/room-alice/read', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({}), }); expect(response.status).toBe(200); expect(readState.markRead).toHaveBeenCalledWith('room-alice', 500); }); it('marks read only through an explicitly supplied timestamp', async () => { const readState = readStateStub(); const app = express() .use(express.json()) .use('/api', createConversationsRouter(contextWithReadState(readState))); await request(app, '/api/conversations/room-alice/read', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ throughTimestamp: 200 }), }); expect(readState.markRead).toHaveBeenCalledWith('room-alice', 200); }); it('marks a conversation unread again', async () => { const readState = readStateStub(); const app = express() .use(express.json()) .use('/api', createConversationsRouter(contextWithReadState(readState))); const response = await request(app, '/api/conversations/room-alice/unread', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({}), }); expect(response.status).toBe(200); expect(readState.markUnread).toHaveBeenCalledWith('room-alice', 500); }); it('rejects a snooze with no deadline', async () => { const readState = readStateStub(); const app = express() .use(express.json()) .use('/api', createConversationsRouter(contextWithReadState(readState))); const response = await request(app, '/api/conversations/room-alice/status', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ status: 'snoozed' }), }); expect(response.status).toBe(400); expect(readState.setStatus).not.toHaveBeenCalled(); }); it('rejects an unrecognised triage status', async () => { const readState = readStateStub(); const app = express() .use(express.json()) .use('/api', createConversationsRouter(contextWithReadState(readState))); const response = await request(app, '/api/conversations/room-alice/status', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ status: 'archived' }), }); expect(response.status).toBe(400); expect(readState.setStatus).not.toHaveBeenCalled(); }); it('stores a snooze deadline', async () => { const readState = readStateStub(); const app = express() .use(express.json()) .use('/api', createConversationsRouter(contextWithReadState(readState))); const response = await request(app, '/api/conversations/room-alice/status', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ status: 'snoozed', snoozeUntil: 9_000 }), }); expect(response.status).toBe(200); expect(readState.setStatus).toHaveBeenCalledWith('room-alice', 'snoozed', 9_000); }); it('reports unresolvable ids in a bulk mark-read rather than failing the batch', async () => { const readState = readStateStub(); const app = express() .use(express.json()) .use('/api', createConversationsRouter(contextWithReadState(readState))); const response = await request(app, '/api/conversations/bulk-read', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ conversationIds: ['room-alice', 'room-missing'] }), }); expect(response.status).toBe(200); expect(await response.json()).toEqual({ success: true, updated: 1, failed: ['room-missing'] }); expect(readState.markRead).toHaveBeenCalledTimes(1); }); it('rejects an empty bulk mark-read', async () => { const app = express() .use(express.json()) .use('/api', createConversationsRouter(contextWithReadState(readStateStub()))); const response = await request(app, '/api/conversations/bulk-read', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ conversationIds: [] }), }); expect(response.status).toBe(400); }); it('exposes the read watermark on the conversation detail so the divider can be placed', async () => { const readState = readStateStub({ conversationId: 'room-alice', lastReadMessageTs: 100, lastReadAt: 100, status: 'open', snoozedUntil: null, }); const app = express().use('/api', createConversationsRouter(contextWithReadState(readState))); const body = await (await request(app, '/api/conversations/room-alice')).json(); expect(body).toMatchObject({ lastReadMessageTs: 100, hasUnreadInbound: true, needsReply: true, triageStatus: 'open', }); }); }); });