// @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, vi } from 'vitest'; import { createMemoryRouter, RouterProvider } from 'react-router-dom'; import { createDashboardRoutes } from '../../apps/dashboard/src/app/router.js'; interface TestStatsOverrides { isRunning?: boolean; recentActivity?: readonly unknown[]; } const server = setupServer(); function createStats(overrides: TestStatsOverrides = {}) { return { isRunning: overrides.isRunning ?? false, dryRunMode: true, totalProcessed: 18, totalMessagesSent: 12, totalResponses: 3, responseRate: 25, messagesThisHour: 2, maxMessagesPerHour: 8, subreddits: [{ name: 'casualconversation', type: 'chat' }], targetGenders: ['female'], recentActivity: overrides.recentActivity ?? [], invitationCooldown: null, }; } function installSuccessfulApi(overrides: TestStatsOverrides = {}) { server.use( http.get('/api/stats', () => HttpResponse.json(createStats(overrides))), http.get('/api/timers', () => HttpResponse.json({ nextMessageAvailableAt: null, hourlyResetAt: Date.now() + 3_600_000, isHourlyLimitReached: false, messagesRemaining: 6, }), ), http.get('/api/messages', () => HttpResponse.json([])), ); } function renderDashboard(initialPath = '/') { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, }); const router = createMemoryRouter(createDashboardRoutes(), { initialEntries: [initialPath] }); render( , ); return { queryClient, router }; } beforeAll(() => server.listen({ onUnhandledRequest: 'error' })); afterEach(() => { cleanup(); server.resetHandlers(); vi.restoreAllMocks(); }); afterAll(() => server.close()); describe('dashboard foundation', () => { it('provides responsive navigation with real routes for every platform area', async () => { installSuccessfulApi(); const user = userEvent.setup(); const { router } = renderDashboard('/'); const navigation = screen.getByRole('navigation', { name: 'Primary navigation' }); for (const label of ['Overview', 'Conversations', 'Media', 'Performance', 'Operations', 'Settings']) { expect(within(navigation).getByRole('link', { name: label })).toBeInTheDocument(); } await user.click(screen.getByRole('button', { name: 'Open navigation' })); expect(screen.getByRole('button', { name: 'Close navigation' })).toHaveAttribute('aria-expanded', 'true'); await user.click(within(navigation).getByRole('link', { name: 'Operations' })); await waitFor(() => expect(router.state.location.pathname).toBe('/operations')); expect(screen.getByRole('heading', { name: 'Operations' })).toBeInTheDocument(); }); it('hydrates media filters from the routed URL', async () => { installSuccessfulApi(); server.use( http.get('/api/media', () => HttpResponse.json([{ id: 'media-1', chatUsername: 'alice', chatRoomId: 'room-1', sender: 'alice', timestamp: Date.now(), originalUrl: 'https://example.test/reaction.gif', fileName: 'reaction.gif', relativePath: 'reaction.gif', createdAt: Date.now(), urlPath: '/media/reaction.gif', }])), http.get('/api/media/sync', () => HttpResponse.json({ isSyncing: false, lastSyncAt: null, lastSyncDurationMs: null, lastSyncChats: null, lastError: null, nextSyncAt: null, })), http.get('/api/login/status', () => HttpResponse.json({ ready: true, loggedIn: true, status: 'authenticated', })), http.get('/api/chats/sync', () => HttpResponse.json({ syncing: false, syncingKind: null, lastFullSyncAt: null, lastDeltaSyncAt: null, hasEverFullSynced: false, cachedChats: 0, progress: null, })), ); renderDashboard('/media?conversation=alice&type=gif&range=7d'); expect(await screen.findByRole('combobox', { name: 'Conversation' })).toHaveValue('alice'); expect(screen.getByRole('combobox', { name: 'Media type' })).toHaveValue('gif'); expect(screen.getByRole('combobox', { name: 'Date range' })).toHaveValue('7d'); }); it('shows loading then renders stats, timers, and an explicit empty activity state', async () => { let resolveStats: ((response: Response) => void) | undefined; server.use( http.get('/api/stats', async () => new Promise((resolve) => { resolveStats = resolve; }), ), http.get('/api/timers', () => HttpResponse.json({ nextMessageAvailableAt: null, hourlyResetAt: Date.now() + 3_600_000, isHourlyLimitReached: false, messagesRemaining: 6, }), ), http.get('/api/messages', () => HttpResponse.json([{ id: 1, postId: 'post-1', recipient: 'alice', subject: 'Hello', message: 'A recent opener', sentAt: Date.now(), gotResponse: true, gotImage: false, responseCheckedAt: Date.now(), strategyId: null, chatRoomId: null, }])), ); renderDashboard(); expect(screen.getByRole('status', { name: 'Loading overview' })).toBeInTheDocument(); await waitFor(() => expect(resolveStats).toBeTypeOf('function')); resolveStats?.(HttpResponse.json(createStats())); expect(await screen.findByText('12', { selector: '[data-metric="messages-sent"]' })).toBeInTheDocument(); expect(screen.getByText('6 messages remaining')).toBeInTheDocument(); expect(screen.getByText('No recent activity yet.')).toBeInTheDocument(); expect(screen.getByText('Dry run')).toBeInTheDocument(); expect(await screen.findByRole('list', { name: 'Recent sent messages' })).toHaveTextContent('A recent opener'); expect(screen.getByRole('list', { name: 'Recent sent messages' })).toHaveTextContent('Responded'); expect(screen.getByRole('link', { name: 'u/alice' })).toHaveAttribute('href', 'https://www.reddit.com/u/alice'); }); it('keeps stats visible when timers fail and retries only the failed panel', async () => { let timerRequests = 0; server.use( http.get('/api/stats', () => HttpResponse.json(createStats())), http.get('/api/timers', () => { timerRequests += 1; if (timerRequests === 1) return HttpResponse.json({ error: 'Rate limiter not initialized' }, { status: 503 }); return HttpResponse.json({ nextMessageAvailableAt: null, hourlyResetAt: Date.now() + 3_600_000, isHourlyLimitReached: false, messagesRemaining: 6, }); }), ); const user = userEvent.setup(); renderDashboard(); expect(await screen.findByText('12', { selector: '[data-metric="messages-sent"]' })).toBeInTheDocument(); const alert = await screen.findByRole('alert'); expect(alert).toHaveTextContent('Rate limiter not initialized'); await user.click(within(alert).getByRole('button', { name: 'Retry timers' })); expect(await screen.findByText('6 messages remaining')).toBeInTheDocument(); expect(timerRequests).toBe(2); }); it('makes usernames in recent activity clickable', async () => { installSuccessfulApi({ recentActivity: [{ timestamp: Date.now(), type: 'response', message: 'Reply from u/alice', recipient: 'alice', }], }); renderDashboard(); const activity = await screen.findByRole('heading', { name: 'Recent activity' }); const panel = activity.closest('section'); expect(panel).not.toBeNull(); expect(within(panel!).getByRole('link', { name: 'u/alice' })).toHaveAttribute( 'href', 'https://www.reddit.com/u/alice', ); }); it('opens saved source-post context from a sent activity entry', async () => { installSuccessfulApi({ recentActivity: [{ timestamp: Date.now(), type: 'sent', message: 'Sent message to u/alice (r/example)', recipient: 'alice', imageUrl: 'https://i.redd.it/source.jpg', postTitle: 'The source post title', postUrl: 'https://www.reddit.com/r/example/comments/source/the_source_post_title/', }], }); const user = userEvent.setup(); renderDashboard(); await user.click(await screen.findByRole('button', { name: 'Sent message to u/alice (r/example)' })); const dialog = screen.getByRole('dialog', { name: 'The source post title' }); expect(within(dialog).getByRole('img', { name: 'Source post: The source post title' })).toHaveAttribute( 'src', 'https://i.redd.it/source.jpg', ); expect(within(dialog).getByRole('link', { name: 'Open Reddit post' })).toHaveAttribute( 'href', 'https://www.reddit.com/r/example/comments/source/the_source_post_title/', ); }); it('styles the recent-messages retry control when its request fails', async () => { installSuccessfulApi(); server.use(http.get('/api/messages', () => HttpResponse.json({ error: 'Messages unavailable' }, { status: 503 }))); renderDashboard(); const retryButton = await screen.findByRole('button', { name: 'Retry messages' }); expect(retryButton).toHaveClass('button', 'secondary'); }); it('shows refresh progress while preserving the current overview', async () => { installSuccessfulApi(); const user = userEvent.setup(); renderDashboard(); const refreshButton = await screen.findByRole('button', { name: 'Refresh overview' }); let resolveRefresh: ((response: Response) => void) | undefined; server.use( http.get('/api/stats', async () => new Promise((resolve) => { resolveRefresh = resolve; }), ), ); await user.click(refreshButton); expect(screen.getByRole('button', { name: 'Refreshing overview' })).toBeDisabled(); expect(screen.getByText('12', { selector: '[data-metric="messages-sent"]' })).toBeInTheDocument(); resolveRefresh?.(HttpResponse.json(createStats())); await waitFor(() => expect(screen.getByRole('button', { name: 'Refresh overview' })).toBeEnabled()); }); it('requires confirmation before starting and reflects the authoritative response', async () => { installSuccessfulApi(); let startRequests = 0; server.use( http.post('/api/start', () => { startRequests += 1; return HttpResponse.json({ success: true, running: true }); }), ); const user = userEvent.setup(); renderDashboard(); await user.click(await screen.findByRole('button', { name: 'Start bot' })); const dialog = screen.getByRole('dialog', { name: 'Start bot?' }); expect(dialog).toHaveTextContent('The bot will resume automated processing.'); await user.click(within(dialog).getByRole('button', { name: 'Cancel' })); expect(startRequests).toBe(0); await user.click(screen.getByRole('button', { name: 'Start bot' })); await user.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'Confirm start' })); expect(await screen.findByText('Running', { selector: '[data-bot-status]' })).toBeInTheDocument(); expect(startRequests).toBe(1); }); });