pipeline-e2e-extended.test.js
1 /** 2 * Extended End-to-End Pipeline Tests 3 * Tests advanced workflows and edge cases not covered in basic E2E test 4 * 5 * Coverage: 6 * 1. Rework workflow 7 * 2. Approval workflow 8 * 3. SMS reply handling 9 * 4. Email reply handling 10 * 5. Webhook events 11 * 6. GDPR blocking 12 * 7. Business hours blocking 13 * 8. Opt-out handling 14 * 9. High score path 15 * 10. Competitor score threshold 16 * 11. Error page detection 17 * 12. Locale-aware dedup 18 * 13. Circuit breaker states 19 * 14. Retry logic 20 * 15. Rate limiting verification 21 * 16. Persistent browser profiles 22 * 17. Conversion tracking 23 * 18. Human review queue 24 */ 25 26 import 'dotenv/config'; 27 import { describe, test, before, after } from 'node:test'; 28 import assert from 'node:assert'; 29 import Database from 'better-sqlite3'; 30 import { join, dirname } from 'path'; 31 import { fileURLToPath } from 'url'; 32 import { existsSync, mkdirSync, rmSync } from 'fs'; 33 import { setContactsJson } from '../../src/utils/contacts-storage.js'; 34 import { setScoreJson } from '../../src/utils/score-storage.js'; 35 36 const __filename = fileURLToPath(import.meta.url); 37 const __dirname = dirname(__filename); 38 const projectRoot = join(__dirname, '../..'); 39 40 const TEST_DB_PATH = join(projectRoot, 'tests/test-e2e-extended.db'); 41 42 // Shared test site ID 43 let siteId; 44 const outreachIds = {}; 45 46 function debug(message, data = null) { 47 const timestamp = new Date().toISOString(); 48 console.log(`\n[${timestamp}] ${message}`); 49 if (data) { 50 console.log(JSON.stringify(data, null, 2)); 51 } 52 } 53 54 describe('Extended E2E Pipeline Tests', () => { 55 before(async () => { 56 // Set up test database 57 process.env.DATABASE_PATH = TEST_DB_PATH; 58 59 debug('Setting up extended E2E test database...'); 60 61 // Remove old test DB if exists 62 if (existsSync(TEST_DB_PATH)) { 63 rmSync(TEST_DB_PATH); 64 } 65 66 // Initialize database with schema 67 const { initTestDb } = await import('../../src/utils/test-db.js'); 68 const testDb = initTestDb(TEST_DB_PATH); 69 70 // Verify tables were created 71 const tables = testDb 72 .prepare("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name") 73 .all(); 74 debug( 75 'Tables created:', 76 tables.map(t => t.name) 77 ); 78 79 testDb.close(); 80 81 // Create test site with low score 82 const db = new Database(TEST_DB_PATH); 83 const result = db 84 .prepare( 85 ` 86 INSERT INTO sites ( 87 domain, landing_page_url, keyword, google_domain, 88 status, score, grade, scored_at, country_code 89 ) VALUES (?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, ?) 90 ` 91 ) 92 .run( 93 'test-extended.com', 94 'https://test-extended.com', 95 'test keyword extended', 96 'google.com', 97 'prog_scored', 98 65, // Low score to trigger proposals 99 'C', 100 'US' 101 ); 102 103 siteId = result.lastInsertRowid; 104 105 // Add contacts to site (use non-demo email domains to avoid isDemoEmail() filter) 106 const contacts = { 107 email_addresses: [ 108 { email: 'john@testbusiness.com', label_variations: ['John'] }, 109 { email: 'jane@testbusiness.com', label_variations: ['Jane'] }, 110 ], 111 phone_numbers: [{ number: '+15005550006', label_variations: ['Support'] }], 112 }; 113 114 db.close(); 115 116 // Write contacts to filesystem (contacts_json column dropped in migration 121) 117 setContactsJson(siteId, JSON.stringify(contacts)); 118 119 debug('✅ Extended E2E test database initialized', { siteId }); 120 }); 121 122 after(() => { 123 // Cleanup 124 if (existsSync(TEST_DB_PATH)) { 125 debug('Cleaning up test database...'); 126 rmSync(TEST_DB_PATH); 127 } 128 }); 129 130 // ============================================================================ 131 // PATH 1 & 2: Approval & Rework Workflow 132 // ============================================================================ 133 134 test('Path 1-2: Approval and Rework Workflow', async () => { 135 debug('Testing approval and rework workflow...'); 136 137 const { 138 generateProposalVariants, 139 getPendingOutreaches, 140 approveOutreach, 141 reworkOutreach, 142 processReworkQueue, 143 } = await import('../../src/proposal-generator-v2.js'); 144 145 // Generate proposals (should create with status='pending') 146 debug('Generating proposals...'); 147 await generateProposalVariants(siteId); 148 149 // Verify proposals are pending 150 const pending = await getPendingOutreaches(); 151 assert.ok(pending.length > 0, 'Should have pending outreaches'); 152 assert.strictEqual(pending[0].approval_status, 'pending', 'Status should be pending'); 153 154 debug(`✅ Created ${pending.length} pending outreaches`); 155 156 // Test approval workflow 157 debug('Approving first outreach...'); 158 const firstId = pending[0].id; 159 outreachIds.approved = firstId; 160 161 await approveOutreach(firstId); 162 163 const db = new Database(TEST_DB_PATH); 164 const approved = db.prepare('SELECT approval_status FROM messages WHERE id = ?').get(firstId); 165 assert.strictEqual(approved.approval_status, 'approved', 'Should be approved'); 166 167 debug('✅ Approval workflow working'); 168 169 // Test rework workflow 170 debug('Requesting rework for second outreach...'); 171 const secondId = pending[1].id; 172 outreachIds.rework = secondId; 173 174 const originalData = db 175 .prepare('SELECT message_body, contact_uri FROM messages WHERE id = ?') 176 .get(secondId); 177 const originalProposal = originalData.message_body; 178 const reworkedContactUri = originalData.contact_uri; 179 180 await reworkOutreach(secondId, 'Make it shorter and more urgent. Max 100 chars.'); 181 182 const reworked = db 183 .prepare('SELECT approval_status, rework_instructions FROM messages WHERE id = ?') 184 .get(secondId); 185 186 assert.strictEqual(reworked.approval_status, 'rework', 'Should have rework status'); 187 assert.ok(reworked.rework_instructions.includes('shorter'), 'Should store instructions'); 188 189 debug('✅ Rework request stored'); 190 191 // Process rework queue 192 debug('Processing rework queue...'); 193 await processReworkQueue(); 194 195 // After rework processing, the old outreach is deleted and new ones are created 196 // We need to find the newly created outreaches for this site 197 const newOutreaches = db 198 .prepare( 199 `SELECT id, approval_status, message_body, contact_uri 200 FROM messages 201 WHERE site_id = ? AND approval_status = 'pending' 202 ORDER BY id DESC` 203 ) 204 .all(siteId); 205 206 // Should have regenerated outreaches (excluding the approved one) 207 assert.ok(newOutreaches.length > 0, 'Should have regenerated outreaches'); 208 209 // Find the outreach for the same contact that was reworked 210 const regenerated = newOutreaches.find(o => o.contact_uri === reworkedContactUri); 211 assert.ok(regenerated, 'Should find regenerated outreach for reworked contact'); 212 213 assert.strictEqual( 214 regenerated.approval_status, 215 'pending', 216 'Should be back to pending after rework' 217 ); 218 assert.notStrictEqual( 219 regenerated.message_body, 220 originalProposal, 221 'Proposal should be regenerated' 222 ); 223 assert.ok( 224 regenerated.message_body.length < originalProposal.length, 225 'New proposal should be shorter as requested' 226 ); 227 228 db.close(); 229 230 debug('✅ Rework workflow complete - proposal regenerated'); 231 }); 232 233 // ============================================================================ 234 // PATH 3 & 4: Inbound Reply Handling 235 // ============================================================================ 236 237 test('Path 3-4: Inbound Reply Handling (SMS and Email)', async () => { 238 debug('Testing inbound reply handling...'); 239 240 const db = new Database(TEST_DB_PATH); 241 242 // Create a separate site for inbound reply testing 243 const replySite = db 244 .prepare( 245 `INSERT INTO sites ( 246 domain, landing_page_url, keyword, google_domain, 247 status, scored_at 248 ) VALUES (?, ?, ?, ?, 'prog_scored', CURRENT_TIMESTAMP)` 249 ) 250 .run('test-replies.com', 'https://test-replies.com', 'test replies', 'google.com'); 251 252 const replySiteId = replySite.lastInsertRowid; 253 254 // Create sent outreach for SMS 255 const smsOutreach = db 256 .prepare( 257 `INSERT INTO messages ( 258 site_id, contact_method, contact_uri, message_body, 259 subject_line, approval_status, delivery_status, sent_at 260 ) VALUES (?, 'sms', '+15005550006', 'Test SMS', '', 'approved', 'sent', CURRENT_TIMESTAMP)` 261 ) 262 .run(replySiteId); 263 264 outreachIds.sms = smsOutreach.lastInsertRowid; 265 266 // Test SMS reply processing 267 debug('Processing positive SMS reply...'); 268 269 const { processSMSReply } = await import('../../src/inbound/sms.js'); 270 271 const inboundSMS = { 272 From: '+15005550006', 273 Body: "Yes, I'm interested! When can we talk?", 274 MessageSid: 'SM_test123', 275 AccountSid: process.env.TWILIO_ACCOUNT_SID, 276 NumMedia: '0', 277 }; 278 279 // Process reply (this will create conversation record) 280 try { 281 await processSMSReply(inboundSMS); 282 283 // Verify conversation created with positive sentiment 284 const conversation = db 285 .prepare( 286 ` 287 SELECT sentiment, message_body 288 FROM messages 289 WHERE contact_uri = ? 290 ORDER BY created_at DESC LIMIT 1 291 ` 292 ) 293 .get('+15005550006'); 294 295 if (conversation) { 296 assert.strictEqual(conversation.sentiment, 'positive', 'Should detect positive sentiment'); 297 debug('✅ SMS reply processed with positive sentiment'); 298 } else { 299 debug('ℹ️ Conversation not created (inbound SMS module may need site_id link)'); 300 } 301 } catch (error) { 302 debug('ℹ️ SMS reply processing skipped (requires Twilio webhook setup)'); 303 } 304 305 // Test STOP keyword handling 306 debug('Testing STOP keyword...'); 307 308 const stopSMS = { 309 From: '+15005550006', 310 Body: 'STOP', 311 MessageSid: 'SM_test124', 312 AccountSid: process.env.TWILIO_ACCOUNT_SID, 313 NumMedia: '0', 314 }; 315 316 try { 317 await processSMSReply(stopSMS); 318 319 // Verify opt-out created 320 const optOut = db 321 .prepare( 322 ` 323 SELECT phone FROM opt_outs WHERE phone = ? AND method = 'sms' 324 ` 325 ) 326 .get('+15005550006'); 327 328 if (optOut) { 329 assert.strictEqual(optOut.phone, '+15005550006', 'Should create opt-out'); 330 debug('✅ STOP keyword created opt-out'); 331 } 332 } catch (error) { 333 debug('ℹ️ STOP processing skipped'); 334 } 335 336 db.close(); 337 }); 338 339 // ============================================================================ 340 // PATH 6: GDPR Blocking 341 // ============================================================================ 342 343 test('Path 6: GDPR Blocking', async () => { 344 debug('Testing GDPR blocking for unverified company emails...'); 345 346 const db = new Database(TEST_DB_PATH); 347 348 // Create site in GDPR country (Germany) 349 const gdprSite = db 350 .prepare( 351 ` 352 INSERT INTO sites ( 353 domain, landing_page_url, keyword, google_domain, country_code, 354 status, score, grade, scored_at 355 ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) 356 ` 357 ) 358 .run( 359 'test-gdpr.de', 360 'https://test-gdpr.de', 361 'test gdpr', 362 'google.de', 363 'DE', // Germany - GDPR country 364 'prog_scored', 365 70, 366 'C' 367 ); 368 369 const gdprSiteId = gdprSite.lastInsertRowid; 370 371 // Write contacts to filesystem (contacts_json column dropped in migration 121) 372 setContactsJson( 373 gdprSiteId, 374 JSON.stringify({ 375 email_addresses: [ 376 { email: 'info@gmail.com', label_variations: ['Contact'] }, // Free email - should block 377 ], 378 }) 379 ); 380 381 // Try to generate proposals (should create outreach but mark as gdpr_blocked) 382 const { generateProposalVariants } = await import('../../src/proposal-generator-v2.js'); 383 384 try { 385 await generateProposalVariants(gdprSiteId); 386 387 // Check if any outreaches were created with gdpr_blocked status 388 const blocked = db 389 .prepare( 390 ` 391 SELECT approval_status, contact_uri, error_message 392 FROM messages 393 WHERE site_id = ? AND approval_status = 'gdpr_blocked' 394 ` 395 ) 396 .all(gdprSiteId); 397 398 if (blocked.length > 0) { 399 debug('✅ GDPR blocking working - free email blocked in GDPR country'); 400 } else { 401 debug('ℹ️ GDPR blocking may happen at send time, not proposal generation'); 402 } 403 } catch (error) { 404 debug('ℹ️ GDPR check happens during outreach send stage'); 405 } 406 407 db.close(); 408 }); 409 410 // ============================================================================ 411 // PATH 7: Business Hours Blocking 412 // ============================================================================ 413 414 test('Path 7: Business Hours Compliance', async () => { 415 debug('Testing business hours compliance for SMS...'); 416 417 const { shouldBlockSMS } = await import('../../src/utils/compliance.js'); 418 419 const db = new Database(TEST_DB_PATH); 420 421 // Create site with US phone 422 const usSite = db 423 .prepare( 424 ` 425 INSERT INTO sites ( 426 domain, landing_page_url, keyword, google_domain, country_code, 427 status, score, grade, scored_at 428 ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) 429 ` 430 ) 431 .run( 432 'test-hours.com', 433 'https://test-hours.com', 434 'test hours', 435 'google.com', 436 'US', 437 'prog_scored', 438 70, 439 'C' 440 ); 441 442 const hoursSiteId = usSite.lastInsertRowid; 443 444 // Test blocking (will use current time, so may or may not block) 445 const result = shouldBlockSMS('+12025551234', hoursSiteId, db); 446 447 if (result.blocked && result.reason === 'outside_business_hours') { 448 debug('✅ Business hours blocking active - outside 8am-9pm window'); 449 } else if (!result.blocked) { 450 debug('✅ Business hours check working - within allowed hours'); 451 } 452 453 db.close(); 454 }); 455 456 // ============================================================================ 457 // PATH 8: Opt-Out Handling 458 // ============================================================================ 459 460 test('Path 8: Opt-Out Handling', async () => { 461 debug('Testing opt-out list compliance...'); 462 463 const db = new Database(TEST_DB_PATH); 464 465 // Add number to opt-out list 466 db.prepare( 467 ` 468 INSERT INTO opt_outs (phone, method, source) 469 VALUES (?, 'sms', 'manual') 470 ` 471 ).run('+15005550999'); 472 473 // Try to create outreach to opted-out number 474 const { shouldBlockSMS } = await import('../../src/utils/compliance.js'); 475 476 const result = shouldBlockSMS('+15005550999', siteId, db); 477 478 assert.strictEqual(result.blocked, true, 'Should block opted-out number'); 479 assert.strictEqual(result.reason, 'opted_out', 'Reason should be opted_out'); 480 481 debug('✅ Opt-out list preventing sends to opted-out numbers'); 482 483 // Test email unsubscribe 484 db.prepare( 485 ` 486 INSERT INTO unsubscribed_emails (email, source) 487 VALUES (?, 'web') 488 ` 489 ).run('unsubscribed@example.com'); 490 491 const { isEmailUnsubscribed } = await import('../../src/utils/sync-unsubscribes.js'); 492 493 const emailBlocked = isEmailUnsubscribed('unsubscribed@example.com'); 494 assert.strictEqual(emailBlocked, true, 'Should detect unsubscribed email'); 495 496 debug('✅ Email unsubscribe list working'); 497 498 db.close(); 499 }); 500 501 // ============================================================================ 502 // PATH 9: High Score Path 503 // ============================================================================ 504 505 test('Path 9: High Score Path (No Proposals)', async () => { 506 debug('Testing high score path - should skip proposals...'); 507 508 const db = new Database(TEST_DB_PATH); 509 510 // Create high-scoring site (score >= 82 triggers the cutoff check) 511 const highScoreSite = db 512 .prepare( 513 ` 514 INSERT INTO sites ( 515 domain, landing_page_url, keyword, google_domain, 516 status, score, grade, scored_at, country_code 517 ) VALUES (?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, ?) 518 ` 519 ) 520 .run( 521 'test-highscore.com', 522 'https://test-highscore.com', 523 'test high score', 524 'google.com', 525 'prog_scored', 526 92, 527 'A', 528 'US' 529 ); 530 531 const highScoreSiteId = highScoreSite.lastInsertRowid; 532 533 // Try to generate proposals (should fail with score threshold error) 534 const { generateProposalVariants } = await import('../../src/proposal-generator-v2.js'); 535 536 try { 537 await generateProposalVariants(highScoreSiteId); 538 assert.fail('Should not generate proposals for high-scoring site'); 539 } catch (error) { 540 assert.ok(error.message.includes('above the cutoff'), 'Should reject high-scoring sites'); 541 debug('✅ High score path working - proposals rejected for score >= 82'); 542 } 543 544 db.close(); 545 }); 546 547 // ============================================================================ 548 // PATH 10: Competitor Score Threshold 549 // ============================================================================ 550 551 test('Path 10: Competitor Score Threshold', async () => { 552 debug('Testing competitor score threshold (10 point difference)...'); 553 554 const db = new Database(TEST_DB_PATH); 555 556 const keyword = 'test competitor keyword'; 557 558 // First create the competitor site with score 75 559 db.prepare( 560 `INSERT INTO sites ( 561 domain, landing_page_url, keyword, google_domain, 562 status, score, grade, scored_at 563 ) VALUES (?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)` 564 ).run( 565 'competitor.com', 566 'https://competitor.com', 567 keyword, 568 'google.com', 569 'prog_scored', 570 75, 571 'C' 572 ); 573 574 // Now create target site with score 70 (only 5 points lower - below 10 point threshold) 575 const closeCompSite = db 576 .prepare( 577 `INSERT INTO sites ( 578 domain, landing_page_url, keyword, google_domain, 579 status, score, grade, scored_at, 580 competitor_domain, country_code 581 ) VALUES (?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, ?, ?)` 582 ) 583 .run( 584 'test-comp-close.com', 585 'https://test-comp-close.com', 586 keyword, 587 'google.com', 588 'prog_scored', 589 70, 590 'C-', 591 'competitor.com', 592 'US' 593 ); 594 595 const closeCompSiteId = closeCompSite.lastInsertRowid; 596 597 // Write contacts to filesystem (contacts_json column dropped in migration 121) 598 setContactsJson( 599 closeCompSiteId, 600 JSON.stringify({ 601 email_addresses: [{ email: 'info@compbusiness.com', label_variations: ['Test'] }], 602 }) 603 ); 604 605 const { generateProposalVariants } = await import('../../src/proposal-generator-v2.js'); 606 await generateProposalVariants(closeCompSiteId); 607 608 const proposal = db 609 .prepare('SELECT message_body FROM messages WHERE site_id = ? LIMIT 1') 610 .get(closeCompSiteId); 611 612 // Proposal should NOT mention competitor (< 10 point difference) 613 assert.ok( 614 !proposal.message_body.includes('competitor.com'), 615 'Should not mention competitor when difference < 10 points' 616 ); 617 618 debug('✅ Competitor threshold working - not mentioned when < 10 point difference'); 619 620 db.close(); 621 }); 622 623 // ============================================================================ 624 // PATH 13: Circuit Breaker States 625 // ============================================================================ 626 627 test('Path 13: Circuit Breaker States', async () => { 628 debug('Testing circuit breaker states...'); 629 630 const { resendBreaker, twilioBreaker } = await import('../../src/utils/circuit-breaker.js'); 631 632 // Check initial state 633 const resendStats = resendBreaker.stats; 634 const twilioStats = twilioBreaker.stats; 635 636 debug('Circuit breaker states', { 637 resend: { 638 enabled: resendBreaker.enabled, 639 state: resendStats.failures === 0 ? 'closed' : 'open', 640 failures: resendStats.failures, 641 }, 642 twilio: { 643 enabled: twilioBreaker.enabled, 644 state: twilioStats.failures === 0 ? 'closed' : 'open', 645 failures: twilioStats.failures, 646 }, 647 }); 648 649 assert.ok(resendBreaker.enabled, 'Resend circuit breaker should be enabled'); 650 assert.ok(twilioBreaker.enabled, 'Twilio circuit breaker should be enabled'); 651 652 debug('✅ Circuit breakers initialized and monitoring'); 653 }); 654 655 // ============================================================================ 656 // PATH 15: Rate Limiting Verification 657 // ============================================================================ 658 659 test('Path 15: Rate Limiting Verification', async () => { 660 debug('Testing rate limiters are configured...'); 661 662 const { resendLimiter, twilioLimiter, openRouterLimiter, zenrowsLimiter } = 663 await import('../../src/utils/rate-limiter.js'); 664 665 // Verify rate limiters exist and are configured 666 assert.ok(resendLimiter, 'Resend rate limiter should exist'); 667 assert.ok(twilioLimiter, 'Twilio rate limiter should exist'); 668 assert.ok(openRouterLimiter, 'OpenRouter rate limiter should exist'); 669 assert.ok(zenrowsLimiter, 'ZenRows rate limiter should exist'); 670 671 debug('✅ All rate limiters configured', { 672 resend: 'max 2/sec (500ms spacing)', 673 twilio: 'max 10/sec (100ms spacing)', 674 openRouter: 'max 5 concurrent', 675 zenrows: `max ${process.env.ZENROWS_CONCURRENCY || 20} concurrent`, 676 }); 677 }); 678 679 // ============================================================================ 680 // PATH 17: Conversion Tracking 681 // ============================================================================ 682 683 test('Path 17: Conversion Tracking', async () => { 684 debug('Testing conversion tracking...'); 685 686 const db = new Database(TEST_DB_PATH); 687 688 // Mark site as converted (resulted_in_sale is on sites table in new schema) 689 db.prepare(`UPDATE sites SET resulted_in_sale = 1, sale_amount = 499.00 WHERE id = ?`).run( 690 siteId 691 ); 692 693 // Verify conversion tracking on sites table 694 const conversion = db 695 .prepare(`SELECT resulted_in_sale, sale_amount FROM sites WHERE id = ?`) 696 .get(siteId); 697 698 assert.strictEqual(conversion.resulted_in_sale, 1, 'Should track conversion'); 699 assert.strictEqual(parseFloat(conversion.sale_amount), 499.0, 'Should track sale amount'); 700 701 // Calculate conversion rate from sites table 702 const stats = db 703 .prepare( 704 ` 705 SELECT 706 (SELECT COUNT(*) FROM messages WHERE direction = 'outbound' AND delivery_status IN ('sent', 'delivered')) as total_sent, 707 SUM(resulted_in_sale) as total_converted, 708 ROUND(AVG(CASE WHEN resulted_in_sale = 1 THEN 100.0 ELSE 0 END), 2) as conversion_rate, 709 SUM(sale_amount) as total_revenue 710 FROM sites 711 WHERE resulted_in_sale IS NOT NULL 712 ` 713 ) 714 .get(); 715 716 debug('✅ Conversion tracking working', { 717 totalSent: stats.total_sent, 718 totalConverted: stats.total_converted, 719 conversionRate: `${stats.conversion_rate}%`, 720 totalRevenue: `$${stats.total_revenue}`, 721 }); 722 723 db.close(); 724 }); 725 726 // ============================================================================ 727 // PATH 18: Human Review Queue 728 // ============================================================================ 729 730 test('Path 18: Human Review Queue', async () => { 731 debug('Testing human review queue...'); 732 733 const { getPendingOutreaches } = await import('../../src/proposal-generator-v2.js'); 734 735 // Get all pending outreaches (waiting for operator review) 736 const pending = await getPendingOutreaches(); 737 738 debug(`✅ Human review queue contains ${pending.length} pending outreaches`); 739 740 if (pending.length > 0) { 741 const sample = pending[0]; 742 debug('Sample pending outreach', { 743 id: sample.id, 744 domain: sample.domain, 745 contactMethod: sample.contact_method, 746 contactUri: sample.contact_uri, 747 status: sample.status, 748 preview: `${sample.message_body.substring(0, 100)}...`, 749 }); 750 } 751 }); 752 753 // ============================================================================ 754 // PATH 11: Error Page Detection 755 // ============================================================================ 756 757 test('Path 11: Error Page Detection', async () => { 758 debug('Testing error page detection...'); 759 760 const { detectErrorPage } = await import('../../src/utils/error-page-detector.js'); 761 762 // Test error pages with 2xx status (false positives) 763 const errorPage = 764 '<html><body><h1>404 Not Found</h1><p>Sorry, the page you are looking for does not exist.</p></body></html>'; 765 766 const errorResult = detectErrorPage(errorPage, 200); 767 assert.ok(errorResult.isErrorPage, 'Should detect error page with 2xx status'); 768 assert.ok(errorResult.wordCount < 200, 'Error page should have few words'); 769 770 // Test legitimate pages (200+ words to bypass error detection) 771 const realPage = `<html><body> 772 <h1>Welcome to Our Business</h1> 773 <p>${'Lorem ipsum dolor sit amet, consectetur adipiscing elit. '.repeat(30)}</p> 774 <p>Contact us today at 555-1234 or visit our office at 123 Main Street.</p> 775 </body></html>`; 776 777 const realResult = detectErrorPage(realPage, 200); 778 assert.ok(!realResult.isErrorPage, 'Should not flag legitimate pages as errors'); 779 assert.ok(realResult.wordCount >= 200, 'Real page should have enough content'); 780 781 debug('✅ Error page detection working'); 782 }); 783 784 // ============================================================================ 785 // PATH 12: Locale-Aware Deduplication 786 // ============================================================================ 787 788 test('Path 12: Locale-Aware Deduplication', async () => { 789 debug('Testing locale-aware deduplication...'); 790 791 const db = new Database(TEST_DB_PATH); 792 793 // Create duplicate domain with different locales 794 const keyword = 'test dedup keyword'; 795 796 // AU locale site 797 db.prepare( 798 `INSERT INTO sites ( 799 domain, landing_page_url, keyword, google_domain, 800 status, scored_at 801 ) VALUES ('duplicate.com', 'https://duplicate.com', ?, 'google.com.au', 'assets_captured', CURRENT_TIMESTAMP)` 802 ).run(keyword); 803 804 // US locale site 805 db.prepare( 806 `INSERT INTO sites ( 807 domain, landing_page_url, keyword, google_domain, 808 status, scored_at 809 ) VALUES ('duplicate.com', 'https://duplicate.com', ?, 'google.com', 'assets_captured', CURRENT_TIMESTAMP)` 810 ).run(keyword); 811 812 const beforeCount = db 813 .prepare('SELECT COUNT(*) as count FROM sites WHERE domain = ?') 814 .get('duplicate.com').count; 815 816 // Run deduplication 817 const { deduplicateSites } = await import('../../src/utils/dedupe-locale-aware.js'); 818 const stats = deduplicateSites(db, false); 819 820 // Verify duplicates were handled 821 const afterCount = db 822 .prepare("SELECT COUNT(*) as count FROM sites WHERE domain = ? AND status != 'ignored'") 823 .get('duplicate.com').count; 824 825 assert.ok(afterCount < beforeCount, 'Should mark duplicates as ignore'); 826 assert.ok(typeof stats.duplicateDomains === 'number', 'Should return duplicate domains count'); 827 assert.ok(typeof stats.sitesIgnored === 'number', 'Should return sites ignored count'); 828 829 debug('✅ Locale-aware deduplication working', stats); 830 831 db.close(); 832 }); 833 834 // ============================================================================ 835 // PATH 14: Retry Logic with Exponential Backoff 836 // ============================================================================ 837 838 test('Path 14: Retry Logic', async () => { 839 debug('Testing retry logic with exponential backoff...'); 840 841 const { retryWithBackoff } = await import('../../src/utils/error-handler.js'); 842 843 let attempts = 0; 844 845 // Test successful retry after failures 846 const unreliableFunction = async () => { 847 attempts++; 848 if (attempts < 2) { 849 throw new Error('Temporary failure'); 850 } 851 return 'Success'; 852 }; 853 854 const result = await retryWithBackoff(unreliableFunction, { maxRetries: 3, delayMs: 10 }); 855 856 assert.strictEqual(result, 'Success', 'Should succeed after retries'); 857 assert.strictEqual(attempts, 2, 'Should retry once before succeeding'); 858 859 debug('✅ Retry logic working - succeeded after 1 retry'); 860 861 // Test max retries exceeded 862 attempts = 0; 863 const alwaysFailFunction = async () => { 864 attempts++; 865 throw new Error('Permanent failure'); 866 }; 867 868 try { 869 await retryWithBackoff(alwaysFailFunction, { maxRetries: 2, delayMs: 10 }); 870 assert.fail('Should throw after max retries'); 871 } catch (error) { 872 assert.ok(error.message.includes('Permanent failure'), 'Should throw original error'); 873 assert.strictEqual(attempts, 3, 'Should try initial + 2 retries'); 874 } 875 876 debug('✅ Max retries working - stopped after 3 attempts'); 877 }); 878 879 // ============================================================================ 880 // PATH 5: Webhook Events Simulation 881 // ============================================================================ 882 883 test('Path 5: Webhook Events Simulation', async () => { 884 debug('Testing webhook events simulation...'); 885 886 const db = new Database(TEST_DB_PATH); 887 888 // Create sent email outreach 889 db.prepare( 890 `INSERT INTO messages ( 891 site_id, contact_method, contact_uri, message_body, 892 subject_line, approval_status, delivery_status, sent_at 893 ) VALUES (?, 'email', 'webhook-test@example.com', 'Test', 'Test', 'approved', 'sent', CURRENT_TIMESTAMP)` 894 ).run(siteId); 895 896 // Simulate email reply (webhook event creates inbound message linked to same site) 897 db.prepare( 898 `INSERT INTO messages ( 899 site_id, contact_method, contact_uri, message_body, 900 direction, sentiment, created_at 901 ) VALUES (?, 'email', 'webhook-test@example.com', ?, 'inbound', 'positive', CURRENT_TIMESTAMP)` 902 ).run(siteId, 'Thanks for reaching out! I would like to learn more.'); 903 904 // Verify inbound message was created 905 const conversation = db 906 .prepare("SELECT * FROM messages WHERE site_id = ? AND direction = 'inbound'") 907 .get(siteId); 908 909 assert.ok(conversation, 'Should create conversation from webhook'); 910 assert.strictEqual(conversation.contact_method, 'email', 'Should store channel'); 911 assert.strictEqual(conversation.direction, 'inbound', 'Should store direction'); 912 assert.strictEqual(conversation.sentiment, 'positive', 'Should detect sentiment'); 913 914 debug('✅ Webhook events simulation working'); 915 916 db.close(); 917 }); 918 919 // ============================================================================ 920 // PATH 16: Persistent Browser Profiles 921 // ============================================================================ 922 923 test('Path 16: Persistent Browser Profiles', async () => { 924 debug('Testing persistent browser profile rotation...'); 925 926 // Note: This is a lightweight test since actual browser profile management 927 // requires persistent storage and is tested separately 928 929 const { getNextProfile } = await import('../../src/utils/stealth-browser.js'); 930 931 // Test that profile names are returned 932 const profile1 = getNextProfile('x'); 933 const profile2 = getNextProfile('linkedin'); 934 935 // Profiles should be valid profile names 936 assert.ok(profile1, 'Should return X profile name'); 937 assert.ok(profile2, 'Should return LinkedIn profile name'); 938 assert.ok(profile1.startsWith('profile-'), 'X profile should follow naming convention'); 939 assert.ok(profile2.startsWith('profile-'), 'LinkedIn profile should follow naming convention'); 940 941 debug(`✅ Browser profile rotation working - X: ${profile1}, LinkedIn: ${profile2}`); 942 }); 943 944 // ============================================================================ 945 // Summary Test 946 // ============================================================================ 947 948 test('Extended E2E Summary', () => { 949 debug('='.repeat(80)); 950 debug('✅ EXTENDED E2E TESTS COMPLETE'); 951 debug('='.repeat(80)); 952 953 const db = new Database(TEST_DB_PATH); 954 955 const summary = { 956 sites_created: db.prepare('SELECT COUNT(*) as count FROM sites').get().count, 957 messages_created: db.prepare('SELECT COUNT(*) as count FROM messages').get().count, 958 approval_statuses: db 959 .prepare( 960 ` 961 SELECT approval_status, COUNT(*) as count 962 FROM messages 963 WHERE direction = 'outbound' 964 GROUP BY approval_status 965 ` 966 ) 967 .all(), 968 conversions: db 969 .prepare( 970 ` 971 SELECT COUNT(*) as count 972 FROM sites 973 WHERE resulted_in_sale = 1 974 ` 975 ) 976 .get().count, 977 }; 978 979 debug('Test Summary', summary); 980 981 db.close(); 982 }); 983 });