// @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'], }; const server = setupServer(); 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('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('shows Reddit session and noVNC state without exposing credentials, then starts and rechecks login', async () => { let statusRequests = 0; let loginRequests = 0; server.use( http.get('/api/config', () => HttpResponse.json(config)), http.get('/api/login/info', ({ request }) => { expect(request.credentials).toBe('same-origin'); return HttpResponse.json({ vncEnabled: true, novncPort: 6080, publicUrl: 'https://login.example/vnc.html', passwordRequired: true, }); }), 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' }); }), http.post('/api/login/start', ({ request }) => { expect(request.credentials).toBe('same-origin'); loginRequests += 1; return HttpResponse.json({ ok: true }); }), ); const user = userEvent.setup(); renderSettings('/settings?section=access'); expect(await screen.findByText('Login required')).toBeInTheDocument(); expect(screen.getByRole('link', { name: 'Open noVNC login window' })).toHaveAttribute('href', 'https://login.example/vnc.html'); expect(screen.getByText('A VNC password is required.')).toBeInTheDocument(); expect(screen.queryByRole('textbox', { name: /password/i })).not.toBeInTheDocument(); await user.click(screen.getByRole('button', { name: 'Open Reddit login' })); expect(await screen.findByText('Login page opened.')).toBeInTheDocument(); expect(loginRequests).toBe(1); expect(await screen.findByText('Authenticated')).toBeInTheDocument(); await user.click(screen.getByRole('button', { name: 'Recheck session' })); expect(statusRequests).toBeGreaterThanOrEqual(3); }); });