// @vitest-environment jsdom import '@testing-library/jest-dom/vitest'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { cleanup, fireEvent, 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 } from 'vitest'; import { ConversationsPage } from '../../apps/dashboard/src/features/conversations/conversations-page.js'; import { resetComposerDrafts } from '../../apps/dashboard/src/features/conversations/composer-drafts.js'; interface ChatFixtureOptions { username: string; roomId: string; lastMessageTime?: number; unread?: boolean; isFromUs?: boolean; lastMessageSnippet?: string; hasReceivedImages?: boolean; sellerDetected?: boolean; automationStatus?: 'active' | 'image-received' | 'human-required' | 'boundary' | 'user-overridden'; automationHandoffReason?: string; } function chatFixture(options: ChatFixtureOptions) { return { username: options.username, roomName: options.username, roomId: options.roomId, url: `https://chat.reddit.com/room/${options.roomId}`, lastMessageTime: options.lastMessageTime ?? 1_786_000_000_000, lastMessageSnippet: options.lastMessageSnippet ?? `Latest from ${options.username}`, isFromUs: options.isFromUs ?? false, unread: options.unread ?? false, ...(options.hasReceivedImages === undefined ? {} : { hasReceivedImages: options.hasReceivedImages }), ...(options.sellerDetected === undefined ? {} : { sellerDetected: options.sellerDetected }), ...(options.automationStatus === undefined ? {} : { automationStatus: options.automationStatus }), ...(options.automationHandoffReason === undefined ? {} : { automationHandoffReason: options.automationHandoffReason }), }; } const alice = chatFixture({ username: 'alice', roomId: 'room-alice', unread: true }); const bob = chatFixture({ username: 'bob', roomId: 'room-bob', isFromUs: true }); function detailFixture(chat = alice) { return { ...chat, messages: [ { sender: 'me', text: 'Hello Alice', timestamp: 1_785_999_900_000, isSelf: true, isAi: true }, { sender: 'alice', text: 'Good to hear from you', timestamp: 1_786_000_000_000, isSelf: false }, ], triggeringPost: { id: 'post-1', subreddit: 'casualconversation', title: 'Looking for a chat', url: 'https://reddit.com/r/casualconversation/comments/post-1', imageUrl: 'https://i.redd.it/source-post.jpg', postedAt: Date.now() - (2 * 60 * 60 * 1000), sentAt: 1_785_999_800_000, }, automation: { turnsUsed: 0, remainingTurns: 3, status: 'active', handoffReason: null }, syncInProgress: null, }; } const server = setupServer( http.get('/api/accounts', () => HttpResponse.json([{ accountId: '@t2_bot:reddit.com', redditUsername: 'bot-account', matrixUserId: '@t2_bot:reddit.com', isRunning: true, status: 'authenticated', createdAt: 1, updatedAt: 2, lastImportedAt: 2, }])), http.get('/api/conversations/:id/shareable-media', () => HttpResponse.json({ requestKind: null, assets: [], warning: null, reason: 'No enabled, unused library images are available to share.', })), // Opening a conversation advances the read watermark. http.post('/api/conversations/:id/read', () => HttpResponse.json({ success: true })), http.post('/api/conversations/:id/unread', () => HttpResponse.json({ success: true })), http.post('/api/conversations/:id/status', () => HttpResponse.json({ success: true })), http.post('/api/conversations/bulk-read', () => HttpResponse.json({ success: true, updated: 1, failed: [] })), ); function installConversationList() { server.use( http.get('/api/conversations', () => HttpResponse.json([alice, bob])), http.get('/api/conversations/room-alice', () => HttpResponse.json(detailFixture())), ); } 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(); }); afterAll(() => server.close()); describe('ConversationsPage', () => { it('shows loading and a clear empty state', async () => { server.use(http.get('/api/conversations', () => HttpResponse.json([]))); renderConversations(); expect(screen.getByRole('status', { name: 'Loading conversations' })).toBeInTheDocument(); expect(await screen.findByText('No conversations yet.')).toBeInTheDocument(); }); it('recovers from a list error without replacing the page', async () => { let requests = 0; server.use( http.get('/api/conversations', () => { requests += 1; if (requests === 1) return HttpResponse.json({ error: 'Chat service unavailable' }, { status: 503 }); return HttpResponse.json([alice]); }), ); const user = userEvent.setup(); renderConversations(); const alert = await screen.findByRole('alert'); expect(alert).toHaveTextContent('Chat service unavailable'); await user.click(within(alert).getByRole('button', { name: 'Retry conversations' })); expect(await screen.findByRole('button', { name: /u\/alice/ })).toBeInTheDocument(); }); it('searches and filters the master list, then opens conversation detail', async () => { installConversationList(); const user = userEvent.setup(); renderConversations(); expect(await screen.findByRole('button', { name: /u\/alice/ })).toBeInTheDocument(); await user.type(screen.getByRole('searchbox', { name: 'Search conversations' }), 'bob'); expect(screen.queryByRole('button', { name: /u\/alice/ })).not.toBeInTheDocument(); expect(screen.getByRole('button', { name: /u\/bob/ })).toBeInTheDocument(); await user.clear(screen.getByRole('searchbox', { name: 'Search conversations' })); await user.click(screen.getByRole('button', { name: /^Unread/ })); expect(screen.getByRole('button', { name: /u\/alice/ })).toBeInTheDocument(); expect(screen.queryByRole('button', { name: /u\/bob/ })).not.toBeInTheDocument(); await user.click(screen.getByRole('button', { name: /u\/alice/ })); expect(await screen.findByRole('heading', { name: 'u/alice' })).toBeInTheDocument(); expect(screen.getAllByRole('link', { name: 'u/alice' })).not.toHaveLength(0); for (const profileLink of screen.getAllByRole('link', { name: 'u/alice' })) { expect(profileLink).toHaveAttribute('href', 'https://www.reddit.com/u/alice'); } expect(screen.getByText('Good to hear from you')).toBeInTheDocument(); expect(screen.getByLabelText('Sent by AI')).toHaveTextContent('AI'); expect(screen.getByRole('link', { name: 'Looking for a chat' })).toHaveAttribute('href', expect.stringContaining('post-1')); expect(screen.getByRole('img', { name: 'Source post: Looking for a chat' })).toHaveAttribute( 'src', 'https://i.redd.it/source-post.jpg', ); expect(screen.getByText('2h ago')).toBeInTheDocument(); expect(screen.getByRole('img', { name: 'Source post: Looking for a chat' })).toHaveClass('is-blurred'); await user.click(screen.getByRole('button', { name: 'Reveal source image: Looking for a chat' })); expect(screen.getByRole('img', { name: 'Source post: Looking for a chat' })).not.toHaveClass('is-blurred'); const previewTrigger = screen.getByRole('button', { name: 'View source image full screen: Looking for a chat' }); await user.click(previewTrigger); const preview = screen.getByRole('dialog', { name: 'Source image: Looking for a chat' }); expect(within(preview).getByRole('img', { name: 'Source post: Looking for a chat' })).toHaveAttribute( 'src', 'https://i.redd.it/source-post.jpg', ); expect(within(preview).getByRole('button', { name: 'Close source image preview' })).toHaveFocus(); await user.keyboard('{Escape}'); expect(screen.queryByRole('dialog', { name: 'Source image: Looking for a chat' })).not.toBeInTheDocument(); expect(previewTrigger).toHaveFocus(); }); it('hides detected sellers by default and reveals marked conversations on request', async () => { const seller = chatFixture({ username: 'seller', roomId: 'room-seller', sellerDetected: true }); server.use(http.get('/api/conversations', () => HttpResponse.json([alice, seller]))); const user = userEvent.setup(); renderConversations(); expect(await screen.findByRole('button', { name: /u\/alice/ })).toBeInTheDocument(); expect(screen.queryByRole('button', { name: /u\/seller/ })).not.toBeInTheDocument(); await user.click(screen.getByRole('checkbox', { name: 'Show sellers' })); expect(screen.getByRole('button', { name: /u\/seller.*Seller/ })).toBeInTheDocument(); expect(screen.getByText('Seller')).toBeInTheDocument(); }); it('orders conversations by latest message activity', async () => { const oldest = chatFixture({ username: 'oldest', roomId: 'room-oldest', lastMessageTime: 100 }); const newest = chatFixture({ username: 'newest', roomId: 'room-newest', lastMessageTime: 300 }); const middle = chatFixture({ username: 'middle', roomId: 'room-middle', lastMessageTime: 200 }); server.use(http.get('/api/conversations', () => HttpResponse.json([oldest, newest, middle]))); renderConversations(); await screen.findByRole('button', { name: /u\/newest/ }); expect(screen.getAllByRole('button', { name: /u\/(?:oldest|newest|middle)/ }).map((button) => button.getAttribute('aria-label'))) .toEqual([ expect.stringContaining('u/newest'), expect.stringContaining('u/middle'), expect.stringContaining('u/oldest'), ]); }); it('shows short relative times for the latest message', async () => { const now = Date.now(); const minutesAgo = chatFixture({ username: 'minutes', roomId: 'room-minutes', lastMessageTime: now - (5 * 60_000) }); const daysAgo = chatFixture({ username: 'days', roomId: 'room-days', lastMessageTime: now - (2 * 86_400_000) }); server.use(http.get('/api/conversations', () => HttpResponse.json([daysAgo, minutesAgo]))); renderConversations(); const recentTime = await screen.findByText('5m ago'); expect(recentTime.tagName).toBe('TIME'); expect(recentTime).toHaveAttribute('dateTime', new Date(minutesAgo.lastMessageTime).toISOString()); expect(recentTime).toHaveAttribute('title'); expect(screen.getByText('2d ago')).toBeInTheDocument(); }); it('marks and filters conversations with received images and renders the images in chat', async () => { const imageChat = chatFixture({ username: 'alice', roomId: 'room-alice', hasReceivedImages: true, lastMessageSnippet: '[Image]', }); server.use( http.get('/api/conversations', () => HttpResponse.json([imageChat, bob])), http.get('/api/conversations/room-alice', () => HttpResponse.json({ ...detailFixture(imageChat), messages: [ { sender: 'alice', text: '[Image]', timestamp: 1_786_000_000_000, isSelf: false, media: [{ originalUrl: 'mxc://reddit.com/image-one', localPath: 'received image.jpg', fileName: 'received image.jpg', }], }, ], })), ); const user = userEvent.setup(); renderConversations(); const aliceItem = await screen.findByRole('button', { name: /u\/alice/ }); expect(screen.getByText('Image received')).toBeInTheDocument(); await user.click(screen.getByRole('button', { name: /^Images/ })); expect(screen.getByRole('button', { name: /u\/alice/ })).toBeInTheDocument(); expect(screen.queryByRole('button', { name: /u\/bob/ })).not.toBeInTheDocument(); await user.click(screen.getByRole('button', { name: /u\/alice/ })); expect(await screen.findByRole('img', { name: 'Image received from u/alice' })).toHaveAttribute( 'src', '/media/received%20image.jpg', ); expect(screen.getByRole('img', { name: 'Image received from u/alice' })).toHaveClass('is-blurred'); await user.click(screen.getByRole('button', { name: 'Reveal conversation image' })); expect(screen.getByRole('img', { name: 'Image received from u/alice' })).not.toHaveClass('is-blurred'); }); it('marks and filters conversations that are being replied to by hand', async () => { const manual = chatFixture({ username: 'alice', roomId: 'room-alice', isFromUs: true, automationStatus: 'user-overridden', automationHandoffReason: 'A manual reply was sent in this conversation.', }); const automated = chatFixture({ username: 'bob', roomId: 'room-bob', automationStatus: 'active' }); server.use(http.get('/api/conversations', () => HttpResponse.json([manual, automated]))); const user = userEvent.setup(); renderConversations(); await screen.findByRole('button', { name: /u\/alice/ }); expect(screen.getByText('Manual', { selector: '.conversation-chip' })).toHaveAttribute( 'title', 'Manual — you replied — A manual reply was sent in this conversation.', ); await user.click(screen.getByRole('button', { name: 'Manual, 1' })); expect(screen.getByRole('button', { name: /u\/alice/ })).toBeInTheDocument(); expect(screen.queryByRole('button', { name: /u\/bob/ })).not.toBeInTheDocument(); }); it('turns a suggestion into an editable draft and confirms sending without losing failed input', async () => { installConversationList(); let sendRequests = 0; server.use( http.post('/api/conversations/room-alice/suggest-reply', () => HttpResponse.json({ suggestedReply: 'That sounds lovely, what happened next?' }), ), http.post('/api/conversations/room-alice/send-reply', async ({ request }) => { sendRequests += 1; const body = await request.json() as { message: string }; expect(body.message).toBe('My edited reply'); return HttpResponse.json({ error: 'Reddit is temporarily unavailable' }, { status: 503 }); }), ); const user = userEvent.setup(); renderConversations(); await user.click(await screen.findByRole('button', { name: /u\/alice/ })); await screen.findByRole('heading', { name: 'u/alice' }); await user.click(screen.getByRole('button', { name: 'Suggest reply' })); const composer = await screen.findByRole('textbox', { name: 'Reply to u/alice' }); expect(composer).toHaveValue('That sounds lovely, what happened next?'); await user.clear(composer); await user.type(composer, 'My edited reply'); const sendButton = screen.getByRole('button', { name: 'Review and send reply' }); await user.click(sendButton); expect(screen.getByRole('dialog', { name: 'Send this reply?' })).toHaveTextContent('My edited reply'); await user.keyboard('{Escape}'); expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); expect(sendButton).toHaveFocus(); expect(sendRequests).toBe(0); await user.click(sendButton); await user.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'Confirm send' })); expect(await screen.findByRole('alert')).toHaveTextContent('Reddit is temporarily unavailable'); expect(composer).toHaveValue('My edited reply'); server.use( http.post('/api/conversations/room-alice/send-reply', () => { sendRequests += 1; return HttpResponse.json({ success: true }); }), ); await user.click(sendButton); await user.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'Confirm send' })); expect(await screen.findByRole('status', { name: 'Reply sent' })).toHaveTextContent('Reply sent to u/alice'); expect(composer).toHaveValue(''); expect(sendRequests).toBe(2); }); it('generates a media ask and looks up durable processed-user history', async () => { installConversationList(); server.use( http.post('/api/conversations/room-alice/suggest-media-ask', () => HttpResponse.json({ suggestedReply: 'Would you like to share a photo?' })), http.get('/api/posts/by-author/alice', () => HttpResponse.json([{ id: 'post-1', subreddit: 'askuk', author: 'alice', title: 'A processed post', imageUrl: null, imageAnalysis: null, generatedMessage: 'Hello', processedAt: Date.now(), messageSent: true, error: null, }])), ); const user = userEvent.setup(); renderConversations(); await user.click(screen.getByText('Processed user lookup')); await user.type(screen.getByLabelText('Lookup Reddit username'), 'u/alice'); await user.click(screen.getByRole('button', { name: 'Look up user' })); expect(await screen.findByRole('list', { name: 'Processed posts for u/alice' })).toHaveTextContent('A processed post'); await user.click(screen.getByRole('button', { name: /u\/alice/ })); await user.click(await screen.findByRole('button', { name: 'Suggest media ask' })); expect(await screen.findByRole('textbox', { name: 'Reply to u/alice' })).toHaveValue('Would you like to share a photo?'); }); it('shares an eligible saved image through an explicit confirmation', async () => { const asset = { id: '11111111-1111-4111-8111-111111111111', tags: ['selfie'], safety: 'sfw', classificationStatus: 'ready', description: 'A selfie', confidence: 0.95, fileName: 'selfie.jpg', mimeType: 'image/jpeg', sizeBytes: 1024, width: 800, height: 1000, enabled: true, consentConfirmedAt: 1, createdAt: 1, updatedAt: 1, sendCount: 0, lastSentAt: null, urlPath: '/library-media/selfie.jpg', }; let sharedAssetId: string | null = null; server.use( http.get('/api/conversations', () => HttpResponse.json([alice])), http.get('/api/conversations/room-alice', () => HttpResponse.json({ ...detailFixture(), messages: [ { sender: 'me', text: 'Hello Alice', timestamp: 1, isSelf: true }, { eventId: '$request', sender: 'alice', text: 'Can you send a selfie?', timestamp: 2, isSelf: false }, ], })), http.get('/api/conversations/room-alice/shareable-media', () => HttpResponse.json({ requestKind: 'selfie', assets: [asset], reason: null, warning: null, })), http.post('/api/conversations/room-alice/share-media', async ({ request }) => { const body = await request.json() as { assetId: string }; sharedAssetId = body.assetId; 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.findByText('Share a saved image')); const image = await screen.findByRole('img', { name: 'Saved image tagged selfie' }); expect(image).toHaveClass('is-blurred'); await user.click(screen.getByRole('button', { name: 'Reveal saved images' })); expect(image).not.toHaveClass('is-blurred'); await user.click(screen.getByRole('radio', { name: /Saved image tagged selfie/ })); const reviewButton = screen.getByRole('button', { name: 'Review image share' }); await user.click(reviewButton); const dialog = screen.getByRole('dialog', { name: 'Share this saved image?' }); expect(within(dialog).getByRole('img', { name: 'Saved image tagged selfie' })).toHaveAttribute('src', asset.urlPath); await user.click(within(dialog).getByRole('button', { name: 'Confirm image share' })); expect(await screen.findByText(/Saved image shared with u\/alice/)).toBeInTheDocument(); expect(sharedAssetId).toBe(asset.id); expect(screen.getByText('Share a saved image')).toHaveFocus(); }); it('shows unused turns separately from a safety handoff', async () => { server.use( http.get('/api/conversations', () => HttpResponse.json([alice])), http.get('/api/conversations/room-alice', () => HttpResponse.json({ ...detailFixture(), automation: { turnsUsed: 0, remainingTurns: 3, status: 'human-required', handoffReason: 'The generated media ask was rejected: media ask does not contain a recognizable image or video request.', }, })), ); const user = userEvent.setup(); renderConversations(); await user.click(await screen.findByRole('button', { name: /u\/alice/ })); expect(await screen.findByText('3 automated turns remaining')).toBeInTheDocument(); expect(screen.getByText(/generated media ask was rejected/)).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Suggest media ask' })).toBeDisabled(); }); it('starts a conversation and preserves both fields after a recoverable failure', async () => { installConversationList(); let startRequests = 0; server.use( http.post('/api/conversations/start', () => { startRequests += 1; return HttpResponse.json({ error: 'Invitation cooldown active' }, { status: 429 }); }), ); const user = userEvent.setup(); renderConversations(); await user.click(screen.getByRole('button', { name: 'New conversation' })); const username = screen.getByRole('textbox', { name: 'Reddit username' }); const message = screen.getByRole('textbox', { name: 'Opening message' }); await user.type(username, 'charlie'); await user.type(message, 'Hello Charlie'); await user.click(screen.getByRole('button', { name: 'Start conversation' })); expect(await screen.findByRole('alert')).toHaveTextContent('Invitation cooldown active'); expect(username).toHaveValue('charlie'); expect(message).toHaveValue('Hello Charlie'); server.use( http.post('/api/conversations/start', () => { startRequests += 1; return HttpResponse.json({ success: true, chat: chatFixture({ username: 'charlie', roomId: 'room-charlie' }) }); }), ); await user.click(screen.getByRole('button', { name: 'Start conversation' })); expect(await screen.findByRole('status', { name: 'Conversation started' })).toHaveTextContent('Conversation started with u/charlie'); expect(within(screen.getByRole('status', { name: 'Conversation started' })).getByRole('link', { name: 'u/charlie' })) .toHaveAttribute('href', 'https://www.reddit.com/u/charlie'); expect(username).toHaveValue(''); expect(message).toHaveValue(''); expect(startRequests).toBe(2); }); it('explains how to enable the start-conversation form when no accounts are running', async () => { server.use( http.get('/api/conversations', () => HttpResponse.json([])), http.get('/api/accounts', () => HttpResponse.json([])), ); const user = userEvent.setup(); renderConversations(); await user.click(screen.getByRole('button', { name: 'New conversation' })); expect(await screen.findByText(/No running accounts/)).toBeInTheDocument(); expect(screen.getByRole('link', { name: 'Settings' })).toHaveAttribute('href', '/settings'); expect(screen.queryByRole('button', { name: 'Start conversation' })).not.toBeInTheDocument(); }); it('counts each triage bucket on its filter pill and marks unread rows in the label', async () => { installConversationList(); renderConversations(); const aliceButton = await screen.findByRole('button', { name: /u\/alice/ }); // alice is inbound and unread; bob is a conversation we replied to last. expect(screen.getByRole('button', { name: 'Unread, 1' })).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Needs reply, 1' })).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Waiting on them, 1' })).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'All, 2' })).toHaveAttribute('aria-pressed', 'true'); expect(aliceButton.getAttribute('aria-label')).toMatch(/^u\/alice, Unread, Needs reply, .+ago/); expect(aliceButton.closest('li')).toHaveClass('is-unread'); expect(screen.getByRole('button', { name: /u\/bob/ }).closest('li')).not.toHaveClass('is-unread'); }); it('offers a jump-to-latest control when the operator scrolls up in the history', async () => { installConversationList(); const user = userEvent.setup(); renderConversations(); await user.click(await screen.findByRole('button', { name: /u\/alice/ })); const history = await screen.findByRole('list', { name: 'Message history' }); expect(screen.queryByRole('button', { name: 'Jump to latest' })).not.toBeInTheDocument(); Object.defineProperty(history, 'scrollHeight', { configurable: true, value: 1000 }); Object.defineProperty(history, 'clientHeight', { configurable: true, value: 300 }); history.scrollTop = 100; fireEvent.scroll(history); const jumpButton = await screen.findByRole('button', { name: 'Jump to latest' }); await user.click(jumpButton); expect(history.scrollTop).toBe(1000); expect(screen.queryByRole('button', { name: 'Jump to latest' })).not.toBeInTheDocument(); }); it('preserves an unsent draft when switching away from and back to a conversation', async () => { installConversationList(); const user = userEvent.setup(); renderConversations(); await user.click(await screen.findByRole('button', { name: /u\/alice/ })); const composer = await screen.findByRole('textbox', { name: 'Reply to u/alice' }); await user.type(composer, 'Draft in progress'); await user.click(screen.getByRole('button', { name: 'Close conversation' })); expect(screen.queryByRole('textbox', { name: 'Reply to u/alice' })).not.toBeInTheDocument(); await user.click(screen.getByRole('button', { name: /u\/alice/ })); expect(await screen.findByRole('textbox', { name: 'Reply to u/alice' })).toHaveValue('Draft in progress'); }); it('lets the operator undo a suggestion that replaced a typed draft', async () => { installConversationList(); server.use( http.post('/api/conversations/room-alice/suggest-reply', () => HttpResponse.json({ suggestedReply: 'A generated suggestion' }), ), ); const user = userEvent.setup(); renderConversations(); await user.click(await screen.findByRole('button', { name: /u\/alice/ })); const composer = await screen.findByRole('textbox', { name: 'Reply to u/alice' }); await user.type(composer, 'My own words'); expect(screen.getByText('12 / 10000')).toBeInTheDocument(); await user.click(screen.getByRole('button', { name: 'Suggest reply' })); await waitFor(() => expect(composer).toHaveValue('A generated suggestion')); expect(screen.getByText('The suggestion replaced your draft.')).toBeInTheDocument(); await user.click(screen.getByRole('button', { name: 'Undo' })); expect(composer).toHaveValue('My own words'); expect(screen.queryByRole('button', { name: 'Undo' })).not.toBeInTheDocument(); }); it('keeps the confirmation image blurred when saved images were re-blurred before review', async () => { const asset = { id: '22222222-2222-4222-8222-222222222222', tags: ['selfie'], safety: 'sfw', classificationStatus: 'ready', description: 'A selfie', confidence: 0.95, fileName: 'selfie.jpg', mimeType: 'image/jpeg', sizeBytes: 1024, width: 800, height: 1000, enabled: true, consentConfirmedAt: 1, createdAt: 1, updatedAt: 1, sendCount: 0, lastSentAt: null, urlPath: '/library-media/selfie.jpg', }; server.use( http.get('/api/conversations', () => HttpResponse.json([alice])), http.get('/api/conversations/room-alice', () => HttpResponse.json(detailFixture())), http.get('/api/conversations/room-alice/shareable-media', () => HttpResponse.json({ requestKind: 'selfie', assets: [asset], reason: null, warning: null, })), ); const user = userEvent.setup(); renderConversations(); await user.click(await screen.findByRole('button', { name: /u\/alice/ })); await user.click(await screen.findByText('Share a saved image')); expect(await screen.findByText('Reveal images to select one.')).toBeInTheDocument(); await user.click(screen.getByRole('button', { name: 'Reveal saved images' })); expect(screen.queryByText('Reveal images to select one.')).not.toBeInTheDocument(); await user.click(screen.getByRole('radio', { name: /Saved image tagged selfie/ })); await user.click(screen.getByRole('button', { name: 'Blur saved images' })); await user.click(screen.getByRole('button', { name: 'Review image share' })); const dialog = screen.getByRole('dialog', { name: 'Share this saved image?' }); const dialogImage = within(dialog).getByRole('img', { name: 'Saved image tagged selfie' }); expect(dialogImage).toHaveClass('is-blurred'); await user.click(within(dialog).getByRole('button', { name: 'Reveal image' })); expect(dialogImage).not.toHaveClass('is-blurred'); }); it('runs a forced seller scan and reports what it cost', async () => { let scanned = 0; installConversationList(); server.use( http.post('/api/conversations/seller-scan', () => { scanned += 1; return HttpResponse.json({ success: true, scanned: 12, alreadyFlagged: 2, unchanged: 6, deterministic: 1, aiScreened: 3, aiRequests: 1, budgetSkipped: 0, emptySkipped: 0, degraded: false, flagged: [{ conversationId: 'room-alice', username: 'alice', source: 'ai', reason: 'AI screening classified this conversation as seller (confidence 0.90)', confidence: 0.9, killed: true, }], }); }), ); const user = userEvent.setup(); renderConversations(); await user.click(await screen.findByRole('button', { name: 'Scan for sellers' })); const summary = await screen.findByRole('status'); expect(summary).toHaveTextContent('Scanned 12 conversations'); expect(summary).toHaveTextContent('1 flagged as sellers (1 ended)'); expect(summary).toHaveTextContent('3 needed AI over 1 request'); expect(summary).toHaveTextContent('6 unchanged since the last scan'); expect(scanned).toBe(1); }); it('surfaces a failed seller scan without disturbing the list', async () => { installConversationList(); server.use( http.post('/api/conversations/seller-scan', () => HttpResponse.json({ error: 'Failed to scan conversations for sellers' }, { status: 500 })), ); const user = userEvent.setup(); renderConversations(); await user.click(await screen.findByRole('button', { name: 'Scan for sellers' })); expect(await screen.findByRole('alert')).toHaveTextContent('Seller scan failed'); expect(screen.getByRole('button', { name: /u\/alice/ })).toBeInTheDocument(); }); });