feat(dashboard): add conversations workspace
This commit is contained in:
1 parent
40e5f4c818
commit
8d4eae7ab5
8 files changed
+840
No files matched your search
@@ -0,0 +1,216 @@
|
||||
// @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;
|
||||
unread?: boolean;
|
||||
isFromUs?: boolean;
|
||||
lastMessageSnippet?: string;
|
||||
}
|
||||
|
||||
function chatFixture(options: ChatFixtureOptions) {
|
||||
return {
|
||||
username: options.username,
|
||||
roomName: options.username,
|
||||
roomId: options.roomId,
|
||||
url: `https://chat.reddit.com/room/${options.roomId}`,
|
||||
lastMessageTime: 1_786_000_000_000,
|
||||
lastMessageSnippet: options.lastMessageSnippet ?? `Latest from ${options.username}`,
|
||||
isFromUs: options.isFromUs ?? false,
|
||||
unread: options.unread ?? false,
|
||||
};
|
||||
}
|
||||
|
||||
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 },
|
||||
{ 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',
|
||||
sentAt: 1_785_999_800_000,
|
||||
},
|
||||
syncInProgress: null,
|
||||
};
|
||||
}
|
||||
|
||||
const server = setupServer();
|
||||
|
||||
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.getByText('Good to hear from you')).toBeInTheDocument();
|
||||
expect(screen.getByRole('link', { name: 'Looking for a chat' })).toHaveAttribute('href', expect.stringContaining('post-1'));
|
||||
});
|
||||
|
||||
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('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(username).toHaveValue('');
|
||||
expect(message).toHaveValue('');
|
||||
expect(startRequests).toBe(2);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user