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 });