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