// @vitest-environment jsdom import '@testing-library/jest-dom/vitest'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { act, 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, maxReadyCandidates: 25 }, 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: }, { path: '/elsewhere', element:

Elsewhere

}, ], { 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(screen.getByText('Resolve 1 validation error before saving.')).toBeInTheDocument(); await waitFor(() => expect(recentWindow).toHaveFocus()); expect(updateRequests).toBe(0); // Discarding a dirty draft requires explicit confirmation. await user.click(screen.getByRole('button', { name: 'Reset changes' })); const resetDialog = screen.getByRole('dialog', { name: 'Discard unsaved settings?' }); await user.click(within(resetDialog).getByRole('button', { name: 'Stay' })); expect(recentWindow).toHaveValue(0); await user.click(screen.getByRole('button', { name: 'Reset changes' })); await user.click(within(screen.getByRole('dialog', { name: 'Discard unsaved settings?' })).getByRole('button', { name: 'Discard 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, maxReadyCandidates: 25 } }); 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('configures autonomous r4r per subreddit and requires explicit confirmation', async () => { installConfigApi(); let requestBody: { subreddits: Array<{ conversation: { mode: string; maxAutomatedTurns: number; rapportTurnsBeforeMediaAsk: number } }> } | undefined; server.use(http.put('/api/config', async ({ request }) => { requestBody = await request.json() as typeof requestBody; return HttpResponse.json({ success: true, config: { rateLimit: config.rateLimit, browser: config.browser, reddit: config.reddit }, subreddits: requestBody?.subreddits ?? config.subreddits, }); })); const user = userEvent.setup(); renderSettings('/settings?section=behaviour'); const behaviour = await screen.findByRole('combobox', { name: 'Subreddit 1 conversation behaviour' }); expect(behaviour).toHaveValue('legacy'); expect(screen.queryByRole('spinbutton', { name: 'Subreddit 1 maximum automated turns' })).not.toBeInTheDocument(); await user.selectOptions(behaviour, 'r4r'); const maxTurns = screen.getByRole('spinbutton', { name: 'Subreddit 1 maximum automated turns' }); await user.clear(maxTurns); await user.type(maxTurns, '7'); await user.click(screen.getByRole('button', { name: 'Save settings' })); const dialog = screen.getByRole('dialog', { name: 'Confirm risky settings' }); expect(dialog).toHaveTextContent('Autonomous R4R will send conversational replies without approval'); expect(requestBody).toBeUndefined(); await user.click(within(dialog).getByRole('button', { name: 'Apply risky settings' })); await waitFor(() => expect(requestBody?.subreddits[0].conversation).toEqual({ mode: 'r4r', maxAutomatedTurns: 7, rapportTurnsBeforeMediaAsk: 3, })); }); it('configures rapport and total turns for the autonomous hybrid mode', async () => { installConfigApi(); let requestBody: { subreddits: Array<{ conversation: { mode: string; maxAutomatedTurns: number; rapportTurnsBeforeMediaAsk: number }; }>; } | undefined; server.use(http.put('/api/config', async ({ request }) => { requestBody = await request.json() as typeof requestBody; return HttpResponse.json({ success: true, config: { rateLimit: config.rateLimit, browser: config.browser, reddit: config.reddit }, subreddits: requestBody?.subreddits ?? config.subreddits, }); })); const user = userEvent.setup(); renderSettings('/settings?section=behaviour'); const behaviour = await screen.findByRole('combobox', { name: 'Subreddit 1 conversation behaviour' }); await user.selectOptions(behaviour, 'r4r-media'); const maxTurns = screen.getByRole('spinbutton', { name: 'Subreddit 1 maximum automated turns' }); const rapportTurns = screen.getByRole('spinbutton', { name: 'Subreddit 1 rapport turns before media ask' }); await user.click(screen.getByRole('button', { name: 'Save settings' })); expect(screen.getByText('Rapport turns must be lower than the maximum automated turns.')).toBeInTheDocument(); expect(requestBody).toBeUndefined(); await user.clear(maxTurns); await user.type(maxTurns, '6'); await user.clear(rapportTurns); await user.type(rapportTurns, '3'); await user.click(screen.getByRole('button', { name: 'Save settings' })); const dialog = screen.getByRole('dialog', { name: 'Confirm risky settings' }); expect(dialog).toHaveTextContent('hybrid mode can make one optional image request after rapport'); await user.click(within(dialog).getByRole('button', { name: 'Apply risky settings' })); await waitFor(() => expect(requestBody?.subreddits[0].conversation).toEqual({ mode: 'r4r-media', maxAutomatedTurns: 6, rapportTurnsBeforeMediaAsk: 3, })); }); 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('confirms in-app navigation away from dirty settings and can discard the draft', async () => { installConfigApi(); const user = userEvent.setup(); const { router } = renderSettings('/settings?section=monitoring'); const recentWindow = await screen.findByRole('spinbutton', { name: 'Recent window in minutes' }); await user.clear(recentWindow); await user.type(recentWindow, '45'); await act(async () => { await router.navigate('/elsewhere'); }); const stayDialog = await screen.findByRole('dialog', { name: 'Discard unsaved settings?' }); await user.click(within(stayDialog).getByRole('button', { name: 'Stay' })); expect(router.state.location.pathname).toBe('/settings'); expect(screen.getByRole('spinbutton', { name: 'Recent window in minutes' })).toHaveValue(45); await act(async () => { await router.navigate('/elsewhere'); }); const discardDialog = await screen.findByRole('dialog', { name: 'Discard unsaved settings?' }); await user.click(within(discardDialog).getByRole('button', { name: 'Discard changes' })); await waitFor(() => expect(router.state.location.pathname).toBe('/elsewhere')); expect(screen.getByRole('heading', { name: 'Elsewhere' })).toBeInTheDocument(); }); it('requires confirmation for an autonomous mode on a renamed subreddit, matched by name not index', async () => { const r4rConfig = { ...config, subreddits: [{ name: 'casualconversation', type: 'chat', weight: 2, tone: 'default', conversation: { mode: 'r4r', maxAutomatedTurns: 5, rapportTurnsBeforeMediaAsk: 3 }, }], }; let updateRequests = 0; server.use( http.get('/api/config', () => HttpResponse.json(r4rConfig)), http.put('/api/config', () => { updateRequests += 1; return HttpResponse.json({ success: true, config: { rateLimit: config.rateLimit, browser: config.browser, reddit: config.reddit }, subreddits: r4rConfig.subreddits, }); }), ); const user = userEvent.setup(); renderSettings('/settings?section=behaviour'); // The persisted row is already r4r at index 0; giving the row a new name is // equivalent to inserting a new r4r-enabled row, so it must be re-confirmed. const name = await screen.findByRole('textbox', { name: 'Subreddit 1 name' }); await user.clear(name); await user.type(name, 'brandnewplace'); await user.click(screen.getByRole('button', { name: 'Save settings' })); const dialog = screen.getByRole('dialog', { name: 'Confirm risky settings' }); expect(dialog).toHaveTextContent('Autonomous R4R will send conversational replies without approval'); expect(updateRequests).toBe(0); }); it('does not re-confirm an unchanged autonomous subreddit when a row before it is removed', async () => { const twoSubredditConfig = { ...config, subreddits: [ { name: 'general', type: 'chat', weight: 1, tone: 'default', conversation: { mode: 'legacy', maxAutomatedTurns: 3, rapportTurnsBeforeMediaAsk: 3 } }, { name: 'r4rlondon', type: 'chat', weight: 1, tone: 'default', conversation: { mode: 'r4r', maxAutomatedTurns: 5, rapportTurnsBeforeMediaAsk: 3 } }, ], }; let updateRequests = 0; server.use( http.get('/api/config', () => HttpResponse.json(twoSubredditConfig)), http.put('/api/config', async ({ request }) => { updateRequests += 1; const body = await request.json() as { subreddits: typeof twoSubredditConfig.subreddits }; return HttpResponse.json({ success: true, config: { rateLimit: config.rateLimit, browser: config.browser, reddit: config.reddit }, subreddits: body.subreddits, }); }), ); const user = userEvent.setup(); renderSettings('/settings?section=behaviour'); // Removing the first row shifts the r4r row to index 0; by-name matching // must recognise it as already approved and save without a risk dialog. await user.click(await screen.findByRole('button', { name: 'Remove subreddit 1' })); await user.click(screen.getByRole('button', { name: 'Save settings' })); expect(screen.queryByRole('dialog', { name: 'Confirm risky settings' })).not.toBeInTheDocument(); expect(await screen.findByText('Settings saved.')).toBeInTheDocument(); expect(updateRequests).toBe(1); }); it('notices a server-side config change while the draft is dirty and loads it on reset', async () => { installConfigApi(); const user = userEvent.setup(); const { queryClient } = renderSettings('/settings?section=monitoring'); const recentWindow = await screen.findByRole('spinbutton', { name: 'Recent window in minutes' }); await user.clear(recentWindow); await user.type(recentWindow, '45'); act(() => { queryClient.setQueryData(['dashboard', 'config'], { ...config, reddit: { ...config.reddit, maxReadyCandidates: 99 }, subreddits: [{ name: 'casualconversation', type: 'chat', weight: 2, tone: 'default', conversation: { mode: 'legacy', maxAutomatedTurns: 3, rapportTurnsBeforeMediaAsk: 3 }, }], }); }); expect(await screen.findByText('Settings changed on the server — Reset to load them.')).toBeInTheDocument(); expect(recentWindow).toHaveValue(45); await user.click(screen.getByRole('button', { name: 'Reset changes' })); await user.click(within(screen.getByRole('dialog', { name: 'Discard unsaved settings?' })).getByRole('button', { name: 'Discard changes' })); expect(screen.getByRole('spinbutton', { name: 'Recent window in minutes' })).toHaveValue(60); expect(screen.getByRole('spinbutton', { name: 'Maximum ready posts' })).toHaveValue(99); }); it('rejects a non-JSON session dump inline without sending a request', async () => { let importRequests = 0; server.use( http.get('/api/config', () => HttpResponse.json(config)), http.get('/api/login/status', () => HttpResponse.json({ ready: true, loggedIn: false, status: 'login-required' })), http.post('/api/login/session-import', () => { importRequests += 1; return HttpResponse.json({ ok: true }); }), ); const user = userEvent.setup(); renderSettings('/settings?section=access'); const brokenDump = new File(['definitely not json'], 'broken.json', { type: 'application/json' }); await user.upload(await screen.findByLabelText('Reddit session export'), brokenDump); expect(await screen.findByText(/isn't valid JSON/)).toBeInTheDocument(); expect(importRequests).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); }); });