Files
RedditBot/tests/reddit-comment-client.test.ts
T
Jamie BruntonandClaude Opus 5 f36e93d5be fix: park the growth page on Reddit's origin before fetching
Growth could never post. Its page is opened with newPage() and was never
navigated, so it sat on about:blank, whose opaque origin cannot fetch
Reddit at all — production logged "Failed to read the Reddit session
modhash: TypeError: Failed to fetch" on every attempt. Confirmed against
real network: the same fetch throws from about:blank and succeeds from
https://old.reddit.com.

The transport now parks the page on that origin before any request, and
re-parks it if the page wanders elsewhere. Being same-origin is also what
sends the session cookies, so the authenticated info.json lookup moved to
the same host rather than www; the signed-out visibility probe stays on
www because it runs from Node, where CORS does not apply.

The unit tests could not catch this: they stub the transport, and the
earlier browser check navigated the page by hand in the harness — the
production path was the one place that never did. The page fake now
starts on about:blank like the real thing and asserts the navigation.

Also stop a transport failure from invalidating the account session. A
modhash lookup that could not reach Reddit returned session-invalid,
which calls auth.invalidate() and so would disrupt outreach and chat for
that account over a network blip. Only Reddit actually reporting a
signed-out session (HTTP 401/403, or a response with no modhash) does
that now; anything else is retryable.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 20:44:33 +01:00

576 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<string, string>; headers?: Record<string, string> }>;
gets: string[];
}
function fakeTransport(responses: {
get?: (url: string) => HttpTextResponse | Promise<HttpTextResponse>;
post?: (url: string, form: Record<string, string>) => HttpTextResponse | Promise<HttpTextResponse>;
}): 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<typeof createMockLogger>;
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 HTTP %i on the identity check as signed out', async (status) => {
const client = new RedditCommentClient(
fakeTransport({ get: () => ({ status, body: '' }) }),
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 403 as an invalid session and clears the cached modhash', async () => {
const transport = fakeTransport({ post: () => ({ status: 403, body: '' }) });
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: '<html>error</html>' }) }),
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.fetchCommentStates', () => {
const logger = createMockLogger();
function infoBody(children: Array<Record<string, unknown>>): 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('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 <Result, Argument>(
fn: (argument: Argument) => Promise<Result>,
argument: Argument,
): Promise<Result> => {
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 <Result, Argument>(
fn: (argument: Argument) => Promise<Result>,
argument: Argument,
): Promise<Result> => {
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);
});
});