// @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, beforeEach, describe, expect, it, vi } from 'vitest'; import { createMemoryRouter, RouterProvider } from 'react-router-dom'; import { SubredditsPage } from '../../apps/dashboard/src/features/subreddits/subreddits-page.js'; const baseSubreddits = [ { name: 'casualconversation', type: 'chat', weight: 1, tone: 'default', enabled: true }, { name: 'r4r', type: 'chat', weight: 3, tone: 'playful', enabled: true, conversation: { mode: 'r4r', maxAutomatedTurns: 6, rapportTurnsBeforeMediaAsk: 3 } }, { name: 'sleepy', type: 'image', weight: 4, tone: 'gentle', enabled: false }, ]; 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: baseSubreddits, tones: ['default', 'dominant', 'submissive', 'playful', 'direct', 'gentle', 'kink-friendly', 'teasing'], }; const performance = [ { subreddit: 'casualconversation', totalSent: 40, totalResponses: 10, responseRate: 0.25, totalImages: 2, imageRate: 0.05 }, { subreddit: 'r4r', totalSent: 5, totalResponses: 4, responseRate: 0.8, totalImages: 0, imageRate: 0 }, ]; let config: Record = structuredClone(baseConfig); /** Subreddit arrays received by PUT /api/config, in order. */ let saved: any[][] = []; const server = setupServer( http.get('/api/config', () => HttpResponse.json(config)), http.get('/api/analytics/by-subreddit', () => HttpResponse.json(performance)), http.put('/api/config', async ({ request }) => { const body = await request.json() as Record; saved.push(body.subreddits); config = { ...config, subreddits: body.subreddits }; return HttpResponse.json({ success: true, config: { rateLimit: config.rateLimit, browser: config.browser, reddit: config.reddit }, subreddits: config.subreddits, }); }), ); function renderSubreddits() { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, }); const router = createMemoryRouter([{ path: '/subreddits', element: }], { initialEntries: ['/subreddits'], }); return render( , ); } async function findRows() { const list = await screen.findByRole('list', { name: 'Configured subreddits' }); return within(list).getAllByRole('listitem'); } async function row(name: string) { const rows = await findRows(); const match = rows.find((item) => rowName(item) === `r/${name}`); if (!match) throw new Error(`No row for r/${name}`); return match; } /** The row's own name element, not its concatenated text content. */ function rowName(item: HTMLElement): string { return within(item).getByRole('heading', { level: 3 }).textContent ?? ''; } async function rowOrder(): Promise { return (await findRows()).map(rowName); } /** Reads one metric from a row's definition list by its label. */ function metric(item: HTMLElement, label: string): string { const term = within(item).getAllByRole('term').find((node) => node.textContent === label); if (!term) throw new Error(`No ${label} metric in row`); return term.parentElement?.querySelector('dd')?.textContent ?? ''; } async function openEditor(name: string) { await userEvent.setup().click(within(await row(name)).getByRole('button', { name: 'Edit' })); return screen.findByRole('dialog', { name: `r/${name}` }); } beforeAll(() => server.listen({ onUnhandledRequest: 'error' })); beforeEach(() => { config = structuredClone(baseConfig); saved = []; }); afterEach(() => { cleanup(); server.resetHandlers(); vi.restoreAllMocks(); }); afterAll(() => server.close()); describe('subreddits page', () => { it('lists each subreddit with its share of polling, mode, and performance', async () => { renderSubreddits(); const rows = await findRows(); expect(rows).toHaveLength(3); // Enabled weights are 1 and 3, so the shares are 25% and 75%. The disabled // row takes none of it despite having the largest weight. const casual = await row('casualconversation'); expect(metric(casual, 'Share')).toContain('25%'); expect(metric(casual, 'Sent')).toBe('40'); expect(metric(casual, 'Replies')).toContain('25% rate'); expect(metric(await row('r4r'), 'Share')).toContain('75%'); const sleepy = await row('sleepy'); expect(metric(sleepy, 'Share')).toBe('Off'); expect(metric(sleepy, 'Sent')).toBe('—'); expect(within(sleepy).getByText('Paused')).toBeInTheDocument(); }); it('summarises how many are polled and how many reply autonomously', async () => { renderSubreddits(); await findRows(); expect(screen.getByText('of 3 being polled')).toBeInTheDocument(); expect(screen.getByText('replying autonomously')).toBeInTheDocument(); expect(await row('r4r')).toHaveTextContent('Autonomous'); }); it('orders by share by default and can be re-sorted', async () => { renderSubreddits(); const user = userEvent.setup(); // Share order leads with the heaviest; name order is alphabetical. The // fixture makes the two orders differ so this proves the sort runs. expect(await rowOrder()).toEqual(['r/r4r', 'r/casualconversation', 'r/sleepy']); await user.selectOptions(screen.getByRole('combobox', { name: 'Sort subreddits' }), 'Name'); expect(await rowOrder()).toEqual(['r/casualconversation', 'r/r4r', 'r/sleepy']); await user.selectOptions(screen.getByRole('combobox', { name: 'Sort subreddits' }), 'Messages sent'); expect(await rowOrder()).toEqual(['r/casualconversation', 'r/r4r', 'r/sleepy']); }); it('filters the list by name', async () => { renderSubreddits(); const user = userEvent.setup(); await findRows(); await user.type(screen.getByRole('searchbox', { name: 'Filter subreddits by name' }), 'sleep'); expect(await findRows()).toHaveLength(1); expect(screen.getByText('r/sleepy')).toBeInTheDocument(); await user.clear(screen.getByRole('searchbox', { name: 'Filter subreddits by name' })); await user.type(screen.getByRole('searchbox', { name: 'Filter subreddits by name' }), 'nothing'); expect(await screen.findByText(/No subreddits match/)).toBeInTheDocument(); }); it('commits the polling toggle immediately, without a save step', async () => { renderSubreddits(); const user = userEvent.setup(); const sleepy = await row('sleepy'); await user.click(within(sleepy).getByRole('checkbox', { name: 'Poll r/sleepy' })); await waitFor(() => expect(saved).toHaveLength(1)); expect(saved[0].find((item: any) => item.name === 'sleepy').enabled).toBe(true); // Enabling weight 4 alongside 3 and 1 rebalances every share. expect(metric(await row('sleepy'), 'Share')).toContain('50%'); }); describe('editing one subreddit', () => { it('can target qualifying commenters on an image subreddit', async () => { renderSubreddits(); const dialog = await openEditor('sleepy'); const user = userEvent.setup(); await user.selectOptions(within(dialog).getByRole('combobox', { name: 'Recipient' }), 'comment-author'); await user.click(within(dialog).getByRole('button', { name: 'Save subreddit' })); await waitFor(() => expect(saved).toHaveLength(1)); expect(saved[0].find((item: any) => item.name === 'sleepy').targetingMode).toBe('comment-author'); }); it('previews the share the chosen weight will actually produce', async () => { renderSubreddits(); const dialog = await openEditor('casualconversation'); const user = userEvent.setup(); expect(within(dialog).getByText('25%')).toBeInTheDocument(); const weight = within(dialog).getByRole('spinbutton', { name: 'Weight' }); await user.clear(weight); await user.type(weight, '3'); expect(within(dialog).getByText('50%')).toBeInTheDocument(); }); it('saves only that subreddit and leaves the others untouched', async () => { renderSubreddits(); const dialog = await openEditor('casualconversation'); const user = userEvent.setup(); await user.selectOptions(within(dialog).getByRole('combobox', { name: 'Tone' }), 'direct'); await user.click(within(dialog).getByRole('button', { name: 'Save subreddit' })); await waitFor(() => expect(saved).toHaveLength(1)); expect(saved[0]).toHaveLength(3); expect(saved[0][0]).toMatchObject({ name: 'casualconversation', tone: 'direct' }); expect(saved[0][1]).toMatchObject({ name: 'r4r', tone: 'playful' }); expect(saved[0][2]).toMatchObject({ name: 'sleepy', tone: 'gentle' }); }); it('rejects a name that is already configured', async () => { renderSubreddits(); const dialog = await openEditor('sleepy'); const user = userEvent.setup(); const name = within(dialog).getByRole('textbox', { name: 'Subreddit name' }); await user.clear(name); await user.type(name, 'R4R'); await user.click(within(dialog).getByRole('button', { name: 'Save subreddit' })); expect(within(dialog).getByText('r/R4R is already configured.')).toBeInTheDocument(); expect(saved).toHaveLength(0); }); it('rejects a name Reddit would not accept', async () => { renderSubreddits(); const dialog = await openEditor('sleepy'); const user = userEvent.setup(); const name = within(dialog).getByRole('textbox', { name: 'Subreddit name' }); await user.clear(name); await user.type(name, 'has spaces'); await user.click(within(dialog).getByRole('button', { name: 'Save subreddit' })); expect(within(dialog).getByText(/2–21 letters, numbers or underscores/)).toBeInTheDocument(); expect(saved).toHaveLength(0); }); it('accepts a name pasted with its r/ prefix', async () => { renderSubreddits(); const dialog = await openEditor('sleepy'); const user = userEvent.setup(); const name = within(dialog).getByRole('textbox', { name: 'Subreddit name' }); await user.clear(name); await user.type(name, 'r/quietplace'); await user.click(within(dialog).getByRole('button', { name: 'Save subreddit' })); await waitFor(() => expect(saved).toHaveLength(1)); expect(saved[0].map((item: any) => item.name)).toContain('quietplace'); }); it('rejects a weight of zero', async () => { renderSubreddits(); const dialog = await openEditor('sleepy'); const user = userEvent.setup(); const weight = within(dialog).getByRole('spinbutton', { name: 'Weight' }); await user.clear(weight); await user.type(weight, '0'); await user.click(within(dialog).getByRole('button', { name: 'Save subreddit' })); expect(within(dialog).getByText('Weight must be a number greater than 0.')).toBeInTheDocument(); expect(saved).toHaveLength(0); }); }); describe('conversation policy', () => { it('reveals the turn budget only for the autonomous modes', async () => { renderSubreddits(); const dialog = await openEditor('casualconversation'); const user = userEvent.setup(); expect(within(dialog).queryByRole('spinbutton', { name: 'Maximum automated turns' })).not.toBeInTheDocument(); await user.click(within(dialog).getByRole('radio', { name: /Autonomous conversation/ })); expect(within(dialog).getByRole('spinbutton', { name: 'Maximum automated turns' })).toBeInTheDocument(); expect(within(dialog).queryByRole('spinbutton', { name: 'Rapport turns before the ask' })).not.toBeInTheDocument(); await user.click(within(dialog).getByRole('radio', { name: /then one media ask/ })); expect(within(dialog).getByRole('spinbutton', { name: 'Rapport turns before the ask' })).toBeInTheDocument(); }); it('requires explicit confirmation before a subreddit replies autonomously', async () => { renderSubreddits(); const dialog = await openEditor('casualconversation'); const user = userEvent.setup(); await user.click(within(dialog).getByRole('radio', { name: /Autonomous conversation/ })); await user.click(within(dialog).getByRole('button', { name: 'Save subreddit' })); expect(await screen.findByText(/r\/casualconversation will reply autonomously/)).toBeInTheDocument(); expect(saved).toHaveLength(0); await user.click(screen.getByRole('button', { name: 'Apply risky settings' })); await waitFor(() => expect(saved).toHaveLength(1)); expect(saved[0][0].conversation.mode).toBe('r4r'); }); it('rejects rapport turns that are not lower than the total turn budget', async () => { renderSubreddits(); const dialog = await openEditor('casualconversation'); const user = userEvent.setup(); await user.click(within(dialog).getByRole('radio', { name: /then one media ask/ })); const total = within(dialog).getByRole('spinbutton', { name: 'Maximum automated turns' }); await user.clear(total); await user.type(total, '4'); const rapport = within(dialog).getByRole('spinbutton', { name: 'Rapport turns before the ask' }); await user.clear(rapport); await user.type(rapport, '4'); await user.click(within(dialog).getByRole('button', { name: 'Save subreddit' })); expect(within(dialog).getByText('Rapport turns must be lower than the maximum automated turns.')).toBeInTheDocument(); expect(saved).toHaveLength(0); }); it('does not re-confirm a subreddit that was already autonomous', async () => { renderSubreddits(); const dialog = await openEditor('r4r'); const user = userEvent.setup(); const total = within(dialog).getByRole('spinbutton', { name: 'Maximum automated turns' }); await user.clear(total); await user.type(total, '8'); await user.click(within(dialog).getByRole('button', { name: 'Save subreddit' })); await waitFor(() => expect(saved).toHaveLength(1)); expect(screen.queryByText('Confirm risky settings')).not.toBeInTheDocument(); expect(saved[0][1].conversation.maxAutomatedTurns).toBe(8); }); it('never prompts about a subreddit other than the one being edited', async () => { renderSubreddits(); // r/r4r is autonomous. Editing an unrelated row must not surface its // policy for re-approval — the old array form confirmed by row index. const dialog = await openEditor('sleepy'); const user = userEvent.setup(); await user.selectOptions(within(dialog).getByRole('combobox', { name: 'Tone' }), 'direct'); await user.click(within(dialog).getByRole('button', { name: 'Save subreddit' })); await waitFor(() => expect(saved).toHaveLength(1)); expect(screen.queryByText('Confirm risky settings')).not.toBeInTheDocument(); }); }); describe('adding and removing', () => { it('adds a subreddit through a dialog, defaulting to the safest policy', async () => { renderSubreddits(); const user = userEvent.setup(); await findRows(); await user.click(screen.getByRole('button', { name: 'Add subreddit' })); const dialog = await screen.findByRole('dialog', { name: 'Add a subreddit' }); expect(within(dialog).getByRole('radio', { name: /Manual only/ })).toBeChecked(); await user.type(within(dialog).getByRole('textbox', { name: 'Subreddit name' }), 'newplace'); await user.click(within(dialog).getByRole('button', { name: 'Add subreddit' })); await waitFor(() => expect(saved).toHaveLength(1)); expect(saved[0]).toHaveLength(4); expect(saved[0][3]).toMatchObject({ name: 'newplace', conversation: { mode: 'manual' } }); expect(await row('newplace')).toBeInTheDocument(); }); it('will not add a subreddit with no name', async () => { renderSubreddits(); const user = userEvent.setup(); await findRows(); await user.click(screen.getByRole('button', { name: 'Add subreddit' })); const dialog = await screen.findByRole('dialog', { name: 'Add a subreddit' }); await user.click(within(dialog).getByRole('button', { name: 'Add subreddit' })); expect(within(dialog).getByText('Subreddit name is required.')).toBeInTheDocument(); expect(saved).toHaveLength(0); }); it('confirms a removal before dropping the subreddit', async () => { renderSubreddits(); const user = userEvent.setup(); await user.click(within(await row('sleepy')).getByRole('button', { name: 'Remove' })); expect(await screen.findByRole('heading', { name: 'Stop using r/sleepy?' })).toBeInTheDocument(); await user.click(screen.getByRole('button', { name: 'Keep it' })); expect(saved).toHaveLength(0); await user.click(within(await row('sleepy')).getByRole('button', { name: 'Remove' })); await user.click(await screen.findByRole('button', { name: 'Remove subreddit' })); await waitFor(() => expect(saved).toHaveLength(1)); expect(saved[0].map((item: any) => item.name)).toEqual(['casualconversation', 'r4r']); }); it('refuses to remove the last remaining subreddit', async () => { config = { ...structuredClone(baseConfig), subreddits: [baseSubreddits[0]] }; renderSubreddits(); const only = await row('casualconversation'); expect(within(only).getByRole('button', { name: 'Remove' })).toBeDisabled(); }); }); describe('bulk actions', () => { async function select(...names: string[]) { const user = userEvent.setup(); for (const name of names) { await user.click(within(await row(name)).getByRole('checkbox', { name: `Select r/${name}` })); } return screen.getByRole('region', { name: 'Bulk actions' }); } it('stays hidden until something is selected', async () => { renderSubreddits(); await findRows(); expect(screen.queryByRole('region', { name: 'Bulk actions' })).not.toBeInTheDocument(); const bar = await select('sleepy'); expect(within(bar).getByText('1 selected')).toBeInTheDocument(); expect(within(bar).getByText('0 polling, 1 paused')).toBeInTheDocument(); }); it('pauses every selected subreddit in one write', async () => { renderSubreddits(); const bar = await select('casualconversation', 'r4r'); await userEvent.setup().click(within(bar).getByRole('button', { name: 'Pause polling' })); await waitFor(() => expect(saved).toHaveLength(1)); expect(saved[0].map((item: any) => [item.name, item.enabled])).toEqual([ ['casualconversation', false], ['r4r', false], ['sleepy', false], ]); }); it('offers only the action that would change something', async () => { renderSubreddits(); const bar = await select('casualconversation'); // Already polling, so there is nothing for "Start polling" to do. expect(within(bar).getByRole('button', { name: 'Start polling' })).toBeDisabled(); expect(within(bar).getByRole('button', { name: 'Pause polling' })).toBeEnabled(); }); it('sets one tone across the selection', async () => { renderSubreddits(); const bar = await select('casualconversation', 'sleepy'); await userEvent.setup().selectOptions( within(bar).getByRole('combobox', { name: 'Set tone for the selected subreddits' }), 'direct', ); await waitFor(() => expect(saved).toHaveLength(1)); expect(saved[0].map((item: any) => item.tone)).toEqual(['direct', 'playful', 'direct']); }); it('names every subreddit turning autonomous before applying it', async () => { renderSubreddits(); const user = userEvent.setup(); const bar = await select('casualconversation', 'r4r', 'sleepy'); await user.selectOptions( within(bar).getByRole('combobox', { name: 'Set conversation policy for the selected subreddits' }), 'Autonomous conversation', ); // r/r4r is already autonomous, so only the other two are called out. const dialog = await screen.findByRole('dialog', { name: 'Confirm risky settings' }); expect(within(dialog).getByText(/2 subreddits will reply autonomously/)).toBeInTheDocument(); expect(within(dialog).getByText(/r\/casualconversation, r\/sleepy/)).toBeInTheDocument(); expect(saved).toHaveLength(0); await user.click(within(dialog).getByRole('button', { name: 'Apply risky settings' })); await waitFor(() => expect(saved).toHaveLength(1)); expect(saved[0].every((item: any) => item.conversation.mode === 'r4r')).toBe(true); }); it('applies a non-autonomous policy without a confirmation step', async () => { renderSubreddits(); const bar = await select('r4r'); await userEvent.setup().selectOptions( within(bar).getByRole('combobox', { name: 'Set conversation policy for the selected subreddits' }), 'Manual only', ); await waitFor(() => expect(saved).toHaveLength(1)); expect(screen.queryByText('Confirm risky settings')).not.toBeInTheDocument(); expect(saved[0][1].conversation.mode).toBe('manual'); }); it('keeps the hybrid mode valid when bulk-applied to a short turn budget', async () => { // r/casualconversation defaults to 3 total / 3 rapport, which the hybrid // mode rejects. A bulk change has no form to fix it, so it must normalise. renderSubreddits(); const user = userEvent.setup(); const bar = await select('casualconversation'); await user.selectOptions( within(bar).getByRole('combobox', { name: 'Set conversation policy for the selected subreddits' }), 'Autonomous, then one media ask', ); await user.click(await screen.findByRole('button', { name: 'Apply risky settings' })); await waitFor(() => expect(saved).toHaveLength(1)); const { maxAutomatedTurns, rapportTurnsBeforeMediaAsk } = saved[0][0].conversation; expect(rapportTurnsBeforeMediaAsk).toBeLessThan(maxAutomatedTurns); }); it('confirms a bulk removal and lists what will go', async () => { renderSubreddits(); const user = userEvent.setup(); const bar = await select('r4r', 'sleepy'); await user.click(within(bar).getByRole('button', { name: 'Remove' })); const dialog = await screen.findByRole('dialog', { name: 'Stop using 2 subreddits?' }); expect(within(dialog).getByText('r/r4r')).toBeInTheDocument(); expect(within(dialog).getByText('r/sleepy')).toBeInTheDocument(); await user.click(within(dialog).getByRole('button', { name: 'Remove subreddits' })); await waitFor(() => expect(saved).toHaveLength(1)); expect(saved[0].map((item: any) => item.name)).toEqual(['casualconversation']); expect(screen.queryByRole('region', { name: 'Bulk actions' })).not.toBeInTheDocument(); }); it('refuses to remove every subreddit at once', async () => { renderSubreddits(); const bar = await select('casualconversation', 'r4r', 'sleepy'); const remove = within(bar).getByRole('button', { name: 'Remove' }); expect(remove).toBeDisabled(); expect(remove).toHaveAttribute('title', 'At least one subreddit must stay configured.'); }); it('selects only the rows the filter is showing', async () => { renderSubreddits(); const user = userEvent.setup(); await findRows(); await user.type(screen.getByRole('searchbox', { name: 'Filter subreddits by name' }), 'r4r'); await user.click(screen.getByRole('checkbox', { name: 'Select all 1 matching subreddits' })); await user.clear(screen.getByRole('searchbox', { name: 'Filter subreddits by name' })); const bar = await screen.findByRole('region', { name: 'Bulk actions' }); expect(within(bar).getByText('1 selected')).toBeInTheDocument(); }); it('selects and clears the whole list', async () => { renderSubreddits(); const user = userEvent.setup(); await findRows(); await user.click(screen.getByRole('checkbox', { name: 'Select all subreddits' })); const bar = await screen.findByRole('region', { name: 'Bulk actions' }); expect(within(bar).getByText('3 selected')).toBeInTheDocument(); await user.click(within(bar).getByRole('button', { name: 'Clear' })); expect(screen.queryByRole('region', { name: 'Bulk actions' })).not.toBeInTheDocument(); }); it('drops a removed subreddit from the selection', async () => { renderSubreddits(); const user = userEvent.setup(); await select('casualconversation', 'sleepy'); // Remove one of the selected rows through its own row control. await user.click(within(await row('sleepy')).getByRole('button', { name: 'Remove' })); await user.click(await screen.findByRole('button', { name: 'Remove subreddit' })); await waitFor(() => expect(saved).toHaveLength(1)); const bar = await screen.findByRole('region', { name: 'Bulk actions' }); expect(within(bar).getByText('1 selected')).toBeInTheDocument(); }); }); it('still lists subreddits when performance data is unavailable', async () => { server.use(http.get('/api/analytics/by-subreddit', () => HttpResponse.json({ error: 'nope' }, { status: 500 }))); renderSubreddits(); expect(await findRows()).toHaveLength(3); expect(await screen.findByText(/performance could not be loaded/)).toBeInTheDocument(); }); it('shows a recoverable error when the configuration cannot be loaded', async () => { let requests = 0; server.use( http.get('/api/config', () => { requests += 1; return requests === 1 ? HttpResponse.json({ error: 'nope' }, { status: 500 }) : HttpResponse.json(config); }), ); renderSubreddits(); expect(await screen.findByRole('alert')).toHaveTextContent('Subreddits unavailable'); await userEvent.setup().click(screen.getByRole('button', { name: 'Retry' })); expect(await findRows()).toHaveLength(3); }); });