All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 2m40s
- 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)
461 lines
18 KiB
Python
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
|