Files
RedditBot/tests/monitor.test.ts
T

785 lines
35 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from 'vitest';
import { MonitorService, parseSelfDeclaredAge } from '../src/services/monitor.js';
import {
RedditRateLimitError,
RedditSubredditUnavailableError,
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<string> = new Set()): PostsRepository {
return {
isProcessed: vi.fn((id: string) => processedIds.has(id)),
isProcessedAnywhere: vi.fn((id: string) => processedIds.has(id)),
insert: vi.fn(),
insertIfAbsent: vi.fn(() => true),
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('disables a subreddit when Reddit returns a terminal listing 404', async () => {
const redditApi = createMockRedditApi();
vi.mocked(redditApi.getNewPosts).mockRejectedValueOnce(new RedditSubredditUnavailableError('banned-sub'));
const pollingControl = {
disableUnavailableSubreddit: vi.fn(),
isSubredditEnabled: vi.fn(() => true),
};
const config = getTestConfig({ reddit: { subreddits: [{ name: 'banned-sub', type: 'image' }] } });
monitor = new MonitorService(
redditApi, createMockPostsRepo(), createMockMessagesRepo(), config, logger, 0, pollingControl,
);
await expect(monitor.getUnprocessedPosts()).resolves.toEqual([]);
expect(pollingControl.disableUnavailableSubreddit).toHaveBeenCalledWith('banned-sub');
expect(logger.logs).toContainEqual(expect.objectContaining({
level: 'warn', message: expect.stringContaining('Disabled r/banned-sub: Reddit returned HTTP 404'),
}));
});
it('uses shared post history so another account cannot rediscover a post', async () => {
const post: RedditPost = {
id: 't3_shared', 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();
vi.mocked(postsRepo.isProcessed).mockReturnValue(false);
vi.mocked(postsRepo.isProcessedAnywhere).mockReturnValue(true);
const config = getTestConfig({ reddit: { subreddits: [{ name: 'sub1', type: 'image' }] } });
monitor = new MonitorService(redditApi, postsRepo, createMockMessagesRepo(), config, logger, 0);
await expect(monitor.getUnprocessedPosts()).resolves.toEqual([]);
expect(postsRepo.isProcessedAnywhere).toHaveBeenCalledWith(post.id);
});
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.insertIfAbsent).toHaveBeenCalledWith(expect.objectContaining({ id: 't3_seller' }));
expect(postsRepo.setError).toHaveBeenCalledWith('t3_seller', 'Seller signals detected');
});
it('logs a per-listing breakdown of screened and deferred posts', async () => {
const makePost = (id: string, author: string, created_utc: number): RedditPost => ({
id, subreddit: 'sub1', author, title: id,
url: `http://example.com/${id}.jpg`, permalink: `/r/sub1/${id}`, thumbnail: '',
is_self: false, created_utc, over_18: true, spoiler: false,
});
const seller = makePost('seller', 'seller', 4);
const sent = makePost('sent', 'sent', 3);
const selected = makePost('selected', 'selected', 2);
const deferred = makePost('deferred', 'deferred', 1);
const redditApi = createMockRedditApi([seller, sent, selected, deferred]);
vi.mocked(redditApi.isSellerProfile).mockImplementation(async (username) => username === 'seller');
const config = getTestConfig({ reddit: { subreddits: [{ name: 'sub1', type: 'image' }] } });
monitor = new MonitorService(
redditApi, createMockPostsRepo(), createMockMessagesRepo(new Set(['sent'])), config, logger, 0,
);
await expect(monitor.getUnprocessedPosts()).resolves.toEqual([selected]);
expect(logger.logs).toContainEqual(expect.objectContaining({
level: 'info',
message: 'r/sub1 listing: 4 fetched; 3 screened; 0 already processed; 1 already messaged; 1 seller signals; 0 missing gender tag; 0 gender not targeted; 0 missing age tag; 0 underage; 1 eligible (selected); 0 outside recent window; 1 not evaluated after selected recent candidate',
}));
});
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('runs free gender and age checks before seller profile requests', 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(['eligible']);
expect(postsRepo.insertIfAbsent).toHaveBeenCalledWith(expect.objectContaining({ id: 't3_newer' }));
expect(postsRepo.setError).toHaveBeenCalledWith('t3_newer', 'Gender tag "male" not targeted');
});
it('parses adult subreddit age tags without treating unrelated numbers as ages', () => {
expect(parseSelfDeclaredAge('28 [F4M] Online')).toBe(28);
expect(parseSelfDeclaredAge('[25F] hello')).toBe(25);
expect(parseSelfDeclaredAge('F 31 looking to chat')).toBe(31);
expect(parseSelfDeclaredAge('F4M, 100 percent bored')).toBeNull();
});
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('screens every older fallback candidate before selecting the newest one', async () => {
const now = Math.floor(Date.now() / 1000);
const fallback: RedditPost = {
id: 't3_fallback', subreddit: 'sub1', author: 'u_fallback', title: 'Fallback',
url: 'http://example.com/fallback.jpg', permalink: '/r/sub1/fallback', thumbnail: '',
is_self: false, created_utc: now - 7200, over_18: false, spoiler: false,
};
const older: RedditPost = { ...fallback, id: 't3_older', author: 'u_older', created_utc: now - 10_800 };
const config = getTestConfig({
reddit: {
subreddits: [{ name: 'sub1', type: 'image' }],
prioritizeRecentPosts: true,
recentWindowMinutes: 60,
},
});
const redditApi = createMockRedditApi([fallback, older]);
monitor = new MonitorService(redditApi, createMockPostsRepo(), createMockMessagesRepo(), config, logger, 0);
await expect(monitor.getUnprocessedPosts()).resolves.toEqual([fallback]);
expect(redditApi.isSellerProfile).toHaveBeenCalledTimes(2);
expect(logger.logs).toContainEqual(expect.objectContaining({
message: expect.stringContaining('2 eligible; 2 outside recent window'),
}));
});
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('checks shared processed-post history before considering a candidate', 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();
expect(postsRepo.isProcessedAnywhere).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');
});
});
describe('comment-author targeting', () => {
it('turns a high-confidence targeted reaction into a commenter candidate with source media intact', async () => {
const source: RedditPost = {
id: 'post1', name: 't3_post1', subreddit: 'scene', author: 'poster', title: 'A scene',
url: 'https://example.com/scene.jpg', permalink: '/r/scene/comments/post1/a_scene/', thumbnail: '',
is_self: false, created_utc: 100, over_18: true, spoiler: false,
};
const redditApi = createMockRedditApi([source]);
redditApi.getBrowsingComments = vi.fn().mockResolvedValue([
{
fullname: 't1_match', kind: 'comment', subreddit: 'scene', author: 'reactor',
permalink: '/r/scene/comments/post1/a_scene/match/', createdAt: 101_000, score: 2,
text: "I'd love that to be done to me", nsfw: true, parentPostId: 'post1',
distinguished: null, removed: false,
},
{
fullname: 't1_praise', kind: 'comment', subreddit: 'scene', author: 'fan',
permalink: '/r/scene/comments/post1/a_scene/praise/', createdAt: 102_000, score: 1,
text: 'Great post, thanks for sharing', nsfw: true, parentPostId: 'post1',
distinguished: null, removed: false,
},
]);
const screener = {
screenCommentReactions: vi.fn().mockResolvedValue([
{ id: 't1_match', qualifies: true, expressedGender: 'female', confidence: 0.94, evidence: 'personal desire' },
{ id: 't1_praise', qualifies: false, expressedGender: 'unknown', confidence: 0.98, evidence: 'generic praise' },
]),
};
const config = getTestConfig({ reddit: {
subreddits: [{ name: 'scene', type: 'image', targetingMode: 'comment-author' }],
targetGenders: ['female'],
} });
monitor = new MonitorService(
redditApi, createMockPostsRepo(), createMockMessagesRepo(), config, logger, 0, undefined, screener,
);
const candidates = await monitor.fetchCandidates(config.reddit.subreddits[0]);
expect(candidates).toHaveLength(1);
expect(candidates[0]).toMatchObject({
id: 't1_match', name: 't1_match', author: 'reactor', url: source.url,
permalink: '/r/scene/comments/post1/a_scene/match/',
targetComment: {
body: "I'd love that to be done to me", sourcePostId: 'post1',
sourcePostAuthor: 'poster', expressedGender: 'female', confidence: 0.94,
},
});
await monitor.fetchCandidates(config.reddit.subreddits[0]);
expect(screener.screenCommentReactions).toHaveBeenCalledTimes(1);
});
it('fails closed on low-confidence, non-qualifying, or non-targeted reactions', async () => {
const source = {
id: 'post1', name: 't3_post1', subreddit: 'scene', author: 'poster', title: 'A scene',
url: 'https://example.com/scene.jpg', permalink: '/r/scene/comments/post1/', thumbnail: '',
is_self: false, created_utc: 100, over_18: true, spoiler: false,
} as RedditPost;
const redditApi = createMockRedditApi([source]);
redditApi.getBrowsingComments = vi.fn().mockResolvedValue([
{ fullname: 't1_low', author: 'a', text: 'I want this as well', removed: false, createdAt: 101_000, permalink: '/low' },
{ fullname: 't1_male', author: 'b', text: 'I want to put my bulge there', removed: false, createdAt: 102_000, permalink: '/male' },
{ fullname: 't1_unknown', author: 'c', text: 'I would love this', removed: false, createdAt: 103_000, permalink: '/unknown' },
]);
const screener = { screenCommentReactions: vi.fn().mockResolvedValue([
{ id: 't1_low', qualifies: true, expressedGender: 'female', confidence: 0.79, evidence: '' },
{ id: 't1_male', qualifies: true, expressedGender: 'male', confidence: 0.99, evidence: 'self-referential male anatomy' },
{ id: 't1_unknown', qualifies: true, expressedGender: 'unknown', confidence: 0.95, evidence: 'no gender perspective' },
]) };
const config = getTestConfig({ reddit: {
subreddits: [{ name: 'scene', type: 'image', targetingMode: 'comment-author' }],
targetGenders: ['female'],
} });
monitor = new MonitorService(
redditApi, createMockPostsRepo(), createMockMessagesRepo(), config, logger, 0, undefined, screener,
);
await expect(monitor.fetchCandidates(config.reddit.subreddits[0])).resolves.toEqual([]);
});
it('allows an expressed gender when it is explicitly targeted', async () => {
const source = {
id: 'post1', name: 't3_post1', subreddit: 'scene', author: 'poster', title: 'A scene',
url: 'https://example.com/scene.jpg', permalink: '/r/scene/comments/post1/', thumbnail: '',
is_self: false, created_utc: 100, over_18: true, spoiler: false,
} as RedditPost;
const redditApi = createMockRedditApi([source]);
redditApi.getBrowsingComments = vi.fn().mockResolvedValue([
{ fullname: 't1_unknown', author: 'a', text: 'I would love this', removed: false, createdAt: 101_000, permalink: '/unknown' },
]);
const screener = { screenCommentReactions: vi.fn().mockResolvedValue([
{ id: 't1_unknown', qualifies: true, expressedGender: 'unknown', confidence: 0.95, evidence: '' },
]) };
const config = getTestConfig({ reddit: {
subreddits: [{ name: 'scene', type: 'image', targetingMode: 'comment-author' }],
targetGenders: ['unknown'],
} });
monitor = new MonitorService(
redditApi, createMockPostsRepo(), createMockMessagesRepo(), config, logger, 0, undefined, screener,
);
const candidates = await monitor.fetchCandidates(config.reddit.subreddits[0]);
expect(candidates.map((candidate) => candidate.id)).toEqual(['t1_unknown']);
});
});
});