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'; const servers: Array> = []; async function request(app: express.Express, path: string): 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}`); } afterEach(async () => { await Promise.all(servers.splice(0).map((server) => new Promise((resolve) => server.close(() => resolve())))); }); describe('conversations route', () => { 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('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: 'alice', text: 'Hello', timestamp: 1_786_000_000_000, isSelf: false, }], }; 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: { 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); expect((await response.json()).username).toBe('alice'); 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', }; 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); expect((await response.json()).triggeringPost).toEqual({ id: 'post-exact', subreddit: 'example', title: 'Exact source post', url: roomOrigin.postUrl, imageUrl: roomOrigin.postImageUrl, sentAt: 9, }); expect(context.repos.messages.getLatestByRecipient).not.toHaveBeenCalled(); }); });