proposal-generator-v2-mocked.test.js
1 /** 2 * Comprehensive Unit Tests for Proposal Generator V2 3 * Uses Node.js 22+ mock.module() to mock LLM provider and dependencies 4 * 5 * Tests cover: 6 * - generateProposalVariants: happy path, error handling, rework, contact filtering 7 * - getPendingOutreaches: empty, with data, limit 8 * - approveOutreach: status update 9 * - reworkOutreach: status + instructions 10 * - processReworkQueue: batch processing 11 * - generateBulkProposals: bulk generation 12 * - Internal helpers via integration: extractBusinessType, extractKeyWeaknesses, 13 * normalizeContactMethod, buildProposalContext, getTopCompetitor 14 */ 15 16 import { test, describe, mock, before, after, afterEach } from 'node:test'; 17 import assert from 'node:assert'; 18 import { initTestDb, getTestDbPath, cleanupTestDb } from '../../src/utils/test-db.js'; 19 import { setScoreJson, deleteScoreJson } from '../../src/utils/score-storage.js'; 20 import { setContactsJson, deleteContactsJson } from '../../src/utils/contacts-storage.js'; 21 22 // Create unique database name for this test suite 23 const dbName = `proposal-gen-v2-mocked-${Date.now()}`; 24 25 // Set test database path BEFORE importing the module under test 26 process.env.DATABASE_PATH = getTestDbPath(dbName); 27 28 // Create mock functions 29 const callLLMMock = mock.fn(); 30 const getProviderMock = mock.fn(() => 'openrouter'); 31 const getProviderDisplayNameMock = mock.fn(() => 'OpenRouter'); 32 const logLLMUsageMock = mock.fn(); 33 const generatePromptRecommendationsMock = mock.fn(); 34 const getAllContactsMock = mock.fn(() => []); 35 const getAllContactsWithNamesMock = mock.fn(async () => []); 36 const cleanInvalidSocialLinksMock = mock.fn(data => data); 37 38 // Mock all external dependencies BEFORE importing 39 mock.module('../../src/utils/llm-provider.js', { 40 namedExports: { 41 callLLM: callLLMMock, 42 getProvider: getProviderMock, 43 getProviderDisplayName: getProviderDisplayNameMock, 44 }, 45 }); 46 47 mock.module('../../src/utils/llm-usage-tracker.js', { 48 namedExports: { 49 logLLMUsage: logLLMUsageMock, 50 }, 51 }); 52 53 mock.module('../../src/contacts/prioritize.js', { 54 namedExports: { 55 getAllContacts: getAllContactsMock, 56 getAllContactsWithNames: getAllContactsWithNamesMock, 57 cleanInvalidSocialLinks: cleanInvalidSocialLinksMock, 58 }, 59 }); 60 61 mock.module('../../src/utils/prompt-learning.js', { 62 namedExports: { 63 generatePromptRecommendations: generatePromptRecommendationsMock, 64 }, 65 }); 66 67 mock.module('../../src/utils/rate-limiter.js', { 68 namedExports: { 69 openRouterLimiter: { 70 schedule: fn => fn(), 71 }, 72 }, 73 }); 74 75 // Shared database for setup/verification (separate from the one the module creates) 76 let setupDb; 77 78 // PG SQL → SQLite translation helper 79 function pgToSqlite(sql) { 80 let s = sql 81 .replace(/\$\d+/g, '?') 82 .replace(/::\w+(?:\[\])?/g, '') 83 .replace(/\bNOW\(\)/gi, "datetime('now')") 84 .replace(/\bCURRENT_TIMESTAMP\b/gi, "datetime('now')") 85 .replace(/\?\s*\+\s*INTERVAL\s*'(\d+)\s*days'/gi, "datetime(?, '+$1 days')") 86 .replace(/\s*ON CONFLICT \([^)]+\) DO UPDATE SET[^;]*/gi, '') 87 .replace(/\s*ON CONFLICT \([^)]+\) DO NOTHING/gi, '') 88 .replace(/\bTRUE\b/g, '1').replace(/\bFALSE\b/g, '0') 89 .replace(/\bNULLS LAST\b/gi, '') 90 .replace(/\bIS true\b/gi, '= 1').replace(/\bIS false\b/gi, '= 0') 91 .replace(/\s*= true\b/gi, ' = 1').replace(/\s*= false\b/gi, ' = 0'); 92 if (s.match(/^\s*INSERT\s+INTO\b/i)) { 93 s = s.replace(/^\s*INSERT\s+INTO\b/i, 'INSERT OR IGNORE INTO'); 94 } 95 return s; 96 } 97 98 // Mock db.js BEFORE importing proposal-generator-v2 (must be called before the import) 99 mock.module('../../src/utils/db.js', { 100 namedExports: { 101 getAll: async (sql, params) => { 102 if (!setupDb) return []; 103 try { 104 const stmt = setupDb.prepare(pgToSqlite(sql)); 105 return stmt.all(...(params || []).map(p => typeof p === 'boolean' ? (p ? 1 : 0) : p)); 106 } catch { return []; } 107 }, 108 getOne: async (sql, params) => { 109 if (!setupDb) return null; 110 try { 111 const stmt = setupDb.prepare(pgToSqlite(sql)); 112 return setupDb.prepare(pgToSqlite(sql)).get(...(params || []).map(p => typeof p === 'boolean' ? (p ? 1 : 0) : p)) || null; 113 } catch { return null; } 114 }, 115 run: async (sql, params) => { 116 if (!setupDb) return { changes: 0, lastInsertRowid: null }; 117 try { 118 const stmt = setupDb.prepare(pgToSqlite(sql)); 119 const r = stmt.run(...(params || []).map(p => typeof p === 'boolean' ? (p ? 1 : 0) : p)); 120 return { changes: r.changes, lastInsertRowid: r.lastInsertRowid }; 121 } catch { return { changes: 0, lastInsertRowid: null }; } 122 }, 123 query: async (sql, params) => { 124 if (!setupDb) return { rows: [], rowCount: 0 }; 125 try { 126 const s = pgToSqlite(sql); 127 const stmt = setupDb.prepare(s); 128 const safeParams = (params || []).map(p => typeof p === 'boolean' ? (p ? 1 : 0) : p); 129 if (/^\s*(SELECT|WITH)/i.test(s)) { 130 const rows = stmt.all(...safeParams); 131 return { rows, rowCount: rows.length }; 132 } else { 133 const r = stmt.run(...safeParams); 134 return { rows: [], rowCount: r.changes }; 135 } 136 } catch { return { rows: [], rowCount: 0 }; } 137 }, 138 withTransaction: async (fn) => { 139 if (!setupDb) return; 140 const fakeClient = { 141 query: async (sql, params) => { 142 const s = pgToSqlite(sql); 143 try { 144 const stmt = setupDb.prepare(s); 145 const safeParams = (params || []).map(p => typeof p === 'boolean' ? (p ? 1 : 0) : p); 146 if (/^\s*(SELECT|WITH)/i.test(s)) { 147 const rows = stmt.all(...safeParams); 148 return { rowCount: rows.length, rows }; 149 } else { 150 const result = stmt.run(...safeParams); 151 return { rowCount: result.changes, rows: [] }; 152 } 153 } catch { return { rowCount: 0, rows: [] }; } 154 }, 155 }; 156 return await fn(fakeClient); 157 }, 158 }, 159 }); 160 161 // Mock child_process to prevent execFileSync('claude') from timing out (30s each) 162 // The polishProposal function tries Claude CLI first, then falls back to OpenRouter. 163 // We make the CLI throw immediately so it goes straight to the mocked OpenRouter fallback. 164 mock.module('child_process', { 165 namedExports: { 166 execFileSync: () => { throw new Error('claude: command not found (mocked)'); }, 167 execSync: () => { throw new Error('execSync mocked'); }, 168 spawnSync: () => ({ status: 1, stderr: Buffer.from('mocked'), stdout: Buffer.from('') }), 169 }, 170 }); 171 172 // Now import proposal generator module (db.js mock must be registered before this import) 173 const { 174 generateProposalVariants, 175 getPendingOutreaches, 176 approveOutreach, 177 reworkOutreach, 178 processReworkQueue, 179 generateBulkProposals, 180 } = await import('../../src/proposal-generator-v2.js'); 181 182 // Track inserted site IDs for cleanup 183 const insertedSiteIds = []; 184 185 /** 186 * Helper: create a low-scoring test site with contacts 187 */ 188 function insertTestSite(overrides = {}) { 189 const defaults = { 190 url: 'https://example-plumber.com', 191 domain: `site-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.com`, 192 keyword: 'plumber sydney', 193 status: 'enriched', 194 score: 55, 195 grade: 'F', 196 country_code: 'AU', 197 google_domain: 'google.com.au', 198 score_json: JSON.stringify({ 199 overall_calculation: { 200 letter_grade: 'F', 201 conversion_score: 55, 202 }, 203 factor_scores: { 204 headline_clarity: { 205 score: 3, 206 reasoning: 'Headline is generic and not compelling', 207 }, 208 value_proposition: { 209 score: 4, 210 reasoning: 'No clear value proposition', 211 }, 212 }, 213 sections: { 214 hero: { 215 score: 40, 216 criteria: { 217 headline_clarity: { 218 score: 3, 219 explanation: 'Headline is generic and not compelling', 220 }, 221 value_proposition: { 222 score: 4, 223 explanation: 'No clear value proposition', 224 }, 225 }, 226 }, 227 trust: { 228 score: 80, 229 criteria: { 230 testimonials: { score: 8, explanation: 'Good testimonials' }, 231 }, 232 }, 233 }, 234 }), 235 contacts_json: JSON.stringify({ 236 emails: ['owner@example-plumber.com'], 237 phones: ['+61412345678'], 238 city: 'Sydney', 239 state: 'NSW', 240 }), 241 }; 242 243 const cfg = { ...defaults, ...overrides }; 244 245 setupDb 246 .prepare( 247 `INSERT INTO sites ( 248 landing_page_url, domain, keyword, status, score, grade, 249 country_code, google_domain 250 ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)` 251 ) 252 .run( 253 cfg.url, 254 cfg.domain, 255 cfg.keyword, 256 cfg.status, 257 cfg.score, 258 cfg.grade, 259 cfg.country_code, 260 cfg.google_domain 261 ); 262 263 const siteId = setupDb.prepare('SELECT id FROM sites WHERE domain = ?').get(cfg.domain).id; 264 if (cfg.score_json) { 265 setScoreJson(siteId, cfg.score_json); 266 // Sync industry_classification from score_json (mirrors migration 113 trigger logic) 267 try { 268 const parsed = JSON.parse(cfg.score_json); 269 const industry = 270 parsed?.overall_calculation?.industry_classification || parsed?.industry_classification; 271 if (industry) { 272 setupDb.prepare('UPDATE sites SET industry_classification = ? WHERE id = ?').run(industry, siteId); 273 } 274 } catch { 275 // ignore parse errors 276 } 277 } 278 if (cfg.contacts_json) setContactsJson(siteId, cfg.contacts_json); 279 insertedSiteIds.push(siteId); 280 return siteId; 281 } 282 283 /** 284 * Helper: standard LLM response builder 285 */ 286 function buildLLMResponse(variantCount, overrides = {}) { 287 const variants = []; 288 for (let i = 1; i <= variantCount; i++) { 289 variants.push({ 290 proposal_text: `Hi there! I noticed your website could use some improvements. Variant ${i} proposal text here with details about conversion optimization.`, 291 variant_number: i, 292 recommended_channel: 'email', 293 reasoning: `Personalized variant ${i} for this contact`, 294 ...((overrides.variants && overrides.variants[i - 1]) || {}), 295 }); 296 } 297 298 return { 299 content: JSON.stringify({ 300 variants, 301 subject_line: overrides.subject_line || 'Quick win for your business website', 302 reasoning: overrides.reasoning || 'Generated personalized proposals for each contact', 303 }), 304 usage: { 305 promptTokens: overrides.promptTokens || 1500, 306 completionTokens: overrides.completionTokens || 400, 307 }, 308 }; 309 } 310 311 /** 312 * Default getAllContacts mock: parse contacts from contacts_json 313 */ 314 function defaultGetAllContacts(contactsJson, _countryCode) { 315 const contacts = []; 316 if (contactsJson?.emails) { 317 for (const email of contactsJson.emails) { 318 contacts.push({ uri: email, channel: 'email', name: null }); 319 } 320 } 321 if (contactsJson?.phones) { 322 for (const phone of contactsJson.phones) { 323 contacts.push({ uri: phone, channel: 'sms', name: null }); 324 } 325 } 326 if (contactsJson?.social_profiles) { 327 for (const profile of contactsJson.social_profiles) { 328 if (typeof profile === 'object' && profile.url) { 329 contacts.push({ uri: profile.url, channel: 'x', name: null }); 330 } 331 } 332 } 333 return contacts; 334 } 335 336 /** 337 * Default getAllContactsWithNames mock: async version of defaultGetAllContacts 338 */ 339 async function defaultGetAllContactsWithNames(contactsJson, countryCode) { 340 return defaultGetAllContacts(contactsJson, countryCode); 341 } 342 343 describe('Proposal Generator V2 - Comprehensive Mocked Tests', () => { 344 before(() => { 345 // Create test database with full schema at the file path the module uses 346 setupDb = initTestDb(getTestDbPath(dbName)); 347 348 // Production schema already creates the messages table with the correct schema 349 // (direction TEXT, approval_status, delivery_status columns) 350 }); 351 352 after(() => { 353 // Clean up filesystem files written during tests 354 for (const siteId of insertedSiteIds) { 355 deleteScoreJson(siteId); 356 deleteContactsJson(siteId); 357 } 358 if (setupDb) { 359 try { 360 setupDb.close(); 361 } catch { 362 // already closed 363 } 364 } 365 cleanupTestDb(dbName); 366 }); 367 368 afterEach(() => { 369 // Reset mocks between tests 370 callLLMMock.mock.resetCalls(); 371 logLLMUsageMock.mock.resetCalls(); 372 getProviderMock.mock.resetCalls(); 373 getProviderDisplayNameMock.mock.resetCalls(); 374 getAllContactsMock.mock.resetCalls(); 375 getAllContactsWithNamesMock.mock.resetCalls(); 376 cleanInvalidSocialLinksMock.mock.resetCalls(); 377 378 // Reset default mock implementations 379 getAllContactsMock.mock.mockImplementation(defaultGetAllContacts); 380 getAllContactsWithNamesMock.mock.mockImplementation(defaultGetAllContactsWithNames); 381 }); 382 383 // =================================================================== 384 // generateProposalVariants - Core Happy Path 385 // =================================================================== 386 387 describe('generateProposalVariants', () => { 388 test('should generate proposals for low-scoring site with contacts', async () => { 389 const siteId = insertTestSite(); 390 391 getAllContactsWithNamesMock.mock.mockImplementation(() => [ 392 { uri: 'owner@example-plumber.com', channel: 'email', name: 'John' }, 393 { uri: '+61412345678', channel: 'sms', name: null }, 394 ]); 395 396 callLLMMock.mock.mockImplementation(() => buildLLMResponse(2)); 397 398 const result = await generateProposalVariants(siteId); 399 400 assert.strictEqual(result.siteId, siteId); 401 assert.strictEqual(result.variants.length, 2); 402 assert.strictEqual(result.outreachIds.length, 2); 403 assert.ok(result.reasoning); 404 assert.strictEqual(result.contactCount, 2); 405 406 // Verify LLM was called at least once (main call + optional Haiku polish calls) 407 assert.ok(callLLMMock.mock.calls.length >= 1); 408 409 // Verify outreaches were stored in database 410 const outreaches = setupDb 411 .prepare('SELECT * FROM messages WHERE site_id = ? ORDER BY id') 412 .all(siteId); 413 assert.strictEqual(outreaches.length, 2); 414 415 // Verify site status updated 416 const site = setupDb.prepare('SELECT status FROM sites WHERE id = ?').get(siteId); 417 assert.strictEqual(site.status, 'proposals_drafted'); 418 }); 419 420 test('should generate proposals for site with single contact', async () => { 421 const siteId = insertTestSite({ 422 contacts_json: JSON.stringify({ emails: ['solo@unique-single.com'] }), 423 }); 424 425 getAllContactsWithNamesMock.mock.mockImplementation(() => [ 426 { uri: 'solo@unique-single.com', channel: 'email', name: null }, 427 ]); 428 429 callLLMMock.mock.mockImplementation(() => buildLLMResponse(1)); 430 431 const result = await generateProposalVariants(siteId); 432 433 assert.strictEqual(result.variants.length, 1); 434 assert.strictEqual(result.outreachIds.length, 1); 435 assert.strictEqual(result.contactCount, 1); 436 }); 437 438 test('should pass correct parameters to LLM', async () => { 439 const siteId = insertTestSite({ 440 contacts_json: JSON.stringify({ emails: ['param-test@t.com'] }), 441 }); 442 443 getAllContactsWithNamesMock.mock.mockImplementation(() => [ 444 { uri: 'param-test@t.com', channel: 'email', name: null }, 445 ]); 446 447 callLLMMock.mock.mockImplementation(() => buildLLMResponse(1)); 448 449 await generateProposalVariants(siteId); 450 451 const llmCall = callLLMMock.mock.calls[0].arguments[0]; 452 assert.strictEqual(llmCall.temperature, 0.7); 453 assert.strictEqual(llmCall.json_mode, true); 454 assert.ok(llmCall.messages.length === 2); 455 assert.strictEqual(llmCall.messages[0].role, 'system'); 456 assert.strictEqual(llmCall.messages[1].role, 'user'); 457 assert.ok(llmCall.max_tokens >= 8192); 458 }); 459 460 test('should scale max_tokens with contact count', async () => { 461 const siteId = insertTestSite({ 462 contacts_json: JSON.stringify({ 463 emails: ['a@t.com', 'b@t.com', 'c@t.com'], 464 phones: ['+61400111222', '+61400333444'], 465 }), 466 }); 467 468 getAllContactsWithNamesMock.mock.mockImplementation(() => [ 469 { uri: 'a@t.com', channel: 'email', name: null }, 470 { uri: 'b@t.com', channel: 'email', name: null }, 471 { uri: 'c@t.com', channel: 'email', name: null }, 472 { uri: '+61400111222', channel: 'sms', name: null }, 473 { uri: '+61400333444', channel: 'sms', name: null }, 474 ]); 475 476 callLLMMock.mock.mockImplementation(() => buildLLMResponse(5)); 477 478 await generateProposalVariants(siteId); 479 480 const llmCall = callLLMMock.mock.calls[0].arguments[0]; 481 assert.ok(llmCall.max_tokens >= 5 * 1200); 482 }); 483 484 test('should include domain and weaknesses in LLM context', async () => { 485 const domain = `context-test-${Date.now()}.com`; 486 const siteId = insertTestSite({ 487 domain, 488 keyword: 'emergency plumber melbourne', 489 contacts_json: JSON.stringify({ emails: ['info@ctx.com'] }), 490 }); 491 492 getAllContactsWithNamesMock.mock.mockImplementation(() => [ 493 { uri: 'info@ctx.com', channel: 'email', name: null }, 494 ]); 495 496 callLLMMock.mock.mockImplementation(() => buildLLMResponse(1)); 497 498 await generateProposalVariants(siteId); 499 500 const userPrompt = callLLMMock.mock.calls[0].arguments[0].messages[1].content; 501 assert.ok(userPrompt.includes(domain)); 502 assert.ok(userPrompt.includes('emergency plumber')); 503 // Should include weakness from score_json 504 assert.ok( 505 userPrompt.includes('headline_clarity') || userPrompt.includes('Headline is generic') 506 ); 507 }); 508 509 test('should include localization and best practices in context', async () => { 510 const siteId = insertTestSite({ 511 country_code: 'AU', 512 contacts_json: JSON.stringify({ emails: ['loc@t.com'] }), 513 }); 514 515 getAllContactsWithNamesMock.mock.mockImplementation(() => [ 516 { uri: 'loc@t.com', channel: 'email', name: null }, 517 ]); 518 519 callLLMMock.mock.mockImplementation(() => buildLLMResponse(1)); 520 521 await generateProposalVariants(siteId); 522 523 const userPrompt = callLLMMock.mock.calls[0].arguments[0].messages[1].content; 524 assert.ok(userPrompt.includes('Australia')); 525 assert.ok(userPrompt.includes('AUD')); 526 assert.ok(userPrompt.includes('EMAIL BEST PRACTICES')); 527 assert.ok(userPrompt.includes('SMS BEST PRACTICES')); 528 }); 529 530 test('should call LLM with stage identifier for usage tracking', async () => { 531 const siteId = insertTestSite({ 532 contacts_json: JSON.stringify({ emails: ['usage@t.com'] }), 533 }); 534 535 getAllContactsWithNamesMock.mock.mockImplementation(() => [ 536 { uri: 'usage@t.com', channel: 'email', name: null }, 537 ]); 538 539 callLLMMock.mock.mockImplementation(() => buildLLMResponse(1)); 540 541 await generateProposalVariants(siteId); 542 543 // LLM usage is tracked via callLLM's stage parameter (logLLMUsage was removed) 544 assert.ok(callLLMMock.mock.calls.length >= 1); 545 const llmCall = callLLMMock.mock.calls[0].arguments[0]; 546 assert.strictEqual(llmCall.stage, 'proposals'); 547 assert.ok(llmCall.siteId === siteId); 548 }); 549 550 test('should store subject line in outreach record', async () => { 551 const siteId = insertTestSite({ 552 contacts_json: JSON.stringify({ emails: ['subj@t.com'] }), 553 }); 554 555 getAllContactsWithNamesMock.mock.mockImplementation(() => [ 556 { uri: 'subj@t.com', channel: 'email', name: null }, 557 ]); 558 559 callLLMMock.mock.mockImplementation(() => 560 buildLLMResponse(1, { subject_line: 'Custom subject line for testing' }) 561 ); 562 563 await generateProposalVariants(siteId); 564 565 const outreach = setupDb 566 .prepare("SELECT subject_line FROM messages WHERE contact_uri = 'subj@t.com'") 567 .get(); 568 assert.strictEqual(outreach.subject_line, 'Custom subject line for testing'); 569 }); 570 571 // =================================================================== 572 // Error Handling 573 // =================================================================== 574 575 test('should throw error for non-existent site', async () => { 576 await assert.rejects( 577 async () => { 578 await generateProposalVariants(99999); 579 }, 580 { message: 'Site not found: 99999' } 581 ); 582 }); 583 584 test('should throw error for high-scoring site', async () => { 585 const siteId = insertTestSite({ 586 score: 95, 587 grade: 'A', 588 score_json: JSON.stringify({ 589 overall_calculation: { letter_grade: 'A', conversion_score: 95 }, 590 }), 591 }); 592 593 await assert.rejects(async () => { 594 await generateProposalVariants(siteId); 595 }, /above the cutoff/); 596 }); 597 598 test('should throw for site at exact cutoff (82)', async () => { 599 const siteId = insertTestSite({ 600 score: 82, 601 grade: 'B-', 602 score_json: JSON.stringify({ 603 overall_calculation: { letter_grade: 'B-', conversion_score: 82 }, 604 }), 605 }); 606 607 await assert.rejects(async () => { 608 await generateProposalVariants(siteId); 609 }, /above the cutoff/); 610 }); 611 612 test('should allow high-scoring site with rework instructions', async () => { 613 const siteId = insertTestSite({ 614 score: 95, 615 grade: 'A', 616 score_json: JSON.stringify({ 617 overall_calculation: { letter_grade: 'A', conversion_score: 95 }, 618 }), 619 contacts_json: JSON.stringify({ emails: ['rework-high@t.com'] }), 620 }); 621 622 getAllContactsWithNamesMock.mock.mockImplementation(() => [ 623 { uri: 'rework-high@t.com', channel: 'email', name: null }, 624 ]); 625 626 callLLMMock.mock.mockImplementation(() => buildLLMResponse(1)); 627 628 const result = await generateProposalVariants(siteId, 'Make it better'); 629 assert.strictEqual(result.variants.length, 1); 630 }); 631 632 test('should return early when no contacts found', async () => { 633 const siteId = insertTestSite({ 634 contacts_json: null, 635 }); 636 637 getAllContactsWithNamesMock.mock.mockImplementation(() => []); 638 639 const result = await generateProposalVariants(siteId); 640 641 assert.strictEqual(result.contactCount, 0); 642 assert.strictEqual(result.variants.length, 0); 643 assert.strictEqual(result.reasoning, 'No contacts found'); 644 assert.strictEqual(callLLMMock.mock.calls.length, 0); 645 }); 646 647 test('should return early when contacts_json is empty object', async () => { 648 const siteId = insertTestSite({ 649 contacts_json: JSON.stringify({}), 650 }); 651 652 getAllContactsWithNamesMock.mock.mockImplementation(() => []); 653 654 const result = await generateProposalVariants(siteId); 655 656 assert.strictEqual(result.contactCount, 0); 657 assert.strictEqual(result.reasoning, 'No contacts found'); 658 }); 659 660 test('should skip contacts that already have outreaches', async () => { 661 const siteId = insertTestSite({ 662 contacts_json: JSON.stringify({ 663 emails: ['existing-dup@t.com', 'new-dup@t.com'], 664 }), 665 }); 666 667 // Insert an existing outreach for one contact 668 setupDb 669 .prepare( 670 `INSERT INTO messages (site_id, direction, contact_method, contact_uri, message_body, delivery_status) 671 VALUES (?, 'outbound', ?, ?, ?, ?)` 672 ) 673 .run(siteId, 'email', 'existing-dup@t.com', 'Old proposal', 'sent'); 674 675 getAllContactsWithNamesMock.mock.mockImplementation(() => [ 676 { uri: 'existing-dup@t.com', channel: 'email', name: null }, 677 { uri: 'new-dup@t.com', channel: 'email', name: null }, 678 ]); 679 680 callLLMMock.mock.mockImplementation(() => buildLLMResponse(1)); 681 682 const result = await generateProposalVariants(siteId); 683 684 assert.strictEqual(result.variants.length, 1); 685 assert.strictEqual(result.outreachIds.length, 1); 686 }); 687 688 test('should return early when ALL contacts already have outreaches', async () => { 689 const siteId = insertTestSite({ 690 contacts_json: JSON.stringify({ emails: ['all-used@t.com'] }), 691 }); 692 693 setupDb 694 .prepare( 695 `INSERT INTO messages (site_id, direction, contact_method, contact_uri, message_body, delivery_status) 696 VALUES (?, 'outbound', ?, ?, ?, ?)` 697 ) 698 .run(siteId, 'email', 'all-used@t.com', 'Old proposal', 'sent'); 699 700 getAllContactsWithNamesMock.mock.mockImplementation(() => [ 701 { uri: 'all-used@t.com', channel: 'email', name: null }, 702 ]); 703 704 const result = await generateProposalVariants(siteId); 705 706 assert.strictEqual(result.outreachIds.length, 0); 707 assert.strictEqual(result.reasoning, 'All contacts already have messages'); 708 assert.strictEqual(callLLMMock.mock.calls.length, 0); 709 }); 710 711 test('should throw when LLM returns wrong number of variants', async () => { 712 const siteId = insertTestSite({ 713 contacts_json: JSON.stringify({ emails: ['vc1@t.com', 'vc2@t.com'] }), 714 }); 715 716 getAllContactsWithNamesMock.mock.mockImplementation(() => [ 717 { uri: 'vc1@t.com', channel: 'email', name: null }, 718 { uri: 'vc2@t.com', channel: 'email', name: null }, 719 ]); 720 721 callLLMMock.mock.mockImplementation(() => buildLLMResponse(1)); 722 723 await assert.rejects(async () => { 724 await generateProposalVariants(siteId); 725 }, /Expected 2 variants but got 1/); 726 }); 727 728 test('should throw when LLM returns invalid JSON', async () => { 729 const siteId = insertTestSite({ 730 contacts_json: JSON.stringify({ emails: ['ijson@t.com'] }), 731 }); 732 733 getAllContactsWithNamesMock.mock.mockImplementation(() => [ 734 { uri: 'ijson@t.com', channel: 'email', name: null }, 735 ]); 736 737 callLLMMock.mock.mockImplementation(() => ({ 738 content: 'not valid json at all', 739 usage: { promptTokens: 100, completionTokens: 50 }, 740 })); 741 742 await assert.rejects(async () => { 743 await generateProposalVariants(siteId); 744 }, /Invalid proposal response format/); 745 }); 746 747 test('should throw when LLM returns null content', async () => { 748 const siteId = insertTestSite({ 749 contacts_json: JSON.stringify({ emails: ['nullc@t.com'] }), 750 }); 751 752 getAllContactsWithNamesMock.mock.mockImplementation(() => [ 753 { uri: 'nullc@t.com', channel: 'email', name: null }, 754 ]); 755 756 callLLMMock.mock.mockImplementation(() => ({ 757 content: null, 758 usage: { promptTokens: 100, completionTokens: 50 }, 759 })); 760 761 await assert.rejects(async () => { 762 await generateProposalVariants(siteId); 763 }, /No content in API response/); 764 }); 765 766 test('should throw when LLM returns JSON without variants array', async () => { 767 const siteId = insertTestSite({ 768 contacts_json: JSON.stringify({ emails: ['novar@t.com'] }), 769 }); 770 771 getAllContactsWithNamesMock.mock.mockImplementation(() => [ 772 { uri: 'novar@t.com', channel: 'email', name: null }, 773 ]); 774 775 callLLMMock.mock.mockImplementation(() => ({ 776 content: JSON.stringify({ reasoning: 'no variants here' }), 777 usage: { promptTokens: 100, completionTokens: 50 }, 778 })); 779 780 await assert.rejects(async () => { 781 await generateProposalVariants(siteId); 782 }, /Invalid proposal response format/); 783 }); 784 785 // =================================================================== 786 // Rework Instructions 787 // =================================================================== 788 789 test('should include rework instructions in LLM prompt', async () => { 790 const siteId = insertTestSite({ 791 contacts_json: JSON.stringify({ emails: ['rw-prompt@t.com'] }), 792 }); 793 794 getAllContactsWithNamesMock.mock.mockImplementation(() => [ 795 { uri: 'rw-prompt@t.com', channel: 'email', name: null }, 796 ]); 797 798 callLLMMock.mock.mockImplementation(() => buildLLMResponse(1)); 799 800 await generateProposalVariants(siteId, 'Make it more personal and mention their location'); 801 802 const userPrompt = callLLMMock.mock.calls[0].arguments[0].messages[1].content; 803 assert.ok(userPrompt.includes('REWORK INSTRUCTIONS FROM OPERATOR')); 804 assert.ok(userPrompt.includes('Make it more personal and mention their location')); 805 }); 806 807 // =================================================================== 808 // Contact Method Normalization 809 // =================================================================== 810 811 test('should store correct contact_method for email contacts', async () => { 812 const siteId = insertTestSite({ 813 contacts_json: JSON.stringify({ emails: ['cm-email@t.com'] }), 814 }); 815 816 getAllContactsWithNamesMock.mock.mockImplementation(() => [ 817 { uri: 'cm-email@t.com', channel: 'email', name: null }, 818 ]); 819 820 callLLMMock.mock.mockImplementation(() => buildLLMResponse(1)); 821 822 await generateProposalVariants(siteId); 823 824 const outreach = setupDb 825 .prepare("SELECT contact_method FROM messages WHERE contact_uri = 'cm-email@t.com'") 826 .get(); 827 assert.strictEqual(outreach.contact_method, 'email'); 828 }); 829 830 test('should store correct contact_method for SMS contacts', async () => { 831 const siteId = insertTestSite({ 832 contacts_json: JSON.stringify({ phones: ['+61499887766'] }), 833 }); 834 835 getAllContactsWithNamesMock.mock.mockImplementation(() => [ 836 { uri: '+61499887766', channel: 'sms', name: null }, 837 ]); 838 839 callLLMMock.mock.mockImplementation(() => buildLLMResponse(1)); 840 841 await generateProposalVariants(siteId); 842 843 const outreach = setupDb 844 .prepare('SELECT contact_method FROM messages WHERE site_id = ?') 845 .get(siteId); 846 assert.strictEqual(outreach.contact_method, 'sms'); 847 }); 848 849 // =================================================================== 850 // Contact Filtering (Gov, Edu, Demo Emails) 851 // =================================================================== 852 853 test('should skip government email contacts (no record created)', async () => { 854 const siteId = insertTestSite({ 855 contacts_json: JSON.stringify({ emails: ['info@council.gov.au'] }), 856 }); 857 858 getAllContactsWithNamesMock.mock.mockImplementation(() => [ 859 { uri: 'info@council.gov.au', channel: 'email', name: null }, 860 ]); 861 862 callLLMMock.mock.mockImplementation(() => buildLLMResponse(1)); 863 864 const result = await generateProposalVariants(siteId); 865 866 // Gov emails are skipped entirely (storeProposalVariant returns null) 867 const outreach = setupDb 868 .prepare('SELECT * FROM messages WHERE site_id = ? AND contact_uri = ?') 869 .get(siteId, 'info@council.gov.au'); 870 assert.strictEqual(outreach, undefined, 'No message record should be created for gov emails'); 871 // outreachIds includes null for skipped contacts 872 assert.ok( 873 result.outreachIds.includes(null), 874 'outreachIds should contain null for skipped gov email' 875 ); 876 }); 877 878 test('should skip education email contacts (no record created)', async () => { 879 const siteId = insertTestSite({ 880 contacts_json: JSON.stringify({ emails: ['prof@university.edu'] }), 881 }); 882 883 getAllContactsWithNamesMock.mock.mockImplementation(() => [ 884 { uri: 'prof@university.edu', channel: 'email', name: null }, 885 ]); 886 887 callLLMMock.mock.mockImplementation(() => buildLLMResponse(1)); 888 889 const result = await generateProposalVariants(siteId); 890 891 const outreach = setupDb 892 .prepare('SELECT * FROM messages WHERE site_id = ? AND contact_uri = ?') 893 .get(siteId, 'prof@university.edu'); 894 assert.strictEqual(outreach, undefined, 'No message record should be created for edu emails'); 895 assert.ok( 896 result.outreachIds.includes(null), 897 'outreachIds should contain null for skipped edu email' 898 ); 899 }); 900 901 test('should skip demo email contacts (no record created)', async () => { 902 const siteId = insertTestSite({ 903 contacts_json: JSON.stringify({ emails: ['test@example.com'] }), 904 }); 905 906 getAllContactsWithNamesMock.mock.mockImplementation(() => [ 907 { uri: 'test@example.com', channel: 'email', name: null }, 908 ]); 909 910 callLLMMock.mock.mockImplementation(() => buildLLMResponse(1)); 911 912 const result = await generateProposalVariants(siteId); 913 914 const outreach = setupDb 915 .prepare('SELECT * FROM messages WHERE site_id = ? AND contact_uri = ?') 916 .get(siteId, 'test@example.com'); 917 assert.strictEqual( 918 outreach, 919 undefined, 920 'No message record should be created for demo emails' 921 ); 922 assert.ok( 923 result.outreachIds.includes(null), 924 'outreachIds should contain null for skipped demo email' 925 ); 926 }); 927 928 test('should block GDPR-unverified email in GDPR country', async () => { 929 // Temporarily clear OUTREACH_BLOCKED_COUNTRIES so the test can reach GDPR logic 930 const savedBlocked = process.env.OUTREACH_BLOCKED_COUNTRIES; 931 process.env.OUTREACH_BLOCKED_COUNTRIES = ''; 932 933 try { 934 const siteId = insertTestSite({ 935 country_code: 'GB', 936 google_domain: 'google.co.uk', 937 contacts_json: JSON.stringify({ emails: ['info@gdpr-test.co.uk'] }), 938 }); 939 940 setupDb.prepare('UPDATE sites SET gdpr_verified = 0 WHERE id = ?').run(siteId); 941 942 getAllContactsWithNamesMock.mock.mockImplementation(() => [ 943 { uri: 'info@gdpr-test.co.uk', channel: 'email', name: null }, 944 ]); 945 946 callLLMMock.mock.mockImplementation(() => buildLLMResponse(1)); 947 948 await generateProposalVariants(siteId); 949 950 const outreach = setupDb 951 .prepare('SELECT approval_status FROM messages WHERE site_id = ?') 952 .get(siteId); 953 assert.strictEqual(outreach.approval_status, 'gdpr_blocked'); 954 } finally { 955 process.env.OUTREACH_BLOCKED_COUNTRIES = savedBlocked; 956 } 957 }); 958 959 test('should NOT block GDPR-verified email in GDPR country', async () => { 960 const savedBlocked = process.env.OUTREACH_BLOCKED_COUNTRIES; 961 process.env.OUTREACH_BLOCKED_COUNTRIES = ''; 962 963 try { 964 const siteId = insertTestSite({ 965 country_code: 'GB', 966 google_domain: 'google.co.uk', 967 contacts_json: JSON.stringify({ emails: ['info@gdpr-verified.co.uk'] }), 968 }); 969 970 setupDb.prepare('UPDATE sites SET gdpr_verified = 1 WHERE id = ?').run(siteId); 971 972 getAllContactsWithNamesMock.mock.mockImplementation(() => [ 973 { uri: 'info@gdpr-verified.co.uk', channel: 'email', name: null }, 974 ]); 975 976 callLLMMock.mock.mockImplementation(() => buildLLMResponse(1)); 977 978 await generateProposalVariants(siteId); 979 980 const outreach = setupDb 981 .prepare('SELECT approval_status FROM messages WHERE site_id = ?') 982 .get(siteId); 983 assert.strictEqual(outreach.approval_status, 'pending'); 984 } finally { 985 process.env.OUTREACH_BLOCKED_COUNTRIES = savedBlocked; 986 } 987 }); 988 989 test('should NOT apply GDPR block for non-GDPR country', async () => { 990 const siteId = insertTestSite({ 991 country_code: 'AU', 992 contacts_json: JSON.stringify({ emails: ['info@no-gdpr.com.au'] }), 993 }); 994 995 setupDb.prepare('UPDATE sites SET gdpr_verified = 0 WHERE id = ?').run(siteId); 996 997 getAllContactsWithNamesMock.mock.mockImplementation(() => [ 998 { uri: 'info@no-gdpr.com.au', channel: 'email', name: null }, 999 ]); 1000 1001 callLLMMock.mock.mockImplementation(() => buildLLMResponse(1)); 1002 1003 await generateProposalVariants(siteId); 1004 1005 const outreach = setupDb 1006 .prepare('SELECT approval_status FROM messages WHERE site_id = ?') 1007 .get(siteId); 1008 assert.strictEqual(outreach.approval_status, 'pending'); 1009 }); 1010 1011 // =================================================================== 1012 // Competitor Context 1013 // =================================================================== 1014 1015 test('should include competitor info when score difference is significant', async () => { 1016 // Insert a high-scoring competitor with same keyword + industry (required for competitor matching) 1017 insertTestSite({ 1018 domain: 'top-comp.com', 1019 url: 'https://top-comp.com', 1020 keyword: 'plumber perth', 1021 score: 85, 1022 grade: 'B', 1023 status: 'prog_scored', 1024 score_json: JSON.stringify({ 1025 overall_calculation: { letter_grade: 'B', conversion_score: 85 }, 1026 industry_classification: 'plumbing', 1027 }), 1028 contacts_json: null, 1029 }); 1030 1031 const siteId = insertTestSite({ 1032 domain: 'weak-comp.com', 1033 url: 'https://weak-comp.com', 1034 keyword: 'plumber perth', 1035 score: 40, 1036 grade: 'F', 1037 score_json: JSON.stringify({ 1038 overall_calculation: { letter_grade: 'F', conversion_score: 40 }, 1039 industry_classification: 'plumbing', 1040 }), 1041 contacts_json: JSON.stringify({ emails: ['info@weak-comp.com'] }), 1042 }); 1043 1044 getAllContactsWithNamesMock.mock.mockImplementation(() => [ 1045 { uri: 'info@weak-comp.com', channel: 'email', name: null }, 1046 ]); 1047 1048 callLLMMock.mock.mockImplementation(() => buildLLMResponse(1)); 1049 1050 await generateProposalVariants(siteId); 1051 1052 const userPrompt = callLLMMock.mock.calls[0].arguments[0].messages[1].content; 1053 assert.ok(userPrompt.includes('top-comp.com')); 1054 assert.ok(userPrompt.includes('TOP COMPETITOR')); 1055 }); 1056 1057 test('should NOT mention competitor scores when difference is small', async () => { 1058 // Use a unique industry to avoid competitor pollution from earlier tests 1059 const industry = `locksmith-${Date.now()}`; 1060 insertTestSite({ 1061 domain: 'sim-comp.com', 1062 url: 'https://sim-comp.com', 1063 keyword: 'locksmith canberra', 1064 score: 58, 1065 grade: 'F', 1066 status: 'prog_scored', 1067 score_json: JSON.stringify({ 1068 overall_calculation: { letter_grade: 'F', conversion_score: 58 }, 1069 industry_classification: industry, 1070 }), 1071 contacts_json: null, 1072 }); 1073 1074 const siteId = insertTestSite({ 1075 domain: 'tgt-comp.com', 1076 url: 'https://tgt-comp.com', 1077 keyword: 'locksmith canberra', 1078 score: 55, 1079 grade: 'F', 1080 score_json: JSON.stringify({ 1081 overall_calculation: { letter_grade: 'F', conversion_score: 55 }, 1082 industry_classification: industry, 1083 }), 1084 contacts_json: JSON.stringify({ emails: ['info@tgt-comp.com'] }), 1085 }); 1086 1087 getAllContactsWithNamesMock.mock.mockImplementation(() => [ 1088 { uri: 'info@tgt-comp.com', channel: 'email', name: null }, 1089 ]); 1090 1091 callLLMMock.mock.mockImplementation(() => buildLLMResponse(1)); 1092 1093 await generateProposalVariants(siteId); 1094 1095 const userPrompt = callLLMMock.mock.calls[0].arguments[0].messages[1].content; 1096 assert.ok(userPrompt.includes('do NOT mention specific scores')); 1097 }); 1098 1099 // =================================================================== 1100 // Score Data Edge Cases 1101 // =================================================================== 1102 1103 test('should handle site with null score_json', async () => { 1104 const siteId = insertTestSite({ 1105 score: 50, 1106 grade: 'F', 1107 contacts_json: JSON.stringify({ emails: ['null-sj@t.com'] }), 1108 }); 1109 1110 deleteScoreJson(siteId); 1111 1112 getAllContactsWithNamesMock.mock.mockImplementation(() => [ 1113 { uri: 'null-sj@t.com', channel: 'email', name: null }, 1114 ]); 1115 1116 callLLMMock.mock.mockImplementation(() => buildLLMResponse(1)); 1117 1118 const result = await generateProposalVariants(siteId); 1119 1120 assert.strictEqual(result.variants.length, 1); 1121 const userPrompt = callLLMMock.mock.calls[0].arguments[0].messages[1].content; 1122 assert.ok(userPrompt.includes('General conversion optimization')); 1123 }); 1124 1125 test('should handle score_json without sections', async () => { 1126 const siteId = insertTestSite({ 1127 score_json: JSON.stringify({ 1128 overall_calculation: { letter_grade: 'F', conversion_score: 35 }, 1129 }), 1130 contacts_json: JSON.stringify({ emails: ['no-sect@t.com'] }), 1131 }); 1132 1133 getAllContactsWithNamesMock.mock.mockImplementation(() => [ 1134 { uri: 'no-sect@t.com', channel: 'email', name: null }, 1135 ]); 1136 1137 callLLMMock.mock.mockImplementation(() => buildLLMResponse(1)); 1138 1139 const result = await generateProposalVariants(siteId); 1140 assert.strictEqual(result.variants.length, 1); 1141 }); 1142 1143 test('should extract weaknesses only from low-scoring sections', async () => { 1144 const siteId = insertTestSite({ 1145 score_json: JSON.stringify({ 1146 overall_calculation: { letter_grade: 'F', conversion_score: 35 }, 1147 factor_scores: { 1148 cta_clarity: { 1149 score: 2, 1150 reasoning: 'No clear call-to-action button visible', 1151 }, 1152 reviews: { 1153 score: 9, 1154 reasoning: 'Many positive reviews', 1155 }, 1156 }, 1157 sections: { 1158 hero: { 1159 score: 30, 1160 criteria: { 1161 cta_clarity: { 1162 score: 2, 1163 explanation: 'No clear call-to-action button visible', 1164 }, 1165 }, 1166 }, 1167 social_proof: { 1168 score: 90, 1169 criteria: { 1170 reviews: { score: 9, explanation: 'Many positive reviews' }, 1171 }, 1172 }, 1173 }, 1174 }), 1175 contacts_json: JSON.stringify({ emails: ['weak@t.com'] }), 1176 }); 1177 1178 getAllContactsWithNamesMock.mock.mockImplementation(() => [ 1179 { uri: 'weak@t.com', channel: 'email', name: null }, 1180 ]); 1181 1182 callLLMMock.mock.mockImplementation(() => buildLLMResponse(1)); 1183 1184 await generateProposalVariants(siteId); 1185 1186 const userPrompt = callLLMMock.mock.calls[0].arguments[0].messages[1].content; 1187 assert.ok( 1188 userPrompt.includes('No clear call-to-action') || userPrompt.includes('cta_clarity') 1189 ); 1190 }); 1191 1192 test('should handle all high-scoring sections (no weaknesses)', async () => { 1193 const siteId = insertTestSite({ 1194 score: 50, 1195 grade: 'F', 1196 score_json: JSON.stringify({ 1197 overall_calculation: { letter_grade: 'F', conversion_score: 50 }, 1198 sections: { 1199 hero: { score: 85, criteria: { headline: { score: 9, explanation: 'Great' } } }, 1200 trust: { score: 90, criteria: { reviews: { score: 9, explanation: 'Many' } } }, 1201 }, 1202 }), 1203 contacts_json: JSON.stringify({ emails: ['highsec@t.com'] }), 1204 }); 1205 1206 getAllContactsWithNamesMock.mock.mockImplementation(() => [ 1207 { uri: 'highsec@t.com', channel: 'email', name: null }, 1208 ]); 1209 1210 callLLMMock.mock.mockImplementation(() => buildLLMResponse(1)); 1211 1212 await generateProposalVariants(siteId); 1213 1214 const userPrompt = callLLMMock.mock.calls[0].arguments[0].messages[1].content; 1215 assert.ok(userPrompt.includes('General conversion optimization')); 1216 }); 1217 1218 test('should truncate weaknesses to max 5', async () => { 1219 const criteria = {}; 1220 for (let i = 0; i < 10; i++) { 1221 criteria[`weakness_${i}`] = { 1222 score: 2, 1223 explanation: `Weakness number ${i} description`, 1224 }; 1225 } 1226 1227 const siteId = insertTestSite({ 1228 score_json: JSON.stringify({ 1229 overall_calculation: { letter_grade: 'F', conversion_score: 15 }, 1230 sections: { 1231 everything_bad: { score: 20, criteria }, 1232 }, 1233 }), 1234 contacts_json: JSON.stringify({ emails: ['trunc@t.com'] }), 1235 }); 1236 1237 getAllContactsWithNamesMock.mock.mockImplementation(() => [ 1238 { uri: 'trunc@t.com', channel: 'email', name: null }, 1239 ]); 1240 1241 callLLMMock.mock.mockImplementation(() => buildLLMResponse(1)); 1242 1243 await generateProposalVariants(siteId); 1244 1245 const userPrompt = callLLMMock.mock.calls[0].arguments[0].messages[1].content; 1246 const weaknessLines = userPrompt.split('\n').filter(l => l.match(/^- weakness_\d+:/)); 1247 assert.ok( 1248 weaknessLines.length <= 5, 1249 `Expected at most 5 weakness lines, got ${weaknessLines.length}` 1250 ); 1251 }); 1252 1253 // =================================================================== 1254 // Location Handling 1255 // =================================================================== 1256 1257 test('should include city and state from contacts_json', async () => { 1258 const siteId = insertTestSite({ 1259 contacts_json: JSON.stringify({ 1260 emails: ['loc-cs@t.com'], 1261 city: 'Melbourne', 1262 state: 'VIC', 1263 }), 1264 }); 1265 1266 getAllContactsWithNamesMock.mock.mockImplementation(() => [ 1267 { uri: 'loc-cs@t.com', channel: 'email', name: null }, 1268 ]); 1269 1270 callLLMMock.mock.mockImplementation(() => buildLLMResponse(1)); 1271 1272 await generateProposalVariants(siteId); 1273 1274 const userPrompt = callLLMMock.mock.calls[0].arguments[0].messages[1].content; 1275 assert.ok(userPrompt.includes('Melbourne, VIC')); 1276 }); 1277 1278 test('should handle city-only location', async () => { 1279 const siteId = insertTestSite({ 1280 contacts_json: JSON.stringify({ 1281 emails: ['loc-c@t.com'], 1282 city: 'Brisbane', 1283 }), 1284 }); 1285 1286 getAllContactsWithNamesMock.mock.mockImplementation(() => [ 1287 { uri: 'loc-c@t.com', channel: 'email', name: null }, 1288 ]); 1289 1290 callLLMMock.mock.mockImplementation(() => buildLLMResponse(1)); 1291 1292 await generateProposalVariants(siteId); 1293 1294 const userPrompt = callLLMMock.mock.calls[0].arguments[0].messages[1].content; 1295 assert.ok(userPrompt.includes('Brisbane')); 1296 }); 1297 1298 test('should handle state-only location', async () => { 1299 const siteId = insertTestSite({ 1300 contacts_json: JSON.stringify({ 1301 emails: ['loc-s@t.com'], 1302 state: 'QLD', 1303 }), 1304 }); 1305 1306 getAllContactsWithNamesMock.mock.mockImplementation(() => [ 1307 { uri: 'loc-s@t.com', channel: 'email', name: null }, 1308 ]); 1309 1310 callLLMMock.mock.mockImplementation(() => buildLLMResponse(1)); 1311 1312 await generateProposalVariants(siteId); 1313 1314 const userPrompt = callLLMMock.mock.calls[0].arguments[0].messages[1].content; 1315 assert.ok(userPrompt.includes('QLD')); 1316 }); 1317 1318 test('should show Unknown when no location data', async () => { 1319 const siteId = insertTestSite({ 1320 contacts_json: JSON.stringify({ emails: ['loc-none@t.com'] }), 1321 }); 1322 1323 getAllContactsWithNamesMock.mock.mockImplementation(() => [ 1324 { uri: 'loc-none@t.com', channel: 'email', name: null }, 1325 ]); 1326 1327 callLLMMock.mock.mockImplementation(() => buildLLMResponse(1)); 1328 1329 await generateProposalVariants(siteId); 1330 1331 const userPrompt = callLLMMock.mock.calls[0].arguments[0].messages[1].content; 1332 assert.ok(userPrompt.includes('Unknown')); 1333 }); 1334 1335 // =================================================================== 1336 // Business Type Extraction 1337 // =================================================================== 1338 1339 test('should extract business type from keyword (removes city)', async () => { 1340 const siteId = insertTestSite({ 1341 keyword: 'electrician sydney', 1342 contacts_json: JSON.stringify({ emails: ['biz@t.com'] }), 1343 }); 1344 1345 getAllContactsWithNamesMock.mock.mockImplementation(() => [ 1346 { uri: 'biz@t.com', channel: 'email', name: null }, 1347 ]); 1348 1349 callLLMMock.mock.mockImplementation(() => buildLLMResponse(1)); 1350 1351 await generateProposalVariants(siteId); 1352 1353 const userPrompt = callLLMMock.mock.calls[0].arguments[0].messages[1].content; 1354 assert.ok(userPrompt.includes('electrician')); 1355 }); 1356 1357 // =================================================================== 1358 // Country-specific Behavior 1359 // =================================================================== 1360 1361 test('should use US formatting for US sites', async () => { 1362 const siteId = insertTestSite({ 1363 country_code: 'US', 1364 google_domain: 'google.com', 1365 contacts_json: JSON.stringify({ emails: ['us@t.com'] }), 1366 }); 1367 1368 getAllContactsWithNamesMock.mock.mockImplementation(() => [ 1369 { uri: 'us@t.com', channel: 'email', name: null }, 1370 ]); 1371 1372 callLLMMock.mock.mockImplementation(() => buildLLMResponse(1)); 1373 1374 await generateProposalVariants(siteId); 1375 1376 const userPrompt = callLLMMock.mock.calls[0].arguments[0].messages[1].content; 1377 assert.ok(userPrompt.includes('United States')); 1378 assert.ok(userPrompt.includes('USD')); 1379 }); 1380 1381 test('should throw when country_code is null (requires re-enrichment)', async () => { 1382 const siteId = insertTestSite({ 1383 contacts_json: JSON.stringify({ emails: ['nullcc@t.com'] }), 1384 }); 1385 1386 setupDb 1387 .prepare('UPDATE sites SET country_code = NULL, google_domain = NULL WHERE id = ?') 1388 .run(siteId); 1389 1390 await assert.rejects(async () => generateProposalVariants(siteId), /country_code is unknown/); 1391 }); 1392 1393 // =================================================================== 1394 // SMS Phone Normalization 1395 // =================================================================== 1396 1397 test('should normalize SMS phone numbers with country code', async () => { 1398 const siteId = insertTestSite({ 1399 country_code: 'AU', 1400 contacts_json: JSON.stringify({ phones: ['0412345678'] }), 1401 }); 1402 1403 getAllContactsWithNamesMock.mock.mockImplementation(() => [ 1404 { uri: '0412345678', channel: 'sms', name: null }, 1405 ]); 1406 1407 callLLMMock.mock.mockImplementation(() => buildLLMResponse(1)); 1408 1409 await generateProposalVariants(siteId); 1410 1411 const outreach = setupDb 1412 .prepare('SELECT contact_uri, contact_method FROM messages WHERE site_id = ?') 1413 .get(siteId); 1414 assert.strictEqual(outreach.contact_method, 'sms'); 1415 // addCountryCode should prepend +61 1416 assert.ok( 1417 outreach.contact_uri.startsWith('+61') || outreach.contact_uri === '0412345678', 1418 `Expected phone to be normalized, got: ${outreach.contact_uri}` 1419 ); 1420 }); 1421 1422 // =================================================================== 1423 // Variant Storage 1424 // =================================================================== 1425 1426 test('should store message_body from each variant', async () => { 1427 const siteId = insertTestSite({ 1428 contacts_json: JSON.stringify({ emails: ['va@t.com', 'vb@t.com'] }), 1429 }); 1430 1431 getAllContactsWithNamesMock.mock.mockImplementation(() => [ 1432 { uri: 'va@t.com', channel: 'email', name: null }, 1433 { uri: 'vb@t.com', channel: 'email', name: null }, 1434 ]); 1435 1436 callLLMMock.mock.mockImplementation(() => ({ 1437 content: JSON.stringify({ 1438 variants: [ 1439 { proposal_text: 'First unique proposal for contact A', variant_number: 1 }, 1440 { proposal_text: 'Second unique proposal for contact B', variant_number: 2 }, 1441 ], 1442 subject_line: 'Test', 1443 reasoning: 'Test', 1444 }), 1445 usage: { promptTokens: 100, completionTokens: 50 }, 1446 })); 1447 1448 await generateProposalVariants(siteId); 1449 1450 const outreaches = setupDb 1451 .prepare('SELECT message_body, direction FROM messages WHERE site_id = ? ORDER BY id') 1452 .all(siteId); 1453 1454 assert.strictEqual(outreaches.length, 2); 1455 assert.ok(outreaches[0].message_body.includes('First unique proposal')); 1456 assert.ok(outreaches[1].message_body.includes('Second unique proposal')); 1457 assert.strictEqual(outreaches[0].direction, 'outbound'); 1458 assert.strictEqual(outreaches[1].direction, 'outbound'); 1459 }); 1460 1461 // =================================================================== 1462 // cleanInvalidSocialLinks integration 1463 // =================================================================== 1464 1465 test('should call cleanInvalidSocialLinks on contacts_json', async () => { 1466 const siteId = insertTestSite({ 1467 contacts_json: JSON.stringify({ 1468 emails: ['clean@t.com'], 1469 social_profiles: ['https://x.com/'], 1470 }), 1471 }); 1472 1473 getAllContactsWithNamesMock.mock.mockImplementation(() => [ 1474 { uri: 'clean@t.com', channel: 'email', name: null }, 1475 ]); 1476 1477 callLLMMock.mock.mockImplementation(() => buildLLMResponse(1)); 1478 1479 await generateProposalVariants(siteId); 1480 1481 assert.ok(cleanInvalidSocialLinksMock.mock.calls.length >= 1); 1482 }); 1483 1484 // =================================================================== 1485 // Site city/state from sites table fallback 1486 // =================================================================== 1487 1488 test('should use city/state from sites table when contacts_json has none', async () => { 1489 const siteId = insertTestSite({ 1490 contacts_json: JSON.stringify({ emails: ['fallback-loc@t.com'] }), 1491 }); 1492 1493 setupDb 1494 .prepare('UPDATE sites SET city = ?, state = ? WHERE id = ?') 1495 .run('Perth', 'WA', siteId); 1496 1497 getAllContactsWithNamesMock.mock.mockImplementation(() => [ 1498 { uri: 'fallback-loc@t.com', channel: 'email', name: null }, 1499 ]); 1500 1501 callLLMMock.mock.mockImplementation(() => buildLLMResponse(1)); 1502 1503 await generateProposalVariants(siteId); 1504 1505 const userPrompt = callLLMMock.mock.calls[0].arguments[0].messages[1].content; 1506 assert.ok(userPrompt.includes('Perth') || userPrompt.includes('WA')); 1507 }); 1508 }); 1509 1510 // =================================================================== 1511 // getPendingOutreaches 1512 // =================================================================== 1513 1514 describe('getPendingOutreaches', () => { 1515 test('should return pending outreaches with site data', async () => { 1516 const siteId = insertTestSite(); 1517 1518 setupDb 1519 .prepare( 1520 `INSERT INTO messages (site_id, direction, contact_method, contact_uri, message_body, approval_status) 1521 VALUES (?, 'outbound', ?, ?, ?, ?)` 1522 ) 1523 .run(siteId, 'email', 'pending-get@t.com', 'Test proposal text', 'pending'); 1524 1525 const result = await getPendingOutreaches(); 1526 1527 // Should include this pending outreach (may include others from prior tests) 1528 const found = result.find(o => o.contact_uri === 'pending-get@t.com'); 1529 assert.ok(found, 'Should find the pending outreach'); 1530 assert.strictEqual(found.approval_status, 'pending'); 1531 }); 1532 1533 test('should NOT return non-pending outreaches', async () => { 1534 const siteId = insertTestSite(); 1535 1536 setupDb 1537 .prepare( 1538 `INSERT INTO messages (site_id, direction, contact_method, contact_uri, message_body, delivery_status) 1539 VALUES (?, 'outbound', ?, ?, ?, ?)` 1540 ) 1541 .run(siteId, 'email', 'sent-only@t.com', 'Already sent', 'sent'); 1542 1543 const result = await getPendingOutreaches(); 1544 const found = result.find(o => o.contact_uri === 'sent-only@t.com'); 1545 assert.strictEqual(found, undefined, 'Sent outreach should not appear in pending list'); 1546 }); 1547 1548 test('should respect limit parameter', async () => { 1549 const siteId = insertTestSite(); 1550 1551 for (let i = 0; i < 5; i++) { 1552 setupDb 1553 .prepare( 1554 `INSERT INTO messages (site_id, direction, contact_method, contact_uri, message_body, approval_status) 1555 VALUES (?, 'outbound', ?, ?, ?, ?)` 1556 ) 1557 .run(siteId, 'email', `limit-test-${i}@t.com`, `Proposal ${i}`, 'pending'); 1558 } 1559 1560 const result = await getPendingOutreaches(2); 1561 assert.strictEqual(result.length, 2); 1562 }); 1563 }); 1564 1565 // =================================================================== 1566 // approveOutreach 1567 // =================================================================== 1568 1569 describe('approveOutreach', () => { 1570 test('should approve a pending outreach', () => { 1571 const siteId = insertTestSite(); 1572 1573 setupDb 1574 .prepare( 1575 `INSERT INTO messages (site_id, direction, contact_method, contact_uri, message_body, approval_status) 1576 VALUES (?, 'outbound', ?, ?, ?, ?)` 1577 ) 1578 .run(siteId, 'email', 'approve-me@t.com', 'Test proposal', 'pending'); 1579 1580 const outreachId = setupDb 1581 .prepare("SELECT id FROM messages WHERE contact_uri = 'approve-me@t.com'") 1582 .get().id; 1583 1584 approveOutreach(outreachId); 1585 1586 const outreach = setupDb 1587 .prepare('SELECT approval_status FROM messages WHERE id = ?') 1588 .get(outreachId); 1589 assert.strictEqual(outreach.approval_status, 'approved'); 1590 }); 1591 }); 1592 1593 // =================================================================== 1594 // reworkOutreach 1595 // =================================================================== 1596 1597 describe('reworkOutreach', () => { 1598 test('should mark outreach for rework with instructions', () => { 1599 const siteId = insertTestSite(); 1600 1601 setupDb 1602 .prepare( 1603 `INSERT INTO messages (site_id, direction, contact_method, contact_uri, message_body, approval_status) 1604 VALUES (?, 'outbound', ?, ?, ?, ?)` 1605 ) 1606 .run(siteId, 'email', 'rework-me@t.com', 'Original proposal', 'pending'); 1607 1608 const outreachId = setupDb 1609 .prepare("SELECT id FROM messages WHERE contact_uri = 'rework-me@t.com'") 1610 .get().id; 1611 1612 reworkOutreach(outreachId, 'Make it shorter and more personal'); 1613 1614 const outreach = setupDb 1615 .prepare('SELECT approval_status, rework_instructions FROM messages WHERE id = ?') 1616 .get(outreachId); 1617 assert.strictEqual(outreach.approval_status, 'rework'); 1618 assert.strictEqual(outreach.rework_instructions, 'Make it shorter and more personal'); 1619 }); 1620 }); 1621 1622 // =================================================================== 1623 // processReworkQueue 1624 // =================================================================== 1625 1626 describe('processReworkQueue', () => { 1627 test('should handle empty rework queue gracefully', async () => { 1628 // Clear any rework items from other tests 1629 setupDb.exec("DELETE FROM messages WHERE approval_status = 'rework'"); 1630 1631 await processReworkQueue(); 1632 assert.strictEqual(callLLMMock.mock.calls.length, 0); 1633 }); 1634 1635 test('should process rework items by regenerating proposals', async () => { 1636 const siteId = insertTestSite({ 1637 contacts_json: JSON.stringify({ emails: ['rework-proc@t.com'] }), 1638 }); 1639 1640 setupDb 1641 .prepare( 1642 `INSERT INTO messages (site_id, direction, contact_method, contact_uri, message_body, approval_status, rework_instructions) 1643 VALUES (?, 'outbound', ?, ?, ?, ?, ?)` 1644 ) 1645 .run(siteId, 'email', 'rework-proc@t.com', 'Old proposal', 'rework', 'Be more direct'); 1646 1647 getAllContactsWithNamesMock.mock.mockImplementation(() => [ 1648 { uri: 'rework-proc@t.com', channel: 'email', name: null }, 1649 ]); 1650 1651 callLLMMock.mock.mockImplementation(() => buildLLMResponse(1)); 1652 1653 await processReworkQueue(); 1654 1655 // Should have called LLM at least once 1656 assert.ok(callLLMMock.mock.calls.length >= 1); 1657 }); 1658 }); 1659 1660 // =================================================================== 1661 // generateBulkProposals 1662 // =================================================================== 1663 1664 describe('generateBulkProposals', () => { 1665 test('should process enriched sites below score cutoff', async () => { 1666 // generateBulkProposals queries enriched sites with score < cutoff 1667 // and no existing outbound messages. With no matching sites, it returns empty. 1668 const result = await generateBulkProposals(); 1669 assert.ok(Array.isArray(result)); 1670 }); 1671 1672 test('should respect limit parameter', async () => { 1673 const result = await generateBulkProposals(5); 1674 assert.ok(Array.isArray(result)); 1675 assert.ok(result.length <= 5); 1676 }); 1677 }); 1678 });