Reddit answers a throttled client with a large HTML 403 interstitial. To the comment client that was indistinguishable from a logged-out session: the body would not parse, so no modhash was found, so it reported "Reddit did not return a modhash for this session" and returned session-invalid — which calls auth.invalidate() and so could disrupt outreach and chat for an account whose session was perfectly healthy. That matches what prod is doing. u/Saira_Makimad posted successfully 25 seconds after boot and every later attempt failed with that message, which is the shape of Reddit starting to throttle rather than a session going bad. A response is now only treated as a verdict about the session when it is actually JSON. Non-JSON, at any status, is Reddit refusing to serve us and stays retryable. A genuine JSON 403, or JSON with no modhash, still invalidates as before. When Reddit does report a signed-out session, the log and the recorded reason now name the cookies visible to the write origin — names and domains only, never values. Cookies scoped to www.reddit.com rather than .reddit.com would produce exactly this symptom, and this makes that answerable from the logs instead of by guesswork. Also clarify the allowance in the dashboard. It is derived from the trust level rather than typed in, and it is a rolling 24-hour window, neither of which the page said. A per-community cap above the account allowance now says how much of it is actually reachable, because "1 of 10 today" next to an account limit of 2 reads as far more headroom than exists. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
415 lines
14 KiB
TypeScript
415 lines
14 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,
|
|
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')).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();
|
|
});
|
|
|
|
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();
|
|
});
|
|
});
|