Files
RedditBot/tests/dashboard/subreddits.test.tsx
T

610 lines
26 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// @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<string, any> = 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<string, any>;
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: <SubredditsPage /> }], {
initialEntries: ['/subreddits'],
});
return render(
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
</QueryClientProvider>,
);
}
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<string[]> {
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);
});
});