"""Unit tests for FeatureService.""" import pytest from app.modules.billing.models import SubscriptionTier from app.modules.billing.models.tier_feature_limit import TierFeatureLimit from app.modules.billing.services.feature_service import FeatureService from app.modules.tenancy.models import Platform @pytest.mark.unit @pytest.mark.billing class TestFeatureService: """Test suite for FeatureService.""" def setup_method(self): self.service = FeatureService() def test_service_instantiation(self): """Service can be instantiated.""" assert self.service is not None # ============================================================================ # Fixtures # ============================================================================ @pytest.fixture def fs_platform(db): """Create a platform for feature service tests.""" platform = Platform(code="fs_test", name="FS Test Platform", is_active=True) db.add(platform) db.commit() db.refresh(platform) return platform @pytest.fixture def fs_second_platform(db): """Create a second platform to test cross-platform isolation.""" platform = Platform(code="fs_test2", name="FS Test Platform 2", is_active=True) db.add(platform) db.commit() db.refresh(platform) return platform @pytest.fixture def fs_tier(db, fs_platform): """Create a tier for feature service tests.""" tier = SubscriptionTier( code="essential", name="Essential", price_monthly_cents=1000, display_order=0, is_active=True, is_public=True, platform_id=fs_platform.id, ) db.add(tier) db.commit() db.refresh(tier) return tier @pytest.fixture def fs_same_code_tier(db, fs_second_platform): """Create a tier with the SAME code but different platform.""" tier = SubscriptionTier( code="essential", name="Essential", price_monthly_cents=2000, display_order=0, is_active=True, is_public=True, platform_id=fs_second_platform.id, ) db.add(tier) db.commit() db.refresh(tier) return tier @pytest.fixture def fs_tier_with_features(db, fs_tier): """Create a tier with pre-existing feature limits.""" features = [ TierFeatureLimit(tier_id=fs_tier.id, feature_code="feature_a", limit_value=None), TierFeatureLimit(tier_id=fs_tier.id, feature_code="feature_b", limit_value=100), TierFeatureLimit(tier_id=fs_tier.id, feature_code="feature_c", limit_value=50), ] db.add_all(features) db.commit() return features # ============================================================================ # get_tier_feature_limits # ============================================================================ @pytest.mark.unit @pytest.mark.billing class TestGetTierFeatureLimits: """Tests for FeatureService.get_tier_feature_limits.""" def test_returns_limits_for_tier(self, db, fs_tier_with_features, fs_tier): """Returns all feature limit rows for the given tier ID.""" service = FeatureService() rows = service.get_tier_feature_limits(db, fs_tier.id) assert len(rows) == 3 codes = {r.feature_code for r in rows} assert codes == {"feature_a", "feature_b", "feature_c"} def test_returns_empty_for_tier_without_features(self, db, fs_tier): """Returns empty list for a tier with no feature limits.""" service = FeatureService() rows = service.get_tier_feature_limits(db, fs_tier.id) assert rows == [] def test_returns_empty_for_nonexistent_tier(self, db): """Returns empty list for a tier ID that doesn't exist.""" service = FeatureService() rows = service.get_tier_feature_limits(db, 999999) assert rows == [] def test_isolates_by_tier_id(self, db, fs_tier, fs_same_code_tier, fs_tier_with_features): """Features for one tier don't leak to another with the same code.""" service = FeatureService() # fs_tier has 3 features rows_tier1 = service.get_tier_feature_limits(db, fs_tier.id) assert len(rows_tier1) == 3 # fs_same_code_tier (same code, different platform) has 0 rows_tier2 = service.get_tier_feature_limits(db, fs_same_code_tier.id) assert len(rows_tier2) == 0 # ============================================================================ # upsert_tier_feature_limits # ============================================================================ @pytest.mark.unit @pytest.mark.billing class TestUpsertTierFeatureLimits: """Tests for FeatureService.upsert_tier_feature_limits.""" def test_inserts_new_features(self, db, fs_tier): """Creates feature limit rows for a tier.""" service = FeatureService() entries = [ {"feature_code": "feat_x", "limit_value": None, "enabled": True}, {"feature_code": "feat_y", "limit_value": 200, "enabled": True}, ] rows = service.upsert_tier_feature_limits(db, fs_tier.id, entries) db.commit() assert len(rows) == 2 assert {r.feature_code for r in rows} == {"feat_x", "feat_y"} def test_replaces_existing_features(self, db, fs_tier, fs_tier_with_features): """Upsert deletes old features and inserts new ones.""" service = FeatureService() entries = [ {"feature_code": "new_feature", "limit_value": None, "enabled": True}, ] rows = service.upsert_tier_feature_limits(db, fs_tier.id, entries) db.commit() assert len(rows) == 1 assert rows[0].feature_code == "new_feature" # Old features should be gone remaining = service.get_tier_feature_limits(db, fs_tier.id) assert len(remaining) == 1 assert remaining[0].feature_code == "new_feature" def test_skips_disabled_entries(self, db, fs_tier): """Entries with enabled=False are not persisted.""" service = FeatureService() entries = [ {"feature_code": "enabled_feat", "limit_value": None, "enabled": True}, {"feature_code": "disabled_feat", "limit_value": None, "enabled": False}, ] rows = service.upsert_tier_feature_limits(db, fs_tier.id, entries) db.commit() assert len(rows) == 1 assert rows[0].feature_code == "enabled_feat" def test_saves_to_correct_tier_by_id(self, db, fs_tier, fs_same_code_tier): """ Regression test: saving by tier_id targets the exact tier, not another tier that happens to share the same code. """ service = FeatureService() entries = [ {"feature_code": "platform_specific", "limit_value": None, "enabled": True}, ] # Save to the second tier (same code "essential", different platform) service.upsert_tier_feature_limits(db, fs_same_code_tier.id, entries) db.commit() # First tier should have 0 features rows_tier1 = service.get_tier_feature_limits(db, fs_tier.id) assert len(rows_tier1) == 0 # Second tier should have 1 feature rows_tier2 = service.get_tier_feature_limits(db, fs_same_code_tier.id) assert len(rows_tier2) == 1 assert rows_tier2[0].feature_code == "platform_specific" def test_clears_all_features_with_empty_list(self, db, fs_tier, fs_tier_with_features): """Passing an empty list removes all features.""" service = FeatureService() rows = service.upsert_tier_feature_limits(db, fs_tier.id, []) db.commit() assert len(rows) == 0 remaining = service.get_tier_feature_limits(db, fs_tier.id) assert len(remaining) == 0 def test_preserves_limit_values(self, db, fs_tier): """Limit values (including None for unlimited) are stored correctly.""" service = FeatureService() entries = [ {"feature_code": "unlimited", "limit_value": None, "enabled": True}, {"feature_code": "limited", "limit_value": 42, "enabled": True}, ] service.upsert_tier_feature_limits(db, fs_tier.id, entries) db.commit() rows = service.get_tier_feature_limits(db, fs_tier.id) limits = {r.feature_code: r.limit_value for r in rows} assert limits["unlimited"] is None assert limits["limited"] == 42