fix broken webhook signature verification and stale billing tests
Webhook handler called Verifier().verify() with raw bytes instead of a request object, so signature verification always failed. Replaced with manual HMAC check matching Paddle's ts=...;h1=... format. Updated tests to produce correct signature format, mock the SDK instead of httpx for manage/cancel routes, and expect JSON for overlay checkout. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,12 +1,11 @@
|
||||
"""
|
||||
Route integration tests for Paddle billing endpoints.
|
||||
External Paddle API calls mocked with respx.
|
||||
Checkout uses Paddle.js overlay (returns JSON), manage/cancel use Paddle SDK.
|
||||
"""
|
||||
import httpx
|
||||
import pytest
|
||||
import respx
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
CHECKOUT_METHOD = "POST"
|
||||
CHECKOUT_PLAN = "starter"
|
||||
|
||||
|
||||
@@ -40,7 +39,7 @@ class TestSuccessPage:
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════════
|
||||
# Checkout
|
||||
# Checkout (Paddle.js overlay — returns JSON)
|
||||
# ════════════════════════════════════════════════════════════
|
||||
|
||||
class TestCheckoutRoute:
|
||||
@@ -48,31 +47,25 @@ class TestCheckoutRoute:
|
||||
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"
|
||||
}
|
||||
}
|
||||
})
|
||||
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"),
|
||||
)
|
||||
response = await auth_client.post(f"/billing/checkout/{CHECKOUT_PLAN}", follow_redirects=False)
|
||||
assert response.status_code in (302, 303, 307)
|
||||
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/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}")
|
||||
response = await auth_client.post("/billing/checkout/nonexistent_plan")
|
||||
assert response.status_code == 400
|
||||
data = await response.get_json()
|
||||
assert "error" in data
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════════
|
||||
@@ -88,20 +81,16 @@ class TestManageRoute:
|
||||
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)
|
||||
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)
|
||||
|
||||
|
||||
@@ -118,15 +107,14 @@ class TestCancelRoute:
|
||||
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)
|
||||
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()
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════════
|
||||
|
||||
Reference in New Issue
Block a user