Files
RedditBot/tests/dashboard/push-notifications-settings.test.tsx
T
Jamie BruntonandClaude Opus 5 c43e534cf1 refactor(dashboard): rethink settings, split out subreddits and accounts
The settings page had accumulated five tabs whose names no longer matched
their contents, three different save models, and the whole subreddit
configuration buried under a tab named after a checkbox.

Structure:
- Subreddits becomes its own page: a list showing each community's mode,
  tone, computed share of polling, and live sent/reply stats, with one
  subreddit edited at a time in a drawer.
- Accounts becomes its own page. It is session lifecycle, not config.
- Settings collapses to one scrolling page: Safety, Pacing (Monitoring and
  Rate limits merged), Targeting, Notifications, Advanced.
- The sidebar is grouped, having gone from 9 to 11 destinations.

Saving:
- Every section owns its own draft, validation, risk gate and save via
  useConfigCard, submitting only the keys it owns. A syntax error in the
  advanced JSON can no longer block a rate-limit change, and enabling an
  autonomous policy is its own deliberate commit.
- UnsavedChangesProvider restores the leave-page guard across cards that no
  longer share a draft.

Subreddit editing:
- Errors are keyed by field name instead of array index, removing the
  defensive invalidation that existed because row indices shifted on add
  and remove. Confirming an autonomous mode can no longer be attributed to
  the wrong row.
- Weight is shown as the share it actually produces, live while editing.
- Bulk actions cover start/pause polling, tone, policy and removal, because
  production runs 30-40 subreddits and per-row editing does not scale.
  Bulk policy changes name every subreddit turning autonomous, and
  normalise the hybrid mode's turn budget, which has no form to catch it.

Physique targeting moves into its own Targeting card. It sends only
reddit.physiqueTargeting, which the config route applies independently, so
it cannot clobber the pacing card's fields. Turning screening on now warns
that it will reduce how many posts qualify.

Also surfaces the effective sending rate, since the hourly limit and the
minimum delay both throttle and only the binding one matters.

Legacy ?section= links redirect or resolve to anchors.

Fixes found while verifying in a browser: a radio-card rule was shrinking
nested number inputs; .share-preview had no styles; the throughput readout
reported a rate from a blank field; and the reconcile logic warned about a
server change whenever a sibling card saved.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 09:07:31 +01:00

175 lines
6.8 KiB
TypeScript

// @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 { 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, 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'],
};
function pushSettings(overrides: Record<string, unknown> = {}) {
return {
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,
...overrides,
};
}
let settings = pushSettings();
let saved: unknown[] = [];
const server = setupServer(
http.get('/api/config', () => HttpResponse.json(config)),
http.get('/api/accounts', () => HttpResponse.json([])),
http.get('/api/push-notifications', () => HttpResponse.json(settings)),
http.put('/api/push-notifications', async ({ request }) => {
const body = await request.json() as Record<string, unknown>;
saved.push(body);
settings = pushSettings({ ...body, events: { ...settings.events, ...(body.events as object) } });
return HttpResponse.json(settings);
}),
);
function renderSettings() {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
const router = createMemoryRouter([{ path: '/settings', element: <SettingsRoute /> }], {
initialEntries: ['/settings?section=notifications'],
});
return render(
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
</QueryClientProvider>,
);
}
function panel() {
return screen.getByRole('region', { name: 'Notifications' });
}
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
beforeEach(() => {
settings = pushSettings();
saved = [];
});
afterEach(() => {
cleanup();
server.resetHandlers();
vi.restoreAllMocks();
});
afterAll(() => server.close());
describe('push notification settings', () => {
it('shows the notifications section with every selectable event', async () => {
renderSettings();
const section = await screen.findByRole('region', { name: 'Notifications' });
expect(within(section).getByLabelText(/Server URL/)).toHaveValue('https://ntfy.sh');
expect(within(section).getByRole('group', { name: /Events to notify me about \(2 of 7\)/ })).toBeInTheDocument();
expect(within(section).getByRole('checkbox', { name: /Image received/ })).toBeChecked();
expect(within(section).getByRole('checkbox', { name: /Reply received/ })).not.toBeChecked();
expect(within(section).getByText(/No access token is configured/)).toBeInTheDocument();
});
it('saves the topic and event selection the operator picked', async () => {
const user = userEvent.setup();
renderSettings();
await screen.findByRole('region', { name: 'Notifications' });
await user.click(within(panel()).getByRole('checkbox', { name: /Send push notifications/ }));
await user.type(within(panel()).getByLabelText(/Topic/), 'redditbot-7f3a9c21');
await user.click(within(panel()).getByRole('checkbox', { name: /Reply received/ }));
await user.click(within(panel()).getByRole('button', { name: /Save notifications/ }));
await waitFor(() => expect(saved).toHaveLength(1));
expect(saved[0]).toMatchObject({
enabled: true,
topic: 'redditbot-7f3a9c21',
events: { imageReceived: true, replyReceived: true },
});
expect(await within(panel()).findByText('Notifications saved.')).toBeInTheDocument();
});
it('refuses to save an enabled configuration with no topic', async () => {
const user = userEvent.setup();
renderSettings();
await screen.findByRole('region', { name: 'Notifications' });
await user.click(within(panel()).getByRole('checkbox', { name: /Send push notifications/ }));
await user.click(within(panel()).getByRole('button', { name: /Save notifications/ }));
expect(within(panel()).getByText(/A topic is required/)).toBeInTheDocument();
expect(saved).toHaveLength(0);
});
it('rejects a topic containing characters ntfy will not accept', async () => {
const user = userEvent.setup();
renderSettings();
await screen.findByRole('region', { name: 'Notifications' });
await user.type(within(panel()).getByLabelText(/Topic/), 'not a topic!');
await user.click(within(panel()).getByRole('button', { name: /Save notifications/ }));
expect(within(panel()).getByText(/only contain letters, numbers/)).toBeInTheDocument();
expect(saved).toHaveLength(0);
});
it('sends a test notification and reports the delivery outcome', async () => {
settings = pushSettings({ enabled: true, topic: 'redditbot-7f3a9c21' });
server.use(http.post('/api/push-notifications/test', () => HttpResponse.json({
delivered: false,
reason: 'ntfy responded 403 Forbidden',
})));
const user = userEvent.setup();
renderSettings();
await screen.findByRole('region', { name: 'Notifications' });
await user.click(within(panel()).getByRole('button', { name: /Send test/ }));
expect(await within(panel()).findByText(/ntfy responded 403 Forbidden/)).toBeInTheDocument();
});
it('will not send a test until unsaved changes are saved', async () => {
settings = pushSettings({ enabled: true, topic: 'redditbot-7f3a9c21' });
const user = userEvent.setup();
renderSettings();
await screen.findByRole('region', { name: 'Notifications' });
await user.click(within(panel()).getByRole('checkbox', { name: /Sync failed/ }));
expect(within(panel()).getByRole('button', { name: /Send test/ })).toBeDisabled();
});
it('reports that an access token is configured without revealing it', async () => {
settings = pushSettings({ hasAuthToken: true });
renderSettings();
const section = await screen.findByRole('region', { name: 'Notifications' });
expect(within(section).getByText(/An access token is configured/)).toBeInTheDocument();
});
});