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

@@ -10,9 +10,11 @@ from unittest.mock import AsyncMock, patch
import aiosqlite
import pytest
from beanflows import analytics, core
from beanflows import core
from beanflows.app import create_app
SCHEMA_PATH = Path(__file__).parent.parent / "src" / "beanflows" / "migrations" / "schema.sql"
@@ -44,9 +46,7 @@ async def db():
async def app(db):
"""Quart app with DB already initialized (init_db/close_db patched to no-op)."""
with patch.object(core, "init_db", new_callable=AsyncMock), \
patch.object(core, "close_db", new_callable=AsyncMock), \
patch.object(analytics, "open_analytics_db"), \
patch.object(analytics, "close_analytics_db"):
patch.object(core, "close_db", new_callable=AsyncMock):
application = create_app()
application.config["TESTING"] = True
yield application
@@ -92,22 +92,17 @@ def create_subscription(db):
user_id: int,
plan: str = "pro",
status: str = "active",
paddle_customer_id: str = "ctm_test123",
paddle_subscription_id: str = "sub_test456",
provider_subscription_id: str = "sub_test456",
current_period_end: str = "2025-03-01T00:00:00Z",
) -> int:
now = datetime.utcnow().isoformat()
async with db.execute(
"""INSERT INTO subscriptions
(user_id, plan, status, paddle_customer_id,
paddle_subscription_id, current_period_end, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
(user_id, plan, status, paddle_customer_id, paddle_subscription_id,
(user_id, plan, status,
provider_subscription_id, current_period_end, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?)""",
(user_id, plan, status, provider_subscription_id,
current_period_end, now, now),
) as cursor:
sub_id = cursor.lastrowid
await db.commit()
@@ -115,6 +110,48 @@ def create_subscription(db):
return _create
# ── Billing Customers ───────────────────────────────────────
@pytest.fixture
def create_billing_customer(db):
"""Factory: create a billing_customers row for a user."""
async def _create(user_id: int, provider_customer_id: str = "cust_test123") -> int:
async with db.execute(
"""INSERT INTO billing_customers (user_id, provider_customer_id)
VALUES (?, ?)
ON CONFLICT(user_id) DO UPDATE SET provider_customer_id = excluded.provider_customer_id""",
(user_id, provider_customer_id),
) as cursor:
row_id = cursor.lastrowid
await db.commit()
return row_id
return _create
# ── Roles ───────────────────────────────────────────────────
@pytest.fixture
def grant_role(db):
"""Factory: grant a role to a user."""
async def _grant(user_id: int, role: str) -> None:
await db.execute(
"INSERT OR IGNORE INTO user_roles (user_id, role) VALUES (?, ?)",
(user_id, role),
)
await db.commit()
return _grant
@pytest.fixture
async def admin_client(app, test_user, grant_role):
"""Test client with admin role and session['user_id'] pre-set."""
await grant_role(test_user["id"], "admin")
async with app.test_client() as c:
async with c.session_transaction() as sess:
sess["user_id"] = test_user["id"]
yield c
# ── Config ───────────────────────────────────────────────────
@pytest.fixture(autouse=True)
@@ -127,6 +164,7 @@ def patch_config():
"PADDLE_API_KEY": "test_api_key_123",
"PADDLE_WEBHOOK_SECRET": "whsec_test_secret",
"PADDLE_ENVIRONMENT": "sandbox",
"PADDLE_PRICES": {"starter": "pri_starter_123", "pro": "pri_pro_456"},
"BASE_URL": "http://localhost:5000",
@@ -147,6 +185,32 @@ def patch_config():
# ── Webhook helpers ──────────────────────────────────────────
@pytest.fixture(autouse=True)
def mock_paddle_verifier(monkeypatch):
"""Mock Paddle's webhook Verifier to accept test payloads."""
def mock_verify(self, payload, secret, signature):
if not signature or signature == "invalid_signature":
raise ValueError("Invalid signature")
monkeypatch.setattr(
"paddle_billing.Notifications.Verifier.verify",
mock_verify,
)
@pytest.fixture
def mock_paddle_client(monkeypatch):
"""Mock _paddle_client() to return a fake PaddleClient."""
from unittest.mock import MagicMock
mock_client = MagicMock()
monkeypatch.setattr(
"beanflows.billing.routes._paddle_client",
lambda: mock_client,
)
return mock_client
def make_webhook_payload(
event_type: str,
subscription_id: str = "sub_test456",
@@ -172,76 +236,8 @@ def make_webhook_payload(
}
def sign_payload(payload_bytes: bytes, secret: str = "whsec_test_secret") -> str:
"""Compute HMAC-SHA256 signature for a webhook payload."""
return hmac.new(secret.encode(), payload_bytes, hashlib.sha256).hexdigest()
# ── Analytics mock data ──────────────────────────────────────
MOCK_TIME_SERIES = [
{"market_year": 2018, "Production": 165000, "Exports": 115000, "Imports": 105000,
"Ending_Stocks": 33000, "Total_Distribution": 160000},
{"market_year": 2019, "Production": 168000, "Exports": 118000, "Imports": 108000,
"Ending_Stocks": 34000, "Total_Distribution": 163000},
{"market_year": 2020, "Production": 170000, "Exports": 120000, "Imports": 110000,
"Ending_Stocks": 35000, "Total_Distribution": 165000},
{"market_year": 2021, "Production": 175000, "Exports": 125000, "Imports": 115000,
"Ending_Stocks": 36000, "Total_Distribution": 170000},
{"market_year": 2022, "Production": 172000, "Exports": 122000, "Imports": 112000,
"Ending_Stocks": 34000, "Total_Distribution": 168000},
]
MOCK_TOP_COUNTRIES = [
{"country_name": "Brazil", "country_code": "BR", "market_year": 2022, "Production": 65000},
{"country_name": "Vietnam", "country_code": "VN", "market_year": 2022, "Production": 30000},
{"country_name": "Colombia", "country_code": "CO", "market_year": 2022, "Production": 14000},
]
MOCK_STU_TREND = [
{"market_year": 2020, "Stock_to_Use_Ratio_pct": 21.2},
{"market_year": 2021, "Stock_to_Use_Ratio_pct": 21.1},
{"market_year": 2022, "Stock_to_Use_Ratio_pct": 20.2},
]
MOCK_BALANCE = [
{"market_year": 2020, "Production": 170000, "Total_Distribution": 165000, "Supply_Demand_Balance": 5000},
{"market_year": 2021, "Production": 175000, "Total_Distribution": 170000, "Supply_Demand_Balance": 5000},
{"market_year": 2022, "Production": 172000, "Total_Distribution": 168000, "Supply_Demand_Balance": 4000},
]
MOCK_YOY = [
{"country_name": "Brazil", "country_code": "BR", "market_year": 2022,
"Production": 65000, "Production_YoY_pct": -3.5},
{"country_name": "Vietnam", "country_code": "VN", "market_year": 2022,
"Production": 30000, "Production_YoY_pct": 2.1},
]
MOCK_COMMODITIES = [
{"commodity_code": 711100, "commodity_name": "Coffee, Green"},
{"commodity_code": 222000, "commodity_name": "Soybeans"},
]
@pytest.fixture
def mock_analytics():
"""Patch all analytics query functions with mock data."""
with patch.object(analytics, "get_global_time_series", new_callable=AsyncMock,
return_value=MOCK_TIME_SERIES), \
patch.object(analytics, "get_top_countries", new_callable=AsyncMock,
return_value=MOCK_TOP_COUNTRIES), \
patch.object(analytics, "get_stock_to_use_trend", new_callable=AsyncMock,
return_value=MOCK_STU_TREND), \
patch.object(analytics, "get_supply_demand_balance", new_callable=AsyncMock,
return_value=MOCK_BALANCE), \
patch.object(analytics, "get_production_yoy_by_country", new_callable=AsyncMock,
return_value=MOCK_YOY), \
patch.object(analytics, "get_country_comparison", new_callable=AsyncMock,
return_value=[]), \
patch.object(analytics, "get_available_commodities", new_callable=AsyncMock,
return_value=MOCK_COMMODITIES), \
patch.object(analytics, "fetch_analytics", new_callable=AsyncMock,
return_value=[{"result": 1}]):
yield
def sign_payload(payload_bytes: bytes) -> str:
"""Return a dummy signature for Paddle webhook tests (Verifier is mocked)."""
return "ts=1234567890;h1=dummy_signature"

View File

@@ -9,10 +9,13 @@ from hypothesis import strategies as st
from beanflows.billing.routes import (
can_access_feature,
get_billing_customer,
get_subscription,
get_subscription_by_provider_id,
is_within_limits,
record_transaction,
update_subscription_status,
upsert_billing_customer,
upsert_subscription,
)
from beanflows.core import config
@@ -45,7 +48,6 @@ class TestUpsertSubscription:
user_id=test_user["id"],
plan="pro",
status="active",
provider_customer_id="cust_abc",
provider_subscription_id="sub_xyz",
current_period_end="2025-06-01T00:00:00Z",
)
@@ -53,39 +55,53 @@ class TestUpsertSubscription:
row = await get_subscription(test_user["id"])
assert row["plan"] == "pro"
assert row["status"] == "active"
assert row["paddle_customer_id"] == "cust_abc"
assert row["paddle_subscription_id"] == "sub_xyz"
assert row["provider_subscription_id"] == "sub_xyz"
assert row["current_period_end"] == "2025-06-01T00:00:00Z"
async def test_update_existing_subscription(self, db, test_user, create_subscription):
original_id = await create_subscription(
test_user["id"], plan="starter", status="active",
paddle_subscription_id="sub_old",
async def test_update_existing_by_provider_subscription_id(self, db, test_user):
"""upsert finds existing by provider_subscription_id, not user_id."""
await upsert_subscription(
user_id=test_user["id"],
plan="starter",
status="active",
provider_subscription_id="sub_same",
)
returned_id = await upsert_subscription(
user_id=test_user["id"],
plan="pro",
status="active",
provider_customer_id="cust_new",
provider_subscription_id="sub_new",
provider_subscription_id="sub_same",
)
assert returned_id == original_id
row = await get_subscription(test_user["id"])
assert row["plan"] == "pro"
assert row["provider_subscription_id"] == "sub_same"
assert row["paddle_subscription_id"] == "sub_new"
async def test_different_provider_id_creates_new(self, db, test_user):
"""Different provider_subscription_id creates a new row (multi-sub support)."""
await upsert_subscription(
user_id=test_user["id"],
plan="starter",
status="active",
provider_subscription_id="sub_first",
)
await upsert_subscription(
user_id=test_user["id"],
plan="pro",
status="active",
provider_subscription_id="sub_second",
)
from beanflows.core import fetch_all
rows = await fetch_all(
"SELECT * FROM subscriptions WHERE user_id = ? ORDER BY created_at",
(test_user["id"],),
)
assert len(rows) == 2
async def test_upsert_with_none_period_end(self, db, test_user):
await upsert_subscription(
user_id=test_user["id"],
plan="pro",
status="active",
provider_customer_id="cust_1",
provider_subscription_id="sub_1",
current_period_end=None,
)
@@ -93,6 +109,28 @@ class TestUpsertSubscription:
assert row["current_period_end"] is None
# ════════════════════════════════════════════════════════════
# upsert_billing_customer / get_billing_customer
# ════════════════════════════════════════════════════════════
class TestUpsertBillingCustomer:
async def test_creates_billing_customer(self, db, test_user):
await upsert_billing_customer(test_user["id"], "cust_abc")
row = await get_billing_customer(test_user["id"])
assert row is not None
assert row["provider_customer_id"] == "cust_abc"
async def test_updates_existing_customer(self, db, test_user):
await upsert_billing_customer(test_user["id"], "cust_old")
await upsert_billing_customer(test_user["id"], "cust_new")
row = await get_billing_customer(test_user["id"])
assert row["provider_customer_id"] == "cust_new"
async def test_get_returns_none_for_unknown_user(self, db):
row = await get_billing_customer(99999)
assert row is None
# ════════════════════════════════════════════════════════════
# get_subscription_by_provider_id
# ════════════════════════════════════════════════════════════
@@ -102,10 +140,8 @@ class TestGetSubscriptionByProviderId:
result = await get_subscription_by_provider_id("nonexistent")
assert result is None
async def test_finds_by_paddle_subscription_id(self, db, test_user, create_subscription):
await create_subscription(test_user["id"], paddle_subscription_id="sub_findme")
async def test_finds_by_provider_subscription_id(self, db, test_user, create_subscription):
await create_subscription(test_user["id"], provider_subscription_id="sub_findme")
result = await get_subscription_by_provider_id("sub_findme")
assert result is not None
assert result["user_id"] == test_user["id"]
@@ -117,18 +153,14 @@ class TestGetSubscriptionByProviderId:
class TestUpdateSubscriptionStatus:
async def test_updates_status(self, db, test_user, create_subscription):
await create_subscription(test_user["id"], status="active", paddle_subscription_id="sub_upd")
await create_subscription(test_user["id"], status="active", provider_subscription_id="sub_upd")
await update_subscription_status("sub_upd", status="cancelled")
row = await get_subscription(test_user["id"])
assert row["status"] == "cancelled"
assert row["updated_at"] is not None
async def test_updates_extra_fields(self, db, test_user, create_subscription):
await create_subscription(test_user["id"], paddle_subscription_id="sub_extra")
await create_subscription(test_user["id"], provider_subscription_id="sub_extra")
await update_subscription_status(
"sub_extra",
status="active",
@@ -141,9 +173,7 @@ class TestUpdateSubscriptionStatus:
assert row["current_period_end"] == "2026-01-01T00:00:00Z"
async def test_noop_for_unknown_provider_id(self, db, test_user, create_subscription):
await create_subscription(test_user["id"], paddle_subscription_id="sub_known", status="active")
await create_subscription(test_user["id"], provider_subscription_id="sub_known", status="active")
await update_subscription_status("sub_unknown", status="expired")
row = await get_subscription(test_user["id"])
assert row["status"] == "active" # unchanged
@@ -155,22 +185,22 @@ class TestUpdateSubscriptionStatus:
class TestCanAccessFeature:
async def test_no_subscription_gets_free_features(self, db, test_user):
assert await can_access_feature(test_user["id"], "dashboard") is True
assert await can_access_feature(test_user["id"], "basic") is True
assert await can_access_feature(test_user["id"], "export") is False
assert await can_access_feature(test_user["id"], "api") is False
async def test_active_pro_gets_all_features(self, db, test_user, create_subscription):
await create_subscription(test_user["id"], plan="pro", status="active")
assert await can_access_feature(test_user["id"], "dashboard") is True
assert await can_access_feature(test_user["id"], "basic") is True
assert await can_access_feature(test_user["id"], "export") is True
assert await can_access_feature(test_user["id"], "api") is True
assert await can_access_feature(test_user["id"], "priority_support") is True
async def test_active_starter_gets_starter_features(self, db, test_user, create_subscription):
await create_subscription(test_user["id"], plan="starter", status="active")
assert await can_access_feature(test_user["id"], "dashboard") is True
assert await can_access_feature(test_user["id"], "basic") is True
assert await can_access_feature(test_user["id"], "export") is True
assert await can_access_feature(test_user["id"], "all_commodities") is False
assert await can_access_feature(test_user["id"], "api") is False
async def test_cancelled_still_has_features(self, db, test_user, create_subscription):
await create_subscription(test_user["id"], plan="pro", status="cancelled")
@@ -183,7 +213,7 @@ class TestCanAccessFeature:
async def test_expired_falls_back_to_free(self, db, test_user, create_subscription):
await create_subscription(test_user["id"], plan="pro", status="expired")
assert await can_access_feature(test_user["id"], "api") is False
assert await can_access_feature(test_user["id"], "dashboard") is True
assert await can_access_feature(test_user["id"], "basic") is True
async def test_past_due_falls_back_to_free(self, db, test_user, create_subscription):
await create_subscription(test_user["id"], plan="pro", status="past_due")
@@ -203,30 +233,28 @@ class TestCanAccessFeature:
# ════════════════════════════════════════════════════════════
class TestIsWithinLimits:
async def test_free_user_no_api_calls(self, db, test_user):
assert await is_within_limits(test_user["id"], "api_calls", 0) is False
async def test_free_user_within_limits(self, db, test_user):
assert await is_within_limits(test_user["id"], "items", 50) is True
async def test_free_user_commodity_limit(self, db, test_user):
assert await is_within_limits(test_user["id"], "commodities", 0) is True
assert await is_within_limits(test_user["id"], "commodities", 1) is False
async def test_free_user_at_limit(self, db, test_user):
assert await is_within_limits(test_user["id"], "items", 100) is False
async def test_free_user_history_limit(self, db, test_user):
assert await is_within_limits(test_user["id"], "history_years", 4) is True
assert await is_within_limits(test_user["id"], "history_years", 5) is False
async def test_free_user_over_limit(self, db, test_user):
assert await is_within_limits(test_user["id"], "items", 150) is False
async def test_pro_unlimited(self, db, test_user, create_subscription):
await create_subscription(test_user["id"], plan="pro", status="active")
assert await is_within_limits(test_user["id"], "commodities", 999999) is True
assert await is_within_limits(test_user["id"], "items", 999999) is True
assert await is_within_limits(test_user["id"], "api_calls", 999999) is True
async def test_starter_limits(self, db, test_user, create_subscription):
await create_subscription(test_user["id"], plan="starter", status="active")
assert await is_within_limits(test_user["id"], "api_calls", 9999) is True
assert await is_within_limits(test_user["id"], "api_calls", 10000) is False
assert await is_within_limits(test_user["id"], "items", 999) is True
assert await is_within_limits(test_user["id"], "items", 1000) is False
async def test_expired_pro_gets_free_limits(self, db, test_user, create_subscription):
await create_subscription(test_user["id"], plan="pro", status="expired")
assert await is_within_limits(test_user["id"], "api_calls", 0) is False
assert await is_within_limits(test_user["id"], "items", 100) is False
async def test_unknown_resource_returns_false(self, db, test_user):
assert await is_within_limits(test_user["id"], "unicorns", 0) is False
@@ -238,7 +266,7 @@ class TestIsWithinLimits:
# ════════════════════════════════════════════════════════════
STATUSES = ["free", "active", "on_trial", "cancelled", "past_due", "paused", "expired"]
FEATURES = ["dashboard", "export", "api", "priority_support"]
FEATURES = ["basic", "export", "api", "priority_support"]
ACTIVE_STATUSES = {"active", "on_trial", "cancelled"}
@@ -282,9 +310,9 @@ async def test_plan_feature_matrix(db, test_user, create_subscription, plan, fea
@pytest.mark.parametrize("plan", PLANS)
@pytest.mark.parametrize("resource,at_limit", [
("commodities", 1),
("commodities", 65),
("api_calls", 0),
("items", 100),
("items", 1000),
("api_calls", 1000),
("api_calls", 10000),
])
async def test_plan_limit_matrix(db, test_user, create_subscription, plan, resource, at_limit):
@@ -307,11 +335,11 @@ async def test_plan_limit_matrix(db, test_user, create_subscription, plan, resou
# ════════════════════════════════════════════════════════════
class TestLimitsHypothesis:
@given(count=st.integers(min_value=0, max_value=100))
@given(count=st.integers(min_value=0, max_value=10000))
@h_settings(max_examples=100, deadline=2000, suppress_health_check=[HealthCheck.function_scoped_fixture])
async def test_free_limit_boundary_commodities(self, db, test_user, count):
result = await is_within_limits(test_user["id"], "commodities", count)
assert result == (count < 1)
async def test_free_limit_boundary_items(self, db, test_user, count):
result = await is_within_limits(test_user["id"], "items", count)
assert result == (count < 100)
@given(count=st.integers(min_value=0, max_value=100000))
@h_settings(max_examples=100, deadline=2000, suppress_health_check=[HealthCheck.function_scoped_fixture])
@@ -319,7 +347,56 @@ class TestLimitsHypothesis:
# Use upsert to avoid duplicate inserts across Hypothesis examples
await upsert_subscription(
user_id=test_user["id"], plan="pro", status="active",
provider_customer_id="cust_hyp", provider_subscription_id="sub_hyp",
provider_subscription_id="sub_hyp",
)
result = await is_within_limits(test_user["id"], "commodities", count)
result = await is_within_limits(test_user["id"], "items", count)
assert result is True
# ════════════════════════════════════════════════════════════
# record_transaction
# ════════════════════════════════════════════════════════════
class TestRecordTransaction:
async def test_inserts_transaction(self, db, test_user):
txn_id = await record_transaction(
user_id=test_user["id"],
provider_transaction_id="txn_abc123",
type="payment",
amount_cents=2999,
currency="EUR",
status="completed",
)
assert txn_id is not None and txn_id > 0
from beanflows.core import fetch_one
row = await fetch_one(
"SELECT * FROM transactions WHERE provider_transaction_id = ?",
("txn_abc123",),
)
assert row is not None
assert row["user_id"] == test_user["id"]
assert row["amount_cents"] == 2999
assert row["currency"] == "EUR"
assert row["status"] == "completed"
async def test_idempotent_on_duplicate_provider_id(self, db, test_user):
await record_transaction(
user_id=test_user["id"],
provider_transaction_id="txn_dup",
amount_cents=1000,
)
# Second insert with same provider_transaction_id should be ignored
await record_transaction(
user_id=test_user["id"],
provider_transaction_id="txn_dup",
amount_cents=9999,
)
from beanflows.core import fetch_all
rows = await fetch_all(
"SELECT * FROM transactions WHERE provider_transaction_id = ?",
("txn_dup",),
)
assert len(rows) == 1
assert rows[0]["amount_cents"] == 1000 # original value preserved

View File

@@ -0,0 +1,122 @@
"""
Tests for the billing event hook system.
"""
import pytest
from beanflows.billing.routes import _billing_hooks, _fire_hooks, on_billing_event
@pytest.fixture(autouse=True)
def clear_hooks():
"""Ensure hooks are clean before and after each test."""
_billing_hooks.clear()
yield
_billing_hooks.clear()
# ════════════════════════════════════════════════════════════
# Registration
# ════════════════════════════════════════════════════════════
class TestOnBillingEvent:
def test_registers_single_event(self):
@on_billing_event("subscription.activated")
async def my_hook(event_type, data):
pass
assert "subscription.activated" in _billing_hooks
assert my_hook in _billing_hooks["subscription.activated"]
def test_registers_multiple_events(self):
@on_billing_event("subscription.activated", "subscription.updated")
async def my_hook(event_type, data):
pass
assert my_hook in _billing_hooks["subscription.activated"]
assert my_hook in _billing_hooks["subscription.updated"]
def test_multiple_hooks_per_event(self):
@on_billing_event("subscription.activated")
async def hook_a(event_type, data):
pass
@on_billing_event("subscription.activated")
async def hook_b(event_type, data):
pass
assert len(_billing_hooks["subscription.activated"]) == 2
def test_decorator_returns_original_function(self):
@on_billing_event("test_event")
async def my_hook(event_type, data):
pass
assert my_hook.__name__ == "my_hook"
# ════════════════════════════════════════════════════════════
# Firing
# ════════════════════════════════════════════════════════════
class TestFireHooks:
async def test_fires_registered_hook(self):
calls = []
@on_billing_event("subscription.activated")
async def recorder(event_type, data):
calls.append((event_type, data))
await _fire_hooks("subscription.activated", {"id": "sub_123"})
assert len(calls) == 1
assert calls[0] == ("subscription.activated", {"id": "sub_123"})
async def test_no_hooks_registered_is_noop(self):
# Should not raise
await _fire_hooks("unregistered_event", {"id": "sub_123"})
async def test_fires_all_hooks_for_event(self):
calls = []
@on_billing_event("subscription.activated")
async def hook_a(event_type, data):
calls.append("a")
@on_billing_event("subscription.activated")
async def hook_b(event_type, data):
calls.append("b")
await _fire_hooks("subscription.activated", {})
assert calls == ["a", "b"]
# ════════════════════════════════════════════════════════════
# Error isolation
# ════════════════════════════════════════════════════════════
class TestHookErrorIsolation:
async def test_failing_hook_does_not_block_others(self):
calls = []
@on_billing_event("subscription.activated")
async def failing_hook(event_type, data):
raise RuntimeError("boom")
@on_billing_event("subscription.activated")
async def good_hook(event_type, data):
calls.append("ok")
# Should not raise despite first hook failing
await _fire_hooks("subscription.activated", {})
assert calls == ["ok"]
async def test_failing_hook_is_logged(self, caplog):
@on_billing_event("subscription.activated")
async def bad_hook(event_type, data):
raise ValueError("test error")
import logging
with caplog.at_level(logging.ERROR):
await _fire_hooks("subscription.activated", {})
assert "bad_hook" in caplog.text
assert "test error" in caplog.text

View File

@@ -1,12 +1,15 @@
"""
Route integration tests for Paddle billing endpoints.
External Paddle API calls mocked with respx.
"""
import json
import httpx
Paddle SDK calls mocked via mock_paddle_client fixture.
"""
from unittest.mock import MagicMock
import pytest
import respx
CHECKOUT_METHOD = "POST"
@@ -54,24 +57,16 @@ class TestCheckoutRoute:
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_creates_checkout_session(self, auth_client, db, test_user, mock_paddle_client):
mock_txn = MagicMock()
mock_txn.checkout.url = "https://checkout.paddle.com/test_123"
mock_paddle_client.transactions.create.return_value = mock_txn
response = await auth_client.post(f"/billing/checkout/{CHECKOUT_PLAN}", follow_redirects=False)
assert response.status_code in (302, 303, 307)
mock_paddle_client.transactions.create.assert_called_once()
async def test_invalid_plan_rejected(self, auth_client, db, test_user):
@@ -82,20 +77,13 @@ class TestCheckoutRoute:
@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):
async def test_api_error_propagates(self, auth_client, db, test_user, mock_paddle_client):
mock_paddle_client.transactions.create.side_effect = Exception("API error")
with pytest.raises(Exception, match="API error"):
await auth_client.post(f"/billing/checkout/{CHECKOUT_PLAN}")
# ════════════════════════════════════════════════════════════
# Manage subscription / Portal
# ════════════════════════════════════════════════════════════
@@ -110,24 +98,18 @@ 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"
}
}
})
)
async def test_redirects_to_portal(self, auth_client, db, test_user, create_subscription, mock_paddle_client):
await create_subscription(test_user["id"], provider_subscription_id="sub_test")
mock_sub = MagicMock()
mock_sub.management_urls.update_payment_method = "https://paddle.com/manage/test_123"
mock_paddle_client.subscriptions.get.return_value = mock_sub
response = await auth_client.post("/billing/manage", follow_redirects=False)
assert response.status_code in (302, 303, 307)
mock_paddle_client.subscriptions.get.assert_called_once_with("sub_test")
@@ -145,18 +127,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": {}})
)
async def test_cancels_subscription(self, auth_client, db, test_user, create_subscription, mock_paddle_client):
await create_subscription(test_user["id"], provider_subscription_id="sub_test")
response = await auth_client.post("/billing/cancel", follow_redirects=False)
assert response.status_code in (302, 303, 307)
mock_paddle_client.subscriptions.cancel.assert_called_once()
@@ -167,8 +145,9 @@ class TestCancelRoute:
# subscription_required decorator
# ════════════════════════════════════════════════════════════
from beanflows.billing.routes import subscription_required
from quart import Blueprint
from quart import Blueprint # noqa: E402
from beanflows.auth.routes import subscription_required # noqa: E402
test_bp = Blueprint("test", __name__)

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()

242
web/tests/test_roles.py Normal file
View File

@@ -0,0 +1,242 @@
"""
Tests for role-based access control: role_required decorator, grant/revoke/ensure_admin_role,
and admin route protection.
"""
import pytest
from quart import Blueprint
from beanflows.auth.routes import (
ensure_admin_role,
grant_role,
revoke_role,
role_required,
)
from beanflows import core
# ════════════════════════════════════════════════════════════
# grant_role / revoke_role
# ════════════════════════════════════════════════════════════
class TestGrantRole:
async def test_grants_role(self, db, test_user):
await grant_role(test_user["id"], "admin")
row = await core.fetch_one(
"SELECT role FROM user_roles WHERE user_id = ?",
(test_user["id"],),
)
assert row is not None
assert row["role"] == "admin"
async def test_idempotent(self, db, test_user):
await grant_role(test_user["id"], "admin")
await grant_role(test_user["id"], "admin")
rows = await core.fetch_all(
"SELECT role FROM user_roles WHERE user_id = ? AND role = 'admin'",
(test_user["id"],),
)
assert len(rows) == 1
class TestRevokeRole:
async def test_revokes_existing_role(self, db, test_user):
await grant_role(test_user["id"], "admin")
await revoke_role(test_user["id"], "admin")
row = await core.fetch_one(
"SELECT role FROM user_roles WHERE user_id = ? AND role = 'admin'",
(test_user["id"],),
)
assert row is None
async def test_noop_for_missing_role(self, db, test_user):
# Should not raise
await revoke_role(test_user["id"], "nonexistent")
# ════════════════════════════════════════════════════════════
# ensure_admin_role
# ════════════════════════════════════════════════════════════
class TestEnsureAdminRole:
async def test_grants_admin_for_listed_email(self, db, test_user):
core.config.ADMIN_EMAILS = ["test@example.com"]
try:
await ensure_admin_role(test_user["id"], "test@example.com")
row = await core.fetch_one(
"SELECT role FROM user_roles WHERE user_id = ? AND role = 'admin'",
(test_user["id"],),
)
assert row is not None
finally:
core.config.ADMIN_EMAILS = []
async def test_skips_for_unlisted_email(self, db, test_user):
core.config.ADMIN_EMAILS = ["boss@example.com"]
try:
await ensure_admin_role(test_user["id"], "test@example.com")
row = await core.fetch_one(
"SELECT role FROM user_roles WHERE user_id = ? AND role = 'admin'",
(test_user["id"],),
)
assert row is None
finally:
core.config.ADMIN_EMAILS = []
async def test_empty_admin_emails_grants_nothing(self, db, test_user):
core.config.ADMIN_EMAILS = []
await ensure_admin_role(test_user["id"], "test@example.com")
row = await core.fetch_one(
"SELECT role FROM user_roles WHERE user_id = ? AND role = 'admin'",
(test_user["id"],),
)
assert row is None
async def test_case_insensitive_matching(self, db, test_user):
core.config.ADMIN_EMAILS = ["test@example.com"]
try:
await ensure_admin_role(test_user["id"], "Test@Example.COM")
row = await core.fetch_one(
"SELECT role FROM user_roles WHERE user_id = ? AND role = 'admin'",
(test_user["id"],),
)
assert row is not None
finally:
core.config.ADMIN_EMAILS = []
# ════════════════════════════════════════════════════════════
# role_required decorator
# ════════════════════════════════════════════════════════════
role_test_bp = Blueprint("role_test", __name__)
@role_test_bp.route("/admin-only")
@role_required("admin")
async def admin_only_route():
return "admin-ok", 200
@role_test_bp.route("/multi-role")
@role_required("admin", "editor")
async def multi_role_route():
return "multi-ok", 200
class TestRoleRequired:
@pytest.fixture
async def role_app(self, app):
app.register_blueprint(role_test_bp)
return app
@pytest.fixture
async def role_client(self, role_app):
async with role_app.test_client() as c:
yield c
async def test_redirects_unauthenticated(self, role_client, db):
response = await role_client.get("/admin-only", follow_redirects=False)
assert response.status_code in (302, 303, 307)
async def test_rejects_user_without_role(self, role_client, db, test_user):
async with role_client.session_transaction() as sess:
sess["user_id"] = test_user["id"]
response = await role_client.get("/admin-only", follow_redirects=False)
assert response.status_code in (302, 303, 307)
async def test_allows_user_with_matching_role(self, role_client, db, test_user):
await grant_role(test_user["id"], "admin")
async with role_client.session_transaction() as sess:
sess["user_id"] = test_user["id"]
response = await role_client.get("/admin-only")
assert response.status_code == 200
async def test_multi_role_allows_any_match(self, role_client, db, test_user):
await grant_role(test_user["id"], "editor")
async with role_client.session_transaction() as sess:
sess["user_id"] = test_user["id"]
response = await role_client.get("/multi-role")
assert response.status_code == 200
async def test_multi_role_rejects_none(self, role_client, db, test_user):
await grant_role(test_user["id"], "viewer")
async with role_client.session_transaction() as sess:
sess["user_id"] = test_user["id"]
response = await role_client.get("/multi-role", follow_redirects=False)
assert response.status_code in (302, 303, 307)
# ════════════════════════════════════════════════════════════
# Admin route protection
# ════════════════════════════════════════════════════════════
class TestAdminRouteProtection:
async def test_admin_index_requires_admin_role(self, auth_client, db):
response = await auth_client.get("/admin/", follow_redirects=False)
assert response.status_code in (302, 303, 307)
async def test_admin_index_accessible_with_admin_role(self, admin_client, db):
response = await admin_client.get("/admin/")
assert response.status_code == 200
async def test_admin_users_requires_admin_role(self, auth_client, db):
response = await auth_client.get("/admin/users", follow_redirects=False)
assert response.status_code in (302, 303, 307)
async def test_admin_tasks_requires_admin_role(self, auth_client, db):
response = await auth_client.get("/admin/tasks", follow_redirects=False)
assert response.status_code in (302, 303, 307)
# ════════════════════════════════════════════════════════════
# Impersonation
# ════════════════════════════════════════════════════════════
class TestImpersonation:
async def test_impersonate_stores_admin_id(self, admin_client, db, test_user):
"""Impersonating stores admin's user_id in session['admin_impersonating']."""
# Create a second user to impersonate
now = "2025-01-01T00:00:00"
other_id = await core.execute(
"INSERT INTO users (email, name, created_at) VALUES (?, ?, ?)",
("other@example.com", "Other", now),
)
async with admin_client.session_transaction() as sess:
sess["csrf_token"] = "test_csrf"
response = await admin_client.post(
f"/admin/users/{other_id}/impersonate",
form={"csrf_token": "test_csrf"},
follow_redirects=False,
)
assert response.status_code in (302, 303, 307)
async with admin_client.session_transaction() as sess:
assert sess["user_id"] == other_id
assert sess["admin_impersonating"] == test_user["id"]
async def test_stop_impersonating_restores_admin(self, app, db, test_user, grant_role):
"""Stopping impersonation restores the admin's user_id."""
await grant_role(test_user["id"], "admin")
async with app.test_client() as c:
async with c.session_transaction() as sess:
sess["user_id"] = 999 # impersonated user
sess["admin_impersonating"] = test_user["id"]
sess["csrf_token"] = "test_csrf"
response = await c.post(
"/admin/stop-impersonating",
form={"csrf_token": "test_csrf"},
follow_redirects=False,
)
assert response.status_code in (302, 303, 307)
async with c.session_transaction() as sess:
assert sess["user_id"] == test_user["id"]
assert "admin_impersonating" not in sess