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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user