feat(prospecting): add complete prospecting module for lead discovery and scoring
Some checks failed
Some checks failed
Migrates scanning pipeline from marketing-.lu-domains app into Orion module. Supports digital (domain scan) and offline (manual capture) lead channels with enrichment, scoring, campaign management, and interaction tracking. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
1
app/modules/prospecting/tests/__init__.py
Normal file
1
app/modules/prospecting/tests/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# app/modules/prospecting/tests/__init__.py
|
||||
231
app/modules/prospecting/tests/conftest.py
Normal file
231
app/modules/prospecting/tests/conftest.py
Normal file
@@ -0,0 +1,231 @@
|
||||
# app/modules/prospecting/tests/conftest.py
|
||||
"""
|
||||
Module-specific fixtures for prospecting tests.
|
||||
Core fixtures (db, client, etc.) are inherited from the root conftest.py.
|
||||
"""
|
||||
|
||||
import json
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
|
||||
from app.modules.prospecting.models import (
|
||||
CampaignTemplate,
|
||||
Prospect,
|
||||
ProspectContact,
|
||||
ProspectInteraction,
|
||||
ProspectPerformanceProfile,
|
||||
ProspectScore,
|
||||
ProspectTechProfile,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def digital_prospect(db):
|
||||
"""Create a digital prospect with a domain."""
|
||||
prospect = Prospect(
|
||||
channel="digital",
|
||||
domain_name=f"test-{uuid.uuid4().hex[:8]}.lu",
|
||||
status="active",
|
||||
source="domain_scan",
|
||||
has_website=True,
|
||||
uses_https=True,
|
||||
http_status_code=200,
|
||||
country="LU",
|
||||
)
|
||||
db.add(prospect)
|
||||
db.commit()
|
||||
db.refresh(prospect)
|
||||
return prospect
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def offline_prospect(db):
|
||||
"""Create an offline prospect with business details."""
|
||||
prospect = Prospect(
|
||||
channel="offline",
|
||||
business_name=f"Test Business {uuid.uuid4().hex[:8]}",
|
||||
status="pending",
|
||||
source="networking_event",
|
||||
city="Luxembourg",
|
||||
postal_code="1234",
|
||||
country="LU",
|
||||
notes="Met at networking event",
|
||||
tags=json.dumps(["networking", "no-website"]),
|
||||
)
|
||||
db.add(prospect)
|
||||
db.commit()
|
||||
db.refresh(prospect)
|
||||
return prospect
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def prospect_with_tech(db, digital_prospect):
|
||||
"""Create a digital prospect with tech profile."""
|
||||
tech = ProspectTechProfile(
|
||||
prospect_id=digital_prospect.id,
|
||||
cms="WordPress",
|
||||
cms_version="5.9",
|
||||
server="nginx",
|
||||
js_framework="jQuery",
|
||||
analytics="Google Analytics",
|
||||
ecommerce_platform=None,
|
||||
)
|
||||
db.add(tech)
|
||||
db.commit()
|
||||
db.refresh(digital_prospect)
|
||||
return digital_prospect
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def prospect_with_performance(db, digital_prospect):
|
||||
"""Create a digital prospect with performance profile."""
|
||||
perf = ProspectPerformanceProfile(
|
||||
prospect_id=digital_prospect.id,
|
||||
performance_score=45,
|
||||
accessibility_score=70,
|
||||
seo_score=60,
|
||||
first_contentful_paint_ms=2500,
|
||||
largest_contentful_paint_ms=4200,
|
||||
total_blocking_time_ms=350,
|
||||
cumulative_layout_shift=0.15,
|
||||
is_mobile_friendly=False,
|
||||
)
|
||||
db.add(perf)
|
||||
db.commit()
|
||||
db.refresh(digital_prospect)
|
||||
return digital_prospect
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def prospect_with_contacts(db, digital_prospect):
|
||||
"""Create a digital prospect with contacts."""
|
||||
contacts = [
|
||||
ProspectContact(
|
||||
prospect_id=digital_prospect.id,
|
||||
contact_type="email",
|
||||
value="info@test.lu",
|
||||
source_url="https://test.lu/contact",
|
||||
is_primary=True,
|
||||
),
|
||||
ProspectContact(
|
||||
prospect_id=digital_prospect.id,
|
||||
contact_type="phone",
|
||||
value="+352 123 456",
|
||||
source_url="https://test.lu/contact",
|
||||
is_primary=True,
|
||||
),
|
||||
]
|
||||
db.add_all(contacts)
|
||||
db.commit()
|
||||
db.refresh(digital_prospect)
|
||||
return digital_prospect
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def prospect_with_score(db, digital_prospect):
|
||||
"""Create a digital prospect with a score."""
|
||||
score = ProspectScore(
|
||||
prospect_id=digital_prospect.id,
|
||||
score=72,
|
||||
technical_health_score=25,
|
||||
modernity_score=20,
|
||||
business_value_score=20,
|
||||
engagement_score=7,
|
||||
reason_flags=json.dumps(["no_ssl", "slow"]),
|
||||
score_breakdown=json.dumps({"no_ssl": 15, "slow": 10}),
|
||||
lead_tier="top_priority",
|
||||
)
|
||||
db.add(score)
|
||||
db.commit()
|
||||
db.refresh(digital_prospect)
|
||||
return digital_prospect
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def prospect_full(db):
|
||||
"""Create a fully enriched digital prospect with all related data."""
|
||||
prospect = Prospect(
|
||||
channel="digital",
|
||||
domain_name=f"full-{uuid.uuid4().hex[:8]}.lu",
|
||||
status="active",
|
||||
source="domain_scan",
|
||||
has_website=True,
|
||||
uses_https=False,
|
||||
http_status_code=200,
|
||||
country="LU",
|
||||
)
|
||||
db.add(prospect)
|
||||
db.flush()
|
||||
|
||||
tech = ProspectTechProfile(
|
||||
prospect_id=prospect.id,
|
||||
cms="Drupal",
|
||||
cms_version="7.0",
|
||||
server="Apache",
|
||||
js_framework="jQuery",
|
||||
)
|
||||
perf = ProspectPerformanceProfile(
|
||||
prospect_id=prospect.id,
|
||||
performance_score=25,
|
||||
accessibility_score=50,
|
||||
seo_score=40,
|
||||
is_mobile_friendly=False,
|
||||
)
|
||||
contact = ProspectContact(
|
||||
prospect_id=prospect.id,
|
||||
contact_type="email",
|
||||
value="info@full-test.lu",
|
||||
is_primary=True,
|
||||
)
|
||||
score = ProspectScore(
|
||||
prospect_id=prospect.id,
|
||||
score=85,
|
||||
technical_health_score=35,
|
||||
modernity_score=20,
|
||||
business_value_score=20,
|
||||
engagement_score=10,
|
||||
reason_flags=json.dumps(["no_ssl", "very_slow", "outdated_cms", "not_mobile_friendly"]),
|
||||
lead_tier="top_priority",
|
||||
)
|
||||
|
||||
db.add_all([tech, perf, contact, score])
|
||||
db.commit()
|
||||
db.refresh(prospect)
|
||||
return prospect
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def campaign_template(db):
|
||||
"""Create a campaign template."""
|
||||
template = CampaignTemplate(
|
||||
name="Test Template",
|
||||
lead_type="bad_website",
|
||||
channel="email",
|
||||
language="fr",
|
||||
subject_template="Votre site {domain} a des problèmes",
|
||||
body_template="Bonjour {business_name},\n\nVotre site {domain} a un score de {score}.\n\nIssues: {issues}",
|
||||
is_active=True,
|
||||
)
|
||||
db.add(template)
|
||||
db.commit()
|
||||
db.refresh(template)
|
||||
return template
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def interaction(db, digital_prospect):
|
||||
"""Create a prospect interaction."""
|
||||
interaction = ProspectInteraction(
|
||||
prospect_id=digital_prospect.id,
|
||||
interaction_type="call",
|
||||
subject="Initial contact",
|
||||
notes="Discussed website needs",
|
||||
outcome="positive",
|
||||
next_action="Send proposal",
|
||||
created_by_user_id=1,
|
||||
)
|
||||
db.add(interaction)
|
||||
db.commit()
|
||||
db.refresh(interaction)
|
||||
return interaction
|
||||
1
app/modules/prospecting/tests/unit/__init__.py
Normal file
1
app/modules/prospecting/tests/unit/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# app/modules/prospecting/tests/unit/__init__.py
|
||||
107
app/modules/prospecting/tests/unit/test_campaign_service.py
Normal file
107
app/modules/prospecting/tests/unit/test_campaign_service.py
Normal file
@@ -0,0 +1,107 @@
|
||||
# app/modules/prospecting/tests/unit/test_campaign_service.py
|
||||
"""
|
||||
Unit tests for CampaignService.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from app.modules.prospecting.exceptions import CampaignTemplateNotFoundException
|
||||
from app.modules.prospecting.services.campaign_service import CampaignService
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.prospecting
|
||||
class TestCampaignService:
|
||||
"""Tests for CampaignService."""
|
||||
|
||||
def setup_method(self):
|
||||
self.service = CampaignService()
|
||||
|
||||
def test_create_template(self, db):
|
||||
"""Test creating a campaign template."""
|
||||
template = self.service.create_template(db, {
|
||||
"name": "Test Campaign",
|
||||
"lead_type": "bad_website",
|
||||
"channel": "email",
|
||||
"language": "fr",
|
||||
"subject_template": "Subject for {domain}",
|
||||
"body_template": "Hello {business_name}, your site {domain} has issues.",
|
||||
})
|
||||
|
||||
assert template.id is not None
|
||||
assert template.name == "Test Campaign"
|
||||
assert template.lead_type == "bad_website"
|
||||
|
||||
def test_get_templates(self, db, campaign_template):
|
||||
"""Test listing campaign templates."""
|
||||
templates = self.service.get_templates(db)
|
||||
assert len(templates) >= 1
|
||||
|
||||
def test_get_templates_filter_lead_type(self, db, campaign_template):
|
||||
"""Test filtering templates by lead type."""
|
||||
templates = self.service.get_templates(db, lead_type="bad_website")
|
||||
assert len(templates) >= 1
|
||||
|
||||
templates = self.service.get_templates(db, lead_type="no_website")
|
||||
# campaign_template is bad_website, so this might be empty
|
||||
assert isinstance(templates, list)
|
||||
|
||||
def test_get_template_by_id(self, db, campaign_template):
|
||||
"""Test getting a template by ID."""
|
||||
result = self.service.get_template_by_id(db, campaign_template.id)
|
||||
assert result.id == campaign_template.id
|
||||
|
||||
def test_get_template_not_found(self, db):
|
||||
"""Test getting non-existent template raises exception."""
|
||||
with pytest.raises(CampaignTemplateNotFoundException):
|
||||
self.service.get_template_by_id(db, 99999)
|
||||
|
||||
def test_update_template(self, db, campaign_template):
|
||||
"""Test updating a template."""
|
||||
updated = self.service.update_template(
|
||||
db, campaign_template.id, {"name": "Updated Campaign"}
|
||||
)
|
||||
assert updated.name == "Updated Campaign"
|
||||
|
||||
def test_delete_template(self, db, campaign_template):
|
||||
"""Test deleting a template."""
|
||||
tid = campaign_template.id
|
||||
self.service.delete_template(db, tid)
|
||||
|
||||
with pytest.raises(CampaignTemplateNotFoundException):
|
||||
self.service.get_template_by_id(db, tid)
|
||||
|
||||
def test_render_campaign(self, db, campaign_template, prospect_with_score):
|
||||
"""Test rendering a campaign with prospect data."""
|
||||
result = self.service.render_campaign(
|
||||
db, campaign_template.id, prospect_with_score.id
|
||||
)
|
||||
|
||||
assert "subject" in result
|
||||
assert "body" in result
|
||||
assert prospect_with_score.domain_name in result["subject"]
|
||||
|
||||
def test_send_campaign(self, db, campaign_template, digital_prospect):
|
||||
"""Test sending a campaign to prospects."""
|
||||
sends = self.service.send_campaign(
|
||||
db,
|
||||
template_id=campaign_template.id,
|
||||
prospect_ids=[digital_prospect.id],
|
||||
sent_by_user_id=1,
|
||||
)
|
||||
|
||||
assert len(sends) == 1
|
||||
assert sends[0].prospect_id == digital_prospect.id
|
||||
assert sends[0].status.value == "sent"
|
||||
|
||||
def test_get_sends(self, db, campaign_template, digital_prospect):
|
||||
"""Test getting campaign sends."""
|
||||
self.service.send_campaign(
|
||||
db,
|
||||
template_id=campaign_template.id,
|
||||
prospect_ids=[digital_prospect.id],
|
||||
sent_by_user_id=1,
|
||||
)
|
||||
|
||||
sends = self.service.get_sends(db, prospect_id=digital_prospect.id)
|
||||
assert len(sends) >= 1
|
||||
@@ -0,0 +1,98 @@
|
||||
# app/modules/prospecting/tests/unit/test_enrichment_service.py
|
||||
"""
|
||||
Unit tests for EnrichmentService.
|
||||
|
||||
Note: These tests mock external HTTP calls to avoid real network requests.
|
||||
"""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from app.modules.prospecting.services.enrichment_service import EnrichmentService
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.prospecting
|
||||
class TestEnrichmentService:
|
||||
"""Tests for EnrichmentService."""
|
||||
|
||||
def setup_method(self):
|
||||
self.service = EnrichmentService()
|
||||
|
||||
def test_check_http_success(self, db, digital_prospect):
|
||||
"""Test HTTP check with mocked successful response."""
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.url = f"https://{digital_prospect.domain_name}"
|
||||
mock_response.headers = {}
|
||||
|
||||
with patch("app.modules.prospecting.services.enrichment_service.requests.get", return_value=mock_response):
|
||||
result = self.service.check_http(db, digital_prospect)
|
||||
|
||||
assert result["has_website"] is True
|
||||
assert result["uses_https"] is True
|
||||
|
||||
def test_check_http_no_website(self, db, digital_prospect):
|
||||
"""Test HTTP check when website doesn't respond."""
|
||||
import requests as req
|
||||
|
||||
with patch(
|
||||
"app.modules.prospecting.services.enrichment_service.requests.get",
|
||||
side_effect=req.exceptions.ConnectionError("Connection refused"),
|
||||
):
|
||||
result = self.service.check_http(db, digital_prospect)
|
||||
|
||||
assert result["has_website"] is False
|
||||
|
||||
def test_check_http_no_domain(self, db, offline_prospect):
|
||||
"""Test HTTP check with no domain name."""
|
||||
result = self.service.check_http(db, offline_prospect)
|
||||
assert result["has_website"] is False
|
||||
assert result["error"] == "No domain name"
|
||||
|
||||
def test_detect_cms_wordpress(self):
|
||||
"""Test CMS detection for WordPress."""
|
||||
html = '<link rel="stylesheet" href="/wp-content/themes/test/style.css">'
|
||||
assert self.service._detect_cms(html) == "wordpress"
|
||||
|
||||
def test_detect_cms_drupal(self):
|
||||
"""Test CMS detection for Drupal."""
|
||||
html = '<meta name="generator" content="drupal 7">'
|
||||
assert self.service._detect_cms(html) == "drupal"
|
||||
|
||||
def test_detect_cms_none(self):
|
||||
"""Test CMS detection when no CMS detected."""
|
||||
html = "<html><body>Hello world</body></html>"
|
||||
assert self.service._detect_cms(html) is None
|
||||
|
||||
def test_detect_js_framework_jquery(self):
|
||||
"""Test JS framework detection for jQuery."""
|
||||
html = '<script src="/js/jquery.min.js"></script>'
|
||||
assert self.service._detect_js_framework(html) == "jquery"
|
||||
|
||||
def test_detect_js_framework_react(self):
|
||||
"""Test JS framework detection for React."""
|
||||
html = '<script id="__NEXT_DATA__" type="application/json">{}</script>'
|
||||
assert self.service._detect_js_framework(html) == "react"
|
||||
|
||||
def test_detect_analytics_google(self):
|
||||
"""Test analytics detection for Google Analytics."""
|
||||
html = '<script async src="https://www.googletagmanager.com/gtag/js?id=G-123"></script>'
|
||||
result = self.service._detect_analytics(html)
|
||||
assert result is not None
|
||||
assert "google" in result
|
||||
|
||||
def test_detect_analytics_none(self):
|
||||
"""Test analytics detection when no analytics detected."""
|
||||
html = "<html><body>No analytics here</body></html>"
|
||||
assert self.service._detect_analytics(html) is None
|
||||
|
||||
def test_service_instance_exists(self):
|
||||
"""Test that service singleton is importable."""
|
||||
from app.modules.prospecting.services.enrichment_service import (
|
||||
enrichment_service,
|
||||
)
|
||||
|
||||
assert enrichment_service is not None
|
||||
assert isinstance(enrichment_service, EnrichmentService)
|
||||
@@ -0,0 +1,77 @@
|
||||
# app/modules/prospecting/tests/unit/test_interaction_service.py
|
||||
"""
|
||||
Unit tests for InteractionService.
|
||||
"""
|
||||
|
||||
from datetime import date, timedelta
|
||||
|
||||
import pytest
|
||||
|
||||
from app.modules.prospecting.services.interaction_service import InteractionService
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.prospecting
|
||||
class TestInteractionService:
|
||||
"""Tests for InteractionService."""
|
||||
|
||||
def setup_method(self):
|
||||
self.service = InteractionService()
|
||||
|
||||
def test_create_interaction(self, db, digital_prospect):
|
||||
"""Test creating an interaction."""
|
||||
interaction = self.service.create(
|
||||
db,
|
||||
prospect_id=digital_prospect.id,
|
||||
user_id=1,
|
||||
data={
|
||||
"interaction_type": "call",
|
||||
"subject": "Initial call",
|
||||
"notes": "Discussed website needs",
|
||||
"outcome": "positive",
|
||||
},
|
||||
)
|
||||
|
||||
assert interaction.id is not None
|
||||
assert interaction.interaction_type.value == "call"
|
||||
assert interaction.outcome.value == "positive"
|
||||
|
||||
def test_create_interaction_with_follow_up(self, db, digital_prospect):
|
||||
"""Test creating an interaction with follow-up action."""
|
||||
follow_up_date = date.today() + timedelta(days=7)
|
||||
interaction = self.service.create(
|
||||
db,
|
||||
prospect_id=digital_prospect.id,
|
||||
user_id=1,
|
||||
data={
|
||||
"interaction_type": "meeting",
|
||||
"subject": "Office visit",
|
||||
"next_action": "Send proposal",
|
||||
"next_action_date": str(follow_up_date),
|
||||
},
|
||||
)
|
||||
|
||||
assert interaction.next_action == "Send proposal"
|
||||
|
||||
def test_get_for_prospect(self, db, digital_prospect, interaction):
|
||||
"""Test getting interactions for a prospect."""
|
||||
interactions = self.service.get_for_prospect(db, digital_prospect.id)
|
||||
assert len(interactions) >= 1
|
||||
assert interactions[0].prospect_id == digital_prospect.id
|
||||
|
||||
def test_get_upcoming_actions(self, db, digital_prospect):
|
||||
"""Test getting upcoming follow-up actions."""
|
||||
follow_up_date = date.today() + timedelta(days=3)
|
||||
self.service.create(
|
||||
db,
|
||||
prospect_id=digital_prospect.id,
|
||||
user_id=1,
|
||||
data={
|
||||
"interaction_type": "call",
|
||||
"next_action": "Follow up",
|
||||
"next_action_date": str(follow_up_date),
|
||||
},
|
||||
)
|
||||
|
||||
upcoming = self.service.get_upcoming_actions(db)
|
||||
assert len(upcoming) >= 1
|
||||
59
app/modules/prospecting/tests/unit/test_lead_service.py
Normal file
59
app/modules/prospecting/tests/unit/test_lead_service.py
Normal file
@@ -0,0 +1,59 @@
|
||||
# app/modules/prospecting/tests/unit/test_lead_service.py
|
||||
"""
|
||||
Unit tests for LeadService.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from app.modules.prospecting.services.lead_service import LeadService
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.prospecting
|
||||
class TestLeadService:
|
||||
"""Tests for LeadService."""
|
||||
|
||||
def setup_method(self):
|
||||
self.service = LeadService()
|
||||
|
||||
def test_get_leads_empty(self, db):
|
||||
"""Test getting leads when none exist."""
|
||||
leads, total = self.service.get_leads(db)
|
||||
assert total == 0
|
||||
assert leads == []
|
||||
|
||||
def test_get_leads_with_scored_prospect(self, db, prospect_with_score):
|
||||
"""Test getting leads returns scored prospects."""
|
||||
leads, total = self.service.get_leads(db)
|
||||
assert total >= 1
|
||||
|
||||
def test_get_leads_filter_min_score(self, db, prospect_with_score):
|
||||
"""Test filtering leads by minimum score."""
|
||||
leads, total = self.service.get_leads(db, min_score=70)
|
||||
assert total >= 1 # prospect_with_score has score 72
|
||||
|
||||
leads, total = self.service.get_leads(db, min_score=90)
|
||||
assert total == 0 # No prospect has score >= 90
|
||||
|
||||
def test_get_leads_filter_lead_tier(self, db, prospect_with_score):
|
||||
"""Test filtering leads by tier."""
|
||||
leads, total = self.service.get_leads(db, lead_tier="top_priority")
|
||||
assert total >= 1 # prospect_with_score is top_priority (72)
|
||||
|
||||
def test_get_top_priority(self, db, prospect_with_score):
|
||||
"""Test getting top priority leads."""
|
||||
leads = self.service.get_top_priority(db)
|
||||
assert len(leads) >= 1
|
||||
|
||||
def test_get_quick_wins(self, db, prospect_with_score):
|
||||
"""Test getting quick win leads (score 50-69)."""
|
||||
# prospect_with_score has score 72, not a quick win
|
||||
leads = self.service.get_quick_wins(db)
|
||||
# May be empty if no prospects in 50-69 range
|
||||
assert isinstance(leads, list)
|
||||
|
||||
def test_export_csv(self, db, prospect_with_score):
|
||||
"""Test CSV export returns valid CSV content."""
|
||||
csv_content = self.service.export_csv(db)
|
||||
assert isinstance(csv_content, str)
|
||||
assert "domain" in csv_content.lower() or "score" in csv_content.lower()
|
||||
149
app/modules/prospecting/tests/unit/test_prospect_service.py
Normal file
149
app/modules/prospecting/tests/unit/test_prospect_service.py
Normal file
@@ -0,0 +1,149 @@
|
||||
# app/modules/prospecting/tests/unit/test_prospect_service.py
|
||||
"""
|
||||
Unit tests for ProspectService.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from app.modules.prospecting.exceptions import ProspectNotFoundException
|
||||
from app.modules.prospecting.services.prospect_service import ProspectService
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.prospecting
|
||||
class TestProspectService:
|
||||
"""Tests for ProspectService."""
|
||||
|
||||
def setup_method(self):
|
||||
self.service = ProspectService()
|
||||
|
||||
def test_create_digital_prospect(self, db):
|
||||
"""Test creating a digital prospect."""
|
||||
prospect = self.service.create(
|
||||
db,
|
||||
{"channel": "digital", "domain_name": "example.lu", "source": "domain_scan"},
|
||||
)
|
||||
|
||||
assert prospect.id is not None
|
||||
assert prospect.channel.value == "digital"
|
||||
assert prospect.domain_name == "example.lu"
|
||||
assert prospect.status.value == "pending"
|
||||
|
||||
def test_create_offline_prospect(self, db):
|
||||
"""Test creating an offline prospect."""
|
||||
prospect = self.service.create(
|
||||
db,
|
||||
{
|
||||
"channel": "offline",
|
||||
"business_name": "Test Café",
|
||||
"source": "street",
|
||||
"city": "Luxembourg",
|
||||
},
|
||||
)
|
||||
|
||||
assert prospect.id is not None
|
||||
assert prospect.channel.value == "offline"
|
||||
assert prospect.business_name == "Test Café"
|
||||
assert prospect.city == "Luxembourg"
|
||||
|
||||
def test_create_digital_prospect_with_contacts(self, db):
|
||||
"""Test creating a digital prospect with inline contacts."""
|
||||
prospect = self.service.create(
|
||||
db,
|
||||
{
|
||||
"channel": "digital",
|
||||
"domain_name": "with-contacts.lu",
|
||||
"contacts": [
|
||||
{"contact_type": "email", "value": "info@with-contacts.lu"},
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
assert prospect.id is not None
|
||||
assert len(prospect.contacts) == 1
|
||||
assert prospect.contacts[0].value == "info@with-contacts.lu"
|
||||
|
||||
def test_get_by_id(self, db, digital_prospect):
|
||||
"""Test getting a prospect by ID."""
|
||||
result = self.service.get_by_id(db, digital_prospect.id)
|
||||
assert result.id == digital_prospect.id
|
||||
assert result.domain_name == digital_prospect.domain_name
|
||||
|
||||
def test_get_by_id_not_found(self, db):
|
||||
"""Test getting non-existent prospect raises exception."""
|
||||
with pytest.raises(ProspectNotFoundException):
|
||||
self.service.get_by_id(db, 99999)
|
||||
|
||||
def test_get_by_domain(self, db, digital_prospect):
|
||||
"""Test getting a prospect by domain name."""
|
||||
result = self.service.get_by_domain(db, digital_prospect.domain_name)
|
||||
assert result is not None
|
||||
assert result.id == digital_prospect.id
|
||||
|
||||
def test_get_all_with_pagination(self, db, digital_prospect, offline_prospect):
|
||||
"""Test listing prospects with pagination."""
|
||||
prospects, total = self.service.get_all(db, page=1, per_page=10)
|
||||
assert total >= 2
|
||||
assert len(prospects) >= 2
|
||||
|
||||
def test_get_all_filter_by_channel(self, db, digital_prospect, offline_prospect):
|
||||
"""Test filtering prospects by channel."""
|
||||
digital, count = self.service.get_all(db, channel="digital")
|
||||
assert count >= 1
|
||||
assert all(p.channel.value == "digital" for p in digital)
|
||||
|
||||
def test_get_all_filter_by_status(self, db, digital_prospect):
|
||||
"""Test filtering prospects by status."""
|
||||
active, count = self.service.get_all(db, status="active")
|
||||
assert count >= 1
|
||||
assert all(p.status.value == "active" for p in active)
|
||||
|
||||
def test_get_all_search(self, db, digital_prospect):
|
||||
"""Test searching prospects by domain or business name."""
|
||||
domain = digital_prospect.domain_name
|
||||
results, count = self.service.get_all(db, search=domain[:8])
|
||||
assert count >= 1
|
||||
|
||||
def test_update_prospect(self, db, digital_prospect):
|
||||
"""Test updating a prospect."""
|
||||
updated = self.service.update(
|
||||
db, digital_prospect.id, {"notes": "Updated notes", "status": "contacted"}
|
||||
)
|
||||
assert updated.notes == "Updated notes"
|
||||
assert updated.status.value == "contacted"
|
||||
|
||||
def test_delete_prospect(self, db, digital_prospect):
|
||||
"""Test deleting a prospect."""
|
||||
pid = digital_prospect.id
|
||||
self.service.delete(db, pid)
|
||||
|
||||
with pytest.raises(ProspectNotFoundException):
|
||||
self.service.get_by_id(db, pid)
|
||||
|
||||
def test_create_bulk(self, db):
|
||||
"""Test bulk creating prospects from domain list."""
|
||||
domains = ["bulk1.lu", "bulk2.lu", "bulk3.lu"]
|
||||
created, skipped = self.service.create_bulk(db, domains, source="csv_import")
|
||||
assert created == 3
|
||||
assert skipped == 0
|
||||
|
||||
def test_create_bulk_skips_duplicates(self, db, digital_prospect):
|
||||
"""Test bulk create skips existing domains."""
|
||||
domains = [digital_prospect.domain_name, "new-domain.lu"]
|
||||
created, skipped = self.service.create_bulk(db, domains, source="csv_import")
|
||||
assert created == 1
|
||||
assert skipped == 1
|
||||
|
||||
def test_count_by_status(self, db, digital_prospect, offline_prospect):
|
||||
"""Test counting prospects by status."""
|
||||
counts = self.service.count_by_status(db)
|
||||
assert isinstance(counts, dict)
|
||||
assert counts.get("active", 0) >= 1
|
||||
assert counts.get("pending", 0) >= 1
|
||||
|
||||
def test_count_by_channel(self, db, digital_prospect, offline_prospect):
|
||||
"""Test counting prospects by channel."""
|
||||
counts = self.service.count_by_channel(db)
|
||||
assert isinstance(counts, dict)
|
||||
assert counts.get("digital", 0) >= 1
|
||||
assert counts.get("offline", 0) >= 1
|
||||
89
app/modules/prospecting/tests/unit/test_scoring_service.py
Normal file
89
app/modules/prospecting/tests/unit/test_scoring_service.py
Normal file
@@ -0,0 +1,89 @@
|
||||
# app/modules/prospecting/tests/unit/test_scoring_service.py
|
||||
"""
|
||||
Unit tests for ScoringService.
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from app.modules.prospecting.services.scoring_service import ScoringService
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.prospecting
|
||||
class TestScoringService:
|
||||
"""Tests for ScoringService."""
|
||||
|
||||
def setup_method(self):
|
||||
self.service = ScoringService()
|
||||
|
||||
def test_score_digital_no_ssl(self, db, digital_prospect):
|
||||
"""Test scoring a digital prospect without SSL."""
|
||||
digital_prospect.uses_https = False
|
||||
db.commit()
|
||||
db.refresh(digital_prospect)
|
||||
|
||||
score = self.service.compute_score(db, digital_prospect)
|
||||
assert score.score > 0
|
||||
flags = json.loads(score.reason_flags) if score.reason_flags else []
|
||||
assert "no_ssl" in flags
|
||||
|
||||
def test_score_digital_slow_site(self, db, prospect_with_performance):
|
||||
"""Test scoring a prospect with slow performance."""
|
||||
score = self.service.compute_score(db, prospect_with_performance)
|
||||
assert score.technical_health_score > 0
|
||||
flags = json.loads(score.reason_flags) if score.reason_flags else []
|
||||
assert "slow" in flags or "very_slow" in flags
|
||||
|
||||
def test_score_digital_outdated_cms(self, db, prospect_with_tech):
|
||||
"""Test scoring a prospect with outdated CMS (jQuery counts as legacy)."""
|
||||
# WordPress is not "outdated" but jQuery is legacy
|
||||
score = self.service.compute_score(db, prospect_with_tech)
|
||||
flags = json.loads(score.reason_flags) if score.reason_flags else []
|
||||
assert "legacy_js" in flags
|
||||
|
||||
def test_score_digital_full_prospect(self, db, prospect_full):
|
||||
"""Test scoring a fully enriched prospect (no SSL, slow, outdated CMS)."""
|
||||
score = self.service.compute_score(db, prospect_full)
|
||||
assert score.score >= 70
|
||||
assert score.lead_tier == "top_priority"
|
||||
|
||||
def test_score_offline_no_website(self, db, offline_prospect):
|
||||
"""Test scoring an offline prospect with no website (base score 70)."""
|
||||
score = self.service.compute_score(db, offline_prospect)
|
||||
assert score.score >= 70
|
||||
assert score.lead_tier == "top_priority"
|
||||
flags = json.loads(score.reason_flags) if score.reason_flags else []
|
||||
assert "no_website" in flags
|
||||
|
||||
def test_score_components_max(self, db, prospect_full):
|
||||
"""Test that score components don't exceed their maximums."""
|
||||
score = self.service.compute_score(db, prospect_full)
|
||||
assert score.technical_health_score <= 40
|
||||
assert score.modernity_score <= 25
|
||||
assert score.business_value_score <= 25
|
||||
assert score.engagement_score <= 10
|
||||
assert score.score <= 100
|
||||
|
||||
def test_lead_tier_assignment(self, db, digital_prospect, offline_prospect):
|
||||
"""Test that lead tiers are correctly assigned based on score."""
|
||||
# Digital prospect with website + SSL should be low score
|
||||
score1 = self.service.compute_score(db, digital_prospect)
|
||||
assert score1.lead_tier in ("top_priority", "quick_win", "strategic", "low_priority")
|
||||
|
||||
# Offline prospect with no website should be top_priority
|
||||
score2 = self.service.compute_score(db, offline_prospect)
|
||||
assert score2.lead_tier == "top_priority"
|
||||
|
||||
def test_compute_score_updates_existing(self, db, prospect_with_score):
|
||||
"""Test that recomputing a score updates the existing record."""
|
||||
old_score_id = prospect_with_score.score.id
|
||||
score = self.service.compute_score(db, prospect_with_score)
|
||||
# Should update the same record
|
||||
assert score.id == old_score_id
|
||||
|
||||
def test_compute_all(self, db, digital_prospect, offline_prospect):
|
||||
"""Test batch score computation."""
|
||||
count = self.service.compute_all(db, limit=100)
|
||||
assert count >= 2
|
||||
36
app/modules/prospecting/tests/unit/test_stats_service.py
Normal file
36
app/modules/prospecting/tests/unit/test_stats_service.py
Normal file
@@ -0,0 +1,36 @@
|
||||
# app/modules/prospecting/tests/unit/test_stats_service.py
|
||||
"""
|
||||
Unit tests for StatsService.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from app.modules.prospecting.services.stats_service import StatsService
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.prospecting
|
||||
class TestStatsService:
|
||||
"""Tests for StatsService."""
|
||||
|
||||
def setup_method(self):
|
||||
self.service = StatsService()
|
||||
|
||||
def test_get_overview_empty(self, db):
|
||||
"""Test overview stats with no data."""
|
||||
stats = self.service.get_overview(db)
|
||||
assert "total_prospects" in stats
|
||||
assert stats["total_prospects"] == 0
|
||||
|
||||
def test_get_overview_with_data(self, db, digital_prospect, offline_prospect):
|
||||
"""Test overview stats with data."""
|
||||
stats = self.service.get_overview(db)
|
||||
assert stats["total_prospects"] >= 2
|
||||
assert stats["digital_count"] >= 1
|
||||
assert stats["offline_count"] >= 1
|
||||
|
||||
def test_get_scan_jobs_empty(self, db):
|
||||
"""Test getting scan jobs when none exist."""
|
||||
jobs, total = self.service.get_scan_jobs(db)
|
||||
assert total == 0
|
||||
assert jobs == []
|
||||
Reference in New Issue
Block a user