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)]) }, recipientAccountStatusRepo: { get: vi.fn((username: string) => username === 'alice' ? { username, status: 'deleted', checkedAt: 123 } : null), }, }, }); 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', needsReply: false, recipientAccountStatus: 'deleted', recipientAccountStatusCheckedAt: 123, 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(() => ({ ...buildConversationState([]), 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, automationStatus: 'active', automationPolicyMode: 'legacy', automationHandoffReason: null, }, ]); }); it('projects the reply-loop status onto the list so manual conversations can be filtered', async () => { const automated = { username: 'alice', roomName: 'alice', roomId: 'room-alice', lastMessageTime: 1, lastMessageSnippet: 'hey', isFromUs: false, unread: true, }; const manual = { username: 'bob', roomName: 'bob', roomId: 'room-bob', lastMessageTime: 2, lastMessageSnippet: 'thanks', isFromUs: true, unread: false, }; const untouched = { username: 'carol', roomName: 'carol', roomId: 'room-carol', lastMessageTime: 3, lastMessageSnippet: 'hi', isFromUs: false, unread: true, }; const handedOver = { ...buildConversationState([]) }; handedOver.automation.status = 'user-overridden'; handedOver.automation.handoffReason = 'A manual reply was sent in this conversation.'; const context = { logger: { error: vi.fn() }, services: { chat: { getCachedConversations: vi.fn().mockResolvedValue([automated, manual, untouched]) }, conversationState: { get: vi.fn((roomId: string) => { if (roomId === 'room-alice') return buildConversationState([]); if (roomId === 'room-bob') return handedOver; return undefined; }), }, }, } as unknown as WebContext; const app = express().use('/api', createConversationsRouter(context)); const response = await request(app, '/api/conversations'); expect(response.status).toBe(200); const body = await response.json() as Array>; expect(body.map((item) => [item.username, item.automationStatus, item.automationHandoffReason])).toEqual([ ['alice', 'active', null], ['bob', 'user-overridden', 'A manual reply was sent in this conversation.'], // Never through the reply loop, so there is nothing to report. ['carol', undefined, undefined], ]); }); 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('forced seller sweep', () => { const sweepConfig = { reddit: { sellerKeywords: ['onlyfans'], sellerDomains: ['fansly.com'] }, conversations: { killOnSellerKeyword: true, aiReplyScreening: { enabled: false, killConfidence: 0.8, flagConfidence: 0.6 }, }, }; function chat(username: string, roomId: string, snippet: string) { return { username, roomName: username, roomId, lastMessageTime: 500, lastMessageSnippet: snippet, isFromUs: false, unread: false, messages: [{ sender: username, text: snippet, timestamp: 500, isSelf: false }], }; } function sweepContext(conversations: unknown[], ai?: unknown, states = new Map()) { const runtime = { session: { accountId: '@t2_one:reddit.com', redditUsername: 'owner-one' }, snapshot: () => ({ state: 'running' }), resources: { chatService: { getCachedConversations: vi.fn().mockResolvedValue(conversations) }, conversationStateRepo: { get: (id: string) => states.get(id), save: vi.fn((id: string, state: unknown) => { states.set(id, state); }), }, conversationReadStateRepo: { setStatus: vi.fn() }, }, }; const context = { logger: { error: vi.fn(), warn: vi.fn(), info: vi.fn() }, broadcaster: { push: vi.fn() }, config: sweepConfig, services: { chat: { getCachedConversations: vi.fn().mockResolvedValue([]) }, ai, accountRuntimes: { list: () => [{ accountId: '@t2_one:reddit.com', redditUsername: 'owner-one', state: 'running' }], get: () => runtime, }, }, } as unknown as WebContext; return { context, runtime, states }; } it('flags keyword hits for free and sends only the unsettled conversations to the classifier', async () => { const ai = { screenConversationsForSellers: vi.fn().mockResolvedValue([]) }; const { context, runtime } = sweepContext( [chat('alice', 'room-alice', 'everything else is on my onlyfans'), chat('bob', 'room-bob', 'how was your day?')], ai, ); const app = express().use(express.json()).use('/api', createConversationsRouter(context)); const response = await request(app, '/api/conversations/seller-scan', { method: 'POST' }); const body = await response.json(); expect(response.status).toBe(200); expect(body).toMatchObject({ success: true, scanned: 2, deterministic: 1, aiRequests: 1 }); expect(body.flagged).toEqual([expect.objectContaining({ // Flags carry the owned id so the dashboard can match its list rows. conversationId: `${encodeURIComponent('@t2_one:reddit.com')}~room-alice`, username: 'alice', source: 'keyword', killed: true, })]); // The keyword hit never reaches the model; only the clean row is screened. expect(ai.screenConversationsForSellers.mock.calls[0][0]).toEqual([ expect.objectContaining({ username: 'bob' }), ]); expect(runtime.resources.conversationReadStateRepo.setStatus).toHaveBeenCalledWith('room-alice', 'done', null); }); it('batches the conversations the rules could not settle into one classifier request', async () => { const ai = { screenConversationsForSellers: vi.fn().mockResolvedValue([ { id: '1', seller: true, confidence: 0.9, evidence: 'sells customs' }, { id: '2', seller: false, confidence: 0.05, evidence: 'ordinary chat' }, ]), }; const { context } = sweepContext( [chat('alice', 'room-alice', 'dm me about customs'), chat('bob', 'room-bob', 'how was your day?')], ai, ); const app = express().use(express.json()).use('/api', createConversationsRouter(context)); const body = await (await request(app, '/api/conversations/seller-scan', { method: 'POST' })).json(); expect(ai.screenConversationsForSellers).toHaveBeenCalledTimes(1); expect(body).toMatchObject({ scanned: 2, aiScreened: 2, aiRequests: 1 }); expect(body.flagged).toEqual([expect.objectContaining({ username: 'alice', source: 'ai', confidence: 0.9 })]); }); it('screens conversations whose transcript was never cached, using the summary snippet', async () => { const ai = { screenConversationsForSellers: vi.fn().mockResolvedValue([ { id: '1', seller: true, confidence: 0.95, evidence: 'links to a paid page' }, ]), }; const { context } = sweepContext([{ username: 'alice', roomName: 'alice', roomId: 'room-alice', lastMessageTime: 500, lastMessageSnippet: 'come see my page for the good stuff', isFromUs: false, unread: false, }], ai); const app = express().use(express.json()).use('/api', createConversationsRouter(context)); const body = await (await request(app, '/api/conversations/seller-scan', { method: 'POST' })).json(); expect(ai.screenConversationsForSellers.mock.calls[0][0][0].messages).toEqual([ { sender: 'alice', text: 'come see my page for the good stuff', isSelf: false }, ]); expect(body.flagged).toHaveLength(1); }); it('reports a degraded sweep instead of failing when screening is unavailable', async () => { const ai = { screenConversationsForSellers: vi.fn().mockRejectedValue(new Error('router down')) }; const { context } = sweepContext([chat('alice', 'room-alice', 'hey there stranger')], ai); const app = express().use(express.json()).use('/api', createConversationsRouter(context)); const response = await request(app, '/api/conversations/seller-scan', { method: 'POST' }); expect(response.status).toBe(200); expect(await response.json()).toMatchObject({ success: true, degraded: true, flagged: [] }); }); it('refuses to scan when no chat service is initialized', async () => { const context = { logger: { error: vi.fn() }, services: {} } as unknown as WebContext; const app = express().use(express.json()).use('/api', createConversationsRouter(context)); expect((await request(app, '/api/conversations/seller-scan', { method: 'POST' })).status).toBe(503); }); }); 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', }); }); }); describe('removed accounts', () => { const ARCHIVED = { username: 'alice', roomName: 'alice', roomId: 'room-alice', lastMessageTime: 500, lastMessageSnippet: 'thanks!', isFromUs: false, unread: false, messages: [{ sender: 'alice', text: 'thanks!', timestamp: 500, isSelf: false }], }; const OWNED_ID = `${encodeURIComponent('@t2_gone:reddit.com')}~${encodeURIComponent('room-alice')}`; /** A retired account: listed by the service, but with no runtime at all. */ function retiredContext() { return { logger: { error: vi.fn(), warn: vi.fn() }, repos: { accountContent: { listConversations: vi.fn((accountId: string) => accountId === '@t2_gone:reddit.com' ? [{ conversationId: 'room-alice', payloadJson: JSON.stringify(ARCHIVED) }] : []), }, messages: { getByChatRoomId: vi.fn(), getLatestByRecipient: vi.fn() }, }, services: { chat: { getCachedConversations: vi.fn().mockResolvedValue([]), getSyncStatus: () => ({ syncing: false }), loadConversationDetails: vi.fn(), saveCacheExternal: vi.fn(), }, // Present so suggest-reply reaches the read-only guard rather than // bailing out earlier on a missing AI client. ai: { generateReply: vi.fn() }, accountRuntimes: { list: () => [], get: () => null }, redditAccounts: { listRemoved: () => [{ accountId: '@t2_gone:reddit.com', redditUsername: 'gone-bot' }], }, }, } as unknown as WebContext; } it('keeps a retired account\'s conversations listed, read-only and out of the reply queue', async () => { const app = express().use('/api', createConversationsRouter(retiredContext())); const body = await (await request(app, '/api/conversations')).json(); expect(body).toEqual([expect.objectContaining({ roomId: 'room-alice', readOnly: true, needsReply: false, hasUnreadInbound: false, // Open, not resolved: the dashboard's default filter hides resolved // conversations, and archived history has to stay browsable. triageStatus: 'open', owner: { accountId: '@t2_gone:reddit.com', redditUsername: 'gone-bot' }, })]); }); it('serves the stored transcript without trying to sync more of it', async () => { const context = retiredContext(); const app = express().use('/api', createConversationsRouter(context)); const body = await (await request(app, `/api/conversations/${encodeURIComponent(OWNED_ID)}`)).json(); expect(body).toMatchObject({ roomId: 'room-alice', readOnly: true }); expect(body.messages).toHaveLength(1); // The fallback chat service belongs to another account: touching it would // both sync the wrong session and mutate the wrong cache. expect(context.services.chat!.loadConversationDetails).not.toHaveBeenCalled(); expect(context.services.chat!.saveCacheExternal).not.toHaveBeenCalled(); }); it('refuses to send, suggest or mark anything in a retired conversation', async () => { const app = express().use(express.json()).use('/api', createConversationsRouter(retiredContext())); const path = `/api/conversations/${encodeURIComponent(OWNED_ID)}`; const responses = await Promise.all([ request(app, `${path}/send-reply`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ message: 'hello again' }), }), request(app, `${path}/suggest-reply`, { method: 'POST' }), request(app, `${path}/read`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}', }), request(app, `${path}/share-media`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ assetId: '00000000-0000-4000-8000-000000000000' }), }), ]); expect(responses.map((response) => response.status)).toEqual([409, 409, 409, 409]); expect((await responses[0].json()).error).toMatch(/removed account/i); }); }); });