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), 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. 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. Tests: 116 files, 1105 passing (from 113, 1027). Adds subreddit, accounts and model unit suites covering share maths, bulk normalisation and the confirm-by-name behaviour. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
7defff422d
commit
5a0846965a
35 files changed
+4129
-1530
No files matched your search
@@ -0,0 +1,184 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import '@testing-library/jest-dom/vitest';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { cleanup, render, screen, 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 { AccountsPage } from '../../apps/dashboard/src/features/accounts/accounts-page.js';
|
||||
|
||||
const account = {
|
||||
accountId: '@t2_bot:reddit.com',
|
||||
redditUsername: 'example-account',
|
||||
matrixUserId: '@t2_bot:reddit.com',
|
||||
isRunning: true,
|
||||
status: 'authenticated',
|
||||
createdAt: 1,
|
||||
updatedAt: 2,
|
||||
lastImportedAt: 2,
|
||||
};
|
||||
|
||||
const server = setupServer(
|
||||
http.get('/api/login/status', () => HttpResponse.json({ ready: true, loggedIn: false, status: 'login-required' })),
|
||||
http.get('/api/accounts', () => HttpResponse.json([])),
|
||||
);
|
||||
|
||||
function renderAccounts() {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||||
});
|
||||
const router = createMemoryRouter([{ path: '/accounts', element: <AccountsPage /> }], {
|
||||
initialEntries: ['/accounts'],
|
||||
});
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<RouterProvider router={router} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
function sessionDump() {
|
||||
return new File([JSON.stringify({
|
||||
cookies: [{ name: 'reddit_session', value: 'secret', domain: '.reddit.com', path: '/' }],
|
||||
matrix: {
|
||||
homeserver: 'https://matrix.redditspace.com',
|
||||
accessToken: 'matrix-secret',
|
||||
userId: '@t2_bot:reddit.com',
|
||||
capturedAt: 1_700_000_000_000,
|
||||
},
|
||||
})], 'redditbot-session.json', { type: 'application/json' });
|
||||
}
|
||||
|
||||
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
server.resetHandlers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
afterAll(() => server.close());
|
||||
|
||||
describe('accounts page', () => {
|
||||
it('reports the session state and how to fix it, without asking for credentials', async () => {
|
||||
renderAccounts();
|
||||
|
||||
expect(await screen.findByText('Login required')).toBeInTheDocument();
|
||||
expect(screen.getByRole('heading', { name: 'Getting a session dump' })).toBeInTheDocument();
|
||||
expect(screen.getByText(/contains login credentials/i)).toBeInTheDocument();
|
||||
// The dashboard never collects Reddit credentials itself.
|
||||
expect(screen.queryByRole('textbox', { name: /password/i })).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/noVNC/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls out paused accounts as needing a refreshed session', async () => {
|
||||
server.use(http.get('/api/accounts', () => HttpResponse.json([{ ...account, isRunning: false, status: 'login-required' }])));
|
||||
renderAccounts();
|
||||
|
||||
expect(await screen.findByText('One account is paused.')).toBeInTheDocument();
|
||||
expect(screen.getByText(/Other accounts keep running normally/)).toBeInTheDocument();
|
||||
expect(screen.getByText('Waiting for a refreshed session')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('says plainly when no accounts have been added', async () => {
|
||||
renderAccounts();
|
||||
|
||||
expect(await screen.findByText('No accounts yet')).toBeInTheDocument();
|
||||
expect(screen.queryByText(/accounts? is paused/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('rejects a non-JSON session dump inline without sending a request', async () => {
|
||||
let importRequests = 0;
|
||||
server.use(
|
||||
http.post('/api/login/session-import', () => {
|
||||
importRequests += 1;
|
||||
return HttpResponse.json({ ok: true });
|
||||
}),
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
renderAccounts();
|
||||
|
||||
const broken = new File(['definitely not json'], 'broken.json', { type: 'application/json' });
|
||||
await user.upload(await screen.findByLabelText('Reddit session export'), broken);
|
||||
|
||||
expect(await screen.findByText(/isn't valid JSON/)).toBeInTheDocument();
|
||||
expect(importRequests).toBe(0);
|
||||
});
|
||||
|
||||
it('imports an extension dump and reflects the verified account', async () => {
|
||||
let statusRequests = 0;
|
||||
let importRequests = 0;
|
||||
server.use(
|
||||
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', username: 'example-account' });
|
||||
}),
|
||||
http.get('/api/accounts', () => HttpResponse.json(statusRequests === 1 ? [] : [account])),
|
||||
http.post('/api/login/session-import', async ({ request }) => {
|
||||
expect(request.credentials).toBe('same-origin');
|
||||
const body = await request.json() as { cookies: unknown[]; matrix: { accessToken: string } };
|
||||
expect(body.cookies).toHaveLength(1);
|
||||
expect(body.matrix.accessToken).toBe('matrix-secret');
|
||||
importRequests += 1;
|
||||
return HttpResponse.json({ ok: true, status: 'authenticated', account });
|
||||
}),
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
renderAccounts();
|
||||
|
||||
await user.upload(await screen.findByLabelText('Reddit session export'), sessionDump());
|
||||
|
||||
expect(await screen.findByText(/Session imported and verified/)).toBeInTheDocument();
|
||||
expect(importRequests).toBe(1);
|
||||
expect(await screen.findByText('Authenticated')).toBeInTheDocument();
|
||||
|
||||
const link = screen.getAllByRole('link', { name: 'u/example-account' })[0];
|
||||
expect(link).toHaveAttribute('href', 'https://www.reddit.com/u/example-account');
|
||||
});
|
||||
|
||||
it('rechecks sessions on request', async () => {
|
||||
let statusRequests = 0;
|
||||
server.use(
|
||||
http.get('/api/login/status', () => {
|
||||
statusRequests += 1;
|
||||
return HttpResponse.json({ ready: true, loggedIn: false, status: 'login-required' });
|
||||
}),
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
renderAccounts();
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: 'Recheck accounts' }));
|
||||
expect(statusRequests).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
it('shows a recoverable error when access cannot be checked', async () => {
|
||||
let requests = 0;
|
||||
server.use(
|
||||
http.get('/api/login/status', () => {
|
||||
requests += 1;
|
||||
return requests === 1
|
||||
? HttpResponse.json({ error: 'nope' }, { status: 500 })
|
||||
: HttpResponse.json({ ready: true, loggedIn: false, status: 'login-required' });
|
||||
}),
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
renderAccounts();
|
||||
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('Reddit access unavailable');
|
||||
await user.click(screen.getByRole('button', { name: 'Retry access' }));
|
||||
expect(await screen.findByRole('region', { name: 'Reddit accounts' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('reports an account list failure separately from the session check', async () => {
|
||||
server.use(http.get('/api/accounts', () => HttpResponse.json({ error: 'nope' }, { status: 500 })));
|
||||
renderAccounts();
|
||||
|
||||
const region = await screen.findByRole('region', { name: 'Reddit accounts' });
|
||||
expect(within(region).getByRole('alert')).toHaveTextContent('Could not load Reddit accounts');
|
||||
// The page itself still renders, so the import button stays available.
|
||||
expect(screen.getByRole('button', { name: 'Add or refresh account' })).toBeEnabled();
|
||||
});
|
||||
});
|
||||
@@ -491,7 +491,7 @@ describe('ConversationsPage', () => {
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'New conversation' }));
|
||||
expect(await screen.findByText(/No running accounts/)).toBeInTheDocument();
|
||||
expect(screen.getByRole('link', { name: 'Settings' })).toHaveAttribute('href', '/settings');
|
||||
expect(screen.getByRole('link', { name: 'Accounts' })).toHaveAttribute('href', '/accounts');
|
||||
expect(screen.queryByRole('button', { name: 'Start conversation' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
|
||||
@@ -111,7 +111,7 @@ describe('push notification settings', () => {
|
||||
topic: 'redditbot-7f3a9c21',
|
||||
events: { imageReceived: true, replyReceived: true },
|
||||
});
|
||||
expect(await within(panel()).findByText('Notification settings saved.')).toBeInTheDocument();
|
||||
expect(await within(panel()).findByText('Notifications saved.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('refuses to save an enabled configuration with no topic', async () => {
|
||||
@@ -148,7 +148,7 @@ describe('push notification settings', () => {
|
||||
renderSettings();
|
||||
await screen.findByRole('region', { name: 'Notifications' });
|
||||
|
||||
await user.click(within(panel()).getByRole('button', { name: /Send test notification/ }));
|
||||
await user.click(within(panel()).getByRole('button', { name: /Send test/ }));
|
||||
|
||||
expect(await within(panel()).findByText(/ntfy responded 403 Forbidden/)).toBeInTheDocument();
|
||||
});
|
||||
@@ -161,7 +161,7 @@ describe('push notification settings', () => {
|
||||
|
||||
await user.click(within(panel()).getByRole('checkbox', { name: /Sync failed/ }));
|
||||
|
||||
expect(within(panel()).getByRole('button', { name: /Send test notification/ })).toBeDisabled();
|
||||
expect(within(panel()).getByRole('button', { name: /Send test/ })).toBeDisabled();
|
||||
});
|
||||
|
||||
it('reports that an access token is configured without revealing it', async () => {
|
||||
|
||||
@@ -0,0 +1,280 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
createPacingDraft,
|
||||
estimateThroughput,
|
||||
pacingRisks,
|
||||
safetyRisks,
|
||||
validateAdvancedDraft,
|
||||
validatePacingDraft,
|
||||
type PacingDraft,
|
||||
} from '../../apps/dashboard/src/features/settings/settings-model.js';
|
||||
import {
|
||||
computeShares,
|
||||
formatShare,
|
||||
normaliseSubredditName,
|
||||
previewShare,
|
||||
validateSubredditDraft,
|
||||
createEmptySubredditDraft,
|
||||
type SubredditConfig,
|
||||
type SubredditDraft,
|
||||
} from '../../apps/dashboard/src/features/subreddits/subreddit-model.js';
|
||||
|
||||
function pacing(overrides: Partial<PacingDraft> = {}): PacingDraft {
|
||||
return {
|
||||
minDelayMinutes: '5',
|
||||
maxMessagesPerHour: '8',
|
||||
jitterSeconds: '60',
|
||||
prioritizeRecentPosts: false,
|
||||
recentWindowMinutes: '60',
|
||||
maxReadyCandidates: '25',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function subreddit(overrides: Partial<SubredditConfig> = {}): SubredditConfig {
|
||||
return {
|
||||
name: 'example',
|
||||
enabled: true,
|
||||
type: 'chat',
|
||||
tone: 'default',
|
||||
conversation: { mode: 'legacy', maxAutomatedTurns: 3, rapportTurnsBeforeMediaAsk: 3 },
|
||||
...overrides,
|
||||
} as SubredditConfig;
|
||||
}
|
||||
|
||||
function draft(overrides: Partial<SubredditDraft> = {}): SubredditDraft {
|
||||
return { ...createEmptySubredditDraft(), name: 'example', ...overrides };
|
||||
}
|
||||
|
||||
describe('createPacingDraft', () => {
|
||||
it('converts stored milliseconds into the units the operator types', () => {
|
||||
const result = createPacingDraft({
|
||||
rateLimit: { minDelayBetweenMessagesMs: 300_000, maxMessagesPerHour: 8, jitterMs: 90_000 },
|
||||
browser: { dryRun: true },
|
||||
reddit: { prioritizeRecentPosts: true, recentWindowMinutes: 45, maxReadyCandidates: 30 },
|
||||
subreddits: [],
|
||||
tones: [],
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
minDelayMinutes: '5',
|
||||
maxMessagesPerHour: '8',
|
||||
jitterSeconds: '90',
|
||||
prioritizeRecentPosts: true,
|
||||
recentWindowMinutes: '45',
|
||||
maxReadyCandidates: '30',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('validatePacingDraft', () => {
|
||||
it('converts back into milliseconds and omits keys it does not own', () => {
|
||||
const { payload, errors } = validatePacingDraft(pacing({ jitterSeconds: '30' }));
|
||||
|
||||
expect(errors).toEqual({});
|
||||
expect(payload).toEqual({
|
||||
rateLimit: { minDelayBetweenMessagesMs: 300_000, maxMessagesPerHour: 8, jitterMs: 30_000 },
|
||||
reddit: { prioritizeRecentPosts: false, recentWindowMinutes: 60, maxReadyCandidates: 25 },
|
||||
});
|
||||
});
|
||||
|
||||
it('collects every problem in one pass rather than one at a time', () => {
|
||||
const { payload, errors } = validatePacingDraft(pacing({
|
||||
minDelayMinutes: '',
|
||||
jitterSeconds: '-1',
|
||||
maxReadyCandidates: '0',
|
||||
}));
|
||||
|
||||
expect(payload).toBeUndefined();
|
||||
expect(Object.keys(errors).sort()).toEqual(['jitterSeconds', 'maxReadyCandidates', 'minDelayMinutes']);
|
||||
});
|
||||
|
||||
it('rejects a fractional hourly limit', () => {
|
||||
expect(validatePacingDraft(pacing({ maxMessagesPerHour: '2.5' })).errors)
|
||||
.toHaveProperty('maxMessagesPerHour');
|
||||
});
|
||||
});
|
||||
|
||||
describe('pacingRisks', () => {
|
||||
it('warns only when the limit both rises and clears the safe ceiling', () => {
|
||||
expect(pacingRisks(pacing({ maxMessagesPerHour: '21' }), pacing())).toHaveLength(1);
|
||||
expect(pacingRisks(pacing({ maxMessagesPerHour: '20' }), pacing())).toEqual([]);
|
||||
// Lowering an already-high limit is a de-escalation, not a risk.
|
||||
expect(pacingRisks(pacing({ maxMessagesPerHour: '30' }), pacing({ maxMessagesPerHour: '40' }))).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('safetyRisks', () => {
|
||||
it('warns only on the transition from dry run into live sending', () => {
|
||||
expect(safetyRisks({ dryRun: false }, { dryRun: true })).toHaveLength(1);
|
||||
expect(safetyRisks({ dryRun: true }, { dryRun: false })).toEqual([]);
|
||||
expect(safetyRisks({ dryRun: false }, { dryRun: false })).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('estimateThroughput', () => {
|
||||
it('reports the hourly cap when it binds before the delay', () => {
|
||||
const estimate = estimateThroughput(pacing({ minDelayMinutes: '5', jitterSeconds: '0' }));
|
||||
expect(estimate).toMatchObject({ effectivePerHour: 8, isDelayBound: false });
|
||||
});
|
||||
|
||||
it('reports the delay when it binds before the hourly cap', () => {
|
||||
const estimate = estimateThroughput(pacing({ minDelayMinutes: '30', jitterSeconds: '0' }));
|
||||
expect(estimate).toMatchObject({ effectivePerHour: 2, isDelayBound: true });
|
||||
});
|
||||
|
||||
it('counts average jitter as part of the spacing', () => {
|
||||
// 10 min delay plus 120s average jitter (half of 240) is 12 min spacing.
|
||||
const estimate = estimateThroughput(pacing({ minDelayMinutes: '10', jitterSeconds: '240' }));
|
||||
expect(estimate?.spacingLabel).toBe('about one every 12 min');
|
||||
expect(estimate?.effectivePerHour).toBe(5);
|
||||
});
|
||||
|
||||
it('gives up on unusable numbers rather than reporting nonsense', () => {
|
||||
expect(estimateThroughput(pacing({ minDelayMinutes: '' }))).toBeNull();
|
||||
expect(estimateThroughput(pacing({ maxMessagesPerHour: '0' }))).toBeNull();
|
||||
expect(estimateThroughput(pacing({ jitterSeconds: '-5' }))).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateAdvancedDraft', () => {
|
||||
it('accepts a JSON object', () => {
|
||||
expect(validateAdvancedDraft({ advancedConfigJson: '{"a":1}' }).payload).toEqual({ advanced: { a: 1 } });
|
||||
});
|
||||
|
||||
it('names the syntax problem rather than just failing', () => {
|
||||
const { errors } = validateAdvancedDraft({ advancedConfigJson: '{"a":' });
|
||||
expect(errors.advancedConfigJson).toMatch(/^Not valid JSON — /);
|
||||
});
|
||||
|
||||
it('refuses arrays, bare values and an emptied editor', () => {
|
||||
expect(validateAdvancedDraft({ advancedConfigJson: '[1]' }).errors.advancedConfigJson).toMatch(/JSON object/);
|
||||
expect(validateAdvancedDraft({ advancedConfigJson: '"text"' }).errors.advancedConfigJson).toMatch(/JSON object/);
|
||||
expect(validateAdvancedDraft({ advancedConfigJson: ' ' }).errors.advancedConfigJson).toMatch(/cannot be emptied/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('computeShares', () => {
|
||||
it('splits attention across enabled subreddits in proportion to weight', () => {
|
||||
const shares = computeShares([
|
||||
subreddit({ name: 'a', weight: 3 }),
|
||||
subreddit({ name: 'b', weight: 1 }),
|
||||
]);
|
||||
|
||||
expect(shares.get('a')?.share).toBeCloseTo(0.75);
|
||||
expect(shares.get('b')?.share).toBeCloseTo(0.25);
|
||||
});
|
||||
|
||||
it('treats a missing weight as the runtime default of 1', () => {
|
||||
const shares = computeShares([subreddit({ name: 'a' }), subreddit({ name: 'b' })]);
|
||||
|
||||
expect(shares.get('a')).toEqual({ effectiveWeight: 1, share: 0.5 });
|
||||
});
|
||||
|
||||
it('gives disabled subreddits no share and excludes them from the total', () => {
|
||||
const shares = computeShares([
|
||||
subreddit({ name: 'a', weight: 1 }),
|
||||
subreddit({ name: 'off', weight: 99, enabled: false }),
|
||||
]);
|
||||
|
||||
expect(shares.get('off')?.share).toBe(0);
|
||||
expect(shares.get('a')?.share).toBe(1);
|
||||
});
|
||||
|
||||
it('reports zero rather than dividing by zero when everything is disabled', () => {
|
||||
const shares = computeShares([subreddit({ name: 'a', enabled: false })]);
|
||||
expect(shares.get('a')?.share).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatShare', () => {
|
||||
it('keeps tiny but non-zero shares visible', () => {
|
||||
expect(formatShare(0.004)).toBe('<1%');
|
||||
expect(formatShare(0)).toBe('—');
|
||||
expect(formatShare(0.256)).toBe('26%');
|
||||
});
|
||||
});
|
||||
|
||||
describe('previewShare', () => {
|
||||
const existing = [subreddit({ name: 'a', weight: 1 }), subreddit({ name: 'b', weight: 1 })];
|
||||
|
||||
it('excludes the edited row from the baseline so its old weight is not counted twice', () => {
|
||||
// Editing 'a' up to weight 3 leaves 'b' at 1, so 'a' takes three quarters.
|
||||
expect(previewShare(draft({ name: 'a', weight: '3' }), existing, 'a')).toBeCloseTo(0.75);
|
||||
});
|
||||
|
||||
it('adds a brand new row on top of the existing total', () => {
|
||||
expect(previewShare(draft({ name: 'c', weight: '2' }), existing, null)).toBeCloseTo(0.5);
|
||||
});
|
||||
|
||||
it('is zero for a disabled or unusable weight', () => {
|
||||
expect(previewShare(draft({ name: 'a', weight: '1', enabled: false }), existing, 'a')).toBe(0);
|
||||
expect(previewShare(draft({ name: 'a', weight: '-2' }), existing, 'a')).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('normaliseSubredditName', () => {
|
||||
it('strips the prefix operators paste in', () => {
|
||||
expect(normaliseSubredditName(' r/Example ')).toBe('Example');
|
||||
expect(normaliseSubredditName('/r/example')).toBe('example');
|
||||
expect(normaliseSubredditName('example')).toBe('example');
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateSubredditDraft', () => {
|
||||
it('keys errors by field name, with no array index to go stale', () => {
|
||||
const { errors } = validateSubredditDraft(draft({ name: '', weight: '0' }), []);
|
||||
expect(Object.keys(errors).sort()).toEqual(['name', 'weight']);
|
||||
});
|
||||
|
||||
it('rejects a duplicate name regardless of case', () => {
|
||||
expect(validateSubredditDraft(draft({ name: 'Example' }), ['example']).errors)
|
||||
.toHaveProperty('name', 'r/Example is already configured.');
|
||||
});
|
||||
|
||||
it('allows a name that only collides with the row being edited', () => {
|
||||
// The caller excludes the edited row from `takenNames`, so saving a
|
||||
// subreddit without renaming it is not a conflict.
|
||||
expect(validateSubredditDraft(draft({ name: 'example' }), []).errors).toEqual({});
|
||||
});
|
||||
|
||||
it('rejects names Reddit itself would not accept', () => {
|
||||
for (const name of ['a', 'has spaces', '_leading', 'way-too-long-for-reddit-to-accept']) {
|
||||
expect(validateSubredditDraft(draft({ name }), []).errors, name).toHaveProperty('name');
|
||||
}
|
||||
});
|
||||
|
||||
it('omits an empty weight and scripted message instead of sending blanks', () => {
|
||||
const { config } = validateSubredditDraft(draft({ weight: '', scripted: ' ' }), []);
|
||||
expect(config).not.toHaveProperty('weight');
|
||||
expect(config).not.toHaveProperty('scripted');
|
||||
});
|
||||
|
||||
it('normalises turn settings away for modes that do not use them', () => {
|
||||
const { config } = validateSubredditDraft(
|
||||
draft({ conversationMode: 'legacy', maxAutomatedTurns: '19', rapportTurnsBeforeMediaAsk: '17' }),
|
||||
[],
|
||||
);
|
||||
expect(config?.conversation).toEqual({ mode: 'legacy', maxAutomatedTurns: 3, rapportTurnsBeforeMediaAsk: 3 });
|
||||
});
|
||||
|
||||
it('requires rapport turns below the total budget for the hybrid mode', () => {
|
||||
const { errors } = validateSubredditDraft(
|
||||
draft({ conversationMode: 'r4r-media', maxAutomatedTurns: '4', rapportTurnsBeforeMediaAsk: '4' }),
|
||||
[],
|
||||
);
|
||||
expect(errors).toHaveProperty('rapportTurnsBeforeMediaAsk');
|
||||
});
|
||||
|
||||
it('rejects a turn budget above the configured maximum', () => {
|
||||
const { errors } = validateSubredditDraft(
|
||||
draft({ conversationMode: 'r4r', maxAutomatedTurns: '21' }),
|
||||
[],
|
||||
);
|
||||
expect(errors).toHaveProperty('maxAutomatedTurns');
|
||||
});
|
||||
|
||||
it('defaults a new subreddit to the mode that cannot send unattended', () => {
|
||||
expect(createEmptySubredditDraft().conversationMode).toBe('manual');
|
||||
});
|
||||
});
|
||||
+348
-472
@@ -6,11 +6,11 @@ import { act, cleanup, render, screen, waitFor, within } from '@testing-library/
|
||||
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 { 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 = {
|
||||
const baseConfig = {
|
||||
rateLimit: { minDelayBetweenMessagesMs: 300_000, maxMessagesPerHour: 8, jitterMs: 60_000 },
|
||||
browser: { dryRun: true },
|
||||
reddit: { prioritizeRecentPosts: false, recentWindowMinutes: 60, maxReadyCandidates: 25 },
|
||||
@@ -19,10 +19,58 @@ const config = {
|
||||
advanced: { openrouter: { chatModel: 'example/chat-model' } },
|
||||
};
|
||||
|
||||
const server = setupServer(http.get('/api/accounts', () => HttpResponse.json([])));
|
||||
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,
|
||||
};
|
||||
|
||||
function installConfigApi() {
|
||||
server.use(http.get('/api/config', () => HttpResponse.json(config)));
|
||||
let config: Record<string, unknown> = structuredClone(baseConfig);
|
||||
/** Bodies received by PUT /api/config, in order. */
|
||||
let saved: Record<string, unknown>[] = [];
|
||||
|
||||
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<string, any>;
|
||||
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') {
|
||||
@@ -32,6 +80,8 @@ function renderSettings(path = '/settings') {
|
||||
const router = createMemoryRouter(
|
||||
[
|
||||
{ path: '/settings', element: <SettingsRoute /> },
|
||||
{ path: '/subreddits', element: <h1>Subreddits page</h1> },
|
||||
{ path: '/accounts', element: <h1>Accounts page</h1> },
|
||||
{ path: '/elsewhere', element: <h1>Elsewhere</h1> },
|
||||
],
|
||||
{ initialEntries: [path] },
|
||||
@@ -44,7 +94,19 @@ function renderSettings(path = '/settings') {
|
||||
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();
|
||||
@@ -52,26 +114,13 @@ afterEach(() => {
|
||||
});
|
||||
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<Response>((resolve) => { resolveConfig = resolve; });
|
||||
}),
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
const { router } = renderSettings('/settings?section=rate-limits');
|
||||
describe('settings page', () => {
|
||||
it('stacks every section on one page instead of hiding them behind tabs', async () => {
|
||||
renderSettings();
|
||||
|
||||
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');
|
||||
for (const name of ['Safety', 'Pacing', 'Notifications', 'Advanced']) {
|
||||
expect(await findCard(name)).toBeInTheDocument();
|
||||
}
|
||||
});
|
||||
|
||||
it('shows a recoverable initial configuration error', async () => {
|
||||
@@ -79,473 +128,300 @@ describe('settings and access', () => {
|
||||
server.use(
|
||||
http.get('/api/config', () => {
|
||||
requests += 1;
|
||||
return requests === 1
|
||||
? HttpResponse.json({ error: 'Configuration unavailable' }, { status: 503 })
|
||||
: HttpResponse.json(config);
|
||||
return requests === 1 ? HttpResponse.json({ error: 'nope' }, { status: 500 }) : 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();
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('Settings unavailable');
|
||||
await user.click(screen.getByRole('button', { name: 'Retry settings' }));
|
||||
expect(await findCard('Pacing')).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(screen.getByText('Resolve 1 validation error before saving.')).toBeInTheDocument();
|
||||
await waitFor(() => expect(recentWindow).toHaveFocus());
|
||||
expect(updateRequests).toBe(0);
|
||||
|
||||
// Discarding a dirty draft requires explicit confirmation.
|
||||
await user.click(screen.getByRole('button', { name: 'Reset changes' }));
|
||||
const resetDialog = screen.getByRole('dialog', { name: 'Discard unsaved settings?' });
|
||||
await user.click(within(resetDialog).getByRole('button', { name: 'Stay' }));
|
||||
expect(recentWindow).toHaveValue(0);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Reset changes' }));
|
||||
await user.click(within(screen.getByRole('dialog', { name: 'Discard unsaved settings?' })).getByRole('button', { name: 'Discard 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<Response>((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, maxReadyCandidates: 25 } });
|
||||
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('configures autonomous r4r per subreddit and requires explicit confirmation', async () => {
|
||||
installConfigApi();
|
||||
let requestBody: { subreddits: Array<{ conversation: { mode: string; maxAutomatedTurns: number; rapportTurnsBeforeMediaAsk: number } }> } | undefined;
|
||||
server.use(http.put('/api/config', async ({ request }) => {
|
||||
requestBody = await request.json() as typeof requestBody;
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
config: { rateLimit: config.rateLimit, browser: config.browser, reddit: config.reddit },
|
||||
subreddits: requestBody?.subreddits ?? config.subreddits,
|
||||
});
|
||||
}));
|
||||
const user = userEvent.setup();
|
||||
renderSettings('/settings?section=behaviour');
|
||||
|
||||
const behaviour = await screen.findByRole('combobox', { name: 'Subreddit 1 conversation behaviour' });
|
||||
expect(behaviour).toHaveValue('legacy');
|
||||
expect(screen.queryByRole('spinbutton', { name: 'Subreddit 1 maximum automated turns' })).not.toBeInTheDocument();
|
||||
|
||||
await user.selectOptions(behaviour, 'r4r');
|
||||
const maxTurns = screen.getByRole('spinbutton', { name: 'Subreddit 1 maximum automated turns' });
|
||||
await user.clear(maxTurns);
|
||||
await user.type(maxTurns, '7');
|
||||
await user.click(screen.getByRole('button', { name: 'Save settings' }));
|
||||
|
||||
const dialog = screen.getByRole('dialog', { name: 'Confirm risky settings' });
|
||||
expect(dialog).toHaveTextContent('Autonomous R4R will send conversational replies without approval');
|
||||
expect(requestBody).toBeUndefined();
|
||||
await user.click(within(dialog).getByRole('button', { name: 'Apply risky settings' }));
|
||||
|
||||
await waitFor(() => expect(requestBody?.subreddits[0].conversation).toEqual({
|
||||
mode: 'r4r', maxAutomatedTurns: 7, rapportTurnsBeforeMediaAsk: 3,
|
||||
}));
|
||||
});
|
||||
|
||||
it('configures rapport and total turns for the autonomous hybrid mode', async () => {
|
||||
installConfigApi();
|
||||
let requestBody: {
|
||||
subreddits: Array<{
|
||||
conversation: { mode: string; maxAutomatedTurns: number; rapportTurnsBeforeMediaAsk: number };
|
||||
}>;
|
||||
} | undefined;
|
||||
server.use(http.put('/api/config', async ({ request }) => {
|
||||
requestBody = await request.json() as typeof requestBody;
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
config: { rateLimit: config.rateLimit, browser: config.browser, reddit: config.reddit },
|
||||
subreddits: requestBody?.subreddits ?? config.subreddits,
|
||||
});
|
||||
}));
|
||||
const user = userEvent.setup();
|
||||
renderSettings('/settings?section=behaviour');
|
||||
|
||||
const behaviour = await screen.findByRole('combobox', { name: 'Subreddit 1 conversation behaviour' });
|
||||
await user.selectOptions(behaviour, 'r4r-media');
|
||||
const maxTurns = screen.getByRole('spinbutton', { name: 'Subreddit 1 maximum automated turns' });
|
||||
const rapportTurns = screen.getByRole('spinbutton', { name: 'Subreddit 1 rapport turns before media ask' });
|
||||
await user.click(screen.getByRole('button', { name: 'Save settings' }));
|
||||
expect(screen.getByText('Rapport turns must be lower than the maximum automated turns.')).toBeInTheDocument();
|
||||
expect(requestBody).toBeUndefined();
|
||||
await user.clear(maxTurns);
|
||||
await user.type(maxTurns, '6');
|
||||
await user.clear(rapportTurns);
|
||||
await user.type(rapportTurns, '3');
|
||||
await user.click(screen.getByRole('button', { name: 'Save settings' }));
|
||||
|
||||
const dialog = screen.getByRole('dialog', { name: 'Confirm risky settings' });
|
||||
expect(dialog).toHaveTextContent('hybrid mode can make one optional image request after rapport');
|
||||
await user.click(within(dialog).getByRole('button', { name: 'Apply risky settings' }));
|
||||
|
||||
await waitFor(() => expect(requestBody?.subreddits[0].conversation).toEqual({
|
||||
mode: 'r4r-media', maxAutomatedTurns: 6, rapportTurnsBeforeMediaAsk: 3,
|
||||
}));
|
||||
});
|
||||
|
||||
it('validates advanced application JSON before saving', 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=behaviour');
|
||||
|
||||
const editor = await screen.findByRole('textbox', { name: 'Advanced configuration JSON' });
|
||||
await user.clear(editor);
|
||||
await user.type(editor, 'invalid');
|
||||
await user.click(screen.getByRole('button', { name: 'Save settings' }));
|
||||
|
||||
expect(screen.getByText('Advanced configuration is not valid JSON.')).toBeInTheDocument();
|
||||
expect(updateRequests).toBe(0);
|
||||
});
|
||||
|
||||
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('confirms in-app navigation away from dirty settings and can discard the draft', async () => {
|
||||
installConfigApi();
|
||||
const user = userEvent.setup();
|
||||
const { router } = renderSettings('/settings?section=monitoring');
|
||||
|
||||
const recentWindow = await screen.findByRole('spinbutton', { name: 'Recent window in minutes' });
|
||||
await user.clear(recentWindow);
|
||||
await user.type(recentWindow, '45');
|
||||
|
||||
await act(async () => { await router.navigate('/elsewhere'); });
|
||||
const stayDialog = await screen.findByRole('dialog', { name: 'Discard unsaved settings?' });
|
||||
await user.click(within(stayDialog).getByRole('button', { name: 'Stay' }));
|
||||
expect(router.state.location.pathname).toBe('/settings');
|
||||
expect(screen.getByRole('spinbutton', { name: 'Recent window in minutes' })).toHaveValue(45);
|
||||
|
||||
await act(async () => { await router.navigate('/elsewhere'); });
|
||||
const discardDialog = await screen.findByRole('dialog', { name: 'Discard unsaved settings?' });
|
||||
await user.click(within(discardDialog).getByRole('button', { name: 'Discard changes' }));
|
||||
await waitFor(() => expect(router.state.location.pathname).toBe('/elsewhere'));
|
||||
expect(screen.getByRole('heading', { name: 'Elsewhere' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('requires confirmation for an autonomous mode on a renamed subreddit, matched by name not index', async () => {
|
||||
const r4rConfig = {
|
||||
...config,
|
||||
subreddits: [{
|
||||
name: 'casualconversation', type: 'chat', weight: 2, tone: 'default',
|
||||
conversation: { mode: 'r4r', maxAutomatedTurns: 5, rapportTurnsBeforeMediaAsk: 3 },
|
||||
}],
|
||||
};
|
||||
let updateRequests = 0;
|
||||
server.use(
|
||||
http.get('/api/config', () => HttpResponse.json(r4rConfig)),
|
||||
http.put('/api/config', () => {
|
||||
updateRequests += 1;
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
config: { rateLimit: config.rateLimit, browser: config.browser, reddit: config.reddit },
|
||||
subreddits: r4rConfig.subreddits,
|
||||
});
|
||||
}),
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
renderSettings('/settings?section=behaviour');
|
||||
|
||||
// The persisted row is already r4r at index 0; giving the row a new name is
|
||||
// equivalent to inserting a new r4r-enabled row, so it must be re-confirmed.
|
||||
const name = await screen.findByRole('textbox', { name: 'Subreddit 1 name' });
|
||||
await user.clear(name);
|
||||
await user.type(name, 'brandnewplace');
|
||||
await user.click(screen.getByRole('button', { name: 'Save settings' }));
|
||||
|
||||
const dialog = screen.getByRole('dialog', { name: 'Confirm risky settings' });
|
||||
expect(dialog).toHaveTextContent('Autonomous R4R will send conversational replies without approval');
|
||||
expect(updateRequests).toBe(0);
|
||||
});
|
||||
|
||||
it('does not re-confirm an unchanged autonomous subreddit when a row before it is removed', async () => {
|
||||
const twoSubredditConfig = {
|
||||
...config,
|
||||
subreddits: [
|
||||
{ name: 'general', type: 'chat', weight: 1, tone: 'default', conversation: { mode: 'legacy', maxAutomatedTurns: 3, rapportTurnsBeforeMediaAsk: 3 } },
|
||||
{ name: 'r4rlondon', type: 'chat', weight: 1, tone: 'default', conversation: { mode: 'r4r', maxAutomatedTurns: 5, rapportTurnsBeforeMediaAsk: 3 } },
|
||||
],
|
||||
};
|
||||
let updateRequests = 0;
|
||||
server.use(
|
||||
http.get('/api/config', () => HttpResponse.json(twoSubredditConfig)),
|
||||
http.put('/api/config', async ({ request }) => {
|
||||
updateRequests += 1;
|
||||
const body = await request.json() as { subreddits: typeof twoSubredditConfig.subreddits };
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
config: { rateLimit: config.rateLimit, browser: config.browser, reddit: config.reddit },
|
||||
subreddits: body.subreddits,
|
||||
});
|
||||
}),
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
renderSettings('/settings?section=behaviour');
|
||||
|
||||
// Removing the first row shifts the r4r row to index 0; by-name matching
|
||||
// must recognise it as already approved and save without a risk dialog.
|
||||
await user.click(await screen.findByRole('button', { name: 'Remove subreddit 1' }));
|
||||
await user.click(screen.getByRole('button', { name: 'Save settings' }));
|
||||
|
||||
expect(screen.queryByRole('dialog', { name: 'Confirm risky settings' })).not.toBeInTheDocument();
|
||||
expect(await screen.findByText('Settings saved.')).toBeInTheDocument();
|
||||
expect(updateRequests).toBe(1);
|
||||
});
|
||||
|
||||
it('notices a server-side config change while the draft is dirty and loads it on reset', async () => {
|
||||
installConfigApi();
|
||||
const user = userEvent.setup();
|
||||
const { queryClient } = renderSettings('/settings?section=monitoring');
|
||||
|
||||
const recentWindow = await screen.findByRole('spinbutton', { name: 'Recent window in minutes' });
|
||||
await user.clear(recentWindow);
|
||||
await user.type(recentWindow, '45');
|
||||
|
||||
act(() => {
|
||||
queryClient.setQueryData(['dashboard', 'config'], {
|
||||
...config,
|
||||
reddit: { ...config.reddit, maxReadyCandidates: 99 },
|
||||
subreddits: [{
|
||||
name: 'casualconversation', type: 'chat', weight: 2, tone: 'default',
|
||||
conversation: { mode: 'legacy', maxAutomatedTurns: 3, rapportTurnsBeforeMediaAsk: 3 },
|
||||
}],
|
||||
});
|
||||
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();
|
||||
});
|
||||
|
||||
expect(await screen.findByText('Settings changed on the server — Reset to load them.')).toBeInTheDocument();
|
||||
expect(recentWindow).toHaveValue(45);
|
||||
it('redirects the access tab to the accounts page', async () => {
|
||||
renderSettings('/settings?section=access');
|
||||
expect(await screen.findByRole('heading', { name: 'Accounts page' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Reset changes' }));
|
||||
await user.click(within(screen.getByRole('dialog', { name: 'Discard unsaved settings?' })).getByRole('button', { name: 'Discard changes' }));
|
||||
expect(screen.getByRole('spinbutton', { name: 'Recent window in minutes' })).toHaveValue(60);
|
||||
expect(screen.getByRole('spinbutton', { name: 'Maximum ready posts' })).toHaveValue(99);
|
||||
it('keeps former in-page tabs on the settings page', async () => {
|
||||
renderSettings('/settings?section=rate-limits');
|
||||
expect(await findCard('Pacing')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects a non-JSON session dump inline without sending a request', async () => {
|
||||
let importRequests = 0;
|
||||
server.use(
|
||||
http.get('/api/config', () => HttpResponse.json(config)),
|
||||
http.get('/api/login/status', () => HttpResponse.json({ ready: true, loggedIn: false, status: 'login-required' })),
|
||||
http.post('/api/login/session-import', () => {
|
||||
importRequests += 1;
|
||||
return HttpResponse.json({ ok: true });
|
||||
}),
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
renderSettings('/settings?section=access');
|
||||
describe('pacing', () => {
|
||||
it('reports the effective rate when the minimum delay is the binding limit', async () => {
|
||||
renderSettings();
|
||||
const pacing = await findCard('Pacing');
|
||||
|
||||
const brokenDump = new File(['definitely not json'], 'broken.json', { type: 'application/json' });
|
||||
await user.upload(await screen.findByLabelText('Reddit session export'), brokenDump);
|
||||
// 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');
|
||||
|
||||
expect(await screen.findByText(/isn't valid JSON/)).toBeInTheDocument();
|
||||
expect(importRequests).toBe(0);
|
||||
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 },
|
||||
});
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
it('waits for and imports an extension dump without exposing credentials', async () => {
|
||||
let statusRequests = 0;
|
||||
let importRequests = 0;
|
||||
server.use(
|
||||
http.get('/api/config', () => HttpResponse.json(config)),
|
||||
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', username: 'example-account' });
|
||||
}),
|
||||
http.get('/api/accounts', () => HttpResponse.json([{
|
||||
accountId: '@t2_bot:reddit.com', redditUsername: 'example-account', matrixUserId: '@t2_bot:reddit.com',
|
||||
isRunning: true, status: 'authenticated', createdAt: 1, updatedAt: 2, lastImportedAt: 2,
|
||||
}])),
|
||||
http.post('/api/login/session-import', async ({ request }) => {
|
||||
expect(request.credentials).toBe('same-origin');
|
||||
const body = await request.json() as { cookies: unknown[]; matrix: { accessToken: string } };
|
||||
expect(body.cookies).toHaveLength(1);
|
||||
expect(body.matrix.accessToken).toBe('matrix-secret');
|
||||
importRequests += 1;
|
||||
return HttpResponse.json({ ok: true, status: 'authenticated', account: {
|
||||
accountId: '@t2_bot:reddit.com', redditUsername: 'example-account', matrixUserId: '@t2_bot:reddit.com',
|
||||
isRunning: true, status: 'authenticated', createdAt: 1, updatedAt: 2, lastImportedAt: 2,
|
||||
} });
|
||||
}),
|
||||
);
|
||||
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('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();
|
||||
renderSettings('/settings?section=access');
|
||||
const { router } = renderSettings();
|
||||
const pacing = await findCard('Pacing');
|
||||
|
||||
expect(await screen.findByText('Login required')).toBeInTheDocument();
|
||||
expect(screen.getByText(/waiting for a fresh session dump/i)).toBeInTheDocument();
|
||||
expect(screen.queryByText(/noVNC/i)).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('textbox', { name: /password/i })).not.toBeInTheDocument();
|
||||
const jitter = within(pacing).getByRole('spinbutton', { name: 'Random jitter' });
|
||||
await user.clear(jitter);
|
||||
await user.type(jitter, '45');
|
||||
|
||||
const dump = new File([JSON.stringify({
|
||||
cookies: [{ name: 'reddit_session', value: 'secret', domain: '.reddit.com', path: '/' }],
|
||||
matrix: {
|
||||
homeserver: 'https://matrix.redditspace.com', accessToken: 'matrix-secret',
|
||||
userId: '@t2_bot:reddit.com', capturedAt: 1_700_000_000_000,
|
||||
},
|
||||
})], 'redditbot-session.json', { type: 'application/json' });
|
||||
await user.upload(screen.getByLabelText('Reddit session export'), dump);
|
||||
await act(() => router.navigate('/elsewhere'));
|
||||
expect(await screen.findByRole('heading', { name: 'Leave without saving?' })).toBeInTheDocument();
|
||||
expect(router.state.location.pathname).toBe('/settings');
|
||||
|
||||
expect(await screen.findByText('Reddit session imported and verified.')).toBeInTheDocument();
|
||||
expect(importRequests).toBe(1);
|
||||
expect(await screen.findByText('Authenticated')).toBeInTheDocument();
|
||||
expect(screen.getAllByText(/u\/example-account/)).toHaveLength(2);
|
||||
expect(screen.getAllByRole('link', { name: 'u/example-account' })[0]).toHaveAttribute(
|
||||
'href',
|
||||
'https://www.reddit.com/u/example-account',
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Recheck accounts' }));
|
||||
expect(statusRequests).toBeGreaterThanOrEqual(2);
|
||||
await user.click(screen.getByRole('button', { name: 'Discard and leave' }));
|
||||
await waitFor(() => expect(router.state.location.pathname).toBe('/elsewhere'));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,600 @@
|
||||
// @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 },
|
||||
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('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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user