Update from Copier template v0.4.0

- Accept RBAC system: user_roles table, role_required decorator, grant_role/revoke_role/ensure_admin_role functions
- Accept improved billing architecture: billing_customers table separation, provider-agnostic naming
- Accept enhanced user loading with subscription/roles eager loading in app.py
- Accept improved email templates with branded styling
- Accept new infrastructure: migration tracking, transaction logging, A/B testing
- Accept template improvements: Resend SDK, Tailwind build stage, UMAMI analytics config
- Keep beanflows-specific configs: BASE_URL 5001, coffee PLAN_FEATURES/PLAN_LIMITS
- Keep beanflows analytics integration and DuckDB health check
- Add new test files and utility scripts from template

Co-Authored-By: Claude Sonnet 4 <noreply@anthropic.com>
This commit is contained in:
Deeman
2026-02-19 22:22:13 +01:00
parent 1e8a173ae8
commit 4b7d4d5a74
20 changed files with 1346 additions and 387 deletions

View File

@@ -5,13 +5,13 @@ Covers signature verification, event parsing, subscription lifecycle transitions
import json
import pytest
from conftest import make_webhook_payload, sign_payload
from hypothesis import HealthCheck, given
from hypothesis import settings as h_settings
from hypothesis import strategies as st
from beanflows.billing.routes import get_subscription
from conftest import make_webhook_payload, sign_payload
from beanflows.billing.routes import get_billing_customer, get_subscription
WEBHOOK_PATH = "/billing/webhook/paddle"
@@ -72,18 +72,19 @@ class TestWebhookSignature:
async def test_modified_payload_rejected(self, client, db, test_user):
# Paddle SDK Verifier handles tamper detection internally.
# We test signature rejection via test_invalid_signature_rejected above.
# This test verifies the Verifier is actually called by sending
# a payload with an explicitly bad signature.
payload = make_webhook_payload("subscription.activated", user_id=str(test_user["id"]))
payload_bytes = json.dumps(payload).encode()
sig = sign_payload(payload_bytes)
tampered = payload_bytes + b"extra"
# Paddle/LemonSqueezy: HMAC signature verification fails before JSON parsing
response = await client.post(
WEBHOOK_PATH,
data=tampered,
headers={SIG_HEADER: sig, "Content-Type": "application/json"},
data=payload_bytes,
headers={SIG_HEADER: "invalid_signature", "Content-Type": "application/json"},
)
assert response.status_code in (400, 401)
assert response.status_code == 400
async def test_empty_payload_rejected(self, client, db):
@@ -105,7 +106,7 @@ class TestWebhookSignature:
class TestWebhookSubscriptionActivated:
async def test_creates_subscription(self, client, db, test_user):
async def test_creates_subscription_and_billing_customer(self, client, db, test_user):
payload = make_webhook_payload(
"subscription.activated",
user_id=str(test_user["id"]),
@@ -126,10 +127,14 @@ class TestWebhookSubscriptionActivated:
assert sub["plan"] == "starter"
assert sub["status"] == "active"
bc = await get_billing_customer(test_user["id"])
assert bc is not None
assert bc["provider_customer_id"] == "ctm_test123"
class TestWebhookSubscriptionUpdated:
async def test_updates_subscription_status(self, client, db, test_user, create_subscription):
await create_subscription(test_user["id"], status="active", paddle_subscription_id="sub_test456")
await create_subscription(test_user["id"], status="active", provider_subscription_id="sub_test456")
payload = make_webhook_payload(
"subscription.updated",
@@ -152,7 +157,7 @@ class TestWebhookSubscriptionUpdated:
class TestWebhookSubscriptionCanceled:
async def test_marks_subscription_cancelled(self, client, db, test_user, create_subscription):
await create_subscription(test_user["id"], status="active", paddle_subscription_id="sub_test456")
await create_subscription(test_user["id"], status="active", provider_subscription_id="sub_test456")
payload = make_webhook_payload(
"subscription.canceled",
@@ -174,7 +179,7 @@ class TestWebhookSubscriptionCanceled:
class TestWebhookSubscriptionPastDue:
async def test_marks_subscription_past_due(self, client, db, test_user, create_subscription):
await create_subscription(test_user["id"], status="active", paddle_subscription_id="sub_test456")
await create_subscription(test_user["id"], status="active", provider_subscription_id="sub_test456")
payload = make_webhook_payload(
"subscription.past_due",
@@ -209,7 +214,7 @@ class TestWebhookSubscriptionPastDue:
])
async def test_event_status_transitions(client, db, test_user, create_subscription, event_type, expected_status):
if event_type != "subscription.activated":
await create_subscription(test_user["id"], paddle_subscription_id="sub_test456")
await create_subscription(test_user["id"], provider_subscription_id="sub_test456")
payload = make_webhook_payload(event_type, user_id=str(test_user["id"]))
payload_bytes = json.dumps(payload).encode()