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 });