""" Endpoints marketing (spec « Stratégie LLM par abonnement ») : - audiences avec compteurs (liste d'attente, inactifs, par plan, tous), - envoi test obligatoire avant tout envoi en masse, - désabonnés exclus de tout envoi, - historique journalisé (envoyés/échecs), - désabonnement public. """ import asyncio import json import pytest from pathlib import Path from fastapi.testclient import TestClient ADMIN_LOGIN_URL = "/api/v1/admin/login" AUDIENCES_URL = "/api/v1/admin/marketing/audiences" SEND_URL = "/api/v1/admin/marketing/email/send" HISTORY_URL = "/api/v1/admin/marketing/email/history" UNSUBSCRIBE_URL = "/api/v1/marketing/unsubscribe" SUBJECT = "Relance test" HTML = "

Bonjour !

" def _write_json(path: Path, data) -> None: path.parent.mkdir(parents=True, exist_ok=True) path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8") @pytest.fixture def marketing_env(tmp_path: Path, monkeypatch): """Fichiers data isolés + service email simulé.""" import routes.admin_routes as admin_routes_mod import routes.waitlist_routes as waitlist_mod import services.email_service as email_svc history_file = tmp_path / "marketing_emails.json" unsub_file = tmp_path / "unsubscribes.json" waitlist_file = tmp_path / "waitlist.json" monkeypatch.setattr(admin_routes_mod, "MARKETING_HISTORY_FILE", history_file) monkeypatch.setattr(admin_routes_mod, "UNSUBSCRIBES_FILE", unsub_file) monkeypatch.setattr(waitlist_mod, "WAITLIST_FILE", waitlist_file) monkeypatch.setattr(admin_routes_mod, "_active_campaign_hashes", set()) sent_to: list[str] = [] captured_headers: dict[str, dict] = {} async def _fake_send(to: str, subject: str, body: str, extra_headers=None) -> bool: sent_to.append(to) captured_headers[to] = extra_headers or {} return True async def _failing_send(to: str, subject: str, body: str, extra_headers=None) -> bool: return False monkeypatch.setattr(email_svc, "send_email_async", _fake_send) monkeypatch.setattr(email_svc, "is_smtp_configured", lambda: True) def set_sender(fn): monkeypatch.setattr(email_svc, "send_email_async", fn) return { "history_file": history_file, "unsub_file": unsub_file, "waitlist_file": waitlist_file, "sent_to": sent_to, "captured_headers": captured_headers, "fail_mode": lambda: set_sender(_failing_send), "set_sender": set_sender, } @pytest.fixture def client(marketing_env, monkeypatch, tmp_path: Path): """TestClient avec authentification JSON (sans base de données).""" import services.auth_service as auth_svc from middleware.rate_limiting import RateLimitManager monkeypatch.setattr(auth_svc, "USERS_FILE", tmp_path / "users.json") monkeypatch.setattr(auth_svc, "USE_DATABASE", False) monkeypatch.setattr(auth_svc, "DATABASE_AVAILABLE", False) async def _check_request_allow(self, request): return True, "ok", "test" async def _check_translation_allow(self, request, file_size_mb=0): return True, "ok" monkeypatch.setattr(RateLimitManager, "check_request", _check_request_allow) monkeypatch.setattr(RateLimitManager, "check_translation", _check_translation_allow) from main import app return TestClient(app, raise_server_exceptions=True) @pytest.fixture def admin_token(client, monkeypatch): import routes.admin_routes as admin_routes_mod monkeypatch.setattr(admin_routes_mod, "ADMIN_USERNAME", "admin") monkeypatch.setattr(admin_routes_mod, "ADMIN_PASSWORD", "admin-secret") monkeypatch.setattr(admin_routes_mod, "ADMIN_PASSWORD_HASH", None) r = client.post(ADMIN_LOGIN_URL, json={"password": "admin-secret"}) assert r.status_code == 200, r.text return r.json()["access_token"] @pytest.fixture def auth_headers(admin_token): return {"Authorization": f"Bearer {admin_token}"} def _seed_users(tmp_path: Path) -> Path: users = { "u-pro": { "email": "pro@example.com", "name": "Pro User", "plan": "pro", "docs_translated_this_month": 5, }, "u-idle": { "email": "idle@example.com", "name": "Idle User", "plan": "starter", "docs_translated_this_month": 0, }, } path = tmp_path / "users.json" _write_json(path, users) return path # --------------------------------------------------------------------------- # Audiences # --------------------------------------------------------------------------- def test_audiences_counts_and_unsubscribed_excluded( client, auth_headers, marketing_env, tmp_path ): _seed_users(tmp_path) _write_json( marketing_env["waitlist_file"], [ {"email": "lead1@example.com", "interest": "pdf", "joined_at": "2026-09-01"}, {"email": "lead2@example.com", "interest": None, "joined_at": "2026-09-02"}, ], ) _write_json(marketing_env["unsub_file"], [{"email": "lead2@example.com"}]) r = client.get(AUDIENCES_URL, headers=auth_headers) assert r.status_code == 200, r.text data = r.json()["data"] assert data["audiences"]["waitlist"] == 1 # lead2 désabonné → exclu assert data["audiences"]["plan:pro"] == 1 assert data["audiences"]["inactive_30"] == 1 # idle@example.com assert data["audiences"]["all_users"] == 2 assert data["unsubscribed_count"] == 1 def test_audiences_requires_admin(client): assert client.get(AUDIENCES_URL).status_code == 401 # --------------------------------------------------------------------------- # Envoi test + garde-fou « test avant envoi réel » # --------------------------------------------------------------------------- def _post_send(client, auth_headers, **overrides): payload = { "audience": "waitlist", "subject": SUBJECT, "html": HTML, "test_mode": True, } payload.update(overrides) return client.post(SEND_URL, json=payload, headers=auth_headers) def test_test_send_writes_history(client, auth_headers, marketing_env): _write_json( marketing_env["waitlist_file"], [{"email": "lead1@example.com"}], ) r = _post_send(client, auth_headers, test_email="admin@example.com") assert r.status_code == 200, r.text assert r.json()["data"]["sent"] == 1 assert marketing_env["sent_to"] == ["admin@example.com"] entries = json.loads(marketing_env["history_file"].read_text(encoding="utf-8")) assert len(entries) == 1 assert entries[0]["test_mode"] is True assert entries[0]["sent_count"] == 1 assert entries[0]["content_hash"] def test_mass_send_requires_matching_test_first(client, auth_headers, marketing_env): _write_json(marketing_env["waitlist_file"], [{"email": "lead1@example.com"}]) r = _post_send(client, auth_headers, test_mode=False) assert r.status_code == 400 assert r.json()["error"] == "TEST_SEND_REQUIRED" assert marketing_env["sent_to"] == [] def test_mass_send_after_test_excludes_unsubscribed(client, auth_headers, marketing_env): _write_json( marketing_env["waitlist_file"], [ {"email": "lead1@example.com"}, {"email": "gone@example.com"}, ], ) _write_json(marketing_env["unsub_file"], [{"email": "gone@example.com"}]) # Envoi test au contenu identique (préalable obligatoire) assert _post_send(client, auth_headers, test_email="admin@example.com").status_code == 200 marketing_env["sent_to"].clear() r = _post_send(client, auth_headers, test_mode=False) assert r.status_code == 202, r.text assert r.json()["data"]["queued"] == 1 # gone@example.com exclu de la file def test_bulk_executor_logs_history_and_excludes_unsubscribed(marketing_env): """L'exécuteur d'envoi de fond journalise envoyés/échecs et n'écrit jamais à un désabonné (résolution d'audience + envoi réel).""" import routes.admin_routes as admin_routes_mod _write_json( marketing_env["waitlist_file"], [ {"email": "lead1@example.com"}, {"email": "gone@example.com"}, ], ) _write_json(marketing_env["unsub_file"], [{"email": "gone@example.com"}]) recipients = admin_routes_mod._resolve_audience("waitlist") assert [r["email"] for r in recipients] == ["lead1@example.com"] entry = {"id": "x", "audience": "waitlist", "test_mode": False, "recipients_total": len(recipients), "sent_count": 0, "failed_count": 0, "failed": [], "sent_at": "2026-09-05T00:00:00+00:00"} asyncio.run( admin_routes_mod._execute_bulk_send(SUBJECT, HTML, recipients, entry, "admin") ) assert marketing_env["sent_to"] == ["lead1@example.com"] entries = json.loads(marketing_env["history_file"].read_text(encoding="utf-8")) assert entries[-1]["sent_count"] == 1 assert entries[-1]["failed_count"] == 0 assert "completed_at" in entries[-1] def test_bulk_executor_continues_after_individual_smtp_failure(marketing_env): """Un échec SMTP individuel n'interrompt pas l'envoi (jamais d'erreur 500).""" import routes.admin_routes as admin_routes_mod async def _flaky_send(to: str, subject: str, body: str, extra_headers=None) -> bool: if to == "bad@example.com": raise RuntimeError("SMTP down") marketing_env["sent_to"].append(to) return True marketing_env["set_sender"](_flaky_send) _write_json( marketing_env["waitlist_file"], [{"email": "bad@example.com"}, {"email": "good@example.com"}], ) recipients = admin_routes_mod._resolve_audience("waitlist") entry = {"id": "y", "audience": "waitlist", "test_mode": False, "recipients_total": len(recipients), "sent_count": 0, "failed_count": 0, "failed": [], "sent_at": "2026-09-05T00:00:00+00:00"} asyncio.run( admin_routes_mod._execute_bulk_send(SUBJECT, HTML, recipients, entry, "admin") ) assert entry["sent_count"] == 1 assert entry["failed_count"] == 1 assert entry["failed"][0]["email"] == "bad@example.com" assert marketing_env["sent_to"] == ["good@example.com"] def test_unsubscribed_via_public_link_is_excluded(client, auth_headers, marketing_env): _write_json(marketing_env["waitlist_file"], [{"email": "bye@example.com"}]) r = client.get(UNSUBSCRIBE_URL, params={"email": "bye@example.com"}) assert r.status_code == 200 unsub = json.loads(marketing_env["unsub_file"].read_text(encoding="utf-8")) assert [u["email"] for u in unsub] == ["bye@example.com"] # Audiences vides → l'envoi est refusé proprement (pas d'erreur 500). r2 = _post_send(client, auth_headers, test_mode=False) assert r2.status_code == 400 assert r2.json()["error"] in ("AUDIENCE_EMPTY", "TEST_SEND_REQUIRED") # --------------------------------------------------------------------------- # Historique + résilience SMTP # --------------------------------------------------------------------------- def test_history_endpoint_returns_entries(client, auth_headers, marketing_env): _write_json( marketing_env["history_file"], [ { "id": "abc", "sent_at": "2026-09-05T10:00:00+00:00", "audience": "waitlist", "subject": SUBJECT, "test_mode": True, "recipients_total": 1, "sent_count": 1, "failed_count": 0, "failed": [], } ], ) r = client.get(HISTORY_URL, headers=auth_headers) assert r.status_code == 200 entries = r.json()["data"] assert len(entries) == 1 assert entries[0]["id"] == "abc" def test_bulk_failure_does_not_raise_and_is_logged(client, auth_headers, marketing_env): marketing_env["fail_mode"]() _write_json(marketing_env["waitlist_file"], [{"email": "lead1@example.com"}]) assert _post_send(client, auth_headers, test_email="admin@example.com").status_code == 502 entry = json.loads(marketing_env["history_file"].read_text(encoding="utf-8"))[0] assert entry["sent_count"] == 0 assert entry["failed_count"] == 1 marketing_env["sent_to"].clear() # L'envoi test ayant échoué, l'envoi réel reste bloqué. r = _post_send(client, auth_headers, test_mode=False) assert r.status_code == 400 assert r.json()["error"] == "TEST_SEND_REQUIRED" # --------------------------------------------------------------------------- # En-têtes de délivrabilité, verrou anti-simultané, re-vérification des désabonnés # --------------------------------------------------------------------------- def test_bulk_send_carries_list_unsubscribe_headers(marketing_env): """Les emails marketing embarquent List-Unsubscribe / List-Unsubscribe-Post.""" import routes.admin_routes as admin_routes_mod _write_json(marketing_env["waitlist_file"], [{"email": "lead1@example.com"}]) recipients = admin_routes_mod._resolve_audience("waitlist") entry = {"id": "h1", "audience": "waitlist", "test_mode": False, "recipients_total": 1, "sent_count": 0, "failed_count": 0, "failed": [], "content_hash": "abc", "sent_at": "t"} asyncio.run( admin_routes_mod._execute_bulk_send(SUBJECT, HTML, recipients, entry, "admin") ) headers = marketing_env["captured_headers"]["lead1@example.com"] assert headers["List-Unsubscribe"].startswith(" bool: if to == "a@example.com": # Se désabonne pendant que la campagne est en cours. with admin_routes_mod._marketing_lock: entries = admin_routes_mod._load_unsubscribes() entries.append({"email": "b@example.com", "unsubscribed_at": "t"}) admin_routes_mod._save_unsubscribes(entries) marketing_env["sent_to"].append(to) return True marketing_env["set_sender"](_slow_send) recipients = admin_routes_mod._resolve_audience("waitlist") entry = {"id": "h2", "audience": "waitlist", "test_mode": False, "recipients_total": len(recipients), "sent_count": 0, "failed_count": 0, "failed": [], "content_hash": "xyz", "sent_at": "t"} asyncio.run( admin_routes_mod._execute_bulk_send(SUBJECT, HTML, recipients, entry, "admin") ) assert marketing_env["sent_to"] == ["a@example.com"] # b est passé en désabonné assert entry["sent_count"] == 1 assert entry["skipped_unsubscribed"] == 1 assert entry["status"] == "completed" def test_unsubscribe_page_escapes_email(client, marketing_env): r = client.get(UNSUBSCRIBE_URL, params={"email": "@x.com"}) assert r.status_code == 200 body = r.text assert "