Spaces:
Sleeping
Sleeping
File size: 10,469 Bytes
9d0739a c8ba8d8 9d0739a c8ba8d8 9d0739a 6516ac8 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 | # tests/test_prospection_cli.py
"""tests/test_prospection_cli.py — export .eml et surface de la CLI."""
from __future__ import annotations
import email
from pathlib import Path
import pytest
from prospection import cli
from prospection.enrich.base import hash_email
from prospection.outreach import compliance, eml
from prospection.store import prospects_db as db
@pytest.fixture(autouse=True)
def tmp_db(tmp_path, monkeypatch):
monkeypatch.setattr(db, "DB_PATH", tmp_path / "prospects.db")
monkeypatch.setenv("PROSPECTION_SENDER_NAME", "Vincent Durey")
monkeypatch.setenv("PROSPECTION_SENDER_EMAIL", "vincent@example.fr")
monkeypatch.setenv("PROSPECTION_OPTOUT_EMAIL", "stop@example.fr")
db.init_db()
def test_render_eml_produces_a_parsable_message():
raw = eml.render_eml("Sujet", "Corps du message", "marie@x.fr")
parsed = email.message_from_bytes(raw)
assert parsed["Subject"] == "Sujet"
assert parsed["To"] == "marie@x.fr"
assert parsed["From"] == "Vincent Durey <vincent@example.fr>"
def test_render_eml_attaches_the_pdf_as_a_second_part(tmp_path):
pdf = tmp_path / "brief.pdf"
pdf.write_bytes(b"%PDF-1.4 fake")
parsed = email.message_from_bytes(eml.render_eml("S", "B", "m@x.fr", attachment=pdf))
names = [part.get_filename() for part in parsed.walk()]
assert "brief.pdf" in names
def test_render_eml_has_no_tracking_pixel_and_no_html_part():
parsed = email.message_from_bytes(eml.render_eml("S", "B", "m@x.fr"))
types = [part.get_content_type() for part in parsed.walk()]
assert "text/html" not in types
def test_cli_rejects_an_unknown_command(capsys):
with pytest.raises(SystemExit):
cli.main(["inexistant"])
def test_cli_report_runs_on_an_empty_database(capsys):
assert cli.main(["report"]) == 0
assert "Tier 1" in capsys.readouterr().out
def test_cli_optout_add_records_the_hash_and_is_idempotent():
assert cli.main(["optout", "add", "marie@x.fr"]) == 0
assert cli.main(["optout", "add", "marie@x.fr"]) == 0
with db.connect() as conn:
assert conn.execute("SELECT COUNT(*) c FROM suppression").fetchone()["c"] == 1
def _approved_touch(email, **contact_overrides):
"""Compte + contact + touche approuvée, prêts à l'export."""
account_id = db.upsert_account({"no_amf": "GP-1", "nom": "B", "statut": "vivant",
"independante": 1})
row = {"account_id": account_id, "prenom": "Marie", "nom": "Durand",
"titre": "Gérante", "seniorite": "c-suite", "email": email,
"email_sha256": hash_email(email) if email else None,
"email_status": "probable", "telephone": None, "linkedin_url": None,
"source": "pattern", "confidence": "probable",
"retrieved_at": "2026-08-29T09:00:00", "is_generic": 0}
row.update(contact_overrides)
contact_id = db.upsert_contact(row)
touch_id = db.upsert_touch({"contact_id": contact_id, "channel": "email",
"template_id": "email_1", "subject": "S", "body": "B",
"status": "approved"})
return account_id, contact_id, touch_id
def test_cli_campaign_export_refuses_a_contact_who_opted_out(tmp_path):
"""La dernière barrière avant qu'un fichier quitte la machine. Sans ce test,
supprimer entièrement l'appel à `assert_sendable` ne ferait échouer aucun
test de la suite."""
_approved_touch("marie.durand@x.fr")
compliance.record_optout("marie.durand@x.fr")
assert cli.main(["campaign", "export", "--out", str(tmp_path)]) == 0
assert list(tmp_path.glob("*.eml")) == []
def test_cli_campaign_export_writes_an_eml_for_a_clean_contact(tmp_path):
"""Le pendant du test précédent : sans lui, une barrière qui refuse tout
passerait pour une barrière qui fonctionne."""
_approved_touch("marie.durand@x.fr")
assert cli.main(["campaign", "export", "--out", str(tmp_path)]) == 0
assert len(list(tmp_path.glob("*.eml"))) == 1
def test_cli_campaign_export_skips_a_contact_with_no_address(tmp_path):
"""Un contact issu de LinkedIn n'a pas d'adresse et n'est pas générique :
rien en amont ne l'écarte, et `EmailMessage` écrirait « To: None »."""
_approved_touch(None)
assert cli.main(["campaign", "export", "--out", str(tmp_path)]) == 0
assert list(tmp_path.glob("*.eml")) == []
def test_render_eml_refuses_an_empty_recipient():
with pytest.raises(ValueError):
eml.render_eml("S", "B", "")
def test_cli_campaign_export_refuses_a_draft_that_is_not_approved(tmp_path):
account_id = db.upsert_account({"no_amf": "GP-1", "nom": "B", "statut": "vivant",
"independante": 1})
contact_id = db.upsert_contact({"account_id": account_id, "email": "m@x.fr",
"email_sha256": "d" * 64, "email_status": "probable",
"source": "pattern", "confidence": "probable",
"retrieved_at": "2026-08-29T09:00:00", "is_generic": 0})
db.upsert_touch({"contact_id": contact_id, "channel": "email", "template_id": "email_1",
"subject": "S", "body": "B", "status": "draft"})
assert cli.main(["campaign", "export", "--out", str(tmp_path)]) == 0
assert list(tmp_path.glob("*.eml")) == []
def test_contact_rank_puts_the_decision_maker_first():
manager = {"titre": "Gérante fondatrice", "email_status": "probable"}
research = {"titre": "Head of Research", "email_status": "verified"}
compliance_officer = {"titre": "RCCI", "email_status": "verified"}
ranked = sorted([compliance_officer, research, manager], key=cli._contact_rank)
assert [c["titre"] for c in ranked] == ["Gérante fondatrice", "Head of Research", "RCCI"]
def test_contact_rank_breaks_ties_on_address_quality():
"""À rang égal, l'adresse vérifiée passe devant la probable : la règle « un
seul contact » rend ce départage décisif."""
probable = {"titre": "Head of Research", "email_status": "probable"}
verified = {"titre": "Head of Research", "email_status": "verified"}
assert sorted([probable, verified], key=cli._contact_rank)[0] is verified
def _tier1_ready_account(no_amf: str = "GP-1") -> int:
"""Un compte que `score --final` promeut réellement en Tier 1.
Sans poche actions, sans position confirmée et sans contact nommé, un compte
finit en Tier 0 quoi qu'il arrive : un test du veto de démo construit sur une
coquille passerait sans rien vérifier.
"""
account_id = db.upsert_account({
"no_amf": no_amf, "nom": "Boutique Gestion SAS", "statut": "vivant",
"independante": 1, "effectif": 18, "type_activite": "Actions",
"dirigeants": ["Marie Durand"]})
holding_id = db.upsert_holding({
"account_id": account_id, "fund_name": "Fonds Actions Internationales",
"raw_label": "Microsoft Corp", "ticker": "MSFT", "weight_pct": 5.4,
"as_of": "2026-07-31", "source_url": "https://x.fr/r.pdf",
"confidence": "probable", "retrieved_at": "2026-08-29T09:00:00"})
db.confirm_holding(holding_id, "2026-08-29T09:00:00")
db.upsert_contact({
"account_id": account_id, "prenom": "Marie", "nom": "Durand",
"titre": "Gérante fondatrice", "seniorite": "c-suite",
"email": "marie.durand@x.fr", "email_sha256": hash_email("marie.durand@x.fr"),
"email_status": "probable", "source": "pattern", "confidence": "probable",
"retrieved_at": "2026-08-29T09:00:00", "is_generic": 0})
return account_id
def test_best_unconfirmed_holding_is_the_heaviest_line_nobody_confirmed():
"""`universe_inferred` signifie exactement qu'aucune position n'est
confirmée. Sans cet accesseur, `campaign draft` passait `confirmed[0]` —
donc `None` — et le prompt présentait la chaîne « None » comme un fait."""
account_id = db.upsert_account({"no_amf": "GP-2", "nom": "B2", "statut": "vivant",
"independante": 1})
for label, weight in (("Apple Inc", 2.0), ("Microsoft Corp", 7.5)):
db.upsert_holding({"account_id": account_id, "fund_name": "Fonds A",
"raw_label": label, "ticker": None, "weight_pct": weight,
"as_of": "2026-07-31", "source_url": "https://x.fr/r.pdf",
"confidence": "probable",
"retrieved_at": "2026-08-29T09:00:00"})
heaviest = db.upsert_holding({"account_id": account_id, "fund_name": "Fonds A",
"raw_label": "Nvidia Corp", "ticker": "NVDA",
"weight_pct": 9.9, "as_of": "2026-07-31",
"source_url": "https://x.fr/r.pdf",
"confidence": "probable",
"retrieved_at": "2026-08-29T09:00:00"})
db.confirm_holding(heaviest, "2026-08-29T09:00:00")
best = db.best_unconfirmed_holding(account_id)
assert best["raw_label"] == "Microsoft Corp"
def test_a_rescore_promotes_an_account_with_nothing_against_it():
"""Le témoin du test suivant : sans lui, un veto qui refuse tout passerait
pour un veto qui fonctionne."""
account_id = _tier1_ready_account()
assert cli.main(["score", "--final"]) == 0
assert db.get_account_by_id(account_id)["tier"] == 1
def test_a_blocked_demo_survives_a_rescore():
"""`assign_final` ne sait rien de la démo : sans persistance, le rescore
suivant annulerait le constat qu'aucun brief n'est livrable."""
account_id = _tier1_ready_account()
db.block_demo(account_id, "2026-08-29T09:00:00")
assert cli.main(["score", "--final"]) == 0
assert db.get_account_by_id(account_id)["tier"] == 2
def test_build_demo_blocks_the_account_when_no_brief_ships(tmp_path):
"""La marque doit être posée par la démo elle-même, pas seulement lisible."""
from prospection.demo import build_demo as demo_module
account_id = _tier1_ready_account()
assert demo_module.build_demo(
account_id, tmp_path,
brief_runner=lambda ticker: {"status": "PARTIAL", "evidence_coverage": {}},
pdf_builder=lambda brief, ticker: b"%PDF") is None
assert db.get_account_by_id(account_id)["demo_blocked_at"]
assert cli.main(["score", "--final"]) == 0
assert db.get_account_by_id(account_id)["tier"] == 2
|