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)
231 lines
8.5 KiB
Python
231 lines
8.5 KiB
Python
"""
|
|
Routage réel des modèles par palier IA (spec « Stratégie LLM par abonnement »).
|
|
|
|
- PLANS expose ai_models_essential / ai_models_premium (gamme officielle).
|
|
- Un plan Pro (moteur openrouter) résout toujours un modèle Essentielle,
|
|
jamais un modèle Premium.
|
|
- Le défaut admin (ai_tiers.<palier>.default_model) prime sur le défaut du
|
|
plan, sans redéploiement.
|
|
"""
|
|
|
|
import pytest
|
|
from pathlib import Path
|
|
from fastapi.testclient import TestClient
|
|
|
|
from models.subscription import (
|
|
DEFAULT_AI_MODELS_ESSENTIAL,
|
|
DEFAULT_AI_MODELS_PREMIUM,
|
|
PLANS,
|
|
PlanType,
|
|
)
|
|
from routes.translate_routes import (
|
|
_allowed_providers_for_plan,
|
|
_plan_ai_models,
|
|
_resolve_tier_model,
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def client(monkeypatch, tmp_path: Path):
|
|
"""TestClient with JSON auth and rate limiting disabled."""
|
|
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)
|
|
|
|
|
|
def _settings(ai_tiers: dict | None = None):
|
|
from routes.admin_routes import SettingsConfig
|
|
|
|
# La résolution est clé-consciente : les tests de résolution partent avec
|
|
# une clé OpenRouter disponible (comme un serveur configuré).
|
|
payload = {"openrouter": {"api_key": "sk-or-test"}}
|
|
if ai_tiers is not None:
|
|
payload["ai_tiers"] = ai_tiers
|
|
return SettingsConfig.model_validate(payload)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Gamme officielle dans PLANS
|
|
# ---------------------------------------------------------------------------
|
|
def test_plans_expose_official_model_ranges():
|
|
essential = PLANS[PlanType.PRO]["ai_models_essential"]
|
|
assert essential == [
|
|
"deepseek/deepseek-v4-flash",
|
|
"z-ai/glm-5.3-flash",
|
|
"minimax/minimax-m3",
|
|
]
|
|
business = PLANS[PlanType.BUSINESS]
|
|
assert business["ai_models_essential"] == essential
|
|
assert business["ai_models_premium"] == [
|
|
"anthropic/claude-sonnet-5",
|
|
"deepseek/deepseek-v4-pro",
|
|
"z-ai/glm-5.3",
|
|
]
|
|
|
|
|
|
def test_old_single_model_keys_removed():
|
|
assert "ai_model_essential" not in PLANS[PlanType.PRO]
|
|
assert "ai_model_essential" not in PLANS[PlanType.BUSINESS]
|
|
assert "ai_model_premium" not in PLANS[PlanType.BUSINESS]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Résolution par palier — renvoie la route complète (provider, model)
|
|
# ---------------------------------------------------------------------------
|
|
def test_pro_resolves_essential_plan_default():
|
|
assert _resolve_tier_model(_settings(), "pro", premium=False) == (
|
|
("openrouter", "deepseek/deepseek-v4-flash")
|
|
)
|
|
|
|
|
|
def test_business_resolves_premium_plan_default():
|
|
assert _resolve_tier_model(_settings(), "business", premium=True) == (
|
|
("openrouter", "anthropic/claude-sonnet-5")
|
|
)
|
|
|
|
|
|
def test_admin_tier_default_overrides_plan_default():
|
|
cfg = _settings(
|
|
{"essential": {"models": [], "default_model": "z-ai/glm-5.3-flash"}}
|
|
)
|
|
assert _resolve_tier_model(cfg, "pro", premium=False) == (
|
|
"openrouter", "z-ai/glm-5.3-flash"
|
|
)
|
|
|
|
|
|
def test_admin_tier_models_first_used_when_default_empty():
|
|
cfg = _settings(
|
|
{
|
|
"essential": {
|
|
"models": ["minimax/minimax-m3", "deepseek/deepseek-v4-flash"],
|
|
"default_model": "",
|
|
}
|
|
}
|
|
)
|
|
assert _resolve_tier_model(cfg, "pro", premium=False) == (
|
|
"openrouter", "minimax/minimax-m3"
|
|
)
|
|
|
|
|
|
def test_pro_never_resolves_premium_model():
|
|
cfg = _settings(
|
|
{
|
|
"essential": {"models": [], "default_model": ""},
|
|
"premium": {"models": [], "default_model": ""},
|
|
}
|
|
)
|
|
for plan_str in ("pro", "PlanType.PRO"):
|
|
provider, model = _resolve_tier_model(cfg, plan_str, premium=False)
|
|
assert provider == "openrouter"
|
|
assert model in DEFAULT_AI_MODELS_ESSENTIAL
|
|
assert model not in DEFAULT_AI_MODELS_PREMIUM
|
|
|
|
|
|
def test_pro_plan_does_not_allow_premium_provider():
|
|
allowed = _allowed_providers_for_plan(PlanType.PRO)
|
|
assert "openrouter" in allowed
|
|
assert "openrouter_premium" not in allowed
|
|
|
|
|
|
def test_plan_fallback_for_plans_without_range():
|
|
# Free/Enterprise n'ont pas de liste : retombée sur la gamme officielle.
|
|
assert _plan_ai_models(PlanType.FREE, premium=False) == DEFAULT_AI_MODELS_ESSENTIAL
|
|
assert _plan_ai_models(PlanType.ENTERPRISE, premium=True) == DEFAULT_AI_MODELS_PREMIUM
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# GET /plans expose les gammes (affichage public)
|
|
# ---------------------------------------------------------------------------
|
|
def test_get_plans_exposes_model_lists(client):
|
|
r = client.get("/api/v1/auth/plans")
|
|
assert r.status_code == 200
|
|
plans = {p["id"]: p for p in r.json()["data"]["plans"]}
|
|
assert plans["pro"]["ai_models_essential"] == [
|
|
"deepseek/deepseek-v4-flash",
|
|
"z-ai/glm-5.3-flash",
|
|
"minimax/minimax-m3",
|
|
]
|
|
assert plans["business"]["ai_models_premium"][0] == "anthropic/claude-sonnet-5"
|
|
assert "ai_model_essential" not in plans["pro"]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Point de contact : GET /providers/available affiche le modèle (chaîne simple)
|
|
# ---------------------------------------------------------------------------
|
|
def test_available_providers_model_is_a_string(monkeypatch, tmp_path):
|
|
"""``_resolve_tier_model`` renvoie désormais (provider, model) : le champ
|
|
« model » de /providers/available doit rester une chaîne, égale au modèle
|
|
attendu pour les deux paliers (appelé ici avec un utilisateur Business,
|
|
car la liste est filtrée par forfait)."""
|
|
import routes.legacy_routes as legacy_mod
|
|
import routes.admin_routes as admin_routes_mod
|
|
|
|
settings = _settings(
|
|
{
|
|
"essential": {"models": [], "default_model": "z-ai/glm-5.3-flash"},
|
|
"premium": {"models": [], "default_model": "anthropic/claude-sonnet-5"},
|
|
}
|
|
)
|
|
# Les deux passerelles sont affichées dès lors qu'elles sont actives côté
|
|
# réglages (indépendamment des variables d'environnement de la machine).
|
|
settings.openrouter.enabled = True
|
|
settings.openrouter_premium.enabled = True
|
|
monkeypatch.setattr(admin_routes_mod, "load_settings", lambda: settings, raising=True)
|
|
|
|
from fastapi.testclient import TestClient
|
|
from middleware.rate_limiting import RateLimitManager
|
|
from models.subscription import PlanType
|
|
|
|
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 services.auth_service as auth_svc
|
|
|
|
monkeypatch.setattr(auth_svc, "USERS_FILE", tmp_path / "users.json")
|
|
monkeypatch.setattr(auth_svc, "USE_DATABASE", False)
|
|
monkeypatch.setattr(auth_svc, "DATABASE_AVAILABLE", False)
|
|
|
|
# Utilisateur Business factice (les moteurs Premium sont filtrés par plan).
|
|
business_user = type("U", (), {"plan": PlanType.BUSINESS, "id": "u-biz"})()
|
|
|
|
async def _fake_user():
|
|
return business_user
|
|
|
|
from main import app
|
|
|
|
app.dependency_overrides[legacy_mod.get_authenticated_user] = _fake_user
|
|
try:
|
|
with TestClient(app, raise_server_exceptions=True) as tc:
|
|
r = tc.get("/api/v1/providers/available")
|
|
finally:
|
|
app.dependency_overrides.pop(legacy_mod.get_authenticated_user, None)
|
|
|
|
assert r.status_code == 200, r.text
|
|
engines = {e["id"]: e for e in r.json()["providers"]}
|
|
assert isinstance(engines["openrouter"]["model"], str)
|
|
assert engines["openrouter"]["model"] == "z-ai/glm-5.3-flash"
|
|
assert isinstance(engines["openrouter_premium"]["model"], str)
|
|
assert engines["openrouter_premium"]["model"] == "anthropic/claude-sonnet-5"
|