New accounts cannot start chats until Reddit considers them established. Until now the bot would burn a candidate on every invitation Reddit was always going to reject, with nothing in the dashboard explaining why the account looked idle. Two signals feed the verdict. A periodic profile check reads the account's own age, karma and email verification from about.json and compares them against configurable thresholds — that is what flags a brand new account before it wastes anything. A rejected invitation is authoritative: Reddit has told us directly, so the account is held out of sending with a backoff that doubles per rejection, and a delivered invitation clears the flag outright. An unestablished account takes no new posts (canAcceptPost), raises an attention item, reports an `unestablished` sending state, and shows its age/karma evidence on the Accounts page. An unchecked account is always allowed through, so a first run is never blocked. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
249 lines
9.8 KiB
TypeScript
249 lines
9.8 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 establishment = {
|
|
state: 'unestablished' as const,
|
|
reasons: ['Account is 2 days old (needs 7 days)', 'Total karma is 4 (needs 20)'],
|
|
accountAgeDays: 2,
|
|
linkKarma: 1,
|
|
commentKarma: 3,
|
|
totalKarma: 4,
|
|
hasVerifiedEmail: false,
|
|
profileCheckedAt: 1_700_000_000_000,
|
|
lastProbeAt: null,
|
|
lastProbeFailure: null,
|
|
retryAt: Date.now() + 4 * 60 * 60_000,
|
|
};
|
|
|
|
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('explains why an unestablished account is not sending, with the profile evidence', async () => {
|
|
server.use(http.get('/api/accounts', () => HttpResponse.json([{ ...account, establishment }])));
|
|
renderAccounts();
|
|
|
|
expect(await screen.findByText('u/example-account is not established yet.')).toBeInTheDocument();
|
|
expect(screen.getByText(/Reddit will not accept chat invitations from a new account/)).toBeInTheDocument();
|
|
expect(screen.getByText('Not established')).toBeInTheDocument();
|
|
expect(screen.getByText(
|
|
'Account is 2 days old (needs 7 days); Total karma is 4 (needs 20)',
|
|
)).toBeInTheDocument();
|
|
expect(screen.getByText(/Next re-test in about 4 hours/)).toBeInTheDocument();
|
|
|
|
const metrics = screen.getByText('Comment karma').closest('dl')!;
|
|
expect(within(metrics).getByText('2 days')).toBeInTheDocument();
|
|
expect(within(metrics).getByText('3')).toBeInTheDocument();
|
|
expect(within(metrics).getByText('No')).toBeInTheDocument();
|
|
});
|
|
|
|
it('marks an account that has cleared the thresholds as established', async () => {
|
|
server.use(http.get('/api/accounts', () => HttpResponse.json([{
|
|
...account,
|
|
establishment: {
|
|
...establishment,
|
|
state: 'established',
|
|
reasons: [],
|
|
accountAgeDays: 400,
|
|
commentKarma: 900,
|
|
totalKarma: 1_200,
|
|
hasVerifiedEmail: true,
|
|
retryAt: null,
|
|
},
|
|
}])));
|
|
renderAccounts();
|
|
|
|
expect(await screen.findByText('Established')).toBeInTheDocument();
|
|
expect(screen.queryByText(/not established yet/)).not.toBeInTheDocument();
|
|
expect(screen.queryByText(/Outbound sending is on hold/)).not.toBeInTheDocument();
|
|
expect(screen.getByText('1,200')).toBeInTheDocument();
|
|
});
|
|
|
|
it('shows no establishment detail before the first check has run', async () => {
|
|
server.use(http.get('/api/accounts', () => HttpResponse.json([{ ...account, establishment: null }])));
|
|
renderAccounts();
|
|
|
|
expect(await screen.findByRole('link', { name: 'u/example-account' })).toBeInTheDocument();
|
|
expect(screen.queryByText('Not established')).not.toBeInTheDocument();
|
|
expect(screen.queryByText('Established')).not.toBeInTheDocument();
|
|
expect(screen.queryByText('Comment karma')).not.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();
|
|
});
|
|
});
|