Files
beanflows/web/tests/test_billing_routes.py
Deeman 4b7d4d5a74 Update from Copier template v0.4.0
- Accept RBAC system: user_roles table, role_required decorator, grant_role/revoke_role/ensure_admin_role functions
- Accept improved billing architecture: billing_customers table separation, provider-agnostic naming
- Accept enhanced user loading with subscription/roles eager loading in app.py
- Accept improved email templates with branded styling
- Accept new infrastructure: migration tracking, transaction logging, A/B testing
- Accept template improvements: Resend SDK, Tailwind build stage, UMAMI analytics config
- Keep beanflows-specific configs: BASE_URL 5001, coffee PLAN_FEATURES/PLAN_LIMITS
- Keep beanflows analytics integration and DuckDB health check
- Add new test files and utility scripts from template

Co-Authored-By: Claude Sonnet 4 <noreply@anthropic.com>
2026-02-19 22:22:13 +01:00

248 lines
10 KiB
Python

"""
Route integration tests for Paddle billing endpoints.
Paddle SDK calls mocked via mock_paddle_client fixture.
"""
from unittest.mock import MagicMock
import pytest
CHECKOUT_METHOD = "POST"
CHECKOUT_PLAN = "starter"
# ════════════════════════════════════════════════════════════
# Public routes (pricing, success)
# ════════════════════════════════════════════════════════════
class TestPricingPage:
async def test_accessible_without_auth(self, client, db):
response = await client.get("/billing/pricing")
assert response.status_code == 200
async def test_accessible_with_auth(self, auth_client, db, test_user):
response = await auth_client.get("/billing/pricing")
assert response.status_code == 200
async def test_with_subscription(self, auth_client, db, test_user, create_subscription):
await create_subscription(test_user["id"], plan="pro", status="active")
response = await auth_client.get("/billing/pricing")
assert response.status_code == 200
class TestSuccessPage:
async def test_requires_auth(self, client, db):
response = await client.get("/billing/success", follow_redirects=False)
assert response.status_code in (302, 303, 307)
async def test_accessible_with_auth(self, auth_client, db, test_user):
response = await auth_client.get("/billing/success")
assert response.status_code == 200
# ════════════════════════════════════════════════════════════
# Checkout
# ════════════════════════════════════════════════════════════
class TestCheckoutRoute:
async def test_requires_auth(self, client, db):
response = await client.post(f"/billing/checkout/{CHECKOUT_PLAN}", follow_redirects=False)
assert response.status_code in (302, 303, 307)
async def test_creates_checkout_session(self, auth_client, db, test_user, mock_paddle_client):
mock_txn = MagicMock()
mock_txn.checkout.url = "https://checkout.paddle.com/test_123"
mock_paddle_client.transactions.create.return_value = mock_txn
response = await auth_client.post(f"/billing/checkout/{CHECKOUT_PLAN}", follow_redirects=False)
assert response.status_code in (302, 303, 307)
mock_paddle_client.transactions.create.assert_called_once()
async def test_invalid_plan_rejected(self, auth_client, db, test_user):
response = await auth_client.post("/billing/checkout/invalid", follow_redirects=False)
assert response.status_code in (302, 303, 307)
async def test_api_error_propagates(self, auth_client, db, test_user, mock_paddle_client):
mock_paddle_client.transactions.create.side_effect = Exception("API error")
with pytest.raises(Exception, match="API error"):
await auth_client.post(f"/billing/checkout/{CHECKOUT_PLAN}")
# ════════════════════════════════════════════════════════════
# Manage subscription / Portal
# ════════════════════════════════════════════════════════════
class TestManageRoute:
async def test_requires_auth(self, client, db):
response = await client.post("/billing/manage", follow_redirects=False)
assert response.status_code in (302, 303, 307)
async def test_requires_subscription(self, auth_client, db, test_user):
response = await auth_client.post("/billing/manage", follow_redirects=False)
assert response.status_code in (302, 303, 307)
async def test_redirects_to_portal(self, auth_client, db, test_user, create_subscription, mock_paddle_client):
await create_subscription(test_user["id"], provider_subscription_id="sub_test")
mock_sub = MagicMock()
mock_sub.management_urls.update_payment_method = "https://paddle.com/manage/test_123"
mock_paddle_client.subscriptions.get.return_value = mock_sub
response = await auth_client.post("/billing/manage", follow_redirects=False)
assert response.status_code in (302, 303, 307)
mock_paddle_client.subscriptions.get.assert_called_once_with("sub_test")
# ════════════════════════════════════════════════════════════
# Cancel subscription
# ════════════════════════════════════════════════════════════
class TestCancelRoute:
async def test_requires_auth(self, client, db):
response = await client.post("/billing/cancel", follow_redirects=False)
assert response.status_code in (302, 303, 307)
async def test_no_error_without_subscription(self, auth_client, db, test_user):
response = await auth_client.post("/billing/cancel", follow_redirects=False)
assert response.status_code in (302, 303, 307)
async def test_cancels_subscription(self, auth_client, db, test_user, create_subscription, mock_paddle_client):
await create_subscription(test_user["id"], provider_subscription_id="sub_test")
response = await auth_client.post("/billing/cancel", follow_redirects=False)
assert response.status_code in (302, 303, 307)
mock_paddle_client.subscriptions.cancel.assert_called_once()
# ════════════════════════════════════════════════════════════
# subscription_required decorator
# ════════════════════════════════════════════════════════════
from quart import Blueprint # noqa: E402
from beanflows.auth.routes import subscription_required # noqa: E402
test_bp = Blueprint("test", __name__)
@test_bp.route("/protected")
@subscription_required()
async def protected_route():
return "success", 200
@test_bp.route("/custom_allowed")
@subscription_required(allowed=("active", "past_due"))
async def custom_allowed_route():
return "success", 200
class TestSubscriptionRequiredDecorator:
@pytest.fixture
async def test_app(self, app):
app.register_blueprint(test_bp)
return app
@pytest.fixture
async def test_client(self, test_app):
async with test_app.test_client() as c:
yield c
async def test_redirects_unauthenticated(self, test_client, db):
response = await test_client.get("/protected", follow_redirects=False)
assert response.status_code in (302, 303, 307)
async def test_redirects_without_subscription(self, test_client, db, test_user):
async with test_client.session_transaction() as sess:
sess["user_id"] = test_user["id"]
response = await test_client.get("/protected", follow_redirects=False)
assert response.status_code in (302, 303, 307)
async def test_allows_active_subscription(self, test_client, db, test_user, create_subscription):
await create_subscription(test_user["id"], plan="pro", status="active")
async with test_client.session_transaction() as sess:
sess["user_id"] = test_user["id"]
response = await test_client.get("/protected")
assert response.status_code == 200
async def test_allows_on_trial(self, test_client, db, test_user, create_subscription):
await create_subscription(test_user["id"], plan="pro", status="on_trial")
async with test_client.session_transaction() as sess:
sess["user_id"] = test_user["id"]
response = await test_client.get("/protected")
assert response.status_code == 200
async def test_allows_cancelled(self, test_client, db, test_user, create_subscription):
await create_subscription(test_user["id"], plan="pro", status="cancelled")
async with test_client.session_transaction() as sess:
sess["user_id"] = test_user["id"]
response = await test_client.get("/protected")
assert response.status_code == 200
async def test_rejects_expired(self, test_client, db, test_user, create_subscription):
await create_subscription(test_user["id"], plan="pro", status="expired")
async with test_client.session_transaction() as sess:
sess["user_id"] = test_user["id"]
response = await test_client.get("/protected", follow_redirects=False)
assert response.status_code in (302, 303, 307)
@pytest.mark.parametrize("status", ["free", "active", "on_trial", "cancelled", "past_due", "paused", "expired"])
async def test_default_allowed_tuple(self, test_client, db, test_user, create_subscription, status):
if status != "free":
await create_subscription(test_user["id"], plan="pro", status=status)
async with test_client.session_transaction() as sess:
sess["user_id"] = test_user["id"]
response = await test_client.get("/protected", follow_redirects=False)
if status in ("active", "on_trial", "cancelled"):
assert response.status_code == 200
else:
assert response.status_code in (302, 303, 307)
async def test_custom_allowed_tuple(self, test_client, db, test_user, create_subscription):
await create_subscription(test_user["id"], plan="pro", status="past_due")
async with test_client.session_transaction() as sess:
sess["user_id"] = test_user["id"]
response = await test_client.get("/custom_allowed")
assert response.status_code == 200