The allowance was derived from the adaptive trust level with no way to change it, which made the dashboard's "1 / 2 today" unexplainable and left no way to slow an account down or push one along. `dailyLimit` is null by default, keeping today's adaptive behaviour. A whole number between 1 and 50 overrides it and is enforced verbatim in either direction — it can raise an account above what it has earned or hold it below. The trust level keeps being tracked underneath and is reported as `adaptiveAllowance`, so the earned figure stays visible next to the override, and clearing the field hands pacing back to the ladder. Raising the limit above the earned allowance does remove the adaptive backoff after a bad comment, so the dashboard says exactly that where the limit is set. It does not touch the pause on two consecutive removals, a comment the public cannot see, or a captcha challenge: those are safety stops rather than rate controls and no limit setting can disable them. Also correct two things the page was saying badly. The allowance is now labelled "used in last 24h" with the trust level shown as what it earns, because it is a rolling window and not a midnight reset. And a per-community cap above the account allowance now states how much of it is actually reachable — "1 of 10 today" beside an account limit of 2 read as far more headroom than existed, since caps distribute the allowance rather than adding to it. Verified against a local instance: override above and below the earned allowance, unrelated saves preserving it, clearing it through the UI, rejection of 0/51/2.5/non-numeric, and a limit failing to clear a pause. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
485 lines
16 KiB
TypeScript
485 lines
16 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 summary(overrides: Record<string, unknown> = {}) {
|
|
return {
|
|
accountId: ACCOUNT_ID,
|
|
redditUsername: 'grower',
|
|
settings: settings(),
|
|
stats: stats(),
|
|
subredditCount: 2,
|
|
activeSubredditCount: 1,
|
|
...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',
|
|
},
|
|
],
|
|
...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.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 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();
|
|
});
|
|
});
|