Some checks failed
Move all auth schemas (UserContext, UserLogin, LoginResponse, etc.) from legacy models/schema/auth.py to app/modules/tenancy/schemas/auth.py per MOD-019. Update 84 import sites across 14 modules. Legacy file now re-exports for backwards compatibility. Add missing tenancy service methods for cross-module consumers: - merchant_service.get_merchant_by_owner_id() - merchant_service.get_merchant_count_for_owner() - admin_service.get_user_by_id() (public, was private-only) - platform_service.get_active_store_count() Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
437 lines
13 KiB
Python
437 lines
13 KiB
Python
# 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 app.modules.tenancy.schemas.auth import UserContext
|
|
from main import app
|
|
|
|
# ============================================================================
|
|
# 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
|