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 { 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 = {}) { const saved = new Map(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); }); });