Files
RedditBot/tests/monitor.test.ts
T
jjbruntonandClaude Opus 4.7 2260388dc8 Blur gallery images by default, sync progress UI, and add chat/monitor tests
- Blur NSFW media gallery images until hover/reveal
- Surface chat sync progress in the dashboard header
- Extend chat service, cache, and monitor with delta-media handling
- Add tests for chat cooldown, delta media, and monitor flows

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 10:01:09 +01:00

495 lines
20 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from 'vitest';
import { MonitorService } from '../src/services/monitor.js';
import type { RedditApiClient, 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<string> = 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<string> = 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<typeof createMockLogger>;
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('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');
});
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('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');
});
});
});