fix: use custom exceptions in onboarding and add tests

- Create onboarding-specific exceptions (OnboardingNotFoundException, etc.)
- Remove HTTPException usage from API endpoints per architecture rules
- Let exceptions propagate to global handler
- Add 12 integration tests for onboarding API endpoints

🤖 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-27 21:55:03 +01:00
parent 409a2eaa05
commit 73f612a01a
5 changed files with 538 additions and 118 deletions

View File

@@ -13,16 +13,12 @@ Vendor Context: Uses token_vendor_id from JWT token (authenticated vendor API pa
import logging
from fastapi import APIRouter, Depends, HTTPException
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from app.api.deps import get_current_vendor_api
from app.core.database import get_db
from app.services.onboarding_service import (
OnboardingError,
OnboardingService,
OnboardingStepError,
)
from app.services.onboarding_service import OnboardingService
from models.database.user import User
from models.schema.onboarding import (
CompanyProfileRequest,
@@ -96,24 +92,19 @@ def save_company_profile(
Updates vendor and company records with provided data.
"""
service = OnboardingService(db)
try:
result = service.complete_company_profile(
vendor_id=current_user.token_vendor_id,
company_name=request.company_name,
brand_name=request.brand_name,
description=request.description,
contact_email=request.contact_email,
contact_phone=request.contact_phone,
website=request.website,
business_address=request.business_address,
tax_number=request.tax_number,
default_language=request.default_language,
dashboard_language=request.dashboard_language,
)
return result
except OnboardingError as e:
raise HTTPException(status_code=400, detail=str(e))
return service.complete_company_profile(
vendor_id=current_user.token_vendor_id,
company_name=request.company_name,
brand_name=request.brand_name,
description=request.description,
contact_email=request.contact_email,
contact_phone=request.contact_phone,
website=request.website,
business_address=request.business_address,
tax_number=request.tax_number,
default_language=request.default_language,
dashboard_language=request.dashboard_language,
)
# =============================================================================
@@ -151,19 +142,12 @@ def save_letzshop_api(
Tests connection first, only saves if successful.
"""
service = OnboardingService(db)
try:
result = service.complete_letzshop_api(
vendor_id=current_user.token_vendor_id,
api_key=request.api_key,
shop_slug=request.shop_slug,
letzshop_vendor_id=request.vendor_id,
)
return result
except OnboardingStepError as e:
raise HTTPException(status_code=400, detail=str(e))
except OnboardingError as e:
raise HTTPException(status_code=500, detail=str(e))
return service.complete_letzshop_api(
vendor_id=current_user.token_vendor_id,
api_key=request.api_key,
shop_slug=request.shop_slug,
letzshop_vendor_id=request.vendor_id,
)
# =============================================================================
@@ -197,22 +181,15 @@ def save_product_import_config(
At least one CSV URL must be provided.
"""
service = OnboardingService(db)
try:
result = service.complete_product_import(
vendor_id=current_user.token_vendor_id,
csv_url_fr=request.csv_url_fr,
csv_url_en=request.csv_url_en,
csv_url_de=request.csv_url_de,
default_tax_rate=request.default_tax_rate,
delivery_method=request.delivery_method,
preorder_days=request.preorder_days,
)
return result
except OnboardingStepError as e:
raise HTTPException(status_code=400, detail=str(e))
except OnboardingError as e:
raise HTTPException(status_code=500, detail=str(e))
return service.complete_product_import(
vendor_id=current_user.token_vendor_id,
csv_url_fr=request.csv_url_fr,
csv_url_en=request.csv_url_en,
csv_url_de=request.csv_url_de,
default_tax_rate=request.default_tax_rate,
delivery_method=request.delivery_method,
preorder_days=request.preorder_days,
)
# =============================================================================
@@ -232,19 +209,12 @@ def trigger_order_sync(
Creates a background job that imports orders from Letzshop.
"""
service = OnboardingService(db)
try:
result = service.trigger_order_sync(
vendor_id=current_user.token_vendor_id,
user_id=current_user.id,
days_back=request.days_back,
include_products=request.include_products,
)
return result
except OnboardingStepError as e:
raise HTTPException(status_code=400, detail=str(e))
except OnboardingError as e:
raise HTTPException(status_code=500, detail=str(e))
return service.trigger_order_sync(
vendor_id=current_user.token_vendor_id,
user_id=current_user.id,
days_back=request.days_back,
include_products=request.include_products,
)
@router.get(
@@ -281,14 +251,7 @@ def complete_order_sync(
This also marks the entire onboarding as complete.
"""
service = OnboardingService(db)
try:
result = service.complete_order_sync(
vendor_id=current_user.token_vendor_id,
job_id=request.job_id,
)
return result
except OnboardingStepError as e:
raise HTTPException(status_code=400, detail=str(e))
except OnboardingError as e:
raise HTTPException(status_code=500, detail=str(e))
return service.complete_order_sync(
vendor_id=current_user.token_vendor_id,
job_id=request.job_id,
)

View File

@@ -252,6 +252,17 @@ from .vendor_theme import (
VendorThemeNotFoundException,
)
# Onboarding exceptions
from .onboarding import (
LetzshopConnectionFailedException,
OnboardingAlreadyCompletedException,
OnboardingCsvUrlRequiredException,
OnboardingNotFoundException,
OnboardingStepOrderException,
OnboardingSyncJobNotFoundException,
OnboardingSyncNotCompleteException,
)
__all__ = [
# Base exceptions
"WizamartException",
@@ -437,4 +448,12 @@ __all__ = [
"SubscriptionAlreadyCancelledException",
"InvalidWebhookSignatureException",
"WebhookMissingSignatureException",
# Onboarding exceptions
"OnboardingNotFoundException",
"OnboardingStepOrderException",
"OnboardingAlreadyCompletedException",
"LetzshopConnectionFailedException",
"OnboardingCsvUrlRequiredException",
"OnboardingSyncJobNotFoundException",
"OnboardingSyncNotCompleteException",
]

View File

@@ -0,0 +1,87 @@
# app/exceptions/onboarding.py
"""
Onboarding-specific exceptions.
Exceptions for the vendor onboarding wizard flow.
"""
from typing import Any
from .base import BusinessLogicException, ResourceNotFoundException, ValidationException
class OnboardingNotFoundException(ResourceNotFoundException):
"""Raised when onboarding record is not found for a vendor."""
def __init__(self, vendor_id: int):
super().__init__(
resource_type="VendorOnboarding",
identifier=str(vendor_id),
)
class OnboardingStepOrderException(ValidationException):
"""Raised when trying to access a step out of order."""
def __init__(self, current_step: str, required_step: str):
super().__init__(
message=f"Please complete the {required_step} step first",
field="step",
details={
"current_step": current_step,
"required_step": required_step,
},
)
class OnboardingAlreadyCompletedException(BusinessLogicException):
"""Raised when trying to modify a completed onboarding."""
def __init__(self, vendor_id: int):
super().__init__(
message="Onboarding has already been completed",
error_code="ONBOARDING_ALREADY_COMPLETED",
details={"vendor_id": vendor_id},
)
class LetzshopConnectionFailedException(BusinessLogicException):
"""Raised when Letzshop API connection test fails."""
def __init__(self, error_message: str):
super().__init__(
message=f"Letzshop connection failed: {error_message}",
error_code="LETZSHOP_CONNECTION_FAILED",
details={"error": error_message},
)
class OnboardingCsvUrlRequiredException(ValidationException):
"""Raised when no CSV URL is provided in product import step."""
def __init__(self):
super().__init__(
message="At least one CSV URL must be provided",
field="csv_url",
)
class OnboardingSyncJobNotFoundException(ResourceNotFoundException):
"""Raised when sync job is not found."""
def __init__(self, job_id: int):
super().__init__(
resource_type="LetzshopHistoricalImportJob",
identifier=str(job_id),
)
class OnboardingSyncNotCompleteException(BusinessLogicException):
"""Raised when trying to complete onboarding before sync is done."""
def __init__(self, job_status: str):
super().__init__(
message=f"Import job is still {job_status}, please wait",
error_code="SYNC_NOT_COMPLETE",
details={"job_status": job_status},
)

View File

@@ -14,10 +14,16 @@ from datetime import UTC, datetime
from sqlalchemy.orm import Session
from app.exceptions import (
OnboardingCsvUrlRequiredException,
OnboardingNotFoundException,
OnboardingStepOrderException,
OnboardingSyncJobNotFoundException,
OnboardingSyncNotCompleteException,
VendorNotFoundException,
)
from app.services.letzshop.credentials_service import LetzshopCredentialsService
from app.services.letzshop.order_service import LetzshopOrderService
from models.database.company import Company
from models.database.letzshop import LetzshopHistoricalImportJob
from models.database.onboarding import (
OnboardingStatus,
OnboardingStep,
@@ -28,18 +34,6 @@ from models.database.vendor import Vendor
logger = logging.getLogger(__name__)
class OnboardingError(Exception):
"""Base exception for onboarding errors."""
class OnboardingNotFoundError(OnboardingError):
"""Raised when onboarding record is not found."""
class OnboardingStepError(OnboardingError):
"""Raised when a step operation fails."""
class OnboardingService:
"""
Service for managing vendor onboarding workflow.
@@ -69,12 +63,10 @@ class OnboardingService:
)
def get_onboarding_or_raise(self, vendor_id: int) -> VendorOnboarding:
"""Get onboarding record or raise OnboardingNotFoundError."""
"""Get onboarding record or raise OnboardingNotFoundException."""
onboarding = self.get_onboarding(vendor_id)
if onboarding is None:
raise OnboardingNotFoundError(
f"Onboarding not found for vendor {vendor_id}"
)
raise OnboardingNotFoundException(vendor_id)
return onboarding
def create_onboarding(self, vendor_id: int) -> VendorOnboarding:
@@ -221,7 +213,7 @@ class OnboardingService:
# Get vendor and company
vendor = self.db.query(Vendor).filter(Vendor.id == vendor_id).first()
if not vendor:
raise OnboardingStepError(f"Vendor {vendor_id} not found")
raise VendorNotFoundException(vendor_id)
company = vendor.company
@@ -326,8 +318,9 @@ class OnboardingService:
# Verify step order
if not onboarding.can_proceed_to_step(OnboardingStep.LETZSHOP_API.value):
raise OnboardingStepError(
"Please complete the Company Profile step first"
raise OnboardingStepOrderException(
current_step=onboarding.current_step,
required_step=OnboardingStep.COMPANY_PROFILE.value,
)
# Test connection first
@@ -412,8 +405,9 @@ class OnboardingService:
# Verify step order
if not onboarding.can_proceed_to_step(OnboardingStep.PRODUCT_IMPORT.value):
raise OnboardingStepError(
"Please complete the Letzshop API step first"
raise OnboardingStepOrderException(
current_step=onboarding.current_step,
required_step=OnboardingStep.LETZSHOP_API.value,
)
# Validate at least one CSV URL
@@ -424,18 +418,12 @@ class OnboardingService:
])
if csv_urls_count == 0:
return {
"success": False,
"step_completed": False,
"next_step": None,
"message": "At least one CSV URL must be provided",
"csv_urls_configured": 0,
}
raise OnboardingCsvUrlRequiredException()
# Update vendor settings
vendor = self.db.query(Vendor).filter(Vendor.id == vendor_id).first()
if not vendor:
raise OnboardingStepError(f"Vendor {vendor_id} not found")
raise VendorNotFoundException(vendor_id)
vendor.letzshop_csv_url_fr = csv_url_fr
vendor.letzshop_csv_url_en = csv_url_en
@@ -480,8 +468,9 @@ class OnboardingService:
# Verify step order
if not onboarding.can_proceed_to_step(OnboardingStep.ORDER_SYNC.value):
raise OnboardingStepError(
"Please complete the Product Import step first"
raise OnboardingStepOrderException(
current_step=onboarding.current_step,
required_step=OnboardingStep.PRODUCT_IMPORT.value,
)
# Create historical import job
@@ -597,16 +586,10 @@ class OnboardingService:
job = order_service.get_historical_import_job_by_id(vendor_id, job_id)
if not job:
raise OnboardingStepError(f"Job {job_id} not found")
raise OnboardingSyncJobNotFoundException(job_id)
if job.status not in ("completed", "failed"):
return {
"success": False,
"step_completed": False,
"onboarding_completed": False,
"message": f"Job is still {job.status}, please wait",
"redirect_url": None,
}
raise OnboardingSyncNotCompleteException(job.status)
# Mark step complete (even if job failed - they can retry later)
onboarding.mark_step_complete(OnboardingStep.ORDER_SYNC.value)