Files
orion/app/modules/billing/tests/integration/test_merchant_routes.py
Samir Boulahtit 4aa6f76e46
Some checks failed
CI / validate (push) Has been cancelled
CI / dependency-scanning (push) Has been cancelled
CI / docs (push) Has been cancelled
CI / deploy (push) Has been cancelled
CI / pytest (push) Has been cancelled
CI / ruff (push) Successful in 10s
refactor(arch): move auth schemas to tenancy module and add cross-module service methods
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>
2026-02-26 23:57:04 +01:00

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