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)
571 lines
23 KiB
Python
571 lines
23 KiB
Python
"""
|
|
Réductions : codes promo (spec « Fournisseur par palier IA »).
|
|
|
|
- création locale (data/promo_codes.json) + Coupon/Promotion Code Stripe
|
|
quand Stripe est configuré (sinon « local_only ») ;
|
|
- validation AU CHECKOUT AVANT tout appel Stripe (actif, forfait, expiration,
|
|
plafond) avec une cause explicite ;
|
|
- incrément du compteur uniquement à la complétion du paiement.
|
|
"""
|
|
|
|
import json
|
|
import pytest
|
|
from pathlib import Path
|
|
from types import SimpleNamespace
|
|
from datetime import datetime, timedelta, timezone
|
|
|
|
import services.payment_service as payment_svc
|
|
|
|
ADMIN_LOGIN_URL = "/api/v1/admin/login"
|
|
PROMOS_URL = "/api/v1/admin/marketing/promos"
|
|
|
|
|
|
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")
|
|
|
|
|
|
def _future(days: int = 30) -> str:
|
|
return (datetime.now(timezone.utc) + timedelta(days=days)).isoformat()
|
|
|
|
|
|
@pytest.fixture
|
|
def promo_file(tmp_path: Path, monkeypatch) -> Path:
|
|
path = tmp_path / "promo_codes.json"
|
|
monkeypatch.setattr(payment_svc, "PROMO_CODES_FILE", path)
|
|
return path
|
|
|
|
|
|
@pytest.fixture
|
|
def client(promo_file, monkeypatch, tmp_path: Path):
|
|
"""TestClient avec authentification JSON (sans base de données)."""
|
|
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):
|
|
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
|
|
token = r.json()["access_token"]
|
|
return {"Authorization": f"Bearer {token}"}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Création / listage / désactivation (endpoints admin)
|
|
# ---------------------------------------------------------------------------
|
|
def test_create_promo_without_stripe_is_local_only(client, admin_headers, promo_file):
|
|
r = client.post(
|
|
PROMOS_URL,
|
|
json={"code": "lancement-10", "type": "percent", "value": 10,
|
|
"max_redemptions": 10, "expires_at": _future()},
|
|
headers=admin_headers,
|
|
)
|
|
assert r.status_code == 201, r.text
|
|
entry = r.json()["data"]
|
|
assert entry["code"] == "LANCEMENT-10" # normalisé en majuscules
|
|
assert entry["stripe_mode"] == "local_only"
|
|
assert entry["stripe_promotion_code_id"] is None
|
|
assert entry["times_used"] == 0
|
|
|
|
r2 = client.get(PROMOS_URL, headers=admin_headers)
|
|
assert r2.status_code == 200
|
|
assert [p["code"] for p in r2.json()["data"]["promos"]] == ["LANCEMENT-10"]
|
|
|
|
|
|
def test_create_promo_with_stripe_creates_coupon_and_code(
|
|
client, admin_headers, promo_file, monkeypatch
|
|
):
|
|
monkeypatch.setattr(payment_svc, "is_stripe_configured", lambda: True)
|
|
|
|
class _FakeObj(dict):
|
|
def __getattr__(self, name):
|
|
try:
|
|
return self[name]
|
|
except KeyError as e: # pragma: no cover
|
|
raise AttributeError(name) from e
|
|
|
|
def _fake_coupon(**kwargs):
|
|
assert kwargs["percent_off"] == 15
|
|
assert kwargs["duration"] == "once"
|
|
return _FakeObj(id="coupon_1")
|
|
|
|
def _fake_promo_code(**kwargs):
|
|
assert kwargs["coupon"] == "coupon_1"
|
|
assert kwargs["max_redemptions"] == 5
|
|
assert "expires_at" in kwargs
|
|
return _FakeObj(id="promo_1")
|
|
|
|
monkeypatch.setattr(payment_svc.stripe.Coupon, "create", _fake_coupon)
|
|
monkeypatch.setattr(payment_svc.stripe.PromotionCode, "create", _fake_promo_code)
|
|
monkeypatch.setattr(payment_svc, "stripe_mode", lambda: "test")
|
|
|
|
r = client.post(
|
|
PROMOS_URL,
|
|
json={"code": "PROMO15", "type": "percent", "value": 15,
|
|
"plans": ["pro", "business"], "max_redemptions": 5,
|
|
"expires_at": _future()},
|
|
headers=admin_headers,
|
|
)
|
|
assert r.status_code == 201, r.text
|
|
entry = r.json()["data"]
|
|
assert entry["stripe_coupon_id"] == "coupon_1"
|
|
assert entry["stripe_promotion_code_id"] == "promo_1"
|
|
assert entry["stripe_mode"] == "test"
|
|
|
|
|
|
def test_create_promo_rejects_invalid_input(client, admin_headers, promo_file):
|
|
# code trop court / minuscules après normalisation impossible (pattern)
|
|
r = client.post(PROMOS_URL, json={"code": "ab", "type": "percent", "value": 10},
|
|
headers=admin_headers)
|
|
assert r.status_code == 400
|
|
assert r.json()["error"] == "INVALID_PROMO_CODE"
|
|
|
|
# code avec caractères interdits
|
|
r = client.post(PROMOS_URL, json={"code": "MAUVAIS CODE!", "type": "percent", "value": 10},
|
|
headers=admin_headers)
|
|
assert r.status_code == 400
|
|
|
|
# pourcentage hors bornes
|
|
r = client.post(PROMOS_URL, json={"code": "GRAND", "type": "percent", "value": 150},
|
|
headers=admin_headers)
|
|
assert r.status_code == 400
|
|
assert r.json()["error"] == "INVALID_PROMO_VALUE"
|
|
|
|
# montant nul
|
|
r = client.post(PROMOS_URL, json={"code": "ZERO", "type": "amount", "value": 0},
|
|
headers=admin_headers)
|
|
assert r.status_code == 400
|
|
|
|
# forfait inconnu
|
|
r = client.post(PROMOS_URL, json={"code": "PLANX", "type": "percent", "value": 10,
|
|
"plans": ["platine"]}, headers=admin_headers)
|
|
assert r.status_code == 400
|
|
assert r.json()["error"] == "INVALID_PROMO_PLAN"
|
|
|
|
# expiration dans le passé
|
|
r = client.post(PROMOS_URL, json={"code": "HIER", "type": "percent", "value": 10,
|
|
"expires_at": "2020-01-01T00:00:00+00:00"},
|
|
headers=admin_headers)
|
|
assert r.status_code == 400
|
|
assert r.json()["error"] == "INVALID_PROMO_EXPIRY"
|
|
|
|
# plafond inférieur à 1
|
|
r = client.post(PROMOS_URL, json={"code": "PLAFOND", "type": "percent", "value": 10,
|
|
"max_redemptions": 0}, headers=admin_headers)
|
|
assert r.status_code == 400
|
|
|
|
|
|
def test_create_duplicate_promo_refused(client, admin_headers, promo_file):
|
|
body = {"code": "DUP", "type": "percent", "value": 10}
|
|
assert client.post(PROMOS_URL, json=body, headers=admin_headers).status_code == 201
|
|
r = client.post(PROMOS_URL, json=body, headers=admin_headers)
|
|
assert r.status_code == 409
|
|
assert r.json()["error"] == "PROMO_ALREADY_EXISTS"
|
|
|
|
|
|
def test_deactivate_promo(client, admin_headers, promo_file, monkeypatch):
|
|
client.post(PROMOS_URL, json={"code": "BYE", "type": "percent", "value": 5},
|
|
headers=admin_headers)
|
|
r = client.post(f"{PROMOS_URL}/BYE/deactivate", headers=admin_headers)
|
|
assert r.status_code == 200
|
|
assert r.json()["data"]["active"] is False
|
|
|
|
r2 = client.post(f"{PROMOS_URL}/INCONNU/deactivate", headers=admin_headers)
|
|
assert r2.status_code == 404
|
|
|
|
|
|
def test_promos_require_admin(client):
|
|
assert client.get(PROMOS_URL).status_code == 401
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Validation au checkout — AVANT tout appel Stripe
|
|
# ---------------------------------------------------------------------------
|
|
@pytest.fixture
|
|
def checkout_mocks(monkeypatch, promo_file):
|
|
"""Stripe « configuré » mais sous surveillance : toute création de session
|
|
est capturée (et aurait levé si appelée à tort)."""
|
|
calls: dict = {"session": 0, "kwargs": None}
|
|
|
|
def _fake_session_create(**kwargs):
|
|
calls["session"] += 1
|
|
calls["kwargs"] = kwargs
|
|
return SimpleNamespace(id="cs_test_1", url="https://stripe.test/checkout")
|
|
|
|
monkeypatch.setattr(payment_svc, "is_stripe_configured", lambda: True)
|
|
monkeypatch.setattr(payment_svc.stripe.checkout.Session, "create", _fake_session_create)
|
|
monkeypatch.setattr(payment_svc, "stripe_price_ids_for_plan", lambda p: ("price_m", "price_y"))
|
|
|
|
user = SimpleNamespace(
|
|
id="u1", email="buyer@example.com", name="Buyer",
|
|
stripe_customer_id="cus_1", stripe_subscription_id=None,
|
|
subscription_status="active", plan="free", cancel_at_period_end=False,
|
|
docs_translated_this_month=0, pages_translated_this_month=0,
|
|
api_calls_this_month=0, extra_credits=0, subscription_ends_at=None,
|
|
)
|
|
monkeypatch.setattr(payment_svc, "get_user_by_id", lambda uid: user if uid == "u1" else None)
|
|
monkeypatch.setattr(payment_svc, "update_user", lambda uid, data: None)
|
|
|
|
# Pas de base de données dans les tests : la vérification d'antidoublon
|
|
# (PaymentHistory) doit échouer proprement — la fonction la tolère.
|
|
import database.connection as db_conn
|
|
|
|
def _no_db():
|
|
raise RuntimeError("pas de base de données dans les tests")
|
|
|
|
monkeypatch.setattr(db_conn, "get_sync_session", _no_db)
|
|
return calls
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_checkout_with_valid_promo_applies_discount(
|
|
checkout_mocks, promo_file, monkeypatch
|
|
):
|
|
monkeypatch.setattr(payment_svc, "stripe_mode", lambda: "test")
|
|
monkeypatch.setattr(
|
|
payment_svc.stripe.Coupon, "create", lambda **kw: SimpleNamespace(id="coupon_9")
|
|
)
|
|
monkeypatch.setattr(
|
|
payment_svc.stripe.PromotionCode,
|
|
"create",
|
|
lambda **kw: SimpleNamespace(id="promo_9"),
|
|
)
|
|
payment_svc.create_promo_code(
|
|
code="SOIF10", type="percent", value=10, plans=["pro"],
|
|
max_redemptions=10, expires_at=_future(),
|
|
)
|
|
|
|
from models.subscription import PlanType
|
|
|
|
result = await payment_svc.create_checkout_session(
|
|
user_id="u1", plan=PlanType.PRO, promo_code="soif10"
|
|
)
|
|
assert "error" not in result, result
|
|
assert result["promo_code"] == "SOIF10"
|
|
kwargs = checkout_mocks["kwargs"]
|
|
assert kwargs["discounts"] == [{"promotion_code": "promo_9"}]
|
|
assert kwargs["metadata"]["promo_code"] == "SOIF10"
|
|
assert kwargs["subscription_data"]["metadata"]["promo_code"] == "SOIF10"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_checkout_rejects_expired_promo_before_stripe(
|
|
checkout_mocks, promo_file
|
|
):
|
|
payment_svc.save_promo_codes([{
|
|
"code": "VIEUX", "active": True, "type": "percent", "value": 10,
|
|
"plans": ["all"], "max_redemptions": None, "times_used": 0,
|
|
"expires_at": "2020-01-01T00:00:00+00:00",
|
|
"stripe_coupon_id": "c", "stripe_promotion_code_id": "p",
|
|
"stripe_mode": "test", "created_at": "t", "deactivated_at": None,
|
|
}])
|
|
from models.subscription import PlanType
|
|
|
|
result = await payment_svc.create_checkout_session(
|
|
user_id="u1", plan=PlanType.PRO, promo_code="VIEUX"
|
|
)
|
|
assert result.get("error_code") == "PROMO_INVALID"
|
|
assert result.get("promo_reason") == "expired"
|
|
assert checkout_mocks["session"] == 0 # aucune session Stripe créée
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_checkout_rejects_exhausted_promo(checkout_mocks, promo_file):
|
|
"""Un code à 10/10 utilisations est refusé avant tout appel Stripe."""
|
|
payment_svc.save_promo_codes([{
|
|
"code": "FINI", "active": True, "type": "percent", "value": 10,
|
|
"plans": ["all"], "max_redemptions": 10, "times_used": 10,
|
|
"expires_at": None, "stripe_coupon_id": "c",
|
|
"stripe_promotion_code_id": "p", "stripe_mode": "test",
|
|
"created_at": "t", "deactivated_at": None,
|
|
}])
|
|
from models.subscription import PlanType
|
|
|
|
result = await payment_svc.create_checkout_session(
|
|
user_id="u1", plan=PlanType.FREE, promo_code="FINI"
|
|
)
|
|
assert result.get("promo_reason") == "exhausted"
|
|
assert checkout_mocks["session"] == 0
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_checkout_rejects_promo_for_other_plan(checkout_mocks, promo_file):
|
|
payment_svc.save_promo_codes([{
|
|
"code": "PROONLY", "active": True, "type": "percent", "value": 10,
|
|
"plans": ["pro"], "max_redemptions": None, "times_used": 0,
|
|
"expires_at": None, "stripe_coupon_id": "c",
|
|
"stripe_promotion_code_id": "p", "stripe_mode": "test",
|
|
"created_at": "t", "deactivated_at": None,
|
|
}])
|
|
from models.subscription import PlanType
|
|
|
|
result = await payment_svc.create_checkout_session(
|
|
user_id="u1", plan=PlanType.STARTER, promo_code="PROONLY"
|
|
)
|
|
assert result.get("promo_reason") == "plan"
|
|
assert checkout_mocks["session"] == 0
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_checkout_rejects_unknown_and_local_only_promos(
|
|
checkout_mocks, promo_file
|
|
):
|
|
from models.subscription import PlanType
|
|
|
|
result = await payment_svc.create_checkout_session(
|
|
user_id="u1", plan=PlanType.PRO, promo_code="NUL"
|
|
)
|
|
assert result.get("promo_reason") == "unknown"
|
|
|
|
payment_svc.save_promo_codes([{
|
|
"code": "LOCAL", "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,
|
|
}])
|
|
result2 = await payment_svc.create_checkout_session(
|
|
user_id="u1", plan=PlanType.PRO, promo_code="LOCAL"
|
|
)
|
|
assert result2.get("promo_reason") == "local_only"
|
|
assert checkout_mocks["session"] == 0
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_completion_increments_promo_counter_once_per_session(
|
|
checkout_mocks, promo_file, monkeypatch
|
|
):
|
|
"""Le compteur local n'augmente qu'à la complétion ; un webhook suivi d'une
|
|
synchronisation du MÊME paiement ne compte qu'une fois, même en mode JSON."""
|
|
monkeypatch.setattr(payment_svc, "stripe_mode", lambda: "test")
|
|
monkeypatch.setattr(
|
|
payment_svc.stripe.Coupon, "create", lambda **kw: SimpleNamespace(id="c")
|
|
)
|
|
monkeypatch.setattr(
|
|
payment_svc.stripe.PromotionCode, "create", lambda **kw: SimpleNamespace(id="p")
|
|
)
|
|
payment_svc.create_promo_code(
|
|
code="COMPTE", type="percent", value=10, max_redemptions=10
|
|
)
|
|
assert payment_svc.load_promo_codes()[0]["times_used"] == 0
|
|
|
|
session = {
|
|
"id": "cs_1",
|
|
"metadata": {"user_id": "u1", "plan": "pro", "promo_code": "COMPTE"},
|
|
"subscription": {"id": "sub_1", "current_period_end": None},
|
|
"customer": "cus_1",
|
|
"payment_intent": None,
|
|
"amount_total": 1900,
|
|
"currency": "eur",
|
|
}
|
|
# La vérification d'antidoublon touche la base : indifférent ici, la
|
|
# fonction tolère son échec.
|
|
await payment_svc.handle_checkout_completed(session)
|
|
await payment_svc.handle_checkout_completed(session) # re-livraison du webhook
|
|
|
|
# Synchronisation manuelle du MÊME paiement (même session Stripe) : 0 incrément.
|
|
await payment_svc.handle_checkout_completed(session)
|
|
|
|
promo = payment_svc.load_promo_codes()[0]
|
|
assert promo["times_used"] == 1
|
|
assert promo["counted_sessions"] == ["cs_1"]
|
|
assert promo["last_used_at"]
|
|
|
|
# Un AUTRE paiement (autre session) compte bien une seconde fois.
|
|
session2 = dict(session, id="cs_2")
|
|
await payment_svc.handle_checkout_completed(session2)
|
|
assert payment_svc.load_promo_codes()[0]["times_used"] == 2
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_checkout_without_promo_has_no_discount(checkout_mocks, promo_file):
|
|
from models.subscription import PlanType
|
|
|
|
result = await payment_svc.create_checkout_session(
|
|
user_id="u1", plan=PlanType.PRO, promo_code=""
|
|
)
|
|
assert "error" not in result
|
|
assert "discounts" not in checkout_mocks["kwargs"]
|
|
assert "promo_code" not in checkout_mocks["kwargs"]["metadata"]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Robustesse (relecture) : doublon sous verrou, Stripe en erreur, JSON corrompu
|
|
# ---------------------------------------------------------------------------
|
|
def test_duplicate_checked_under_lock_two_creates_one_409(promo_file):
|
|
"""Deux créations du même code : la seconde est rejetée par la vérification
|
|
SOUS le verrou du service (une seule entrée persistée)."""
|
|
payment_svc.create_promo_code(code="RACE1", type="percent", value=10)
|
|
with pytest.raises(payment_svc.DuplicatePromoError):
|
|
payment_svc.create_promo_code(code="RACE1", type="percent", value=10)
|
|
assert [p["code"] for p in payment_svc.load_promo_codes()] == ["RACE1"]
|
|
|
|
|
|
def test_stripe_failure_cleans_orphan_coupon_and_persists_nothing(
|
|
promo_file, monkeypatch
|
|
):
|
|
"""Coupon créé puis PromotionCode refusé : le coupon orphelin est supprimé,
|
|
l'erreur est typée et RIEN n'est écrit dans promo_codes.json."""
|
|
monkeypatch.setattr(payment_svc, "is_stripe_configured", lambda: True)
|
|
monkeypatch.setattr(
|
|
payment_svc.stripe.Coupon, "create", lambda **kw: SimpleNamespace(id="coupon_X")
|
|
)
|
|
deleted: list = []
|
|
monkeypatch.setattr(
|
|
payment_svc.stripe.Coupon, "delete", lambda cid: deleted.append(cid)
|
|
)
|
|
|
|
def _boom(**kw):
|
|
raise payment_svc.stripe.error.StripeError("card_error test")
|
|
|
|
monkeypatch.setattr(payment_svc.stripe.PromotionCode, "create", _boom)
|
|
|
|
with pytest.raises(payment_svc.StripePromoCreationError):
|
|
payment_svc.create_promo_code(code="RATEE", type="percent", value=10)
|
|
assert deleted == ["coupon_X"]
|
|
assert payment_svc.load_promo_codes() == []
|
|
|
|
|
|
def test_unreadable_limit_or_counter_means_exhausted(promo_file):
|
|
"""max_redemptions / times_used illisibles (JSON corrompu) : le code est
|
|
traité comme épuisé avec un message, jamais comme illimité."""
|
|
base = {
|
|
"code": "CORROMPU", "active": True, "type": "percent", "value": 10,
|
|
"plans": ["all"], "expires_at": None, "stripe_coupon_id": "c",
|
|
"stripe_promotion_code_id": "p", "stripe_mode": "test",
|
|
"created_at": "t", "deactivated_at": None,
|
|
}
|
|
payment_svc.save_promo_codes([dict(base, max_redemptions="n/a", times_used=0)])
|
|
with pytest.raises(payment_svc.PromoValidationError) as err:
|
|
payment_svc.validate_promo_code("CORROMPU", "pro")
|
|
assert err.value.reason == "exhausted"
|
|
|
|
payment_svc.save_promo_codes([dict(base, max_redemptions=10, times_used="beaucoup")])
|
|
with pytest.raises(payment_svc.PromoValidationError) as err2:
|
|
payment_svc.validate_promo_code("CORROMPU", "pro")
|
|
assert err2.value.reason == "exhausted"
|
|
|
|
|
|
def test_save_is_atomic_no_temp_file_left(promo_file):
|
|
payment_svc.save_promo_codes([{
|
|
"code": "ATOM", "active": True, "type": "percent", "value": 5,
|
|
}])
|
|
assert payment_svc.PROMO_CODES_FILE.exists()
|
|
assert not payment_svc.PROMO_CODES_FILE.with_name(
|
|
payment_svc.PROMO_CODES_FILE.name + ".tmp"
|
|
).exists()
|
|
# le JSON reste lisible après écriture atomique
|
|
assert payment_svc.load_promo_codes()[0]["code"] == "ATOM"
|
|
|
|
|
|
def test_create_promo_for_free_plan_refused(client, admin_headers, promo_file):
|
|
r = client.post(
|
|
PROMOS_URL,
|
|
json={"code": "FREEBIE", "type": "percent", "value": 10, "plans": ["free"]},
|
|
headers=admin_headers,
|
|
)
|
|
assert r.status_code == 400
|
|
assert r.json()["error"] == "INVALID_PROMO_PLAN"
|
|
assert "Gratuit" in r.json()["message"]
|
|
|
|
|
|
def test_delete_promo_endpoint(client, admin_headers, promo_file, monkeypatch):
|
|
client.post(PROMOS_URL, json={"code": "ADIEU", "type": "percent", "value": 5},
|
|
headers=admin_headers)
|
|
r = client.delete(f"{PROMOS_URL}/ADIEU", headers=admin_headers)
|
|
assert r.status_code == 200
|
|
assert payment_svc.load_promo_codes() == []
|
|
|
|
r2 = client.delete(f"{PROMOS_URL}/ADIEU", headers=admin_headers)
|
|
assert r2.status_code == 404
|
|
|
|
|
|
def test_link_local_only_promo_to_stripe(client, admin_headers, promo_file, monkeypatch):
|
|
monkeypatch.setattr(payment_svc, "is_stripe_configured", lambda: True)
|
|
monkeypatch.setattr(payment_svc, "stripe_mode", lambda: "test")
|
|
monkeypatch.setattr(
|
|
payment_svc.stripe.Coupon, "create", lambda **kw: SimpleNamespace(id="coupon_L")
|
|
)
|
|
monkeypatch.setattr(
|
|
payment_svc.stripe.PromotionCode,
|
|
"create",
|
|
lambda **kw: SimpleNamespace(id="promo_L"),
|
|
)
|
|
|
|
client.post(PROMOS_URL, json={"code": "LOCAL1", "type": "amount", "value": 5},
|
|
headers=admin_headers)
|
|
r = client.post(f"{PROMOS_URL}/LOCAL1/link-stripe", headers=admin_headers)
|
|
assert r.status_code == 200, r.text
|
|
entry = r.json()["data"]
|
|
assert entry["stripe_coupon_id"] == "coupon_L"
|
|
assert entry["stripe_promotion_code_id"] == "promo_L"
|
|
assert entry["stripe_mode"] == "test"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Point de contact public : GET /api/v1/promos/validate
|
|
# ---------------------------------------------------------------------------
|
|
def test_public_validate_endpoint(promo_file, client):
|
|
payment_svc.save_promo_codes([{
|
|
"code": "VALID10", "active": True, "type": "percent", "value": 10,
|
|
"plans": ["all"], "max_redemptions": None, "times_used": 0,
|
|
"expires_at": None, "stripe_coupon_id": "c",
|
|
"stripe_promotion_code_id": "p", "stripe_mode": "test",
|
|
"created_at": "t", "deactivated_at": None,
|
|
}])
|
|
r = client.get("/api/v1/promos/validate", params={"code": "valid10", "plan": "pro"})
|
|
assert r.status_code == 200
|
|
data = r.json()["data"]
|
|
assert data["valid"] is True
|
|
assert data["percent_off"] == 10
|
|
|
|
# montant en euros
|
|
payment_svc.save_promo_codes(payment_svc.load_promo_codes() + [{
|
|
"code": "EUROS5", "active": True, "type": "amount", "value": 5,
|
|
"plans": ["pro"], "max_redemptions": None, "times_used": 0,
|
|
"expires_at": None, "stripe_coupon_id": "c",
|
|
"stripe_promotion_code_id": "p", "stripe_mode": "test",
|
|
"created_at": "t", "deactivated_at": None,
|
|
}])
|
|
r2 = client.get("/api/v1/promos/validate", params={"code": "EUROS5", "plan": "pro"})
|
|
assert r2.json()["data"]["amount_off"] == 5
|
|
|
|
# causes explicites
|
|
r3 = client.get("/api/v1/promos/validate", params={"code": "INCONNU", "plan": "pro"})
|
|
assert r3.json()["data"]["valid"] is False
|
|
assert r3.json()["data"]["reason_code"] == "unknown"
|
|
|
|
payment_svc.save_promo_codes([dict(
|
|
code="EXPIRE1", active=True, type="percent", value=10,
|
|
plans=["all"], max_redemptions=None, times_used=0,
|
|
expires_at="2020-01-01T00:00:00+00:00", stripe_coupon_id="c",
|
|
stripe_promotion_code_id="p", stripe_mode="test",
|
|
created_at="t", deactivated_at=None,
|
|
)])
|
|
r4 = client.get("/api/v1/promos/validate", params={"code": "EXPIRE1", "plan": "pro"})
|
|
assert r4.json()["data"]["reason_code"] == "expired"
|