Reddit answers a throttled client with a large HTML 403 interstitial. To the comment client that was indistinguishable from a logged-out session: the body would not parse, so no modhash was found, so it reported "Reddit did not return a modhash for this session" and returned session-invalid — which calls auth.invalidate() and so could disrupt outreach and chat for an account whose session was perfectly healthy. That matches what prod is doing. u/Saira_Makimad posted successfully 25 seconds after boot and every later attempt failed with that message, which is the shape of Reddit starting to throttle rather than a session going bad. A response is now only treated as a verdict about the session when it is actually JSON. Non-JSON, at any status, is Reddit refusing to serve us and stays retryable. A genuine JSON 403, or JSON with no modhash, still invalidates as before. When Reddit does report a signed-out session, the log and the recorded reason now name the cookies visible to the write origin — names and domains only, never values. Cookies scoped to www.reddit.com rather than .reddit.com would produce exactly this symptom, and this makes that answerable from the logs instead of by guesswork. Also clarify the allowance in the dashboard. It is derived from the trust level rather than typed in, and it is a rolling 24-hour window, neither of which the page said. A per-community cap above the account allowance now says how much of it is actually reachable, because "1 of 10 today" next to an account limit of 2 reads as far more headroom than exists. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
681 lines
25 KiB
TypeScript
681 lines
25 KiB
TypeScript
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.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);
|
||
});
|
||
});
|