Files
RedditBot/tests/reddit-comment-client.test.ts
T

694 lines
26 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 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: '<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.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<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('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: '<html><body>' + 'x'.repeat(2_000) + '</body></html>' };
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: '<html>are you a robot?</html>' }) }),
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 <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);
});
});