Files
padelnomics/padelnomics/tests/test_billing_routes.py
Deeman 9703651562 add hybrid calculator refactor and comprehensive billing test suite
Move planner financial model from client-side JS to server-side Python
(calculator.py + /planner/calculate endpoint). Add full test coverage:
227 calculator tests and 371 billing tests covering SQL helpers,
webhooks, routes, and subscription gating with Hypothesis fuzzing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 12:05:03 +01:00

309 lines
15 KiB
Python

"""
Route tests for billing pages, checkout, manage, cancel, resume.
External LemonSqueezy API calls mocked with respx.
"""
import json
import httpx
import pytest
import respx
# ════════════════════════════════════════════════════════════
# GET /billing/pricing
# ════════════════════════════════════════════════════════════
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
# ════════════════════════════════════════════════════════════
# GET /billing/success
# ════════════════════════════════════════════════════════════
class TestSuccessPage:
async def test_requires_auth(self, client, db):
response = await client.get("/billing/success")
assert response.status_code == 302
assert "/auth/login" in response.headers["Location"]
async def test_accessible_with_auth(self, auth_client, db, test_user):
response = await auth_client.get("/billing/success")
assert response.status_code == 200
# ════════════════════════════════════════════════════════════
# GET /billing/checkout/<plan>
# ════════════════════════════════════════════════════════════
class TestCheckoutRoute:
async def test_requires_auth(self, client, db):
response = await client.get("/billing/checkout/monthly")
assert response.status_code == 302
assert "/auth/login" in response.headers["Location"]
@respx.mock
async def test_monthly_redirects_to_checkout_url(self, auth_client, db, test_user):
checkout_url = "https://checkout.lemonsqueezy.com/checkout/test123"
respx.post("https://api.lemonsqueezy.com/v1/checkouts").mock(
return_value=httpx.Response(
200, json={"data": {"attributes": {"url": checkout_url}}},
)
)
response = await auth_client.get("/billing/checkout/monthly")
assert response.status_code == 302
assert response.headers["Location"] == checkout_url
@respx.mock
async def test_yearly_redirects_to_checkout_url(self, auth_client, db, test_user):
checkout_url = "https://checkout.lemonsqueezy.com/checkout/yearly456"
respx.post("https://api.lemonsqueezy.com/v1/checkouts").mock(
return_value=httpx.Response(
200, json={"data": {"attributes": {"url": checkout_url}}},
)
)
response = await auth_client.get("/billing/checkout/yearly")
assert response.status_code == 302
assert response.headers["Location"] == checkout_url
async def test_invalid_plan_redirects_to_pricing(self, auth_client, db, test_user):
response = await auth_client.get("/billing/checkout/enterprise")
assert response.status_code == 302
assert "/billing/pricing" in response.headers["Location"]
@respx.mock
async def test_ajax_returns_json(self, auth_client, db, test_user):
checkout_url = "https://checkout.lemonsqueezy.com/checkout/ajax789"
respx.post("https://api.lemonsqueezy.com/v1/checkouts").mock(
return_value=httpx.Response(
200, json={"data": {"attributes": {"url": checkout_url}}},
)
)
response = await auth_client.get(
"/billing/checkout/monthly",
headers={"X-Requested-With": "XMLHttpRequest"},
)
assert response.status_code == 200
data = await response.get_json()
assert data["checkout_url"] == checkout_url
@respx.mock
async def test_sends_correct_api_payload(self, auth_client, db, test_user):
route = respx.post("https://api.lemonsqueezy.com/v1/checkouts").mock(
return_value=httpx.Response(
200, json={"data": {"attributes": {"url": "https://example.com"}}},
)
)
await auth_client.get("/billing/checkout/monthly")
assert route.called
sent_json = json.loads(route.calls.last.request.content)
assert sent_json["data"]["type"] == "checkouts"
assert sent_json["data"]["attributes"]["checkout_data"]["email"] == test_user["email"]
assert sent_json["data"]["attributes"]["checkout_data"]["custom"]["user_id"] == str(test_user["id"])
assert sent_json["data"]["relationships"]["store"]["data"]["id"] == "store_999"
assert sent_json["data"]["relationships"]["variant"]["data"]["id"] == "variant_monthly_100"
@respx.mock
async def test_api_error_propagates(self, auth_client, db, test_user):
respx.post("https://api.lemonsqueezy.com/v1/checkouts").mock(
return_value=httpx.Response(500, json={"error": "server error"})
)
with pytest.raises(httpx.HTTPStatusError):
await auth_client.get("/billing/checkout/monthly")
# ════════════════════════════════════════════════════════════
# POST /billing/manage
# ════════════════════════════════════════════════════════════
class TestManageRoute:
async def test_requires_auth(self, client, db):
response = await client.post("/billing/manage")
assert response.status_code == 302
assert "/auth/login" in response.headers["Location"]
async def test_no_subscription_redirects(self, auth_client, db, test_user):
response = await auth_client.post("/billing/manage")
assert response.status_code == 302
assert "/dashboard" in response.headers["Location"]
@respx.mock
async def test_redirects_to_portal(self, auth_client, db, test_user, create_subscription):
await create_subscription(test_user["id"], ls_subscription_id="sub_manage_001")
portal_url = "https://app.lemonsqueezy.com/my-orders/portal"
respx.get("https://api.lemonsqueezy.com/v1/subscriptions/sub_manage_001").mock(
return_value=httpx.Response(200, json={
"data": {"attributes": {"urls": {"customer_portal": portal_url}}}
})
)
response = await auth_client.post("/billing/manage")
assert response.status_code == 302
assert response.headers["Location"] == portal_url
# ════════════════════════════════════════════════════════════
# POST /billing/cancel
# ════════════════════════════════════════════════════════════
class TestCancelRoute:
async def test_requires_auth(self, client, db):
response = await client.post("/billing/cancel")
assert response.status_code == 302
assert "/auth/login" in response.headers["Location"]
async def test_no_subscription_redirects(self, auth_client, db, test_user):
response = await auth_client.post("/billing/cancel")
assert response.status_code == 302
assert "/dashboard" in response.headers["Location"]
@respx.mock
async def test_sends_cancel_patch(self, auth_client, db, test_user, create_subscription):
await create_subscription(test_user["id"], ls_subscription_id="sub_cancel_route")
route = respx.patch(
"https://api.lemonsqueezy.com/v1/subscriptions/sub_cancel_route"
).mock(return_value=httpx.Response(200, json={}))
response = await auth_client.post("/billing/cancel")
assert response.status_code == 302
assert "/dashboard" in response.headers["Location"]
assert route.called
sent_json = json.loads(route.calls.last.request.content)
assert sent_json["data"]["attributes"]["cancelled"] is True
# ════════════════════════════════════════════════════════════
# POST /billing/resume
# ════════════════════════════════════════════════════════════
class TestResumeRoute:
async def test_requires_auth(self, client, db):
response = await client.post("/billing/resume")
assert response.status_code == 302
assert "/auth/login" in response.headers["Location"]
@respx.mock
async def test_sends_resume_patch(self, auth_client, db, test_user, create_subscription):
await create_subscription(test_user["id"], ls_subscription_id="sub_resume_route")
route = respx.patch(
"https://api.lemonsqueezy.com/v1/subscriptions/sub_resume_route"
).mock(return_value=httpx.Response(200, json={}))
response = await auth_client.post("/billing/resume")
assert response.status_code == 302
assert "/dashboard" in response.headers["Location"]
assert route.called
sent_json = json.loads(route.calls.last.request.content)
assert sent_json["data"]["attributes"]["cancelled"] is False
# ════════════════════════════════════════════════════════════
# subscription_required decorator
# ════════════════════════════════════════════════════════════
class TestSubscriptionRequired:
@pytest.fixture
async def gated_app(self, app):
"""Register a test route using subscription_required with restricted allowed."""
from padelnomics.billing.routes import subscription_required
@app.route("/test-gated")
@subscription_required(allowed=("active", "on_trial"))
async def gated():
return "OK", 200
return app
async def test_no_session_redirects_to_login(self, gated_app, db):
async with gated_app.test_client() as c:
response = await c.get("/test-gated")
assert response.status_code == 302
assert "/auth/login" in response.headers["Location"]
async def test_no_subscription_redirects_to_pricing(self, gated_app, db, test_user):
async with gated_app.test_client() as c:
async with c.session_transaction() as sess:
sess["user_id"] = test_user["id"]
response = await c.get("/test-gated")
assert response.status_code == 302
assert "/billing/pricing" in response.headers["Location"]
async def test_active_passes(self, gated_app, db, test_user, create_subscription):
await create_subscription(test_user["id"], status="active")
async with gated_app.test_client() as c:
async with c.session_transaction() as sess:
sess["user_id"] = test_user["id"]
response = await c.get("/test-gated")
assert response.status_code == 200
async def test_on_trial_passes(self, gated_app, db, test_user, create_subscription):
await create_subscription(test_user["id"], status="on_trial")
async with gated_app.test_client() as c:
async with c.session_transaction() as sess:
sess["user_id"] = test_user["id"]
response = await c.get("/test-gated")
assert response.status_code == 200
async def test_cancelled_rejected_when_not_in_allowed(self, gated_app, db, test_user, create_subscription):
await create_subscription(test_user["id"], status="cancelled")
async with gated_app.test_client() as c:
async with c.session_transaction() as sess:
sess["user_id"] = test_user["id"]
response = await c.get("/test-gated")
assert response.status_code == 302
assert "/billing/pricing" in response.headers["Location"]
async def test_expired_redirects(self, gated_app, db, test_user, create_subscription):
await create_subscription(test_user["id"], status="expired")
async with gated_app.test_client() as c:
async with c.session_transaction() as sess:
sess["user_id"] = test_user["id"]
response = await c.get("/test-gated")
assert response.status_code == 302
# ════════════════════════════════════════════════════════════
# Parameterized: subscription_required default allowed
# ════════════════════════════════════════════════════════════
ALL_STATUSES = ["free", "active", "on_trial", "cancelled", "past_due", "paused", "expired"]
DEFAULT_ALLOWED = ("active", "on_trial", "cancelled")
@pytest.mark.parametrize("status", ALL_STATUSES)
async def test_subscription_required_default_allowed(app, db, test_user, create_subscription, status):
from padelnomics.billing.routes import subscription_required
@app.route(f"/test-gate-{status}")
@subscription_required()
async def gated():
return "OK", 200
if status != "free":
await create_subscription(test_user["id"], status=status)
async with app.test_client() as c:
async with c.session_transaction() as sess:
sess["user_id"] = test_user["id"]
response = await c.get(f"/test-gate-{status}")
if status in DEFAULT_ALLOWED:
assert response.status_code == 200
else:
assert response.status_code == 302