// @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, beforeEach, 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 baseConfig = { rateLimit: { minDelayBetweenMessagesMs: 300_000, maxMessagesPerHour: 8, jitterMs: 60_000 }, browser: { dryRun: true }, reddit: { prioritizeRecentPosts: false, recentWindowMinutes: 60, maxReadyCandidates: 25, physiqueTargeting: { enabled: false, minScore: 1, maxScore: 10 } }, 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 pushSettings = { enabled: false, serverUrl: 'https://ntfy.sh', topic: '', priority: 'default', events: { imageReceived: true, replyReceived: false, messageFailed: false, botStopped: false, rateLimited: false, sessionExpired: true, syncFailed: false, }, hasAuthToken: false, }; let config: Record = structuredClone(baseConfig); /** Bodies received by PUT /api/config, in order. */ let saved: Record[] = []; const server = setupServer( http.get('/api/config', () => HttpResponse.json(config)), http.get('/api/push-notifications', () => HttpResponse.json(pushSettings)), ); /** Applies a partial update the way the real endpoint does, and echoes it back. */ function installConfigWriter() { server.use( http.put('/api/config', async ({ request }) => { const body = await request.json() as Record; saved.push(body); config = { ...config, rateLimit: { ...(config.rateLimit as object), ...(body.rateLimit ?? {}) }, browser: { ...(config.browser as object), ...(body.browser ?? {}) }, reddit: { ...(config.reddit as object), ...(body.reddit ?? {}) }, subreddits: body.subreddits ?? config.subreddits, advanced: body.advanced ?? config.advanced, }; return HttpResponse.json({ success: true, config: { rateLimit: config.rateLimit, browser: config.browser, reddit: config.reddit, }, subreddits: config.subreddits, advanced: config.advanced, }); }), ); } function renderSettings(path = '/settings') { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, }); const router = createMemoryRouter( [ { path: '/settings', element: }, { path: '/subreddits', element:

