// @vitest-environment jsdom import '@testing-library/jest-dom/vitest'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { cleanup, render, screen, waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { HttpResponse, http } from 'msw'; import { setupServer } from 'msw/node'; import { MemoryRouter } from 'react-router-dom'; import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; import { ConversationsPage } from '../../apps/dashboard/src/features/conversations/conversations-page.js'; import { resetComposerDrafts } from '../../apps/dashboard/src/features/conversations/composer-drafts.js'; const NOW = 1_786_000_000_000; interface ChatOptions { username: string; roomId?: string; lastMessageTime?: number; isFromUs?: boolean; hasUnreadInbound?: boolean; needsReply?: boolean; triageStatus?: 'open' | 'snoozed' | 'done'; lastReadMessageTs?: number; sellerDetected?: boolean; } function chatFixture(options: ChatOptions) { const roomId = options.roomId ?? `room-${options.username}`; const isFromUs = options.isFromUs ?? false; const triageStatus = options.triageStatus ?? 'open'; return { username: options.username, roomName: options.username, roomId, url: `https://chat.reddit.com/room/${roomId}`, lastMessageTime: options.lastMessageTime ?? NOW, lastMessageSnippet: `Latest from ${options.username}`, isFromUs, unread: false, hasUnreadInbound: options.hasUnreadInbound ?? (!isFromUs && triageStatus === 'open'), needsReply: options.needsReply ?? (!isFromUs && triageStatus === 'open'), triageStatus, snoozedUntil: null, lastReadMessageTs: options.lastReadMessageTs ?? 0, ...(options.sellerDetected === undefined ? {} : { sellerDetected: options.sellerDetected }), }; } function detailFixture(chat: ReturnType, messages?: unknown[]) { return { ...chat, messages: messages ?? [ { eventId: '$one', sender: 'me', text: 'Hello there', timestamp: NOW - 200_000, isSelf: true }, { eventId: '$two', sender: chat.username, text: 'Replying now', timestamp: NOW, isSelf: false }, ], triggeringPost: null, automation: { turnsUsed: 0, remainingTurns: 3, status: 'active', handoffReason: null }, syncInProgress: null, }; } const alice = chatFixture({ username: 'alice' }); const server = setupServer( http.get('/api/accounts', () => HttpResponse.json([])), http.get('/api/conversations/:id/shareable-media', () => HttpResponse.json({ requestKind: null, assets: [], reason: 'Nothing to share.', warning: null, })), ); function renderConversations() { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, }); render( , ); return queryClient; } beforeAll(() => server.listen({ onUnhandledRequest: 'error' })); afterEach(() => { cleanup(); server.resetHandlers(); resetComposerDrafts(); vi.useRealTimers(); }); afterAll(() => server.close()); describe('application-level read state', () => { it('advances the read watermark after the conversation has been open a moment', async () => { const readCalls: Array<{ id: string; body: unknown }> = []; server.use( http.get('/api/conversations', () => HttpResponse.json([alice])), http.get('/api/conversations/room-alice', () => HttpResponse.json(detailFixture(alice))), http.post('/api/conversations/:id/read', async ({ params, request }) => { readCalls.push({ id: String(params.id), body: await request.json() }); return HttpResponse.json({ success: true }); }), ); const user = userEvent.setup(); renderConversations(); await user.click(await screen.findByRole('button', { name: /u\/alice/ })); await screen.findByRole('heading', { name: 'u/alice' }); await waitFor(() => expect(readCalls).toHaveLength(1)); expect(readCalls[0]).toEqual({ id: 'room-alice', body: { throughTimestamp: NOW } }); }); it('does not mark a conversation read that is already at the watermark', async () => { const read = chatFixture({ username: 'alice', hasUnreadInbound: false, lastReadMessageTs: NOW }); let readCalls = 0; server.use( http.get('/api/conversations', () => HttpResponse.json([read])), http.get('/api/conversations/room-alice', () => HttpResponse.json(detailFixture(read))), http.post('/api/conversations/:id/read', () => { readCalls += 1; return HttpResponse.json({ success: true }); }), ); const user = userEvent.setup(); renderConversations(); await user.click(await screen.findByRole('button', { name: /u\/alice/ })); await screen.findByRole('heading', { name: 'u/alice' }); // Give the debounce more than enough time to fire if it were going to. await new Promise((resolve) => setTimeout(resolve, 60)); expect(readCalls).toBe(0); }); it('clears the unread chip once the watermark has advanced', async () => { // Stateful so the assertion covers the whole round trip, not just the // optimistic frame that the follow-up refetch would overwrite. let watermark = 0; server.use( http.get('/api/conversations', () => HttpResponse.json([ chatFixture({ username: 'alice', hasUnreadInbound: watermark < NOW, lastReadMessageTs: watermark }), ])), http.get('/api/conversations/room-alice', () => HttpResponse.json(detailFixture( chatFixture({ username: 'alice', hasUnreadInbound: watermark < NOW, lastReadMessageTs: watermark }), ))), http.post('/api/conversations/:id/read', async ({ request }) => { const body = await request.json() as { throughTimestamp?: number }; watermark = Math.max(watermark, body.throughTimestamp ?? NOW); return HttpResponse.json({ success: true }); }), ); const user = userEvent.setup(); renderConversations(); const row = await screen.findByRole('button', { name: /u\/alice/ }); expect(row.getAttribute('aria-label')).toContain('Unread'); await user.click(row); // The debounce plus the follow-up refetch takes longer than waitFor's default. await waitFor(() => expect( screen.getByRole('button', { name: /u\/alice/ }).getAttribute('aria-label'), ).not.toContain('Unread'), { timeout: 4_000 }); }); it('renders a new-messages divider at the read boundary and none for a first read', async () => { const partiallyRead = chatFixture({ username: 'alice', lastReadMessageTs: NOW - 100_000 }); server.use( http.get('/api/conversations', () => HttpResponse.json([partiallyRead])), http.get('/api/conversations/room-alice', () => HttpResponse.json(detailFixture(partiallyRead))), http.post('/api/conversations/:id/read', () => HttpResponse.json({ success: true })), ); const user = userEvent.setup(); renderConversations(); await user.click(await screen.findByRole('button', { name: /u\/alice/ })); expect(await screen.findByText('New messages')).toBeInTheDocument(); }); it('keeps the divider in place after auto-mark-read advances the watermark', async () => { // Regression: the divider used to vanish a second after opening, because it // was derived from the live watermark that auto-mark-read had just moved. let watermark = NOW - 100_000; const current = () => chatFixture({ username: 'alice', hasUnreadInbound: watermark < NOW, lastReadMessageTs: watermark, }); server.use( http.get('/api/conversations', () => HttpResponse.json([current()])), http.get('/api/conversations/room-alice', () => HttpResponse.json(detailFixture(current()))), http.post('/api/conversations/:id/read', async ({ request }) => { const body = await request.json() as { throughTimestamp?: number }; watermark = Math.max(watermark, body.throughTimestamp ?? NOW); return HttpResponse.json({ success: true }); }), ); const user = userEvent.setup(); renderConversations(); await user.click(await screen.findByRole('button', { name: /u\/alice/ })); expect(await screen.findByText('New messages')).toBeInTheDocument(); // Wait for the read to land and the panel to re-render from fresh data. await waitFor(() => expect( screen.getByRole('button', { name: /u\/alice/ }).getAttribute('aria-label'), ).not.toContain('Unread'), { timeout: 4_000 }); expect(screen.getByText('New messages')).toBeInTheDocument(); }); it('omits the divider when the conversation has never been read', async () => { server.use( http.get('/api/conversations', () => HttpResponse.json([alice])), http.get('/api/conversations/room-alice', () => HttpResponse.json(detailFixture(alice))), http.post('/api/conversations/:id/read', () => HttpResponse.json({ success: true })), ); const user = userEvent.setup(); renderConversations(); await user.click(await screen.findByRole('button', { name: /u\/alice/ })); await screen.findByText('Replying now'); expect(screen.queryByText('New messages')).not.toBeInTheDocument(); }); it('groups history under day dividers instead of stamping every message with a date', async () => { const twoDaysAgo = NOW - (2 * 86_400_000); server.use( http.get('/api/conversations', () => HttpResponse.json([alice])), http.get('/api/conversations/room-alice', () => HttpResponse.json(detailFixture(alice, [ { eventId: '$old', sender: 'me', text: 'Older message', timestamp: twoDaysAgo, isSelf: true }, { eventId: '$new', sender: 'alice', text: 'Newer message', timestamp: Date.now(), isSelf: false }, ]))), http.post('/api/conversations/:id/read', () => HttpResponse.json({ success: true })), ); const user = userEvent.setup(); renderConversations(); await user.click(await screen.findByRole('button', { name: /u\/alice/ })); await screen.findByText('Newer message'); expect(screen.getByText('Today')).toBeInTheDocument(); // Two distinct days produce two dividers. expect(document.querySelectorAll('.conversation-day-divider')).toHaveLength(2); }); it('returns a conversation to the queue from the detail header', async () => { let watermark = NOW; let unreadBody: unknown = null; const current = () => chatFixture({ username: 'alice', hasUnreadInbound: watermark < NOW, lastReadMessageTs: watermark, }); server.use( http.get('/api/conversations', () => HttpResponse.json([current()])), http.get('/api/conversations/room-alice', () => HttpResponse.json(detailFixture(current()))), http.post('/api/conversations/:id/unread', async ({ request }) => { unreadBody = await request.json(); watermark = 0; return HttpResponse.json({ success: true }); }), // Re-opening the panel re-reads it; that must not fight the manual unread. http.post('/api/conversations/:id/read', () => HttpResponse.json({ success: true })), ); const user = userEvent.setup(); renderConversations(); await user.click(await screen.findByRole('button', { name: /u\/alice/ })); await user.click(await screen.findByRole('button', { name: 'Mark unread' })); await waitFor(() => expect(unreadBody).toEqual({})); await waitFor(() => expect( screen.getByRole('button', { name: /u\/alice/ }).getAttribute('aria-label'), ).toContain('Unread')); }); it('resolves a conversation and closes the detail panel', async () => { let statusBody: unknown = null; server.use( http.get('/api/conversations', () => HttpResponse.json([alice])), http.get('/api/conversations/room-alice', () => HttpResponse.json(detailFixture(alice))), http.post('/api/conversations/:id/read', () => HttpResponse.json({ success: true })), http.post('/api/conversations/:id/status', async ({ request }) => { statusBody = await request.json(); return HttpResponse.json({ success: true }); }), ); const user = userEvent.setup(); renderConversations(); await user.click(await screen.findByRole('button', { name: /u\/alice/ })); await user.click(await screen.findByRole('button', { name: 'Mark done' })); await waitFor(() => expect(statusBody).toEqual({ status: 'done' })); expect(await screen.findByText('Select a conversation')).toBeInTheDocument(); }); it('snoozes for a chosen duration', async () => { let statusBody: { status: string; snoozeUntil: number } | null = null; server.use( http.get('/api/conversations', () => HttpResponse.json([alice])), http.get('/api/conversations/room-alice', () => HttpResponse.json(detailFixture(alice))), http.post('/api/conversations/:id/read', () => HttpResponse.json({ success: true })), http.post('/api/conversations/:id/status', async ({ request }) => { statusBody = await request.json() as { status: string; snoozeUntil: number }; return HttpResponse.json({ success: true }); }), ); const user = userEvent.setup(); renderConversations(); await user.click(await screen.findByRole('button', { name: /u\/alice/ })); await user.click(await screen.findByRole('button', { name: 'Snooze' })); const menu = screen.getByRole('group', { name: 'Snooze options' }); await user.click(within(menu).getByRole('button', { name: 'Tomorrow' })); await waitFor(() => expect(statusBody?.status).toBe('snoozed')); expect(statusBody?.snoozeUntil).toBeGreaterThan(Date.now()); }); it('marks every selected conversation read in one request', async () => { let bulkBody: { conversationIds: string[] } | null = null; server.use( http.get('/api/conversations', () => HttpResponse.json([ chatFixture({ username: 'alice' }), chatFixture({ username: 'bob', lastMessageTime: NOW - 1_000 }), ])), http.post('/api/conversations/bulk-read', async ({ request }) => { bulkBody = await request.json() as { conversationIds: string[] }; return HttpResponse.json({ success: true, updated: 2, failed: [] }); }), ); const user = userEvent.setup(); renderConversations(); await screen.findByRole('button', { name: /u\/alice/ }); await user.click(screen.getByRole('checkbox', { name: 'Select all visible conversations' })); expect(screen.getByText('2 selected')).toBeInTheDocument(); await user.click(screen.getByRole('button', { name: 'Mark read' })); await waitFor(() => expect(bulkBody?.conversationIds).toEqual(['room-alice', 'room-bob'])); }); it('marks a single checked conversation read without touching the others', async () => { let bulkBody: { conversationIds: string[] } | null = null; server.use( http.get('/api/conversations', () => HttpResponse.json([ chatFixture({ username: 'alice' }), chatFixture({ username: 'bob', lastMessageTime: NOW - 1_000 }), ])), http.post('/api/conversations/bulk-read', async ({ request }) => { bulkBody = await request.json() as { conversationIds: string[] }; return HttpResponse.json({ success: true, updated: 1, failed: [] }); }), ); const user = userEvent.setup(); renderConversations(); await screen.findByRole('button', { name: /u\/alice/ }); await user.click(screen.getByRole('checkbox', { name: 'Select u/bob' })); await user.click(screen.getByRole('button', { name: 'Mark read' })); await waitFor(() => expect(bulkBody?.conversationIds).toEqual(['room-bob'])); }); it('drives the list from the keyboard', async () => { let readId: string | null = null; let statusBody: unknown = null; server.use( http.get('/api/conversations', () => HttpResponse.json([ chatFixture({ username: 'alice' }), chatFixture({ username: 'bob', lastMessageTime: NOW - 1_000 }), ])), http.post('/api/conversations/:id/read', ({ params }) => { readId = String(params.id); return HttpResponse.json({ success: true }); }), http.post('/api/conversations/:id/status', async ({ request }) => { statusBody = await request.json(); return HttpResponse.json({ success: true }); }), ); const user = userEvent.setup(); renderConversations(); await screen.findByRole('button', { name: /u\/alice/ }); // j moves to the first row, then to the second. await user.keyboard('j'); expect(screen.getByRole('button', { name: /u\/alice/ })).toHaveFocus(); await user.keyboard('j'); expect(screen.getByRole('button', { name: /u\/bob/ })).toHaveFocus(); await user.keyboard('k'); expect(screen.getByRole('button', { name: /u\/alice/ })).toHaveFocus(); // u marks the focused row read; e resolves it. await user.keyboard('u'); await waitFor(() => expect(readId).toBe('room-alice')); await user.keyboard('e'); await waitFor(() => expect(statusBody).toEqual({ status: 'done' })); }); it('focuses search with / and ignores shortcuts while typing', async () => { server.use(http.get('/api/conversations', () => HttpResponse.json([alice]))); const user = userEvent.setup(); renderConversations(); await screen.findByRole('button', { name: /u\/alice/ }); await user.keyboard('/'); const search = screen.getByRole('searchbox', { name: 'Search conversations' }); expect(search).toHaveFocus(); // 'j' and 'e' are literal characters here, not shortcuts. await user.keyboard('je'); expect(search).toHaveValue('je'); expect(search).toHaveFocus(); }); it('holds the row order while a conversation is open and offers an explicit refresh', async () => { let listRequests = 0; server.use( http.get('/api/conversations', () => { listRequests += 1; return HttpResponse.json(listRequests === 1 ? [chatFixture({ username: 'alice' })] : [chatFixture({ username: 'carol', lastMessageTime: NOW + 5_000 }), chatFixture({ username: 'alice' })]); }), http.get('/api/conversations/room-alice', () => HttpResponse.json(detailFixture(alice))), http.post('/api/conversations/:id/read', () => HttpResponse.json({ success: true })), ); const user = userEvent.setup(); const queryClient = renderConversations(); await user.click(await screen.findByRole('button', { name: /u\/alice/ })); await screen.findByRole('heading', { name: 'u/alice' }); await queryClient.refetchQueries({ queryKey: ['conversations'] }); // carol arrived but must not push alice down mid-triage. await waitFor(() => expect(screen.getByRole('button', { name: /1 new conversation/ })).toBeInTheDocument()); expect(screen.queryByRole('button', { name: /u\/carol/ })).not.toBeInTheDocument(); await user.click(screen.getByRole('button', { name: /1 new conversation/ })); expect(await screen.findByRole('button', { name: /u\/carol/ })).toBeInTheDocument(); }); it('explains rows hidden by the seller filter instead of silently dropping them', async () => { server.use( http.get('/api/conversations', () => HttpResponse.json([ chatFixture({ username: 'alice' }), chatFixture({ username: 'seller', sellerDetected: true }), ])), ); renderConversations(); await screen.findByRole('button', { name: /u\/alice/ }); expect(screen.getByText('1 hidden by the seller filter.')).toBeInTheDocument(); }); it('flags an unanswered conversation that has gone stale', async () => { server.use( http.get('/api/conversations', () => HttpResponse.json([ chatFixture({ username: 'stale', lastMessageTime: Date.now() - (3 * 86_400_000) }), ])), ); renderConversations(); const row = await screen.findByRole('button', { name: /u\/stale/ }); expect(row.getAttribute('aria-label')).toContain('Overdue'); expect(row.querySelector('time')).toHaveClass('is-critical'); }); });