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('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; beforeEach(() => { logger = createMockLogger(); const config = getTestConfig(); client = new RedditApiClient(config, logger); vi.clearAllMocks(); }); describe('hasImage', () => { const validPostFactory = (overrides: Partial = {}): 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); }); }); });