Files
RedditBot/tests/dashboard/growth.test.tsx
T

617 lines
21 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, beforeEach, describe, expect, it } from 'vitest';
import { GrowthPage } from '../../apps/dashboard/src/features/growth/growth-page.js';
const ACCOUNT_ID = '@t2_grower:reddit.com';
function settings(overrides: Record<string, unknown> = {}) {
return {
enabled: true,
trustLevel: 1,
dailyAllowance: 3,
adaptiveAllowance: 3,
dailyLimit: null,
minDelayMs: 3_600_000,
jitterMs: 900_000,
quietHours: { enabled: true, startHour: 23, endHour: 7 },
allowNsfw: false,
pausedReason: null,
pausedAt: null,
consecutiveRemovals: 0,
levelChangedAt: null,
updatedAt: 1_700_000_000_000,
...overrides,
};
}
function stats(overrides: Record<string, unknown> = {}) {
return {
postedLast24h: 1,
postedTotal: 6,
removedTotal: 1,
medianScore: 4,
queued: 2,
rejectedTotal: 3,
...overrides,
};
}
function browsing(includeRecent = false) {
return {
settings: {
enabled: false,
dailyTargetMin: 3,
dailyTargetMax: 6,
postWeight: 60,
commentWeight: 40,
quietHours: { enabled: true, startHour: 23, endHour: 7 },
minimumGapMs: 2_700_000,
jitterMs: 900_000,
selectionsPerSession: 1,
allowNsfw: false,
autoVoteEnabled: false,
nextDueAt: null,
updatedAt: 1_700_000_000_000,
},
todayTarget: 4,
stats: { selected: 0, posts: 0, comments: 0, rejected: 0, skipped: 0 },
noVotesSubmitted: true,
...(includeRecent ? { recentDecisions: [] } : {}),
};
}
function summary(overrides: Record<string, unknown> = {}) {
return {
accountId: ACCOUNT_ID,
redditUsername: 'grower',
settings: settings(),
stats: stats(),
subredditCount: 2,
activeSubredditCount: 1,
browsing: browsing(),
...overrides,
};
}
function detail(overrides: Record<string, unknown> = {}) {
return {
...summary(),
subreddits: [
{
subreddit: 'AskUK',
enabled: true,
weight: 1,
dailyCap: 2,
disabledReason: null,
cooldownUntil: null,
addedAt: 1,
postedLast24h: 1,
},
{
subreddit: 'cats',
enabled: false,
weight: 1,
dailyCap: 3,
disabledReason: 'banned (r/cats)',
cooldownUntil: null,
addedAt: 2,
postedLast24h: 0,
},
],
recentComments: [
{
postId: 'p1',
subreddit: 'AskUK',
status: 'posted',
threadTitle: 'Best way to get to Leeds',
threadUrl: 'https://www.reddit.com/r/AskUK/comments/p1/',
comment: 'the train is usually quicker than the coach at that time of day',
commentPermalink: '/r/AskUK/comments/p1/_/c1/',
score: 7,
removed: false,
shadowBanned: null,
postedAt: 1_700_000_000_000,
discoveredAt: 1_699_000_000_000,
lastError: null,
},
{
postId: 'p2',
subreddit: 'AskUK',
status: 'rejected',
threadTitle: 'A thread that got discarded',
threadUrl: 'https://www.reddit.com/r/AskUK/comments/p2/',
comment: 'check out my profile for more',
commentPermalink: null,
score: null,
removed: false,
shadowBanned: null,
postedAt: null,
discoveredAt: 1_699_000_001_000,
lastError: 'Comment solicits contact or promotes a profile',
},
],
browsing: browsing(true),
...overrides,
};
}
let overviewPayload: Record<string, unknown>[] = [];
let detailPayload: Record<string, unknown> = detail();
const requests: Array<{ method: string; url: string; body: unknown }> = [];
const server = setupServer(
http.get('/api/growth', () => HttpResponse.json({
generatedAt: Date.now(),
accounts: overviewPayload,
})),
http.get('/api/growth/:accountId', () => HttpResponse.json({
success: true,
account: detailPayload,
})),
http.patch('/api/growth/:accountId', async ({ request }) => {
requests.push({ method: 'PATCH', url: request.url, body: await request.json() });
return HttpResponse.json({ success: true, account: detailPayload });
}),
http.patch('/api/growth/:accountId/browsing', async ({ request }) => {
const body = await request.json() as Record<string, unknown>;
requests.push({ method: 'PATCH', url: request.url, body });
const currentBrowsing = detailPayload.browsing as ReturnType<typeof browsing>;
detailPayload = detail({
browsing: {
...currentBrowsing,
settings: { ...currentBrowsing.settings, ...body },
},
});
return HttpResponse.json({ success: true, account: detailPayload });
}),
http.post('/api/growth/:accountId/browsing/run', ({ request }) => {
requests.push({ method: 'POST', url: request.url, body: null });
return HttpResponse.json({ success: true, account: detailPayload });
}),
http.post('/api/growth/:accountId/pause', async ({ request }) => {
requests.push({ method: 'POST', url: request.url, body: await request.json() });
return HttpResponse.json({ success: true, account: detailPayload });
}),
http.post('/api/growth/:accountId/resume', ({ request }) => {
requests.push({ method: 'POST', url: request.url, body: null });
return HttpResponse.json({ success: true, account: detailPayload });
}),
http.post('/api/growth/:accountId/subreddits', async ({ request }) => {
requests.push({ method: 'POST', url: request.url, body: await request.json() });
return HttpResponse.json({ success: true, account: detailPayload });
}),
http.patch('/api/growth/:accountId/subreddits/:subreddit', async ({ request }) => {
requests.push({ method: 'PATCH', url: request.url, body: await request.json() });
return HttpResponse.json({ success: true, account: detailPayload });
}),
http.delete('/api/growth/:accountId/subreddits/:subreddit', ({ request }) => {
requests.push({ method: 'DELETE', url: request.url, body: null });
return HttpResponse.json({ success: true, account: detailPayload });
}),
);
function renderGrowth() {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
return render(
<QueryClientProvider client={queryClient}>
<GrowthPage />
</QueryClientProvider>,
);
}
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
afterEach(() => {
server.resetHandlers();
cleanup();
});
afterAll(() => server.close());
beforeEach(() => {
overviewPayload = [summary()];
detailPayload = detail();
requests.length = 0;
});
describe('GrowthPage', () => {
it('says what the feature does, including that comments are unreviewed', async () => {
renderGrowth();
expect(await screen.findByRole('heading', { level: 1, name: 'Account growth' })).toBeVisible();
expect(screen.getByText(/posted without review/i)).toBeVisible();
});
it('shows browsing sessions and can enable automatic upvotes', async () => {
renderGrowth();
const user = userEvent.setup();
expect(await screen.findByRole('heading', { name: 'Browsing sessions' })).toBeVisible();
expect(screen.getByText('No Reddit votes will be submitted.')).toBeVisible();
expect(screen.getByText('0 / 4')).toBeVisible();
await user.click(screen.getByLabelText('Run browsing sessions'));
await waitFor(() => expect(requests).toContainEqual(expect.objectContaining({
method: 'PATCH', body: { enabled: true },
})));
await waitFor(() => expect(screen.getByRole('button', { name: 'Run session now' })).toBeEnabled());
expect(requests[0].url).toContain('/api/growth/%40t2_grower%3Areddit.com/browsing');
await user.click(screen.getByLabelText('Submit selected upvotes'));
await waitFor(() => expect(requests).toContainEqual(expect.objectContaining({
method: 'PATCH', body: { autoVoteEnabled: true },
})));
});
it('runs an enabled browsing session immediately', async () => {
const currentBrowsing = browsing(true);
detailPayload = detail({
browsing: {
...currentBrowsing,
settings: { ...currentBrowsing.settings, enabled: true },
},
});
renderGrowth();
const user = userEvent.setup();
await user.click(await screen.findByRole('button', { name: 'Run session now' }));
await waitFor(() => expect(requests).toContainEqual(expect.objectContaining({
method: 'POST', body: null,
})));
});
it('updates browsing session pacing and size', async () => {
renderGrowth();
const user = userEvent.setup();
const gap = await screen.findByLabelText('Minimum session gap');
await user.clear(gap);
await user.type(gap, '90');
await user.click(screen.getByRole('button', { name: 'Save session gap' }));
const size = screen.getByLabelText('Selections per session');
await user.clear(size);
await user.type(size, '4');
await user.click(screen.getByRole('button', { name: 'Save session size' }));
await waitFor(() => expect(requests).toEqual(expect.arrayContaining([
expect.objectContaining({ method: 'PATCH', body: { minimumGapMs: 5_400_000 } }),
expect.objectContaining({ method: 'PATCH', body: { selectionsPerSession: 4 } }),
])));
});
it('updates the browsing daily target range together', async () => {
renderGrowth();
const user = userEvent.setup();
const minimum = await screen.findByLabelText('Daily minimum');
await user.clear(minimum);
await user.type(minimum, '6');
const maximum = screen.getByLabelText('Daily maximum');
await user.clear(maximum);
await user.type(maximum, '9');
await user.click(screen.getByRole('button', { name: 'Save daily range' }));
await waitFor(() => expect(requests).toContainEqual(expect.objectContaining({
method: 'PATCH', body: { dailyTargetMin: 6, dailyTargetMax: 9 },
})));
});
it('rejects an inverted browsing daily target range in the dashboard', async () => {
renderGrowth();
const user = userEvent.setup();
const minimum = await screen.findByLabelText('Daily minimum');
await user.clear(minimum);
await user.type(minimum, '8');
const maximum = screen.getByLabelText('Daily maximum');
await user.clear(maximum);
await user.type(maximum, '4');
expect(screen.getByRole('button', { name: 'Save daily range' })).toBeDisabled();
expect(screen.getAllByText(/minimum no greater than the maximum/i)).toHaveLength(2);
});
it('shows an empty state when there are no accounts', async () => {
overviewPayload = [];
renderGrowth();
expect(await screen.findByText('No Reddit accounts yet')).toBeVisible();
});
it('surfaces the allowance, trust level and comment health', async () => {
renderGrowth();
expect(await screen.findByText('1 / 3')).toBeVisible();
expect(screen.getByText('Used in last 24h')).toBeVisible();
expect(screen.getByText('1 of 4 → 3/day earned')).toBeVisible();
// 6 posted less 1 removed.
expect(screen.getByText('5')).toBeVisible();
expect(screen.getByText('4')).toBeVisible();
});
it('explains where the allowance comes from and that it is a rolling window', async () => {
renderGrowth();
// The number is derived from the trust level, not typed in by the operator,
// which is the thing most likely to confuse someone reading the page.
expect(await screen.findByText(/level 0 permits 2 comments a day/i)).toBeVisible();
expect(screen.getByText(/rolling 24-hour window rather than a midnight reset/i)).toBeVisible();
expect(screen.getByText(/Two removals in a row/i)).toBeVisible();
});
describe('daily limit', () => {
it('shows the allowance as automatic until an operator overrides it', async () => {
renderGrowth();
expect(await screen.findByText('Automatic — 3/day')).toBeVisible();
expect(screen.getByText('1 of 4 → 3/day earned')).toBeVisible();
});
it('sets a limit through the API', async () => {
renderGrowth();
const user = userEvent.setup();
const field = await screen.findByLabelText('Daily limit');
await user.clear(field);
await user.type(field, '9');
await user.click(screen.getByRole('button', { name: 'Save limit' }));
await waitFor(() => expect(requests).toHaveLength(1));
expect(requests[0]).toMatchObject({ method: 'PATCH', body: { dailyLimit: 9 } });
});
it('clearing the field hands the allowance back to the ladder', async () => {
detailPayload = detail({ settings: settings({ dailyLimit: 9, dailyAllowance: 9 }) });
renderGrowth();
const user = userEvent.setup();
await user.clear(await screen.findByLabelText('Daily limit'));
await user.click(screen.getByRole('button', { name: 'Save limit' }));
await waitFor(() => expect(requests).toHaveLength(1));
expect(requests[0]).toMatchObject({ body: { dailyLimit: null } });
});
it('does not submit an out-of-range limit', async () => {
renderGrowth();
const user = userEvent.setup();
const field = await screen.findByLabelText('Daily limit');
await user.clear(field);
await user.type(field, '99');
await user.click(screen.getByRole('button', { name: 'Save limit' }));
expect(requests).toHaveLength(0);
});
it('shows an override and warns when it exceeds what was earned', async () => {
detailPayload = detail({
settings: settings({ trustLevel: 0, dailyLimit: 12, dailyAllowance: 12, adaptiveAllowance: 2 }),
});
renderGrowth();
expect(await screen.findByText('Set to 12/day')).toBeVisible();
expect(screen.getByText(/above the 2\/day this account has earned/i)).toBeVisible();
// The safety stop is not something a limit can switch off.
expect(screen.getByText(/pause on two removals in a row.*still applies/i)).toBeVisible();
});
it('does not warn when the override sits within what was earned', async () => {
detailPayload = detail({
settings: settings({ trustLevel: 4, dailyLimit: 3, dailyAllowance: 3, adaptiveAllowance: 12 }),
});
renderGrowth();
expect(await screen.findByText('Set to 3/day')).toBeVisible();
expect(screen.queryByText(/this account has earned/i)).not.toBeInTheDocument();
});
});
it('warns when a community cap exceeds what the account allowance permits', async () => {
detailPayload = detail({
settings: settings({ trustLevel: 0, dailyAllowance: 2 }),
subreddits: [{
subreddit: 'AskUK',
enabled: true,
weight: 1,
dailyCap: 10,
disabledReason: null,
cooldownUntil: null,
addedAt: 1,
postedLast24h: 1,
}],
});
renderGrowth();
// A cap of 10 with an allowance of 2 reads as "10 available" otherwise.
expect(await screen.findByText(/Only 2 of these can actually be used today/i)).toBeVisible();
});
it('does not warn when the cap sits within the allowance', async () => {
detailPayload = detail({
settings: settings({ dailyAllowance: 5 }),
subreddits: [{
subreddit: 'AskUK',
enabled: true,
weight: 1,
dailyCap: 2,
disabledReason: null,
cooldownUntil: null,
addedAt: 1,
postedLast24h: 0,
}],
});
renderGrowth();
expect(await screen.findByText('r/AskUK')).toBeVisible();
expect(screen.queryByText(/can actually be used today/i)).not.toBeInTheDocument();
});
it('toggles growth off through the API', async () => {
renderGrowth();
const user = userEvent.setup();
await user.click(await screen.findByLabelText('Grow this account'));
await waitFor(() => expect(requests).toHaveLength(1));
expect(requests[0]).toMatchObject({ method: 'PATCH', body: { enabled: false } });
});
it('encodes the Matrix-shaped account ID in every request path', async () => {
renderGrowth();
const user = userEvent.setup();
await user.click(await screen.findByLabelText('Grow this account'));
await waitFor(() => expect(requests).toHaveLength(1));
expect(requests[0].url).toContain(encodeURIComponent(ACCOUNT_ID));
expect(requests[0].url).not.toContain('@t2_grower:reddit.com');
});
it('adds a community', async () => {
renderGrowth();
const user = userEvent.setup();
await user.type(await screen.findByLabelText('Add a community'), 'CasualUK');
await user.click(screen.getByRole('button', { name: 'Add community' }));
await waitFor(() => expect(requests).toHaveLength(1));
expect(requests[0]).toMatchObject({ method: 'POST', body: { subreddit: 'CasualUK' } });
});
it('does not submit an empty community name', async () => {
renderGrowth();
const user = userEvent.setup();
await user.click(await screen.findByRole('button', { name: 'Add community' }));
expect(requests).toHaveLength(0);
});
it('toggles and removes a community', async () => {
renderGrowth();
const user = userEvent.setup();
const rows = await screen.findAllByText(/^r\//);
const askUk = rows[0].closest('.growth-subreddit-row') as HTMLElement;
await user.click(within(askUk).getByRole('button', { name: 'Disable' }));
await waitFor(() => expect(requests).toHaveLength(1));
expect(requests[0]).toMatchObject({ method: 'PATCH', body: { enabled: false } });
await user.click(within(askUk).getByRole('button', { name: 'Remove' }));
await waitFor(() => expect(requests).toHaveLength(2));
expect(requests[1].method).toBe('DELETE');
});
it('shows why Reddit refused a community', async () => {
renderGrowth();
expect(await screen.findByText(/Reddit refused this account here: banned \(r\/cats\)/))
.toBeVisible();
});
it('saves a changed daily cap on blur, and not before', async () => {
renderGrowth();
const user = userEvent.setup();
const capInput = await screen.findByLabelText('Daily cap for r/AskUK');
await user.clear(capInput);
await user.type(capInput, '4');
expect(requests).toHaveLength(0);
await user.tab();
await waitFor(() => expect(requests).toHaveLength(1));
expect(requests[0]).toMatchObject({ method: 'PATCH', body: { dailyCap: 4 } });
});
it('shows the posted comment text as the audit record', async () => {
renderGrowth();
expect(await screen.findByText('the train is usually quicker than the coach at that time of day'))
.toBeVisible();
expect(screen.getByText('7 points')).toBeVisible();
expect(screen.getByRole('link', { name: 'View on Reddit' }))
.toHaveAttribute('href', 'https://www.reddit.com/r/AskUK/comments/p1/_/c1/');
});
it('shows a discarded comment and the reason it was refused', async () => {
renderGrowth();
expect(await screen.findByText('Discarded')).toBeVisible();
expect(screen.getByText('Comment solicits contact or promotes a profile')).toBeVisible();
expect(screen.getByText('check out my profile for more')).toBeVisible();
});
describe('when the account is paused', () => {
beforeEach(() => {
const paused = settings({
pausedReason: 'A posted comment is not visible to signed-out visitors',
pausedAt: 1_700_000_000_000,
});
overviewPayload = [summary({ settings: paused })];
detailPayload = detail({ settings: paused });
});
it('warns prominently and tells the operator to review before resuming', async () => {
renderGrowth();
expect(await screen.findByText('Growth is paused for this account.')).toBeVisible();
expect(screen.getByText(/not visible to signed-out visitors/)).toBeVisible();
expect(screen.getByText(/needs a rest, not another attempt/i)).toBeVisible();
expect(screen.getByText(/Growth is paused for u\/grower\./)).toBeVisible();
});
it('offers resume instead of pause, and only an operator can clear it', async () => {
renderGrowth();
const user = userEvent.setup();
const resume = await screen.findByRole('button', { name: 'Resume growth' });
expect(screen.queryByRole('button', { name: 'Pause growth' })).not.toBeInTheDocument();
await user.click(resume);
await waitFor(() => expect(requests).toHaveLength(1));
expect(requests[0].url).toContain('/resume');
});
});
it('pauses on demand', async () => {
renderGrowth();
const user = userEvent.setup();
await user.click(await screen.findByRole('button', { name: 'Pause growth' }));
await waitFor(() => expect(requests).toHaveLength(1));
expect(requests[0].url).toContain('/pause');
});
it('reports a failed load', async () => {
server.use(http.get('/api/growth', () => HttpResponse.json({ error: 'nope' }, { status: 500 })));
renderGrowth();
expect(await screen.findByText('Could not load account growth')).toBeVisible();
});
it('surfaces a rejected settings change', async () => {
server.use(http.patch('/api/growth/:accountId', () => HttpResponse.json(
{ error: 'Invalid growth settings' },
{ status: 400 },
)));
renderGrowth();
const user = userEvent.setup();
await user.click(await screen.findByLabelText('Grow this account'));
expect(await screen.findByText('Invalid growth settings')).toBeVisible();
});
});