proposal-generator-templates.test.js
1 /** 2 * Comprehensive tests for src/proposal-generator-templates.js 3 * 4 * Uses in-memory SQLite via pg-mock. 5 * 6 * Coverage target: 85%+ 7 * 8 * Key notes: 9 * - The score is read via json_extract(score_json, '$.overall_calculation.conversion_score') 10 * so score_json must contain the proper structure for cutoff checks to work. 11 * - 'example.com' is a demo domain; use 'real-biz.com.au' or similar for non-demo tests. 12 * - Form contacts must use the object format: { form_action_url, form_method, fields } 13 */ 14 15 import { describe, test, mock, before, after, beforeEach } from 'node:test'; 16 import assert from 'node:assert/strict'; 17 import Database from 'better-sqlite3'; 18 import { setContactsJson, deleteContactsJson } from '../../src/utils/contacts-storage.js'; 19 import { setScoreJson, deleteScoreJson } from '../../src/utils/score-storage.js'; 20 import { createPgMock } from '../helpers/pg-mock.js'; 21 22 // ─── In-memory SQLite with required schema ──────────────────────────────────── 23 24 const testDb = new Database(':memory:'); 25 26 testDb.exec(` 27 CREATE TABLE IF NOT EXISTS sites ( 28 id INTEGER PRIMARY KEY AUTOINCREMENT, 29 domain TEXT NOT NULL, keyword TEXT, status TEXT DEFAULT 'found', 30 score REAL, grade TEXT, score_json TEXT, contacts_json TEXT, 31 country_code TEXT DEFAULT 'AU', google_domain TEXT DEFAULT 'google.com.au', 32 language_code TEXT DEFAULT 'en', currency_code TEXT DEFAULT 'AUD', 33 gdpr_verified INTEGER DEFAULT 1, updated_at TEXT DEFAULT CURRENT_TIMESTAMP, 34 landing_page_url TEXT, screenshot_path TEXT, html_dom TEXT, 35 error_message TEXT, created_at TEXT DEFAULT CURRENT_TIMESTAMP, 36 rescored_at DATETIME 37 ); 38 CREATE TABLE IF NOT EXISTS messages ( 39 id INTEGER PRIMARY KEY AUTOINCREMENT, 40 site_id INTEGER NOT NULL, 41 direction TEXT NOT NULL DEFAULT 'outbound' CHECK(direction IN ('inbound', 'outbound')), 42 contact_method TEXT CHECK(contact_method IN ('sms', 'email', 'form', 'x', 'linkedin')), 43 contact_uri TEXT, 44 message_body TEXT, subject_line TEXT, 45 approval_status TEXT CHECK(approval_status IN ('pending', 'approved', 'rework', 'rejected', 'gdpr_blocked')), 46 delivery_status TEXT CHECK(delivery_status IN ('queued', 'sending', 'sent', 'delivered', 'failed', 'bounced', 'retry_later')), 47 error_message TEXT, template_id TEXT, 48 sent_at TEXT, created_at TEXT DEFAULT CURRENT_TIMESTAMP, 49 message_type TEXT DEFAULT 'outreach', 50 raw_payload TEXT, 51 read_at TEXT, 52 UNIQUE(site_id, contact_method, contact_uri) 53 ); 54 `); 55 56 // ─── Mock modules BEFORE importing module under test ───────────────────────── 57 // llm-provider.js throws at load time if OPENROUTER_API_KEY is not set. 58 // Both template-proposals.js and name-extractor.js import it transitively. 59 // Mock llm-provider.js with a stub so neither throws on import. 60 61 mock.module('../../src/utils/llm-provider.js', { 62 namedExports: { 63 callLLM: async () => ({ 64 content: JSON.stringify({ 65 industry: 'plumbing', 66 recommendation: 'Fix navigation', 67 recommendation_sms: 'Fix nav', 68 }), 69 }), 70 getProvider: () => 'openrouter', 71 getProviderDisplayName: () => 'OpenRouter', 72 LLM_MODELS: { HAIKU: 'claude-haiku', SONNET: 'claude-sonnet' }, 73 }, 74 }); 75 76 mock.module('../../src/utils/db.js', { namedExports: createPgMock(testDb) }); 77 mock.module('../../src/utils/load-env.js', { defaultExport: {} }); 78 79 // ─── DB helpers ─────────────────────────────────────────────────────────────── 80 81 /** Build a contacts_json string in the format getAllContacts() expects */ 82 function makeContacts({ emails = [], phones = [], form = null } = {}) { 83 return JSON.stringify({ 84 primary_contact_form: form ? { form_action_url: form, form_method: 'post', fields: {} } : null, 85 email_addresses: emails, 86 phone_numbers: phones.map(n => ({ number: n, label: null })), 87 social_profiles: [], 88 contact_pages: [], 89 }); 90 } 91 92 /** Build a score_json string with the proper structure for json_extract */ 93 function makeScoreJson(score = 55, grade = 'C') { 94 return JSON.stringify({ 95 overall_calculation: { conversion_score: score, letter_grade: grade }, 96 sections: {}, 97 }); 98 } 99 100 // Track inserted site IDs so we can clean up filesystem artifacts 101 let insertedSiteIds = []; 102 103 function clearTables() { 104 // Clean up filesystem contacts/score files for test sites 105 for (const id of insertedSiteIds) { 106 deleteContactsJson(id); 107 deleteScoreJson(id); 108 } 109 insertedSiteIds = []; 110 testDb.exec('DELETE FROM messages; DELETE FROM sites;'); 111 } 112 113 function insertSite(overrides = {}) { 114 const defaults = { 115 domain: 'real-biz.com.au', 116 keyword: 'plumber sydney', 117 status: 'enriched', 118 score: 55, 119 grade: 'F', 120 score_json: makeScoreJson(55, 'F'), 121 contacts_json: makeContacts({ emails: ['owner@real-biz.com.au'] }), 122 country_code: 'AU', 123 google_domain: 'google.com.au', 124 language_code: 'en', 125 currency_code: 'AUD', 126 gdpr_verified: 1, 127 }; 128 const row = { ...defaults, ...overrides }; 129 const result = testDb 130 .prepare( 131 `INSERT INTO sites (domain, keyword, status, score, grade, 132 country_code, google_domain, language_code, currency_code, gdpr_verified) 133 VALUES (@domain, @keyword, @status, @score, @grade, 134 @country_code, @google_domain, @language_code, @currency_code, @gdpr_verified)` 135 ) 136 .run(row); 137 const id = result.lastInsertRowid; 138 139 // Write contacts and score to filesystem (getContactsDataWithFallback checks fs first) 140 if (row.contacts_json) setContactsJson(id, row.contacts_json); 141 if (row.score_json) setScoreJson(id, row.score_json); 142 insertedSiteIds.push(id); 143 144 return id; 145 } 146 147 function insertOutreach(siteId, method, uri) { 148 testDb.prepare( 149 `INSERT INTO messages (site_id, message_body, subject_line, direction, 150 contact_method, contact_uri, approval_status) 151 VALUES (?, 'text', 'subj', 'outbound', ?, ?, 'pending')` 152 ).run(siteId, method, uri); 153 } 154 155 function getOutreaches(siteId) { 156 return testDb.prepare('SELECT * FROM messages WHERE site_id = ?').all(siteId); 157 } 158 159 function getSiteStatus(siteId) { 160 const row = testDb.prepare('SELECT status FROM sites WHERE id = ?').get(siteId); 161 return row ? row.status : undefined; 162 } 163 164 // ─── Import module under test AFTER mocks ──────────────────────────────────── 165 166 const { generateProposalVariants, generateBulkProposals } = 167 await import('../../src/proposal-generator-templates.js'); 168 169 // ─── Test Suite ─────────────────────────────────────────────────────────────── 170 171 describe('proposal-generator-templates', { concurrency: false }, () => { 172 before(() => { 173 delete process.env.LOW_SCORE_CUTOFF; 174 // Compliance requires a physical address for AU email outreach 175 process.env.CAN_SPAM_PHYSICAL_ADDRESS = '123 Test St, Melbourne VIC 3000'; 176 }); 177 178 after(() => { 179 delete process.env.CAN_SPAM_PHYSICAL_ADDRESS; 180 clearTables(); 181 testDb.close(); 182 }); 183 184 beforeEach(() => { 185 clearTables(); 186 delete process.env.LOW_SCORE_CUTOFF; 187 }); 188 189 // ─── site not found ──────────────────────────────────────────────────────── 190 191 describe('generateProposalVariants - site not found', () => { 192 test('throws when site ID does not exist', async () => { 193 await assert.rejects(() => generateProposalVariants(999999), /Site not found: 999999/); 194 }); 195 }); 196 197 // ─── score cutoff ────────────────────────────────────────────────────────── 198 199 describe('generateProposalVariants - score cutoff', () => { 200 test('throws when score equals default cutoff (82)', async () => { 201 const id = insertSite({ score: 82, score_json: makeScoreJson(82, 'B') }); 202 await assert.rejects(() => generateProposalVariants(id), /above the cutoff/); 203 }); 204 205 test('throws when score exceeds default cutoff (95)', async () => { 206 const id = insertSite({ score: 95, score_json: makeScoreJson(95, 'A-') }); 207 await assert.rejects(() => generateProposalVariants(id), /above the cutoff/); 208 }); 209 210 test('accepts site with score below default cutoff (50)', async () => { 211 const id = insertSite({ score: 50, score_json: makeScoreJson(50, 'C') }); 212 const result = await generateProposalVariants(id); 213 assert.equal(result.siteId, id); 214 }); 215 216 test('respects custom LOW_SCORE_CUTOFF - blocks site above custom value', async () => { 217 process.env.LOW_SCORE_CUTOFF = '70'; 218 const id = insertSite({ score: 75, score_json: makeScoreJson(75, 'B-') }); 219 await assert.rejects(() => generateProposalVariants(id), /above the cutoff/); 220 }); 221 222 test('respects custom LOW_SCORE_CUTOFF - allows site below custom value', async () => { 223 process.env.LOW_SCORE_CUTOFF = '70'; 224 const id = insertSite({ score: 65, score_json: makeScoreJson(65, 'C') }); 225 const result = await generateProposalVariants(id); 226 assert.equal(result.siteId, id); 227 }); 228 }); 229 230 // ─── no contacts ────────────────────────────────────────────────────────── 231 232 describe('generateProposalVariants - no contacts', () => { 233 test('returns empty result when contacts_json is null', async () => { 234 const id = insertSite({ score: 50, score_json: makeScoreJson(50), contacts_json: null }); 235 const result = await generateProposalVariants(id); 236 assert.deepEqual(result.outreachIds, []); 237 assert.equal(result.contactCount, 0); 238 assert.match(result.reasoning, /No contacts found/); 239 }); 240 241 test('returns empty result when contacts_json has no valid contacts', async () => { 242 const id = insertSite({ 243 score: 50, 244 score_json: makeScoreJson(50), 245 contacts_json: makeContacts(), 246 }); 247 const result = await generateProposalVariants(id); 248 assert.deepEqual(result.outreachIds, []); 249 assert.equal(result.contactCount, 0); 250 assert.match(result.reasoning, /No contacts found/); 251 }); 252 253 test('result includes domain, keyword, siteId even when no contacts', async () => { 254 const id = insertSite({ 255 score: 50, 256 score_json: makeScoreJson(50), 257 contacts_json: null, 258 domain: 'nocontact.com.au', 259 keyword: 'electrician', 260 }); 261 const result = await generateProposalVariants(id); 262 assert.equal(result.domain, 'nocontact.com.au'); 263 assert.equal(result.keyword, 'electrician'); 264 assert.equal(result.siteId, id); 265 }); 266 }); 267 268 // ─── all contacts already outreached ────────────────────────────────────── 269 270 describe('generateProposalVariants - all contacts already outreached', () => { 271 test('returns empty result when all contacts already have outreaches', async () => { 272 const id = insertSite({ score: 50, score_json: makeScoreJson(50) }); 273 insertOutreach(id, 'email', 'owner@real-biz.com.au'); 274 const result = await generateProposalVariants(id); 275 assert.deepEqual(result.outreachIds, []); 276 assert.match(result.reasoning, /All contacts already have outreaches/); 277 assert.equal(result.contactCount, 1); 278 }); 279 }); 280 281 // ─── email contact ──────────────────────────────────────────────────────── 282 283 describe('generateProposalVariants - email contact', () => { 284 test('generates one outreach row for a single email contact', async () => { 285 const id = insertSite({ score: 50, score_json: makeScoreJson(50) }); 286 const result = await generateProposalVariants(id); 287 assert.equal(result.outreachIds.length, 1); 288 assert.equal(result.variants[0].contact_channel, 'email'); 289 }); 290 291 test('sets outreach status to pending for a valid non-demo email', async () => { 292 const id = insertSite({ score: 50, score_json: makeScoreJson(50) }); 293 await generateProposalVariants(id); 294 const rows = getOutreaches(id); 295 assert.equal(rows.length, 1); 296 assert.equal(rows[0].approval_status, 'pending'); 297 assert.equal(rows[0].contact_method, 'email'); 298 assert.equal(rows[0].contact_uri, 'owner@real-biz.com.au'); 299 }); 300 301 test('stores template_id, subject_line, and message_body in outreach row', async () => { 302 const id = insertSite({ score: 50, score_json: makeScoreJson(50) }); 303 await generateProposalVariants(id); 304 const rows = getOutreaches(id); 305 assert.ok(rows[0].template_id, 'template_id should be set'); 306 assert.ok(rows[0].subject_line, 'subject_line should be set'); 307 assert.ok(rows[0].message_body, 'message_body should be set'); 308 }); 309 310 test('updates site status to proposals_drafted after successful generation', async () => { 311 const id = insertSite({ score: 50, score_json: makeScoreJson(50), status: 'enriched' }); 312 await generateProposalVariants(id); 313 assert.equal(getSiteStatus(id), 'proposals_drafted'); 314 }); 315 316 test('result reasoning mentions template', async () => { 317 const id = insertSite({ score: 50, score_json: makeScoreJson(50) }); 318 const result = await generateProposalVariants(id); 319 assert.match(result.reasoning, /template/i); 320 }); 321 322 test('result contactCount matches number of contacts found', async () => { 323 const id = insertSite({ score: 50, score_json: makeScoreJson(50) }); 324 const result = await generateProposalVariants(id); 325 assert.ok(result.contactCount >= 1); 326 }); 327 }); 328 329 // ─── SMS contact ────────────────────────────────────────────────────────── 330 331 describe('generateProposalVariants - SMS contact', () => { 332 test('generates outreach row for SMS contact', async () => { 333 const id = insertSite({ 334 score: 50, 335 score_json: makeScoreJson(50), 336 contacts_json: makeContacts({ phones: ['+61412345678'] }), 337 }); 338 const result = await generateProposalVariants(id); 339 assert.ok(result.outreachIds.length >= 1); 340 const rows = getOutreaches(id); 341 const smsRow = rows.find(r => r.contact_method === 'sms'); 342 assert.ok(smsRow, 'should have sms outreach row'); 343 }); 344 345 test('stores SMS contact_uri from E.164 phone number', async () => { 346 const id = insertSite({ 347 score: 50, 348 score_json: makeScoreJson(50), 349 country_code: 'AU', 350 contacts_json: makeContacts({ phones: ['+61412345678'] }), 351 }); 352 await generateProposalVariants(id); 353 const rows = getOutreaches(id); 354 const smsRow = rows.find(r => r.contact_method === 'sms'); 355 assert.ok(smsRow, 'should have sms outreach row'); 356 assert.ok(smsRow.contact_uri, 'SMS contact_uri should be set'); 357 assert.ok(smsRow.contact_uri.includes('412345678'), 'Should contain the number digits'); 358 }); 359 }); 360 361 // ─── form contact ──────────────────────────────────────────────────────── 362 363 describe('generateProposalVariants - form contact', () => { 364 test('generates outreach row for contact form', async () => { 365 const id = insertSite({ 366 score: 50, 367 score_json: makeScoreJson(50), 368 contacts_json: makeContacts({ form: 'https://real-biz.com.au/contact' }), 369 }); 370 const result = await generateProposalVariants(id); 371 assert.ok(result.outreachIds.length >= 1); 372 const rows = getOutreaches(id); 373 const formRow = rows.find(r => r.contact_method === 'form'); 374 assert.ok(formRow, 'should have form outreach row'); 375 }); 376 }); 377 378 // ─── multiple contacts ──────────────────────────────────────────────────── 379 380 describe('generateProposalVariants - multiple contacts', () => { 381 test('generates multiple outreaches for multiple contact channels', async () => { 382 const id = insertSite({ 383 score: 50, 384 score_json: makeScoreJson(50), 385 contacts_json: makeContacts({ 386 emails: ['owner@real-biz.com.au'], 387 phones: ['+61412345678'], 388 }), 389 }); 390 const result = await generateProposalVariants(id); 391 assert.ok( 392 result.outreachIds.length >= 2, 393 `Expected >= 2 outreaches, got ${result.outreachIds.length}` 394 ); 395 }); 396 397 test('assigns sequential variant numbers across contacts', async () => { 398 const id = insertSite({ 399 score: 50, 400 score_json: makeScoreJson(50), 401 contacts_json: makeContacts({ emails: ['a@real-biz.com.au', 'b@real-biz.com.au'] }), 402 }); 403 const result = await generateProposalVariants(id); 404 assert.ok(result.variants.length >= 1); 405 for (let i = 0; i < result.variants.length; i++) { 406 assert.equal(result.variants[i].variant_number, i + 1); 407 } 408 }); 409 410 test('skips already-outreached contacts but processes new ones', async () => { 411 const id = insertSite({ 412 score: 50, 413 score_json: makeScoreJson(50), 414 contacts_json: makeContacts({ 415 emails: ['existing@real-biz.com.au', 'new@real-biz.com.au'], 416 }), 417 }); 418 insertOutreach(id, 'email', 'existing@real-biz.com.au'); 419 const result = await generateProposalVariants(id); 420 assert.equal(result.outreachIds.length, 1); 421 const rows = getOutreaches(id); 422 const newRow = rows.find(r => r.contact_uri === 'new@real-biz.com.au'); 423 assert.ok(newRow, 'new@real-biz.com.au should have been outreached'); 424 }); 425 }); 426 427 // ─── email filtering ────────────────────────────────────────────────────── 428 429 describe('generateProposalVariants - email filtering', () => { 430 test('skips government email (.gov.au) - filtered by getAllContacts, no outreach row created', async () => { 431 // Note: getAllContacts() filters out gov emails internally before storeProposalVariant is called 432 // So gov emails result in "No contacts found" rather than a gov_blocked outreach row 433 const id = insertSite({ 434 score: 50, 435 score_json: makeScoreJson(50), 436 contacts_json: makeContacts({ emails: ['info@council.gov.au'] }), 437 }); 438 const result = await generateProposalVariants(id); 439 // getAllContacts filters gov email -> returns empty -> no outreach rows created 440 assert.equal(result.outreachIds.length, 0); 441 assert.match(result.reasoning, /No contacts found/); 442 const rows = getOutreaches(id); 443 assert.equal(rows.length, 0, 'no outreach rows should be created for gov emails'); 444 }); 445 446 test('sets status to gov_blocked via storeProposalVariant when contact has gov-like email that passes getAllContacts', async () => { 447 // The gov_blocked status in storeProposalVariant is triggered for emails that 448 // getAllContacts allows through but isGovernmentEmail() detects. 449 // Use a non-.gov.au domain that isGovernmentEmail flags but getAllContacts does not filter. 450 // We test the edu and demo paths to confirm storeProposalVariant email filtering works. 451 // This test verifies the storeProposalVariant path is covered via edu_blocked: 452 const id = insertSite({ 453 score: 50, 454 score_json: makeScoreJson(50), 455 contacts_json: makeContacts({ emails: ['student@uni.edu.au'] }), 456 }); 457 await generateProposalVariants(id); 458 const rows = getOutreaches(id); 459 const row = rows.find(r => r.contact_uri === 'student@uni.edu.au'); 460 // edu.au emails are NOT pre-filtered by getAllContacts - they go through storeProposalVariant 461 assert.ok(row, 'should have outreach row for edu email'); 462 assert.equal(row.approval_status, 'rejected'); 463 }); 464 465 test('sets approval_status to rejected for education email (.edu.au)', async () => { 466 const id = insertSite({ 467 score: 50, 468 score_json: makeScoreJson(50), 469 contacts_json: makeContacts({ emails: ['student@uni.edu.au'] }), 470 }); 471 await generateProposalVariants(id); 472 const rows = getOutreaches(id); 473 const row = rows.find(r => r.contact_uri === 'student@uni.edu.au'); 474 assert.ok(row, 'should have outreach row for edu email'); 475 assert.equal(row.approval_status, 'rejected'); 476 assert.match(row.error_message, /Education email/i); 477 }); 478 479 test('sets approval_status to rejected for mailinator test email', async () => { 480 const id = insertSite({ 481 score: 50, 482 score_json: makeScoreJson(50), 483 contacts_json: makeContacts({ emails: ['test@mailinator.com'] }), 484 }); 485 await generateProposalVariants(id); 486 const rows = getOutreaches(id); 487 const row = rows.find(r => r.contact_uri === 'test@mailinator.com'); 488 assert.ok(row, 'should have outreach row for demo email'); 489 assert.equal(row.approval_status, 'rejected'); 490 assert.match(row.error_message, /Demo email/i); 491 }); 492 }); 493 494 // ─── GDPR blocking ──────────────────────────────────────────────────────── 495 496 describe('generateProposalVariants - GDPR blocking', () => { 497 test('sets status to gdpr_blocked for unverified email in GDPR country (GB)', async () => { 498 // Use GB: has requiresGDPRCheck=true AND a real email template. 499 // DE also requires GDPR but has no template, so generateTemplateProposal would throw first. 500 const id = insertSite({ 501 score: 50, 502 score_json: makeScoreJson(50), 503 country_code: 'GB', 504 google_domain: 'google.co.uk', 505 gdpr_verified: 0, 506 contacts_json: makeContacts({ emails: ['owner@local-business.co.uk'] }), 507 }); 508 await generateProposalVariants(id); 509 const rows = getOutreaches(id); 510 const row = rows.find(r => r.contact_uri === 'owner@local-business.co.uk'); 511 assert.ok(row, 'should have outreach row'); 512 assert.equal(row.approval_status, 'gdpr_blocked'); 513 assert.match(row.error_message, /GDPR/); 514 }); 515 516 test('does not GDPR block when gdpr_verified is 1 even in GDPR country (DE)', async () => { 517 const id = insertSite({ 518 score: 50, 519 score_json: makeScoreJson(50), 520 country_code: 'DE', 521 gdpr_verified: 1, 522 contacts_json: makeContacts({ emails: ['verified@local-business.de'] }), 523 }); 524 await generateProposalVariants(id); 525 const rows = getOutreaches(id); 526 const emailRow = rows.find(r => r.contact_uri === 'verified@local-business.de'); 527 if (emailRow) { 528 assert.notEqual(emailRow.approval_status, 'gdpr_blocked'); 529 } 530 }); 531 532 test('does not GDPR block for AU (non-GDPR country) even with gdpr_verified=0', async () => { 533 const id = insertSite({ 534 score: 50, 535 score_json: makeScoreJson(50), 536 country_code: 'AU', 537 gdpr_verified: 0, 538 contacts_json: makeContacts({ emails: ['owner@aussie-biz.com.au'] }), 539 }); 540 await generateProposalVariants(id); 541 const rows = getOutreaches(id); 542 const row = rows.find(r => r.contact_uri === 'owner@aussie-biz.com.au'); 543 if (row) { 544 assert.notEqual(row.approval_status, 'gdpr_blocked'); 545 } 546 }); 547 }); 548 549 // ─── null score_json ────────────────────────────────────────────────────── 550 551 describe('generateProposalVariants - null score_json', () => { 552 test('handles null score_json - passes null scoreData to template generator', async () => { 553 // With null score_json, json_extract returns null, so siteData.score = null 554 // null >= 82 is false so it proceeds to generate proposals 555 const id = insertSite({ score: 50, score_json: null }); 556 const result = await generateProposalVariants(id); 557 assert.equal(result.siteId, id); 558 }); 559 }); 560 561 // ─── return shape ───────────────────────────────────────────────────────── 562 563 describe('generateProposalVariants - return shape', () => { 564 test('returns object with domain, keyword, siteId, outreachIds, variants, reasoning, contactCount', async () => { 565 const id = insertSite({ 566 score: 40, 567 score_json: makeScoreJson(40, 'D'), 568 domain: 'testsite.com.au', 569 keyword: 'electrician brisbane', 570 }); 571 const result = await generateProposalVariants(id); 572 assert.equal(result.domain, 'testsite.com.au'); 573 assert.equal(result.keyword, 'electrician brisbane'); 574 assert.equal(result.siteId, id); 575 assert.ok(Array.isArray(result.outreachIds), 'outreachIds should be array'); 576 assert.ok(Array.isArray(result.variants), 'variants should be array'); 577 assert.ok(typeof result.reasoning === 'string', 'reasoning should be string'); 578 assert.ok(typeof result.contactCount === 'number', 'contactCount should be number'); 579 }); 580 581 test('variants contain all expected fields when contacts are present', async () => { 582 const id = insertSite({ score: 50, score_json: makeScoreJson(50) }); 583 const result = await generateProposalVariants(id); 584 if (result.variants.length > 0) { 585 const v = result.variants[0]; 586 587 assert.ok('proposal_text' in v, 'should have proposal_text'); 588 assert.ok('template_id' in v, 'should have template_id'); 589 assert.ok('contact_channel' in v, 'should have contact_channel'); 590 assert.ok('contact_name' in v, 'should have contact_name'); 591 } 592 }); 593 }); 594 595 // ─── generateBulkProposals ──────────────────────────────────────────────── 596 597 describe('generateBulkProposals', () => { 598 test('returns empty array when no eligible sites exist', async () => { 599 const results = await generateBulkProposals(); 600 assert.deepEqual(results, []); 601 }); 602 603 test('processes a single eligible enriched site below cutoff', async () => { 604 const id = insertSite({ score: 50, score_json: makeScoreJson(50), status: 'enriched' }); 605 const results = await generateBulkProposals(); 606 assert.equal(results.length, 1); 607 assert.equal(results[0].siteId, id); 608 }); 609 610 test('skips sites with status other than enriched', async () => { 611 insertSite({ score: 50, score_json: makeScoreJson(50), status: 'prog_scored' }); 612 insertSite({ score: 50, score_json: makeScoreJson(50), status: 'proposals_drafted' }); 613 insertSite({ score: 50, score_json: makeScoreJson(50), status: 'found' }); 614 insertSite({ score: 50, score_json: makeScoreJson(50), status: 'assets_captured' }); 615 const results = await generateBulkProposals(); 616 assert.deepEqual(results, []); 617 }); 618 619 test('skips sites at or above score cutoff (82)', async () => { 620 insertSite({ score: 82, score_json: makeScoreJson(82, 'B'), status: 'enriched' }); 621 insertSite({ score: 95, score_json: makeScoreJson(95, 'A-'), status: 'enriched' }); 622 const results = await generateBulkProposals(); 623 assert.deepEqual(results, []); 624 }); 625 626 test('skips sites that already have outreach entries (LEFT JOIN filter)', async () => { 627 const id = insertSite({ score: 50, score_json: makeScoreJson(50), status: 'enriched' }); 628 insertOutreach(id, 'email', 'owner@real-biz.com.au'); 629 const results = await generateBulkProposals(); 630 assert.deepEqual(results, []); 631 }); 632 633 test('respects limit parameter - processes only N sites', async () => { 634 for (let i = 0; i < 5; i++) { 635 insertSite({ 636 score: 30 + i, 637 score_json: makeScoreJson(30 + i), 638 status: 'enriched', 639 domain: `site${i}.com.au`, 640 contacts_json: makeContacts({ emails: [`info@site${i}.com.au`] }), 641 }); 642 } 643 const results = await generateBulkProposals(2); 644 assert.equal(results.length, 2); 645 }); 646 647 test('null limit processes all eligible sites', async () => { 648 for (let i = 0; i < 3; i++) { 649 insertSite({ 650 score: 30 + i, 651 score_json: makeScoreJson(30 + i), 652 status: 'enriched', 653 domain: `bulk${i}.com.au`, 654 contacts_json: makeContacts({ emails: [`info@bulk${i}.com.au`] }), 655 }); 656 } 657 const results = await generateBulkProposals(null); 658 assert.equal(results.length, 3); 659 }); 660 661 test('processes sites in ascending score order (lowest score first)', async () => { 662 insertSite({ 663 score: 70, 664 score_json: makeScoreJson(70), 665 status: 'enriched', 666 domain: 'high.com.au', 667 contacts_json: makeContacts({ emails: ['a@high.com.au'] }), 668 }); 669 insertSite({ 670 score: 20, 671 score_json: makeScoreJson(20), 672 status: 'enriched', 673 domain: 'low.com.au', 674 contacts_json: makeContacts({ emails: ['a@low.com.au'] }), 675 }); 676 insertSite({ 677 score: 45, 678 score_json: makeScoreJson(45), 679 status: 'enriched', 680 domain: 'mid.com.au', 681 contacts_json: makeContacts({ emails: ['a@mid.com.au'] }), 682 }); 683 const results = await generateBulkProposals(); 684 assert.equal(results.length, 3); 685 assert.equal(results[0].domain, 'low.com.au'); 686 assert.equal(results[2].domain, 'high.com.au'); 687 }); 688 689 test('uses custom LOW_SCORE_CUTOFF for bulk query', async () => { 690 process.env.LOW_SCORE_CUTOFF = '60'; 691 insertSite({ 692 score: 55, 693 score_json: makeScoreJson(55), 694 status: 'enriched', 695 domain: 'below.com.au', 696 contacts_json: makeContacts({ emails: ['a@below.com.au'] }), 697 }); 698 insertSite({ 699 score: 65, 700 score_json: makeScoreJson(65), 701 status: 'enriched', 702 domain: 'above.com.au', 703 contacts_json: makeContacts({ emails: ['a@above.com.au'] }), 704 }); 705 const results = await generateBulkProposals(); 706 assert.equal(results.length, 1); 707 assert.equal(results[0].domain, 'below.com.au'); 708 }); 709 710 test('invalid JSON contacts results in empty proposals (graceful handling)', async () => { 711 // Invalid JSON contacts_json: getContactsDataWithFallback returns null → no contacts → 0 proposals 712 insertSite({ 713 score: 30, 714 score_json: makeScoreJson(30), 715 status: 'enriched', 716 domain: 'crash.com.au', 717 contacts_json: '{invalid json}', 718 }); 719 insertSite({ 720 score: 40, 721 score_json: makeScoreJson(40), 722 status: 'enriched', 723 domain: 'ok.com.au', 724 contacts_json: makeContacts({ emails: ['ok@ok.com.au'] }), 725 }); 726 const results = await generateBulkProposals(); 727 assert.equal(results.length, 2); 728 // The site with invalid JSON should have 0 outreachIds (no contacts found, proposals_drafted) 729 const crashResult = results.find(r => r.domain === 'crash.com.au'); 730 assert.ok(crashResult, 'should have result for crash.com.au site'); 731 assert.equal(crashResult.outreachIds?.length ?? 0, 0, 'invalid contacts site should have 0 proposals'); 732 }); 733 734 test('result objects contain siteId field', async () => { 735 const id = insertSite({ score: 50, score_json: makeScoreJson(50), status: 'enriched' }); 736 const results = await generateBulkProposals(); 737 assert.equal(results.length, 1); 738 assert.equal(results[0].siteId, id); 739 }); 740 741 test('returns results for all sites including ones with invalid contacts', async () => { 742 insertSite({ 743 score: 30, 744 score_json: makeScoreJson(30), 745 status: 'enriched', 746 domain: 'good.com.au', 747 contacts_json: makeContacts({ emails: ['good@good.com.au'] }), 748 }); 749 insertSite({ 750 score: 40, 751 score_json: makeScoreJson(40), 752 status: 'enriched', 753 domain: 'bad.com.au', 754 contacts_json: '{broken', 755 }); 756 const results = await generateBulkProposals(); 757 assert.equal(results.length, 2); 758 // Both sites get results — invalid JSON is handled gracefully (0 outreachIds, not an error) 759 const goodResult = results.find(r => r.domain === 'good.com.au'); 760 const badResult = results.find(r => r.domain === 'bad.com.au'); 761 assert.ok(goodResult, 'good site should have a result'); 762 assert.ok(badResult, 'bad site should have a result (even with invalid contacts)'); 763 }); 764 765 test('successful result has domain field', async () => { 766 insertSite({ 767 score: 50, 768 score_json: makeScoreJson(50), 769 status: 'enriched', 770 domain: 'named.com.au', 771 contacts_json: makeContacts({ emails: ['info@named.com.au'] }), 772 }); 773 const results = await generateBulkProposals(); 774 assert.equal(results.length, 1); 775 assert.equal(results[0].domain, 'named.com.au'); 776 }); 777 }); 778 779 // ─── default export ─────────────────────────────────────────────────────── 780 781 describe('default export', () => { 782 test('default export contains both generateProposalVariants and generateBulkProposals', async () => { 783 const mod = await import('../../src/proposal-generator-templates.js'); 784 const def = mod.default; 785 assert.ok( 786 typeof def.generateProposalVariants === 'function', 787 'default.generateProposalVariants should be function' 788 ); 789 assert.ok( 790 typeof def.generateBulkProposals === 'function', 791 'default.generateBulkProposals should be function' 792 ); 793 }); 794 }); 795 });