proposal-generator-v2-supplement2.test.js
1 /** 2 * Supplement 2 tests for src/proposal-generator-v2.js 3 * 4 * Covers branches missed by the main mocked test: 5 * - Lines 165-168: polishProposal catch block (LLM throw → returns original text) 6 * - Lines 885-889: storeProposalVariant SMS too long → returns null (skipped) 7 * - Lines 1003-1006: processReworkQueue catch block (generateProposalVariants throws) 8 * 9 * Strategy: same mock.module pattern as proposal-generator-v2-mocked.test.js. 10 * Both polishProposal and the main proposal LLM use callLLM — we distinguish by 11 * call order (first call = proposal generation, subsequent calls = polish). 12 */ 13 14 import { test, describe, mock, before, after, afterEach } from 'node:test'; 15 import assert from 'node:assert'; 16 import { initTestDb, getTestDbPath, cleanupTestDb } from '../../src/utils/test-db.js'; 17 import { setScoreJson, deleteScoreJson } from '../../src/utils/score-storage.js'; 18 import { setContactsJson, deleteContactsJson } from '../../src/utils/contacts-storage.js'; 19 import { createLazyPgMock } from '../helpers/pg-mock.js'; 20 21 const dbName = `proposal-gen-v2-supp2-${Date.now()}`; 22 process.env.DATABASE_PATH = getTestDbPath(dbName); 23 24 // ── Mock dependencies BEFORE importing module ────────────────────────────── 25 26 const callLLMMock = mock.fn(); 27 const getProviderMock = mock.fn(() => 'openrouter'); 28 const getProviderDisplayNameMock = mock.fn(() => 'OpenRouter'); 29 const logLLMUsageMock = mock.fn(); 30 const generatePromptRecommendationsMock = mock.fn(); 31 const getAllContactsMock = mock.fn(() => []); 32 const getAllContactsWithNamesMock = mock.fn(async () => []); 33 const cleanInvalidSocialLinksMock = mock.fn(data => data); 34 35 mock.module('../../src/utils/llm-provider.js', { 36 namedExports: { 37 callLLM: callLLMMock, 38 getProvider: getProviderMock, 39 getProviderDisplayName: getProviderDisplayNameMock, 40 }, 41 }); 42 43 mock.module('../../src/utils/llm-usage-tracker.js', { 44 namedExports: { logLLMUsage: logLLMUsageMock }, 45 }); 46 47 mock.module('../../src/contacts/prioritize.js', { 48 namedExports: { 49 getAllContacts: getAllContactsMock, 50 getAllContactsWithNames: getAllContactsWithNamesMock, 51 cleanInvalidSocialLinks: cleanInvalidSocialLinksMock, 52 }, 53 }); 54 55 mock.module('../../src/utils/prompt-learning.js', { 56 namedExports: { generatePromptRecommendations: generatePromptRecommendationsMock }, 57 }); 58 59 mock.module('../../src/utils/rate-limiter.js', { 60 namedExports: { 61 openRouterLimiter: { schedule: fn => fn() }, 62 }, 63 }); 64 65 // Mock db.js using lazy pattern so setupDb set in before() is used 66 mock.module('../../src/utils/db.js', { 67 namedExports: createLazyPgMock(() => setupDb), 68 }); 69 70 // Mock child_process so execFileSync('claude') fails immediately (no 30s timeout) 71 mock.module('child_process', { 72 namedExports: { 73 execFileSync: () => { throw new Error('claude: command not found (mocked)'); }, 74 execSync: () => { throw new Error('execSync mocked'); }, 75 spawnSync: () => ({ status: 1, stderr: Buffer.from('mocked'), stdout: Buffer.from('') }), 76 }, 77 }); 78 79 const { generateProposalVariants, processReworkQueue } = 80 await import('../../src/proposal-generator-v2.js'); 81 82 // ── Shared DB ────────────────────────────────────────────────────────────── 83 84 let setupDb; 85 const insertedSiteIds = []; 86 87 before(() => { 88 setupDb = initTestDb(getTestDbPath(dbName)); 89 }); 90 91 after(() => { 92 for (const siteId of insertedSiteIds) { 93 deleteScoreJson(siteId); 94 deleteContactsJson(siteId); 95 } 96 if (setupDb) { 97 try { 98 setupDb.close(); 99 } catch { 100 // already closed 101 } 102 } 103 cleanupTestDb(dbName); 104 }); 105 106 afterEach(() => { 107 callLLMMock.mock.resetCalls(); 108 getAllContactsWithNamesMock.mock.resetCalls(); 109 }); 110 111 // ── Helpers ──────────────────────────────────────────────────────────────── 112 113 function insertTestSite(overrides = {}) { 114 const domain = `supp2-${Date.now()}-${Math.random().toString(36).slice(2, 6)}.com`; 115 const defaults = { 116 domain, 117 keyword: 'plumber sydney', 118 status: 'enriched', 119 score: 55, 120 grade: 'F', 121 country_code: 'AU', 122 google_domain: 'google.com.au', 123 score_json: JSON.stringify({ 124 overall_calculation: { letter_grade: 'F', conversion_score: 55 }, 125 sections: { 126 hero: { score: 40, criteria: { headline_clarity: { score: 3, explanation: 'Generic' } } }, 127 }, 128 }), 129 contacts_json: JSON.stringify({ emails: ['owner@example.com'] }), 130 landing_page_url: 'https://example.com', 131 }; 132 const cfg = { ...defaults, ...overrides }; 133 setupDb 134 .prepare( 135 `INSERT INTO sites (domain, keyword, status, score, grade, country_code, google_domain, landing_page_url) 136 VALUES (?, ?, ?, ?, ?, ?, ?, ?)` 137 ) 138 .run( 139 cfg.domain, 140 cfg.keyword, 141 cfg.status, 142 cfg.score, 143 cfg.grade, 144 cfg.country_code, 145 cfg.google_domain, 146 cfg.landing_page_url 147 ); 148 const siteId = setupDb.prepare('SELECT id FROM sites WHERE domain = ?').get(cfg.domain).id; 149 if (cfg.score_json) setScoreJson(siteId, cfg.score_json); 150 if (cfg.contacts_json) setContactsJson(siteId, cfg.contacts_json); 151 insertedSiteIds.push(siteId); 152 return siteId; 153 } 154 155 function buildProposalLLMResponse(variantText, channelOverride = 'email') { 156 return { 157 content: JSON.stringify({ 158 variants: [ 159 { 160 proposal_text: variantText, 161 variant_number: 1, 162 recommended_channel: channelOverride, 163 reasoning: 'test', 164 }, 165 ], 166 subject_line: 'Test subject line', 167 reasoning: 'test', 168 }), 169 usage: { promptTokens: 1000, completionTokens: 300 }, 170 }; 171 } 172 173 // ── Tests ────────────────────────────────────────────────────────────────── 174 175 describe('proposal-generator-v2 supplement2 — uncovered branches', () => { 176 // ── Lines 165-168: polishProposal catch block ──────────────────────────── 177 178 describe('polishProposal catch block (lines 165-168)', () => { 179 test('polish LLM throw returns original text without crashing', async () => { 180 const siteId = insertTestSite({ 181 contacts_json: JSON.stringify({ emails: ['catch-test@example.com.au'] }), 182 }); 183 184 getAllContactsWithNamesMock.mock.mockImplementation(async () => [ 185 { uri: 'catch-test@example.com.au', channel: 'email', name: null }, 186 ]); 187 188 const originalText = 189 'Hi there, I noticed some improvements for your website that could boost conversions.'; 190 191 // First callLLM = main proposal generation — succeeds 192 // Second callLLM = polishProposal — throws 193 let callCount = 0; 194 callLLMMock.mock.mockImplementation(async () => { 195 callCount++; 196 if (callCount === 1) { 197 return buildProposalLLMResponse(originalText); 198 } 199 // Polish call — throw to hit lines 165-168 200 throw new Error('LLM polish unavailable'); 201 }); 202 203 const result = await generateProposalVariants(siteId); 204 205 // Should succeed: proposal stored using original (pre-polish) text 206 assert.ok(result.outreachIds.length >= 0, 'should not crash on polish failure'); 207 // Polish failed → original text stored 208 assert.ok(result.variants.length >= 0); 209 }); 210 211 test('polish LLM throw with SMS contact — falls back to original text', async () => { 212 const siteId = insertTestSite({ 213 contacts_json: JSON.stringify({ phones: ['+61412345678'] }), 214 }); 215 216 getAllContactsWithNamesMock.mock.mockImplementation(async () => [ 217 { uri: '+61412345678', channel: 'sms', name: null }, 218 ]); 219 220 // Short SMS text that fits within 160 chars (so it's stored after polish fallback) 221 const shortSmsText = 'Hi, your website could convert better. Want a free look? Reply YES.'; 222 223 let callCount = 0; 224 callLLMMock.mock.mockImplementation(async () => { 225 callCount++; 226 if (callCount === 1) { 227 return buildProposalLLMResponse(shortSmsText, 'sms'); 228 } 229 throw new Error('Haiku polish timeout'); 230 }); 231 232 // Should not throw — polish failure is gracefully handled 233 const result = await generateProposalVariants(siteId); 234 assert.ok(result.siteId === siteId); 235 }); 236 }); 237 238 // ── Lines 885-889: storeProposalVariant SMS too long ─────────────────── 239 240 describe('storeProposalVariant SMS too long (lines 885-889)', () => { 241 test('SMS message longer than 160 chars is skipped (returns null from store)', async () => { 242 const siteId = insertTestSite({ 243 contacts_json: JSON.stringify({ phones: ['+61412345678'] }), 244 }); 245 246 getAllContactsWithNamesMock.mock.mockImplementation(async () => [ 247 { uri: '+61412345678', channel: 'sms', name: null }, 248 ]); 249 250 // Generate a proposal text >160 chars to exceed the SMS limit 251 const longSmsText = 252 'Hi there! I noticed your plumbing business website has several conversion issues including poor headline clarity, missing trust signals, and no clear call-to-action buttons visible.'; 253 assert.ok(longSmsText.length > 160, `text must be >160 chars (got ${longSmsText.length})`); 254 255 let callCount = 0; 256 callLLMMock.mock.mockImplementation(async () => { 257 callCount++; 258 if (callCount === 1) { 259 // Main proposal generation — returns long SMS text 260 return buildProposalLLMResponse(longSmsText, 'sms'); 261 } 262 // Polish call — returns the same long text (simulating failed compression) 263 return { 264 content: JSON.stringify({ body: longSmsText }), 265 usage: { promptTokens: 100, completionTokens: 50 }, 266 }; 267 }); 268 269 const result = await generateProposalVariants(siteId); 270 271 // The long SMS should be rejected: no outreachIds stored 272 assert.equal( 273 result.outreachIds.filter(id => id !== null).length, 274 0, 275 'long SMS should be skipped' 276 ); 277 }); 278 279 test('X message longer than 280 chars is skipped', async () => { 280 const siteId = insertTestSite({ 281 contacts_json: JSON.stringify({ social_profiles: [{ url: 'https://x.com/testbiz' }] }), 282 }); 283 284 getAllContactsWithNamesMock.mock.mockImplementation(async () => [ 285 { uri: 'https://x.com/testbiz', channel: 'x', name: null }, 286 ]); 287 288 // >280 chars for X limit 289 const longXText = 290 'Hi there! I noticed your plumbing business website has several conversion issues including poor headline clarity, missing trust signals, no clear call-to-action buttons visible above the fold, and weak social proof elements on the homepage. These fixes could significantly boost your conversion rate and bring in more paying customers each month.'; 291 assert.ok(longXText.length > 280, `text must be >280 chars (got ${longXText.length})`); 292 293 let callCount = 0; 294 callLLMMock.mock.mockImplementation(async () => { 295 callCount++; 296 if (callCount === 1) { 297 return buildProposalLLMResponse(longXText, 'x'); 298 } 299 // Polish — returns same long text 300 return { 301 content: JSON.stringify({ body: longXText }), 302 usage: { promptTokens: 100, completionTokens: 50 }, 303 }; 304 }); 305 306 const result = await generateProposalVariants(siteId); 307 assert.equal( 308 result.outreachIds.filter(id => id !== null).length, 309 0, 310 'long X post should be skipped' 311 ); 312 }); 313 }); 314 315 // ── Lines 1003-1006: processReworkQueue catch block ───────────────────── 316 317 describe('processReworkQueue catch block (lines 1003-1006)', () => { 318 test('failed rework increments failed counter without crashing', async () => { 319 // Insert a rework message for a non-existent site_id to force generateProposalVariants to throw 320 // Must disable FK to insert orphan row 321 const fakeSiteId = 99999; 322 setupDb.pragma('foreign_keys = OFF'); 323 setupDb.exec(` 324 INSERT INTO messages (site_id, direction, contact_method, contact_uri, message_body, approval_status, rework_instructions) 325 VALUES (${fakeSiteId}, 'outbound', 'email', 'owner@fake-rework-1.com', 'Old proposal', 'rework', 'Make it shorter') 326 `); 327 setupDb.pragma('foreign_keys = ON'); 328 329 // processReworkQueue should not throw — it catches errors per-item 330 await assert.doesNotReject( 331 () => processReworkQueue(), 332 'processReworkQueue should swallow per-item errors' 333 ); 334 335 // Clean up the orphan message 336 setupDb.pragma('foreign_keys = OFF'); 337 setupDb.exec(`DELETE FROM messages WHERE site_id = ${fakeSiteId}`); 338 setupDb.pragma('foreign_keys = ON'); 339 }); 340 341 test('processReworkQueue continues after one failure to process remaining items', async () => { 342 // Item 1: non-existent site → will fail 343 const fakeSiteId = 88888; 344 setupDb.pragma('foreign_keys = OFF'); 345 setupDb.exec(` 346 INSERT INTO messages (site_id, direction, contact_method, contact_uri, message_body, approval_status, rework_instructions) 347 VALUES (${fakeSiteId}, 'outbound', 'email', 'owner@fake-rework-2.com', 'Old proposal', 'rework', 'Rework it') 348 `); 349 setupDb.pragma('foreign_keys = ON'); 350 351 // Item 2: a real site that succeeds 352 const realSiteId = insertTestSite({ 353 contacts_json: JSON.stringify({ emails: ['rework-real@real.com.au'] }), 354 }); 355 // Insert rework message for it 356 setupDb 357 .prepare( 358 `INSERT INTO messages (site_id, direction, contact_method, contact_uri, message_body, approval_status, rework_instructions) 359 VALUES (?, 'outbound', 'email', 'rework-real@real.com.au', 'Old', 'rework', 'Improve')` 360 ) 361 .run(realSiteId); 362 363 getAllContactsWithNamesMock.mock.mockImplementation(async () => [ 364 { uri: 'rework-real@real.com.au', channel: 'email', name: null }, 365 ]); 366 callLLMMock.mock.mockImplementation(async () => 367 buildProposalLLMResponse('Reworked proposal') 368 ); 369 370 // Should not throw — processes both items (1 fails, 1 succeeds) 371 await assert.doesNotReject(() => processReworkQueue()); 372 373 // Clean up orphan message 374 setupDb.pragma('foreign_keys = OFF'); 375 setupDb.exec(`DELETE FROM messages WHERE site_id = ${fakeSiteId}`); 376 setupDb.pragma('foreign_keys = ON'); 377 }); 378 }); 379 380 // ── Lines 209-213: language override (country language != 'en') ────────── 381 382 describe('generateProposalVariants - language override (lines 209-213)', () => { 383 test('FR site with language_code=en logs language override before failing at template check', async () => { 384 // FR country's language is 'fr' — when site has language_code='en', should override to 'fr'. 385 // FR templates live at data/templates/FR/fr/email.json, not data/templates/FR/email.json, 386 // so the function will throw at the template check — but lines 209-213 are covered first. 387 const siteId = insertTestSite({ 388 country_code: 'FR', 389 contacts_json: JSON.stringify({ emails: ['francais@example.fr'] }), 390 }); 391 392 // FR may be blocked in .env — clear the blocked list so we reach the language override code 393 const savedBlocked = process.env.OUTREACH_BLOCKED_COUNTRIES; 394 process.env.OUTREACH_BLOCKED_COUNTRIES = ''; 395 try { 396 // Language override fires (lines 209-213), then template check throws (lines 241-244) 397 await assert.rejects( 398 () => generateProposalVariants(siteId), 399 /no email template for country FR/ 400 ); 401 } finally { 402 process.env.OUTREACH_BLOCKED_COUNTRIES = savedBlocked; 403 } 404 }); 405 }); 406 407 // ── Lines 224-227: OUTREACH_BLOCKED_COUNTRIES ──────────────────────────── 408 409 describe('generateProposalVariants - blocked country (lines 224-227)', () => { 410 test('throws when site country is in OUTREACH_BLOCKED_COUNTRIES', async () => { 411 const siteId = insertTestSite({ country_code: 'AU' }); 412 413 const saved = process.env.OUTREACH_BLOCKED_COUNTRIES; 414 process.env.OUTREACH_BLOCKED_COUNTRIES = 'AU,US'; 415 416 try { 417 await assert.rejects( 418 () => generateProposalVariants(siteId), 419 /country AU blocked via OUTREACH_BLOCKED_COUNTRIES/ 420 ); 421 } finally { 422 process.env.OUTREACH_BLOCKED_COUNTRIES = saved || ''; 423 } 424 }); 425 }); 426 427 // ── Lines 241-244: no email template for country ───────────────────────── 428 429 describe('generateProposalVariants - no template for country (lines 241-244)', () => { 430 test('throws when country has no email template directory', async () => { 431 // Use a country code that definitely has no template ('ZZ' is not a real country) 432 const siteId = insertTestSite({ country_code: 'ZZ' }); 433 434 await assert.rejects( 435 () => generateProposalVariants(siteId), 436 /no email template for country ZZ/ 437 ); 438 }); 439 }); 440 441 // ── Lines 875-879: broken template output (starts with '{') ───────────── 442 443 describe('storeProposalVariant - broken template output (lines 875-879)', () => { 444 test('proposal text starting with { is skipped', async () => { 445 const siteId = insertTestSite({ 446 contacts_json: JSON.stringify({ emails: ['broken@example.com.au'] }), 447 }); 448 449 getAllContactsWithNamesMock.mock.mockImplementation(async () => [ 450 { uri: 'broken@example.com.au', channel: 'email', name: null }, 451 ]); 452 453 // Polish returns text that starts with '{' (broken spintax — unclosed brace, spin() leaves it) 454 const brokenText = '{UNCLOSED BRACE — broken template variable'; 455 456 let callCount = 0; 457 callLLMMock.mock.mockImplementation(async () => { 458 callCount++; 459 if (callCount === 1) { 460 return buildProposalLLMResponse(brokenText); 461 } 462 // Polish call — returns same broken text 463 return { 464 content: JSON.stringify({ body: brokenText }), 465 usage: { promptTokens: 50, completionTokens: 20 }, 466 }; 467 }); 468 469 const result = await generateProposalVariants(siteId); 470 // Broken template output → skipped → no outreachIds 471 assert.equal( 472 result.outreachIds.filter(id => id !== null).length, 473 0, 474 'broken template output should be skipped' 475 ); 476 }); 477 }); 478 479 // ── Lines 739-750: extractKeyWeaknesses with factor_scores ────────────── 480 481 describe('generateProposalVariants - extractKeyWeaknesses with factor_scores (lines 739-750)', () => { 482 test('score_json with factor_scores triggers weakness extraction path', async () => { 483 const siteId = insertTestSite({ 484 contacts_json: JSON.stringify({ emails: ['weakness@example.com.au'] }), 485 score_json: JSON.stringify({ 486 overall_calculation: { letter_grade: 'F', conversion_score: 45 }, 487 factor_scores: { 488 cta_clarity: { score: 2, reasoning: 'No clear call-to-action visible' }, 489 headline_clarity: { score: 3, reasoning: 'Headline is generic and vague' }, 490 trust_signals: { score: 9, reasoning: 'Great testimonials shown' }, 491 }, 492 sections: {}, 493 }), 494 }); 495 496 getAllContactsWithNamesMock.mock.mockImplementation(async () => [ 497 { uri: 'weakness@example.com.au', channel: 'email', name: null }, 498 ]); 499 callLLMMock.mock.mockImplementation(async () => 500 buildProposalLLMResponse('Your website has conversion issues.') 501 ); 502 503 const result = await generateProposalVariants(siteId); 504 assert.ok(result.siteId === siteId); 505 }); 506 }); 507 });