Files
RedditBot/tests/seller-sweep.test.ts
T

288 lines
11 KiB
TypeScript

import { describe, expect, it, vi } from 'vitest';
import {
sweepConversationsForSellers,
type SellerSweepConversation,
type SellerSweepOptions,
} from '../src/services/seller-sweep.js';
import {
buildConversationState,
type ConversationState,
} from '../src/services/conversation-state.js';
const OPTIONS: SellerSweepOptions = {
sellerKeywords: ['onlyfans'],
sellerDomains: ['fansly.com'],
killConfidence: 0.8,
flagConfidence: 0.6,
killOnSellerKeyword: true,
offPlatformKill: true,
};
function conversation(overrides: Partial<SellerSweepConversation> = {}): SellerSweepConversation {
return {
conversationId: 'room-1',
username: 'alice',
messages: [
{ sender: 'me', text: 'how has your week been?', isSelf: true },
{ sender: 'alice', text: 'busy, but good', isSelf: false },
],
watermark: 'w1',
...overrides,
};
}
/** In-memory stand-in for the durable conversation state repository. */
function stateStore(initial: Record<string, ConversationState> = {}) {
const saved = new Map<string, ConversationState>(Object.entries(initial));
return {
saved,
get: (id: string) => saved.get(id),
save: vi.fn((id: string, state: ConversationState) => { saved.set(id, structuredClone(state)); }),
};
}
function screener(verdicts: Array<{ id: string; seller: boolean; confidence: number; evidence: string }>) {
return { screenConversationsForSellers: vi.fn().mockResolvedValue(verdicts) };
}
describe('sweepConversationsForSellers', () => {
it('flags configured keywords and domains without spending an AI call', async () => {
const states = stateStore();
const ai = screener([]);
const summary = await sweepConversationsForSellers(
[
conversation({
conversationId: 'room-keyword',
messages: [{ sender: 'alice', text: 'everything is on my onlyfans', isSelf: false }],
}),
conversation({
conversationId: 'room-domain',
username: 'bea',
messages: [{ sender: 'bea', text: 'check fansly.com/bea for more', isSelf: false }],
}),
],
OPTIONS,
{ states, screener: ai },
);
expect(ai.screenConversationsForSellers).not.toHaveBeenCalled();
expect(summary.deterministic).toBe(2);
expect(summary.aiRequests).toBe(0);
expect(summary.flagged.map((flag) => flag.source)).toEqual(['funnel', 'funnel']);
expect(summary.flagged.every((flag) => flag.killed)).toBe(true);
expect(states.saved.get('room-keyword')?.sellerDetected).toBe(true);
expect(states.saved.get('room-keyword')?.automation.status).toBe('killed');
});
it('catches obfuscated funnel language the keyword list does not enumerate', async () => {
const states = stateStore();
const summary = await sweepConversationsForSellers(
[conversation({ messages: [{ sender: 'alice', text: 'my links are in my bio babe', isSelf: false }] })],
OPTIONS,
{ states },
);
expect(summary.flagged).toHaveLength(1);
expect(summary.flagged[0].source).toBe('funnel');
});
it('treats contact-platform and payment pushes as deterministic seller outcomes', async () => {
const states = stateStore();
const summary = await sweepConversationsForSellers([
conversation({ conversationId: 'telegram', messages: [{ sender: 'alice', text: 'add me on telegram', isSelf: false }] }),
conversation({ conversationId: 'snap', messages: [{ sender: 'alice', text: 'my snapchat is seller123', isSelf: false }] }),
conversation({ conversationId: 'cash', messages: [{ sender: 'alice', text: 'cash app is $seller123', isSelf: false }] }),
], OPTIONS, { states });
expect(summary.flagged.map((flag) => flag.source)).toEqual([
'off-platform', 'off-platform', 'off-platform',
]);
expect(summary.flagged.every((flag) => flag.killed)).toBe(true);
});
it('hands the deterministic kill decision to the configured policy', async () => {
const states = stateStore();
const triage = { setStatus: vi.fn() };
const summary = await sweepConversationsForSellers(
[conversation({ messages: [{ sender: 'alice', text: 'you could spoil me', isSelf: false }] })],
{ ...OPTIONS, sellerKeywords: ['spoil me'], killOnSellerKeyword: false },
{ states, triage },
);
expect(summary.flagged[0].killed).toBe(false);
expect(triage.setStatus).not.toHaveBeenCalled();
expect(states.saved.get('room-1')?.sellerDetected).toBe(true);
expect(states.saved.get('room-1')?.automation.status).toBe('human-required');
});
it('batches the remaining conversations into a single classifier request', async () => {
const states = stateStore();
const ai = screener([
{ id: '1', seller: true, confidence: 0.92, evidence: 'points at a paid page' },
{ id: '2', seller: false, confidence: 0.1, evidence: 'ordinary chat' },
]);
const triage = { setStatus: vi.fn() };
const summary = await sweepConversationsForSellers(
[
conversation({ conversationId: 'room-1', username: 'alice' }),
conversation({ conversationId: 'room-2', username: 'bea' }),
],
OPTIONS,
{ states, screener: ai, triage },
);
expect(ai.screenConversationsForSellers).toHaveBeenCalledTimes(1);
expect(summary.aiRequests).toBe(1);
expect(summary.aiScreened).toBe(2);
expect(summary.flagged).toEqual([expect.objectContaining({
conversationId: 'room-1', username: 'alice', source: 'ai', confidence: 0.92, killed: true,
})]);
expect(triage.setStatus).toHaveBeenCalledWith('room-1', 'done', null);
expect(states.saved.get('room-2')?.sellerDetected).toBe(false);
});
it('flags without killing between the flag and kill confidence thresholds', async () => {
const states = stateStore();
const ai = screener([{ id: '1', seller: true, confidence: 0.7, evidence: 'hints at a menu' }]);
const triage = { setStatus: vi.fn() };
const summary = await sweepConversationsForSellers([conversation()], OPTIONS, { states, screener: ai, triage });
expect(summary.flagged[0].killed).toBe(false);
expect(triage.setStatus).not.toHaveBeenCalled();
expect(states.saved.get('room-1')?.automation.status).toBe('human-required');
});
it('treats a low-confidence seller verdict as clean', async () => {
const states = stateStore();
const ai = screener([{ id: '1', seller: true, confidence: 0.2, evidence: 'weak' }]);
const summary = await sweepConversationsForSellers([conversation()], OPTIONS, { states, screener: ai });
expect(summary.flagged).toHaveLength(0);
expect(states.saved.get('room-1')?.sellerScreenedKey).toBe('w1');
});
it('skips conversations that are unchanged since the last clean sweep', async () => {
const cleared = { ...buildConversationState([]), sellerScreenedKey: 'w1' };
const states = stateStore({ 'room-1': cleared });
const ai = screener([]);
const summary = await sweepConversationsForSellers([conversation()], OPTIONS, { states, screener: ai });
expect(ai.screenConversationsForSellers).not.toHaveBeenCalled();
expect(summary.unchanged).toBe(1);
expect(summary.aiScreened).toBe(0);
});
it('re-screens a conversation once a newer inbound message arrives', async () => {
const cleared = { ...buildConversationState([]), sellerScreenedKey: 'w1' };
const states = stateStore({ 'room-1': cleared });
const ai = screener([{ id: '1', seller: false, confidence: 0.1, evidence: '' }]);
const summary = await sweepConversationsForSellers(
[conversation({ watermark: 'w2' })],
OPTIONS,
{ states, screener: ai },
);
expect(ai.screenConversationsForSellers).toHaveBeenCalledTimes(1);
expect(summary.aiScreened).toBe(1);
expect(states.saved.get('room-1')?.sellerScreenedKey).toBe('w2');
});
it('never re-screens conversations already flagged or killed', async () => {
const flagged = { ...buildConversationState([]), sellerDetected: true };
const killed = buildConversationState([]);
killed.automation.status = 'killed';
const states = stateStore({ 'room-1': flagged, 'room-2': killed });
const ai = screener([]);
const summary = await sweepConversationsForSellers(
[conversation(), conversation({ conversationId: 'room-2', username: 'bea' })],
OPTIONS,
{ states, screener: ai },
);
expect(ai.screenConversationsForSellers).not.toHaveBeenCalled();
expect(summary.alreadyFlagged).toBe(2);
expect(summary.scanned).toBe(2);
});
it('skips conversations with nothing inbound to judge', async () => {
const states = stateStore();
const ai = screener([]);
const summary = await sweepConversationsForSellers(
[conversation({ messages: [{ sender: 'me', text: 'hey there', isSelf: true }] })],
OPTIONS,
{ states, screener: ai },
);
expect(ai.screenConversationsForSellers).not.toHaveBeenCalled();
expect(summary.emptySkipped).toBe(1);
});
it('honours the per-sweep AI budget and reports what it left behind', async () => {
const states = stateStore();
const ai = screener([{ id: '1', seller: false, confidence: 0.1, evidence: '' }]);
const summary = await sweepConversationsForSellers(
[conversation({ conversationId: 'room-1' }), conversation({ conversationId: 'room-2' })],
{ ...OPTIONS, maxAiConversations: 1 },
{ states, screener: ai },
);
expect(summary.aiScreened).toBe(1);
expect(summary.budgetSkipped).toBe(1);
expect(states.saved.has('room-2')).toBe(false);
});
it('degrades rather than fails when the classifier is unavailable, keeping the old watermark', async () => {
const states = stateStore();
const ai = { screenConversationsForSellers: vi.fn().mockRejectedValue(new Error('router down')) };
const logger = { info: vi.fn(), warn: vi.fn() };
const summary = await sweepConversationsForSellers([conversation()], OPTIONS, { states, screener: ai, logger });
expect(summary.degraded).toBe(true);
expect(summary.flagged).toHaveLength(0);
expect(states.save).not.toHaveBeenCalled();
expect(logger.warn).toHaveBeenCalled();
});
it('leaves a conversation the model omitted for the next sweep', async () => {
const states = stateStore();
const ai = screener([{ id: '1', seller: false, confidence: 0.1, evidence: '' }]);
const summary = await sweepConversationsForSellers(
[conversation({ conversationId: 'room-1' }), conversation({ conversationId: 'room-2' })],
OPTIONS,
{ states, screener: ai },
);
expect(summary.aiScreened).toBe(1);
expect(states.saved.has('room-2')).toBe(false);
});
it('splits large sweeps into fixed-size batches', async () => {
const states = stateStore();
const ai = { screenConversationsForSellers: vi.fn().mockResolvedValue([]) };
await sweepConversationsForSellers(
Array.from({ length: 5 }, (_, index) => conversation({ conversationId: `room-${index}` })),
{ ...OPTIONS, batchSize: 2 },
{ states, screener: ai },
);
expect(ai.screenConversationsForSellers).toHaveBeenCalledTimes(3);
expect(ai.screenConversationsForSellers.mock.calls[0][0]).toHaveLength(2);
expect(ai.screenConversationsForSellers.mock.calls[2][0]).toHaveLength(1);
});
});