reply-classifier.test.js
1 /** 2 * Tests for Reply Classifier Module 3 */ 4 5 import { test, describe, mock, beforeEach } from 'node:test'; 6 import assert from 'node:assert'; 7 8 // Mock dependencies before importing 9 let mockCallLLM; 10 let mockRetryWithBackoff; 11 12 mock.module('../../src/utils/llm-provider.js', { 13 namedExports: { 14 callLLM: (...args) => mockCallLLM(...args), 15 }, 16 }); 17 18 mock.module('../../src/utils/error-handler.js', { 19 namedExports: { 20 retryWithBackoff: (...args) => mockRetryWithBackoff(...args), 21 }, 22 }); 23 24 mock.module('../../src/utils/logger.js', { 25 defaultExport: class { 26 info() {} 27 warn() {} 28 error() {} 29 success() {} 30 debug() {} 31 }, 32 }); 33 34 mock.module('dotenv', { namedExports: { config: () => {} }, defaultExport: { config: () => {} } }); 35 36 const { classifyReply, classifyRepliesBatch } = await import('../../src/utils/reply-classifier.js'); 37 38 describe('Reply Classifier', () => { 39 beforeEach(() => { 40 // Default: retryWithBackoff just calls the function directly 41 mockRetryWithBackoff = async fn => fn(); 42 }); 43 44 describe('classifyReply - LLM classification', () => { 45 test('classifies interested reply correctly', async () => { 46 mockCallLLM = async () => ({ 47 content: JSON.stringify({ 48 classification: 'interested', 49 confidence: 0.95, 50 reasoning: 'Prospect asked about pricing', 51 }), 52 usage: { promptTokens: 100, completionTokens: 50 }, 53 }); 54 55 const result = await classifyReply('How much does the report cost?', 'email'); 56 assert.strictEqual(result.classification, 'interested'); 57 assert.strictEqual(result.confidence, 0.95); 58 assert.ok(result.reasoning.includes('pricing')); 59 assert.ok(result.model); 60 }); 61 62 test('classifies not_interested reply correctly', async () => { 63 mockCallLLM = async () => ({ 64 content: JSON.stringify({ 65 classification: 'not_interested', 66 confidence: 0.9, 67 reasoning: 'Prospect declined', 68 }), 69 usage: { promptTokens: 100, completionTokens: 50 }, 70 }); 71 72 const result = await classifyReply('No thanks, not interested', 'sms'); 73 assert.strictEqual(result.classification, 'not_interested'); 74 }); 75 76 test('classifies unsubscribe reply correctly', async () => { 77 mockCallLLM = async () => ({ 78 content: JSON.stringify({ 79 classification: 'unsubscribe', 80 confidence: 0.99, 81 reasoning: 'Prospect wants to opt out', 82 }), 83 usage: { promptTokens: 100, completionTokens: 50 }, 84 }); 85 86 const result = await classifyReply('STOP', 'sms'); 87 assert.strictEqual(result.classification, 'unsubscribe'); 88 }); 89 90 test('classifies question reply correctly', async () => { 91 mockCallLLM = async () => ({ 92 content: JSON.stringify({ 93 classification: 'question', 94 confidence: 0.7, 95 reasoning: 'Prospect has a question', 96 }), 97 usage: { promptTokens: 100, completionTokens: 50 }, 98 }); 99 100 const result = await classifyReply('Can you call me to discuss?', 'email'); 101 assert.strictEqual(result.classification, 'question'); 102 }); 103 104 test('includes token usage in result', async () => { 105 mockCallLLM = async () => ({ 106 content: JSON.stringify({ 107 classification: 'interested', 108 confidence: 0.85, 109 reasoning: 'Test', 110 }), 111 usage: { promptTokens: 200, completionTokens: 75 }, 112 }); 113 114 const result = await classifyReply('Yes please', 'email'); 115 assert.strictEqual(result.tokens.prompt, 200); 116 assert.strictEqual(result.tokens.completion, 75); 117 }); 118 119 test('passes context info to LLM when provided', async () => { 120 let capturedMessages; 121 mockCallLLM = async opts => { 122 capturedMessages = opts.messages; 123 return { 124 content: JSON.stringify({ 125 classification: 'interested', 126 confidence: 0.9, 127 reasoning: 'test', 128 }), 129 usage: { promptTokens: 100, completionTokens: 50 }, 130 }; 131 }; 132 133 await classifyReply('Sure thing', 'email', 'Original proposal was about CRO'); 134 assert.ok(capturedMessages[1].content.includes('Original proposal was about CRO')); 135 }); 136 137 test('defaults confidence to 0.8 when not provided by LLM', async () => { 138 mockCallLLM = async () => ({ 139 content: JSON.stringify({ 140 classification: 'interested', 141 reasoning: 'test', 142 }), 143 usage: { promptTokens: 100, completionTokens: 50 }, 144 }); 145 146 const result = await classifyReply('Yes', 'sms'); 147 assert.strictEqual(result.confidence, 0.8); 148 }); 149 150 test('throws for null messageBody', async () => { 151 await assert.rejects(() => classifyReply(null, 'email'), { 152 message: /messageBody is required/, 153 }); 154 }); 155 156 test('throws for non-string messageBody', async () => { 157 await assert.rejects(() => classifyReply(123, 'email'), { 158 message: /messageBody is required/, 159 }); 160 }); 161 }); 162 163 describe('classifyReply - fallback classification', () => { 164 beforeEach(() => { 165 // Make LLM call fail to trigger fallback 166 mockRetryWithBackoff = async () => { 167 throw new Error('LLM unavailable'); 168 }; 169 }); 170 171 test('falls back to unsubscribe for STOP keyword', async () => { 172 const result = await classifyReply('STOP', 'sms'); 173 assert.strictEqual(result.classification, 'unsubscribe'); 174 assert.strictEqual(result.model, 'fallback'); 175 }); 176 177 test('falls back to unsubscribe for opt out', async () => { 178 const result = await classifyReply('Please opt out my email', 'email'); 179 assert.strictEqual(result.classification, 'unsubscribe'); 180 }); 181 182 test('falls back to unsubscribe for remove request', async () => { 183 const result = await classifyReply('remove me from your list', 'email'); 184 assert.strictEqual(result.classification, 'unsubscribe'); 185 }); 186 187 test('falls back to not_interested for no thanks', async () => { 188 const result = await classifyReply('no thanks mate', 'sms'); 189 assert.strictEqual(result.classification, 'not_interested'); 190 }); 191 192 test('falls back to not_interested for already have', async () => { 193 const result = await classifyReply('We already have a web developer', 'email'); 194 assert.strictEqual(result.classification, 'not_interested'); 195 }); 196 197 test('falls back to not_interested for maybe later', async () => { 198 const result = await classifyReply('Maybe later, too busy right now', 'sms'); 199 assert.strictEqual(result.classification, 'not_interested'); 200 }); 201 202 test('falls back to interested for yes', async () => { 203 const result = await classifyReply('Yes, sounds good', 'email'); 204 assert.strictEqual(result.classification, 'interested'); 205 }); 206 207 test('falls back to interested for pricing inquiry', async () => { 208 const result = await classifyReply('How much does it cost?', 'email'); 209 assert.strictEqual(result.classification, 'interested'); 210 }); 211 212 test('falls back to question for send link (ambiguous)', async () => { 213 // "send" and "link" are explicitly treated as ambiguous (question), not interested 214 const result = await classifyReply('Please send me the link', 'sms'); 215 assert.strictEqual(result.classification, 'question'); 216 }); 217 218 test('falls back to question for unclear messages', async () => { 219 const result = await classifyReply('I received your message about my website', 'email'); 220 assert.strictEqual(result.classification, 'question'); 221 assert.strictEqual(result.confidence, 0.5); 222 }); 223 224 test('unsubscribe takes priority over other keywords', async () => { 225 // Message with both "interested" and "stop" — unsubscribe should win 226 const result = await classifyReply('I was interested but stop contacting me', 'email'); 227 assert.strictEqual(result.classification, 'unsubscribe'); 228 }); 229 }); 230 231 describe('classifyRepliesBatch', () => { 232 test('classifies multiple replies', async () => { 233 let callCount = 0; 234 mockRetryWithBackoff = async fn => fn(); 235 mockCallLLM = async () => { 236 callCount++; 237 const classifications = ['interested', 'not_interested', 'question']; 238 return { 239 content: JSON.stringify({ 240 classification: classifications[callCount - 1] || 'question', 241 confidence: 0.9, 242 reasoning: 'test', 243 }), 244 usage: { promptTokens: 100, completionTokens: 50 }, 245 }; 246 }; 247 248 const replies = [ 249 { id: 1, messageBody: 'Yes please', channel: 'email' }, 250 { id: 2, messageBody: 'No thanks', channel: 'sms' }, 251 { id: 3, messageBody: 'What does it include?', channel: 'email' }, 252 ]; 253 254 const results = await classifyRepliesBatch(replies); 255 assert.strictEqual(results.length, 3); 256 assert.strictEqual(results[0].id, 1); 257 assert.strictEqual(results[1].id, 2); 258 assert.strictEqual(results[2].id, 3); 259 }); 260 261 test('handles individual failures gracefully', async () => { 262 let callCount = 0; 263 mockRetryWithBackoff = async fn => { 264 callCount++; 265 if (callCount === 2) throw new Error('API error'); 266 return fn(); 267 }; 268 mockCallLLM = async () => ({ 269 content: JSON.stringify({ 270 classification: 'interested', 271 confidence: 0.9, 272 reasoning: 'test', 273 }), 274 usage: { promptTokens: 100, completionTokens: 50 }, 275 }); 276 277 const replies = [ 278 { id: 1, messageBody: 'Yes', channel: 'email' }, 279 { id: 2, messageBody: 'No', channel: 'sms' }, 280 { id: 3, messageBody: 'Maybe', channel: 'email' }, 281 ]; 282 283 const results = await classifyRepliesBatch(replies); 284 assert.strictEqual(results.length, 3); 285 // Second reply: retryWithBackoff throws → classifyReply catches → fallbackClassification('No') 286 // 'No' matches not_interested keywords → confidence 0.8, or falls to default question 0.5 287 assert.ok(['not_interested', 'question'].includes(results[1].classification)); 288 assert.ok(typeof results[1].confidence === 'number'); 289 assert.ok(results[1].confidence >= 0 && results[1].confidence <= 1); 290 }); 291 292 test('returns empty array for empty input', async () => { 293 const results = await classifyRepliesBatch([]); 294 assert.deepStrictEqual(results, []); 295 }); 296 }); 297 });