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

@@ -11,15 +11,13 @@ Provides endpoints for:
import logging
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi import APIRouter, Depends, Query
from pydantic import BaseModel
from sqlalchemy.orm import Session
from app.api.deps import get_current_admin_api
from app.core.database import get_db
from app.services.feature_service import feature_service
from models.database.feature import Feature
from models.database.subscription import SubscriptionTier
from models.database.user import User
router = APIRouter(prefix="/features")
@@ -103,6 +101,41 @@ class CategoryListResponse(BaseModel):
categories: list[str]
class TierFeatureDetailResponse(BaseModel):
"""Tier features with full details."""
tier_code: str
tier_name: str
features: list[dict]
feature_count: int
# ============================================================================
# Helper Functions
# ============================================================================
def _feature_to_response(feature) -> FeatureResponse:
"""Convert Feature model to response."""
return FeatureResponse(
id=feature.id,
code=feature.code,
name=feature.name,
description=feature.description,
category=feature.category,
ui_location=feature.ui_location,
ui_icon=feature.ui_icon,
ui_route=feature.ui_route,
ui_badge_text=feature.ui_badge_text,
minimum_tier_id=feature.minimum_tier_id,
minimum_tier_code=feature.minimum_tier.code if feature.minimum_tier else None,
minimum_tier_name=feature.minimum_tier.name if feature.minimum_tier else None,
is_active=feature.is_active,
is_visible=feature.is_visible,
display_order=feature.display_order,
)
# ============================================================================
# Endpoints
# ============================================================================
@@ -121,26 +154,7 @@ def list_features(
)
return FeatureListResponse(
features=[
FeatureResponse(
id=f.id,
code=f.code,
name=f.name,
description=f.description,
category=f.category,
ui_location=f.ui_location,
ui_icon=f.ui_icon,
ui_route=f.ui_route,
ui_badge_text=f.ui_badge_text,
minimum_tier_id=f.minimum_tier_id,
minimum_tier_code=f.minimum_tier.code if f.minimum_tier else None,
minimum_tier_name=f.minimum_tier.name if f.minimum_tier else None,
is_active=f.is_active,
is_visible=f.is_visible,
display_order=f.display_order,
)
for f in features
],
features=[_feature_to_response(f) for f in features],
total=len(features),
)
@@ -161,12 +175,7 @@ def list_tiers_with_features(
db: Session = Depends(get_db),
):
"""List all tiers with their feature assignments."""
tiers = (
db.query(SubscriptionTier)
.filter(SubscriptionTier.is_active == True) # noqa: E712
.order_by(SubscriptionTier.display_order)
.all()
)
tiers = feature_service.get_all_tiers_with_features(db)
return TierListWithFeaturesResponse(
tiers=[
@@ -189,29 +198,19 @@ def get_feature(
current_user: User = Depends(get_current_admin_api),
db: Session = Depends(get_db),
):
"""Get a single feature by code."""
"""
Get a single feature by code.
Raises 404 if feature not found.
"""
feature = feature_service.get_feature_by_code(db, feature_code)
if not feature:
raise HTTPException(status_code=404, detail=f"Feature '{feature_code}' not found")
from app.exceptions import FeatureNotFoundError
return FeatureResponse(
id=feature.id,
code=feature.code,
name=feature.name,
description=feature.description,
category=feature.category,
ui_location=feature.ui_location,
ui_icon=feature.ui_icon,
ui_route=feature.ui_route,
ui_badge_text=feature.ui_badge_text,
minimum_tier_id=feature.minimum_tier_id,
minimum_tier_code=feature.minimum_tier.code if feature.minimum_tier else None,
minimum_tier_name=feature.minimum_tier.name if feature.minimum_tier else None,
is_active=feature.is_active,
is_visible=feature.is_visible,
display_order=feature.display_order,
)
raise FeatureNotFoundError(feature_code)
return _feature_to_response(feature)
@router.put("/{feature_code}", response_model=FeatureResponse)
@@ -221,73 +220,33 @@ def update_feature(
current_user: User = Depends(get_current_admin_api),
db: Session = Depends(get_db),
):
"""Update feature metadata."""
feature = db.query(Feature).filter(Feature.code == feature_code).first()
"""
Update feature metadata.
if not feature:
raise HTTPException(status_code=404, detail=f"Feature '{feature_code}' not found")
# Update fields if provided
if request.name is not None:
feature.name = request.name
if request.description is not None:
feature.description = request.description
if request.category is not None:
feature.category = request.category
if request.ui_location is not None:
feature.ui_location = request.ui_location
if request.ui_icon is not None:
feature.ui_icon = request.ui_icon
if request.ui_route is not None:
feature.ui_route = request.ui_route
if request.ui_badge_text is not None:
feature.ui_badge_text = request.ui_badge_text
if request.is_active is not None:
feature.is_active = request.is_active
if request.is_visible is not None:
feature.is_visible = request.is_visible
if request.display_order is not None:
feature.display_order = request.display_order
# Update minimum tier if provided
if request.minimum_tier_code is not None:
if request.minimum_tier_code == "":
feature.minimum_tier_id = None
else:
tier = (
db.query(SubscriptionTier)
.filter(SubscriptionTier.code == request.minimum_tier_code)
.first()
)
if not tier:
raise HTTPException(
status_code=400,
detail=f"Tier '{request.minimum_tier_code}' not found",
)
feature.minimum_tier_id = tier.id
Raises 404 if feature not found, 400 if tier code is invalid.
"""
feature = feature_service.update_feature(
db,
feature_code,
name=request.name,
description=request.description,
category=request.category,
ui_location=request.ui_location,
ui_icon=request.ui_icon,
ui_route=request.ui_route,
ui_badge_text=request.ui_badge_text,
minimum_tier_code=request.minimum_tier_code,
is_active=request.is_active,
is_visible=request.is_visible,
display_order=request.display_order,
)
db.commit()
db.refresh(feature)
logger.info(f"Updated feature {feature_code} by admin {current_user.id}")
return FeatureResponse(
id=feature.id,
code=feature.code,
name=feature.name,
description=feature.description,
category=feature.category,
ui_location=feature.ui_location,
ui_icon=feature.ui_icon,
ui_route=feature.ui_route,
ui_badge_text=feature.ui_badge_text,
minimum_tier_id=feature.minimum_tier_id,
minimum_tier_code=feature.minimum_tier.code if feature.minimum_tier else None,
minimum_tier_name=feature.minimum_tier.name if feature.minimum_tier else None,
is_active=feature.is_active,
is_visible=feature.is_visible,
display_order=feature.display_order,
)
return _feature_to_response(feature)
@router.put("/tiers/{tier_code}/features", response_model=TierFeaturesResponse)
@@ -297,54 +256,46 @@ def update_tier_features(
current_user: User = Depends(get_current_admin_api),
db: Session = Depends(get_db),
):
"""Update features assigned to a tier."""
try:
tier = feature_service.update_tier_features(db, tier_code, request.feature_codes)
db.commit()
"""
Update features assigned to a tier.
logger.info(
f"Updated tier {tier_code} features to {len(request.feature_codes)} features "
f"by admin {current_user.id}"
)
Raises 404 if tier not found, 422 if any feature codes are invalid.
"""
tier = feature_service.update_tier_features(db, tier_code, request.feature_codes)
db.commit()
return TierFeaturesResponse(
id=tier.id,
code=tier.code,
name=tier.name,
description=tier.description,
features=tier.features or [],
feature_count=len(tier.features or []),
)
logger.info(
f"Updated tier {tier_code} features to {len(request.feature_codes)} features "
f"by admin {current_user.id}"
)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
return TierFeaturesResponse(
id=tier.id,
code=tier.code,
name=tier.name,
description=tier.description,
features=tier.features or [],
feature_count=len(tier.features or []),
)
@router.get("/tiers/{tier_code}/features")
@router.get("/tiers/{tier_code}/features", response_model=TierFeatureDetailResponse)
def get_tier_features(
tier_code: str,
current_user: User = Depends(get_current_admin_api),
db: Session = Depends(get_db),
):
"""Get features assigned to a specific tier."""
tier = db.query(SubscriptionTier).filter(SubscriptionTier.code == tier_code).first()
"""
Get features assigned to a specific tier with full details.
if not tier:
raise HTTPException(status_code=404, detail=f"Tier '{tier_code}' not found")
Raises 404 if tier not found.
"""
tier, features = feature_service.get_tier_features_with_details(db, tier_code)
# Get full feature details for the tier's features
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_code": tier.code,
"tier_name": tier.name,
"features": [
return TierFeatureDetailResponse(
tier_code=tier.code,
tier_name=tier.name,
features=[
{
"code": f.code,
"name": f.name,
@@ -353,5 +304,5 @@ def get_tier_features(
}
for f in features
],
"feature_count": len(features),
}
feature_count=len(features),
)