// @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 { SettingsRoute } from '../../apps/dashboard/src/features/settings/settings-route.js'; const config = { rateLimit: { minDelayBetweenMessagesMs: 300_000, maxMessagesPerHour: 8, jitterMs: 60_000 }, browser: { dryRun: true }, reddit: { prioritizeRecentPosts: false, recentWindowMinutes: 60 }, subreddits: [{ name: 'casualconversation', type: 'chat', weight: 2, tone: 'default' }], tones: ['default', 'dominant', 'submissive', 'playful', 'direct', 'gentle', 'kink-friendly', 'teasing'], advanced: { openrouter: { chatModel: 'example/chat-model' } }, }; const server = setupServer(http.get('/api/accounts', () => HttpResponse.json([]))); function installConfigApi() { server.use(http.get('/api/config', () => HttpResponse.json(config))); } function renderSettings(path = '/settings') { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, }); const router = createMemoryRouter( [{ path: '/settings', element: }], { initialEntries: [path] }, ); const view = render( , ); return { ...view, queryClient, router }; } beforeAll(() => server.listen({ onUnhandledRequest: 'error' })); afterEach(() => { cleanup(); server.resetHandlers(); vi.restoreAllMocks(); }); afterAll(() => server.close()); describe('settings and access', () => { it('loads the requested route-friendly section and keeps section selection in the URL', async () => { let resolveConfig: ((response: Response) => void) | undefined; server.use( http.get('/api/config', async ({ request }) => { expect(request.credentials).toBe('same-origin'); return new Promise((resolve) => { resolveConfig = resolve; }); }), ); const user = userEvent.setup(); const { router } = renderSettings('/settings?section=rate-limits'); expect(screen.getByRole('status', { name: 'Loading settings' })).toBeInTheDocument(); await waitFor(() => expect(resolveConfig).toBeTypeOf('function')); resolveConfig?.(HttpResponse.json(config)); expect(await screen.findByRole('heading', { name: 'Rate limits' })).toBeInTheDocument(); await user.click(screen.getByRole('button', { name: 'Monitoring' })); expect(screen.getByRole('heading', { name: 'Monitoring' })).toBeInTheDocument(); expect(router.state.location.search).toBe('?section=monitoring'); }); it('shows a recoverable initial configuration error', async () => { let requests = 0; server.use( http.get('/api/config', () => { requests += 1; return requests === 1 ? HttpResponse.json({ error: 'Configuration unavailable' }, { status: 503 }) : HttpResponse.json(config); }), ); const user = userEvent.setup(); renderSettings(); const alert = await screen.findByRole('alert'); expect(alert).toHaveTextContent('Configuration unavailable'); await user.click(within(alert).getByRole('button', { name: 'Retry settings' })); expect(await screen.findByRole('heading', { name: 'Monitoring' })).toBeInTheDocument(); }); it('validates fields, warns about unsaved changes, and resets to persisted values', async () => { installConfigApi(); let updateRequests = 0; server.use(http.put('/api/config', () => { updateRequests += 1; return HttpResponse.json({ success: true, config, subreddits: config.subreddits }); })); const user = userEvent.setup(); renderSettings('/settings?section=monitoring'); const recentWindow = await screen.findByRole('spinbutton', { name: 'Recent window in minutes' }); await user.clear(recentWindow); await user.type(recentWindow, '0'); expect(screen.getByRole('status')).toHaveTextContent('You have unsaved changes.'); const beforeUnload = new Event('beforeunload', { cancelable: true }); window.dispatchEvent(beforeUnload); expect(beforeUnload.defaultPrevented).toBe(true); await user.click(screen.getByRole('button', { name: 'Save settings' })); expect(screen.getByText('Recent window must be greater than 0 minutes.')).toBeInTheDocument(); expect(recentWindow).toHaveAttribute('aria-invalid', 'true'); expect(updateRequests).toBe(0); await user.click(screen.getByRole('button', { name: 'Reset changes' })); expect(recentWindow).toHaveValue(60); expect(screen.queryByText('You have unsaved changes.')).not.toBeInTheDocument(); }); it('rejects blank numeric controls and invalid subreddit fields before transport', async () => { installConfigApi(); let updateRequests = 0; server.use(http.put('/api/config', () => { updateRequests += 1; return HttpResponse.json({ success: true }); })); const user = userEvent.setup(); const { router } = renderSettings('/settings?section=rate-limits'); const minimumDelay = await screen.findByRole('spinbutton', { name: 'Minimum delay in minutes' }); await user.clear(minimumDelay); await user.click(screen.getByRole('button', { name: 'Save settings' })); expect(screen.getByText('Minimum delay is required.')).toBeInTheDocument(); expect(minimumDelay).toHaveAttribute('aria-invalid', 'true'); await user.click(screen.getByRole('button', { name: 'Behaviour' })); expect(router.state.location.search).toBe('?section=behaviour'); const subredditName = screen.getByRole('textbox', { name: 'Subreddit 1 name' }); await user.clear(subredditName); await user.click(screen.getByRole('button', { name: 'Save settings' })); expect(screen.getByText('Subreddit name is required.')).toBeInTheDocument(); expect(subredditName).toHaveAttribute('aria-invalid', 'true'); await user.type(subredditName, 'example'); const weight = screen.getByRole('spinbutton', { name: 'Subreddit 1 weight' }); await user.clear(weight); await user.type(weight, '-1'); await user.click(screen.getByRole('button', { name: 'Save settings' })); expect(screen.getByText('Subreddit weight must be greater than 0.')).toBeInTheDocument(); expect(weight).toHaveAttribute('aria-invalid', 'true'); expect(updateRequests).toBe(0); }); it('saves validated settings with pending and authoritative success states', async () => { installConfigApi(); let requestBody: unknown; let resolveUpdate: ((response: Response) => void) | undefined; server.use( http.put('/api/config', async ({ request }) => { expect(request.credentials).toBe('same-origin'); requestBody = await request.json(); return new Promise((resolve) => { resolveUpdate = resolve; }); }), ); const user = userEvent.setup(); renderSettings('/settings?section=monitoring'); const prioritize = await screen.findByRole('checkbox', { name: 'Prioritise recent posts' }); await user.click(prioritize); await user.click(screen.getByRole('button', { name: 'Save settings' })); expect(screen.getByRole('button', { name: 'Saving settings' })).toBeDisabled(); await waitFor(() => expect(resolveUpdate).toBeTypeOf('function')); expect(requestBody).toMatchObject({ reddit: { prioritizeRecentPosts: true, recentWindowMinutes: 60 } }); resolveUpdate?.(HttpResponse.json({ success: true, config: { rateLimit: config.rateLimit, browser: config.browser, reddit: { ...config.reddit, prioritizeRecentPosts: true }, }, subreddits: config.subreddits, })); expect(await screen.findByText('Settings saved.')).toBeInTheDocument(); expect(screen.queryByText('You have unsaved changes.')).not.toBeInTheDocument(); await user.click(prioritize); expect(screen.getByText('You have unsaved changes.')).toBeInTheDocument(); expect(screen.queryByText('Settings saved.')).not.toBeInTheDocument(); }); it('validates subreddit rows and confirms disabling dry run before saving behaviour', async () => { installConfigApi(); let updateRequests = 0; server.use( http.put('/api/config', async ({ request }) => { updateRequests += 1; const body = await request.json() as { browser: { dryRun: boolean }; subreddits: typeof config.subreddits }; return HttpResponse.json({ success: true, config: { rateLimit: config.rateLimit, browser: body.browser, reddit: config.reddit }, subreddits: body.subreddits, }); }), ); const user = userEvent.setup(); renderSettings('/settings?section=behaviour'); await user.click(await screen.findByRole('button', { name: 'Add subreddit' })); await user.type(screen.getByRole('textbox', { name: 'Subreddit 2 name' }), 'CasualConversation'); await user.click(screen.getByRole('button', { name: 'Save settings' })); expect(screen.getByText('Subreddit names must be unique.')).toBeInTheDocument(); expect(updateRequests).toBe(0); const secondName = screen.getByRole('textbox', { name: 'Subreddit 2 name' }); await user.clear(secondName); await user.type(secondName, 'askreddit'); await user.click(screen.getByRole('checkbox', { name: 'Dry run mode' })); await user.click(screen.getByRole('button', { name: 'Save settings' })); const dialog = screen.getByRole('dialog', { name: 'Confirm risky settings' }); expect(dialog).toHaveTextContent('Automated messages will be sent to Reddit'); expect(updateRequests).toBe(0); await user.click(within(dialog).getByRole('button', { name: 'Apply risky settings' })); expect(await screen.findByText('Settings saved.')).toBeInTheDocument(); expect(updateRequests).toBe(1); }); it('validates advanced application JSON before saving', async () => { installConfigApi(); let updateRequests = 0; server.use(http.put('/api/config', () => { updateRequests += 1; return HttpResponse.json({ success: true, config, subreddits: config.subreddits }); })); const user = userEvent.setup(); renderSettings('/settings?section=behaviour'); const editor = await screen.findByRole('textbox', { name: 'Advanced configuration JSON' }); await user.clear(editor); await user.type(editor, 'invalid'); await user.click(screen.getByRole('button', { name: 'Save settings' })); expect(screen.getByText('Advanced configuration is not valid JSON.')).toBeInTheDocument(); expect(updateRequests).toBe(0); }); it('confirms unusually large hourly limits', async () => { installConfigApi(); let updateRequests = 0; server.use(http.put('/api/config', () => { updateRequests += 1; return HttpResponse.json({ success: true, config, subreddits: config.subreddits }); })); const user = userEvent.setup(); renderSettings('/settings?section=rate-limits'); const hourlyLimit = await screen.findByRole('spinbutton', { name: 'Maximum messages per hour' }); await user.clear(hourlyLimit); await user.type(hourlyLimit, '50'); await user.click(screen.getByRole('button', { name: 'Save settings' })); expect(screen.getByRole('dialog', { name: 'Confirm risky settings' })).toHaveTextContent('50 messages per hour'); expect(updateRequests).toBe(0); }); it('waits for and imports an extension dump without exposing credentials', async () => { let statusRequests = 0; let importRequests = 0; server.use( http.get('/api/config', () => HttpResponse.json(config)), 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([{ accountId: '@t2_bot:reddit.com', redditUsername: 'example-account', matrixUserId: '@t2_bot:reddit.com', isRunning: true, status: 'authenticated', createdAt: 1, updatedAt: 2, lastImportedAt: 2, }])), 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: { accountId: '@t2_bot:reddit.com', redditUsername: 'example-account', matrixUserId: '@t2_bot:reddit.com', isRunning: true, status: 'authenticated', createdAt: 1, updatedAt: 2, lastImportedAt: 2, } }); }), ); const user = userEvent.setup(); renderSettings('/settings?section=access'); expect(await screen.findByText('Login required')).toBeInTheDocument(); expect(screen.getByText(/waiting for a fresh session dump/i)).toBeInTheDocument(); expect(screen.queryByText(/noVNC/i)).not.toBeInTheDocument(); expect(screen.queryByRole('textbox', { name: /password/i })).not.toBeInTheDocument(); const dump = 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' }); await user.upload(screen.getByLabelText('Reddit session export'), dump); expect(await screen.findByText('Reddit session imported and verified.')).toBeInTheDocument(); expect(importRequests).toBe(1); expect(await screen.findByText('Authenticated')).toBeInTheDocument(); expect(screen.getAllByText(/u\/example-account/)).toHaveLength(2); expect(screen.getAllByRole('link', { name: 'u/example-account' })[0]).toHaveAttribute( 'href', 'https://www.reddit.com/u/example-account', ); await user.click(screen.getByRole('button', { name: 'Recheck accounts' })); expect(statusRequests).toBeGreaterThanOrEqual(2); }); });