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