The settings page had accumulated five tabs whose names no longer matched their contents, three different save models, and the whole subreddit configuration buried under a tab named after a checkbox. Structure: - Subreddits becomes its own page: a list showing each community's mode, tone, computed share of polling, and live sent/reply stats, with one subreddit edited at a time in a drawer. - Accounts becomes its own page. It is session lifecycle, not config. - Settings collapses to one scrolling page: Safety, Pacing (Monitoring and Rate limits merged), Targeting, Notifications, Advanced. - The sidebar is grouped, having gone from 9 to 11 destinations. Saving: - Every section owns its own draft, validation, risk gate and save via useConfigCard, submitting only the keys it owns. A syntax error in the advanced JSON can no longer block a rate-limit change, and enabling an autonomous policy is its own deliberate commit. - UnsavedChangesProvider restores the leave-page guard across cards that no longer share a draft. Subreddit editing: - Errors are keyed by field name instead of array index, removing the defensive invalidation that existed because row indices shifted on add and remove. Confirming an autonomous mode can no longer be attributed to the wrong row. - Weight is shown as the share it actually produces, live while editing. - Bulk actions cover start/pause polling, tone, policy and removal, because production runs 30-40 subreddits and per-row editing does not scale. Bulk policy changes name every subreddit turning autonomous, and normalise the hybrid mode's turn budget, which has no form to catch it. Physique targeting moves into its own Targeting card. It sends only reddit.physiqueTargeting, which the config route applies independently, so it cannot clobber the pacing card's fields. Turning screening on now warns that it will reduce how many posts qualify. Also surfaces the effective sending rate, since the hourly limit and the minimum delay both throttle and only the binding one matters. Legacy ?section= links redirect or resolve to anchors. Fixes found while verifying in a browser: a radio-card rule was shrinking nested number inputs; .share-preview had no styles; the throughput readout reported a rate from a blank field; and the reconcile logic warned about a server change whenever a sibling card saved. 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: 'Accounts' })).toHaveAttribute('href', '/accounts');
|
|
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();
|
|
});
|
|
});
|