"""Tests for the Stripe billing API endpoints. Covers: - POST /api/billing/create-checkout-session - POST /api/billing/create-portal-session - POST /api/billing/webhook (all event types) - GET /api/billing/success - Internal helpers: _handle_stripe_event, _on_checkout_completed, _on_subscription_updated, _on_subscription_deleted """ import json from unittest.mock import MagicMock, patch import pytest from fastapi.testclient import TestClient from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker from sqlalchemy.pool import StaticPool from app.api.billing import ( _handle_stripe_event, _on_checkout_completed, _on_subscription_deleted, _on_subscription_updated, _resolve_plan_id_from_price, _resolve_user_id_from_customer, ) from app.database import Base, get_db from app.models import SubscriptionPlan, UserProfile # --------------------------------------------------------------------------- # Fixtures # --------------------------------------------------------------------------- @pytest.fixture() def bill_engine(): """In-memory SQLite engine for billing tests.""" engine = create_engine( "sqlite:///:memory:", connect_args={"check_same_thread": False}, poolclass=StaticPool, ) Base.metadata.create_all(bind=engine) yield engine Base.metadata.drop_all(bind=engine) @pytest.fixture() def bill_session(bill_engine): """DB session for one test.""" Session = sessionmaker(bind=bill_engine) session = Session() yield session session.close() @pytest.fixture() def bill_client(bill_engine): """TestClient with DB dependency overridden and a logged-in session.""" from app.main import app Session = sessionmaker(bind=bill_engine) def override_get_db(): db = Session() try: yield db finally: db.close() app.dependency_overrides[get_db] = override_get_db with TestClient(app, base_url="http://localhost", raise_server_exceptions=False) as client: yield client app.dependency_overrides.pop(get_db, None) @pytest.fixture() def starter_plan(bill_session): """A SubscriptionPlan with Stripe price IDs in the DB.""" plan = SubscriptionPlan( plan_id="starter", name="Starter", price_monthly=9.0, price_yearly=90.0, trial_days=0, stripe_price_id_monthly="price_monthly_starter", stripe_price_id_yearly="price_yearly_starter", ) bill_session.add(plan) bill_session.commit() return plan @pytest.fixture() def user_profile(bill_session): """A UserProfile for user1@example.com.""" profile = UserProfile( user_id="user1@example.com", display_name="Test User", stripe_customer_id=None, ) bill_session.add(profile) bill_session.commit() return profile # --------------------------------------------------------------------------- # Tests: _get_stripe returns None when not configured # --------------------------------------------------------------------------- @pytest.mark.unit def test_get_stripe_returns_none_when_not_configured(): """_get_stripe returns None when stripe_secret_key is not set.""" from app.api.billing import _get_stripe with patch("app.api.billing.settings") as mock_settings: mock_settings.stripe_secret_key = None result = _get_stripe() assert result is None @pytest.mark.unit def test_get_stripe_returns_client_when_configured(): """_get_stripe returns a StripeClient when key is configured.""" from app.api.billing import _get_stripe with patch("app.api.billing.settings") as mock_settings: mock_settings.stripe_secret_key = "sk_test_fake" result = _get_stripe() assert result is not None # --------------------------------------------------------------------------- # Tests: create-checkout-session # --------------------------------------------------------------------------- @pytest.mark.integration def test_create_checkout_session_stripe_not_configured(bill_client): """POST /api/billing/create-checkout-session returns 503 when Stripe not set.""" with patch("app.api.billing._get_stripe", return_value=None): resp = bill_client.post( "/api/billing/create-checkout-session", json={"plan_id": "starter", "billing_cycle": "monthly"}, ) assert resp.status_code == 503 @pytest.mark.integration def test_create_checkout_session_plan_not_found(bill_client): """POST /api/billing/create-checkout-session returns 404 for unknown plan.""" mock_client = MagicMock() with patch("app.api.billing._get_stripe", return_value=mock_client): resp = bill_client.post( "/api/billing/create-checkout-session", json={"plan_id": "nonexistent", "billing_cycle": "monthly"}, ) assert resp.status_code == 404 @pytest.mark.integration def test_create_checkout_session_no_price_id(bill_client, bill_session): """POST /api/billing/create-checkout-session returns 404 when price ID not set.""" plan = SubscriptionPlan( plan_id="noprice", name="No Price", price_monthly=5.0, price_yearly=50.0, trial_days=0, stripe_price_id_monthly=None, stripe_price_id_yearly=None, ) bill_session.add(plan) bill_session.commit() mock_client = MagicMock() with patch("app.api.billing._get_stripe", return_value=mock_client): resp = bill_client.post( "/api/billing/create-checkout-session", json={"plan_id": "noprice", "billing_cycle": "monthly"}, ) assert resp.status_code == 404 @pytest.mark.integration def test_create_checkout_session_success(bill_client, starter_plan, user_profile): """POST /api/billing/create-checkout-session returns checkout_url on success.""" mock_client = MagicMock() mock_customer = MagicMock() mock_customer.id = "cus_test123" mock_session = MagicMock() mock_session.id = "cs_test456" mock_session.url = "https://checkout.stripe.com/test" mock_client.customers.create.return_value = mock_customer mock_client.checkout.sessions.create.return_value = mock_session with ( patch("app.api.billing._get_stripe", return_value=mock_client), patch("app.api.billing.get_current_owner_id", return_value="user1@example.com"), ): resp = bill_client.post( "/api/billing/create-checkout-session", json={"plan_id": "starter", "billing_cycle": "monthly"}, ) assert resp.status_code == 200 data = resp.json() assert "checkout_url" in data assert data["checkout_url"] == "https://checkout.stripe.com/test" @pytest.mark.integration def test_create_checkout_session_yearly(bill_client, starter_plan, user_profile): """POST /api/billing/create-checkout-session uses yearly price ID for yearly cycle.""" mock_client = MagicMock() mock_customer = MagicMock() mock_customer.id = "cus_test123" mock_session = MagicMock() mock_session.id = "cs_test456" mock_session.url = "https://checkout.stripe.com/yearly" mock_client.customers.create.return_value = mock_customer mock_client.checkout.sessions.create.return_value = mock_session with ( patch("app.api.billing._get_stripe", return_value=mock_client), patch("app.api.billing.get_current_owner_id", return_value="user1@example.com"), ): resp = bill_client.post( "/api/billing/create-checkout-session", json={"plan_id": "starter", "billing_cycle": "yearly"}, ) assert resp.status_code == 200 # Verify yearly price ID was used call_params = mock_client.checkout.sessions.create.call_args[1]["params"] assert call_params["line_items"][0]["price"] == "price_yearly_starter" # --------------------------------------------------------------------------- # Tests: create-portal-session # --------------------------------------------------------------------------- @pytest.mark.integration def test_create_portal_session_stripe_not_configured(bill_client): """POST /api/billing/create-portal-session returns 503 when not configured.""" with patch("app.api.billing._get_stripe", return_value=None): resp = bill_client.post("/api/billing/create-portal-session", json={}) assert resp.status_code == 503 @pytest.mark.integration def test_create_portal_session_no_customer(bill_client, user_profile): """POST /api/billing/create-portal-session returns 404 when no Stripe customer.""" mock_client = MagicMock() with ( patch("app.api.billing._get_stripe", return_value=mock_client), patch("app.api.billing.get_current_owner_id", return_value="user1@example.com"), ): resp = bill_client.post("/api/billing/create-portal-session", json={}) assert resp.status_code == 404 @pytest.mark.integration def test_create_portal_session_success(bill_client, bill_session, user_profile): """POST /api/billing/create-portal-session returns portal_url on success.""" user_profile.stripe_customer_id = "cus_existing" bill_session.commit() mock_client = MagicMock() mock_portal = MagicMock() mock_portal.url = "https://billing.stripe.com/portal/test" mock_client.billing_portal.sessions.create.return_value = mock_portal with ( patch("app.api.billing._get_stripe", return_value=mock_client), patch("app.api.billing.get_current_owner_id", return_value="user1@example.com"), ): resp = bill_client.post("/api/billing/create-portal-session", json={}) assert resp.status_code == 200 assert resp.json()["portal_url"] == "https://billing.stripe.com/portal/test" # --------------------------------------------------------------------------- # Tests: webhook # --------------------------------------------------------------------------- @pytest.mark.integration def test_webhook_not_configured(bill_client): """POST /api/billing/webhook returns 503 when billing not configured.""" with patch("app.api.billing.settings") as mock_settings: mock_settings.stripe_secret_key = None mock_settings.stripe_webhook_secret = None resp = bill_client.post( "/api/billing/webhook", content=b"{}", headers={"content-type": "application/json"}, ) assert resp.status_code == 503 @pytest.mark.integration def test_webhook_invalid_signature(bill_client): """POST /api/billing/webhook returns 400 on invalid Stripe signature.""" import stripe with ( patch("app.api.billing.settings") as mock_settings, patch("stripe.Webhook.construct_event", side_effect=stripe.SignatureVerificationError("bad", "sig")), ): mock_settings.stripe_secret_key = "sk_test_fake" mock_settings.stripe_webhook_secret = "whsec_test" resp = bill_client.post( "/api/billing/webhook", content=b'{"type":"test"}', headers={"stripe-signature": "bad_sig", "content-type": "application/json"}, ) assert resp.status_code == 400 @pytest.mark.integration def test_webhook_checkout_completed(bill_client, bill_session, starter_plan, user_profile): """POST /api/billing/webhook activates plan on checkout.session.completed.""" payload = json.dumps( { "type": "checkout.session.completed", "data": { "object": { "customer": "cus_new", "metadata": { "docuelevate_user_id": "user1@example.com", "plan_id": "starter", "billing_cycle": "monthly", }, } }, } ).encode() with ( patch("app.api.billing.settings") as mock_settings, patch("stripe.Event.construct_from", return_value=json.loads(payload)), ): mock_settings.stripe_secret_key = "sk_test_fake" mock_settings.stripe_webhook_secret = None resp = bill_client.post( "/api/billing/webhook", content=payload, headers={"content-type": "application/json"}, ) assert resp.status_code == 200 bill_session.expire_all() profile = bill_session.query(UserProfile).filter(UserProfile.user_id == "user1@example.com").first() assert profile.subscription_tier == "starter" @pytest.mark.integration def test_webhook_subscription_deleted(bill_client, bill_session, user_profile): """POST /api/billing/webhook downgrades to free on subscription deleted.""" user_profile.stripe_customer_id = "cus_del" user_profile.subscription_tier = "starter" bill_session.commit() payload = json.dumps( { "type": "customer.subscription.deleted", "data": {"object": {"customer": "cus_del"}}, } ).encode() with ( patch("app.api.billing.settings") as mock_settings, patch("stripe.Event.construct_from", return_value=json.loads(payload)), ): mock_settings.stripe_secret_key = "sk_test_fake" mock_settings.stripe_webhook_secret = None resp = bill_client.post( "/api/billing/webhook", content=payload, headers={"content-type": "application/json"}, ) assert resp.status_code == 200 bill_session.expire_all() profile = bill_session.query(UserProfile).filter(UserProfile.user_id == "user1@example.com").first() assert profile.subscription_tier == "free" # --------------------------------------------------------------------------- # Unit tests: internal helpers # --------------------------------------------------------------------------- @pytest.mark.unit def test_resolve_user_id_from_customer(bill_session, user_profile): """_resolve_user_id_from_customer returns user_id for known Stripe customer.""" user_profile.stripe_customer_id = "cus_known" bill_session.commit() result = _resolve_user_id_from_customer(bill_session, "cus_known") assert result == "user1@example.com" @pytest.mark.unit def test_resolve_user_id_from_customer_unknown(bill_session): """_resolve_user_id_from_customer returns None for unknown customer.""" result = _resolve_user_id_from_customer(bill_session, "cus_unknown") assert result is None @pytest.mark.unit def test_resolve_plan_id_from_price(bill_session, starter_plan): """_resolve_plan_id_from_price finds plan by monthly price ID.""" result = _resolve_plan_id_from_price(bill_session, "price_monthly_starter") assert result == "starter" @pytest.mark.unit def test_resolve_plan_id_from_price_yearly(bill_session, starter_plan): """_resolve_plan_id_from_price finds plan by yearly price ID.""" result = _resolve_plan_id_from_price(bill_session, "price_yearly_starter") assert result == "starter" @pytest.mark.unit def test_resolve_plan_id_from_price_unknown(bill_session): """_resolve_plan_id_from_price returns None for unknown price.""" result = _resolve_plan_id_from_price(bill_session, "price_unknown") assert result is None @pytest.mark.unit def test_on_checkout_completed_missing_user_id(bill_session): """_on_checkout_completed does nothing when user_id is absent.""" data = {"metadata": {}, "customer": "cus_test"} _on_checkout_completed(bill_session, data) # Should not raise @pytest.mark.unit def test_on_subscription_updated_no_items(bill_session, user_profile): """_on_subscription_updated does nothing when items list is empty.""" user_profile.stripe_customer_id = "cus_upd" bill_session.commit() data = {"customer": "cus_upd", "items": {"data": []}} _on_subscription_updated(bill_session, data) # Should not raise @pytest.mark.unit def test_on_subscription_deleted_unknown_customer(bill_session): """_on_subscription_deleted does nothing for unknown customer.""" data = {"customer": "cus_nobody"} _on_subscription_deleted(bill_session, data) # Should not raise @pytest.mark.unit def test_handle_stripe_event_unhandled_type(bill_session): """_handle_stripe_event logs but does not raise for unknown event types.""" event = {"type": "unknown.event.type", "data": {"object": {}}} _handle_stripe_event(bill_session, event) # Should not raise @pytest.mark.unit def test_handle_stripe_event_payment_failed(bill_session): """_handle_stripe_event handles invoice.payment_failed without raising.""" event = { "type": "invoice.payment_failed", "data": {"object": {"customer": "cus_fail"}}, } _handle_stripe_event(bill_session, event) # Should not raise # --------------------------------------------------------------------------- # Tests: billing success page # --------------------------------------------------------------------------- @pytest.mark.integration def test_billing_success_page(bill_client): """GET /api/billing/success returns 200 for logged-in user.""" resp = bill_client.get("/api/billing/success") assert resp.status_code == 200 assert b"subscription" in resp.content.lower() or b"success" in resp.content.lower() # --------------------------------------------------------------------------- # Tests: GET /api/billing/stripe/status (admin only) # --------------------------------------------------------------------------- @pytest.mark.integration def test_stripe_status_not_admin(bill_client): """GET /api/billing/stripe/status returns 403 for non-admin.""" with patch("app.api.billing.settings") as mock_settings: mock_settings.stripe_secret_key = "sk_test_fake" mock_settings.stripe_webhook_secret = "whsec_test" # bill_client session has no is_admin flag resp = bill_client.get("/api/billing/stripe/status") assert resp.status_code == 403 @pytest.mark.integration def test_stripe_status_not_configured(bill_client): """GET /api/billing/stripe/status returns configured=False when key is missing.""" with ( patch("app.api.billing.settings") as mock_settings, patch( "app.api.billing._require_admin", return_value=None, ), ): mock_settings.stripe_secret_key = None mock_settings.stripe_webhook_secret = None resp = bill_client.get("/api/billing/stripe/status") assert resp.status_code == 200 data = resp.json() assert data["configured"] is False assert data["connection"] == "not_configured" @pytest.mark.integration def test_stripe_status_configured_ok(bill_client, starter_plan): """GET /api/billing/stripe/status returns plan list with sync status when configured.""" mock_account = MagicMock() mock_account.livemode = False # test mode mock_client = MagicMock() mock_client.accounts.retrieve.return_value = mock_account with ( patch("app.api.billing._get_stripe", return_value=mock_client), patch("app.api.billing.settings") as mock_settings, patch("app.api.billing._require_admin", return_value=None), ): mock_settings.stripe_secret_key = "sk_test_fake" mock_settings.stripe_webhook_secret = "whsec_test" resp = bill_client.get("/api/billing/stripe/status") assert resp.status_code == 200 data = resp.json() assert data["configured"] is True assert data["connection"] == "ok" assert data["mode"] == "test" assert data["webhook_secret_configured"] is True assert "plans" in data assert "webhook_endpoint" in data # starter_plan has price IDs set → synced plan_entry = next((p for p in data["plans"] if p["plan_id"] == "starter"), None) assert plan_entry is not None assert plan_entry["stripe_price_id_monthly"] == "price_monthly_starter" assert plan_entry["synced"] is True @pytest.mark.integration def test_stripe_status_plan_not_synced(bill_client, bill_session): """GET /api/billing/stripe/status shows synced=False for paid plan without price IDs.""" plan = SubscriptionPlan( plan_id="unsynced", name="Unsynced", price_monthly=5.0, price_yearly=50.0, trial_days=0, stripe_price_id_monthly=None, stripe_price_id_yearly=None, ) bill_session.add(plan) bill_session.commit() mock_account = MagicMock() mock_account.livemode = False mock_client = MagicMock() mock_client.accounts.retrieve.return_value = mock_account with ( patch("app.api.billing._get_stripe", return_value=mock_client), patch("app.api.billing.settings") as mock_settings, patch("app.api.billing._require_admin", return_value=None), ): mock_settings.stripe_secret_key = "sk_test_fake" mock_settings.stripe_webhook_secret = None resp = bill_client.get("/api/billing/stripe/status") assert resp.status_code == 200 data = resp.json() assert data["webhook_secret_configured"] is False entry = next((p for p in data["plans"] if p["plan_id"] == "unsynced"), None) assert entry is not None assert entry["synced"] is False # --------------------------------------------------------------------------- # Tests: POST /api/billing/stripe/sync-plans (admin only) # --------------------------------------------------------------------------- @pytest.mark.integration def test_stripe_sync_plans_not_configured(bill_client): """POST /api/billing/stripe/sync-plans returns 503 when billing not configured.""" with ( patch("app.api.billing._get_stripe", return_value=None), patch("app.api.billing._require_admin", return_value=None), ): resp = bill_client.post("/api/billing/stripe/sync-plans") assert resp.status_code == 503 @pytest.mark.integration def test_stripe_sync_plans_not_admin(bill_client): """POST /api/billing/stripe/sync-plans returns 403 for non-admin.""" mock_client = MagicMock() with patch("app.api.billing._get_stripe", return_value=mock_client): resp = bill_client.post("/api/billing/stripe/sync-plans") assert resp.status_code == 403 @pytest.mark.integration def test_stripe_sync_plans_creates_prices(bill_client, bill_session): """POST /api/billing/stripe/sync-plans creates Stripe products and prices for paid plans.""" plan = SubscriptionPlan( plan_id="pro", name="Professional", price_monthly=19.0, price_yearly=190.0, trial_days=0, stripe_price_id_monthly=None, stripe_price_id_yearly=None, ) bill_session.add(plan) bill_session.commit() mock_client = MagicMock() mock_product = MagicMock() mock_product.id = "prod_test123" mock_monthly_price = MagicMock() mock_monthly_price.id = "price_monthly_pro_new" mock_yearly_price = MagicMock() mock_yearly_price.id = "price_yearly_pro_new" mock_client.products.create.return_value = mock_product mock_client.prices.create.side_effect = [mock_monthly_price, mock_yearly_price] with ( patch("app.api.billing._get_stripe", return_value=mock_client), patch("app.api.billing._require_admin", return_value=None), ): resp = bill_client.post("/api/billing/stripe/sync-plans") assert resp.status_code == 200 data = resp.json() assert "results" in data result = next((r for r in data["results"] if r["plan_id"] == "pro"), None) assert result is not None assert result["status"] == "created" assert result["stripe_price_id_monthly"] == "price_monthly_pro_new" assert result["stripe_price_id_yearly"] == "price_yearly_pro_new" # Verify DB was updated bill_session.expire_all() db_plan = bill_session.query(SubscriptionPlan).filter(SubscriptionPlan.plan_id == "pro").first() assert db_plan.stripe_price_id_monthly == "price_monthly_pro_new" assert db_plan.stripe_price_id_yearly == "price_yearly_pro_new" # Verify correct amounts were passed to Stripe, matching by interval (order-independent) price_calls = mock_client.prices.create.call_args_list by_interval = {call[1]["params"]["recurring"]["interval"]: call[1]["params"] for call in price_calls} assert by_interval["month"]["unit_amount"] == 1900 # $19.00 → 1900 cents assert by_interval["year"]["unit_amount"] == 19000 # $190.00 → 19000 cents @pytest.mark.integration def test_stripe_sync_plans_skips_free(bill_client, bill_session): """POST /api/billing/stripe/sync-plans skips free plans.""" free_plan = SubscriptionPlan( plan_id="free_test", name="Free", price_monthly=0.0, price_yearly=0.0, trial_days=0, stripe_price_id_monthly=None, stripe_price_id_yearly=None, ) bill_session.add(free_plan) bill_session.commit() mock_client = MagicMock() with ( patch("app.api.billing._get_stripe", return_value=mock_client), patch("app.api.billing._require_admin", return_value=None), ): resp = bill_client.post("/api/billing/stripe/sync-plans") assert resp.status_code == 200 data = resp.json() result = next((r for r in data["results"] if r["plan_id"] == "free_test"), None) assert result is not None assert result["status"] == "skipped_free" # Products.create should NOT have been called for a free plan mock_client.products.create.assert_not_called() @pytest.mark.integration def test_stripe_sync_plans_skips_already_synced(bill_client, starter_plan): """POST /api/billing/stripe/sync-plans skips plans that already have price IDs.""" mock_client = MagicMock() with ( patch("app.api.billing._get_stripe", return_value=mock_client), patch("app.api.billing._require_admin", return_value=None), ): resp = bill_client.post("/api/billing/stripe/sync-plans") assert resp.status_code == 200 data = resp.json() result = next((r for r in data["results"] if r["plan_id"] == "starter"), None) assert result is not None assert result["status"] == "already_synced" mock_client.products.create.assert_not_called() @pytest.mark.integration def test_stripe_sync_plans_handles_stripe_error(bill_client, bill_session): """POST /api/billing/stripe/sync-plans returns error status when Stripe API fails.""" plan = SubscriptionPlan( plan_id="errplan", name="Error Plan", price_monthly=9.99, price_yearly=0.0, trial_days=0, stripe_price_id_monthly=None, stripe_price_id_yearly=None, ) bill_session.add(plan) bill_session.commit() mock_client = MagicMock() mock_client.products.create.side_effect = Exception("Stripe connection error") with ( patch("app.api.billing._get_stripe", return_value=mock_client), patch("app.api.billing._require_admin", return_value=None), ): resp = bill_client.post("/api/billing/stripe/sync-plans") assert resp.status_code == 200 data = resp.json() result = next((r for r in data["results"] if r["plan_id"] == "errplan"), None) assert result is not None assert result["status"] == "error" assert "Stripe connection error" in result["detail"] # --------------------------------------------------------------------------- # Tests: plans API now exposes stripe_price_id fields # --------------------------------------------------------------------------- @pytest.mark.unit def test_plan_to_response_includes_stripe_ids(bill_session, starter_plan): """_plan_to_response includes stripe_price_id_monthly and stripe_price_id_yearly.""" from app.api.plans import _plan_to_response result = _plan_to_response(starter_plan) assert result["stripe_price_id_monthly"] == "price_monthly_starter" assert result["stripe_price_id_yearly"] == "price_yearly_starter" @pytest.mark.integration def test_plan_api_returns_stripe_ids(bill_client, starter_plan): """GET /api/plans/{plan_id} returns Stripe price IDs in the response.""" resp = bill_client.get("/api/plans/starter") assert resp.status_code == 200 data = resp.json() assert data["stripe_price_id_monthly"] == "price_monthly_starter" assert data["stripe_price_id_yearly"] == "price_yearly_starter" @pytest.mark.integration def test_plan_api_update_sets_stripe_ids(bill_client, bill_engine, starter_plan): """PUT /api/plans/{plan_id} can update Stripe price IDs.""" from app.api.plans import _require_admin from app.main import app def override_admin(): return {"email": "admin@test.com", "is_admin": True} app.dependency_overrides[_require_admin] = override_admin try: resp = bill_client.put( "/api/plans/starter", json={ "name": "Starter", "price_monthly": 9.0, "price_yearly": 90.0, "stripe_price_id_monthly": "price_new_monthly", "stripe_price_id_yearly": "price_new_yearly", "features": [], }, ) finally: app.dependency_overrides.pop(_require_admin, None) assert resp.status_code == 200 data = resp.json() assert data["stripe_price_id_monthly"] == "price_new_monthly" assert data["stripe_price_id_yearly"] == "price_new_yearly" @pytest.mark.integration def test_plan_api_update_clears_stripe_ids(bill_client, bill_engine, starter_plan): """PUT /api/plans/{plan_id} clears Stripe price IDs when empty string is passed.""" from app.api.plans import _require_admin from app.main import app def override_admin(): return {"email": "admin@test.com", "is_admin": True} app.dependency_overrides[_require_admin] = override_admin try: resp = bill_client.put( "/api/plans/starter", json={ "name": "Starter", "price_monthly": 9.0, "price_yearly": 90.0, "stripe_price_id_monthly": "", "stripe_price_id_yearly": "", "features": [], }, ) finally: app.dependency_overrides.pop(_require_admin, None) assert resp.status_code == 200 data = resp.json() assert data["stripe_price_id_monthly"] is None assert data["stripe_price_id_yearly"] is None