test: add service tests and fix architecture violations

- Add comprehensive unit tests for FeatureService (24 tests)
- Add comprehensive unit tests for UsageService (11 tests)
- Fix API-002/API-003 architecture violations in feature/usage APIs
- Move database queries from API layer to service layer
- Create UsageService for usage and limits management
- Create custom exceptions (FeatureNotFoundError, TierNotFoundError)
- Fix ValidationException usage in content_pages.py
- Refactor vendor features API to use proper response models
- All 35 new tests passing

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2025-12-31 18:48:59 +01:00
parent 7d1a421826
commit aa4b5a4c63
10 changed files with 1474 additions and 408 deletions

View File

@@ -29,6 +29,11 @@ from functools import lru_cache
from sqlalchemy.orm import Session, joinedload
from app.exceptions.feature import (
FeatureNotFoundError,
InvalidFeatureCodesError,
TierNotFoundError,
)
from models.database.feature import Feature, FeatureCode
from models.database.subscription import SubscriptionTier, VendorSubscription
@@ -370,6 +375,51 @@ class FeatureService:
# Admin Operations
# =========================================================================
def get_all_tiers_with_features(self, db: Session) -> list[SubscriptionTier]:
"""Get all active tiers with their features for admin."""
return (
db.query(SubscriptionTier)
.filter(SubscriptionTier.is_active == True) # noqa: E712
.order_by(SubscriptionTier.display_order)
.all()
)
def get_tier_by_code(self, db: Session, tier_code: str) -> SubscriptionTier:
"""
Get tier by code, raising exception if not found.
Raises:
TierNotFoundError: If tier not found
"""
tier = db.query(SubscriptionTier).filter(SubscriptionTier.code == tier_code).first()
if not tier:
raise TierNotFoundError(tier_code)
return tier
def get_tier_features_with_details(
self, db: Session, tier_code: str
) -> tuple[SubscriptionTier, list[Feature]]:
"""
Get tier with full feature details.
Returns:
Tuple of (tier, list of Feature objects)
Raises:
TierNotFoundError: If tier not found
"""
tier = self.get_tier_by_code(db, tier_code)
feature_codes = tier.features or []
features = (
db.query(Feature)
.filter(Feature.code.in_(feature_codes))
.order_by(Feature.category, Feature.display_order)
.all()
)
return tier, features
def update_tier_features(
self, db: Session, tier_code: str, feature_codes: list[str]
) -> SubscriptionTier:
@@ -383,11 +433,15 @@ class FeatureService:
Returns:
Updated tier
Raises:
TierNotFoundError: If tier not found
InvalidFeatureCodesError: If any feature codes are invalid
"""
tier = db.query(SubscriptionTier).filter(SubscriptionTier.code == tier_code).first()
if not tier:
raise ValueError(f"Tier '{tier_code}' not found")
raise TierNotFoundError(tier_code)
# Validate feature codes exist
valid_codes = {
@@ -395,7 +449,7 @@ class FeatureService:
}
invalid = set(feature_codes) - valid_codes
if invalid:
raise ValueError(f"Invalid feature codes: {invalid}")
raise InvalidFeatureCodesError(invalid)
tier.features = feature_codes
@@ -405,6 +459,86 @@ class FeatureService:
logger.info(f"Updated features for tier {tier_code}: {len(feature_codes)} features")
return tier
def update_feature(
self,
db: Session,
feature_code: str,
name: str | None = None,
description: str | None = None,
category: str | None = None,
ui_location: str | None = None,
ui_icon: str | None = None,
ui_route: str | None = None,
ui_badge_text: str | None = None,
minimum_tier_code: str | None = None,
is_active: bool | None = None,
is_visible: bool | None = None,
display_order: int | None = None,
) -> Feature:
"""
Update feature metadata.
Args:
db: Database session
feature_code: Feature code to update
... other optional fields to update
Returns:
Updated feature
Raises:
FeatureNotFoundError: If feature not found
TierNotFoundError: If minimum_tier_code provided but not found
"""
feature = (
db.query(Feature)
.options(joinedload(Feature.minimum_tier))
.filter(Feature.code == feature_code)
.first()
)
if not feature:
raise FeatureNotFoundError(feature_code)
# Update fields if provided
if name is not None:
feature.name = name
if description is not None:
feature.description = description
if category is not None:
feature.category = category
if ui_location is not None:
feature.ui_location = ui_location
if ui_icon is not None:
feature.ui_icon = ui_icon
if ui_route is not None:
feature.ui_route = ui_route
if ui_badge_text is not None:
feature.ui_badge_text = ui_badge_text
if is_active is not None:
feature.is_active = is_active
if is_visible is not None:
feature.is_visible = is_visible
if display_order is not None:
feature.display_order = display_order
# Update minimum tier if provided
if minimum_tier_code is not None:
if minimum_tier_code == "":
feature.minimum_tier_id = None
else:
tier = (
db.query(SubscriptionTier)
.filter(SubscriptionTier.code == minimum_tier_code)
.first()
)
if not tier:
raise TierNotFoundError(minimum_tier_code)
feature.minimum_tier_id = tier.id
logger.info(f"Updated feature {feature_code}")
return feature
def update_feature_minimum_tier(
self, db: Session, feature_code: str, tier_code: str | None
) -> Feature:
@@ -415,16 +549,20 @@ class FeatureService:
db: Database session
feature_code: Feature code
tier_code: Tier code or None
Raises:
FeatureNotFoundError: If feature not found
TierNotFoundError: If tier_code provided but not found
"""
feature = db.query(Feature).filter(Feature.code == feature_code).first()
if not feature:
raise ValueError(f"Feature '{feature_code}' not found")
raise FeatureNotFoundError(feature_code)
if tier_code:
tier = db.query(SubscriptionTier).filter(SubscriptionTier.code == tier_code).first()
if not tier:
raise ValueError(f"Tier '{tier_code}' not found")
raise TierNotFoundError(tier_code)
feature.minimum_tier_id = tier.id
else:
feature.minimum_tier_id = None