""" 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 : repli = sujet, corps = HTML.""" _with_openrouter_key(monkeypatch) async def _fake_llm(admin_cfg, provider, model, system_prompt, user_prompt): return ( "<html><head><title>Relance" "

Reprenez 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 "x

"}) monkeypatch.setattr(admin_routes_mod, "_call_llm_chat", _fake_llm) r = client.post( GENERATE_URL, json={"provider": "openrouter", "brief": "b", "promo_code": "rentree10"}, headers=admin_headers, ) assert r.status_code == 200 assert "RENTREE10" in captured["system"] assert "-10 %" in captured["system"] def test_generate_uses_requested_channel_and_model(client, admin_headers, gen_env, monkeypatch): from routes.admin_routes import SettingsConfig monkeypatch.setattr( admin_routes_mod, "load_settings", lambda: SettingsConfig.model_validate({"zhipu": {"api_key": "zk"}}), raising=True, ) captured: dict = {} async def _fake_llm(admin_cfg, provider, model, system_prompt, user_prompt): captured["provider"], captured["model"] = provider, model return json.dumps({"subject": "s", "html": "

x

"}) monkeypatch.setattr(admin_routes_mod, "_call_llm_chat", _fake_llm) r = client.post( GENERATE_URL, json={"provider": "zhipu", "model": "glm-5.3-flash", "brief": "b"}, headers=admin_headers, ) assert r.status_code == 200 assert captured["provider"] == "zhipu" assert captured["model"] == "glm-5.3-flash" def test_generate_requires_admin(client, gen_env): r = client.post(GENERATE_URL, json={"provider": "openrouter", "brief": "b"}) assert r.status_code == 401 # --------------------------------------------------------------------------- # Helpers d'analyse (tests unitaires directs) # --------------------------------------------------------------------------- def test_parse_generated_email_variants(): parse = admin_routes_mod._parse_generated_email assert parse('{"subject":"a","html":"

b

"}') == ("a", "

b

") assert parse('Blabla\n{"subject":"a","html":"

b

"}\nFin') == ("a", "

b

") assert parse("pas de contenu utile") is None assert parse("") is None # JSON sans html → None (sujet ET html requis) assert parse('{"subject":"a"}') is None # --------------------------------------------------------------------------- # Correctifs de relecture : plan manquant, promo inconnue, CTA, RTL, bornes # --------------------------------------------------------------------------- def test_generate_without_marketing_plan_is_refused(client, admin_headers, gen_env, monkeypatch): """Plan marketing absent : erreur claire, jamais de chiffres inventés.""" _with_openrouter_key(monkeypatch) monkeypatch.setattr( admin_routes_mod, "MARKETING_PLAN_FILE", gen_env["plan_file"].with_suffix(".absent") ) r = client.post( GENERATE_URL, json={"provider": "openrouter", "brief": "b"}, headers=admin_headers, ) assert r.status_code == 503 assert r.json()["error"] == "MARKETING_PLAN_MISSING" def test_generate_unknown_promo_returns_promo_not_found( client, admin_headers, gen_env, monkeypatch ): """Code promo inexistant ou inactif : 400 explicite, pas de génération silencieuse sans offre.""" _with_openrouter_key(monkeypatch) async def _fake_llm(*a, **kw): # ne doit PAS être appelé raise AssertionError("l'appel LLM ne doit pas avoir lieu") monkeypatch.setattr(admin_routes_mod, "_call_llm_chat", _fake_llm) r = client.post( GENERATE_URL, json={"provider": "openrouter", "brief": "b", "promo_code": "NEXISTEPAS"}, headers=admin_headers, ) assert r.status_code == 400 assert r.json()["error"] == "PROMO_NOT_FOUND" def test_generate_prompt_contains_pricing_cta_link( client, admin_headers, gen_env, monkeypatch ): """Avec une réduction : le prompt impose un bouton vers /pricing?promo=CODE.""" _with_openrouter_key(monkeypatch) import services.payment_service as payment_svc payment_svc.save_promo_codes([{ "code": "RENTREE10", "active": True, "type": "percent", "value": 10, "plans": ["all"], "max_redemptions": None, "times_used": 0, "expires_at": None, "stripe_coupon_id": None, "stripe_promotion_code_id": None, "stripe_mode": "local_only", "created_at": "t", "deactivated_at": None, }]) monkeypatch.setenv("FRONTEND_URL", "https://wordly.art") captured: dict = {} async def _fake_llm(admin_cfg, provider, model, system_prompt, user_prompt): captured["system"] = system_prompt return json.dumps({"subject": "s", "html": "

x

"}) monkeypatch.setattr(admin_routes_mod, "_call_llm_chat", _fake_llm) r = client.post( GENERATE_URL, json={"provider": "openrouter", "brief": "b", "promo_code": "rentree10"}, headers=admin_headers, ) assert r.status_code == 200 assert "https://wordly.art/pricing?promo=RENTREE10" in captured["system"] def test_generate_wraps_rtl_language_in_dir_rtl( client, admin_headers, gen_env, monkeypatch ): """Arabe et persan : le HTML généré est enveloppé dans
.""" _with_openrouter_key(monkeypatch) async def _fake_llm(admin_cfg, provider, model, system_prompt, user_prompt): assert 'dir="rtl"' in system_prompt # consigne donnée au modèle return json.dumps({"subject": "s", "html": "

مرحبا

"}) monkeypatch.setattr(admin_routes_mod, "_call_llm_chat", _fake_llm) for language in ("العربية", "فارسی"): r = client.post( GENERATE_URL, json={"provider": "openrouter", "brief": "b", "language": language}, headers=admin_headers, ) assert r.status_code == 200 html = r.json()["data"]["html"] assert html.startswith('
') assert html.endswith("
") def test_generate_field_bounds_are_enforced(client, admin_headers, gen_env): # Les bornes Pydantic sont appliquées (422 brut ou 400 via le gestionnaire # global RequestValidationError de l'application). r = client.post( GENERATE_URL, json={"provider": "openrouter", "brief": "b", "tone": "x" * 60}, headers=admin_headers, ) assert r.status_code in (400, 422) r2 = client.post( GENERATE_URL, json={"provider": "openrouter", "brief": "b", "model": "m" * 130}, headers=admin_headers, ) assert r2.status_code in (400, 422) # --------------------------------------------------------------------------- # Durcissement du nettoyage HTML (casse mixte, balises orphelines, data:) # --------------------------------------------------------------------------- def test_sanitize_generated_html_hardened(): sanitize = admin_routes_mod._sanitize_generated_html # script orphelin (jamais fermé) et casse mixte assert "ok