import { beforeEach, describe, expect, it, vi } from 'vitest'; import { RedditCommentClient, createBrowserWriteTransport, parseRetryAfterMs, type HttpTextResponse, type RedditWriteTransport, } from '../src/reddit/comment-client.js'; import { createMockLogger } from './mockLogger.js'; const MODHASH_OK: HttpTextResponse = { status: 200, body: JSON.stringify({ data: { modhash: 'abc123', name: 'grower' } }), }; function errorBody(code: string, message = ''): HttpTextResponse { return { status: 200, body: JSON.stringify({ json: { errors: [[code, message, 'field']], data: {} } }), }; } function successBody(name = 't1_new', permalink: string | null = '/r/cats/comments/a/_/new/'): HttpTextResponse { return { status: 200, body: JSON.stringify({ json: { errors: [], data: { things: [{ kind: 't1', data: { id: name.replace(/^t1_/, ''), name, permalink } }] }, }, }), }; } interface FakeTransport extends RedditWriteTransport { posts: Array<{ url: string; form: Record; headers?: Record }>; gets: string[]; } function fakeTransport(responses: { get?: (url: string) => HttpTextResponse | Promise; post?: (url: string, form: Record) => HttpTextResponse | Promise; }): FakeTransport { const posts: FakeTransport['posts'] = []; const gets: string[] = []; return { posts, gets, get: async (url) => { gets.push(url); return responses.get ? await responses.get(url) : MODHASH_OK; }, postForm: async (url, form, headers) => { posts.push({ url, form, headers }); if (!responses.post) throw new Error('unexpected post'); return await responses.post(url, form); }, }; } describe('parseRetryAfterMs', () => { it('reads the wait out of Reddit’s prose', () => { expect(parseRetryAfterMs('You are doing that too much. try again in 9 minutes.')).toBe(540_000); expect(parseRetryAfterMs('try again in 32 seconds.')).toBe(32_000); expect(parseRetryAfterMs('try again in 1 minute.')).toBe(60_000); expect(parseRetryAfterMs('try again in 2 hours.')).toBe(7_200_000); expect(parseRetryAfterMs('try again in 1.5 minutes')).toBe(90_000); }); it('returns null when there is no parseable wait', () => { expect(parseRetryAfterMs('slow down')).toBeNull(); expect(parseRetryAfterMs('')).toBeNull(); }); }); describe('RedditCommentClient.postComment', () => { let logger: ReturnType; beforeEach(() => { logger = createMockLogger(); }); it('posts a comment and returns its identity', async () => { const transport = fakeTransport({ post: () => successBody('t1_xyz', '/r/cats/x/') }); const client = new RedditCommentClient(transport, logger); const result = await client.postComment('t3_thread', 'cats', 'Mine does this too.'); expect(result).toEqual({ status: 'posted', fullname: 't1_xyz', permalink: '/r/cats/x/' }); expect(transport.posts[0].url).toBe('https://old.reddit.com/api/comment'); expect(transport.posts[0].form).toEqual({ thing_id: 't3_thread', text: 'Mine does this too.', api_type: 'json', uh: 'abc123', }); expect(transport.posts[0].headers).toMatchObject({ 'X-Modhash': 'abc123' }); }); it('derives the fullname when Reddit returns only a bare id', async () => { const transport = fakeTransport({ post: () => ({ status: 200, body: JSON.stringify({ json: { errors: [], data: { things: [{ data: { id: 'bare' } }] } } }), }), }); const client = new RedditCommentClient(transport, logger); const result = await client.postComment('t3_thread', 'cats', 'text'); expect(result).toEqual({ status: 'posted', fullname: 't1_bare', permalink: null }); }); it('reuses one modhash across comments', async () => { const transport = fakeTransport({ post: () => successBody() }); const client = new RedditCommentClient(transport, logger); await client.postComment('t3_a', 'cats', 'one'); await client.postComment('t3_b', 'cats', 'two'); expect(transport.gets.filter((url) => url.includes('me.json'))).toHaveLength(1); }); it('refuses a malformed thread id and an empty comment without calling Reddit', async () => { const transport = fakeTransport({}); const client = new RedditCommentClient(transport, logger); expect(await client.postComment('t1_comment', 'cats', 'text')).toEqual({ status: 'failed', reason: '"t1_comment" is not a post fullname', }); expect(await client.postComment('t3_a', 'cats', ' ')).toEqual({ status: 'failed', reason: 'Refusing to post an empty comment', }); expect(transport.posts).toHaveLength(0); expect(transport.gets).toHaveLength(0); }); it('treats a missing modhash as an invalid session', async () => { const transport = fakeTransport({ get: () => ({ status: 200, body: JSON.stringify({ data: { modhash: '' } }) }), }); const client = new RedditCommentClient(transport, logger); const result = await client.postComment('t3_a', 'cats', 'text'); expect(result.status).toBe('session-invalid'); expect(transport.posts).toHaveLength(0); }); describe('when the modhash lookup cannot reach Reddit', () => { // `session-invalid` invalidates the account's shared auth context, which // would disrupt outreach and chat too. Only Reddit actually saying we are // signed out may do that; a transport failure must stay retryable. it('reports a thrown transport error as retryable, not signed out', async () => { const transport = fakeTransport({ get: () => { throw new TypeError('Failed to fetch'); }, }); const client = new RedditCommentClient(transport, logger); const result = await client.postComment('t3_a', 'cats', 'text'); expect(result.status).toBe('failed'); expect(result).toMatchObject({ reason: expect.stringContaining('Failed to fetch') }); expect(transport.posts).toHaveLength(0); }); it('reports a 5xx identity check as retryable', async () => { const client = new RedditCommentClient( fakeTransport({ get: () => ({ status: 503, body: '' }) }), logger, ); expect((await client.postComment('t3_a', 'cats', 'text')).status).toBe('failed'); }); it.each([401, 403])('treats a JSON HTTP %i on the identity check as signed out', async (status) => { const client = new RedditCommentClient( fakeTransport({ get: () => ({ status, body: '{"error":' + status + '}' }) }), logger, ); expect((await client.postComment('t3_a', 'cats', 'text')).status).toBe('session-invalid'); }); it('retries the lookup on the next attempt rather than caching the failure', async () => { let attempt = 0; const transport = fakeTransport({ get: () => { attempt += 1; if (attempt === 1) throw new TypeError('Failed to fetch'); return MODHASH_OK; }, post: () => successBody('t1_ok'), }); const client = new RedditCommentClient(transport, logger); expect((await client.postComment('t3_a', 'cats', 'text')).status).toBe('failed'); expect((await client.postComment('t3_a', 'cats', 'text')).status).toBe('posted'); }); }); describe('Reddit error codes', () => { it('maps RATELIMIT to an account-scoped wait and cools the shared scheduler', async () => { const extendCooldown = vi.fn(); const transport = fakeTransport({ post: () => errorBody('RATELIMIT', 'You are doing that too much. try again in 9 minutes.'), }); const client = new RedditCommentClient(transport, logger, { scheduler: { schedule: (request) => request(), extendCooldown }, }); const result = await client.postComment('t3_a', 'cats', 'text'); expect(result).toMatchObject({ status: 'rate-limited', scope: 'account', retryAfterMs: 540_000 }); expect(extendCooldown).toHaveBeenCalledWith(540_000); }); it('maps SUBREDDIT_RATELIMIT to a subreddit-scoped wait', async () => { const transport = fakeTransport({ post: () => errorBody('SUBREDDIT_RATELIMIT', 'try again in 20 minutes'), }); const client = new RedditCommentClient(transport, logger); expect(await client.postComment('t3_a', 'cats', 'text')).toMatchObject({ status: 'rate-limited', scope: 'subreddit', retryAfterMs: 1_200_000, }); }); it('falls back to a default wait when the throttle message has no duration', async () => { const transport = fakeTransport({ post: () => errorBody('RATELIMIT', 'slow down') }); const client = new RedditCommentClient(transport, logger); expect(await client.postComment('t3_a', 'cats', 'text')).toMatchObject({ status: 'rate-limited', retryAfterMs: 600_000, }); }); it.each([ ['THREAD_LOCKED', 'thread-closed'], ['TOO_OLD', 'thread-closed'], ['DELETED_COMMENT', 'thread-closed'], ['DELETED_LINK', 'thread-closed'], ['SUBREDDIT_NOTALLOWED', 'not-allowed'], ['BANNED_FROM_SUBREDDIT', 'not-allowed'], ['USER_REQUIRED', 'session-invalid'], ['BAD_CAPTCHA', 'blocked'], ['CAPTCHA_REQUIRED', 'blocked'], ['SOMETHING_NEW', 'failed'], ])('maps %s to %s', async (code, expected) => { const transport = fakeTransport({ post: () => errorBody(code, 'because reasons') }); const client = new RedditCommentClient(transport, logger); const result = await client.postComment('t3_a', 'cats', 'text'); expect(result.status).toBe(expected); }); it('names the subreddit on a not-allowed refusal so it can be disabled', async () => { const transport = fakeTransport({ post: () => errorBody('SUBREDDIT_NOTALLOWED', 'you are banned') }); const client = new RedditCommentClient(transport, logger); const result = await client.postComment('t3_a', 'cats', 'text'); expect(result).toEqual({ status: 'not-allowed', reason: 'you are banned (r/cats)' }); }); it('maps QUOTA_FILLED to a long account cooldown', async () => { const transport = fakeTransport({ post: () => errorBody('QUOTA_FILLED') }); const client = new RedditCommentClient(transport, logger); expect(await client.postComment('t3_a', 'cats', 'text')).toMatchObject({ status: 'rate-limited', scope: 'account', retryAfterMs: 21_600_000, }); }); it('re-derives the modhash after USER_REQUIRED', async () => { const transport = fakeTransport({ post: () => errorBody('USER_REQUIRED') }); const client = new RedditCommentClient(transport, logger); await client.postComment('t3_a', 'cats', 'text'); await client.postComment('t3_b', 'cats', 'text'); expect(transport.gets.filter((url) => url.includes('me.json'))).toHaveLength(2); }); }); describe('HTTP failures', () => { it('treats a JSON 403 as an invalid session and clears the cached modhash', async () => { // A bodiless or HTML 403 is Reddit blocking us; only a JSON 403 is a // statement about the session. See the block-page tests below. const transport = fakeTransport({ post: () => ({ status: 403, body: '{"message":"Forbidden"}' }) }); const client = new RedditCommentClient(transport, logger); expect((await client.postComment('t3_a', 'cats', 'text')).status).toBe('session-invalid'); await client.postComment('t3_b', 'cats', 'text'); expect(transport.gets.filter((url) => url.includes('me.json'))).toHaveLength(2); }); it('treats 429 as an account rate limit', async () => { const transport = fakeTransport({ post: () => ({ status: 429, body: '' }) }); const client = new RedditCommentClient(transport, logger); expect(await client.postComment('t3_a', 'cats', 'text')).toMatchObject({ status: 'rate-limited', scope: 'account', }); }); it('treats 5xx and unreadable bodies as retryable failures', async () => { const serverError = new RedditCommentClient( fakeTransport({ post: () => ({ status: 503, body: 'nope' }) }), logger, ); expect(await serverError.postComment('t3_a', 'cats', 'text')).toEqual({ status: 'failed', reason: 'Reddit returned HTTP 503', }); const garbage = new RedditCommentClient( fakeTransport({ post: () => ({ status: 200, body: 'error' }) }), logger, ); expect((await garbage.postComment('t3_a', 'cats', 'text')).status).toBe('failed'); }); it('turns a thrown transport error into a retryable failure', async () => { const transport = fakeTransport({ post: () => { throw new Error('socket hang up'); }, }); const client = new RedditCommentClient(transport, logger); expect(await client.postComment('t3_a', 'cats', 'text')).toEqual({ status: 'failed', reason: 'socket hang up', }); }); it('does not claim success when Reddit returns no comment identity', async () => { const transport = fakeTransport({ post: () => ({ status: 200, body: JSON.stringify({ json: { errors: [], data: { things: [] } } }) }), }); const client = new RedditCommentClient(transport, logger); expect((await client.postComment('t3_a', 'cats', 'text')).status).toBe('failed'); }); }); }); describe('RedditCommentClient.upvote', () => { it('submits the selected fullname through Reddit’s vote endpoint', async () => { const transport = fakeTransport({ post: () => ({ status: 200, body: JSON.stringify({ json: { errors: [] } }) }) }); const client = new RedditCommentClient(transport, createMockLogger()); await expect(client.upvote('t1_comment')).resolves.toEqual({ status: 'submitted' }); expect(transport.posts[0]).toMatchObject({ url: 'https://old.reddit.com/api/vote', form: { id: 't1_comment', dir: '1', api_type: 'json', uh: 'abc123' }, }); }); }); describe('RedditCommentClient.fetchCommentStates', () => { const logger = createMockLogger(); function infoBody(children: Array>): HttpTextResponse { return { status: 200, body: JSON.stringify({ data: { children: children.map((data) => ({ kind: 't1', data })) } }), }; } it('returns scores and flags removals', async () => { const transport = fakeTransport({ get: () => infoBody([ { name: 't1_a', score: 12, body: 'nice' }, { name: 't1_b', score: 1, body: '[removed]' }, { name: 't1_c', score: 3, body: 'ok', banned_by: 'AutoModerator' }, ]), }); const client = new RedditCommentClient(transport, logger); const states = await client.fetchCommentStates(['t1_a', 't1_b', 't1_c']); expect(states).toEqual([ { fullname: 't1_a', score: 12, removed: false, missing: false }, { fullname: 't1_b', score: 1, removed: true, missing: false }, { fullname: 't1_c', score: 3, removed: true, missing: false }, ]); }); it('reports comments Reddit does not return as missing', async () => { const transport = fakeTransport({ get: () => infoBody([{ name: 't1_a', score: 2, body: 'hi' }]) }); const client = new RedditCommentClient(transport, logger); const states = await client.fetchCommentStates(['t1_a', 't1_gone']); expect(states[1]).toEqual({ fullname: 't1_gone', score: null, removed: false, missing: true }); }); it('batches into a single request and ignores non-comment ids', async () => { const transport = fakeTransport({ get: () => infoBody([]) }); const client = new RedditCommentClient(transport, logger); await client.fetchCommentStates(['t1_a', 't1_a', 't3_post', 'rubbish']); expect(transport.gets).toHaveLength(1); expect(transport.gets[0]).toContain('id=t1_a&'); }); it('does nothing when there is nothing to check', async () => { const transport = fakeTransport({}); const client = new RedditCommentClient(transport, logger); expect(await client.fetchCommentStates([])).toEqual([]); expect(transport.gets).toHaveLength(0); }); it('rejects an oversized batch and a failed lookup', async () => { const client = new RedditCommentClient( fakeTransport({ get: () => ({ status: 500, body: '' }) }), logger, ); await expect(client.fetchCommentStates( Array.from({ length: 101 }, (_, index) => `t1_x${index}`), )).rejects.toThrow(/at most 100/); await expect(client.fetchCommentStates(['t1_a'])).rejects.toThrow(/HTTP 500/); }); }); describe('RedditCommentClient.fetchPubliclyVisible', () => { const logger = createMockLogger(); it('reports which comments a signed-out visitor can see', async () => { const anonymousFetch = vi.fn(async () => ({ status: 200, body: JSON.stringify({ data: { children: [ { data: { name: 't1_visible', score: 3, body: 'hello' } }, { data: { name: 't1_filtered', score: 1, body: '[removed]' } }, ], }, }), })); const client = new RedditCommentClient(fakeTransport({}), logger, { anonymousFetch }); const visible = await client.fetchPubliclyVisible(['t1_visible', 't1_filtered', 't1_absent']); expect([...visible]).toEqual(['t1_visible']); // The probe must be signed out, so it never touches the browser transport. expect(anonymousFetch).toHaveBeenCalledTimes(1); }); it('never concludes a shadowban from a failed probe', async () => { const client = new RedditCommentClient(fakeTransport({}), logger, { anonymousFetch: async () => ({ status: 502, body: '' }), }); await expect(client.fetchPubliclyVisible(['t1_a'])).rejects.toThrow(/HTTP 502/); }); it('treats every comment as visible when no anonymous fetcher is configured', async () => { const client = new RedditCommentClient(fakeTransport({}), logger); expect([...await client.fetchPubliclyVisible(['t1_a'])]).toEqual(['t1_a']); }); }); describe('Reddit anti-abuse block pages', () => { const logger = createMockLogger(); // Reddit serves a large HTML interstitial with a 403 when it throttles a // client. It looks identical to a signed-out session unless the body is // inspected, and misreading it invalidates a healthy account's auth. const BLOCK_PAGE = { status: 403, body: '' + 'x'.repeat(2_000) + '' }; it('treats an HTML 403 on the identity check as retryable, not signed out', async () => { const client = new RedditCommentClient(fakeTransport({ get: () => BLOCK_PAGE }), logger); const result = await client.postComment('t3_a', 'cats', 'text'); expect(result.status).toBe('failed'); expect(result).toMatchObject({ reason: expect.stringContaining('anti-abuse block page') }); }); it('treats an HTML 403 on the comment itself as retryable', async () => { const client = new RedditCommentClient( fakeTransport({ post: () => BLOCK_PAGE }), logger, ); const result = await client.postComment('t3_a', 'cats', 'text'); expect(result.status).toBe('failed'); expect(result).toMatchObject({ reason: expect.stringContaining('anti-abuse block page') }); }); it('still honours a genuine JSON 403 on the comment as an invalid session', async () => { const client = new RedditCommentClient( fakeTransport({ post: () => ({ status: 403, body: '{"message":"Forbidden"}' }) }), logger, ); expect((await client.postComment('t3_a', 'cats', 'text')).status).toBe('session-invalid'); }); it('treats a non-JSON 200 as retryable too', async () => { const client = new RedditCommentClient( fakeTransport({ get: () => ({ status: 200, body: 'are you a robot?' }) }), logger, ); expect((await client.postComment('t3_a', 'cats', 'text')).status).toBe('failed'); }); }); describe('signed-out session diagnostics', () => { const logger = createMockLogger(); it('reports which cookies were visible when Reddit does not recognise the session', async () => { const client = new RedditCommentClient( fakeTransport({ get: () => ({ status: 200, body: JSON.stringify({ data: { modhash: '' } }) }) }), logger, { describeCookies: async () => 'reddit_session@www.reddit.com, token_v2@www.reddit.com' }, ); const result = await client.postComment('t3_a', 'cats', 'text'); expect(result.status).toBe('session-invalid'); // The cookie host is the usual cause, so it belongs in the reason. expect(result).toMatchObject({ reason: expect.stringContaining('www.reddit.com') }); }); it('never leaks cookie values, only what the caller chose to describe', async () => { const describeCookies = vi.fn(async () => 'reddit_session@.reddit.com'); const client = new RedditCommentClient( fakeTransport({ get: () => ({ status: 200, body: JSON.stringify({ data: { modhash: '' } }) }) }), logger, { describeCookies }, ); const result = await client.postComment('t3_a', 'cats', 'text'); expect(describeCookies).toHaveBeenCalledTimes(1); expect(result).toMatchObject({ reason: expect.stringContaining('reddit_session@.reddit.com') }); }); it('survives a cookie description that throws', async () => { const client = new RedditCommentClient( fakeTransport({ get: () => ({ status: 200, body: JSON.stringify({ data: { modhash: '' } }) }) }), logger, { describeCookies: async () => { throw new Error('context closed'); } }, ); const result = await client.postComment('t3_a', 'cats', 'text'); expect(result.status).toBe('session-invalid'); expect(result).toMatchObject({ reason: expect.stringContaining('context closed') }); }); it('says so when no diagnostic is wired up', async () => { const client = new RedditCommentClient( fakeTransport({ get: () => ({ status: 200, body: JSON.stringify({ data: { modhash: '' } }) }) }), logger, ); expect(await client.postComment('t3_a', 'cats', 'text')).toMatchObject({ reason: expect.stringContaining('not inspected'), }); }); }); describe('createBrowserWriteTransport', () => { /** Mirrors production: a freshly opened Playwright page is on about:blank. */ function fakePage(initialUrl = 'about:blank') { const calls: unknown[] = []; const navigations: string[] = []; let current = initialUrl; return { calls, navigations, url: () => current, goto: async (target: string) => { navigations.push(target); current = target; return null; }, evaluate: async ( fn: (argument: Argument) => Promise, argument: Argument, ): Promise => { calls.push(argument); const fetchStub = async (url: string, init?: { method?: string; body?: string }) => ({ status: 200, text: async () => JSON.stringify({ url, method: init?.method ?? 'GET', body: init?.body }), }); const original = globalThis.fetch; (globalThis as { fetch: unknown }).fetch = fetchStub; try { return await fn(argument); } finally { (globalThis as { fetch: unknown }).fetch = original; } }, }; } it('parks the page on the Reddit origin before fetching from about:blank', async () => { const page = fakePage(); const transport = createBrowserWriteTransport(page); await transport.get('https://old.reddit.com/api/me.json'); // Without this a fetch from about:blank fails outright with "Failed to fetch", // and any other origin would withhold the session cookies. expect(page.navigations).toEqual(['https://old.reddit.com']); }); it('navigates once and reuses the origin for later requests', async () => { const page = fakePage(); const transport = createBrowserWriteTransport(page); await transport.get('https://old.reddit.com/api/me.json'); await transport.postForm('https://old.reddit.com/api/comment', { text: 'hi' }); await transport.get('https://old.reddit.com/api/info.json?id=t1_a'); expect(page.navigations).toEqual(['https://old.reddit.com']); }); it('re-parks the page if it wanders to another origin', async () => { const page = fakePage('https://www.reddit.com/r/cats/'); const transport = createBrowserWriteTransport(page); await transport.postForm('https://old.reddit.com/api/comment', { text: 'hi' }); // www is a different origin, so the cookies would not be sent. expect(page.navigations).toEqual(['https://old.reddit.com']); }); it('does not navigate when already on the write origin', async () => { const page = fakePage('https://old.reddit.com/r/cats/'); const transport = createBrowserWriteTransport(page); await transport.get('https://old.reddit.com/api/me.json'); expect(page.navigations).toEqual([]); }); it('runs both verbs inside the page so requests carry the profile session', async () => { const calls: unknown[] = []; const page = { url: () => 'https://old.reddit.com/', goto: async () => null, evaluate: async ( fn: (argument: Argument) => Promise, argument: Argument, ): Promise => { calls.push(argument); const fetchStub = async (url: string, init?: { method?: string; body?: string }) => ({ status: 200, text: async () => JSON.stringify({ url, method: init?.method ?? 'GET', body: init?.body }), }); const original = globalThis.fetch; (globalThis as { fetch: unknown }).fetch = fetchStub; try { return await fn(argument); } finally { (globalThis as { fetch: unknown }).fetch = original; } }, }; const transport = createBrowserWriteTransport(page); const got = await transport.get('https://old.reddit.com/api/me.json'); const posted = await transport.postForm( 'https://old.reddit.com/api/comment', { text: 'hi there', uh: 'abc' }, { 'X-Modhash': 'abc' }, ); expect(JSON.parse(got.body).url).toBe('https://old.reddit.com/api/me.json'); expect(JSON.parse(posted.body).method).toBe('POST'); expect(JSON.parse(posted.body).body).toBe('text=hi+there&uh=abc'); expect(calls).toHaveLength(2); }); });