Files
office_translator/tests/test_marketing_email_generation.py
sepehr 0a013c679d
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 2m40s
feat(admin): canaux IA par palier (z.ai/DeepSeek directs), emails generes par IA, reductions Stripe
- Chaque modele d'un palier devient une route {canal, modele} : openrouter,
  deepseek direct, zhipu (z.ai, nouveau canal), minimax, openai, xai ;
  repli automatique sur la route suivante si la cle manque, compatibilite
  ascendante avec les anciens reglages a chaines
- Page Fournisseurs refondue en sections ; interrupteurs et chaine de
  secours retablis ; tests de connexion avec delai d'attente
- Emails de relance generes par IA (canal au choix, prompt construit depuis
  le plan marketing, HTML sane, RTL arabe/persan, jamais d'envoi automatique)
- Codes promo : validation locale avant Stripe, Coupon+PromotionCode avec
  double plafond, comptage par session (webhook+sync=1 fois), ecriture
  atomique, relier-a-Stripe, suppression, validation publique au checkout
- Page Tarifs : champ code promo (?promo=) avec verification traduite
- Tests : 1357 verts (facturation par palier testee dans le worker,
  point de contact providers/available, promos, generation)
2026-09-05 19:46:33 +02:00

461 lines
18 KiB
Python

"""
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": "<html><body><p>Terminez votre traduction.</p></body></html>",
})
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": "<p>Bonjour !</p>"}\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"] == "<p>Bonjour !</p>"
def test_generate_falls_back_to_title(client, admin_headers, gen_env, monkeypatch):
"""Sortie non JSON : repli <title> = sujet, corps = HTML."""
_with_openrouter_key(monkeypatch)
async def _fake_llm(admin_cfg, provider, model, system_prompt, user_prompt):
return (
"<html><head><title>Relance</title></head>"
"<body><p>Reprenez votre traduction.</p></body></html>"
)
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 <script>, pas de gestionnaires d'événements."""
_with_openrouter_key(monkeypatch)
async def _fake_llm(admin_cfg, provider, model, system_prompt, user_prompt):
return json.dumps({
"subject": "s",
"html": "<p onclick=\"evil()\">ok</p><script>alert(1)</script>"
"<a href=\"javascript:alert(2)\">x</a>",
})
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 "<script" not in html
assert "onclick" not in html
assert "javascript:" not in html
def test_generate_mentions_active_promo_in_prompt(client, admin_headers, gen_env, monkeypatch):
"""Le sélecteur de promo alimente le prompt (code + valeur exacts)."""
_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": ["pro"], "max_redemptions": 50, "times_used": 3,
"expires_at": None, "stripe_coupon_id": None,
"stripe_promotion_code_id": None, "stripe_mode": "local_only",
"created_at": "t", "deactivated_at": None,
}])
captured: dict = {}
async def _fake_llm(admin_cfg, provider, model, system_prompt, user_prompt):
captured["system"] = system_prompt
return json.dumps({"subject": "s", "html": "<p>x</p>"})
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": "<p>x</p>"})
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":"<p>b</p>"}') == ("a", "<p>b</p>")
assert parse('Blabla\n{"subject":"a","html":"<p>b</p>"}\nFin') == ("a", "<p>b</p>")
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": "<p>x</p>"})
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 <div dir="rtl">."""
_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": "<p>مرحبا</p>"})
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('<div dir="rtl">')
assert html.endswith("</div>")
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 "<script" not in sanitize('<p>ok</p><ScRiPt>alert(1)')
# iframe / object / embed / form retirés avec leur contenu
html = sanitize('<p>a</p><iframe src="https://x.y"><p>hidden</p></iframe><p>b</p>')
assert "iframe" not in html and "hidden" not in html
html = sanitize('<object data="x"></object><embed src="y"><form action="z"></form>')
for tag in ("object", "embed", "form"):
assert tag not in html
# javascript: quel que soit le style, et data:
assert "javascript:" not in sanitize("<a href='JaVa ScRiPt:alert(1)'>x</a>")
assert "javascript:" not in sanitize("<a href='java\tscript:alert(1)'>x</a>")
assert "data:" not in sanitize("<a href='DATA:text/html;base64,xxx'>x</a>")
# le contenu sain passe intact
assert sanitize('<p style="color:#333">Bonjour</p>') == '<p style="color:#333">Bonjour</p>'
def test_marketing_plan_excerpt_truncated(tmp_path, monkeypatch):
plan = tmp_path / "MARKETING_PLAN.md"
plan.write_text("## 1. A\n" + "x" * 9000 + "\n## 3. B\n", encoding="utf-8")
monkeypatch.setattr(admin_routes_mod, "MARKETING_PLAN_FILE", plan)
excerpt = admin_routes_mod._load_marketing_plan_excerpt()
assert len(excerpt) <= admin_routes_mod.MARKETING_PLAN_PROMPT_MAX_CHARS + 10
assert excerpt.startswith("## 1.")
assert "## 3." not in excerpt