"""EntitlementService enforcement (ENTITLEMENTS.md §4, §5).""" import pytest from infrasynth.billing.entitlements import EntitlementService from infrasynth.billing.models import App, Entitlement, Plan from infrasynth.billing.services import BillingService from infrasynth.tenancy.models import Tenant pytestmark = pytest.mark.django_db @pytest.fixture def app(): return App.objects.create(slug="messenger", name="Messenger", monetization="subscription") @pytest.fixture def plan(app): return Plan.objects.create( app=app, slug="pro", name="Pro", price_amount=1000, features={"broadcast": True, "analytics": False}, limits={"max_agents": 10}, ) def _entitlement(tenant, app, plan=None, status="active"): return Entitlement.all_objects.create(tenant=tenant, app=app, plan=plan, status=status) class TestIsEntitled: def test_false_without_entitlement(self, tenant, app): assert EntitlementService().is_entitled(tenant, "messenger") is False def test_active_is_entitled(self, tenant, app, plan): _entitlement(tenant, app, plan) assert EntitlementService().is_entitled(tenant, "messenger") is True @pytest.mark.parametrize("status", ["trialing", "grace", "past_due"]) def test_grace_and_trial_are_entitled(self, tenant, app, plan, status): _entitlement(tenant, app, plan, status=status) assert EntitlementService().is_entitled(tenant, "messenger") is True @pytest.mark.parametrize("status", ["suspended", "expired", "cancelled", "revoked"]) def test_inactive_statuses_are_not_entitled(self, tenant, app, plan, status): _entitlement(tenant, app, plan, status=status) assert EntitlementService().is_entitled(tenant, "messenger") is False def test_suspended_tenant_denied_regardless_of_entitlement(self, tenant, app, plan): _entitlement(tenant, app, plan, status="active") tenant.status = Tenant.Status.SUSPENDED tenant.save(update_fields=["status"]) assert EntitlementService().is_entitled(tenant, "messenger") is False def test_feature_gate_reads_plan_features(self, tenant, app, plan): _entitlement(tenant, app, plan) svc = EntitlementService() assert svc.is_entitled(tenant, "messenger", feature="broadcast") is True assert svc.is_entitled(tenant, "messenger", feature="analytics") is False assert svc.is_entitled(tenant, "messenger", feature="unknown") is False def test_none_tenant_is_never_entitled(self, app): assert EntitlementService().is_entitled(None, "messenger") is False class TestCheckLimit: def test_within_and_at_limit(self, tenant, app, plan): _entitlement(tenant, app, plan) svc = EntitlementService() assert svc.check_limit(tenant, "messenger", "max_agents", 9) is True assert svc.check_limit(tenant, "messenger", "max_agents", 10) is False def test_missing_limit_is_unlimited(self, tenant, app, plan): _entitlement(tenant, app, plan) assert EntitlementService().check_limit(tenant, "messenger", "nonexistent", 10**9) is True def test_no_entitlement_denies(self, tenant, app): assert EntitlementService().check_limit(tenant, "messenger", "max_agents", 0) is False class TestCacheInvalidation: def test_mutation_invalidates_cache(self, tenant, app, plan): entitlement = _entitlement(tenant, app, plan) svc = EntitlementService() assert svc.is_entitled(tenant, "messenger") is True entitlement.status = "revoked" entitlement.save(update_fields=["status"]) # still cached assert svc.is_entitled(tenant, "messenger") is True svc.invalidate(tenant, "messenger") assert svc.is_entitled(tenant, "messenger") is False class TestLifecycleTransitions: def test_past_due_grace_suspend_reinstate(self, tenant, app, plan): entitlement = _entitlement(tenant, app, plan) svc = BillingService() svc.mark_past_due(entitlement) assert EntitlementService().get(tenant, "messenger").status == "past_due" svc.enter_grace(entitlement) assert EntitlementService().is_entitled(tenant, "messenger") is True svc.suspend_entitlement(entitlement) assert EntitlementService().is_entitled(tenant, "messenger") is False svc.reinstate_entitlement(entitlement) assert EntitlementService().is_entitled(tenant, "messenger") is True