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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user