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