// @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 = {}) { 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 = {}) { 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 = {}) { return { accountId: ACCOUNT_ID, redditUsername: 'grower', settings: settings(), stats: stats(), subredditCount: 2, activeSubredditCount: 1, browsing: browsing(), ...overrides, }; } function detail(overrides: Record = {}) { 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[] = []; let detailPayload: Record = 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; requests.push({ method: 'PATCH', url: request.url, body }); const currentBrowsing = detailPayload.browsing as ReturnType; 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( , ); } 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('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(); }); });