/ tests / proposals / proposal-generator-templates.test.js
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  });