Files
RedditBot/tests/dashboard/conversation-read-state.test.tsx
T

473 lines
20 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 { MemoryRouter } from 'react-router-dom';
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
import { ConversationsPage } from '../../apps/dashboard/src/features/conversations/conversations-page.js';
import { resetComposerDrafts } from '../../apps/dashboard/src/features/conversations/composer-drafts.js';
const NOW = 1_786_000_000_000;
interface ChatOptions {
username: string;
roomId?: string;
lastMessageTime?: number;
isFromUs?: boolean;
hasUnreadInbound?: boolean;
needsReply?: boolean;
triageStatus?: 'open' | 'snoozed' | 'done';
lastReadMessageTs?: number;
sellerDetected?: boolean;
}
function chatFixture(options: ChatOptions) {
const roomId = options.roomId ?? `room-${options.username}`;
const isFromUs = options.isFromUs ?? false;
const triageStatus = options.triageStatus ?? 'open';
return {
username: options.username,
roomName: options.username,
roomId,
url: `https://chat.reddit.com/room/${roomId}`,
lastMessageTime: options.lastMessageTime ?? NOW,
lastMessageSnippet: `Latest from ${options.username}`,
isFromUs,
unread: false,
hasUnreadInbound: options.hasUnreadInbound ?? (!isFromUs && triageStatus === 'open'),
needsReply: options.needsReply ?? (!isFromUs && triageStatus === 'open'),
triageStatus,
snoozedUntil: null,
lastReadMessageTs: options.lastReadMessageTs ?? 0,
...(options.sellerDetected === undefined ? {} : { sellerDetected: options.sellerDetected }),
};
}
function detailFixture(chat: ReturnType<typeof chatFixture>, messages?: unknown[]) {
return {
...chat,
messages: messages ?? [
{ eventId: '$one', sender: 'me', text: 'Hello there', timestamp: NOW - 200_000, isSelf: true },
{ eventId: '$two', sender: chat.username, text: 'Replying now', timestamp: NOW, isSelf: false },
],
triggeringPost: null,
automation: { turnsUsed: 0, remainingTurns: 3, status: 'active', handoffReason: null },
syncInProgress: null,
};
}
const alice = chatFixture({ username: 'alice' });
const server = setupServer(
http.get('/api/accounts', () => HttpResponse.json([])),
http.get('/api/conversations/:id/shareable-media', () => HttpResponse.json({
requestKind: null, assets: [], reason: 'Nothing to share.', warning: null,
})),
);
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();
vi.useRealTimers();
});
afterAll(() => server.close());
describe('application-level read state', () => {
it('advances the read watermark after the conversation has been open a moment', async () => {
const readCalls: Array<{ id: string; body: unknown }> = [];
server.use(
http.get('/api/conversations', () => HttpResponse.json([alice])),
http.get('/api/conversations/room-alice', () => HttpResponse.json(detailFixture(alice))),
http.post('/api/conversations/:id/read', async ({ params, request }) => {
readCalls.push({ id: String(params.id), body: await request.json() });
return HttpResponse.json({ success: true });
}),
);
const user = userEvent.setup();
renderConversations();
await user.click(await screen.findByRole('button', { name: /u\/alice/ }));
await screen.findByRole('heading', { name: 'u/alice' });
await waitFor(() => expect(readCalls).toHaveLength(1));
expect(readCalls[0]).toEqual({ id: 'room-alice', body: { throughTimestamp: NOW } });
});
it('does not mark a conversation read that is already at the watermark', async () => {
const read = chatFixture({ username: 'alice', hasUnreadInbound: false, lastReadMessageTs: NOW });
let readCalls = 0;
server.use(
http.get('/api/conversations', () => HttpResponse.json([read])),
http.get('/api/conversations/room-alice', () => HttpResponse.json(detailFixture(read))),
http.post('/api/conversations/:id/read', () => {
readCalls += 1;
return HttpResponse.json({ success: true });
}),
);
const user = userEvent.setup();
renderConversations();
await user.click(await screen.findByRole('button', { name: /u\/alice/ }));
await screen.findByRole('heading', { name: 'u/alice' });
// Give the debounce more than enough time to fire if it were going to.
await new Promise((resolve) => setTimeout(resolve, 60));
expect(readCalls).toBe(0);
});
it('clears the unread chip once the watermark has advanced', async () => {
// Stateful so the assertion covers the whole round trip, not just the
// optimistic frame that the follow-up refetch would overwrite.
let watermark = 0;
server.use(
http.get('/api/conversations', () => HttpResponse.json([
chatFixture({ username: 'alice', hasUnreadInbound: watermark < NOW, lastReadMessageTs: watermark }),
])),
http.get('/api/conversations/room-alice', () => HttpResponse.json(detailFixture(
chatFixture({ username: 'alice', hasUnreadInbound: watermark < NOW, lastReadMessageTs: watermark }),
))),
http.post('/api/conversations/:id/read', async ({ request }) => {
const body = await request.json() as { throughTimestamp?: number };
watermark = Math.max(watermark, body.throughTimestamp ?? NOW);
return HttpResponse.json({ success: true });
}),
);
const user = userEvent.setup();
renderConversations();
const row = await screen.findByRole('button', { name: /u\/alice/ });
expect(row.getAttribute('aria-label')).toContain('Unread');
await user.click(row);
// The debounce plus the follow-up refetch takes longer than waitFor's default.
await waitFor(() => expect(
screen.getByRole('button', { name: /u\/alice/ }).getAttribute('aria-label'),
).not.toContain('Unread'), { timeout: 4_000 });
});
it('renders a new-messages divider at the read boundary and none for a first read', async () => {
const partiallyRead = chatFixture({ username: 'alice', lastReadMessageTs: NOW - 100_000 });
server.use(
http.get('/api/conversations', () => HttpResponse.json([partiallyRead])),
http.get('/api/conversations/room-alice', () => HttpResponse.json(detailFixture(partiallyRead))),
http.post('/api/conversations/:id/read', () => HttpResponse.json({ success: true })),
);
const user = userEvent.setup();
renderConversations();
await user.click(await screen.findByRole('button', { name: /u\/alice/ }));
expect(await screen.findByText('New messages')).toBeInTheDocument();
});
it('keeps the divider in place after auto-mark-read advances the watermark', async () => {
// Regression: the divider used to vanish a second after opening, because it
// was derived from the live watermark that auto-mark-read had just moved.
let watermark = NOW - 100_000;
const current = () => chatFixture({
username: 'alice', hasUnreadInbound: watermark < NOW, lastReadMessageTs: watermark,
});
server.use(
http.get('/api/conversations', () => HttpResponse.json([current()])),
http.get('/api/conversations/room-alice', () => HttpResponse.json(detailFixture(current()))),
http.post('/api/conversations/:id/read', async ({ request }) => {
const body = await request.json() as { throughTimestamp?: number };
watermark = Math.max(watermark, body.throughTimestamp ?? NOW);
return HttpResponse.json({ success: true });
}),
);
const user = userEvent.setup();
renderConversations();
await user.click(await screen.findByRole('button', { name: /u\/alice/ }));
expect(await screen.findByText('New messages')).toBeInTheDocument();
// Wait for the read to land and the panel to re-render from fresh data.
await waitFor(() => expect(
screen.getByRole('button', { name: /u\/alice/ }).getAttribute('aria-label'),
).not.toContain('Unread'), { timeout: 4_000 });
expect(screen.getByText('New messages')).toBeInTheDocument();
});
it('omits the divider when the conversation has never been read', async () => {
server.use(
http.get('/api/conversations', () => HttpResponse.json([alice])),
http.get('/api/conversations/room-alice', () => HttpResponse.json(detailFixture(alice))),
http.post('/api/conversations/:id/read', () => HttpResponse.json({ success: true })),
);
const user = userEvent.setup();
renderConversations();
await user.click(await screen.findByRole('button', { name: /u\/alice/ }));
await screen.findByText('Replying now');
expect(screen.queryByText('New messages')).not.toBeInTheDocument();
});
it('groups history under day dividers instead of stamping every message with a date', async () => {
const twoDaysAgo = NOW - (2 * 86_400_000);
server.use(
http.get('/api/conversations', () => HttpResponse.json([alice])),
http.get('/api/conversations/room-alice', () => HttpResponse.json(detailFixture(alice, [
{ eventId: '$old', sender: 'me', text: 'Older message', timestamp: twoDaysAgo, isSelf: true },
{ eventId: '$new', sender: 'alice', text: 'Newer message', timestamp: Date.now(), isSelf: false },
]))),
http.post('/api/conversations/:id/read', () => HttpResponse.json({ success: true })),
);
const user = userEvent.setup();
renderConversations();
await user.click(await screen.findByRole('button', { name: /u\/alice/ }));
await screen.findByText('Newer message');
expect(screen.getByText('Today')).toBeInTheDocument();
// Two distinct days produce two dividers.
expect(document.querySelectorAll('.conversation-day-divider')).toHaveLength(2);
});
it('returns a conversation to the queue from the detail header', async () => {
let watermark = NOW;
let unreadBody: unknown = null;
const current = () => chatFixture({
username: 'alice', hasUnreadInbound: watermark < NOW, lastReadMessageTs: watermark,
});
server.use(
http.get('/api/conversations', () => HttpResponse.json([current()])),
http.get('/api/conversations/room-alice', () => HttpResponse.json(detailFixture(current()))),
http.post('/api/conversations/:id/unread', async ({ request }) => {
unreadBody = await request.json();
watermark = 0;
return HttpResponse.json({ success: true });
}),
// Re-opening the panel re-reads it; that must not fight the manual unread.
http.post('/api/conversations/:id/read', () => HttpResponse.json({ success: true })),
);
const user = userEvent.setup();
renderConversations();
await user.click(await screen.findByRole('button', { name: /u\/alice/ }));
await user.click(await screen.findByRole('button', { name: 'Mark unread' }));
await waitFor(() => expect(unreadBody).toEqual({}));
await waitFor(() => expect(
screen.getByRole('button', { name: /u\/alice/ }).getAttribute('aria-label'),
).toContain('Unread'));
});
it('resolves a conversation and closes the detail panel', async () => {
let statusBody: unknown = null;
server.use(
http.get('/api/conversations', () => HttpResponse.json([alice])),
http.get('/api/conversations/room-alice', () => HttpResponse.json(detailFixture(alice))),
http.post('/api/conversations/:id/read', () => HttpResponse.json({ success: true })),
http.post('/api/conversations/:id/status', async ({ request }) => {
statusBody = await request.json();
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.findByRole('button', { name: 'Mark done' }));
await waitFor(() => expect(statusBody).toEqual({ status: 'done' }));
expect(await screen.findByText('Select a conversation')).toBeInTheDocument();
});
it('snoozes for a chosen duration', async () => {
let statusBody: { status: string; snoozeUntil: number } | null = null;
server.use(
http.get('/api/conversations', () => HttpResponse.json([alice])),
http.get('/api/conversations/room-alice', () => HttpResponse.json(detailFixture(alice))),
http.post('/api/conversations/:id/read', () => HttpResponse.json({ success: true })),
http.post('/api/conversations/:id/status', async ({ request }) => {
statusBody = await request.json() as { status: string; snoozeUntil: number };
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.findByRole('button', { name: 'Snooze' }));
const menu = screen.getByRole('group', { name: 'Snooze options' });
await user.click(within(menu).getByRole('button', { name: 'Tomorrow' }));
await waitFor(() => expect(statusBody?.status).toBe('snoozed'));
expect(statusBody?.snoozeUntil).toBeGreaterThan(Date.now());
});
it('marks every selected conversation read in one request', async () => {
let bulkBody: { conversationIds: string[] } | null = null;
server.use(
http.get('/api/conversations', () => HttpResponse.json([
chatFixture({ username: 'alice' }),
chatFixture({ username: 'bob', lastMessageTime: NOW - 1_000 }),
])),
http.post('/api/conversations/bulk-read', async ({ request }) => {
bulkBody = await request.json() as { conversationIds: string[] };
return HttpResponse.json({ success: true, updated: 2, failed: [] });
}),
);
const user = userEvent.setup();
renderConversations();
await screen.findByRole('button', { name: /u\/alice/ });
await user.click(screen.getByRole('checkbox', { name: 'Select all visible conversations' }));
expect(screen.getByText('2 selected')).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: 'Mark read' }));
await waitFor(() => expect(bulkBody?.conversationIds).toEqual(['room-alice', 'room-bob']));
});
it('marks a single checked conversation read without touching the others', async () => {
let bulkBody: { conversationIds: string[] } | null = null;
server.use(
http.get('/api/conversations', () => HttpResponse.json([
chatFixture({ username: 'alice' }),
chatFixture({ username: 'bob', lastMessageTime: NOW - 1_000 }),
])),
http.post('/api/conversations/bulk-read', async ({ request }) => {
bulkBody = await request.json() as { conversationIds: string[] };
return HttpResponse.json({ success: true, updated: 1, failed: [] });
}),
);
const user = userEvent.setup();
renderConversations();
await screen.findByRole('button', { name: /u\/alice/ });
await user.click(screen.getByRole('checkbox', { name: 'Select u/bob' }));
await user.click(screen.getByRole('button', { name: 'Mark read' }));
await waitFor(() => expect(bulkBody?.conversationIds).toEqual(['room-bob']));
});
it('drives the list from the keyboard', async () => {
let readId: string | null = null;
let statusBody: unknown = null;
server.use(
http.get('/api/conversations', () => HttpResponse.json([
chatFixture({ username: 'alice' }),
chatFixture({ username: 'bob', lastMessageTime: NOW - 1_000 }),
])),
http.post('/api/conversations/:id/read', ({ params }) => {
readId = String(params.id);
return HttpResponse.json({ success: true });
}),
http.post('/api/conversations/:id/status', async ({ request }) => {
statusBody = await request.json();
return HttpResponse.json({ success: true });
}),
);
const user = userEvent.setup();
renderConversations();
await screen.findByRole('button', { name: /u\/alice/ });
// j moves to the first row, then to the second.
await user.keyboard('j');
expect(screen.getByRole('button', { name: /u\/alice/ })).toHaveFocus();
await user.keyboard('j');
expect(screen.getByRole('button', { name: /u\/bob/ })).toHaveFocus();
await user.keyboard('k');
expect(screen.getByRole('button', { name: /u\/alice/ })).toHaveFocus();
// u marks the focused row read; e resolves it.
await user.keyboard('u');
await waitFor(() => expect(readId).toBe('room-alice'));
await user.keyboard('e');
await waitFor(() => expect(statusBody).toEqual({ status: 'done' }));
});
it('focuses search with / and ignores shortcuts while typing', async () => {
server.use(http.get('/api/conversations', () => HttpResponse.json([alice])));
const user = userEvent.setup();
renderConversations();
await screen.findByRole('button', { name: /u\/alice/ });
await user.keyboard('/');
const search = screen.getByRole('searchbox', { name: 'Search conversations' });
expect(search).toHaveFocus();
// 'j' and 'e' are literal characters here, not shortcuts.
await user.keyboard('je');
expect(search).toHaveValue('je');
expect(search).toHaveFocus();
});
it('holds the row order while a conversation is open and offers an explicit refresh', async () => {
let listRequests = 0;
server.use(
http.get('/api/conversations', () => {
listRequests += 1;
return HttpResponse.json(listRequests === 1
? [chatFixture({ username: 'alice' })]
: [chatFixture({ username: 'carol', lastMessageTime: NOW + 5_000 }), chatFixture({ username: 'alice' })]);
}),
http.get('/api/conversations/room-alice', () => HttpResponse.json(detailFixture(alice))),
http.post('/api/conversations/:id/read', () => HttpResponse.json({ success: true })),
);
const user = userEvent.setup();
const queryClient = renderConversations();
await user.click(await screen.findByRole('button', { name: /u\/alice/ }));
await screen.findByRole('heading', { name: 'u/alice' });
await queryClient.refetchQueries({ queryKey: ['conversations'] });
// carol arrived but must not push alice down mid-triage.
await waitFor(() => expect(screen.getByRole('button', { name: /1 new conversation/ })).toBeInTheDocument());
expect(screen.queryByRole('button', { name: /u\/carol/ })).not.toBeInTheDocument();
await user.click(screen.getByRole('button', { name: /1 new conversation/ }));
expect(await screen.findByRole('button', { name: /u\/carol/ })).toBeInTheDocument();
});
it('keeps seller rows reachable through their dedicated queue', async () => {
server.use(
http.get('/api/conversations', () => HttpResponse.json([
chatFixture({ username: 'alice' }),
chatFixture({ username: 'seller', sellerDetected: true }),
])),
);
const user = userEvent.setup();
renderConversations();
await screen.findByRole('button', { name: /u\/alice/ });
expect(screen.getByRole('button', { name: 'Sellers, 1' })).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: 'Sellers, 1' }));
expect(screen.getByRole('button', { name: /u\/seller/ })).toBeInTheDocument();
expect(screen.queryByRole('button', { name: /u\/alice/ })).not.toBeInTheDocument();
});
it('flags an unanswered conversation that has gone stale', async () => {
server.use(
http.get('/api/conversations', () => HttpResponse.json([
chatFixture({ username: 'stale', lastMessageTime: Date.now() - (3 * 86_400_000) }),
])),
);
renderConversations();
const row = await screen.findByRole('button', { name: /u\/stale/ });
expect(row.getAttribute('aria-label')).toContain('Overdue');
expect(row.querySelector('time')).toHaveClass('is-critical');
});
});