Files
RedditBot/tests/dashboard/conversations.test.tsx
T

377 lines
17 KiB
TypeScript

// @vitest-environment jsdom
import '@testing-library/jest-dom/vitest';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { cleanup, render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { HttpResponse, http } from 'msw';
import { setupServer } from 'msw/node';
import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest';
import { ConversationsPage } from '../../apps/dashboard/src/features/conversations/conversations-page.js';
interface ChatFixtureOptions {
username: string;
roomId: string;
lastMessageTime?: number;
unread?: boolean;
isFromUs?: boolean;
lastMessageSnippet?: string;
hasReceivedImages?: boolean;
sellerDetected?: boolean;
}
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 }),
};
}
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,
}])));
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(
<QueryClientProvider client={queryClient}>
<ConversationsPage />
</QueryClientProvider>,
);
return queryClient;
}
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
afterEach(() => {
cleanup();
server.resetHandlers();
});
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.selectOptions(screen.getByRole('combobox', { name: 'Filter conversations' }), '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 original image' }));
expect(screen.getByRole('img', { name: 'Source post: Looking for a chat' })).not.toHaveClass('is-blurred');
});
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.selectOptions(screen.getByRole('combobox', { name: 'Filter conversations' }), 'received-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('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.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('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);
});
});