/ tests / utils / reply-classifier.test.js
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  });