// @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, 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 summary(overrides: Record = {}) { return { accountId: ACCOUNT_ID, redditUsername: 'grower', settings: settings(), stats: stats(), subredditCount: 2, activeSubredditCount: 1, ...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', }, ], ...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.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 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 today')).toBeVisible(); expect(screen.getByText('1 of 4')).toBeVisible(); // 6 posted less 1 removed. expect(screen.getByText('5')).toBeVisible(); expect(screen.getByText('4')).toBeVisible(); }); it('explains that the allowance is adaptive rather than fixed', async () => { renderGrowth(); expect(await screen.findByText(/allowance rises on its own/i)).toBeVisible(); expect(screen.getByText(/Two removals in a row/i)).toBeVisible(); }); 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(); }); });