// @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: }], {
initialEntries: ['/accounts'],
});
return render(
,
);
}
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();
});
});