The settings page had accumulated five tabs whose names no longer matched their contents, three different save models, and the whole subreddit configuration buried under a tab named after a checkbox. Structure: - Subreddits becomes its own page: a list showing each community's mode, tone, computed share of polling, and live sent/reply stats, with one subreddit edited at a time in a drawer. - Accounts becomes its own page. It is session lifecycle, not config. - Settings collapses to one scrolling page: Safety, Pacing (Monitoring and Rate limits merged), Targeting, Notifications, Advanced. - The sidebar is grouped, having gone from 9 to 11 destinations. Saving: - Every section owns its own draft, validation, risk gate and save via useConfigCard, submitting only the keys it owns. A syntax error in the advanced JSON can no longer block a rate-limit change, and enabling an autonomous policy is its own deliberate commit. - UnsavedChangesProvider restores the leave-page guard across cards that no longer share a draft. Subreddit editing: - Errors are keyed by field name instead of array index, removing the defensive invalidation that existed because row indices shifted on add and remove. Confirming an autonomous mode can no longer be attributed to the wrong row. - Weight is shown as the share it actually produces, live while editing. - Bulk actions cover start/pause polling, tone, policy and removal, because production runs 30-40 subreddits and per-row editing does not scale. Bulk policy changes name every subreddit turning autonomous, and normalise the hybrid mode's turn budget, which has no form to catch it. Physique targeting moves into its own Targeting card. It sends only reddit.physiqueTargeting, which the config route applies independently, so it cannot clobber the pacing card's fields. Turning screening on now warns that it will reduce how many posts qualify. Also surfaces the effective sending rate, since the hourly limit and the minimum delay both throttle and only the binding one matters. Legacy ?section= links redirect or resolve to anchors. Fixes found while verifying in a browser: a radio-card rule was shrinking nested number inputs; .share-preview had no styles; the throughput readout reported a rate from a blank field; and the reconcile logic warned about a server change whenever a sibling card saved. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
185 lines
7.2 KiB
TypeScript
185 lines
7.2 KiB
TypeScript
// @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();
|
|
});
|
|
});
|