Files
orion/app/modules/billing/tests/integration/test_admin_routes.py
Samir Boulahtit 32acc76b49 feat: platform-aware storefront routing and billing improvements
Overhaul storefront URL routing to be platform-aware:
- Dev: /platforms/{code}/storefront/{store_code}/
- Prod: subdomain.platform.lu/ (internally rewritten to /storefront/)
- Add subdomain detection in PlatformContextMiddleware
- Add /storefront/ path rewrite for prod mode (subdomain/custom domain)
- Remove all silent platform fallbacks (platform_id=1)
- Add require_platform dependency for clean endpoint validation
- Update route registration, templates, module definitions, base_url calc
- Update StoreContextMiddleware for /storefront/ path detection
- Remove /stores/ from FrontendDetector STOREFRONT_PATH_PREFIXES

Billing service improvements:
- Add store_platform_sync_service to keep store_platforms in sync
- Make tier lookups platform-aware across billing services
- Add tiers for all platforms in seed data
- Add demo subscriptions to seed

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 23:42:41 +01:00

563 lines
18 KiB
Python

# app/modules/billing/tests/integration/test_admin_routes.py
"""
Integration tests for billing admin API routes.
Tests the admin subscription management endpoints at:
/api/v1/admin/subscriptions/*
Uses super_admin_headers fixture which bypasses module access checks.
"""
import uuid
from datetime import UTC, datetime, timedelta
import pytest
from app.modules.billing.models import (
BillingHistory,
MerchantSubscription,
SubscriptionStatus,
SubscriptionTier,
)
from app.modules.tenancy.models import Merchant, Platform, User
# ============================================================================
# Fixtures
# ============================================================================
BASE = "/api/v1/admin/subscriptions"
@pytest.fixture
def rt_platform(db):
"""Create a platform for route tests."""
platform = Platform(
code=f"test_{uuid.uuid4().hex[:8]}",
name="Test Platform",
is_active=True,
)
db.add(platform)
db.commit()
db.refresh(platform)
return platform
@pytest.fixture
def rt_tiers(db, rt_platform):
"""Create subscription tiers for route 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=rt_platform.id,
)
db.add(tier)
tiers.append(tier)
db.commit()
for t in tiers:
db.refresh(t)
return tiers
@pytest.fixture
def rt_merchant(db, rt_platform):
"""Create a merchant with owner for route tests."""
from middleware.auth import AuthManager
auth = AuthManager()
owner = User(
email=f"merchant_{uuid.uuid4().hex[:8]}@test.com",
username=f"merchant_{uuid.uuid4().hex[:8]}",
hashed_password=auth.hash_password("pass123"),
role="merchant_owner",
is_active=True,
)
db.add(owner)
db.commit()
db.refresh(owner)
merchant = Merchant(
name="Route Test Merchant",
owner_user_id=owner.id,
contact_email=owner.email,
is_active=True,
is_verified=True,
)
db.add(merchant)
db.commit()
db.refresh(merchant)
return merchant
@pytest.fixture
def rt_subscription(db, rt_merchant, rt_platform, rt_tiers):
"""Create a subscription for route tests."""
sub = MerchantSubscription(
merchant_id=rt_merchant.id,
platform_id=rt_platform.id,
tier_id=rt_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 rt_billing_history(db, rt_merchant):
"""Create billing history entries for route tests."""
records = []
for i in range(3):
record = BillingHistory(
merchant_id=rt_merchant.id,
invoice_number=f"INV-{1000 + 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"Invoice {i}",
)
db.add(record) # noqa: PERF006
records.append(record)
db.commit()
for r in records:
db.refresh(r)
return records
# ============================================================================
# Tier Endpoints
# ============================================================================
class TestAdminListTiers:
"""Tests for GET /api/v1/admin/subscriptions/tiers."""
def test_list_tiers_success(self, client, super_admin_headers, rt_tiers):
response = client.get(f"{BASE}/tiers", headers=super_admin_headers)
assert response.status_code == 200
data = response.json()
assert "tiers" in data
assert "total" in data
assert data["total"] >= 3
def test_list_tiers_active_only_by_default(self, client, super_admin_headers, rt_tiers, db):
# Deactivate one tier
rt_tiers[2].is_active = False
db.commit()
response = client.get(f"{BASE}/tiers", headers=super_admin_headers)
assert response.status_code == 200
codes = [t["code"] for t in response.json()["tiers"]]
assert "business" not in codes
def test_list_tiers_include_inactive(self, client, super_admin_headers, rt_tiers, db):
rt_tiers[2].is_active = False
db.commit()
response = client.get(
f"{BASE}/tiers",
params={"include_inactive": True},
headers=super_admin_headers,
)
assert response.status_code == 200
codes = [t["code"] for t in response.json()["tiers"]]
assert "business" in codes
def test_list_tiers_filter_by_platform(self, client, super_admin_headers, rt_tiers, rt_platform):
response = client.get(
f"{BASE}/tiers",
params={"platform_id": rt_platform.id},
headers=super_admin_headers,
)
assert response.status_code == 200
assert response.json()["total"] == 3
def test_list_tiers_unauthorized(self, client):
response = client.get(f"{BASE}/tiers")
assert response.status_code in (401, 403)
class TestAdminGetTier:
"""Tests for GET /api/v1/admin/subscriptions/tiers/{tier_code}."""
def test_get_tier_success(self, client, super_admin_headers, rt_tiers):
response = client.get(
f"{BASE}/tiers/professional", headers=super_admin_headers
)
assert response.status_code == 200
data = response.json()
assert data["code"] == "professional"
assert data["name"] == "Professional"
assert data["price_monthly_cents"] == 2900
def test_get_tier_not_found(self, client, super_admin_headers):
response = client.get(
f"{BASE}/tiers/nonexistent", headers=super_admin_headers
)
assert response.status_code == 404
class TestAdminCreateTier:
"""Tests for POST /api/v1/admin/subscriptions/tiers."""
def test_create_tier_success(self, client, super_admin_headers, rt_platform):
response = client.post(
f"{BASE}/tiers",
json={
"code": "starter",
"name": "Starter",
"description": "Starter plan",
"price_monthly_cents": 990,
"price_annual_cents": 9900,
"display_order": 0,
"is_active": True,
"is_public": True,
"platform_id": rt_platform.id,
},
headers=super_admin_headers,
)
assert response.status_code == 201
data = response.json()
assert data["code"] == "starter"
assert data["price_monthly_cents"] == 990
def test_create_tier_duplicate_code(self, client, super_admin_headers, rt_tiers):
response = client.post(
f"{BASE}/tiers",
json={
"code": "essential",
"name": "Essential Dup",
"price_monthly_cents": 0,
},
headers=super_admin_headers,
)
assert response.status_code in (400, 409, 422)
class TestAdminUpdateTier:
"""Tests for PATCH /api/v1/admin/subscriptions/tiers/{tier_code}."""
def test_update_tier_success(self, client, super_admin_headers, rt_tiers):
response = client.patch(
f"{BASE}/tiers/professional",
json={"name": "Professional Plus", "price_monthly_cents": 3900},
headers=super_admin_headers,
)
assert response.status_code == 200
data = response.json()
assert data["name"] == "Professional Plus"
assert data["price_monthly_cents"] == 3900
def test_update_tier_not_found(self, client, super_admin_headers):
response = client.patch(
f"{BASE}/tiers/nonexistent",
json={"name": "Updated"},
headers=super_admin_headers,
)
assert response.status_code == 404
class TestAdminDeleteTier:
"""Tests for DELETE /api/v1/admin/subscriptions/tiers/{tier_code}."""
def test_delete_tier_success(self, client, super_admin_headers, rt_tiers):
response = client.delete(
f"{BASE}/tiers/business", headers=super_admin_headers
)
assert response.status_code == 204
def test_delete_tier_with_active_subs(
self, client, super_admin_headers, rt_subscription, rt_tiers
):
# Try to delete the tier used by rt_subscription (professional)
response = client.delete(
f"{BASE}/tiers/professional", headers=super_admin_headers
)
assert response.status_code in (400, 409, 422)
def test_delete_tier_not_found(self, client, super_admin_headers):
response = client.delete(
f"{BASE}/tiers/nonexistent", headers=super_admin_headers
)
assert response.status_code == 404
# ============================================================================
# Subscription Endpoints
# ============================================================================
class TestAdminListSubscriptions:
"""Tests for GET /api/v1/admin/subscriptions."""
def test_list_subscriptions_success(
self, client, super_admin_headers, rt_subscription
):
response = client.get(f"{BASE}", headers=super_admin_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_pagination(
self, client, super_admin_headers, rt_subscription
):
response = client.get(
f"{BASE}",
params={"page": 1, "per_page": 5},
headers=super_admin_headers,
)
assert response.status_code == 200
data = response.json()
assert data["page"] == 1
assert data["per_page"] == 5
def test_list_subscriptions_filter_by_status(
self, client, super_admin_headers, rt_subscription
):
response = client.get(
f"{BASE}",
params={"status": "active"},
headers=super_admin_headers,
)
assert response.status_code == 200
for sub in response.json()["subscriptions"]:
assert sub["status"] == "active"
class TestAdminCreateSubscription:
"""Tests for POST /api/v1/admin/subscriptions/merchants/{id}/platforms/{id}."""
def test_create_subscription_success(
self, client, super_admin_headers, rt_merchant, rt_platform, rt_tiers
):
response = client.post(
f"{BASE}/merchants/{rt_merchant.id}/platforms/{rt_platform.id}",
json={
"merchant_id": rt_merchant.id,
"platform_id": rt_platform.id,
"tier_code": "essential",
"status": "trial",
"trial_days": 14,
"is_annual": False,
},
headers=super_admin_headers,
)
assert response.status_code == 201
data = response.json()
assert data["merchant_id"] == rt_merchant.id
assert data["platform_id"] == rt_platform.id
class TestAdminGetSubscription:
"""Tests for GET /api/v1/admin/subscriptions/merchants/{id}/platforms/{id}."""
def test_get_subscription_success(
self, client, super_admin_headers, rt_subscription, rt_merchant, rt_platform
):
response = client.get(
f"{BASE}/merchants/{rt_merchant.id}/platforms/{rt_platform.id}",
headers=super_admin_headers,
)
assert response.status_code == 200
data = response.json()
assert data["merchant_id"] == rt_merchant.id
assert data["status"] == "active"
def test_get_subscription_not_found(
self, client, super_admin_headers, rt_platform
):
response = client.get(
f"{BASE}/merchants/99999/platforms/{rt_platform.id}",
headers=super_admin_headers,
)
assert response.status_code == 404
class TestAdminUpdateSubscription:
"""Tests for PATCH /api/v1/admin/subscriptions/merchants/{id}/platforms/{id}."""
def test_update_subscription_status(
self, client, super_admin_headers, rt_subscription, rt_merchant, rt_platform
):
response = client.patch(
f"{BASE}/merchants/{rt_merchant.id}/platforms/{rt_platform.id}",
json={"status": "past_due"},
headers=super_admin_headers,
)
assert response.status_code == 200
assert response.json()["status"] == "past_due"
def test_update_subscription_tier(
self,
client,
super_admin_headers,
rt_subscription,
rt_merchant,
rt_platform,
rt_tiers,
):
response = client.patch(
f"{BASE}/merchants/{rt_merchant.id}/platforms/{rt_platform.id}",
json={"tier_code": "business"},
headers=super_admin_headers,
)
assert response.status_code == 200
# ============================================================================
# Stats Endpoint
# ============================================================================
class TestAdminStats:
"""Tests for GET /api/v1/admin/subscriptions/stats."""
def test_get_stats_success(
self, client, super_admin_headers, rt_subscription
):
response = client.get(
f"{BASE}/stats", headers=super_admin_headers
)
assert response.status_code == 200
data = response.json()
assert "total_subscriptions" in data
assert "active_count" in data
assert "mrr_cents" in data
assert "arr_cents" in data
assert "tier_distribution" in data
assert data["active_count"] >= 1
def test_get_stats_empty(self, client, super_admin_headers):
response = client.get(
f"{BASE}/stats", headers=super_admin_headers
)
assert response.status_code == 200
data = response.json()
assert data["total_subscriptions"] == 0
# ============================================================================
# Billing History Endpoint
# ============================================================================
class TestAdminBillingHistory:
"""Tests for GET /api/v1/admin/subscriptions/billing/history."""
def test_list_billing_history_success(
self, client, super_admin_headers, rt_billing_history
):
response = client.get(
f"{BASE}/billing/history", headers=super_admin_headers
)
assert response.status_code == 200
data = response.json()
assert "invoices" in data
assert "total" in data
assert data["total"] >= 3
def test_list_billing_history_pagination(
self, client, super_admin_headers, rt_billing_history
):
response = client.get(
f"{BASE}/billing/history",
params={"page": 1, "per_page": 2},
headers=super_admin_headers,
)
assert response.status_code == 200
data = response.json()
assert data["per_page"] == 2
assert len(data["invoices"]) <= 2
def test_list_billing_history_filter_by_merchant(
self, client, super_admin_headers, rt_billing_history, rt_merchant
):
response = client.get(
f"{BASE}/billing/history",
params={"merchant_id": rt_merchant.id},
headers=super_admin_headers,
)
assert response.status_code == 200
assert response.json()["total"] == 3
def test_list_billing_history_empty(self, client, super_admin_headers):
response = client.get(
f"{BASE}/billing/history", headers=super_admin_headers
)
assert response.status_code == 200
assert response.json()["total"] == 0
# ============================================================================
# Store Subscription Convenience Endpoint
# ============================================================================
class TestAdminStoreSubscription:
"""Tests for GET /api/v1/admin/subscriptions/store/{store_id}."""
def test_get_subscriptions_for_store(
self, client, super_admin_headers, rt_subscription, rt_store
):
"""Returns subscriptions when store has a merchant with subscriptions."""
response = client.get(
f"{BASE}/store/{rt_store.id}",
headers=super_admin_headers,
)
assert response.status_code == 200
data = response.json()
assert "subscriptions" in data
assert len(data["subscriptions"]) >= 1
def test_get_subscriptions_for_nonexistent_store(
self, client, super_admin_headers
):
"""Returns 404 for non-existent store ID."""
response = client.get(
f"{BASE}/store/999999",
headers=super_admin_headers,
)
assert response.status_code == 404
@pytest.fixture
def rt_store(db, rt_merchant):
"""Create a store for route tests."""
from app.modules.tenancy.models import Store
store = Store(
merchant_id=rt_merchant.id,
store_code=f"RT_{uuid.uuid4().hex[:6].upper()}",
name="Route Test Store",
subdomain=f"rt-{uuid.uuid4().hex[:8]}",
is_active=True,
is_verified=True,
created_at=datetime.now(UTC),
updated_at=datetime.now(UTC),
)
db.add(store)
db.commit()
db.refresh(store)
return store