// @vitest-environment jsdom import '@testing-library/jest-dom/vitest'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { cleanup, render, screen, 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 { AccountsPage } from '../../apps/dashboard/src/features/accounts/accounts-page.js'; const account = { accountId: '@t2_bot:reddit.com', redditUsername: 'example-account', matrixUserId: '@t2_bot:reddit.com', isRunning: true, status: 'authenticated', createdAt: 1, updatedAt: 2, lastImportedAt: 2, }; const establishment = { state: 'unestablished' as const, sendingHeld: true, reasons: ['Account is 2 days old (below the configured floor of 7 days)'], accountAgeDays: 2, linkKarma: 1, commentKarma: 3, totalKarma: 4, hasVerifiedEmail: false, profileCheckedAt: 1_700_000_000_000, lastProbeAt: 1_700_000_000_000, lastProbeFailure: 'Reddit rejected an invitation from this account: account is too new (rate.invitation_limit_new_account)', retryAt: Date.now() + 4 * 60 * 60_000, }; const server = setupServer( http.get('/api/login/status', () => HttpResponse.json({ ready: true, loggedIn: false, status: 'login-required' })), http.get('/api/accounts', () => HttpResponse.json([])), ); function renderAccounts() { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, }); const router = createMemoryRouter([{ path: '/accounts', element: }], { initialEntries: ['/accounts'], }); return render( , ); } function sessionDump() { return new File([JSON.stringify({ cookies: [{ name: 'reddit_session', value: 'secret', domain: '.reddit.com', path: '/' }], matrix: { homeserver: 'https://matrix.redditspace.com', accessToken: 'matrix-secret', userId: '@t2_bot:reddit.com', capturedAt: 1_700_000_000_000, }, })], 'redditbot-session.json', { type: 'application/json' }); } beforeAll(() => server.listen({ onUnhandledRequest: 'error' })); afterEach(() => { cleanup(); server.resetHandlers(); vi.restoreAllMocks(); }); afterAll(() => server.close()); describe('accounts page', () => { it('reports the session state and how to fix it, without asking for credentials', async () => { renderAccounts(); expect(await screen.findByText('Login required')).toBeInTheDocument(); expect(screen.getByRole('heading', { name: 'Getting a session dump' })).toBeInTheDocument(); expect(screen.getByText(/contains login credentials/i)).toBeInTheDocument(); // The dashboard never collects Reddit credentials itself. expect(screen.queryByRole('textbox', { name: /password/i })).not.toBeInTheDocument(); expect(screen.queryByText(/noVNC/i)).not.toBeInTheDocument(); }); it('calls out paused accounts as needing a refreshed session', async () => { server.use(http.get('/api/accounts', () => HttpResponse.json([{ ...account, isRunning: false, status: 'login-required' }]))); renderAccounts(); expect(await screen.findByText('One account is paused.')).toBeInTheDocument(); expect(screen.getByText(/Other accounts keep running normally/)).toBeInTheDocument(); expect(screen.getByText('Waiting for a refreshed session')).toBeInTheDocument(); }); it('shows a confirmed Reddit suspension separately from a refreshable session pause', async () => { server.use(http.get('/api/accounts', () => HttpResponse.json([ { ...account, isRunning: false, status: 'suspended' }, ]))); renderAccounts(); expect(await screen.findByText('u/example-account is suspended by Reddit.')).toBeInTheDocument(); expect(screen.getAllByText('Account suspended')).toHaveLength(1); expect(screen.getByText(/cannot send, scan, or sync/i)).toBeInTheDocument(); expect(screen.queryByText('One account is paused.')).not.toBeInTheDocument(); }); it('explains a held account with what Reddit actually said, plus the profile evidence', async () => { server.use(http.get('/api/accounts', () => HttpResponse.json([{ ...account, establishment }]))); renderAccounts(); expect(await screen.findByText('u/example-account cannot start chats yet.')).toBeInTheDocument(); expect(screen.getByText(/Reddit rejected an invitation from a new account/)).toBeInTheDocument(); expect(screen.getByText('Sending on hold')).toBeInTheDocument(); expect(screen.getByText(/rate\.invitation_limit_new_account/)).toBeInTheDocument(); expect(screen.getByText(/Next re-test in about 4 hours/)).toBeInTheDocument(); const metrics = screen.getByText('Comment karma').closest('dl')!; expect(within(metrics).getByText('2 days')).toBeInTheDocument(); expect(within(metrics).getByText('3')).toBeInTheDocument(); expect(within(metrics).getByText('No')).toBeInTheDocument(); }); it('does not claim an account is blocked when it is only below our own thresholds', async () => { server.use(http.get('/api/accounts', () => HttpResponse.json([{ ...account, establishment: { ...establishment, sendingHeld: false, lastProbeFailure: null, retryAt: null }, }]))); renderAccounts(); expect(await screen.findByText('Below thresholds')).toBeInTheDocument(); expect(screen.getByText(/Still sending normally/)).toBeInTheDocument(); expect(screen.getByText(/not limits Reddit\s+publishes/)).toBeInTheDocument(); expect(screen.getByText('Account is 2 days old (below the configured floor of 7 days)')).toBeInTheDocument(); // Nothing may imply sending has stopped. expect(screen.queryByText(/cannot start chats yet/)).not.toBeInTheDocument(); expect(screen.queryByText(/Outbound sending is on hold/)).not.toBeInTheDocument(); expect(screen.queryByText('Sending on hold')).not.toBeInTheDocument(); }); it('marks an account that has cleared the thresholds as established', async () => { server.use(http.get('/api/accounts', () => HttpResponse.json([{ ...account, establishment: { ...establishment, state: 'established', sendingHeld: false, lastProbeFailure: null, reasons: [], accountAgeDays: 400, commentKarma: 900, totalKarma: 1_200, hasVerifiedEmail: true, retryAt: null, }, }]))); renderAccounts(); expect(await screen.findByText('Established')).toBeInTheDocument(); expect(screen.queryByText(/cannot start chats yet/)).not.toBeInTheDocument(); expect(screen.queryByText(/Outbound sending is on hold/)).not.toBeInTheDocument(); expect(screen.getByText('1,200')).toBeInTheDocument(); }); it('shows no establishment detail before the first check has run', async () => { server.use(http.get('/api/accounts', () => HttpResponse.json([{ ...account, establishment: null }]))); renderAccounts(); expect(await screen.findByRole('link', { name: 'u/example-account' })).toBeInTheDocument(); expect(screen.queryByText('Sending on hold')).not.toBeInTheDocument(); expect(screen.queryByText('Below thresholds')).not.toBeInTheDocument(); expect(screen.queryByText('Established')).not.toBeInTheDocument(); expect(screen.queryByText('Comment karma')).not.toBeInTheDocument(); }); it('says plainly when no accounts have been added', async () => { renderAccounts(); expect(await screen.findByText('No accounts yet')).toBeInTheDocument(); expect(screen.queryByText(/accounts? is paused/)).not.toBeInTheDocument(); }); it('rejects a non-JSON session dump inline without sending a request', async () => { let importRequests = 0; server.use( http.post('/api/login/session-import', () => { importRequests += 1; return HttpResponse.json({ ok: true }); }), ); const user = userEvent.setup(); renderAccounts(); const broken = new File(['definitely not json'], 'broken.json', { type: 'application/json' }); await user.upload(await screen.findByLabelText('Reddit session export'), broken); expect(await screen.findByText(/isn't valid JSON/)).toBeInTheDocument(); expect(importRequests).toBe(0); }); it('imports an extension dump and reflects the verified account', async () => { let statusRequests = 0; let importRequests = 0; server.use( http.get('/api/login/status', () => { statusRequests += 1; return HttpResponse.json(statusRequests === 1 ? { ready: true, loggedIn: false, status: 'login-required' } : { ready: true, loggedIn: true, status: 'authenticated', username: 'example-account' }); }), http.get('/api/accounts', () => HttpResponse.json(statusRequests === 1 ? [] : [account])), http.post('/api/login/session-import', async ({ request }) => { expect(request.credentials).toBe('same-origin'); const body = await request.json() as { cookies: unknown[]; matrix: { accessToken: string } }; expect(body.cookies).toHaveLength(1); expect(body.matrix.accessToken).toBe('matrix-secret'); importRequests += 1; return HttpResponse.json({ ok: true, status: 'authenticated', account }); }), ); const user = userEvent.setup(); renderAccounts(); await user.upload(await screen.findByLabelText('Reddit session export'), sessionDump()); expect(await screen.findByText(/Session imported and verified/)).toBeInTheDocument(); expect(importRequests).toBe(1); expect(await screen.findByText('Authenticated')).toBeInTheDocument(); const link = screen.getAllByRole('link', { name: 'u/example-account' })[0]; expect(link).toHaveAttribute('href', 'https://www.reddit.com/u/example-account'); }); it('rechecks sessions on request', async () => { let statusRequests = 0; server.use( http.get('/api/login/status', () => { statusRequests += 1; return HttpResponse.json({ ready: true, loggedIn: false, status: 'login-required' }); }), ); const user = userEvent.setup(); renderAccounts(); await user.click(await screen.findByRole('button', { name: 'Recheck accounts' })); expect(statusRequests).toBeGreaterThanOrEqual(2); }); it('shows a recoverable error when access cannot be checked', async () => { let requests = 0; server.use( http.get('/api/login/status', () => { requests += 1; return requests === 1 ? HttpResponse.json({ error: 'nope' }, { status: 500 }) : HttpResponse.json({ ready: true, loggedIn: false, status: 'login-required' }); }), ); const user = userEvent.setup(); renderAccounts(); expect(await screen.findByRole('alert')).toHaveTextContent('Reddit access unavailable'); await user.click(screen.getByRole('button', { name: 'Retry access' })); expect(await screen.findByRole('region', { name: 'Reddit accounts' })).toBeInTheDocument(); }); it('reports an account list failure separately from the session check', async () => { server.use(http.get('/api/accounts', () => HttpResponse.json({ error: 'nope' }, { status: 500 }))); renderAccounts(); const region = await screen.findByRole('region', { name: 'Reddit accounts' }); expect(within(region).getByRole('alert')).toHaveTextContent('Could not load Reddit accounts'); // The page itself still renders, so the import button stays available. expect(screen.getByRole('button', { name: 'Add or refresh account' })).toBeEnabled(); }); });