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