621 lines
26 KiB
TypeScript
621 lines
26 KiB
TypeScript
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<string> = new Set()): PostsRepository {
|
|
return {
|
|
isProcessed: vi.fn((id: string) => processedIds.has(id)),
|
|
isProcessedAnywhere: 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('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.insert).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; 1 eligible (selected); 0 outside recent window; 1 not evaluated after outcome',
|
|
}));
|
|
});
|
|
|
|
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('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');
|
|
});
|
|
});
|
|
});
|