Files
padelnomics/padelnomics/tests/test_billing_helpers.py
Deeman 9703651562 add hybrid calculator refactor and comprehensive billing test suite
Move planner financial model from client-side JS to server-side Python
(calculator.py + /planner/calculate endpoint). Add full test coverage:
227 calculator tests and 371 billing tests covering SQL helpers,
webhooks, routes, and subscription gating with Hypothesis fuzzing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 12:05:03 +01:00

336 lines
17 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Unit tests for billing SQL helpers, feature/limit access, and plan determination.
"""
import pytest
from hypothesis import HealthCheck, given
from hypothesis import settings as h_settings
from hypothesis import strategies as st
from padelnomics.billing.routes import (
VARIANT_TO_PLAN,
can_access_feature,
determine_plan,
get_subscription,
get_subscription_by_provider_id,
is_within_limits,
update_subscription_status,
upsert_subscription,
)
from padelnomics.core import config
# ════════════════════════════════════════════════════════════
# get_subscription
# ════════════════════════════════════════════════════════════
class TestGetSubscription:
async def test_returns_none_for_user_without_subscription(self, db, test_user):
result = await get_subscription(test_user["id"])
assert result is None
async def test_returns_subscription_for_user(self, db, test_user, create_subscription):
await create_subscription(test_user["id"], plan="pro", status="active")
result = await get_subscription(test_user["id"])
assert result is not None
assert result["plan"] == "pro"
assert result["status"] == "active"
assert result["user_id"] == test_user["id"]
# ════════════════════════════════════════════════════════════
# upsert_subscription
# ════════════════════════════════════════════════════════════
class TestUpsertSubscription:
async def test_insert_new_subscription(self, db, test_user):
sub_id = await upsert_subscription(
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",
)
assert sub_id > 0
row = await get_subscription(test_user["id"])
assert row["plan"] == "pro"
assert row["status"] == "active"
assert row["lemonsqueezy_customer_id"] == "cust_abc"
assert row["lemonsqueezy_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",
ls_subscription_id="sub_old",
)
returned_id = await upsert_subscription(
user_id=test_user["id"],
plan="pro",
status="active",
provider_customer_id="cust_new",
provider_subscription_id="sub_new",
)
assert returned_id == original_id
row = await get_subscription(test_user["id"])
assert row["plan"] == "pro"
assert row["lemonsqueezy_subscription_id"] == "sub_new"
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,
)
row = await get_subscription(test_user["id"])
assert row["current_period_end"] is None
# ════════════════════════════════════════════════════════════
# get_subscription_by_provider_id
# ════════════════════════════════════════════════════════════
class TestGetSubscriptionByProviderId:
async def test_returns_none_for_unknown_id(self, db):
result = await get_subscription_by_provider_id("nonexistent")
assert result is None
async def test_finds_by_lemonsqueezy_subscription_id(self, db, test_user, create_subscription):
await create_subscription(test_user["id"], ls_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"]
# ════════════════════════════════════════════════════════════
# update_subscription_status
# ════════════════════════════════════════════════════════════
class TestUpdateSubscriptionStatus:
async def test_updates_status(self, db, test_user, create_subscription):
await create_subscription(test_user["id"], status="active", ls_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"], ls_subscription_id="sub_extra")
await update_subscription_status(
"sub_extra",
status="active",
plan="starter",
current_period_end="2026-01-01T00:00:00Z",
)
row = await get_subscription(test_user["id"])
assert row["status"] == "active"
assert row["plan"] == "starter"
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"], ls_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
# ════════════════════════════════════════════════════════════
# can_access_feature
# ════════════════════════════════════════════════════════════
class TestCanAccessFeature:
async def test_no_subscription_gets_free_features(self, db, test_user):
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"], "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"], "basic") is True
assert await can_access_feature(test_user["id"], "export") is True
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")
assert await can_access_feature(test_user["id"], "api") is True
async def test_on_trial_has_features(self, db, test_user, create_subscription):
await create_subscription(test_user["id"], plan="pro", status="on_trial")
assert await can_access_feature(test_user["id"], "api") is True
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"], "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")
assert await can_access_feature(test_user["id"], "export") is False
async def test_paused_falls_back_to_free(self, db, test_user, create_subscription):
await create_subscription(test_user["id"], plan="pro", status="paused")
assert await can_access_feature(test_user["id"], "api") is False
async def test_nonexistent_feature_returns_false(self, db, test_user, create_subscription):
await create_subscription(test_user["id"], plan="pro", status="active")
assert await can_access_feature(test_user["id"], "teleportation") is False
# ════════════════════════════════════════════════════════════
# is_within_limits
# ════════════════════════════════════════════════════════════
class TestIsWithinLimits:
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_at_limit(self, db, test_user):
assert await is_within_limits(test_user["id"], "items", 100) 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"], "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"], "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"], "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
# ════════════════════════════════════════════════════════════
# determine_plan
# ════════════════════════════════════════════════════════════
class TestDeterminePlan:
def test_monthly_variant_returns_pro(self):
VARIANT_TO_PLAN.clear()
assert determine_plan("variant_monthly_100") == "pro"
def test_yearly_variant_returns_pro(self):
VARIANT_TO_PLAN.clear()
assert determine_plan("variant_yearly_200") == "pro"
def test_unknown_variant_returns_free(self):
VARIANT_TO_PLAN.clear()
assert determine_plan("unknown_variant") == "free"
def test_integer_variant_id_coerced(self):
VARIANT_TO_PLAN.clear()
assert determine_plan(12345) == "free"
def test_none_variant_returns_free(self):
VARIANT_TO_PLAN.clear()
assert determine_plan(None) == "free"
# ════════════════════════════════════════════════════════════
# Parameterized: status × feature access matrix
# ════════════════════════════════════════════════════════════
STATUSES = ["free", "active", "on_trial", "cancelled", "past_due", "paused", "expired"]
FEATURES = ["basic", "export", "api", "priority_support"]
ACTIVE_STATUSES = {"active", "on_trial", "cancelled"}
@pytest.mark.parametrize("status", STATUSES)
@pytest.mark.parametrize("feature", FEATURES)
async def test_feature_access_matrix(db, test_user, create_subscription, status, feature):
if status != "free":
await create_subscription(test_user["id"], plan="pro", status=status)
result = await can_access_feature(test_user["id"], feature)
if status in ACTIVE_STATUSES:
expected = feature in config.PLAN_FEATURES["pro"]
else:
expected = feature in config.PLAN_FEATURES["free"]
assert result == expected, f"status={status}, feature={feature}"
# ════════════════════════════════════════════════════════════
# Parameterized: plan × feature matrix (active status)
# ════════════════════════════════════════════════════════════
PLANS = ["free", "starter", "pro"]
@pytest.mark.parametrize("plan", PLANS)
@pytest.mark.parametrize("feature", FEATURES)
async def test_plan_feature_matrix(db, test_user, create_subscription, plan, feature):
if plan != "free":
await create_subscription(test_user["id"], plan=plan, status="active")
result = await can_access_feature(test_user["id"], feature)
expected = feature in config.PLAN_FEATURES.get(plan, [])
assert result == expected, f"plan={plan}, feature={feature}"
# ════════════════════════════════════════════════════════════
# Parameterized: plan × resource limit boundaries
# ════════════════════════════════════════════════════════════
@pytest.mark.parametrize("plan", PLANS)
@pytest.mark.parametrize("resource,at_limit", [
("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):
if plan != "free":
await create_subscription(test_user["id"], plan=plan, status="active")
plan_limit = config.PLAN_LIMITS.get(plan, {}).get(resource, 0)
result = await is_within_limits(test_user["id"], resource, at_limit)
if plan_limit == -1:
assert result is True
elif at_limit < plan_limit:
assert result is True
else:
assert result is False
# ════════════════════════════════════════════════════════════
# Hypothesis: limit boundaries
# ════════════════════════════════════════════════════════════
class TestLimitsHypothesis:
@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_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])
async def test_pro_always_within_limits(self, db, test_user, create_subscription, count):
# 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",
)
result = await is_within_limits(test_user["id"], "items", count)
assert result is True