/ tests / stages / replies-supplement.test.js
replies-supplement.test.js
  1  /**
  2   * Replies Stage Supplement — Cover already-classified branch (lines 91-147)
  3   *
  4   * When a message already has sentiment AND intent set, the replies stage
  5   * takes the else branch (lines 134-160): advances conversation_status and
  6   * marks as processed without calling analyzeReply.
  7   *
  8   * Also covers:
  9   * - Lines 91-95: logging when intent/sentiment are already set on message
 10   * - Lines 136-147: intentStatusMap in the else branch
 11   */
 12  
 13  import { test, describe, mock, beforeEach } from 'node:test';
 14  import assert from 'node:assert/strict';
 15  import Database from 'better-sqlite3';
 16  import { createPgMock } from '../helpers/pg-mock.js';
 17  
 18  // ─── Create in-memory test DB ─────────────────────────────────────────────────
 19  
 20  const db = new Database(':memory:');
 21  
 22  db.exec(`
 23    CREATE TABLE sites (
 24      id INTEGER PRIMARY KEY AUTOINCREMENT,
 25      domain TEXT NOT NULL,
 26      landing_page_url TEXT,
 27      status TEXT DEFAULT 'outreach_sent',
 28      keyword TEXT,
 29      score REAL,
 30      conversation_status TEXT,
 31      created_at TEXT DEFAULT CURRENT_TIMESTAMP,
 32      updated_at TEXT DEFAULT CURRENT_TIMESTAMP,
 33      rescored_at DATETIME
 34    );
 35  
 36    CREATE TABLE messages (
 37      id INTEGER PRIMARY KEY AUTOINCREMENT,
 38      site_id INTEGER,
 39      contact_uri TEXT,
 40      contact_method TEXT,
 41      message_body TEXT,
 42      direction TEXT NOT NULL DEFAULT 'inbound',
 43      intent TEXT,
 44      sentiment TEXT,
 45      processed_at TEXT,
 46      created_at TEXT DEFAULT CURRENT_TIMESTAMP,
 47      updated_at TEXT DEFAULT CURRENT_TIMESTAMP,
 48      message_type TEXT DEFAULT 'outreach',
 49      raw_payload TEXT,
 50      read_at TEXT
 51    );
 52  
 53    CREATE TABLE opt_outs (
 54      id INTEGER PRIMARY KEY AUTOINCREMENT,
 55      phone TEXT,
 56      email TEXT,
 57      method TEXT NOT NULL,
 58      opted_out_at DATETIME DEFAULT CURRENT_TIMESTAMP,
 59      source TEXT DEFAULT 'inbound',
 60      created_at DATETIME DEFAULT CURRENT_TIMESTAMP
 61    );
 62  `);
 63  
 64  // ─── Mock db.js BEFORE importing replies.js ──────────────────────────────────
 65  
 66  mock.module('../../src/utils/db.js', {
 67    namedExports: createPgMock(db),
 68  });
 69  
 70  mock.module('../../src/utils/logger.js', {
 71    defaultExport: class {
 72      info() {}
 73      warn() {}
 74      error() {}
 75      success() {}
 76      debug() {}
 77    },
 78  });
 79  
 80  mock.module('../../src/utils/summary-generator.js', {
 81    namedExports: {
 82      generateStageCompletion: () => {},
 83      displayProgress: () => {},
 84    },
 85  });
 86  
 87  // Import AFTER mock.module
 88  const { runRepliesStage } = await import('../../src/stages/replies.js');
 89  
 90  // ─── Helpers ─────────────────────────────────────────────────────────────────
 91  
 92  let siteSeq = 100;
 93  
 94  function clearTables() {
 95    db.prepare('DELETE FROM messages').run();
 96    db.prepare('DELETE FROM sites').run();
 97  }
 98  
 99  function insertSite() {
100    const id = siteSeq++;
101    db.prepare(
102      `INSERT INTO sites (id, domain, landing_page_url, status, keyword)
103       VALUES (?, ?, ?, 'outreach_sent', 'test kw')`
104    ).run(id, `site${id}.com`, `https://site${id}.com`);
105    return id;
106  }
107  
108  function insertClassifiedReply(siteId, intent = 'inquiry', sentiment = 'neutral') {
109    db.prepare(`UPDATE sites SET conversation_status = 'active' WHERE id = ?`).run(siteId);
110    const result = db.prepare(
111      `INSERT INTO messages (site_id, contact_uri, contact_method, message_body, direction, intent, sentiment)
112       VALUES (?, 'sender@test.com', 'email', 'Hello world!', 'inbound', ?, ?)`
113    ).run(siteId, intent, sentiment);
114    return result.lastInsertRowid;
115  }
116  
117  // ─── Tests ───────────────────────────────────────────────────────────────────
118  
119  describe('Replies Stage Supplement - already-classified branch (lines 134-147)', () => {
120    beforeEach(clearTables);
121  
122    test('already-classified reply with inquiry intent → advances to active status', async () => {
123      const siteId = insertSite();
124      const msgId = insertClassifiedReply(siteId, 'inquiry', 'neutral');
125  
126      const result = await runRepliesStage();
127  
128      assert.ok(result.processed >= 1);
129  
130      const msg = db.prepare('SELECT * FROM messages WHERE id = ?').get(msgId);
131      const site = db.prepare('SELECT * FROM sites WHERE id = ?').get(siteId);
132  
133      assert.ok(msg.processed_at !== null, 'message should be marked as processed');
134      assert.equal(site.conversation_status, 'active', 'inquiry → active');
135    });
136  
137    test('already-classified reply with interested intent → site qualified', async () => {
138      const siteId = insertSite();
139      insertClassifiedReply(siteId, 'interested', 'positive');
140  
141      await runRepliesStage();
142  
143      const site = db.prepare('SELECT * FROM sites WHERE id = ?').get(siteId);
144      assert.equal(site.conversation_status, 'qualified', 'interested → qualified');
145    });
146  
147    test('already-classified reply with opt-out intent → site not_interested', async () => {
148      const siteId = insertSite();
149      insertClassifiedReply(siteId, 'opt-out', 'negative');
150  
151      await runRepliesStage();
152  
153      const site = db.prepare('SELECT * FROM sites WHERE id = ?').get(siteId);
154      assert.equal(site.conversation_status, 'not_interested', 'opt-out → not_interested');
155    });
156  
157    test('already-classified reply with autoresponder intent → site closed', async () => {
158      const siteId = insertSite();
159      insertClassifiedReply(siteId, 'autoresponder', 'neutral');
160  
161      await runRepliesStage();
162  
163      const site = db.prepare('SELECT * FROM sites WHERE id = ?').get(siteId);
164      assert.equal(site.conversation_status, 'closed', 'autoresponder → closed');
165    });
166  
167    test('already-classified reply with pricing intent → site active', async () => {
168      const siteId = insertSite();
169      insertClassifiedReply(siteId, 'pricing', 'neutral');
170  
171      await runRepliesStage();
172  
173      const site = db.prepare('SELECT * FROM sites WHERE id = ?').get(siteId);
174      assert.equal(site.conversation_status, 'active', 'pricing → active');
175    });
176  });