feat(abonnements): paliers LLM Essentielle/Premium, admin modeles par forfait, statistiques revenus, emails marketing
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 2m51s
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 2m51s
- Gamme officielle : Essentielle (Pro) = deepseek-v4-flash, glm-5.3-flash, minimax-m3 ; Premium (Business) = claude-sonnet-5, deepseek-v4-pro, glm-5.3 - Routage reel : le modele employe suit le palier IA du forfait (reglages admin > defaut du plan), garde anti-croisement de palier - Nouvelle page admin « Modeles & abonnements » (matrice forfaits/paliers, modele par defaut, catalogue OpenRouter) - Statistiques enrichies : revenus (30 j + total), MRR estime, credits, liste d'attente, paliers utilises - Nouvelle page admin « Marketing » : audiences avec compteurs, apercu, envoi test obligatoire, historique, desabonnement public + en-tetes List-Unsubscribe ; .gitignore pour les donnees d'execution - Interface (accueil + tarifs) alignee sur la gamme, 13 langues completes - Tests : routage par palier (fonction + tache), marketing, revenus en base
This commit is contained in:
243
tests/test_admin_stats_revenue.py
Normal file
243
tests/test_admin_stats_revenue.py
Normal file
@@ -0,0 +1,243 @@
|
||||
"""
|
||||
Statistiques admin enrichies (spec « Stratégie LLM par abonnement ») :
|
||||
revenus encaissés, MRR estimé, paliers IA utilisés, compteur liste d'attente.
|
||||
En mode JSON (sans base de données), les revenus restent à zéro sans erreur.
|
||||
"""
|
||||
|
||||
import json
|
||||
import pytest
|
||||
from pathlib import Path
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
ADMIN_LOGIN_URL = "/api/v1/admin/login"
|
||||
STATS_URL = "/api/v1/admin/stats"
|
||||
|
||||
|
||||
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 stats_env(tmp_path: Path, monkeypatch):
|
||||
import routes.waitlist_routes as waitlist_mod
|
||||
|
||||
waitlist_file = tmp_path / "waitlist.json"
|
||||
monkeypatch.setattr(waitlist_mod, "WAITLIST_FILE", waitlist_file)
|
||||
return {"waitlist_file": waitlist_file}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(stats_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 main import app
|
||||
|
||||
return TestClient(app, raise_server_exceptions=True)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def auth_headers(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 {"Authorization": f"Bearer {r.json()['access_token']}"}
|
||||
|
||||
|
||||
def test_stats_include_revenue_mrr_waitlist_and_tiers(client, auth_headers, stats_env):
|
||||
_write_json(stats_env["waitlist_file"], [{"email": "a@example.com"}])
|
||||
|
||||
r = client.get(STATS_URL, headers=auth_headers)
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.json()
|
||||
|
||||
# Nouveaux blocs présents
|
||||
for key in ("revenue", "mrr_estimated", "ai_tier_usage", "waitlist_count"):
|
||||
assert key in body, f"clé manquante dans /admin/stats : {key}"
|
||||
|
||||
# Sans base de données : revenus à zéro, sans erreur
|
||||
assert body["revenue"]["collected_total"] == 0.0
|
||||
assert body["revenue"]["collected_30d"] == 0.0
|
||||
assert body["revenue"]["credits_purchased"] == 0.0
|
||||
|
||||
# Compteur liste d'attente branché sur data/waitlist.json
|
||||
assert body["waitlist_count"] == 1
|
||||
|
||||
# Paliers IA : structure complète
|
||||
assert set(body["ai_tier_usage"].keys()) == {
|
||||
"essential",
|
||||
"premium",
|
||||
"classic",
|
||||
"other",
|
||||
}
|
||||
|
||||
# Anciennes clés toujours présentes (non-régression)
|
||||
assert "users" in body and "translations" in body and "cache" in body
|
||||
|
||||
|
||||
def test_stats_mrr_estimated_from_plan_distribution(client, auth_headers, monkeypatch):
|
||||
"""Le MRR estimé s'appuie sur la répartition des plans (prix mensuel effectif)."""
|
||||
import services.auth_service as auth_svc
|
||||
|
||||
users_file = auth_svc.USERS_FILE
|
||||
users_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
_write_json(
|
||||
users_file,
|
||||
{
|
||||
"u1": {
|
||||
"email": "p1@example.com",
|
||||
"name": "P1",
|
||||
"plan": "pro",
|
||||
"docs_translated_this_month": 0,
|
||||
},
|
||||
"u2": {
|
||||
"email": "p2@example.com",
|
||||
"name": "P2",
|
||||
"plan": "pro",
|
||||
"docs_translated_this_month": 0,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
r = client.get(STATS_URL, headers=auth_headers)
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.json()
|
||||
|
||||
from services import pricing_config as pricing_cfg
|
||||
from models.subscription import PlanType
|
||||
|
||||
monthly_pro, _ = pricing_cfg.get_effective_monthly_yearly(PlanType.PRO.value)
|
||||
assert body["users"]["by_plan"].get("pro") == 2
|
||||
assert body["mrr_estimated"]["by_plan"].get("pro") == pytest.approx(
|
||||
round(monthly_pro * 2, 2)
|
||||
)
|
||||
assert body["mrr_estimated"]["total"] == pytest.approx(
|
||||
round(monthly_pro * 2, 2)
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Revenus et paliers IA en mode base de données (SQLite de test, cf. conftest)
|
||||
# ---------------------------------------------------------------------------
|
||||
@pytest.fixture
|
||||
def db_client(monkeypatch, tmp_path: Path):
|
||||
"""TestClient avec authentification admin simple et base de test active."""
|
||||
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", True)
|
||||
monkeypatch.setattr(auth_svc, "DATABASE_AVAILABLE", True)
|
||||
|
||||
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)
|
||||
|
||||
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)
|
||||
|
||||
from main import app
|
||||
|
||||
return TestClient(app, raise_server_exceptions=True)
|
||||
|
||||
|
||||
def test_stats_revenue_and_tiers_from_database(db_client):
|
||||
"""Paiements réussis + traductions en base → revenus et paliers non nuls."""
|
||||
from database.connection import get_sync_session
|
||||
from database.models import User as DBUser, PaymentHistory, Translation
|
||||
|
||||
with get_sync_session() as session:
|
||||
user = DBUser(
|
||||
email="payer@example.com",
|
||||
name="Payer",
|
||||
password_hash="not-a-real-hash",
|
||||
)
|
||||
session.add(user)
|
||||
session.flush()
|
||||
|
||||
session.add_all(
|
||||
[
|
||||
PaymentHistory(
|
||||
user_id=user.id,
|
||||
amount_cents=1900,
|
||||
currency="eur",
|
||||
payment_type="subscription",
|
||||
status="succeeded",
|
||||
),
|
||||
PaymentHistory(
|
||||
user_id=user.id,
|
||||
amount_cents=900,
|
||||
currency="eur",
|
||||
payment_type="credits",
|
||||
status="succeeded",
|
||||
),
|
||||
PaymentHistory(
|
||||
user_id=user.id,
|
||||
amount_cents=5000,
|
||||
currency="eur",
|
||||
payment_type="subscription",
|
||||
status="failed", # jamais comptabilisé
|
||||
),
|
||||
Translation(
|
||||
user_id=user.id,
|
||||
original_filename="a.xlsx",
|
||||
file_type="xlsx",
|
||||
target_language="fr",
|
||||
provider="openrouter",
|
||||
status="completed",
|
||||
),
|
||||
Translation(
|
||||
user_id=user.id,
|
||||
original_filename="b.xlsx",
|
||||
file_type="xlsx",
|
||||
target_language="fr",
|
||||
provider="openrouter_premium",
|
||||
status="completed",
|
||||
),
|
||||
]
|
||||
)
|
||||
session.commit()
|
||||
|
||||
r = db_client.get(STATS_URL, headers=_admin_headers(db_client))
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.json()
|
||||
|
||||
assert body["revenue"]["currency"] == "EUR"
|
||||
assert body["revenue"]["collected_total"] >= 28.0 # 19 € + 9 € (l'échec ignoré)
|
||||
assert body["revenue"]["collected_30d"] >= 28.0
|
||||
assert body["revenue"]["credits_purchased"] >= 9.0
|
||||
assert body["revenue"]["payments_30d"] >= 2
|
||||
assert body["ai_tier_usage"]["essential"] >= 1
|
||||
assert body["ai_tier_usage"]["premium"] >= 1
|
||||
|
||||
|
||||
def _admin_headers(client) -> dict:
|
||||
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']}"}
|
||||
Reference in New Issue
Block a user