refactor: flatten padelnomics/padelnomics/ → repo root
git mv all tracked files from the nested padelnomics/ workspace directory to the git repo root. Merged .gitignore files. No code changes — pure path rename. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
222
web/tests/test_billing_routes.py
Normal file
222
web/tests/test_billing_routes.py
Normal file
@@ -0,0 +1,222 @@
|
||||
"""
|
||||
Route integration tests for Paddle billing endpoints.
|
||||
Checkout uses Paddle.js overlay (returns JSON), manage/cancel use Paddle SDK.
|
||||
"""
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
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 (Paddle.js overlay — returns JSON)
|
||||
# ════════════════════════════════════════════════════════════
|
||||
|
||||
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_returns_checkout_json(self, auth_client, db, test_user):
|
||||
# Insert a paddle_products row so get_paddle_price() finds it
|
||||
await db.execute(
|
||||
"INSERT INTO paddle_products (key, paddle_product_id, paddle_price_id, name, price_cents, currency, billing_type) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
("starter", "pro_test", "pri_starter_123", "Starter", 1900, "EUR", "subscription"),
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
response = await auth_client.post(f"/billing/checkout/{CHECKOUT_PLAN}")
|
||||
assert response.status_code == 200
|
||||
data = await response.get_json()
|
||||
assert "items" in data
|
||||
assert data["items"][0]["priceId"] == "pri_starter_123"
|
||||
|
||||
async def test_invalid_plan_rejected(self, auth_client, db, test_user):
|
||||
response = await auth_client.post("/billing/checkout/nonexistent_plan")
|
||||
assert response.status_code == 400
|
||||
data = await response.get_json()
|
||||
assert "error" in data
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════════
|
||||
# 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):
|
||||
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_client = MagicMock()
|
||||
mock_client.subscriptions.get.return_value = mock_sub
|
||||
|
||||
with patch("padelnomics.billing.routes._paddle_client", return_value=mock_client):
|
||||
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)
|
||||
|
||||
async def test_cancels_subscription(self, auth_client, db, test_user, create_subscription):
|
||||
await create_subscription(test_user["id"], provider_subscription_id="sub_test")
|
||||
|
||||
mock_client = MagicMock()
|
||||
with patch("padelnomics.billing.routes._paddle_client", return_value=mock_client):
|
||||
response = await auth_client.post("/billing/cancel", follow_redirects=False)
|
||||
assert response.status_code in (302, 303, 307)
|
||||
mock_client.subscriptions.cancel.assert_called_once()
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════════
|
||||
# subscription_required decorator
|
||||
# ════════════════════════════════════════════════════════════
|
||||
|
||||
from padelnomics.auth.routes import subscription_required # noqa: E402
|
||||
from quart import Blueprint # 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
|
||||
Reference in New Issue
Block a user