feat(prospecting): add complete prospecting module for lead discovery and scoring
Some checks failed
CI / pytest (push) Failing after 48m31s
CI / docs (push) Has been skipped
CI / deploy (push) Has been skipped
CI / ruff (push) Successful in 11s
CI / validate (push) Successful in 23s
CI / dependency-scanning (push) Successful in 28s

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:
2026-02-28 00:59:47 +01:00
parent a709adaee8
commit 6d6eba75bf
79 changed files with 7551 additions and 0 deletions

View File

@@ -0,0 +1 @@
# app/modules/prospecting/tests/unit/__init__.py

View 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

View File

@@ -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)

View File

@@ -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

View 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()

View 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

View 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

View 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 == []