""" Route integration tests for Paddle billing endpoints. External Paddle API calls mocked with respx. """ import json import httpx import pytest import respx 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) @respx.mock async def test_creates_checkout_session(self, auth_client, db, test_user): respx.post("https://api.paddle.com/transactions").mock( return_value=httpx.Response(200, json={ "data": { "checkout": { "url": "https://checkout.paddle.com/test_123" } } }) ) response = await auth_client.post(f"/billing/checkout/{CHECKOUT_PLAN}", follow_redirects=False) assert response.status_code in (302, 303, 307) 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) @respx.mock async def test_api_error_propagates(self, auth_client, db, test_user): respx.post("https://api.paddle.com/transactions").mock( return_value=httpx.Response(500, json={"error": "server error"}) ) with pytest.raises(httpx.HTTPStatusError): 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) @respx.mock async def test_redirects_to_portal(self, auth_client, db, test_user, create_subscription): await create_subscription(test_user["id"], paddle_subscription_id="sub_test") respx.get("https://api.paddle.com/subscriptions/sub_test").mock( return_value=httpx.Response(200, json={ "data": { "management_urls": { "update_payment_method": "https://paddle.com/manage/test_123" } } }) ) response = await auth_client.post("/billing/manage", follow_redirects=False) assert response.status_code in (302, 303, 307) # ════════════════════════════════════════════════════════════ # 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) @respx.mock async def test_cancels_subscription(self, auth_client, db, test_user, create_subscription): await create_subscription(test_user["id"], paddle_subscription_id="sub_test") respx.post("https://api.paddle.com/subscriptions/sub_test/cancel").mock( return_value=httpx.Response(200, json={"data": {}}) ) response = await auth_client.post("/billing/cancel", follow_redirects=False) assert response.status_code in (302, 303, 307) # ════════════════════════════════════════════════════════════ # subscription_required decorator # ════════════════════════════════════════════════════════════ from beanflows.billing.routes import subscription_required from quart import Blueprint 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