Adds a "Scan for sellers" button to the conversations header, backed by POST /api/conversations/seller-scan. The sweep reuses the existing seller detection rather than inventing a second one: the configured keyword/domain list, the seller-link funnel patterns, and the aiReplyScreening confidences all decide the verdict, so a swept conversation lands in the state the live reply loop would have given it. Being operator-forced, it deliberately ignores filterSellers and aiReplyScreening.enabled. AI is the last resort. Conversations are skipped when already flagged or killed, settled for free by the deterministic rules, or skipped against a durable sellerScreenedKey watermark when nothing new has arrived since the last clean sweep. Only the remainder reaches the model, batched eight at a time through a new screenConversationsForSellers, so the system prompt and round trip are paid per batch instead of per conversation. A classifier outage degrades the sweep instead of failing it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
652 lines
31 KiB
TypeScript
652 lines
31 KiB
TypeScript
// @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(
|
|
<MemoryRouter>
|
|
<QueryClientProvider client={queryClient}>
|
|
<ConversationsPage />
|
|
</QueryClientProvider>
|
|
</MemoryRouter>,
|
|
);
|
|
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();
|
|
});
|
|
});
|