/ tests / proposals / stages-proposals-mocked.test.js
stages-proposals-mocked.test.js
  1  /**
  2   * Mocked Unit Tests for Proposals Stage
  3   *
  4   * Uses mock.module() to mock proposal-generator-v2.js (LLM-heavy dependency)
  5   * and a real in-memory SQLite database via pg-mock for everything else.
  6   * This covers lines 31-133, 143-165, 217-255 that are not hit by the integration test.
  7   */
  8  
  9  import { describe, test, mock, after, beforeEach } from 'node:test';
 10  import assert from 'node:assert/strict';
 11  import Database from 'better-sqlite3';
 12  import { readFileSync } from 'fs';
 13  import { join } from 'path';
 14  import { createPgMock } from '../helpers/pg-mock.js';
 15  
 16  process.env.USE_LLM_PROPOSALS = 'true'; // Use LLM mode since we're mocking LLM proposal generator
 17  
 18  // Create in-memory SQLite with full schema
 19  const setupDb = new Database(':memory:');
 20  const schema = readFileSync(join(import.meta.dirname, '../../db/schema.sql'), 'utf-8');
 21  setupDb.exec(schema);
 22  
 23  // Mock db.js BEFORE importing any modules that depend on it
 24  mock.module('../../src/utils/db.js', { namedExports: createPgMock(setupDb) });
 25  
 26  // Mock the proposal generator (LLM-heavy dependency) BEFORE importing the module
 27  const mockGenerateProposalVariants = mock.fn();
 28  await mock.module('../../src/proposal-generator-v2.js', {
 29    namedExports: {
 30      generateProposalVariants: mockGenerateProposalVariants,
 31      processReworkQueue: mock.fn(async () => {}),
 32    },
 33  });
 34  
 35  // Now import the module under test (uses mocked proposal generator)
 36  const { runProposalsStage, getProposalsStats, regenerateProposals } =
 37    await import('../../src/stages/proposals.js');
 38  
 39  describe('Proposals Stage - Mocked Unit Tests', () => {
 40    beforeEach(() => {
 41      setupDb.prepare('DELETE FROM messages').run();
 42      setupDb.prepare('DELETE FROM sites').run();
 43  
 44      // Reset mock
 45      mockGenerateProposalVariants.mock.resetCalls();
 46      mockGenerateProposalVariants.mock.restore();
 47    });
 48  
 49    after(() => {
 50      setupDb.close();
 51    });
 52  
 53    // Helper to insert a site
 54    function insertSite(overrides = {}) {
 55      const defaults = {
 56        domain: 'example.com',
 57        landing_page_url: 'https://example.com',
 58        keyword: 'test keyword',
 59        status: 'enriched',
 60        score: 50,
 61        grade: 'F',
 62        country_code: 'AU',
 63      };
 64      const site = { ...defaults, ...overrides };
 65      const result = setupDb
 66        .prepare(
 67          `INSERT INTO sites (domain, landing_page_url, keyword, status, score, grade, country_code)
 68           VALUES (?, ?, ?, ?, ?, ?, ?)`
 69        )
 70        .run(
 71          site.domain,
 72          site.landing_page_url,
 73          site.keyword,
 74          site.status,
 75          site.score,
 76          site.grade,
 77          site.country_code
 78        );
 79      return result.lastInsertRowid;
 80    }
 81  
 82    // Helper to insert an outreach
 83    function insertOutreach(siteId, overrides = {}) {
 84      const defaults = {
 85        contact_method: 'email',
 86        contact_uri: 'test@example.com',
 87        message_body: 'Test proposal',
 88        status: 'pending',
 89      };
 90      const o = { ...defaults, ...overrides };
 91      const deliveryStatuses = ['sent', 'delivered', 'failed', 'bounced', 'retry_later', 'queued'];
 92      const isDelivery = deliveryStatuses.includes(o.status);
 93      const approvalStatus = isDelivery ? 'approved' : o.status || 'pending';
 94      const deliveryStatus = isDelivery ? o.status : null;
 95      const result = setupDb
 96        .prepare(
 97          `INSERT INTO messages (site_id, contact_method, contact_uri, message_body, approval_status, delivery_status)
 98           VALUES (?, ?, ?, ?, ?, ?)`
 99        )
100        .run(siteId, o.contact_method, o.contact_uri, o.message_body, approvalStatus, deliveryStatus);
101      return result.lastInsertRowid;
102    }
103  
104    describe('runProposalsStage', () => {
105      test('returns zeros when no enriched sites exist', async () => {
106        const result = await runProposalsStage();
107  
108        assert.strictEqual(result.processed, 0);
109        assert.strictEqual(result.succeeded, 0);
110        assert.strictEqual(result.failed, 0);
111        assert.strictEqual(result.skipped, 0);
112        assert.ok(result.duration >= 0);
113        assert.strictEqual(mockGenerateProposalVariants.mock.calls.length, 0);
114      });
115  
116      test('returns zeros when only non-enriched sites exist', async () => {
117        insertSite({ domain: 'scored.com', status: 'prog_scored', score: 50 });
118        insertSite({ domain: 'rescored.com', status: 'semantic_scored', score: 60 });
119        insertSite({ domain: 'found.com', status: 'found', score: 40 });
120  
121        const result = await runProposalsStage();
122  
123        assert.strictEqual(result.processed, 0);
124        assert.strictEqual(result.succeeded, 0);
125        assert.strictEqual(result.failed, 0);
126      });
127  
128      test('processes enriched sites within default score range (0-82)', async () => {
129        const siteId = insertSite({
130          domain: 'lowscore.com',
131          landing_page_url: 'https://lowscore.com',
132          status: 'enriched',
133          score: 50,
134          grade: 'F',
135        });
136  
137        mockGenerateProposalVariants.mock.mockImplementation(async () => ({
138          variants: [{ text: 'Variant 1' }, { text: 'Variant 2' }],
139          contactCount: 2,
140        }));
141  
142        const result = await runProposalsStage();
143  
144        assert.strictEqual(result.processed, 1);
145        assert.strictEqual(result.succeeded, 1);
146        assert.strictEqual(result.failed, 0);
147        assert.ok(result.duration >= 0);
148        assert.strictEqual(mockGenerateProposalVariants.mock.calls.length, 1);
149  
150        // Verify it was called with the correct site ID
151        const callArgs = mockGenerateProposalVariants.mock.calls[0].arguments;
152        assert.strictEqual(callArgs[0], Number(siteId));
153      });
154  
155      test('processes multiple enriched sites', async () => {
156        insertSite({
157          domain: 'site1.com',
158          landing_page_url: 'https://site1.com',
159          status: 'enriched',
160          score: 40,
161          grade: 'F',
162        });
163        insertSite({
164          domain: 'site2.com',
165          landing_page_url: 'https://site2.com',
166          status: 'enriched',
167          score: 60,
168          grade: 'D-',
169        });
170        insertSite({
171          domain: 'site3.com',
172          landing_page_url: 'https://site3.com',
173          status: 'enriched',
174          score: 75,
175          grade: 'C',
176        });
177  
178        mockGenerateProposalVariants.mock.mockImplementation(async () => ({
179          variants: [{ text: 'Proposal' }],
180          contactCount: 1,
181        }));
182  
183        const result = await runProposalsStage();
184  
185        assert.strictEqual(result.processed, 3);
186        assert.strictEqual(result.succeeded, 3);
187      });
188  
189      test('skips sites with score above max (default 82)', async () => {
190        insertSite({
191          domain: 'highscore.com',
192          status: 'enriched',
193          score: 90,
194          grade: 'A-',
195        });
196  
197        const result = await runProposalsStage();
198  
199        assert.strictEqual(result.processed, 0);
200        assert.strictEqual(mockGenerateProposalVariants.mock.calls.length, 0);
201      });
202  
203      test('skips sites with score below minScore option', async () => {
204        insertSite({
205          domain: 'tolow.com',
206          status: 'enriched',
207          score: 10,
208          grade: 'F',
209        });
210  
211        const result = await runProposalsStage({ minScore: 30 });
212  
213        assert.strictEqual(result.processed, 0);
214        assert.strictEqual(mockGenerateProposalVariants.mock.calls.length, 0);
215      });
216  
217      test('respects custom minScore and maxScore options', async () => {
218        insertSite({
219          domain: 'in-range.com',
220          status: 'enriched',
221          score: 55,
222          grade: 'F',
223        });
224        insertSite({
225          domain: 'below-range.com',
226          status: 'enriched',
227          score: 30,
228          grade: 'F',
229        });
230        insertSite({
231          domain: 'above-range.com',
232          status: 'enriched',
233          score: 75,
234          grade: 'C',
235        });
236  
237        mockGenerateProposalVariants.mock.mockImplementation(async () => ({
238          variants: [{ text: 'Proposal' }],
239          contactCount: 1,
240        }));
241  
242        const result = await runProposalsStage({ minScore: 40, maxScore: 60 });
243  
244        assert.strictEqual(result.processed, 1);
245        assert.strictEqual(result.succeeded, 1);
246      });
247  
248      test('skips sites that already have outreaches (EXISTS check)', async () => {
249        const siteId = insertSite({
250          domain: 'already-proposed.com',
251          status: 'enriched',
252          score: 50,
253          grade: 'F',
254        });
255  
256        // Insert existing outreach for this site
257        insertOutreach(siteId, { contact_uri: 'owner@already-proposed.com' });
258  
259        const result = await runProposalsStage();
260  
261        assert.strictEqual(result.processed, 0);
262        assert.strictEqual(mockGenerateProposalVariants.mock.calls.length, 0);
263      });
264  
265      test('processes site without outreaches but skips site with outreaches', async () => {
266        const siteWithOutreach = insertSite({
267          domain: 'has-outreach.com',
268          landing_page_url: 'https://has-outreach.com',
269          status: 'enriched',
270          score: 50,
271          grade: 'F',
272        });
273        insertOutreach(siteWithOutreach, { contact_uri: 'owner@has-outreach.com' });
274  
275        insertSite({
276          domain: 'no-outreach.com',
277          landing_page_url: 'https://no-outreach.com',
278          status: 'enriched',
279          score: 60,
280          grade: 'D-',
281        });
282  
283        mockGenerateProposalVariants.mock.mockImplementation(async () => ({
284          variants: [{ text: 'Proposal' }],
285          contactCount: 1,
286        }));
287  
288        const result = await runProposalsStage();
289  
290        // Only the site without outreach should be processed
291        assert.strictEqual(result.processed, 1);
292        assert.strictEqual(result.succeeded, 1);
293        assert.strictEqual(mockGenerateProposalVariants.mock.calls.length, 1);
294      });
295  
296      test('respects limit option', async () => {
297        // Insert 5 enriched sites
298        for (let i = 1; i <= 5; i++) {
299          insertSite({
300            domain: `site${i}.com`,
301            landing_page_url: `https://site${i}.com`,
302            status: 'enriched',
303            score: 40 + i * 5,
304            grade: 'F',
305          });
306        }
307  
308        mockGenerateProposalVariants.mock.mockImplementation(async () => ({
309          variants: [{ text: 'Proposal' }],
310          contactCount: 1,
311        }));
312  
313        const result = await runProposalsStage({ limit: 2 });
314  
315        assert.strictEqual(result.processed, 2);
316        assert.strictEqual(result.succeeded, 2);
317        assert.strictEqual(mockGenerateProposalVariants.mock.calls.length, 2);
318      });
319  
320      test('handles generateProposalVariants failure gracefully', async () => {
321        insertSite({
322          domain: 'failing-site.com',
323          landing_page_url: 'https://failing-site.com',
324          status: 'enriched',
325          score: 50,
326          grade: 'F',
327        });
328  
329        mockGenerateProposalVariants.mock.mockImplementation(async () => {
330          throw new Error('LLM API rate limit exceeded');
331        });
332  
333        const result = await runProposalsStage();
334  
335        assert.strictEqual(result.processed, 1);
336        assert.strictEqual(result.succeeded, 0);
337        assert.strictEqual(result.failed, 1);
338      });
339  
340      test('handles mix of successful and failed proposals', async () => {
341        insertSite({
342          domain: 'success-site.com',
343          landing_page_url: 'https://success-site.com',
344          status: 'enriched',
345          score: 40,
346          grade: 'F',
347        });
348        insertSite({
349          domain: 'fail-site.com',
350          landing_page_url: 'https://fail-site.com',
351          status: 'enriched',
352          score: 60,
353          grade: 'D-',
354        });
355  
356        let callCount = 0;
357        mockGenerateProposalVariants.mock.mockImplementation(async () => {
358          callCount++;
359          if (callCount === 2) {
360            throw new Error('API error');
361          }
362          return { variants: [{ text: 'Proposal' }], contactCount: 1 };
363        });
364  
365        const result = await runProposalsStage();
366  
367        assert.strictEqual(result.processed, 2);
368        assert.strictEqual(result.succeeded, 1);
369        assert.strictEqual(result.failed, 1);
370      });
371  
372      test('marks blocklisted sites as ignore', async () => {
373        // yelp.com is in the directory blocklist
374        const siteId = insertSite({
375          domain: 'yelp.com',
376          landing_page_url: 'https://yelp.com/biz/example',
377          status: 'enriched',
378          score: 50,
379          grade: 'F',
380          country_code: 'US',
381        });
382  
383        // Also insert a legitimate site to verify it still gets processed
384        insertSite({
385          domain: 'legit-business.com',
386          landing_page_url: 'https://legit-business.com',
387          status: 'enriched',
388          score: 55,
389          grade: 'F',
390          country_code: 'US',
391        });
392  
393        mockGenerateProposalVariants.mock.mockImplementation(async () => ({
394          variants: [{ text: 'Proposal' }],
395          contactCount: 1,
396        }));
397  
398        await runProposalsStage();
399  
400        const blockedSite = setupDb.prepare('SELECT status FROM sites WHERE id = ?').get(siteId);
401        assert.strictEqual(blockedSite.status, 'ignored');
402  
403        // Verify the legitimate site was still processed
404        assert.strictEqual(mockGenerateProposalVariants.mock.calls.length, 2);
405      });
406  
407      test('marks social media sites as ignore', async () => {
408        const siteId = insertSite({
409          domain: 'facebook.com',
410          landing_page_url: 'https://facebook.com/somebusiness',
411          status: 'enriched',
412          score: 45,
413          grade: 'F',
414          country_code: 'US',
415        });
416  
417        mockGenerateProposalVariants.mock.mockImplementation(async () => ({
418          variants: [{ text: 'Proposal' }],
419          contactCount: 1,
420        }));
421  
422        await runProposalsStage();
423  
424        const site = setupDb
425          .prepare('SELECT status, error_message FROM sites WHERE id = ?')
426          .get(siteId);
427        assert.strictEqual(site.status, 'ignored');
428      });
429  
430      test('records failure with retry handler when proposal generation fails', async () => {
431        const siteId = insertSite({
432          domain: 'retry-site.com',
433          landing_page_url: 'https://retry-site.com',
434          status: 'enriched',
435          score: 50,
436          grade: 'F',
437        });
438  
439        mockGenerateProposalVariants.mock.mockImplementation(async () => {
440          throw new Error('Connection timeout');
441        });
442  
443        await runProposalsStage();
444  
445        // Verify retry_count was incremented via recordFailure
446        const site = setupDb
447          .prepare('SELECT retry_count, error_message, status FROM sites WHERE id = ?')
448          .get(siteId);
449        assert.ok(site.retry_count > 0, 'retry_count should be incremented');
450        assert.ok(site.error_message.includes('Connection timeout'));
451        // First failure should keep status as 'enriched' (not yet 'failing')
452        assert.strictEqual(site.status, 'enriched');
453      });
454  
455      test('resets retries on successful proposal generation', async () => {
456        // Insert site with existing retries
457        const siteId = insertSite({
458          domain: 'recovering-site.com',
459          landing_page_url: 'https://recovering-site.com',
460          status: 'enriched',
461          score: 50,
462          grade: 'F',
463        });
464  
465        // Manually set retry count to simulate previous failures
466        setupDb
467          .prepare('UPDATE sites SET retry_count = 3, error_message = ? WHERE id = ?')
468          .run('Previous error', siteId);
469  
470        mockGenerateProposalVariants.mock.mockImplementation(async () => ({
471          variants: [{ text: 'Proposal' }],
472          contactCount: 1,
473        }));
474  
475        await runProposalsStage();
476  
477        // Verify retries were reset via resetRetries
478        const site = setupDb
479          .prepare('SELECT retry_count, error_message FROM sites WHERE id = ?')
480          .get(siteId);
481        assert.strictEqual(site.retry_count, 0);
482        assert.strictEqual(site.error_message, null);
483      });
484  
485      test('uses LOW_SCORE_CUTOFF env var as default maxScore', async () => {
486        const originalCutoff = process.env.LOW_SCORE_CUTOFF;
487        process.env.LOW_SCORE_CUTOFF = '60';
488  
489        try {
490          insertSite({
491            domain: 'within-cutoff.com',
492            status: 'enriched',
493            score: 55,
494            grade: 'F',
495          });
496          insertSite({
497            domain: 'above-cutoff.com',
498            status: 'enriched',
499            score: 70,
500            grade: 'C-',
501          });
502  
503          mockGenerateProposalVariants.mock.mockImplementation(async () => ({
504            variants: [{ text: 'Proposal' }],
505            contactCount: 1,
506          }));
507  
508          const result = await runProposalsStage();
509  
510          // Only the site with score 55 should be processed (maxScore = 60)
511          assert.strictEqual(result.processed, 1);
512          assert.strictEqual(result.succeeded, 1);
513        } finally {
514          if (originalCutoff === undefined) {
515            delete process.env.LOW_SCORE_CUTOFF;
516          } else {
517            process.env.LOW_SCORE_CUTOFF = originalCutoff;
518          }
519        }
520      });
521  
522      test('orders sites by score ascending (lowest first)', async () => {
523        insertSite({
524          domain: 'high.com',
525          landing_page_url: 'https://high.com',
526          status: 'enriched',
527          score: 75,
528          grade: 'C',
529        });
530        insertSite({
531          domain: 'low.com',
532          landing_page_url: 'https://low.com',
533          status: 'enriched',
534          score: 30,
535          grade: 'F',
536        });
537        insertSite({
538          domain: 'mid.com',
539          landing_page_url: 'https://mid.com',
540          status: 'enriched',
541          score: 50,
542          grade: 'F',
543        });
544  
545        const processedSiteIds = [];
546        mockGenerateProposalVariants.mock.mockImplementation(async siteId => {
547          processedSiteIds.push(siteId);
548          return { variants: [{ text: 'Proposal' }], contactCount: 1 };
549        });
550  
551        await runProposalsStage();
552  
553        assert.strictEqual(processedSiteIds.length, 3);
554  
555        // Verify score ascending order by checking site IDs correspond to ascending scores
556        const scores = processedSiteIds.map(id => {
557          const site = setupDb.prepare('SELECT score FROM sites WHERE id = ?').get(id);
558          return site.score;
559        });
560        for (let i = 1; i < scores.length; i++) {
561          assert.ok(scores[i] >= scores[i - 1], `Scores should be ascending: ${scores}`);
562        }
563      });
564  
565      test('respects concurrency option', async () => {
566        // Insert several enriched sites
567        for (let i = 1; i <= 4; i++) {
568          insertSite({
569            domain: `concurrent${i}.com`,
570            landing_page_url: `https://concurrent${i}.com`,
571            status: 'enriched',
572            score: 40 + i * 5,
573            grade: 'F',
574          });
575        }
576  
577        mockGenerateProposalVariants.mock.mockImplementation(async () => ({
578          variants: [{ text: 'Proposal' }],
579          contactCount: 1,
580        }));
581  
582        // Use concurrency of 1 to force sequential processing
583        const result = await runProposalsStage({ concurrency: 1 });
584  
585        assert.strictEqual(result.processed, 4);
586        assert.strictEqual(result.succeeded, 4);
587      });
588    });
589  
590    describe('getProposalsStats', () => {
591      test('returns zero stats when no outreaches exist', async () => {
592        const stats = await getProposalsStats();
593  
594        assert.strictEqual(stats.sites_with_proposals, 0);
595        assert.strictEqual(stats.total_messages, 0);
596        assert.strictEqual(stats.pending_messages, 0);
597        assert.strictEqual(stats.sent_messages, 0);
598      });
599  
600      test('returns correct counts with multiple outreaches', async () => {
601        const siteId1 = insertSite({ domain: 'stats1.com' });
602        const siteId2 = insertSite({ domain: 'stats2.com' });
603  
604        insertOutreach(siteId1, {
605          contact_uri: 'a@stats1.com',
606          status: 'pending',
607        });
608        insertOutreach(siteId1, {
609          contact_method: 'sms',
610          contact_uri: '+61400000001',
611          status: 'sent',
612        });
613        insertOutreach(siteId2, {
614          contact_uri: 'a@stats2.com',
615          status: 'pending',
616        });
617        insertOutreach(siteId2, {
618          contact_uri: 'b@stats2.com',
619          status: 'sent',
620        });
621  
622        const stats = await getProposalsStats();
623  
624        assert.strictEqual(stats.sites_with_proposals, 2);
625        assert.strictEqual(stats.total_messages, 4);
626        assert.strictEqual(stats.pending_messages, 2);
627        assert.strictEqual(stats.sent_messages, 2);
628      });
629  
630      test('counts delivered and failed statuses in total but not in specific counts', async () => {
631        const siteId = insertSite({ domain: 'delivery-stats.com' });
632  
633        insertOutreach(siteId, {
634          contact_uri: 'a@delivery.com',
635          status: 'delivered',
636        });
637        insertOutreach(siteId, {
638          contact_method: 'sms',
639          contact_uri: '+61400000002',
640          status: 'failed',
641        });
642        insertOutreach(siteId, {
643          contact_uri: 'c@delivery.com',
644          status: 'bounced',
645        });
646  
647        const stats = await getProposalsStats();
648  
649        assert.strictEqual(stats.total_messages, 3);
650        assert.strictEqual(stats.pending_messages, 0);
651        assert.strictEqual(stats.sent_messages, 0);
652        assert.strictEqual(stats.sites_with_proposals, 1);
653      });
654  
655      test('variant distribution handles messages without variants', async () => {
656        const siteId = insertSite({ domain: 'nullvariant.com' });
657  
658        setupDb
659          .prepare(
660            `INSERT INTO messages (site_id, contact_method, contact_uri, message_body, approval_status)
661             VALUES (?, ?, ?, ?, ?)`
662          )
663          .run(siteId, 'email', 'null@example.com', 'Test', 'pending');
664  
665        const stats = await getProposalsStats();
666  
667        assert.strictEqual(stats.total_messages, 1);
668      });
669    });
670  
671    describe('regenerateProposals', () => {
672      test('deletes existing outreaches and regenerates for given site IDs', async () => {
673        const siteId1 = insertSite({
674          domain: 'regen1.com',
675          landing_page_url: 'https://regen1.com',
676          status: 'proposals_drafted',
677          score: 50,
678        });
679        const siteId2 = insertSite({
680          domain: 'regen2.com',
681          landing_page_url: 'https://regen2.com',
682          status: 'proposals_drafted',
683          score: 60,
684        });
685  
686        // Insert existing outreaches that should be deleted
687        insertOutreach(siteId1, { contact_uri: 'old1@regen1.com' });
688        insertOutreach(siteId1, { contact_method: 'sms', contact_uri: '+61400000010' });
689        insertOutreach(siteId2, { contact_uri: 'old1@regen2.com' });
690  
691        // Verify outreaches exist before regeneration
692        const beforeCount = setupDb.prepare('SELECT COUNT(*) as cnt FROM messages').get().cnt;
693        assert.strictEqual(beforeCount, 3);
694  
695        mockGenerateProposalVariants.mock.mockImplementation(async () => ({
696          variants: [{ text: 'New proposal' }],
697          contactCount: 1,
698        }));
699  
700        const result = await regenerateProposals([Number(siteId1), Number(siteId2)]);
701  
702        assert.strictEqual(result.processed, 2);
703        assert.strictEqual(result.succeeded, 2);
704        assert.strictEqual(result.failed, 0);
705  
706        // Verify old outreaches were deleted
707        const afterCount = setupDb.prepare('SELECT COUNT(*) as cnt FROM messages').get().cnt;
708        assert.strictEqual(afterCount, 0, 'Old outreaches should be deleted');
709      });
710  
711      test('handles failure during regeneration', async () => {
712        const siteId = insertSite({
713          domain: 'regen-fail.com',
714          landing_page_url: 'https://regen-fail.com',
715          status: 'proposals_drafted',
716          score: 50,
717        });
718  
719        insertOutreach(siteId, { contact_uri: 'old@regen-fail.com' });
720  
721        mockGenerateProposalVariants.mock.mockImplementation(async () => {
722          throw new Error('LLM service unavailable');
723        });
724  
725        const result = await regenerateProposals([Number(siteId)]);
726  
727        assert.strictEqual(result.processed, 1);
728        assert.strictEqual(result.succeeded, 0);
729        assert.strictEqual(result.failed, 1);
730      });
731  
732      test('handles mix of success and failure during regeneration', async () => {
733        const siteId1 = insertSite({
734          domain: 'regen-ok.com',
735          landing_page_url: 'https://regen-ok.com',
736          status: 'proposals_drafted',
737          score: 50,
738        });
739        const siteId2 = insertSite({
740          domain: 'regen-err.com',
741          landing_page_url: 'https://regen-err.com',
742          status: 'proposals_drafted',
743          score: 60,
744        });
745  
746        insertOutreach(siteId1, { contact_uri: 'old@regen-ok.com' });
747        insertOutreach(siteId2, { contact_uri: 'old@regen-err.com' });
748  
749        let callIdx = 0;
750        mockGenerateProposalVariants.mock.mockImplementation(async () => {
751          callIdx++;
752          if (callIdx === 2) {
753            throw new Error('API quota exceeded');
754          }
755          return { variants: [{ text: 'New proposal' }], contactCount: 1 };
756        });
757  
758        const result = await regenerateProposals([Number(siteId1), Number(siteId2)]);
759  
760        assert.strictEqual(result.processed, 2);
761        assert.strictEqual(result.succeeded, 1);
762        assert.strictEqual(result.failed, 1);
763      });
764  
765      test('correctly deletes only outreaches for specified site IDs', async () => {
766        const siteId1 = insertSite({
767          domain: 'targeted.com',
768          landing_page_url: 'https://targeted.com',
769          status: 'proposals_drafted',
770          score: 50,
771        });
772        const siteId2 = insertSite({
773          domain: 'untouched.com',
774          landing_page_url: 'https://untouched.com',
775          status: 'proposals_drafted',
776          score: 60,
777        });
778  
779        insertOutreach(siteId1, { contact_uri: 'del@targeted.com' });
780        insertOutreach(siteId2, { contact_uri: 'keep@untouched.com' });
781  
782        mockGenerateProposalVariants.mock.mockImplementation(async () => ({
783          variants: [{ text: 'Regenerated' }],
784          contactCount: 1,
785        }));
786  
787        // Only regenerate for siteId1
788        await regenerateProposals([Number(siteId1)]);
789  
790        // siteId2's outreach should still exist
791        const remainingOutreaches = setupDb.prepare('SELECT site_id FROM messages').all();
792        assert.strictEqual(remainingOutreaches.length, 1);
793        assert.strictEqual(remainingOutreaches[0].site_id, Number(siteId2));
794      });
795  
796      test('records failure via retry handler when regeneration fails', async () => {
797        const siteId = insertSite({
798          domain: 'regen-retry.com',
799          landing_page_url: 'https://regen-retry.com',
800          status: 'proposals_drafted',
801          score: 50,
802        });
803  
804        insertOutreach(siteId, { contact_uri: 'old@regen-retry.com' });
805  
806        mockGenerateProposalVariants.mock.mockImplementation(async () => {
807          throw new Error('Timeout');
808        });
809  
810        await regenerateProposals([Number(siteId)]);
811  
812        // Verify retry_count was incremented by recordFailure
813        const site = setupDb
814          .prepare('SELECT retry_count, error_message FROM sites WHERE id = ?')
815          .get(siteId);
816        assert.ok(site.retry_count > 0, 'retry_count should be incremented on failure');
817        assert.ok(site.error_message.includes('Timeout'));
818      });
819    });
820  });