292 lines
13 KiB
TypeScript
292 lines
13 KiB
TypeScript
// @vitest-environment jsdom
|
|
|
|
import '@testing-library/jest-dom/vitest';
|
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
|
import { cleanup, render, screen, waitFor, within } from '@testing-library/react';
|
|
import userEvent from '@testing-library/user-event';
|
|
import { HttpResponse, http } from 'msw';
|
|
import { setupServer } from 'msw/node';
|
|
import { afterAll, afterEach, beforeAll, 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'],
|
|
};
|
|
|
|
const server = setupServer();
|
|
|
|
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: <SettingsRoute /> }],
|
|
{ initialEntries: [path] },
|
|
);
|
|
const view = render(
|
|
<QueryClientProvider client={queryClient}>
|
|
<RouterProvider router={router} />
|
|
</QueryClientProvider>,
|
|
);
|
|
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<Response>((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<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 } });
|
|
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('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('shows Reddit session and noVNC state without exposing credentials, then starts and rechecks login', async () => {
|
|
let statusRequests = 0;
|
|
let loginRequests = 0;
|
|
server.use(
|
|
http.get('/api/config', () => HttpResponse.json(config)),
|
|
http.get('/api/login/info', ({ request }) => {
|
|
expect(request.credentials).toBe('same-origin');
|
|
return HttpResponse.json({
|
|
vncEnabled: true,
|
|
novncPort: 6080,
|
|
publicUrl: 'https://login.example/vnc.html',
|
|
passwordRequired: true,
|
|
});
|
|
}),
|
|
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' });
|
|
}),
|
|
http.post('/api/login/start', ({ request }) => {
|
|
expect(request.credentials).toBe('same-origin');
|
|
loginRequests += 1;
|
|
return HttpResponse.json({ ok: true });
|
|
}),
|
|
);
|
|
const user = userEvent.setup();
|
|
renderSettings('/settings?section=access');
|
|
|
|
expect(await screen.findByText('Login required')).toBeInTheDocument();
|
|
expect(screen.getByRole('link', { name: 'Open noVNC login window' })).toHaveAttribute('href', 'https://login.example/vnc.html');
|
|
expect(screen.getByText('A VNC password is required.')).toBeInTheDocument();
|
|
expect(screen.queryByRole('textbox', { name: /password/i })).not.toBeInTheDocument();
|
|
|
|
await user.click(screen.getByRole('button', { name: 'Open Reddit login' }));
|
|
expect(await screen.findByText('Login page opened.')).toBeInTheDocument();
|
|
expect(loginRequests).toBe(1);
|
|
expect(await screen.findByText('Authenticated')).toBeInTheDocument();
|
|
|
|
await user.click(screen.getByRole('button', { name: 'Recheck session' }));
|
|
expect(statusRequests).toBeGreaterThanOrEqual(3);
|
|
});
|
|
});
|