- Fix IPv6 host parsing with _strip_port() utility - Remove dangerous StorePlatform→Store.subdomain silent fallback - Close storefront gate bypass when frontend_type is None - Add custom subdomain management UI and API for stores - Add domain health diagnostic tool - Convert db.add() in loops to db.add_all() (24 PERF-006 fixes) - Add tests for all new functionality (18 subdomain service tests) - Add .github templates for validator compliance Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
241 lines
8.2 KiB
Python
241 lines
8.2 KiB
Python
"""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
|