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>
This commit is contained in:
Deeman
2026-02-13 12:05:03 +01:00
parent cf11add1e5
commit 9703651562
14 changed files with 2905 additions and 186 deletions

View File

@@ -0,0 +1,308 @@
"""
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