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

View File

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

View File

@@ -0,0 +1,368 @@
# tests/integration/api/v1/vendor/test_onboarding.py
"""
Integration tests for vendor onboarding API endpoints.
Tests cover:
1. Onboarding status retrieval
2. Step 1: Company profile setup
3. Step 2: Letzshop API configuration
4. Step 3: Product import configuration
5. Step 4: Order sync (mocked)
6. Step order validation (can't skip ahead)
7. Onboarding completion flow
"""
import pytest
from models.database.onboarding import OnboardingStatus, OnboardingStep, VendorOnboarding
@pytest.mark.integration
@pytest.mark.api
@pytest.mark.vendor
class TestVendorOnboardingStatusAPI:
"""Test onboarding status endpoint"""
def test_get_status_creates_onboarding_if_missing(
self, client, vendor_user_headers, test_vendor_with_vendor_user, db
):
"""Test that getting status creates onboarding record if none exists"""
# First ensure no onboarding exists
existing = (
db.query(VendorOnboarding)
.filter(VendorOnboarding.vendor_id == test_vendor_with_vendor_user.id)
.first()
)
if existing:
db.delete(existing)
db.commit()
response = client.get(
"/api/v1/vendor/onboarding/status", headers=vendor_user_headers
)
assert response.status_code == 200
data = response.json()
assert data["vendor_id"] == test_vendor_with_vendor_user.id
assert data["status"] == OnboardingStatus.NOT_STARTED.value
assert data["current_step"] == OnboardingStep.COMPANY_PROFILE.value
assert data["completed_steps_count"] == 0
assert data["total_steps"] == 4
assert data["is_completed"] is False
def test_get_status_structure(
self, client, vendor_user_headers, test_vendor_with_vendor_user, db
):
"""Test status response has correct structure"""
response = client.get(
"/api/v1/vendor/onboarding/status", headers=vendor_user_headers
)
assert response.status_code == 200
data = response.json()
# Verify structure
assert "id" in data
assert "vendor_id" in data
assert "status" in data
assert "current_step" in data
assert "company_profile" in data
assert "letzshop_api" in data
assert "product_import" in data
assert "order_sync" in data
assert "completion_percentage" in data
assert "completed_steps_count" in data
assert "total_steps" in data
assert "is_completed" in data
def test_get_status_requires_auth(self, client):
"""Test that onboarding status requires authentication"""
response = client.get("/api/v1/vendor/onboarding/status")
assert response.status_code == 401
@pytest.mark.integration
@pytest.mark.api
@pytest.mark.vendor
class TestVendorOnboardingStep1API:
"""Test Step 1: Company Profile endpoints"""
def test_get_company_profile_data(
self, client, vendor_user_headers, test_vendor_with_vendor_user, db
):
"""Test getting company profile data"""
response = client.get(
"/api/v1/vendor/onboarding/step/company-profile",
headers=vendor_user_headers,
)
assert response.status_code == 200
data = response.json()
# Should return pre-filled data
assert "brand_name" in data
assert "contact_email" in data
assert "default_language" in data
def test_save_company_profile_success(
self, client, vendor_user_headers, test_vendor_with_vendor_user, db
):
"""Test saving company profile completes step 1"""
# First ensure clean state
existing = (
db.query(VendorOnboarding)
.filter(VendorOnboarding.vendor_id == test_vendor_with_vendor_user.id)
.first()
)
if existing:
db.delete(existing)
db.commit()
response = client.post(
"/api/v1/vendor/onboarding/step/company-profile",
headers=vendor_user_headers,
json={
"company_name": "Test Company Ltd",
"brand_name": "Test Brand",
"description": "A test company for testing",
"contact_email": "test@example.com",
"contact_phone": "+352123456789",
"website": "https://test.example.com",
"business_address": "123 Test Street, Luxembourg",
"tax_number": "LU12345678",
"default_language": "fr",
"dashboard_language": "en",
},
)
assert response.status_code == 200
data = response.json()
assert data["success"] is True
assert data["step_completed"] is True
assert data["next_step"] == OnboardingStep.LETZSHOP_API.value
# Verify onboarding was updated
onboarding = (
db.query(VendorOnboarding)
.filter(VendorOnboarding.vendor_id == test_vendor_with_vendor_user.id)
.first()
)
db.refresh(onboarding)
assert onboarding.step_company_profile_completed is True
assert onboarding.status == OnboardingStatus.IN_PROGRESS.value
def test_save_company_profile_with_minimal_data(
self, client, vendor_user_headers, test_vendor_with_vendor_user, db
):
"""Test saving company profile with minimal data"""
# Clear existing onboarding
existing = (
db.query(VendorOnboarding)
.filter(VendorOnboarding.vendor_id == test_vendor_with_vendor_user.id)
.first()
)
if existing:
db.delete(existing)
db.commit()
response = client.post(
"/api/v1/vendor/onboarding/step/company-profile",
headers=vendor_user_headers,
json={
"default_language": "fr",
"dashboard_language": "fr",
},
)
assert response.status_code == 200
data = response.json()
assert data["success"] is True
@pytest.mark.integration
@pytest.mark.api
@pytest.mark.vendor
class TestVendorOnboardingStep2API:
"""Test Step 2: Letzshop API Configuration endpoints"""
def test_letzshop_api_test_endpoint(
self, client, vendor_user_headers, test_vendor_with_vendor_user
):
"""Test the Letzshop API test endpoint"""
response = client.post(
"/api/v1/vendor/onboarding/step/letzshop-api/test",
headers=vendor_user_headers,
json={
"api_key": "test_invalid_key_12345",
"shop_slug": "test-shop",
},
)
assert response.status_code == 200
data = response.json()
# Response should indicate success or failure
assert "success" in data
assert "message" in data
def test_letzshop_api_requires_step1_complete(
self, client, vendor_user_headers, test_vendor_with_vendor_user, db
):
"""Test that step 2 requires step 1 to be completed"""
# Ensure fresh state with no steps completed
existing = (
db.query(VendorOnboarding)
.filter(VendorOnboarding.vendor_id == test_vendor_with_vendor_user.id)
.first()
)
if existing:
db.delete(existing)
db.commit()
# Try to save step 2 directly
response = client.post(
"/api/v1/vendor/onboarding/step/letzshop-api",
headers=vendor_user_headers,
json={
"api_key": "test_api_key_12345678901234567890",
"shop_slug": "test-shop",
},
)
# Should fail because step 1 is not complete
assert response.status_code == 422 # Validation error
data = response.json()
assert "step" in str(data).lower() or "complete" in str(data).lower()
@pytest.mark.integration
@pytest.mark.api
@pytest.mark.vendor
class TestVendorOnboardingStep3API:
"""Test Step 3: Product Import Configuration endpoints"""
def test_get_product_import_config(
self, client, vendor_user_headers, test_vendor_with_vendor_user
):
"""Test getting product import configuration"""
response = client.get(
"/api/v1/vendor/onboarding/step/product-import",
headers=vendor_user_headers,
)
assert response.status_code == 200
data = response.json()
assert "csv_url_fr" in data
assert "csv_url_en" in data
assert "csv_url_de" in data
assert "default_tax_rate" in data
assert "delivery_method" in data
def test_product_import_requires_csv_url(
self, client, vendor_user_headers, test_vendor_with_vendor_user, db
):
"""Test that product import requires at least one CSV URL"""
# Set up: complete steps 1 and 2 first
onboarding = (
db.query(VendorOnboarding)
.filter(VendorOnboarding.vendor_id == test_vendor_with_vendor_user.id)
.first()
)
if not onboarding:
onboarding = VendorOnboarding(
vendor_id=test_vendor_with_vendor_user.id,
status=OnboardingStatus.IN_PROGRESS.value,
current_step=OnboardingStep.PRODUCT_IMPORT.value,
)
db.add(onboarding)
onboarding.step_company_profile_completed = True
onboarding.step_letzshop_api_completed = True
onboarding.current_step = OnboardingStep.PRODUCT_IMPORT.value
db.commit()
# Try to save without any CSV URL
response = client.post(
"/api/v1/vendor/onboarding/step/product-import",
headers=vendor_user_headers,
json={
"default_tax_rate": 17,
"delivery_method": "package_delivery",
"preorder_days": 1,
},
)
# Should fail with validation error
assert response.status_code == 422
@pytest.mark.integration
@pytest.mark.api
@pytest.mark.vendor
class TestVendorOnboardingStep4API:
"""Test Step 4: Order Sync endpoints"""
def test_order_sync_progress_endpoint(
self, client, vendor_user_headers, test_vendor_with_vendor_user
):
"""Test getting order sync progress for non-existent job"""
response = client.get(
"/api/v1/vendor/onboarding/step/order-sync/progress/99999",
headers=vendor_user_headers,
)
assert response.status_code == 200
data = response.json()
# Should return not_found status for non-existent job
assert data["status"] == "not_found"
assert data["job_id"] == 99999
@pytest.mark.integration
@pytest.mark.api
@pytest.mark.vendor
class TestVendorOnboardingFlowAPI:
"""Test complete onboarding flow"""
def test_completion_percentage_updates(
self, client, vendor_user_headers, test_vendor_with_vendor_user, db
):
"""Test that completion percentage updates as steps are completed"""
# Clear existing onboarding
existing = (
db.query(VendorOnboarding)
.filter(VendorOnboarding.vendor_id == test_vendor_with_vendor_user.id)
.first()
)
if existing:
db.delete(existing)
db.commit()
# Check initial state
response = client.get(
"/api/v1/vendor/onboarding/status", headers=vendor_user_headers
)
data = response.json()
assert data["completion_percentage"] == 0
# Complete step 1
client.post(
"/api/v1/vendor/onboarding/step/company-profile",
headers=vendor_user_headers,
json={
"default_language": "fr",
"dashboard_language": "fr",
},
)
# Check progress
response = client.get(
"/api/v1/vendor/onboarding/status", headers=vendor_user_headers
)
data = response.json()
assert data["completion_percentage"] == 25 # 1/4 = 25%
assert data["completed_steps_count"] == 1