""" 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"