""" Génération d'email par IA dans la page Marketing (spec « Fournisseur par palier IA ») : prompt construit depuis MARKETING_PLAN.md, analyse robuste de la sortie, nettoyage basique du HTML, aucun envoi automatique. """ import json import pytest from pathlib import Path import routes.admin_routes as admin_routes_mod ADMIN_LOGIN_URL = "/api/v1/admin/login" GENERATE_URL = "/api/v1/admin/marketing/email/generate" FAKE_PLAN = ( "# Plan Marketing — Office Translator (Wordly.art)\n\n" "## 1. Positionnement & Proposition de Valeur\n" "Office Translator (marque Wordly.art) traduit Word, Excel, PowerPoint et " "PDF en préservant la mise en page. USP : « Traduit en place. Zéro perte " "de mise en page. »\n\n" "## 2. Tarification\n" "| Plan | Mensuel |\n| Free | 0 € |\n| Starter | 9 € |\n| Pro | 19 € |\n\n" "## 3. Actifs Marketing en Place\n" "(cette section ne doit PAS figurer dans le prompt)\n" ) @pytest.fixture def promo_file(tmp_path: Path, monkeypatch) -> Path: import services.payment_service as payment_svc path = tmp_path / "promo_codes.json" monkeypatch.setattr(payment_svc, "PROMO_CODES_FILE", path) return path @pytest.fixture def gen_env(tmp_path: Path, monkeypatch, promo_file): """Plan marketing isolé + aucune clé par défaut (chaque test choisit).""" plan_file = tmp_path / "MARKETING_PLAN.md" plan_file.write_text(FAKE_PLAN, encoding="utf-8") monkeypatch.setattr(admin_routes_mod, "MARKETING_PLAN_FILE", plan_file) monkeypatch.delenv("OPENROUTER_API_KEY", raising=False) monkeypatch.delenv("ZHIPU_API_KEY", raising=False) monkeypatch.delenv("DEEPSEEK_API_KEY", raising=False) return {"plan_file": plan_file} @pytest.fixture def client(gen_env, monkeypatch, tmp_path: Path): 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 fastapi.testclient import TestClient from main import app return TestClient(app, raise_server_exceptions=True) @pytest.fixture def admin_headers(client, monkeypatch): 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 {"Authorization": f"Bearer {r.json()['access_token']}"} def _with_openrouter_key(monkeypatch): from routes.admin_routes import SettingsConfig monkeypatch.setattr( admin_routes_mod, "load_settings", lambda: SettingsConfig.model_validate({"openrouter": {"api_key": "sk-or-test"}}), raising=True, ) # --------------------------------------------------------------------------- def test_generate_requires_known_channel(client, admin_headers, gen_env): r = client.post( GENERATE_URL, json={"provider": "mystery", "brief": "Relance d'activation."}, headers=admin_headers, ) assert r.status_code == 400 assert r.json()["error"] == "UNKNOWN_CHANNEL" def test_generate_requires_configured_key(client, admin_headers, gen_env, monkeypatch): monkeypatch.setattr( admin_routes_mod, "load_settings", lambda: admin_routes_mod.SettingsConfig(), raising=True, ) r = client.post( GENERATE_URL, json={"provider": "openrouter", "brief": "Relance d'activation."}, headers=admin_headers, ) assert r.status_code == 400 assert r.json()["error"] == "PROVIDER_NOT_CONFIGURED" def test_generate_builds_prompt_from_marketing_plan(client, admin_headers, gen_env, monkeypatch): """Le prompt système embarque le plan marketing (positionnement + tarifs) et le brief ; les sections suivantes du plan n'y figurent pas.""" _with_openrouter_key(monkeypatch) captured: dict = {} async def _fake_llm(admin_cfg, provider, model, system_prompt, user_prompt): captured["system"] = system_prompt captured["user"] = user_prompt captured["provider"], captured["model"] = provider, model return json.dumps({ "subject": "Votre document vous attend", "html": "
Terminez votre traduction.
", }) monkeypatch.setattr(admin_routes_mod, "_call_llm_chat", _fake_llm) r = client.post( GENERATE_URL, json={"provider": "openrouter", "brief": "Relance des inscrits de la liste d'attente."}, headers=admin_headers, ) assert r.status_code == 200, r.text assert "Wordly.art" in captured["system"] assert "Traduit en place" in captured["system"] assert "19 €" in captured["system"] assert "Actifs Marketing" not in captured["system"] assert "liste d'attente" in captured["user"] assert captured["model"] # modèle par défaut du canal def test_generate_parses_fenced_json(client, admin_headers, gen_env, monkeypatch): _with_openrouter_key(monkeypatch) async def _fake_llm(admin_cfg, provider, model, system_prompt, user_prompt): return ( "```json\n" '{"subject": "Offre de lancement", "html": "Bonjour !
"}\n' "```" ) monkeypatch.setattr(admin_routes_mod, "_call_llm_chat", _fake_llm) r = client.post( GENERATE_URL, json={"provider": "openrouter", "brief": "b"}, headers=admin_headers, ) assert r.status_code == 200 data = r.json()["data"] assert data["subject"] == "Offre de lancement" assert data["html"] == "Bonjour !
" def test_generate_falls_back_to_title(client, admin_headers, gen_env, monkeypatch): """Sortie non JSON : repliReprenez votre traduction.
" ) monkeypatch.setattr(admin_routes_mod, "_call_llm_chat", _fake_llm) r = client.post( GENERATE_URL, json={"provider": "openrouter", "brief": "b"}, headers=admin_headers, ) assert r.status_code == 200 data = r.json()["data"] assert data["subject"] == "Relance" assert "Reprenez votre traduction." in data["html"] def test_generate_unparseable_output_returns_502(client, admin_headers, gen_env, monkeypatch): _with_openrouter_key(monkeypatch) async def _fake_llm(admin_cfg, provider, model, system_prompt, user_prompt): return "Je ne peux pas répondre à cela." monkeypatch.setattr(admin_routes_mod, "_call_llm_chat", _fake_llm) r = client.post( GENERATE_URL, json={"provider": "openrouter", "brief": "b"}, headers=admin_headers, ) assert r.status_code == 502 assert r.json()["error"] == "AI_GENERATION_FAILED" def test_generate_llm_error_returns_502(client, admin_headers, gen_env, monkeypatch): _with_openrouter_key(monkeypatch) async def _fake_llm(admin_cfg, provider, model, system_prompt, user_prompt): raise RuntimeError("upstream timeout") monkeypatch.setattr(admin_routes_mod, "_call_llm_chat", _fake_llm) r = client.post( GENERATE_URL, json={"provider": "openrouter", "brief": "b"}, headers=admin_headers, ) assert r.status_code == 502 assert r.json()["error"] == "AI_GENERATION_FAILED" def test_generate_sanitizes_html(client, admin_headers, gen_env, monkeypatch): """Nettoyage basique : pas de " "x", }) monkeypatch.setattr(admin_routes_mod, "_call_llm_chat", _fake_llm) r = client.post( GENERATE_URL, json={"provider": "openrouter", "brief": "b"}, headers=admin_headers, ) assert r.status_code == 200 html = r.json()["data"]["html"] assert "