# app/modules/billing/tests/integration/test_merchant_routes.py """ Integration tests for merchant billing API routes. Tests the merchant portal billing endpoints at: /api/v1/merchants/billing/* Authentication: Overrides get_current_merchant_from_cookie_or_header with a mock that returns a UserContext for the merchant owner. """ import uuid from datetime import UTC, datetime, timedelta from unittest.mock import patch import pytest from app.api.deps import get_current_merchant_api, get_merchant_for_current_user from app.modules.billing.models import ( BillingHistory, MerchantSubscription, SubscriptionStatus, SubscriptionTier, ) from app.modules.tenancy.models import Merchant, Platform, User from main import app from models.schema.auth import UserContext # ============================================================================ # Fixtures # ============================================================================ BASE = "/api/v1/merchants/billing" @pytest.fixture def merch_owner(db): """Create a merchant owner user.""" from middleware.auth import AuthManager auth = AuthManager() user = User( email=f"merchowner_{uuid.uuid4().hex[:8]}@test.com", username=f"merchowner_{uuid.uuid4().hex[:8]}", hashed_password=auth.hash_password("merchpass123"), role="merchant_owner", is_active=True, ) db.add(user) db.commit() db.refresh(user) return user @pytest.fixture def merch_platform(db): """Create a platform for merchant tests.""" platform = Platform( code=f"merch_{uuid.uuid4().hex[:8]}", name="Merchant Test Platform", is_active=True, ) db.add(platform) db.commit() db.refresh(platform) return platform @pytest.fixture def merch_merchant(db, merch_owner): """Create a merchant owned by merch_owner.""" merchant = Merchant( name="Merchant Route Test", owner_user_id=merch_owner.id, contact_email=merch_owner.email, is_active=True, is_verified=True, ) db.add(merchant) db.commit() db.refresh(merchant) return merchant @pytest.fixture def merch_tiers(db, merch_platform): """Create tiers for merchant tests.""" tiers = [] for i, (code, name, price) in enumerate([ ("essential", "Essential", 0), ("professional", "Professional", 2900), ("business", "Business", 7900), ]): tier = SubscriptionTier( code=code, name=name, description=f"{name} tier", price_monthly_cents=price, price_annual_cents=price * 10 if price > 0 else 0, display_order=i, is_active=True, is_public=True, platform_id=merch_platform.id, ) db.add(tier) tiers.append(tier) db.commit() for t in tiers: db.refresh(t) return tiers @pytest.fixture def merch_subscription(db, merch_merchant, merch_platform, merch_tiers): """Create a subscription for the merchant.""" sub = MerchantSubscription( merchant_id=merch_merchant.id, platform_id=merch_platform.id, tier_id=merch_tiers[1].id, # professional status=SubscriptionStatus.ACTIVE.value, is_annual=False, period_start=datetime.now(UTC), period_end=datetime.now(UTC) + timedelta(days=30), ) db.add(sub) db.commit() db.refresh(sub) return sub @pytest.fixture def merch_invoices(db, merch_merchant): """Create invoice records for the merchant.""" records = [] for i in range(3): record = BillingHistory( merchant_id=merch_merchant.id, invoice_number=f"MINV-{2000 + i}", invoice_date=datetime.now(UTC) - timedelta(days=30 * i), subtotal_cents=2900, tax_cents=493, total_cents=3393, amount_paid_cents=3393, currency="EUR", status="paid", description=f"Merchant invoice {i}", ) db.add(record) # noqa: PERF006 records.append(record) db.commit() for r in records: db.refresh(r) return records @pytest.fixture def merch_auth_headers(merch_owner, merch_merchant): """Override auth dependencies to return merchant/user for the merchant owner.""" user_context = UserContext( id=merch_owner.id, email=merch_owner.email, username=merch_owner.username, role="merchant_owner", is_active=True, ) def _override_merchant(): return merch_merchant def _override_user(): return user_context app.dependency_overrides[get_merchant_for_current_user] = _override_merchant app.dependency_overrides[get_current_merchant_api] = _override_user yield {"Authorization": "Bearer fake-token"} app.dependency_overrides.pop(get_merchant_for_current_user, None) app.dependency_overrides.pop(get_current_merchant_api, None) # ============================================================================ # Subscription Endpoints # ============================================================================ class TestMerchantListSubscriptions: """Tests for GET /api/v1/merchants/billing/subscriptions.""" def test_list_subscriptions_success( self, client, merch_auth_headers, merch_subscription, merch_merchant ): response = client.get( f"{BASE}/subscriptions", headers=merch_auth_headers ) assert response.status_code == 200 data = response.json() assert "subscriptions" in data assert "total" in data assert data["total"] >= 1 def test_list_subscriptions_includes_tier_info( self, client, merch_auth_headers, merch_subscription ): response = client.get( f"{BASE}/subscriptions", headers=merch_auth_headers ) assert response.status_code == 200 sub = response.json()["subscriptions"][0] assert "tier" in sub assert "tier_name" in sub assert sub["tier"] == "professional" def test_list_subscriptions_empty( self, client, merch_auth_headers, merch_merchant ): response = client.get( f"{BASE}/subscriptions", headers=merch_auth_headers ) assert response.status_code == 200 assert response.json()["total"] == 0 class TestMerchantGetSubscription: """Tests for GET /api/v1/merchants/billing/subscriptions/{platform_id}.""" def test_get_subscription_success( self, client, merch_auth_headers, merch_subscription, merch_platform, ): response = client.get( f"{BASE}/subscriptions/{merch_platform.id}", headers=merch_auth_headers, ) assert response.status_code == 200 data = response.json() assert "subscription" in data assert "tier" in data assert data["subscription"]["status"] == "active" def test_get_subscription_with_tier_details( self, client, merch_auth_headers, merch_subscription, merch_platform, ): response = client.get( f"{BASE}/subscriptions/{merch_platform.id}", headers=merch_auth_headers, ) assert response.status_code == 200 tier = response.json()["tier"] assert tier is not None assert tier["code"] == "professional" assert tier["price_monthly_cents"] == 2900 def test_get_subscription_not_found( self, client, merch_auth_headers, merch_merchant ): response = client.get( f"{BASE}/subscriptions/99999", headers=merch_auth_headers, ) assert response.status_code == 404 class TestMerchantGetAvailableTiers: """Tests for GET /api/v1/merchants/billing/subscriptions/{platform_id}/tiers.""" def test_get_tiers_success( self, client, merch_auth_headers, merch_subscription, merch_platform, merch_tiers, ): response = client.get( f"{BASE}/subscriptions/{merch_platform.id}/tiers", headers=merch_auth_headers, ) assert response.status_code == 200 data = response.json() assert "tiers" in data assert "current_tier" in data assert data["current_tier"] == "professional" def test_get_tiers_includes_upgrade_info( self, client, merch_auth_headers, merch_subscription, merch_platform, merch_tiers, ): response = client.get( f"{BASE}/subscriptions/{merch_platform.id}/tiers", headers=merch_auth_headers, ) assert response.status_code == 200 tiers = response.json()["tiers"] assert len(tiers) >= 3 class TestMerchantChangeTier: """Tests for POST /api/v1/merchants/billing/subscriptions/{platform_id}/change-tier.""" @patch("app.modules.billing.routes.api.merchant.billing_service") def test_change_tier_success( self, mock_billing, client, merch_auth_headers, merch_subscription, merch_platform, merch_tiers, ): mock_billing.change_tier.return_value = { "message": "Tier changed to business", "new_tier": "business", "effective_immediately": True, } response = client.post( f"{BASE}/subscriptions/{merch_platform.id}/change-tier", json={"tier_code": "business", "is_annual": False}, headers=merch_auth_headers, ) assert response.status_code == 200 data = response.json() assert "new_tier" in data or "message" in data mock_billing.change_tier.assert_called_once() def test_change_tier_no_stripe_returns_error( self, client, merch_auth_headers, merch_subscription, merch_platform, ): """Without Stripe subscription, change_tier returns 400.""" response = client.post( f"{BASE}/subscriptions/{merch_platform.id}/change-tier", json={"tier_code": "business", "is_annual": False}, headers=merch_auth_headers, ) assert response.status_code == 400 class TestMerchantCheckout: """Tests for POST /api/v1/merchants/billing/subscriptions/{platform_id}/checkout.""" @patch("app.modules.billing.routes.api.merchant.billing_service") def test_create_checkout_with_stripe( self, mock_billing, client, merch_auth_headers, merch_subscription, merch_platform, merch_tiers, ): mock_billing.create_checkout_session.return_value = { "checkout_url": "https://checkout.stripe.com/test", "session_id": "cs_test_123", } response = client.post( f"{BASE}/subscriptions/{merch_platform.id}/checkout", json={"tier_code": "business", "is_annual": False}, headers=merch_auth_headers, ) assert response.status_code == 200 data = response.json() assert data["checkout_url"] == "https://checkout.stripe.com/test" assert data["session_id"] == "cs_test_123" mock_billing.create_checkout_session.assert_called_once() # ============================================================================ # Invoice Endpoints # ============================================================================ class TestMerchantInvoices: """Tests for GET /api/v1/merchants/billing/invoices.""" def test_list_invoices_success( self, client, merch_auth_headers, merch_invoices ): response = client.get( f"{BASE}/invoices", headers=merch_auth_headers ) assert response.status_code == 200 data = response.json() assert "invoices" in data assert "total" in data assert data["total"] >= 3 def test_list_invoices_pagination( self, client, merch_auth_headers, merch_invoices ): response = client.get( f"{BASE}/invoices", params={"skip": 0, "limit": 2}, headers=merch_auth_headers, ) assert response.status_code == 200 data = response.json() assert len(data["invoices"]) <= 2 def test_list_invoices_response_shape( self, client, merch_auth_headers, merch_invoices ): response = client.get( f"{BASE}/invoices", headers=merch_auth_headers ) assert response.status_code == 200 inv = response.json()["invoices"][0] assert "id" in inv assert "invoice_number" in inv assert "invoice_date" in inv assert "total_cents" in inv assert "currency" in inv assert "status" in inv def test_list_invoices_empty( self, client, merch_auth_headers, merch_merchant ): response = client.get( f"{BASE}/invoices", headers=merch_auth_headers ) assert response.status_code == 200 assert response.json()["total"] == 0