New accounts cannot start chats until Reddit considers them established. Until now the bot would burn a candidate on every invitation Reddit was always going to reject, with nothing in the dashboard explaining why the account looked idle. Two signals feed the verdict. A periodic profile check reads the account's own age, karma and email verification from about.json and compares them against configurable thresholds — that is what flags a brand new account before it wastes anything. A rejected invitation is authoritative: Reddit has told us directly, so the account is held out of sending with a backoff that doubles per rejection, and a delivered invitation clears the flag outright. An unestablished account takes no new posts (canAcceptPost), raises an attention item, reports an `unestablished` sending state, and shows its age/karma evidence on the Accounts page. An unchecked account is always allowed through, so a first run is never blocked. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
915 lines
34 KiB
TypeScript
915 lines
34 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
import axios from 'axios';
|
|
import type { AxiosInstance } from 'axios';
|
|
import type { BrowserContext } from 'playwright';
|
|
import { RedditApiClient, RedditRateLimitError } from '../src/reddit/api.js';
|
|
import type { RedditRequestScheduler } from '../src/reddit/request-coordinator.js';
|
|
import type { RedditPost } from '../src/reddit/api.js';
|
|
import { createMockLogger } from '../tests/mockLogger.js';
|
|
import { getTestConfig } from '../tests/testUtils.js';
|
|
|
|
vi.mock('axios', async () => {
|
|
const actual = await vi.importActual<typeof import('axios')>('axios');
|
|
return {
|
|
default: {
|
|
...actual.default,
|
|
create: vi.fn(),
|
|
isAxiosError: (e: unknown) => Boolean(e && typeof e === 'object' && (e as { isAxiosError?: boolean }).isAxiosError),
|
|
},
|
|
};
|
|
});
|
|
|
|
describe('RedditApiClient', () => {
|
|
let client: RedditApiClient;
|
|
let logger: ReturnType<typeof createMockLogger>;
|
|
|
|
beforeEach(() => {
|
|
logger = createMockLogger();
|
|
const config = getTestConfig();
|
|
client = new RedditApiClient(config, logger);
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
describe('hasImage', () => {
|
|
const validPostFactory = (overrides: Partial<RedditPost> = {}): RedditPost => ({
|
|
id: 't3_test',
|
|
name: 't3_test',
|
|
subreddit: 'test',
|
|
author: 'testuser',
|
|
title: 'Test',
|
|
url: 'http://example.com',
|
|
permalink: '/r/test/comments/abc/test',
|
|
thumbnail: '',
|
|
is_self: false,
|
|
created_utc: Date.now() / 1000,
|
|
over_18: false,
|
|
spoiler: false,
|
|
...overrides,
|
|
});
|
|
|
|
it('accepts posts with post_hint=image', () => {
|
|
const post = validPostFactory({ post_hint: 'image' });
|
|
// We can't test private method directly, but we can test via getImageUrl
|
|
const url = client.getImageUrl(post);
|
|
// Since url doesn't match image patterns, it falls through to thumbnail
|
|
// which is empty, so returns null. The hasImage check is internal.
|
|
// Let's test via getNewPosts filtering instead.
|
|
});
|
|
|
|
it('accepts posts with preview images', () => {
|
|
const post = validPostFactory({
|
|
preview: {
|
|
images: [{
|
|
source: { url: 'http://i.redd.it/image.jpg', width: 100, height: 100 },
|
|
resolutions: [],
|
|
}],
|
|
},
|
|
});
|
|
const url = client.getImageUrl(post);
|
|
expect(url).toBe('http://i.redd.it/image.jpg');
|
|
});
|
|
|
|
it('extracts the first valid image from a Reddit gallery in display order', () => {
|
|
const post = validPostFactory({
|
|
url: 'https://www.reddit.com/gallery/t3_gallery',
|
|
is_gallery: true,
|
|
gallery_data: {
|
|
items: [
|
|
{ media_id: 'second-in-metadata' },
|
|
{ media_id: 'first-in-metadata' },
|
|
],
|
|
},
|
|
media_metadata: {
|
|
'first-in-metadata': {
|
|
status: 'valid',
|
|
m: 'image/jpg',
|
|
s: { u: 'https://i.redd.it/first.jpg' },
|
|
},
|
|
'second-in-metadata': {
|
|
status: 'valid',
|
|
m: 'image/jpg',
|
|
s: { u: 'https://preview.redd.it/second.jpg?width=1080&format=pjpg' },
|
|
},
|
|
},
|
|
});
|
|
|
|
expect(client.getImageUrl(post)).toBe(
|
|
'https://preview.redd.it/second.jpg?width=1080&format=pjpg',
|
|
);
|
|
});
|
|
|
|
it('ignores invalid and non-image gallery media', () => {
|
|
const post = validPostFactory({
|
|
url: 'https://www.reddit.com/gallery/t3_gallery',
|
|
is_gallery: true,
|
|
gallery_data: {
|
|
items: [{ media_id: 'invalid' }, { media_id: 'video' }],
|
|
},
|
|
media_metadata: {
|
|
invalid: { status: 'failed', m: 'image/jpg', s: { u: 'https://i.redd.it/invalid.jpg' } },
|
|
video: { status: 'valid', m: 'video/mp4', s: { u: 'https://v.redd.it/video.mp4' } },
|
|
},
|
|
});
|
|
|
|
expect(client.getImageUrl(post)).toBeNull();
|
|
});
|
|
|
|
it('accepts posts with i.redd.it URLs', () => {
|
|
const post = validPostFactory({ url: 'https://i.redd.it/abc123.jpg' });
|
|
const url = client.getImageUrl(post);
|
|
expect(url).toBe('https://i.redd.it/abc123.jpg');
|
|
});
|
|
|
|
it('accepts posts with i.imgur.com URLs', () => {
|
|
const post = validPostFactory({ url: 'https://i.imgur.com/abc123.jpg' });
|
|
const url = client.getImageUrl(post);
|
|
expect(url).toBe('https://i.imgur.com/abc123.jpg');
|
|
});
|
|
|
|
it('extracts imgur direct URL from imgur page URL', () => {
|
|
const post = validPostFactory({ url: 'https://imgur.com/abc123' });
|
|
const url = client.getImageUrl(post);
|
|
expect(url).toBe('https://i.imgur.com/abc123.jpg');
|
|
});
|
|
|
|
it('extracts imgur direct URL from imgur page URL with extension', () => {
|
|
const post = validPostFactory({ url: 'https://imgur.com/abc123.jpg' });
|
|
const url = client.getImageUrl(post);
|
|
expect(url).toBe('https://i.imgur.com/abc123.jpg');
|
|
});
|
|
|
|
it('accepts posts with image file extensions in URL', () => {
|
|
const post = validPostFactory({ url: 'http://example.com/photo.png' });
|
|
const url = client.getImageUrl(post);
|
|
expect(url).toBe('http://example.com/photo.png');
|
|
});
|
|
|
|
it('accepts .gif URLs', () => {
|
|
const post = validPostFactory({ url: 'http://example.com/animation.gif' });
|
|
const url = client.getImageUrl(post);
|
|
expect(url).toBe('http://example.com/animation.gif');
|
|
});
|
|
|
|
it('accepts .webp URLs', () => {
|
|
const post = validPostFactory({ url: 'http://example.com/image.webp' });
|
|
const url = client.getImageUrl(post);
|
|
expect(url).toBe('http://example.com/image.webp');
|
|
});
|
|
|
|
it('falls back to thumbnail when no other image source', () => {
|
|
const post = validPostFactory({ thumbnail: 'http://example.com/thumb.jpg' });
|
|
const url = client.getImageUrl(post);
|
|
expect(url).toBe('http://example.com/thumb.jpg');
|
|
});
|
|
|
|
it('returns null when no image source available', () => {
|
|
const post = validPostFactory({ thumbnail: '' });
|
|
const url = client.getImageUrl(post);
|
|
expect(url).toBeNull();
|
|
});
|
|
|
|
it('returns null for empty thumbnail', () => {
|
|
const post = validPostFactory({ thumbnail: '' });
|
|
const url = client.getImageUrl(post);
|
|
expect(url).toBeNull();
|
|
});
|
|
|
|
it('skips non-image thumbnail URLs', () => {
|
|
const post = validPostFactory({ thumbnail: 'about:blank' });
|
|
const url = client.getImageUrl(post);
|
|
expect(url).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('decodeRedditUrl', () => {
|
|
it('decodes HTML entities in preview URLs', () => {
|
|
const post: RedditPost = {
|
|
id: 't3_test',
|
|
name: 't3_test',
|
|
subreddit: 'test',
|
|
author: 'user',
|
|
title: 'Test',
|
|
url: 'http://example.com',
|
|
permalink: '/r/test/comments/abc/test',
|
|
thumbnail: '',
|
|
is_self: false,
|
|
created_utc: Date.now() / 1000,
|
|
over_18: false,
|
|
spoiler: false,
|
|
preview: {
|
|
images: [{
|
|
source: { url: 'http://example.com/&test.jpg', width: 100, height: 100 },
|
|
resolutions: [],
|
|
}],
|
|
},
|
|
};
|
|
const url = client.getImageUrl(post);
|
|
expect(url).toBe('http://example.com/&test.jpg');
|
|
});
|
|
});
|
|
|
|
describe('getPostUrl', () => {
|
|
it('constructs correct URL from permalink', () => {
|
|
const post: RedditPost = {
|
|
id: 't3_abc',
|
|
name: 't3_abc',
|
|
subreddit: 'test',
|
|
author: 'user',
|
|
title: 'Test',
|
|
url: 'http://example.com',
|
|
permalink: '/r/test/comments/abc/test',
|
|
thumbnail: '',
|
|
is_self: false,
|
|
created_utc: Date.now() / 1000,
|
|
over_18: false,
|
|
spoiler: false,
|
|
};
|
|
expect(client.getPostUrl(post)).toBe('https://www.reddit.com/r/test/comments/abc/test');
|
|
});
|
|
});
|
|
|
|
describe('isValidPost (via getNewPosts filtering)', () => {
|
|
it('skips self posts', () => {
|
|
const textPost: RedditPost = {
|
|
id: 't3_text',
|
|
name: 't3_text',
|
|
subreddit: 'test',
|
|
author: 'user',
|
|
title: 'Text post',
|
|
url: 'https://www.reddit.com/r/test/comments/abc/',
|
|
permalink: '/r/test/comments/abc/text',
|
|
thumbnail: 'self',
|
|
is_self: true,
|
|
created_utc: Date.now() / 1000,
|
|
over_18: false,
|
|
spoiler: false,
|
|
};
|
|
|
|
// We can't easily test the private isValidPost, but we can verify
|
|
// the behavior through the public API by checking that image posts
|
|
// pass through and text posts don't.
|
|
});
|
|
|
|
it('skips deleted authors', () => {
|
|
const post: RedditPost = {
|
|
id: 't3_del',
|
|
name: 't3_del',
|
|
subreddit: 'test',
|
|
author: '[deleted]',
|
|
title: 'Deleted user',
|
|
url: 'http://i.redd.it/img.jpg',
|
|
permalink: '/r/test/comments/abc/del',
|
|
thumbnail: 'http://i.redd.it/img.jpg',
|
|
is_self: false,
|
|
created_utc: Date.now() / 1000,
|
|
over_18: false,
|
|
spoiler: false,
|
|
};
|
|
// isValidPost should return false for [deleted]
|
|
});
|
|
|
|
it('skips AutoModerator posts', () => {
|
|
const post: RedditPost = {
|
|
id: 't3_mod',
|
|
name: 't3_mod',
|
|
subreddit: 'test',
|
|
author: 'AutoModerator',
|
|
title: 'Mod message',
|
|
url: 'http://i.redd.it/img.jpg',
|
|
permalink: '/r/test/comments/abc/mod',
|
|
thumbnail: 'http://i.redd.it/img.jpg',
|
|
is_self: false,
|
|
created_utc: Date.now() / 1000,
|
|
over_18: false,
|
|
spoiler: false,
|
|
};
|
|
// isValidPost should return false for AutoModerator
|
|
});
|
|
});
|
|
|
|
describe('isSellerProfile', () => {
|
|
it('returns true for a seller signal in the current post without fetching the profile', async () => {
|
|
const get = vi.fn();
|
|
vi.mocked(axios.create).mockReturnValue({ get } as any);
|
|
const client2 = new RedditApiClient(getTestConfig(), logger);
|
|
const post = {
|
|
id: 'post-1', name: 't3_post-1', subreddit: 'test', author: 'testuser',
|
|
title: 'Come and subscribe', selftext: 'My page is at https://onlyfans.com/example',
|
|
url: 'https://reddit.com/post-1', permalink: '/r/test/post-1', thumbnail: '',
|
|
is_self: true, created_utc: 1, over_18: true, spoiler: false,
|
|
};
|
|
|
|
await expect(client2.isSellerProfile('testuser', ['onlyfans'], ['onlyfans.com'], post))
|
|
.resolves.toBe(true);
|
|
expect(get).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('treats a Telegram reference in the current post as a seller signal', async () => {
|
|
const get = vi.fn();
|
|
vi.mocked(axios.create).mockReturnValue({ get } as any);
|
|
const client2 = new RedditApiClient(getTestConfig(), logger);
|
|
const post = {
|
|
id: 'post-telegram', name: 't3_post-telegram', subreddit: 'test', author: 'testuser',
|
|
title: 'Message me there', selftext: 'More content on t.me/example',
|
|
url: 'https://reddit.com/post-telegram', permalink: '/r/test/post-telegram', thumbnail: '',
|
|
is_self: true, created_utc: 1, over_18: true, spoiler: false,
|
|
};
|
|
|
|
await expect(client2.isSellerProfile('testuser', ['telegram'], ['t.me', 'telegram.me'], post))
|
|
.resolves.toBe(true);
|
|
expect(get).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('detects seller signals in recent profile activity when the bio is clean', async () => {
|
|
const get = vi.fn()
|
|
.mockResolvedValueOnce({ data: { data: { subreddit: { public_description: 'Just me' } } } })
|
|
.mockResolvedValueOnce({
|
|
data: { data: { children: [{ data: { title: 'New content', subreddit: 'OnlyFansPromotions' } }] } },
|
|
});
|
|
vi.mocked(axios.create).mockReturnValue({ get } as any);
|
|
const client2 = new RedditApiClient(getTestConfig(), logger);
|
|
|
|
await expect(client2.isSellerProfile('testuser', ['onlyfans'])).resolves.toBe(true);
|
|
expect(get).toHaveBeenNthCalledWith(2, '/user/testuser/overview.json', {
|
|
params: { limit: 25, raw_json: 1 },
|
|
headers: {},
|
|
});
|
|
});
|
|
|
|
it('returns true when seller keyword matches', async () => {
|
|
const mockResponse = {
|
|
data: {
|
|
data: {
|
|
subreddit: {
|
|
public_description: 'Check out my OnlyFans page!',
|
|
title: 'My Profile',
|
|
display_name_prefixed: 'u/testuser',
|
|
},
|
|
},
|
|
},
|
|
};
|
|
vi.mocked(axios.create).mockReturnValue({
|
|
get: vi.fn().mockResolvedValue(mockResponse),
|
|
} as any);
|
|
|
|
const client2 = new RedditApiClient(getTestConfig(), logger);
|
|
const result = await client2.isSellerProfile('testuser', ['onlyfans']);
|
|
expect(result).toBe(true);
|
|
});
|
|
|
|
it('detects a configured seller platform in structured social links', async () => {
|
|
const get = vi.fn().mockResolvedValue({
|
|
data: {
|
|
data: {
|
|
subreddit: { public_description: 'Just me', over_18: true },
|
|
social_links: [{ title: 'My OnlyFans', outbound_url: 'https://onlyfans.com/example' }],
|
|
},
|
|
},
|
|
});
|
|
vi.mocked(axios.create).mockReturnValue({ get } as any);
|
|
const client2 = new RedditApiClient(getTestConfig(), logger);
|
|
|
|
await expect(client2.isSellerProfile('testuser', ['onlyfans'], ['onlyfans.com']))
|
|
.resolves.toBe(true);
|
|
expect(get).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('detects a narrow NSFW link-to-more funnel when Reddit omits the social link', async () => {
|
|
const get = vi.fn().mockResolvedValue({
|
|
data: {
|
|
data: {
|
|
subreddit: {
|
|
public_description: "Hit the link to see more of me, we both know why you're here",
|
|
title: '',
|
|
display_name_prefixed: 'u/testuser',
|
|
over_18: true,
|
|
},
|
|
},
|
|
},
|
|
});
|
|
vi.mocked(axios.create).mockReturnValue({ get } as any);
|
|
const client2 = new RedditApiClient(getTestConfig(), logger);
|
|
|
|
await expect(client2.isSellerProfile('testuser', ['onlyfans'], ['onlyfans.com']))
|
|
.resolves.toBe(true);
|
|
expect(get).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('does not treat an ordinary or non-NSFW profile link as a seller funnel', async () => {
|
|
const get = vi.fn()
|
|
.mockResolvedValueOnce({
|
|
data: { data: { subreddit: { public_description: 'Hit the link to see more of my art', over_18: false } } },
|
|
})
|
|
.mockResolvedValueOnce({ data: { data: { children: [] } } });
|
|
vi.mocked(axios.create).mockReturnValue({ get } as any);
|
|
const client2 = new RedditApiClient(getTestConfig(), logger);
|
|
|
|
await expect(client2.isSellerProfile('testuser', ['onlyfans'], ['onlyfans.com']))
|
|
.resolves.toBe(false);
|
|
expect(get).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it('returns false when no keywords match', async () => {
|
|
const mockResponse = {
|
|
data: {
|
|
data: {
|
|
subreddit: {
|
|
public_description: 'Just a regular user',
|
|
title: 'My Profile',
|
|
display_name_prefixed: 'u/testuser',
|
|
},
|
|
},
|
|
},
|
|
};
|
|
vi.mocked(axios.create).mockReturnValue({
|
|
get: vi.fn().mockResolvedValue(mockResponse),
|
|
} as any);
|
|
|
|
const client2 = new RedditApiClient(getTestConfig(), logger);
|
|
const result = await client2.isSellerProfile('testuser', ['onlyfans', 'fansly']);
|
|
expect(result).toBe(false);
|
|
});
|
|
|
|
it('returns false for empty seller keywords', async () => {
|
|
const client2 = new RedditApiClient(getTestConfig(), logger);
|
|
const result = await client2.isSellerProfile('testuser', []);
|
|
expect(result).toBe(false);
|
|
});
|
|
|
|
it('returns false when user profile not found (404)', async () => {
|
|
const error = { isAxiosError: true, response: { status: 404 } };
|
|
vi.mocked(axios.create).mockReturnValue({
|
|
get: vi.fn().mockRejectedValue(error),
|
|
} as any);
|
|
|
|
const client2 = new RedditApiClient(getTestConfig(), logger);
|
|
const result = await client2.isSellerProfile('nonexistent', ['onlyfans']);
|
|
expect(result).toBe(false);
|
|
});
|
|
|
|
it('is case-insensitive for keyword matching', async () => {
|
|
const mockResponse = {
|
|
data: {
|
|
data: {
|
|
subreddit: {
|
|
public_description: 'Check out my ONLYFANS page!',
|
|
title: 'My Profile',
|
|
display_name_prefixed: 'u/testuser',
|
|
},
|
|
},
|
|
},
|
|
};
|
|
vi.mocked(axios.create).mockReturnValue({
|
|
get: vi.fn().mockResolvedValue(mockResponse),
|
|
} as any);
|
|
|
|
const client2 = new RedditApiClient(getTestConfig(), logger);
|
|
const result = await client2.isSellerProfile('testuser', ['onlyfans']);
|
|
expect(result).toBe(true);
|
|
});
|
|
|
|
it('returns false on non-404 errors', async () => {
|
|
vi.mocked(axios.create).mockReturnValue({
|
|
get: vi.fn().mockRejectedValue(new Error('Network error')),
|
|
} as any);
|
|
|
|
const client2 = new RedditApiClient(getTestConfig(), logger);
|
|
const result = await client2.isSellerProfile('testuser', ['onlyfans']);
|
|
expect(result).toBe(false);
|
|
});
|
|
|
|
it('propagates profile rate limits so the main loop backs off', async () => {
|
|
const error = {
|
|
isAxiosError: true,
|
|
response: { status: 429, headers: { 'retry-after': '42' } },
|
|
};
|
|
vi.mocked(axios.create).mockReturnValue({ get: vi.fn().mockRejectedValue(error) } as any);
|
|
|
|
const client2 = new RedditApiClient(getTestConfig(), logger);
|
|
await expect(client2.isSellerProfile('testuser', ['onlyfans']))
|
|
.rejects.toMatchObject({ retryAfterMs: 42_000 });
|
|
});
|
|
|
|
it('returns true when a domain in the bio matches sellerDomains', async () => {
|
|
const mockResponse = {
|
|
data: {
|
|
data: {
|
|
subreddit: {
|
|
public_description: 'find me here https://my.linktr.ee/cutiepie',
|
|
title: 'My Profile',
|
|
display_name_prefixed: 'u/testuser',
|
|
},
|
|
},
|
|
},
|
|
};
|
|
vi.mocked(axios.create).mockReturnValue({
|
|
get: vi.fn().mockResolvedValue(mockResponse),
|
|
} as any);
|
|
|
|
const client2 = new RedditApiClient(getTestConfig(), logger);
|
|
const result = await client2.isSellerProfile('testuser', [], ['linktr.ee']);
|
|
expect(result).toBe(true);
|
|
});
|
|
|
|
it('matches bare domains without a scheme', async () => {
|
|
const mockResponse = {
|
|
data: {
|
|
data: {
|
|
subreddit: {
|
|
public_description: 'dm for fansly.com/me',
|
|
title: '',
|
|
display_name_prefixed: 'u/testuser',
|
|
},
|
|
},
|
|
},
|
|
};
|
|
vi.mocked(axios.create).mockReturnValue({
|
|
get: vi.fn().mockResolvedValue(mockResponse),
|
|
} as any);
|
|
|
|
const client2 = new RedditApiClient(getTestConfig(), logger);
|
|
const result = await client2.isSellerProfile('testuser', [], ['fansly.com']);
|
|
expect(result).toBe(true);
|
|
});
|
|
|
|
it('does not match unrelated domains that share a suffix', async () => {
|
|
const mockResponse = {
|
|
data: {
|
|
data: {
|
|
subreddit: {
|
|
public_description: 'see notlinktr.ee for my art',
|
|
title: '',
|
|
display_name_prefixed: 'u/testuser',
|
|
},
|
|
},
|
|
},
|
|
};
|
|
vi.mocked(axios.create).mockReturnValue({
|
|
get: vi.fn().mockResolvedValue(mockResponse),
|
|
} as any);
|
|
|
|
const client2 = new RedditApiClient(getTestConfig(), logger);
|
|
const result = await client2.isSellerProfile('testuser', [], ['linktr.ee']);
|
|
expect(result).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('resolveUsernameToT2', () => {
|
|
it('uses the imported Reddit session and normalizes the account ID', async () => {
|
|
const mockAxios = {
|
|
get: vi.fn().mockResolvedValue({ data: { data: { id: 'abc123' } } }),
|
|
};
|
|
vi.mocked(axios.create).mockReturnValue(mockAxios as unknown as AxiosInstance);
|
|
const client2 = new RedditApiClient(getTestConfig(), logger);
|
|
const cookies = vi.fn().mockResolvedValue([
|
|
{ name: 'reddit_session', value: 'session-value' },
|
|
]);
|
|
client2.setBrowserContext({ cookies } as unknown as BrowserContext);
|
|
|
|
await expect(client2.resolveUsernameToT2('alice')).resolves.toBe('t2_abc123');
|
|
expect(mockAxios.get).toHaveBeenCalledWith('/user/alice/about.json', {
|
|
params: { raw_json: 1 },
|
|
headers: { Cookie: 'reddit_session=session-value' },
|
|
});
|
|
});
|
|
|
|
it('returns null only when Reddit confirms the user is missing', async () => {
|
|
const error = { isAxiosError: true, response: { status: 404 } };
|
|
const mockAxios = { get: vi.fn().mockRejectedValue(error) };
|
|
vi.mocked(axios.create).mockReturnValue(mockAxios as unknown as AxiosInstance);
|
|
const client2 = new RedditApiClient(getTestConfig(), logger);
|
|
|
|
await expect(client2.resolveUsernameToT2('missing')).resolves.toBeNull();
|
|
});
|
|
|
|
it('does not misclassify a 403 as an unknown user', async () => {
|
|
const error = { isAxiosError: true, response: { status: 403 } };
|
|
const mockAxios = { get: vi.fn().mockRejectedValue(error) };
|
|
vi.mocked(axios.create).mockReturnValue(mockAxios as unknown as AxiosInstance);
|
|
const client2 = new RedditApiClient(getTestConfig(), logger);
|
|
|
|
await expect(client2.resolveUsernameToT2('alice')).rejects.toBe(error);
|
|
});
|
|
});
|
|
|
|
describe('getUserAccountStatus', () => {
|
|
it('distinguishes active, suspended and deleted accounts', async () => {
|
|
const mockAxios = { get: vi.fn() };
|
|
vi.mocked(axios.create).mockReturnValue(mockAxios as unknown as AxiosInstance);
|
|
const client2 = new RedditApiClient(getTestConfig(), logger);
|
|
|
|
mockAxios.get.mockResolvedValueOnce({ data: { data: {} } });
|
|
await expect(client2.getUserAccountStatus('active-user')).resolves.toBe('active');
|
|
|
|
mockAxios.get.mockResolvedValueOnce({ data: { data: { is_suspended: true } } });
|
|
await expect(client2.getUserAccountStatus('suspended-user')).resolves.toBe('suspended');
|
|
|
|
mockAxios.get.mockRejectedValueOnce({ isAxiosError: true, response: { status: 404 } });
|
|
await expect(client2.getUserAccountStatus('deleted-user')).resolves.toBe('deleted');
|
|
});
|
|
|
|
it('does not turn temporary or malformed responses into deletion signals', async () => {
|
|
const mockAxios = { get: vi.fn() };
|
|
vi.mocked(axios.create).mockReturnValue(mockAxios as unknown as AxiosInstance);
|
|
const client2 = new RedditApiClient(getTestConfig(), logger);
|
|
|
|
const temporary = { isAxiosError: true, response: { status: 503 } };
|
|
mockAxios.get.mockRejectedValueOnce(temporary);
|
|
await expect(client2.getUserAccountStatus('alice')).rejects.toBe(temporary);
|
|
|
|
mockAxios.get.mockResolvedValueOnce({ data: {} });
|
|
await expect(client2.getUserAccountStatus('alice')).rejects.toThrow();
|
|
});
|
|
});
|
|
|
|
describe('getAccountProfile', () => {
|
|
it('reads age, karma and email verification for one of our own accounts', async () => {
|
|
const mockAxios = { get: vi.fn() };
|
|
vi.mocked(axios.create).mockReturnValue(mockAxios as unknown as AxiosInstance);
|
|
const client2 = new RedditApiClient(getTestConfig(), logger);
|
|
|
|
mockAxios.get.mockResolvedValueOnce({
|
|
data: {
|
|
data: {
|
|
created_utc: 1_700_000_000,
|
|
link_karma: 12,
|
|
comment_karma: 30,
|
|
total_karma: 42,
|
|
has_verified_email: true,
|
|
},
|
|
},
|
|
});
|
|
|
|
await expect(client2.getAccountProfile('new-account')).resolves.toEqual({
|
|
createdAt: 1_700_000_000_000,
|
|
linkKarma: 12,
|
|
commentKarma: 30,
|
|
totalKarma: 42,
|
|
hasVerifiedEmail: true,
|
|
isSuspended: false,
|
|
});
|
|
});
|
|
|
|
it('derives total karma when Reddit omits it and leaves absent fields null', async () => {
|
|
const mockAxios = { get: vi.fn() };
|
|
vi.mocked(axios.create).mockReturnValue(mockAxios as unknown as AxiosInstance);
|
|
const client2 = new RedditApiClient(getTestConfig(), logger);
|
|
|
|
mockAxios.get.mockResolvedValueOnce({ data: { data: { link_karma: 3, comment_karma: 4 } } });
|
|
|
|
await expect(client2.getAccountProfile('new-account')).resolves.toEqual({
|
|
createdAt: null,
|
|
linkKarma: 3,
|
|
commentKarma: 4,
|
|
totalKarma: 7,
|
|
hasVerifiedEmail: null,
|
|
isSuspended: false,
|
|
});
|
|
});
|
|
|
|
it('reports a missing profile as null and still propagates real failures', async () => {
|
|
const mockAxios = { get: vi.fn() };
|
|
vi.mocked(axios.create).mockReturnValue(mockAxios as unknown as AxiosInstance);
|
|
const client2 = new RedditApiClient(getTestConfig(), logger);
|
|
|
|
mockAxios.get.mockRejectedValueOnce({ isAxiosError: true, response: { status: 404 } });
|
|
await expect(client2.getAccountProfile('gone')).resolves.toBeNull();
|
|
|
|
const temporary = { isAxiosError: true, response: { status: 503 } };
|
|
mockAxios.get.mockRejectedValueOnce(temporary);
|
|
await expect(client2.getAccountProfile('new-account')).rejects.toBe(temporary);
|
|
});
|
|
});
|
|
|
|
describe('getNewPosts', () => {
|
|
it('throws a permanent-disable signal for a plain 404 subreddit', async () => {
|
|
const error = { isAxiosError: true, response: { status: 404 } };
|
|
const mockAxios = { get: vi.fn().mockRejectedValue(error) };
|
|
vi.mocked(axios.create).mockReturnValue(mockAxios as any);
|
|
|
|
const client2 = new RedditApiClient(getTestConfig(), logger);
|
|
await expect(client2.getNewPosts('nonexistent')).rejects.toMatchObject({
|
|
name: 'RedditSubredditUnavailableError', subreddit: 'nonexistent',
|
|
});
|
|
});
|
|
|
|
it('throws the same permanent-disable signal for Reddit banned responses', async () => {
|
|
const error = {
|
|
isAxiosError: true,
|
|
response: { status: 404, data: { reason: 'banned', message: 'Not Found', error: 404 } },
|
|
};
|
|
const mockAxios = { get: vi.fn().mockRejectedValue(error) };
|
|
vi.mocked(axios.create).mockReturnValue(mockAxios as unknown as AxiosInstance);
|
|
|
|
const client2 = new RedditApiClient(getTestConfig(), logger);
|
|
await expect(client2.getNewPosts('banned-sub')).rejects.toMatchObject({
|
|
name: 'RedditSubredditUnavailableError', subreddit: 'banned-sub',
|
|
});
|
|
});
|
|
|
|
it('returns empty array and explains an initial 403 access denial', async () => {
|
|
const error = { isAxiosError: true, response: { status: 403 } };
|
|
const mockAxios = { get: vi.fn().mockRejectedValue(error) };
|
|
vi.mocked(axios.create).mockReturnValue(mockAxios as any);
|
|
|
|
const client2 = new RedditApiClient(getTestConfig(), logger);
|
|
const result = await client2.getNewPosts('private');
|
|
expect(result).toEqual([]);
|
|
expect(logger.logs).toContainEqual(expect.objectContaining({
|
|
level: 'warn', message: expect.stringContaining('HTTP 403'),
|
|
}));
|
|
});
|
|
|
|
it('treats consecutive 403 listing responses as a temporary Reddit block', async () => {
|
|
const error = { isAxiosError: true, response: { status: 403 } };
|
|
const mockAxios = { get: vi.fn().mockRejectedValue(error) };
|
|
vi.mocked(axios.create).mockReturnValue(mockAxios as unknown as AxiosInstance);
|
|
const scheduler: RedditRequestScheduler = { schedule: async (request) => await request(), extendCooldown: vi.fn() };
|
|
const client2 = new RedditApiClient(getTestConfig(), logger, scheduler);
|
|
|
|
await expect(client2.getNewPosts('first')).resolves.toEqual([]);
|
|
await expect(client2.getNewPosts('second')).rejects.toMatchObject({
|
|
name: 'RedditRateLimitError', retryAfterMs: 120_000,
|
|
});
|
|
expect(scheduler.extendCooldown).toHaveBeenCalledWith(120_000);
|
|
});
|
|
|
|
it('throws a compact error with the Reddit rate-limit reset delay on 429', async () => {
|
|
const error = {
|
|
isAxiosError: true,
|
|
response: { status: 429, headers: { 'x-ratelimit-reset': '57' } },
|
|
};
|
|
const mockAxios = { get: vi.fn().mockRejectedValue(error) };
|
|
vi.mocked(axios.create).mockReturnValue(mockAxios as any);
|
|
|
|
const client2 = new RedditApiClient(getTestConfig(), logger);
|
|
const result = client2.getNewPosts('test');
|
|
|
|
await expect(result).rejects.toBeInstanceOf(RedditRateLimitError);
|
|
await expect(result).rejects.toMatchObject({ retryAfterMs: 57_000 });
|
|
expect(logger.logs.some((entry) =>
|
|
entry.level === 'warn' && entry.message.includes('retry after 57s'),
|
|
)).toBe(true);
|
|
});
|
|
|
|
it('extends the shared request cooldown when Reddit returns 429', async () => {
|
|
const error = {
|
|
isAxiosError: true,
|
|
response: { status: 429, headers: { 'retry-after': '12' } },
|
|
};
|
|
const mockAxios = { get: vi.fn().mockRejectedValue(error) };
|
|
vi.mocked(axios.create).mockReturnValue(mockAxios as unknown as AxiosInstance);
|
|
const scheduler: RedditRequestScheduler = {
|
|
schedule: async (request) => await request(),
|
|
extendCooldown: vi.fn(),
|
|
};
|
|
const client2 = new RedditApiClient(getTestConfig(), logger, scheduler);
|
|
|
|
await expect(client2.getNewPosts('test')).rejects.toMatchObject({ retryAfterMs: 12_000 });
|
|
expect(scheduler.extendCooldown).toHaveBeenCalledWith(12_000);
|
|
});
|
|
|
|
it('parses valid image posts from response', async () => {
|
|
const mockResponse = {
|
|
data: { data: { children: [
|
|
{
|
|
data: {
|
|
id: 't3_1',
|
|
name: 't3_1',
|
|
subreddit: 'test',
|
|
author: 'user1',
|
|
title: 'Image post 1',
|
|
url: 'https://i.redd.it/abc123.jpg',
|
|
permalink: '/r/test/comments/abc/1',
|
|
thumbnail: 'https://i.redd.it/abc123.jpg',
|
|
is_self: false,
|
|
post_hint: 'image',
|
|
created_utc: 1000000,
|
|
over_18: false,
|
|
spoiler: false,
|
|
},
|
|
},
|
|
{
|
|
data: {
|
|
id: 't3_2',
|
|
name: 't3_2',
|
|
subreddit: 'test',
|
|
author: 'user2',
|
|
title: 'Text post',
|
|
url: 'https://www.reddit.com/r/test/comments/abc/2',
|
|
permalink: '/r/test/comments/abc/2',
|
|
thumbnail: 'self',
|
|
is_self: true,
|
|
created_utc: 1000001,
|
|
over_18: false,
|
|
spoiler: false,
|
|
},
|
|
},
|
|
] } },
|
|
};
|
|
const mockAxios = { get: vi.fn().mockResolvedValue(mockResponse) };
|
|
vi.mocked(axios.create).mockReturnValue(mockAxios as any);
|
|
|
|
const client2 = new RedditApiClient(getTestConfig(), logger);
|
|
const result = await client2.getNewPosts('test');
|
|
expect(result.length).toBe(1);
|
|
expect(result[0].id).toBe('t3_1');
|
|
expect(result[0].title).toBe('Image post 1');
|
|
});
|
|
|
|
it('keeps Reddit gallery posts whose images are stored in media metadata', async () => {
|
|
const galleryPost: RedditPost = {
|
|
id: 't3_gallery',
|
|
name: 't3_gallery',
|
|
subreddit: 'test',
|
|
author: 'gallery-user',
|
|
title: 'Gallery post',
|
|
url: 'https://www.reddit.com/gallery/t3_gallery',
|
|
permalink: '/r/test/comments/t3_gallery/gallery_post',
|
|
thumbnail: 'nsfw',
|
|
is_self: false,
|
|
is_gallery: true,
|
|
gallery_data: { items: [{ media_id: 'media-1' }] },
|
|
media_metadata: {
|
|
'media-1': {
|
|
status: 'valid',
|
|
m: 'image/jpg',
|
|
s: { u: 'https://preview.redd.it/gallery-image.jpg' },
|
|
},
|
|
},
|
|
created_utc: 1000000,
|
|
over_18: true,
|
|
spoiler: false,
|
|
};
|
|
const mockAxios = {
|
|
get: vi.fn().mockResolvedValue({
|
|
data: { data: { children: [{ data: galleryPost }] } },
|
|
}),
|
|
};
|
|
vi.mocked(axios.create).mockReturnValue(mockAxios as unknown as AxiosInstance);
|
|
const client2 = new RedditApiClient(getTestConfig(), logger);
|
|
|
|
await expect(client2.getNewPosts('test')).resolves.toEqual([galleryPost]);
|
|
expect(client2.getImageUrl(galleryPost)).toBe('https://preview.redd.it/gallery-image.jpg');
|
|
});
|
|
|
|
it('filters out deleted authors', async () => {
|
|
const mockResponse = {
|
|
data: { data: { children: [
|
|
{
|
|
data: {
|
|
id: 't3_del',
|
|
name: 't3_del',
|
|
subreddit: 'test',
|
|
author: '[deleted]',
|
|
title: 'Deleted',
|
|
url: 'https://i.redd.it/img.jpg',
|
|
permalink: '/r/test/comments/abc/del',
|
|
thumbnail: 'https://i.redd.it/img.jpg',
|
|
is_self: false,
|
|
created_utc: 1000000,
|
|
over_18: false,
|
|
spoiler: false,
|
|
},
|
|
},
|
|
] } },
|
|
};
|
|
const mockAxios = { get: vi.fn().mockResolvedValue(mockResponse) };
|
|
vi.mocked(axios.create).mockReturnValue(mockAxios as any);
|
|
|
|
const client2 = new RedditApiClient(getTestConfig(), logger);
|
|
const result = await client2.getNewPosts('test');
|
|
expect(result.length).toBe(0);
|
|
});
|
|
|
|
it('filters out AutoModerator posts', async () => {
|
|
const mockResponse = {
|
|
data: { data: { children: [
|
|
{
|
|
data: {
|
|
id: 't3_mod',
|
|
name: 't3_mod',
|
|
subreddit: 'test',
|
|
author: 'AutoModerator',
|
|
title: 'Mod message',
|
|
url: 'https://i.redd.it/img.jpg',
|
|
permalink: '/r/test/comments/abc/mod',
|
|
thumbnail: 'https://i.redd.it/img.jpg',
|
|
is_self: false,
|
|
created_utc: 1000000,
|
|
over_18: false,
|
|
spoiler: false,
|
|
},
|
|
},
|
|
] } },
|
|
};
|
|
const mockAxios = { get: vi.fn().mockResolvedValue(mockResponse) };
|
|
vi.mocked(axios.create).mockReturnValue(mockAxios as any);
|
|
|
|
const client2 = new RedditApiClient(getTestConfig(), logger);
|
|
const result = await client2.getNewPosts('test');
|
|
expect(result.length).toBe(0);
|
|
});
|
|
});
|
|
});
|