The profile check was allowed to set state 'unestablished', which made isSendable() false, which made canAcceptPost() false — so a flagged account never attempted an invitation and the functional probe could never run. The one signal with real authority was gated behind the one that is guessing, and because each check pushed retryAt forward by another interval, accounts that send perfectly well were held indefinitely. Reddit does not publish the thresholds its chat gate applies, so ours are a guess and are now advisory only. Sending is held solely by a live rejection, tracked as sendingHeld on the account summary and keyed off by the attention feed, the sending state and the Accounts page. Reason strings now say "below the configured floor of N" rather than "needs N", and the Accounts page distinguishes "Sending on hold" from "Below thresholds", stating outright that the latter is our own number and the account is still sending. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
268 lines
11 KiB
TypeScript
268 lines
11 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,
|
|
sendingHeld: true,
|
|
reasons: ['Account is 2 days old (below the configured floor of 7 days)'],
|
|
accountAgeDays: 2,
|
|
linkKarma: 1,
|
|
commentKarma: 3,
|
|
totalKarma: 4,
|
|
hasVerifiedEmail: false,
|
|
profileCheckedAt: 1_700_000_000_000,
|
|
lastProbeAt: 1_700_000_000_000,
|
|
lastProbeFailure: 'Reddit rejected an invitation from this account: account is too new (rate.invitation_limit_new_account)',
|
|
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 a held account with what Reddit actually said, plus the profile evidence', async () => {
|
|
server.use(http.get('/api/accounts', () => HttpResponse.json([{ ...account, establishment }])));
|
|
renderAccounts();
|
|
|
|
expect(await screen.findByText('u/example-account cannot start chats yet.')).toBeInTheDocument();
|
|
expect(screen.getByText(/Reddit rejected an invitation from a new account/)).toBeInTheDocument();
|
|
expect(screen.getByText('Sending on hold')).toBeInTheDocument();
|
|
expect(screen.getByText(/rate\.invitation_limit_new_account/)).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('does not claim an account is blocked when it is only below our own thresholds', async () => {
|
|
server.use(http.get('/api/accounts', () => HttpResponse.json([{
|
|
...account,
|
|
establishment: { ...establishment, sendingHeld: false, lastProbeFailure: null, retryAt: null },
|
|
}])));
|
|
renderAccounts();
|
|
|
|
expect(await screen.findByText('Below thresholds')).toBeInTheDocument();
|
|
expect(screen.getByText(/Still sending normally/)).toBeInTheDocument();
|
|
expect(screen.getByText(/not limits Reddit\s+publishes/)).toBeInTheDocument();
|
|
expect(screen.getByText('Account is 2 days old (below the configured floor of 7 days)')).toBeInTheDocument();
|
|
// Nothing may imply sending has stopped.
|
|
expect(screen.queryByText(/cannot start chats yet/)).not.toBeInTheDocument();
|
|
expect(screen.queryByText(/Outbound sending is on hold/)).not.toBeInTheDocument();
|
|
expect(screen.queryByText('Sending on hold')).not.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',
|
|
sendingHeld: false,
|
|
lastProbeFailure: null,
|
|
reasons: [],
|
|
accountAgeDays: 400,
|
|
commentKarma: 900,
|
|
totalKarma: 1_200,
|
|
hasVerifiedEmail: true,
|
|
retryAt: null,
|
|
},
|
|
}])));
|
|
renderAccounts();
|
|
|
|
expect(await screen.findByText('Established')).toBeInTheDocument();
|
|
expect(screen.queryByText(/cannot start chats 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('Sending on hold')).not.toBeInTheDocument();
|
|
expect(screen.queryByText('Below thresholds')).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();
|
|
});
|
|
});
|