Some checks failed
Consolidate User.role (2-value: admin/store) + User.is_super_admin (boolean) into a single 4-value UserRole enum: super_admin, platform_admin, merchant_owner, store_member. Drop stale StoreUser.user_type column. Fix role="user" bug in merchant creation. Key changes: - Expand UserRole enum from 2 to 4 values with computed properties (is_admin, is_super_admin, is_platform_admin, is_merchant_owner, is_store_user) - Add Alembic migration (tenancy_003) for data migration + column drops - Remove is_super_admin from JWT token payload - Update all auth dependencies, services, routes, templates, JS, and tests - Update all RBAC documentation 66 files changed, 1219 unit tests passing. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
510 lines
16 KiB
Python
510 lines
16 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
|