/ tests / inbound / autoresponder-helpers.test.js
autoresponder-helpers.test.js
  1  /**
  2   * Unit tests for autoresponder.js helper functions
  3   *
  4   * Covers pure/near-pure functions that were previously untested:
  5   *   - getPricing() — pricing resolution with overrides
  6   *   - DEFAULT_PRICING — static pricing table
  7   *   - PROJECT_CONFIG — project identity configuration
  8   *   - classifyFunnelStage() — additional edge cases (regex boundaries)
  9   *   - shouldAutoRespond() — additional edge cases
 10   *   - buildContext() — 2step project path, pricing overrides, weakness parsing
 11   *
 12   * Uses node:test + node:assert/strict. pg-mock pattern for DB-backed tests.
 13   */
 14  
 15  import { describe, test, mock, beforeEach } from 'node:test';
 16  import assert from 'node:assert/strict';
 17  import Database from 'better-sqlite3';
 18  import { createPgMock } from '../helpers/pg-mock.js';
 19  
 20  // Set env before any module loads
 21  process.env.AUTORESPONDER_ENABLED = 'true';
 22  process.env.PERSONA_NAME = 'Marcus Webb';
 23  process.env.PERSONA_FIRST_NAME = 'Marcus';
 24  process.env.BRAND_NAME = 'Test Brand';
 25  process.env.BRAND_DOMAIN = 'example.com';
 26  process.env.BRAND_URL = 'https://example.com';
 27  
 28  // ─── Mocks ───────────────────────────────────────────────────────────────────
 29  
 30  const _scoreStore = new Map();
 31  
 32  mock.module('../../src/utils/score-storage.js', {
 33    namedExports: {
 34      getScoreDataWithFallback: mock.fn((siteId) => _scoreStore.get(siteId) ?? null),
 35      getScoreJsonWithFallback: mock.fn((siteId) => {
 36        const d = _scoreStore.get(siteId);
 37        return d ? JSON.stringify(d) : null;
 38      }),
 39      setScoreJson: mock.fn((siteId, json) => {
 40        _scoreStore.set(siteId, typeof json === 'string' ? JSON.parse(json) : json);
 41      }),
 42      getScoreJson: mock.fn((siteId) => {
 43        const d = _scoreStore.get(siteId);
 44        return d ? JSON.stringify(d) : null;
 45      }),
 46      getScoreData: mock.fn((siteId) => _scoreStore.get(siteId) ?? null),
 47      deleteScoreJson: mock.fn((siteId) => _scoreStore.delete(siteId)),
 48      hasScoreJson: mock.fn((siteId) => _scoreStore.has(siteId)),
 49      DATA_DIR: '/tmp/test-scores',
 50    },
 51  });
 52  
 53  // In-memory DB with full schema — set up BEFORE mock.module for db.js
 54  const db = new Database(':memory:');
 55  db.exec(`
 56    CREATE TABLE IF NOT EXISTS sites (
 57      id INTEGER PRIMARY KEY AUTOINCREMENT,
 58      domain TEXT NOT NULL,
 59      landing_page_url TEXT NOT NULL DEFAULT 'https://example.com',
 60      keyword TEXT NOT NULL DEFAULT 'test',
 61      score REAL,
 62      grade TEXT,
 63      country_code TEXT DEFAULT 'AU',
 64      city TEXT DEFAULT 'Sydney',
 65      status TEXT DEFAULT 'outreach_sent',
 66      conversation_status TEXT,
 67      created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
 68      updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
 69      rescored_at DATETIME
 70    );
 71  
 72    CREATE TABLE IF NOT EXISTS messages (
 73      id INTEGER PRIMARY KEY AUTOINCREMENT,
 74      site_id INTEGER NOT NULL REFERENCES sites(id),
 75      direction TEXT NOT NULL DEFAULT 'inbound',
 76      contact_method TEXT NOT NULL DEFAULT 'sms',
 77      contact_uri TEXT NOT NULL DEFAULT '+61400000000',
 78      message_body TEXT,
 79      subject_line TEXT,
 80      intent TEXT,
 81      sentiment TEXT,
 82      message_type TEXT DEFAULT 'outreach',
 83      delivery_status TEXT,
 84      raw_payload TEXT,
 85      sent_at TEXT,
 86      created_at TEXT NOT NULL DEFAULT (datetime('now')),
 87      updated_at TEXT NOT NULL DEFAULT (datetime('now')),
 88      read_at TEXT
 89    );
 90  
 91    CREATE TABLE IF NOT EXISTS countries (
 92      country_code TEXT PRIMARY KEY,
 93      country_name TEXT NOT NULL DEFAULT 'Australia',
 94      google_domain TEXT NOT NULL DEFAULT 'google.com.au',
 95      language_code TEXT NOT NULL DEFAULT 'en',
 96      timezone TEXT NOT NULL DEFAULT 'Australia/Sydney',
 97      currency_code TEXT NOT NULL DEFAULT 'AUD',
 98      currency_symbol TEXT NOT NULL DEFAULT '$',
 99      date_format TEXT NOT NULL DEFAULT 'DD/MM/YYYY',
100      price_usd INTEGER NOT NULL DEFAULT 33700,
101      pricing_tier TEXT NOT NULL DEFAULT 'Premium',
102      is_active BOOLEAN DEFAULT 1,
103      twilio_phone_number TEXT,
104      sms_enabled BOOLEAN DEFAULT 1,
105      created_at TEXT DEFAULT CURRENT_TIMESTAMP,
106      updated_at TEXT DEFAULT CURRENT_TIMESTAMP
107    );
108  
109    CREATE TABLE IF NOT EXISTS opt_outs (
110      id INTEGER PRIMARY KEY AUTOINCREMENT,
111      phone TEXT,
112      email TEXT,
113      method TEXT NOT NULL,
114      opted_out_at DATETIME DEFAULT CURRENT_TIMESTAMP,
115      source TEXT DEFAULT 'inbound',
116      created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
117      UNIQUE(phone, method),
118      UNIQUE(email, method)
119    );
120  
121    CREATE TABLE IF NOT EXISTS llm_usage (
122      id INTEGER PRIMARY KEY AUTOINCREMENT,
123      site_id INTEGER,
124      stage TEXT NOT NULL,
125      provider TEXT NOT NULL,
126      model TEXT NOT NULL,
127      prompt_tokens INTEGER NOT NULL,
128      completion_tokens INTEGER NOT NULL,
129      total_tokens INTEGER NOT NULL,
130      estimated_cost DECIMAL(10, 6),
131      request_id TEXT,
132      created_at DATETIME DEFAULT CURRENT_TIMESTAMP
133    );
134  
135    CREATE TABLE IF NOT EXISTS alt_messages (
136      id INTEGER PRIMARY KEY AUTOINCREMENT,
137      site_id INTEGER NOT NULL,
138      direction TEXT NOT NULL DEFAULT 'inbound',
139      contact_method TEXT NOT NULL DEFAULT 'sms',
140      contact_uri TEXT NOT NULL DEFAULT '+61400000000',
141      message_body TEXT,
142      intent TEXT,
143      sentiment TEXT,
144      message_type TEXT DEFAULT 'outreach',
145      delivery_status TEXT,
146      raw_payload TEXT,
147      sent_at TEXT,
148      created_at TEXT NOT NULL DEFAULT (datetime('now')),
149      updated_at TEXT NOT NULL DEFAULT (datetime('now'))
150    );
151  `);
152  
153  mock.module('../../src/utils/db.js', { namedExports: createPgMock(db) });
154  
155  const {
156    shouldAutoRespond,
157    classifyFunnelStage,
158    buildContext,
159    DEFAULT_PRICING,
160    getPricing,
161    PROJECT_CONFIG,
162  } = await import('../../src/inbound/autoresponder.js');
163  
164  // ─── Test helpers ─────────────────────────────────────────────────────────────
165  
166  function clearTables() {
167    db.exec('DELETE FROM messages; DELETE FROM sites; DELETE FROM opt_outs; DELETE FROM alt_messages');
168    _scoreStore.clear();
169  }
170  
171  function insertTestSite(overrides = {}) {
172    const defaults = {
173      domain: 'example.com.au',
174      score: 55,
175      grade: 'C',
176      score_json: {
177        mobile: { score: 40, details: 'Not mobile responsive' },
178        seo: { score: 30, details: 'Missing meta description' },
179        speed: { score: 65, details: 'Slow load time: 4.2s' },
180        ssl: { score: 100, details: 'HTTPS enabled' },
181      },
182      country_code: 'AU',
183      city: 'Sydney',
184    };
185    const data = { ...defaults, ...overrides };
186  
187    const result = db
188      .prepare(
189        `INSERT INTO sites (domain, score, grade, country_code, city)
190         VALUES (?, ?, ?, ?, ?)`
191      )
192      .run(data.domain, data.score, data.grade, data.country_code, data.city);
193  
194    const siteId = Number(result.lastInsertRowid);
195    if (data.score_json !== null && data.score_json !== undefined) {
196      _scoreStore.set(
197        siteId,
198        typeof data.score_json === 'string' ? JSON.parse(data.score_json) : data.score_json
199      );
200    }
201    return siteId;
202  }
203  
204  function insertTestMessage(siteId, overrides = {}) {
205    const defaults = {
206      direction: 'inbound',
207      contact_method: 'sms',
208      contact_uri: '+61400000000',
209      message_body: 'Yes interested',
210      intent: 'interested',
211      sentiment: 'positive',
212      message_type: 'outreach',
213      sent_at: null,
214      created_at: new Date().toISOString(),
215    };
216    const data = { ...defaults, ...overrides };
217  
218    const result = db
219      .prepare(
220        `INSERT INTO messages (site_id, direction, contact_method, contact_uri,
221          message_body, intent, sentiment, message_type, sent_at, created_at)
222         VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
223      )
224      .run(
225        siteId,
226        data.direction,
227        data.contact_method,
228        data.contact_uri,
229        data.message_body,
230        data.intent,
231        data.sentiment,
232        data.message_type,
233        data.sent_at,
234        data.created_at
235      );
236  
237    return Number(result.lastInsertRowid);
238  }
239  
240  // ═══════════════════════════════════════════════════════════════════════════════
241  // DEFAULT_PRICING
242  // ═══════════════════════════════════════════════════════════════════════════════
243  
244  describe('DEFAULT_PRICING', () => {
245    test('contains AU with AUD $337', () => {
246      assert.equal(DEFAULT_PRICING.AU.amount, 337);
247      assert.equal(DEFAULT_PRICING.AU.currency, 'AUD');
248      assert.equal(DEFAULT_PRICING.AU.symbol, '$');
249    });
250  
251    test('contains UK with GBP 159', () => {
252      assert.equal(DEFAULT_PRICING.UK.amount, 159);
253      assert.equal(DEFAULT_PRICING.UK.currency, 'GBP');
254      assert.equal(DEFAULT_PRICING.UK.symbol, '\u00a3');
255    });
256  
257    test('contains US with USD $297', () => {
258      assert.equal(DEFAULT_PRICING.US.amount, 297);
259      assert.equal(DEFAULT_PRICING.US.currency, 'USD');
260      assert.equal(DEFAULT_PRICING.US.symbol, '$');
261    });
262  
263    test('contains CA with CAD $297', () => {
264      assert.equal(DEFAULT_PRICING.CA.amount, 297);
265      assert.equal(DEFAULT_PRICING.CA.currency, 'CAD');
266      assert.equal(DEFAULT_PRICING.CA.symbol, '$');
267    });
268  
269    test('contains NZ with NZD $349', () => {
270      assert.equal(DEFAULT_PRICING.NZ.amount, 349);
271      assert.equal(DEFAULT_PRICING.NZ.currency, 'NZD');
272      assert.equal(DEFAULT_PRICING.NZ.symbol, '$');
273    });
274  
275    test('has exactly 5 countries configured', () => {
276      assert.equal(Object.keys(DEFAULT_PRICING).length, 5);
277    });
278  });
279  
280  // ═══════════════════════════════════════════════════════════════════════════════
281  // getPricing
282  // ═══════════════════════════════════════════════════════════════════════════════
283  
284  describe('getPricing', () => {
285    test('returns DEFAULT_PRICING for known country without override', () => {
286      const result = getPricing('AU');
287      assert.deepEqual(result, DEFAULT_PRICING.AU);
288    });
289  
290    test('falls back to US for unknown country without override', () => {
291      const result = getPricing('ZZ');
292      assert.deepEqual(result, DEFAULT_PRICING.US);
293    });
294  
295    test('falls back to US for undefined country code', () => {
296      const result = getPricing(undefined);
297      assert.deepEqual(result, DEFAULT_PRICING.US);
298    });
299  
300    // Object override
301    test('uses object override when country matches', () => {
302      const override = { AU: { amount: 500, currency: 'AUD', symbol: '$' } };
303      const result = getPricing('AU', override);
304      assert.equal(result.amount, 500);
305    });
306  
307    test('falls through object override to DEFAULT_PRICING when country not in override', () => {
308      const override = { AU: { amount: 500, currency: 'AUD', symbol: '$' } };
309      const result = getPricing('UK', override);
310      assert.deepEqual(result, DEFAULT_PRICING.UK);
311    });
312  
313    test('falls through object override to US for unknown country not in override', () => {
314      const override = { AU: { amount: 500, currency: 'AUD', symbol: '$' } };
315      const result = getPricing('ZZ', override);
316      assert.deepEqual(result, DEFAULT_PRICING.US);
317    });
318  
319    // Function override
320    test('uses function override when it returns a result', () => {
321      const override = (cc) => (cc === 'DE' ? { amount: 199, currency: 'EUR', symbol: '\u20ac' } : null);
322      const result = getPricing('DE', override);
323      assert.equal(result.amount, 199);
324      assert.equal(result.currency, 'EUR');
325    });
326  
327    test('falls through function override to DEFAULT_PRICING when function returns null', () => {
328      const override = () => null;
329      const result = getPricing('AU', override);
330      assert.deepEqual(result, DEFAULT_PRICING.AU);
331    });
332  
333    test('falls through function override to US when function returns null for unknown country', () => {
334      const override = () => null;
335      const result = getPricing('ZZ', override);
336      assert.deepEqual(result, DEFAULT_PRICING.US);
337    });
338  
339    test('function override returning undefined falls through', () => {
340      const override = () => undefined;
341      const result = getPricing('UK', override);
342      assert.deepEqual(result, DEFAULT_PRICING.UK);
343    });
344  });
345  
346  // ═══════════════════════════════════════════════════════════════════════════════
347  // PROJECT_CONFIG
348  // ═══════════════════════════════════════════════════════════════════════════════
349  
350  describe('PROJECT_CONFIG', () => {
351    test('has 333method config', () => {
352      assert.ok(PROJECT_CONFIG['333method']);
353      assert.ok(PROJECT_CONFIG['333method'].identity.includes(process.env.PERSONA_NAME));
354      assert.match(PROJECT_CONFIG['333method'].service, /audit/i);
355      assert.ok(PROJECT_CONFIG['333method'].paymentUrlPrefix.includes(`${process.env.BRAND_DOMAIN}/o/`));
356    });
357  
358    test('has 2step config', () => {
359      assert.ok(PROJECT_CONFIG['2step']);
360      assert.match(PROJECT_CONFIG['2step'].service, /video/i);
361      assert.ok(PROJECT_CONFIG['2step'].paymentUrlPrefix.includes(`${process.env.BRAND_DOMAIN}/v/`));
362    });
363  
364    test('333method defaultPrompt mentions JSON', () => {
365      assert.match(PROJECT_CONFIG['333method'].defaultPrompt, /json/i);
366    });
367  
368    test('2step defaultPrompt mentions video', () => {
369      assert.match(PROJECT_CONFIG['2step'].defaultPrompt, /video/i);
370    });
371  });
372  
373  // ═══════════════════════════════════════════════════════════════════════════════
374  // classifyFunnelStage — additional edge cases
375  // ═══════════════════════════════════════════════════════════════════════════════
376  
377  describe('classifyFunnelStage — edge cases', () => {
378    test('null intent and null sentiment defaults to checking body only', () => {
379      const result = classifyFunnelStage({ intent: null, sentiment: null, message_body: 'hello' });
380      assert.equal(result, 'unknown');
381    });
382  
383    test('undefined intent/sentiment/body returns unknown', () => {
384      const result = classifyFunnelStage({});
385      assert.equal(result, 'unknown');
386    });
387  
388    // Autoresponder variants
389    test('"auto-reply" in body classifies as autoresponder', () => {
390      assert.equal(
391        classifyFunnelStage({ message_body: 'This is an auto-reply message' }),
392        'autoresponder'
393      );
394    });
395  
396    test('"automated response" in body classifies as autoresponder', () => {
397      assert.equal(
398        classifyFunnelStage({ message_body: 'Thank you. This is an automated response.' }),
399        'autoresponder'
400      );
401    });
402  
403    test('autoresponder intent classifies as autoresponder regardless of body', () => {
404      assert.equal(
405        classifyFunnelStage({ intent: 'autoresponder', message_body: 'Yes interested' }),
406        'autoresponder'
407      );
408    });
409  
410    // Not interested variants
411    test('"unsubscribe" in body classifies as not_interested', () => {
412      assert.equal(
413        classifyFunnelStage({ message_body: 'Please unsubscribe me' }),
414        'not_interested'
415      );
416    });
417  
418    test('"remove me" in body classifies as not_interested', () => {
419      assert.equal(
420        classifyFunnelStage({ message_body: 'Remove me from your list' }),
421        'not_interested'
422      );
423    });
424  
425    test('"dont contact" in body classifies as not_interested', () => {
426      assert.equal(
427        classifyFunnelStage({ message_body: "dont contact me again" }),
428        'not_interested'
429      );
430    });
431  
432    test('"go away" in body classifies as not_interested', () => {
433      assert.equal(
434        classifyFunnelStage({ message_body: 'go away please' }),
435        'not_interested'
436      );
437    });
438  
439    test('opt-out intent classifies as not_interested', () => {
440      assert.equal(
441        classifyFunnelStage({ intent: 'opt-out', message_body: 'whatever' }),
442        'not_interested'
443      );
444    });
445  
446    // Qualified variants
447    test('"what do you charge" classifies as qualified', () => {
448      assert.equal(
449        classifyFunnelStage({ message_body: 'What do you charge for the report?' }),
450        'qualified'
451      );
452    });
453  
454    test('"whats it cost" classifies as qualified', () => {
455      assert.equal(
456        classifyFunnelStage({ message_body: "Whats it cost?" }),
457        'qualified'
458      );
459    });
460  
461    test('"pricing" in body classifies as qualified', () => {
462      assert.equal(
463        classifyFunnelStage({ message_body: 'Can you send me your pricing?' }),
464        'qualified'
465      );
466    });
467  
468    // Objection variants
469    test('"how does it work" classifies as objection', () => {
470      assert.equal(
471        classifyFunnelStage({ message_body: 'How does it work exactly?' }),
472        'objection'
473      );
474    });
475  
476    test('"is this legit" classifies as objection', () => {
477      assert.equal(
478        classifyFunnelStage({ message_body: 'Is this legit or a scam?' }),
479        'objection'
480      );
481    });
482  
483    test('"what do you offer" classifies as objection', () => {
484      assert.equal(
485        classifyFunnelStage({ message_body: 'What do you offer?' }),
486        'objection'
487      );
488    });
489  
490    test('"whats included" classifies as objection', () => {
491      assert.equal(
492        classifyFunnelStage({ message_body: "whats included in the report" }),
493        'objection'
494      );
495    });
496  
497    test('"sample" in body classifies as objection', () => {
498      assert.equal(
499        classifyFunnelStage({ message_body: 'Can I see a sample report?' }),
500        'objection'
501      );
502    });
503  
504    // Interested variants
505    test('schedule intent classifies as interested', () => {
506      assert.equal(
507        classifyFunnelStage({ intent: 'schedule', message_body: 'Can we talk Tuesday?' }),
508        'interested'
509      );
510    });
511  
512    test('"sounds good" in body classifies as interested', () => {
513      assert.equal(
514        classifyFunnelStage({ message_body: 'Sounds good to me' }),
515        'interested'
516      );
517    });
518  
519    test('"sure" in body classifies as interested', () => {
520      assert.equal(
521        classifyFunnelStage({ message_body: 'Sure, send it over' }),
522        'interested'
523      );
524    });
525  
526    test('"lets do it" in body classifies as interested', () => {
527      assert.equal(
528        classifyFunnelStage({ message_body: "lets do it" }),
529        'interested'
530      );
531    });
532  
533    test('"go ahead" alone classifies as interested', () => {
534      // Note: "Go ahead and send the report" would match objection (contains "report")
535      // because objection checks run before interested checks — this is by design.
536      assert.equal(
537        classifyFunnelStage({ message_body: 'Go ahead with it' }),
538        'interested'
539      );
540    });
541  
542    test('"perfect" in body classifies as interested', () => {
543      assert.equal(
544        classifyFunnelStage({ message_body: 'Perfect, thanks' }),
545        'interested'
546      );
547    });
548  
549    // Priority: not_interested takes precedence over interested keywords
550    test('not-interested intent overrides positive body text', () => {
551      assert.equal(
552        classifyFunnelStage({ intent: 'not-interested', message_body: 'Yes interested' }),
553        'not_interested'
554      );
555    });
556  
557    // Priority: qualified (pricing) beats interested
558    test('pricing intent overrides positive sentiment', () => {
559      assert.equal(
560        classifyFunnelStage({ intent: 'pricing', sentiment: 'positive', message_body: 'How much?' }),
561        'qualified'
562      );
563    });
564  
565    // Case insensitivity
566    test('body classification is case-insensitive', () => {
567      assert.equal(classifyFunnelStage({ message_body: 'STOP' }), 'not_interested');
568      assert.equal(classifyFunnelStage({ message_body: 'HOW MUCH' }), 'qualified');
569      assert.equal(classifyFunnelStage({ message_body: 'YES' }), 'interested');
570    });
571  });
572  
573  // ═══════════════════════════════════════════════════════════════════════════════
574  // shouldAutoRespond — additional edge cases
575  // ═══════════════════════════════════════════════════════════════════════════════
576  
577  describe('shouldAutoRespond — edge cases', () => {
578    beforeEach(() => clearTables());
579  
580    test('returns true when created_at is null (no age check)', async () => {
581      const siteId = insertTestSite();
582      const inbound = {
583        id: 1,
584        site_id: siteId,
585        intent: 'interested',
586        created_at: null,
587      };
588      assert.equal(await shouldAutoRespond(inbound), true);
589    });
590  
591    test('returns true when message is exactly 71 hours old', async () => {
592      const siteId = insertTestSite();
593      const date71h = new Date(Date.now() - 71 * 60 * 60 * 1000).toISOString();
594      const inbound = {
595        id: 1,
596        site_id: siteId,
597        intent: 'interested',
598        created_at: date71h,
599      };
600      assert.equal(await shouldAutoRespond(inbound), true);
601    });
602  
603    test('returns true when intent is null', async () => {
604      const siteId = insertTestSite();
605      const inbound = {
606        id: 1,
607        site_id: siteId,
608        intent: null,
609        created_at: new Date().toISOString(),
610      };
611      assert.equal(await shouldAutoRespond(inbound), true);
612    });
613  
614    test('respects custom messagesTable parameter', async () => {
615      const siteId = insertTestSite();
616      const now = new Date().toISOString();
617  
618      // Insert reply only in alt_messages
619      db.prepare(
620        `INSERT INTO alt_messages (site_id, direction, message_type, sent_at, created_at, message_body)
621         VALUES (?, 'outbound', 'reply', ?, ?, 'Thanks!')`
622      ).run(siteId, now, now);
623  
624      const inbound = {
625        id: 1,
626        site_id: siteId,
627        intent: 'interested',
628        created_at: now,
629      };
630  
631      // Default table (messages) has no reply — should return true
632      assert.equal(await shouldAutoRespond(inbound, 'messages'), true);
633  
634      // Alt table has a reply — should return false
635      assert.equal(await shouldAutoRespond(inbound, 'alt_messages'), false);
636    });
637  });
638  
639  // ═══════════════════════════════════════════════════════════════════════════════
640  // buildContext — additional scenarios
641  // ═══════════════════════════════════════════════════════════════════════════════
642  
643  describe('buildContext — additional scenarios', () => {
644    beforeEach(() => clearTables());
645  
646    test('uses pricing override (object) for known country', async () => {
647      const siteId = insertTestSite({ country_code: 'AU' });
648      const msgId = insertTestMessage(siteId);
649      const inbound = db.prepare('SELECT * FROM messages WHERE id = ?').get(msgId);
650      const override = { AU: { amount: 999, currency: 'AUD', symbol: '$' } };
651      const context = await buildContext(inbound, 'messages', override);
652      assert.equal(context.pricing.amount, 999);
653    });
654  
655    test('uses pricing override (function) for custom country', async () => {
656      const siteId = insertTestSite({ country_code: 'DE' });
657      const msgId = insertTestMessage(siteId);
658      const inbound = db.prepare('SELECT * FROM messages WHERE id = ?').get(msgId);
659      const override = (cc) =>
660        cc === 'DE' ? { amount: 199, currency: 'EUR', symbol: '\u20ac' } : null;
661      const context = await buildContext(inbound, 'messages', override);
662      assert.equal(context.pricing.amount, 199);
663      assert.equal(context.pricing.currency, 'EUR');
664    });
665  
666    test('project parameter defaults to 333method', async () => {
667      const siteId = insertTestSite();
668      const msgId = insertTestMessage(siteId);
669      const inbound = db.prepare('SELECT * FROM messages WHERE id = ?').get(msgId);
670      const context = await buildContext(inbound);
671      assert.equal(context.project, '333method');
672    });
673  
674    test('project parameter can be set to 2step', async () => {
675      const siteId = insertTestSite();
676      const msgId = insertTestMessage(siteId);
677      const inbound = db.prepare('SELECT * FROM messages WHERE id = ?').get(msgId);
678      const context = await buildContext(inbound, 'messages', null, '2step');
679      assert.equal(context.project, '2step');
680    });
681  
682    test('weaknesses are sorted by score ascending (worst first)', async () => {
683      const scoreJson = {
684        mobile: { score: 60, details: 'OK' },
685        seo: { score: 20, details: 'Terrible SEO' },
686        speed: { score: 40, details: 'Slow' },
687        ssl: { score: 100, details: 'Good' },
688      };
689      const siteId = insertTestSite({ score_json: scoreJson });
690      const msgId = insertTestMessage(siteId);
691      const inbound = db.prepare('SELECT * FROM messages WHERE id = ?').get(msgId);
692      const context = await buildContext(inbound);
693  
694      // Only scores < 70 should be included
695      assert.equal(context.weaknesses.length, 3); // seo(20), speed(40), mobile(60)
696      assert.equal(context.weaknesses[0].category, 'seo');
697      assert.equal(context.weaknesses[0].score, 20);
698      assert.equal(context.weaknesses[1].category, 'speed');
699      assert.equal(context.weaknesses[2].category, 'mobile');
700    });
701  
702    test('weaknesses exclude scores >= 70', async () => {
703      const scoreJson = {
704        mobile: { score: 70, details: 'Fine' },
705        seo: { score: 85, details: 'Good' },
706        ssl: { score: 100, details: 'Perfect' },
707      };
708      const siteId = insertTestSite({ score_json: scoreJson });
709      const msgId = insertTestMessage(siteId);
710      const inbound = db.prepare('SELECT * FROM messages WHERE id = ?').get(msgId);
711      const context = await buildContext(inbound);
712  
713      assert.equal(context.weaknesses.length, 0);
714      assert.equal(context.weaknessCount, 0);
715    });
716  
717    test('handles score_json with plain numeric values (not objects)', async () => {
718      const scoreJson = {
719        mobile: 35,
720        seo: 50,
721        ssl: 90,
722      };
723      const siteId = insertTestSite({ score_json: scoreJson });
724      const msgId = insertTestMessage(siteId);
725      const inbound = db.prepare('SELECT * FROM messages WHERE id = ?').get(msgId);
726      const context = await buildContext(inbound);
727  
728      assert.equal(context.weaknesses.length, 2); // mobile(35), seo(50)
729      assert.equal(context.weaknesses[0].score, 35);
730      assert.equal(context.weaknesses[0].details, '');
731    });
732  
733    test('inbound context contains expected fields', async () => {
734      const siteId = insertTestSite();
735      const msgId = insertTestMessage(siteId, {
736        message_body: 'Test message',
737        intent: 'interested',
738        sentiment: 'positive',
739        contact_method: 'email',
740      });
741      const inbound = db.prepare('SELECT * FROM messages WHERE id = ?').get(msgId);
742      const context = await buildContext(inbound);
743  
744      assert.equal(context.inbound.text, 'Test message');
745      assert.equal(context.inbound.intent, 'interested');
746      assert.equal(context.inbound.sentiment, 'positive');
747      assert.equal(context.inbound.channel, 'email');
748    });
749  
750    test('originalOutreach is fetched from first sent outbound message', async () => {
751      const siteId = insertTestSite();
752  
753      // Insert outreach (sent) with subject_line
754      const now = new Date().toISOString();
755      db.prepare(
756        `INSERT INTO messages (site_id, direction, contact_method, contact_uri,
757          message_body, subject_line, message_type, sent_at, created_at)
758         VALUES (?, 'outbound', 'email', 'test@example.com', ?, ?, 'outreach', ?, ?)`
759      ).run(siteId, 'Original outreach text', 'Your site audit', now, now);
760  
761      // Insert inbound reply
762      const msgId = insertTestMessage(siteId, {
763        direction: 'inbound',
764        message_body: 'Interested!',
765      });
766  
767      const inbound = db.prepare('SELECT * FROM messages WHERE id = ?').get(msgId);
768      const context = await buildContext(inbound);
769  
770      assert.ok(context.originalOutreach);
771      assert.equal(context.originalOutreach.message_body, 'Original outreach text');
772      assert.equal(context.originalOutreach.subject_line, 'Your site audit');
773    });
774  
775    test('originalOutreach is null when no outreach was sent', async () => {
776      const siteId = insertTestSite();
777      const msgId = insertTestMessage(siteId, {
778        direction: 'inbound',
779        message_body: 'Hello?',
780      });
781  
782      const inbound = db.prepare('SELECT * FROM messages WHERE id = ?').get(msgId);
783      const context = await buildContext(inbound);
784  
785      assert.ok(context.originalOutreach === null || context.originalOutreach === undefined, `expected null or undefined, got ${context.originalOutreach}`);
786    });
787  
788    test('funnelStage in context matches classifyFunnelStage', async () => {
789      const siteId = insertTestSite();
790      const msgId = insertTestMessage(siteId, {
791        direction: 'inbound',
792        message_body: 'How much does it cost?',
793        intent: null,
794        sentiment: null,
795      });
796  
797      const inbound = db.prepare('SELECT * FROM messages WHERE id = ?').get(msgId);
798      const context = await buildContext(inbound);
799  
800      assert.equal(context.funnelStage, 'qualified');
801    });
802  
803    test('conversation_status is included in site context', async () => {
804      const siteId = insertTestSite();
805      db.prepare('UPDATE sites SET conversation_status = ? WHERE id = ?').run('engaged', siteId);
806  
807      const msgId = insertTestMessage(siteId);
808      const inbound = db.prepare('SELECT * FROM messages WHERE id = ?').get(msgId);
809      const context = await buildContext(inbound);
810  
811      assert.equal(context.site.conversationStatus, 'engaged');
812    });
813  });