- Fix pipeline granularity: add market_year to cleaned/serving SQL models - Add DuckDB data access layer with async query functions (analytics.py) - Build Chart.js dashboard: supply/demand, STU ratio, top producers, YoY table - Add country comparison page with multi-select picker - Replace items CRUD with read-only commodity API (list, metrics, countries, CSV) - Configure BeanFlows plan tiers (Free/Starter/Pro) with feature gating - Rewrite public pages for coffee market intelligence positioning - Remove boilerplate items schema, update health check for DuckDB - Add test suite: 139 tests passing (dashboard, API, billing) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
269 lines
10 KiB
Python
269 lines
10 KiB
Python
"""
|
|
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
|