Subreddits page

}, { path: '/accounts', element:

Accounts page

}, { path: '/elsewhere', element:

Elsewhere

}, ], { initialEntries: [path] }, ); const view = render( , ); return { ...view, queryClient, router }; } function card(name: string) { return screen.getByRole('region', { name }); } async function findCard(name: string) { return screen.findByRole('region', { name }); } beforeAll(() => server.listen({ onUnhandledRequest: 'error' })); beforeEach(() => { config = structuredClone(baseConfig); saved = []; }); afterEach(() => { cleanup(); server.resetHandlers(); vi.restoreAllMocks(); }); afterAll(() => server.close()); describe('settings page', () => { it('stacks every section on one page instead of hiding them behind tabs', async () => { renderSettings(); for (const name of ['Safety', 'Pacing', 'Targeting', 'Notifications', 'Advanced']) { expect(await findCard(name)).toBeInTheDocument(); } }); 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: 'nope' }, { status: 500 }) : HttpResponse.json(config); }), ); const user = userEvent.setup(); renderSettings(); expect(await screen.findByRole('alert')).toHaveTextContent('Settings unavailable'); await user.click(screen.getByRole('button', { name: 'Retry settings' })); expect(await findCard('Pacing')).toBeInTheDocument(); }); describe('legacy section links', () => { it('redirects the subreddit tab to the subreddits page', async () => { renderSettings('/settings?section=behaviour'); expect(await screen.findByRole('heading', { name: 'Subreddits page' })).toBeInTheDocument(); }); it('redirects the access tab to the accounts page', async () => { renderSettings('/settings?section=access'); expect(await screen.findByRole('heading', { name: 'Accounts page' })).toBeInTheDocument(); }); it('keeps former in-page tabs on the settings page', async () => { renderSettings('/settings?section=rate-limits'); expect(await findCard('Pacing')).toBeInTheDocument(); }); }); describe('pacing', () => { it('reports the effective rate when the minimum delay is the binding limit', async () => { renderSettings(); const pacing = await findCard('Pacing'); // 5 min minimum delay caps sending at 12/hr, below the 8/hr limit... so // here the limit binds. Raise the delay and the readout should flip. expect(pacing).toHaveTextContent('8 messages per hour'); expect(pacing).toHaveTextContent('the hourly limit is the binding limit'); const user = userEvent.setup(); const delay = within(pacing).getByRole('spinbutton', { name: 'Minimum delay' }); await user.clear(delay); await user.type(delay, '30'); expect(pacing).toHaveTextContent('2 messages per hour'); expect(pacing).toHaveTextContent('the minimum delay is the binding limit'); }); it('rejects blank and out-of-range values before sending anything', async () => { installConfigWriter(); const user = userEvent.setup(); renderSettings(); const pacing = await findCard('Pacing'); await user.clear(within(pacing).getByRole('spinbutton', { name: 'Minimum delay' })); const readyPosts = within(pacing).getByRole('spinbutton', { name: 'Maximum ready posts' }); await user.clear(readyPosts); await user.type(readyPosts, '900'); await user.click(within(pacing).getByRole('button', { name: 'Save pacing' })); expect(within(pacing).getByText('Minimum delay is required.')).toBeInTheDocument(); expect(within(pacing).getByText('Ready-post limit cannot exceed 500.')).toBeInTheDocument(); expect(saved).toHaveLength(0); }); it('saves only the keys it owns, never the subreddit array', async () => { installConfigWriter(); const user = userEvent.setup(); renderSettings(); const pacing = await findCard('Pacing'); const jitter = within(pacing).getByRole('spinbutton', { name: 'Random jitter' }); await user.clear(jitter); await user.type(jitter, '90'); await user.click(within(pacing).getByRole('button', { name: 'Save pacing' })); await waitFor(() => expect(saved).toHaveLength(1)); expect(saved[0]).toEqual({ rateLimit: { minDelayBetweenMessagesMs: 300_000, maxMessagesPerHour: 8, jitterMs: 90_000 }, reddit: { prioritizeRecentPosts: false, recentWindowMinutes: 60, maxReadyCandidates: 25, physiqueTargeting: { enabled: false, minScore: 1, maxScore: 10 } }, }); expect(saved[0]).not.toHaveProperty('subreddits'); expect(saved[0]).not.toHaveProperty('browser'); expect(await within(pacing).findByText('Pacing saved.')).toBeInTheDocument(); }); it('confirms an unusually large hourly limit before applying it', async () => { installConfigWriter(); const user = userEvent.setup(); renderSettings(); const pacing = await findCard('Pacing'); const limit = within(pacing).getByRole('spinbutton', { name: 'Hourly limit' }); await user.clear(limit); await user.type(limit, '60'); await user.click(within(pacing).getByRole('button', { name: 'Save pacing' })); expect(await screen.findByText(/will allow 60 messages per hour/)).toBeInTheDocument(); expect(saved).toHaveLength(0); await user.click(screen.getByRole('button', { name: 'Apply risky settings' })); await waitFor(() => expect(saved).toHaveLength(1)); }); it('resets edits back to the persisted values', async () => { const user = userEvent.setup(); renderSettings(); const pacing = await findCard('Pacing'); const limit = within(pacing).getByRole('spinbutton', { name: 'Hourly limit' }); await user.clear(limit); await user.type(limit, '19'); expect(within(pacing).getByRole('status')).toHaveTextContent('Unsaved changes in this section.'); await user.click(within(pacing).getByRole('button', { name: 'Reset' })); expect(limit).toHaveValue(8); expect(within(pacing).getByText('Saved.')).toBeInTheDocument(); }); }); describe('safety', () => { it('states the current sending state before offering the switch', async () => { renderSettings(); const safety = await findCard('Safety'); expect(within(safety).getByRole('status')).toHaveTextContent('No messages are being sent.'); expect(within(safety).getByText('Dry run')).toBeInTheDocument(); }); it('requires confirmation before dry run is turned off', async () => { installConfigWriter(); const user = userEvent.setup(); renderSettings(); const safety = await findCard('Safety'); await user.click(within(safety).getByRole('checkbox', { name: 'Dry run mode' })); await user.click(within(safety).getByRole('button', { name: 'Save safety' })); expect(await screen.findByText(/Dry run is being turned off/)).toBeInTheDocument(); expect(saved).toHaveLength(0); await user.click(screen.getByRole('button', { name: 'Apply risky settings' })); await waitFor(() => expect(saved).toEqual([{ browser: { dryRun: false } }])); expect(await within(safety).findByText('Sending live')).toBeInTheDocument(); }); it('does not re-confirm when dry run is left off', async () => { config = { ...structuredClone(baseConfig), browser: { dryRun: false } }; installConfigWriter(); const user = userEvent.setup(); renderSettings(); const safety = await findCard('Safety'); // Toggle on and back off: the persisted value never allowed sending to // start, so there is nothing to confirm. await user.click(within(safety).getByRole('checkbox', { name: 'Dry run mode' })); await user.click(within(safety).getByRole('checkbox', { name: 'Dry run mode' })); expect(within(safety).getByRole('button', { name: 'Save safety' })).toBeDisabled(); }); }); describe('targeting', () => { it('sends only the physique range, leaving the pacing fields alone', async () => { installConfigWriter(); const user = userEvent.setup(); renderSettings(); const targeting = await findCard('Targeting'); const min = within(targeting).getByRole('spinbutton', { name: 'Minimum score' }); await user.clear(min); await user.type(min, '4'); await user.click(within(targeting).getByRole('button', { name: 'Save targeting' })); await waitFor(() => expect(saved).toHaveLength(1)); expect(saved[0]).toEqual({ reddit: { physiqueTargeting: { enabled: false, minScore: 4, maxScore: 10 } } }); expect(saved[0].reddit).not.toHaveProperty('maxReadyCandidates'); }); it('rejects a minimum above the maximum', async () => { installConfigWriter(); const user = userEvent.setup(); renderSettings(); const targeting = await findCard('Targeting'); const min = within(targeting).getByRole('spinbutton', { name: 'Minimum score' }); await user.clear(min); await user.type(min, '9'); const max = within(targeting).getByRole('spinbutton', { name: 'Maximum score' }); await user.clear(max); await user.type(max, '3'); await user.click(within(targeting).getByRole('button', { name: 'Save targeting' })); expect(within(targeting).getByText('Minimum physique score cannot exceed maximum physique score.')).toBeInTheDocument(); expect(saved).toHaveLength(0); }); it('rejects a score outside 1 to 10', async () => { installConfigWriter(); const user = userEvent.setup(); renderSettings(); const targeting = await findCard('Targeting'); const max = within(targeting).getByRole('spinbutton', { name: 'Maximum score' }); await user.clear(max); await user.type(max, '11'); await user.click(within(targeting).getByRole('button', { name: 'Save targeting' })); expect(within(targeting).getByText('Physique scores must be between 1 and 10.')).toBeInTheDocument(); expect(saved).toHaveLength(0); }); it('warns that turning screening on will cut how many posts qualify', async () => { installConfigWriter(); const user = userEvent.setup(); renderSettings(); const targeting = await findCard('Targeting'); await user.click(within(targeting).getByRole('checkbox', { name: 'Enable physique targeting' })); await user.click(within(targeting).getByRole('button', { name: 'Save targeting' })); expect(await screen.findByText(/posts without enough visible body evidence will be skipped/)).toBeInTheDocument(); expect(saved).toHaveLength(0); await user.click(screen.getByRole('button', { name: 'Apply risky settings' })); await waitFor(() => expect(saved).toHaveLength(1)); expect(await within(card('Targeting')).findByText('Screening on')).toBeInTheDocument(); }); it('says the range is inert while screening is off', async () => { renderSettings(); const targeting = await findCard('Targeting'); expect(within(targeting).getByText(/saved but not applied/)).toBeInTheDocument(); expect(within(targeting).getByText('Off')).toBeInTheDocument(); }); }); describe('advanced', () => { it('stays locked behind a deliberate step and lists what it holds', async () => { renderSettings(); const advanced = await findCard('Advanced'); expect(within(advanced).getByText('Locked')).toBeInTheDocument(); expect(within(advanced).getByText('openrouter')).toBeInTheDocument(); expect(within(advanced).queryByRole('textbox')).not.toBeInTheDocument(); await userEvent.setup().click(within(advanced).getByRole('button', { name: 'Edit raw configuration' })); expect(within(card('Advanced')).getByRole('textbox', { name: 'Advanced configuration (JSON)' })).toBeInTheDocument(); }); it('explains why the JSON is invalid instead of silently blocking', async () => { installConfigWriter(); const user = userEvent.setup(); renderSettings(); await user.click(within(await findCard('Advanced')).getByRole('button', { name: 'Edit raw configuration' })); const editor = within(card('Advanced')).getByRole('textbox', { name: 'Advanced configuration (JSON)' }); await user.clear(editor); await user.type(editor, '{{"openrouter": '); await user.click(within(card('Advanced')).getByRole('button', { name: 'Save advanced' })); expect(within(card('Advanced')).getByText(/Not valid JSON/)).toBeInTheDocument(); expect(saved).toHaveLength(0); }); it('rejects a JSON array, which would not merge as configuration', async () => { installConfigWriter(); const user = userEvent.setup(); renderSettings(); await user.click(within(await findCard('Advanced')).getByRole('button', { name: 'Edit raw configuration' })); const editor = within(card('Advanced')).getByRole('textbox', { name: 'Advanced configuration (JSON)' }); await user.clear(editor); await user.type(editor, '[[1]'); await user.click(within(card('Advanced')).getByRole('button', { name: 'Save advanced' })); expect(within(card('Advanced')).getByText(/must be a JSON object/)).toBeInTheDocument(); expect(saved).toHaveLength(0); }); it('saves valid JSON on its own', async () => { installConfigWriter(); const user = userEvent.setup(); renderSettings(); await user.click(within(await findCard('Advanced')).getByRole('button', { name: 'Edit raw configuration' })); const editor = within(card('Advanced')).getByRole('textbox', { name: 'Advanced configuration (JSON)' }); await user.clear(editor); await user.type(editor, '{{"openrouter": {{"chatModel": "other/model"}}'); await user.click(within(card('Advanced')).getByRole('button', { name: 'Save advanced' })); await waitFor(() => expect(saved).toEqual([{ advanced: { openrouter: { chatModel: 'other/model' } } }])); }); }); describe('card independence', () => { it('lets pacing save while the advanced JSON is broken', async () => { installConfigWriter(); const user = userEvent.setup(); renderSettings(); await user.click(within(await findCard('Advanced')).getByRole('button', { name: 'Edit raw configuration' })); const editor = within(card('Advanced')).getByRole('textbox', { name: 'Advanced configuration (JSON)' }); await user.clear(editor); await user.type(editor, 'not json at all'); const pacing = card('Pacing'); const jitter = within(pacing).getByRole('spinbutton', { name: 'Random jitter' }); await user.clear(jitter); await user.type(jitter, '15'); await user.click(within(pacing).getByRole('button', { name: 'Save pacing' })); await waitFor(() => expect(saved).toHaveLength(1)); expect(saved[0]).toHaveProperty('rateLimit'); expect(saved[0]).not.toHaveProperty('advanced'); expect(await within(card('Pacing')).findByText('Pacing saved.')).toBeInTheDocument(); }); it('does not report another card as changed on the server after a sibling saves', async () => { installConfigWriter(); const user = userEvent.setup(); renderSettings(); const safety = await findCard('Safety'); // Leave pacing dirty, then save safety. Pacing must not claim the server // moved underneath it: nothing it owns actually changed. const limit = within(card('Pacing')).getByRole('spinbutton', { name: 'Hourly limit' }); await user.clear(limit); await user.type(limit, '9'); await user.click(within(safety).getByRole('checkbox', { name: 'Dry run mode' })); await user.click(within(safety).getByRole('button', { name: 'Save safety' })); await user.click(await screen.findByRole('button', { name: 'Apply risky settings' })); await waitFor(() => expect(saved).toHaveLength(1)); expect(within(card('Pacing')).queryByText(/Changed on the server/)).not.toBeInTheDocument(); expect(within(card('Pacing')).getByRole('spinbutton', { name: 'Hourly limit' })).toHaveValue(9); }); it('flags a genuine server-side change and loads it on reset', async () => { const user = userEvent.setup(); const { queryClient } = renderSettings(); const pacing = await findCard('Pacing'); const limit = within(pacing).getByRole('spinbutton', { name: 'Hourly limit' }); await user.clear(limit); await user.type(limit, '11'); config = { ...structuredClone(baseConfig), rateLimit: { ...baseConfig.rateLimit, maxMessagesPerHour: 40 } }; await queryClient.refetchQueries({ queryKey: ['dashboard', 'config'] }); expect(await within(card('Pacing')).findByText(/Changed on the server/)).toBeInTheDocument(); await user.click(within(card('Pacing')).getByRole('button', { name: 'Reset' })); expect(within(card('Pacing')).getByRole('spinbutton', { name: 'Hourly limit' })).toHaveValue(40); }); }); it('confirms navigation away while any card is dirty, and can discard', async () => { const user = userEvent.setup(); const { router } = renderSettings(); const pacing = await findCard('Pacing'); const jitter = within(pacing).getByRole('spinbutton', { name: 'Random jitter' }); await user.clear(jitter); await user.type(jitter, '45'); await act(() => router.navigate('/elsewhere')); expect(await screen.findByRole('heading', { name: 'Leave without saving?' })).toBeInTheDocument(); expect(router.state.location.pathname).toBe('/settings'); await user.click(screen.getByRole('button', { name: 'Discard and leave' })); await waitFor(() => expect(router.state.location.pathname).toBe('/elsewhere')); }); });