import { describe, it, expect, vi, beforeEach } from 'vitest'; import { MonitorService } from '../src/services/monitor.js'; import { RedditRateLimitError, type RedditApiClient, type RedditPost } from '../src/reddit/api.js'; import type { PostsRepository, MessagesRepository } from '../src/database/repositories.js'; import { createMockLogger } from '../tests/mockLogger.js'; import { getTestConfig } from '../tests/testUtils.js'; function createMockRedditApi(posts: RedditPost[] = []): RedditApiClient { return { getNewPosts: vi.fn(async () => posts), getImageUrl: vi.fn(() => 'http://example.com/img.jpg'), getPostUrl: vi.fn(() => 'https://www.reddit.com/test'), isSellerProfile: vi.fn(async () => false), } as unknown as RedditApiClient; } function createMockPostsRepo(processedIds: Set = new Set()): PostsRepository { return { isProcessed: vi.fn((id: string) => processedIds.has(id)), insert: vi.fn(), updateAnalysis: vi.fn(), markMessageSent: vi.fn(), setError: vi.fn(), getById: vi.fn(() => undefined), getByAuthor: vi.fn(() => []), } as unknown as PostsRepository; } function createMockMessagesRepo(sentRecipients: Set = new Set()): MessagesRepository { return { hasSentTo: vi.fn((recipient: string) => sentRecipients.has(recipient)), insert: vi.fn(() => 1), getByPostId: vi.fn(), getRecent: vi.fn(), getUncheckedMessages: vi.fn(), markGotResponse: vi.fn(), markChecked: vi.fn(), getStrategyStats: vi.fn(() => []), getSubredditStats: vi.fn(() => []), getResponseRateTrend: vi.fn(() => []), } as unknown as MessagesRepository; } describe('MonitorService', () => { let monitor: MonitorService; let logger: ReturnType; beforeEach(() => { logger = createMockLogger(); }); it('returns posts from first subreddit with valid posts', async () => { const post1: RedditPost = { id: 't3_1', subreddit: 'sub1', author: 'user1', title: 'Post 1', url: 'http://example.com/1.jpg', permalink: '/r/sub1/1', thumbnail: '', is_self: false, created_utc: Date.now() / 1000, over_18: false, spoiler: false, }; const redditApi = createMockRedditApi([post1]); const postsRepo = createMockPostsRepo(); const messagesRepo = createMockMessagesRepo(); const config = getTestConfig({ reddit: { subreddits: [{ name: 'sub1', type: 'image' }, { name: 'sub2', type: 'image' }] } }); monitor = new MonitorService(redditApi, postsRepo, messagesRepo, config, logger, 0); const result = await monitor.getUnprocessedPosts(); expect(result.length).toBe(1); expect(result[0].id).toBe('t3_1'); }); it('skips processed posts', async () => { const post1: RedditPost = { id: 't3_1', subreddit: 'sub1', author: 'user1', title: 'Post 1', url: 'http://example.com/1.jpg', permalink: '/r/sub1/1', thumbnail: '', is_self: false, created_utc: Date.now() / 1000, over_18: false, spoiler: false, }; const redditApi = createMockRedditApi([post1]); const postsRepo = createMockPostsRepo(new Set(['t3_1'])); const messagesRepo = createMockMessagesRepo(); const config = getTestConfig({ reddit: { subreddits: [{ name: 'sub1', type: 'image' }] } }); monitor = new MonitorService(redditApi, postsRepo, messagesRepo, config, logger, 0); const result = await monitor.getUnprocessedPosts(); expect(result.length).toBe(0); }); it('skips posts from users already messaged', async () => { const post1: RedditPost = { id: 't3_1', subreddit: 'sub1', author: 'already_msgd', title: 'Post 1', url: 'http://example.com/1.jpg', permalink: '/r/sub1/1', thumbnail: '', is_self: false, created_utc: Date.now() / 1000, over_18: false, spoiler: false, }; const redditApi = createMockRedditApi([post1]); const postsRepo = createMockPostsRepo(); const messagesRepo = createMockMessagesRepo(new Set(['already_msgd'])); const config = getTestConfig({ reddit: { subreddits: [{ name: 'sub1', type: 'image' }] } }); monitor = new MonitorService(redditApi, postsRepo, messagesRepo, config, logger, 0); const result = await monitor.getUnprocessedPosts(); expect(result.length).toBe(0); expect(messagesRepo.hasSentTo).toHaveBeenCalledWith('already_msgd'); }); it('records seller posts as processed and continues to a clean candidate', async () => { const sellerPost: RedditPost = { id: 't3_seller', subreddit: 'sub1', author: 'seller', title: 'My page', selftext: 'OnlyFans in bio', url: 'http://example.com/1.jpg', permalink: '/r/sub1/1', thumbnail: '', is_self: false, created_utc: 2, over_18: true, spoiler: false, }; const cleanPost: RedditPost = { id: 't3_clean', subreddit: 'sub1', author: 'clean', title: 'Hello', url: 'http://example.com/2.jpg', permalink: '/r/sub1/2', thumbnail: '', is_self: false, created_utc: 1, over_18: true, spoiler: false, }; const redditApi = createMockRedditApi([sellerPost, cleanPost]); vi.mocked(redditApi.isSellerProfile).mockImplementation(async (username) => username === 'seller'); const postsRepo = createMockPostsRepo(); const config = getTestConfig({ reddit: { subreddits: [{ name: 'sub1', type: 'image' }] } }); monitor = new MonitorService(redditApi, postsRepo, createMockMessagesRepo(), config, logger, 0); await expect(monitor.getUnprocessedPosts()).resolves.toEqual([cleanPost]); expect(redditApi.isSellerProfile).toHaveBeenCalledWith( 'seller', config.reddit.sellerKeywords, config.reddit.sellerDomains, sellerPost, ); expect(postsRepo.insert).toHaveBeenCalledWith(expect.objectContaining({ id: 't3_seller' })); expect(postsRepo.setError).toHaveBeenCalledWith('t3_seller', 'Seller signals detected'); }); it('returns newest post when multiple valid posts exist', async () => { const post1: RedditPost = { id: 't3_1', subreddit: 'sub1', author: 'user1', title: 'Older', url: 'http://example.com/1.jpg', permalink: '/r/sub1/1', thumbnail: '', is_self: false, created_utc: 1000000, over_18: false, spoiler: false, }; const post2: RedditPost = { id: 't3_2', subreddit: 'sub1', author: 'user2', title: 'Newer', url: 'http://example.com/2.jpg', permalink: '/r/sub1/2', thumbnail: '', is_self: false, created_utc: 2000000, over_18: false, spoiler: false, }; const redditApi = createMockRedditApi([post1, post2]); const postsRepo = createMockPostsRepo(); const messagesRepo = createMockMessagesRepo(); const config = getTestConfig({ reddit: { subreddits: [{ name: 'sub1', type: 'image' }] } }); monitor = new MonitorService(redditApi, postsRepo, messagesRepo, config, logger, 0); const result = await monitor.getUnprocessedPosts(); expect(result.length).toBe(1); expect(result[0].id).toBe('t3_2'); expect(redditApi.isSellerProfile).toHaveBeenCalledTimes(1); expect(redditApi.isSellerProfile).toHaveBeenCalledWith( 'user2', config.reddit.sellerKeywords, config.reddit.sellerDomains, post2, ); }); it('finishes the seller and gender checks for one candidate before moving to the next', async () => { const newer: RedditPost = { id: 't3_newer', subreddit: 'chat-sub', author: 'newer', title: '[30M] hello', url: '', permalink: '/r/chat-sub/newer', thumbnail: '', is_self: true, created_utc: 2, over_18: false, spoiler: false, }; const eligible: RedditPost = { id: 't3_eligible', subreddit: 'chat-sub', author: 'eligible', title: '[25F] hello', url: '', permalink: '/r/chat-sub/eligible', thumbnail: '', is_self: true, created_utc: 1, over_18: false, spoiler: false, }; const redditApi = createMockRedditApi([eligible, newer]); const config = getTestConfig({ reddit: { subreddits: [{ name: 'chat-sub', type: 'chat' }], targetGenders: ['female'], }, }); const postsRepo = createMockPostsRepo(); monitor = new MonitorService( redditApi, postsRepo, createMockMessagesRepo(), config, logger, 0, ); await expect(monitor.getUnprocessedPosts()).resolves.toEqual([eligible]); expect(vi.mocked(redditApi.isSellerProfile).mock.calls.map(([username]) => username)) .toEqual(['newer', 'eligible']); expect(postsRepo.insert).toHaveBeenCalledWith(expect.objectContaining({ id: 't3_newer' })); expect(postsRepo.setError).toHaveBeenCalledWith('t3_newer', 'Gender tag "male" not targeted'); }); it('tries next subreddit when first has no valid posts', async () => { const post1: RedditPost = { id: 't3_1', subreddit: 'sub2', author: 'user1', title: 'Post', url: 'http://example.com/1.jpg', permalink: '/r/sub2/1', thumbnail: '', is_self: false, created_utc: Date.now() / 1000, over_18: false, spoiler: false, }; const redditApi = createMockRedditApi([post1]); const postsRepo1 = createMockPostsRepo(); const postsRepo2 = createMockPostsRepo(); // First call returns empty (all processed), second returns valid post let callCount = 0; postsRepo1.isProcessed = vi.fn(() => { callCount++; return callCount <= 1; }); const messagesRepo = createMockMessagesRepo(); const config = getTestConfig({ reddit: { subreddits: [{ name: 'sub1', type: 'image' }, { name: 'sub2', type: 'image' }] } }); // Override to use postsRepo1 for first check, postsRepo2 for second monitor = new MonitorService(redditApi, postsRepo1, messagesRepo, config, logger, 0); // The monitor shuffles subreddits, so we can't guarantee order. // But we can verify it tries multiple subreddits. const result = await monitor.getUnprocessedPosts(); // Result depends on shuffle - just verify it doesn't crash expect(Array.isArray(result)).toBe(true); }); it('prefers a recent post over an older one when prioritizeRecentPosts is enabled', async () => { const now = Math.floor(Date.now() / 1000); const oldPost: RedditPost = { id: 't3_old', subreddit: 'sub1', author: 'u_old', title: 'Old', url: 'http://example.com/old.jpg', permalink: '/r/sub1/old', thumbnail: '', is_self: false, created_utc: now - 7200, over_18: false, spoiler: false, }; const recentPost: RedditPost = { id: 't3_new', subreddit: 'sub2', author: 'u_new', title: 'New', url: 'http://example.com/new.jpg', permalink: '/r/sub2/new', thumbnail: '', is_self: false, created_utc: now - 600, over_18: false, spoiler: false, }; const redditApi = { getNewPosts: vi.fn(async (sub: string) => sub === 'sub1' ? [oldPost] : [recentPost], ), getImageUrl: vi.fn(), getPostUrl: vi.fn(), isSellerProfile: vi.fn(async () => false), } as unknown as RedditApiClient; const postsRepo = createMockPostsRepo(); const messagesRepo = createMockMessagesRepo(); const config = getTestConfig({ reddit: { subreddits: [{ name: 'sub1', type: 'image' }, { name: 'sub2', type: 'image' }], prioritizeRecentPosts: true, recentWindowMinutes: 60, }, }); monitor = new MonitorService(redditApi, postsRepo, messagesRepo, config, logger, 0); const result = await monitor.getUnprocessedPosts(); expect(result.length).toBe(1); expect(result[0].id).toBe('t3_new'); }); it('falls back to the newest older post when no recent posts exist', async () => { const now = Math.floor(Date.now() / 1000); const oldPost: RedditPost = { id: 't3_old', subreddit: 'sub1', author: 'u_old', title: 'Old', url: 'http://example.com/old.jpg', permalink: '/r/sub1/old', thumbnail: '', is_self: false, created_utc: now - 7200, over_18: false, spoiler: false, }; const redditApi = createMockRedditApi([oldPost]); const postsRepo = createMockPostsRepo(); const messagesRepo = createMockMessagesRepo(); const config = getTestConfig({ reddit: { subreddits: [{ name: 'sub1', type: 'image' }], prioritizeRecentPosts: true, recentWindowMinutes: 60, }, }); monitor = new MonitorService(redditApi, postsRepo, messagesRepo, config, logger, 0); const result = await monitor.getUnprocessedPosts(); expect(result.length).toBe(1); expect(result[0].id).toBe('t3_old'); }); it('returns empty array when no subreddits have valid posts', async () => { const redditApi = createMockRedditApi([]); const postsRepo = createMockPostsRepo(); const messagesRepo = createMockMessagesRepo(); const config = getTestConfig({ reddit: { subreddits: [{ name: 'sub1', type: 'image' }, { name: 'sub2', type: 'image' }] } }); monitor = new MonitorService(redditApi, postsRepo, messagesRepo, config, logger, 0); const result = await monitor.getUnprocessedPosts(); expect(result).toEqual([]); }); it('handles API errors gracefully', async () => { const redditApi = createMockRedditApi([]); vi.mocked(redditApi.getNewPosts).mockRejectedValue(new Error('API error')); const postsRepo = createMockPostsRepo(); const messagesRepo = createMockMessagesRepo(); const config = getTestConfig({ reddit: { subreddits: [{ name: 'sub1', type: 'image' }] } }); monitor = new MonitorService(redditApi, postsRepo, messagesRepo, config, logger, 0); const result = await monitor.getUnprocessedPosts(); expect(result).toEqual([]); expect(logger.logs.some(l => l.level === 'error' && l.message.includes('Failed to fetch'))).toBe(true); }); it('stops scanning and propagates Reddit rate limits without logging the error object', async () => { const redditApi = createMockRedditApi([]); vi.mocked(redditApi.getNewPosts).mockRejectedValue(new RedditRateLimitError(57_000)); const config = getTestConfig({ reddit: { subreddits: [ { name: 'sub1', type: 'image' }, { name: 'sub2', type: 'image' }, ], }, }); monitor = new MonitorService( redditApi, createMockPostsRepo(), createMockMessagesRepo(), config, logger, 0, ); await expect(monitor.getUnprocessedPosts()).rejects.toMatchObject({ retryAfterMs: 57_000 }); expect(redditApi.getNewPosts).toHaveBeenCalledTimes(1); expect(logger.logs.some((entry) => entry.level === 'error')).toBe(false); }); it('returns only one post per call', async () => { const posts: RedditPost[] = Array.from({ length: 10 }, (_, i) => ({ id: `t3_${i}`, subreddit: 'sub1', author: `user${i}`, title: `Post ${i}`, url: `http://example.com/${i}.jpg`, permalink: `/r/sub1/${i}`, thumbnail: '', is_self: false, created_utc: Date.now() / 1000 - i, over_18: false, spoiler: false, })); const redditApi = createMockRedditApi(posts); const postsRepo = createMockPostsRepo(); const messagesRepo = createMockMessagesRepo(); const config = getTestConfig({ reddit: { subreddits: [{ name: 'sub1', type: 'image' }] } }); monitor = new MonitorService(redditApi, postsRepo, messagesRepo, config, logger, 0); const result = await monitor.getUnprocessedPosts(); expect(result.length).toBe(1); }); it('shuffles subreddit order (non-deterministic)', async () => { const config = getTestConfig({ reddit: { subreddits: [{ name: 'a', type: 'image' }, { name: 'b', type: 'image' }, { name: 'c', type: 'image' }, { name: 'd', type: 'image' }, { name: 'e', type: 'image' }] } }); const redditApi = createMockRedditApi([]); const postsRepo = createMockPostsRepo(); const messagesRepo = createMockMessagesRepo(); monitor = new MonitorService(redditApi, postsRepo, messagesRepo, config, logger, 0); // Run multiple times and verify the order changes (with high probability) const orders: string[][] = []; for (let i = 0; i < 10; i++) { // We can't directly access the shuffled array, but we can verify // that the shuffle function works by checking it produces different results // Since getNewPosts returns empty, we just verify no errors await monitor.getUnprocessedPosts(); } // If shuffle works, 10 runs should produce varied behavior // (We can't directly test the shuffle, but we can verify it doesn't crash) }); it('limits results to 10 per author via postsRepo', async () => { const post: RedditPost = { id: 't3_1', subreddit: 'sub1', author: 'user1', title: 'Post', url: 'http://example.com/1.jpg', permalink: '/r/sub1/1', thumbnail: '', is_self: false, created_utc: Date.now() / 1000, over_18: false, spoiler: false, }; const redditApi = createMockRedditApi([post]); const postsRepo = createMockPostsRepo(); const messagesRepo = createMockMessagesRepo(); const config = getTestConfig({ reddit: { subreddits: [{ name: 'sub1', type: 'image' }] } }); monitor = new MonitorService(redditApi, postsRepo, messagesRepo, config, logger, 0); await monitor.getUnprocessedPosts(); // Verify isProcessed was called expect(postsRepo.isProcessed).toHaveBeenCalledWith('t3_1'); }); describe('image/chat interleaving', () => { it('alternates between image and chat subs across calls when both have no posts', async () => { const redditApi = createMockRedditApi([]); const postsRepo = createMockPostsRepo(); const messagesRepo = createMockMessagesRepo(); const config = getTestConfig({ reddit: { subreddits: [ { name: 'i1', type: 'image' }, { name: 'i2', type: 'image' }, { name: 'i3', type: 'image' }, { name: 'c1', type: 'chat' }, ], }, }); monitor = new MonitorService(redditApi, postsRepo, messagesRepo, config, logger, 0); // First call: should visit one chat sub among the first two positions await monitor.getUnprocessedPosts(); const calls1 = vi.mocked(redditApi.getNewPosts).mock.calls.map((c) => c[0]); // c1 (the only chat sub) must appear within the first 2 positions in // each call, regardless of which type goes first. expect(calls1.slice(0, 2)).toContain('c1'); vi.mocked(redditApi.getNewPosts).mockClear(); await monitor.getUnprocessedPosts(); const calls2 = vi.mocked(redditApi.getNewPosts).mock.calls.map((c) => c[0]); expect(calls2.slice(0, 2)).toContain('c1'); // Across two calls, the leading subreddit should be a chat sub at // least once (proves the flip — pure image-first would never hit c1 // at position 0). expect([calls1[0], calls2[0]]).toContain('c1'); }); it('biases earlier check positions toward higher-weighted subs', async () => { const redditApi = createMockRedditApi([]); const postsRepo = createMockPostsRepo(); const messagesRepo = createMockMessagesRepo(); const config = getTestConfig({ reddit: { subreddits: [ { name: 'heavy', type: 'image', weight: 100 }, { name: 'light1', type: 'image', weight: 1 }, { name: 'light2', type: 'image', weight: 1 }, { name: 'light3', type: 'image', weight: 1 }, ], }, }); monitor = new MonitorService(redditApi, postsRepo, messagesRepo, config, logger, 0); let heavyFirstCount = 0; const trials = 200; for (let i = 0; i < trials; i++) { vi.mocked(redditApi.getNewPosts).mockClear(); await monitor.getUnprocessedPosts(); const first = vi.mocked(redditApi.getNewPosts).mock.calls[0]?.[0]; if (first === 'heavy') heavyFirstCount++; } // Uniform would put 'heavy' first ~25% of the time. With weight 100 vs // 1+1+1, expected ≈ 100/103 ≈ 97%. Allow generous slack for randomness. expect(heavyFirstCount / trials).toBeGreaterThan(0.85); }); it('passes subreddit type to redditApi.getNewPosts', async () => { const redditApi = createMockRedditApi([]); const postsRepo = createMockPostsRepo(); const messagesRepo = createMockMessagesRepo(); const config = getTestConfig({ reddit: { subreddits: [ { name: 'img-sub', type: 'image' }, { name: 'chat-sub', type: 'chat' }, ], }, }); monitor = new MonitorService(redditApi, postsRepo, messagesRepo, config, logger, 0); await monitor.getUnprocessedPosts(); const calls = vi.mocked(redditApi.getNewPosts).mock.calls; const imgCall = calls.find((c) => c[0] === 'img-sub'); const chatCall = calls.find((c) => c[0] === 'chat-sub'); expect(imgCall?.[2]).toBe('image'); expect(chatCall?.[2]).toBe('chat'); }); }); describe('chat-sub gender pre-filter', () => { function makeChatPost(id: string, title: string, selftext = ''): RedditPost { return { id, subreddit: 'chat-sub', author: `user_${id}`, title, url: '', permalink: `/r/chat-sub/${id}`, thumbnail: '', is_self: true, selftext, created_utc: Date.now() / 1000, over_18: false, spoiler: false, } as RedditPost; } it('drops chat posts whose gender tag does not match targetGenders', async () => { const malePost = makeChatPost('t3_m', '[30M] looking for someone'); const femalePost = makeChatPost('t3_f', '[25F] bored at home'); const redditApi = createMockRedditApi([malePost, femalePost]); const postsRepo = createMockPostsRepo(); const messagesRepo = createMockMessagesRepo(); const config = getTestConfig({ reddit: { subreddits: [{ name: 'chat-sub', type: 'chat' }], targetGenders: ['female'], }, }); monitor = new MonitorService(redditApi, postsRepo, messagesRepo, config, logger, 0); const result = await monitor.getUnprocessedPosts(); expect(result.length).toBe(1); expect(result[0].id).toBe('t3_f'); }); it('drops chat posts with no gender tag when targetGenders is set', async () => { const untagged = makeChatPost('t3_u', 'hey anyone wanna chat'); const tagged = makeChatPost('t3_f', '[22F] hi'); const redditApi = createMockRedditApi([untagged, tagged]); const postsRepo = createMockPostsRepo(); const messagesRepo = createMockMessagesRepo(); const config = getTestConfig({ reddit: { subreddits: [{ name: 'chat-sub', type: 'chat' }], targetGenders: ['female'], }, }); monitor = new MonitorService(redditApi, postsRepo, messagesRepo, config, logger, 0); const result = await monitor.getUnprocessedPosts(); expect(result.length).toBe(1); expect(result[0].id).toBe('t3_f'); }); it('does not filter image-sub posts by gender tag', async () => { const imgPost: RedditPost = { id: 't3_img', subreddit: 'img-sub', author: 'user1', title: 'just a pic', url: 'http://example.com/x.jpg', permalink: '/r/img-sub/1', thumbnail: '', is_self: false, created_utc: Date.now() / 1000, over_18: false, spoiler: false, }; const redditApi = createMockRedditApi([imgPost]); const postsRepo = createMockPostsRepo(); const messagesRepo = createMockMessagesRepo(); const config = getTestConfig({ reddit: { subreddits: [{ name: 'img-sub', type: 'image' }], targetGenders: ['female'], }, }); monitor = new MonitorService(redditApi, postsRepo, messagesRepo, config, logger, 0); const result = await monitor.getUnprocessedPosts(); expect(result.length).toBe(1); expect(result[0].id).toBe('t3_img'); }); it('keeps all chat posts when targetGenders is empty', async () => { const malePost = makeChatPost('t3_m', '[30M] hi'); const redditApi = createMockRedditApi([malePost]); const postsRepo = createMockPostsRepo(); const messagesRepo = createMockMessagesRepo(); const config = getTestConfig({ reddit: { subreddits: [{ name: 'chat-sub', type: 'chat' }], targetGenders: [], }, }); monitor = new MonitorService(redditApi, postsRepo, messagesRepo, config, logger, 0); const result = await monitor.getUnprocessedPosts(); expect(result.length).toBe(1); expect(result[0].id).toBe('t3_m'); }); }); });