// @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, describe, expect, it, vi } from 'vitest';
import { createMemoryRouter, RouterProvider } from 'react-router-dom';
import { SettingsRoute } from '../../apps/dashboard/src/features/settings/settings-route.js';
const config = {
rateLimit: { minDelayBetweenMessagesMs: 300_000, maxMessagesPerHour: 8, jitterMs: 60_000 },
browser: { dryRun: true },
reddit: { prioritizeRecentPosts: false, recentWindowMinutes: 60 },
subreddits: [{ name: 'casualconversation', type: 'chat', weight: 2, tone: 'default' }],
tones: ['default', 'dominant', 'submissive', 'playful', 'direct', 'gentle', 'kink-friendly', 'teasing'],
advanced: { openrouter: { chatModel: 'example/chat-model' } },
};
const server = setupServer(http.get('/api/accounts', () => HttpResponse.json([])));
function installConfigApi() {
server.use(http.get('/api/config', () => HttpResponse.json(config)));
}
function renderSettings(path = '/settings') {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
const router = createMemoryRouter(
[{ path: '/settings', element: }],
{ initialEntries: [path] },
);
const view = render(
,
);
return { ...view, queryClient, router };
}
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
afterEach(() => {
cleanup();
server.resetHandlers();
vi.restoreAllMocks();
});
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((resolve) => { resolveConfig = resolve; });
}),
);
const user = userEvent.setup();
const { router } = renderSettings('/settings?section=rate-limits');
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');
});
it('shows a recoverable initial configuration error', async () => {
let requests = 0;
server.use(
http.get('/api/config', () => {
requests += 1;
return requests === 1
? HttpResponse.json({ error: 'Configuration unavailable' }, { status: 503 })
: 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();
});
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(updateRequests).toBe(0);
await user.click(screen.getByRole('button', { name: 'Reset 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((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 } });
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('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('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,
} });
}),
);
const user = userEvent.setup();
renderSettings('/settings?section=access');
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 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);
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);
});
});