694 lines
26 KiB
TypeScript
694 lines
26 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.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);
|
||
});
|
||
});
|