shop product refactoring

This commit is contained in:
2025-10-04 23:38:53 +02:00
parent 4d2866af5e
commit 0114b6c46e
68 changed files with 2234 additions and 2236 deletions

View File

@@ -35,10 +35,10 @@ except ImportError as e:
print(f" ✗ Stock model failed: {e}")
try:
from models.database.shop import Shop
print("Shop model imported")
from models.database.vendor import Vendor
print("Vendor model imported")
except ImportError as e:
print(f"Shop model failed: {e}")
print(f"Vendor model failed: {e}")
try:
from models.database.product import Product

View File

@@ -60,13 +60,13 @@ def upgrade() -> None:
sa.Column('shipping', sa.String(), nullable=True),
sa.Column('currency', sa.String(), nullable=True),
sa.Column('marketplace', sa.String(), nullable=True),
sa.Column('shop_name', sa.String(), nullable=True),
sa.Column('vendor_name', sa.String(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('updated_at', sa.DateTime(), nullable=False),
sa.PrimaryKeyConstraint('id')
)
op.create_index('idx_marketplace_brand', 'products', ['marketplace', 'brand'], unique=False)
op.create_index('idx_marketplace_shop', 'products', ['marketplace', 'shop_name'], unique=False)
op.create_index('idx_marketplace_shop', 'products', ['marketplace', 'vendor_name'], unique=False)
op.create_index(op.f('ix_products_availability'), 'products', ['availability'], unique=False)
op.create_index(op.f('ix_products_brand'), 'products', ['brand'], unique=False)
op.create_index(op.f('ix_products_google_product_category'), 'products', ['google_product_category'], unique=False)
@@ -74,7 +74,7 @@ def upgrade() -> None:
op.create_index(op.f('ix_products_id'), 'products', ['id'], unique=False)
op.create_index(op.f('ix_products_marketplace'), 'products', ['marketplace'], unique=False)
op.create_index(op.f('ix_products_product_id'), 'products', ['marketplace_product_id'], unique=True)
op.create_index(op.f('ix_products_shop_name'), 'products', ['shop_name'], unique=False)
op.create_index(op.f('ix_products_vendor_name'), 'products', ['vendor_name'], unique=False)
op.create_table('users',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('email', sa.String(), nullable=False),
@@ -90,10 +90,10 @@ def upgrade() -> None:
op.create_index(op.f('ix_users_email'), 'users', ['email'], unique=True)
op.create_index(op.f('ix_users_id'), 'users', ['id'], unique=False)
op.create_index(op.f('ix_users_username'), 'users', ['username'], unique=True)
op.create_table('shops',
op.create_table('vendors',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('shop_code', sa.String(), nullable=False),
sa.Column('shop_name', sa.String(), nullable=False),
sa.Column('vendor_code', sa.String(), nullable=False),
sa.Column('vendor_name', sa.String(), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('owner_id', sa.Integer(), nullable=False),
sa.Column('contact_email', sa.String(), nullable=True),
@@ -108,15 +108,15 @@ def upgrade() -> None:
sa.ForeignKeyConstraint(['owner_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_shops_id'), 'shops', ['id'], unique=False)
op.create_index(op.f('ix_shops_shop_code'), 'shops', ['shop_code'], unique=True)
op.create_index(op.f('ix_vendors_id'), 'vendors', ['id'], unique=False)
op.create_index(op.f('ix_vendors_vendor_code'), 'vendors', ['vendor_code'], unique=True)
op.create_table('marketplace_import_jobs',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('status', sa.String(), nullable=False),
sa.Column('source_url', sa.String(), nullable=False),
sa.Column('marketplace', sa.String(), nullable=False),
sa.Column('shop_name', sa.String(), nullable=False),
sa.Column('shop_id', sa.Integer(), nullable=False),
sa.Column('vendor_name', sa.String(), nullable=False),
sa.Column('vendor_id', sa.Integer(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('imported_count', sa.Integer(), nullable=True),
sa.Column('updated_count', sa.Integer(), nullable=True),
@@ -126,26 +126,26 @@ def upgrade() -> None:
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('started_at', sa.DateTime(), nullable=True),
sa.Column('completed_at', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(['shop_id'], ['shops.id'], ),
sa.ForeignKeyConstraint(['vendor_id'], ['vendors.id'], ),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index('idx_marketplace_import_shop_id', 'marketplace_import_jobs', ['shop_id'], unique=False)
op.create_index('idx_marketplace_import_shop_status', 'marketplace_import_jobs', ['status'], unique=False)
op.create_index('idx_marketplace_import_vendor_id', 'marketplace_import_jobs', ['vendor_id'], unique=False)
op.create_index('idx_marketplace_import_vendor_status', 'marketplace_import_jobs', ['status'], unique=False)
op.create_index('idx_marketplace_import_user_marketplace', 'marketplace_import_jobs', ['user_id', 'marketplace'], unique=False)
op.create_index(op.f('ix_marketplace_import_jobs_id'), 'marketplace_import_jobs', ['id'], unique=False)
op.create_index(op.f('ix_marketplace_import_jobs_marketplace'), 'marketplace_import_jobs', ['marketplace'], unique=False)
op.create_index(op.f('ix_marketplace_import_jobs_shop_name'), 'marketplace_import_jobs', ['shop_name'], unique=False)
op.create_table('shop_products',
op.create_index(op.f('ix_marketplace_import_jobs_vendor_name'), 'marketplace_import_jobs', ['vendor_name'], unique=False)
op.create_table('vendor_products',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('shop_id', sa.Integer(), nullable=False),
sa.Column('vendor_id', sa.Integer(), nullable=False),
sa.Column('marketplace_product_id', sa.Integer(), nullable=False),
sa.Column('shop_product_id', sa.String(), nullable=True),
sa.Column('shop_price', sa.Float(), nullable=True),
sa.Column('shop_sale_price', sa.Float(), nullable=True),
sa.Column('shop_currency', sa.String(), nullable=True),
sa.Column('shop_availability', sa.String(), nullable=True),
sa.Column('shop_condition', sa.String(), nullable=True),
sa.Column('vendor_product_id', sa.String(), nullable=True),
sa.Column('price', sa.Float(), nullable=True),
sa.Column('vendor_sale_price', sa.Float(), nullable=True),
sa.Column('vendor_currency', sa.String(), nullable=True),
sa.Column('vendor_availability', sa.String(), nullable=True),
sa.Column('vendor_condition', sa.String(), nullable=True),
sa.Column('is_featured', sa.Boolean(), nullable=True),
sa.Column('is_active', sa.Boolean(), nullable=True),
sa.Column('display_order', sa.Integer(), nullable=True),
@@ -154,23 +154,23 @@ def upgrade() -> None:
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.Column('updated_at', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(['marketplace_product_id'], ['products.id'], ),
sa.ForeignKeyConstraint(['shop_id'], ['shops.id'], ),
sa.ForeignKeyConstraint(['vendor_id'], ['vendors.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('shop_id', 'marketplace_product_id', name='uq_shop_product')
sa.UniqueConstraint('vendor_id', 'marketplace_product_id', name='uq_vendor_product')
)
op.create_index('idx_shop_product_active', 'shop_products', ['shop_id', 'is_active'], unique=False)
op.create_index('idx_shop_product_featured', 'shop_products', ['shop_id', 'is_featured'], unique=False)
op.create_index(op.f('ix_shop_products_id'), 'shop_products', ['id'], unique=False)
op.create_index('idx_vendor_product_active', 'vendor_products', ['vendor_id', 'is_active'], unique=False)
op.create_index('idx_vendor_product_featured', 'vendor_products', ['vendor_id', 'is_featured'], unique=False)
op.create_index(op.f('ix_vendor_products_id'), 'vendor_products', ['id'], unique=False)
op.create_table('stock',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('gtin', sa.String(), nullable=False),
sa.Column('location', sa.String(), nullable=False),
sa.Column('quantity', sa.Integer(), nullable=False),
sa.Column('reserved_quantity', sa.Integer(), nullable=True),
sa.Column('shop_id', sa.Integer(), nullable=True),
sa.Column('vendor_id', sa.Integer(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('updated_at', sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(['shop_id'], ['shops.id'], ),
sa.ForeignKeyConstraint(['vendor_id'], ['vendors.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('gtin', 'location', name='uq_stock_gtin_location')
)
@@ -188,25 +188,25 @@ def downgrade() -> None:
op.drop_index(op.f('ix_stock_gtin'), table_name='stock')
op.drop_index('idx_stock_gtin_location', table_name='stock')
op.drop_table('stock')
op.drop_index(op.f('ix_shop_products_id'), table_name='shop_products')
op.drop_index('idx_shop_product_featured', table_name='shop_products')
op.drop_index('idx_shop_product_active', table_name='shop_products')
op.drop_table('shop_products')
op.drop_index(op.f('ix_marketplace_import_jobs_shop_name'), table_name='marketplace_import_jobs')
op.drop_index(op.f('ix_vendor_products_id'), table_name='vendor_products')
op.drop_index('idx_vendor_product_featured', table_name='vendor_products')
op.drop_index('idx_vendor_product_active', table_name='vendor_products')
op.drop_table('vendor_products')
op.drop_index(op.f('ix_marketplace_import_jobs_vendor_name'), table_name='marketplace_import_jobs')
op.drop_index(op.f('ix_marketplace_import_jobs_marketplace'), table_name='marketplace_import_jobs')
op.drop_index(op.f('ix_marketplace_import_jobs_id'), table_name='marketplace_import_jobs')
op.drop_index('idx_marketplace_import_user_marketplace', table_name='marketplace_import_jobs')
op.drop_index('idx_marketplace_import_shop_status', table_name='marketplace_import_jobs')
op.drop_index('idx_marketplace_import_shop_id', table_name='marketplace_import_jobs')
op.drop_index('idx_marketplace_import_vendor_status', table_name='marketplace_import_jobs')
op.drop_index('idx_marketplace_import_vendor_id', table_name='marketplace_import_jobs')
op.drop_table('marketplace_import_jobs')
op.drop_index(op.f('ix_shops_shop_code'), table_name='shops')
op.drop_index(op.f('ix_shops_id'), table_name='shops')
op.drop_table('shops')
op.drop_index(op.f('ix_vendors_vendor_code'), table_name='vendors')
op.drop_index(op.f('ix_vendors_id'), table_name='vendors')
op.drop_table('vendors')
op.drop_index(op.f('ix_users_username'), table_name='users')
op.drop_index(op.f('ix_users_id'), table_name='users')
op.drop_index(op.f('ix_users_email'), table_name='users')
op.drop_table('users')
op.drop_index(op.f('ix_products_shop_name'), table_name='products')
op.drop_index(op.f('ix_products_vendor_name'), table_name='products')
op.drop_index(op.f('ix_products_product_id'), table_name='products')
op.drop_index(op.f('ix_products_marketplace'), table_name='products')
op.drop_index(op.f('ix_products_id'), table_name='products')

View File

@@ -14,9 +14,9 @@ from sqlalchemy.orm import Session
from app.core.database import get_db
from middleware.auth import AuthManager
from middleware.rate_limiter import RateLimiter
from models.database.shop import Shop
from models.database.vendor import Vendor
from models.database.user import User
from app.exceptions import (AdminRequiredException,ShopNotFoundException, UnauthorizedShopAccessException)
from app.exceptions import (AdminRequiredException, VendorNotFoundException, UnauthorizedVendorAccessException)
# Set auto_error=False to prevent automatic 403 responses
security = HTTPBearer(auto_error=False)
@@ -43,18 +43,18 @@ def get_current_admin_user(current_user: User = Depends(get_current_user)):
return auth_manager.require_admin(current_user)
def get_user_shop(
shop_code: str,
def get_user_vendor(
vendor_code: str,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""Get shop and verify user ownership."""
shop = db.query(Shop).filter(Shop.shop_code == shop_code.upper()).first()
if not shop:
raise ShopNotFoundException(shop_code)
"""Get vendor and verify user ownership."""
vendor = db.query(Vendor).filter(Vendor.vendor_code == vendor_code.upper()).first()
if not vendor:
raise VendorNotFoundException(vendor_code)
if current_user.role != "admin" and shop.owner_id != current_user.id:
raise UnauthorizedShopAccessException(shop_code, current_user.id)
if current_user.role != "admin" and vendor.owner_id != current_user.id:
raise UnauthorizedVendorAccessException(vendor_code, current_user.id)
return shop
return vendor

View File

@@ -9,7 +9,7 @@ This module provides classes and functions for:
from fastapi import APIRouter
from app.api.v1 import admin, auth, marketplace, shop, stats, stock
from app.api.v1 import admin, auth, marketplace, vendor, stats, stock
api_router = APIRouter()
@@ -17,6 +17,6 @@ api_router = APIRouter()
api_router.include_router(admin.router, tags=["admin"])
api_router.include_router(auth.router, tags=["authentication"])
api_router.include_router(marketplace.router, tags=["marketplace"])
api_router.include_router(shop.router, tags=["shop"])
api_router.include_router(vendor.router, tags=["vendor"])
api_router.include_router(stats.router, tags=["statistics"])
api_router.include_router(stock.router, tags=["stock"])

View File

@@ -4,7 +4,7 @@ Admin endpoints - simplified with service-level exception handling.
This module provides classes and functions for:
- User management (view, toggle status)
- Shop management (view, verify, toggle status)
- Vendor management (view, verify, toggle status)
- Marketplace import job monitoring
- Admin dashboard statistics
"""
@@ -20,7 +20,7 @@ from app.core.database import get_db
from app.services.admin_service import admin_service
from models.schemas.auth import UserResponse
from models.schemas.marketplace_import_job import MarketplaceImportJobResponse
from models.schemas.shop import ShopListResponse
from models.schemas.vendor import VendorListResponse
from models.database.user import User
router = APIRouter()
@@ -50,37 +50,37 @@ def toggle_user_status(
return {"message": message}
@router.get("/admin/shops", response_model=ShopListResponse)
def get_all_shops_admin(
@router.get("/admin/vendors", response_model=VendorListResponse)
def get_all_vendors_admin(
skip: int = Query(0, ge=0),
limit: int = Query(100, ge=1, le=1000),
db: Session = Depends(get_db),
current_admin: User = Depends(get_current_admin_user),
):
"""Get all shops with admin view (Admin only)."""
shops, total = admin_service.get_all_shops(db=db, skip=skip, limit=limit)
return ShopListResponse(shops=shops, total=total, skip=skip, limit=limit)
"""Get all vendors with admin view (Admin only)."""
vendors, total = admin_service.get_all_vendors(db=db, skip=skip, limit=limit)
return VendorListResponse(vendors=vendors, total=total, skip=skip, limit=limit)
@router.put("/admin/shops/{shop_id}/verify")
def verify_shop(
shop_id: int,
@router.put("/admin/vendors/{vendor_id}/verify")
def verify_vendor(
vendor_id: int,
db: Session = Depends(get_db),
current_admin: User = Depends(get_current_admin_user),
):
"""Verify/unverify shop (Admin only)."""
shop, message = admin_service.verify_shop(db, shop_id)
"""Verify/unverify vendor (Admin only)."""
vendor, message = admin_service.verify_vendor(db, vendor_id)
return {"message": message}
@router.put("/admin/shops/{shop_id}/status")
def toggle_shop_status(
shop_id: int,
@router.put("/admin/vendors/{vendor_id}/status")
def toggle_vendor_status(
vendor_id: int,
db: Session = Depends(get_db),
current_admin: User = Depends(get_current_admin_user),
):
"""Toggle shop active status (Admin only)."""
shop, message = admin_service.toggle_shop_status(db, shop_id)
"""Toggle vendor active status (Admin only)."""
vendor, message = admin_service.toggle_vendor_status(db, vendor_id)
return {"message": message}
@@ -89,7 +89,7 @@ def toggle_shop_status(
)
def get_all_marketplace_import_jobs(
marketplace: Optional[str] = Query(None),
shop_name: Optional[str] = Query(None),
vendor_name: Optional[str] = Query(None),
status: Optional[str] = Query(None),
skip: int = Query(0, ge=0),
limit: int = Query(100, ge=1, le=100),
@@ -100,7 +100,7 @@ def get_all_marketplace_import_jobs(
return admin_service.get_marketplace_import_jobs(
db=db,
marketplace=marketplace,
shop_name=shop_name,
vendor_name=vendor_name,
status=status,
skip=skip,
limit=limit,
@@ -116,10 +116,10 @@ def get_user_statistics(
return admin_service.get_user_statistics(db)
@router.get("/admin/stats/shops")
def get_shop_statistics(
@router.get("/admin/stats/vendors")
def get_vendor_statistics(
db: Session = Depends(get_db),
current_admin: User = Depends(get_current_admin_user),
):
"""Get shop statistics for admin dashboard (Admin only)."""
return admin_service.get_shop_statistics(db)
"""Get vendor statistics for admin dashboard (Admin only)."""
return admin_service.get_vendor_statistics(db)

View File

@@ -42,7 +42,7 @@ logger = logging.getLogger(__name__)
@router.get("/marketplace/product/export-csv")
async def export_csv(
marketplace: Optional[str] = Query(None, description="Filter by marketplace"),
shop_name: Optional[str] = Query(None, description="Filter by shop name"),
vendor_name: Optional[str] = Query(None, description="Filter by vendor name"),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
@@ -50,14 +50,14 @@ async def export_csv(
def generate_csv():
return marketplace_product_service.generate_csv_export(
db=db, marketplace=marketplace, shop_name=shop_name
db=db, marketplace=marketplace, vendor_name=vendor_name
)
filename = "marketplace_products_export"
if marketplace:
filename += f"_{marketplace}"
if shop_name:
filename += f"_{shop_name}"
if vendor_name:
filename += f"_{vendor_name}"
filename += ".csv"
return StreamingResponse(
@@ -75,12 +75,12 @@ def get_products(
category: Optional[str] = Query(None),
availability: Optional[str] = Query(None),
marketplace: Optional[str] = Query(None, description="Filter by marketplace"),
shop_name: Optional[str] = Query(None, description="Filter by shop name"),
vendor_name: Optional[str] = Query(None, description="Filter by vendor name"),
search: Optional[str] = Query(None),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Get products with advanced filtering including marketplace and shop (Protected)."""
"""Get products with advanced filtering including marketplace and vendor (Protected)."""
products, total = marketplace_product_service.get_products_with_filters(
db=db,
skip=skip,
@@ -89,7 +89,7 @@ def get_products(
category=category,
availability=availability,
marketplace=marketplace,
shop_name=shop_name,
vendor_name=vendor_name,
search=search,
)
@@ -166,7 +166,7 @@ async def import_products_from_marketplace(
):
"""Import products from marketplace CSV with background processing (Protected)."""
logger.info(
f"Starting marketplace import: {request.marketplace} -> {request.shop_code} by user {current_user.username}"
f"Starting marketplace import: {request.marketplace} -> {request.vendor_code} by user {current_user.username}"
)
# Create import job through service
@@ -178,7 +178,7 @@ async def import_products_from_marketplace(
import_job.id,
request.url,
request.marketplace,
request.shop_code,
request.vendor_code,
request.batch_size or 1000,
)
@@ -186,9 +186,9 @@ async def import_products_from_marketplace(
job_id=import_job.id,
status="pending",
marketplace=request.marketplace,
shop_code=request.shop_code,
shop_id=import_job.shop_id,
shop_name=import_job.shop_name,
vendor_code=request.vendor_code,
vendor_id=import_job.vendor_id,
vendor_name=import_job.vendor_name,
message=f"Marketplace import started from {request.marketplace}. Check status with "
f"/import-status/{import_job.id}",
)
@@ -212,7 +212,7 @@ def get_marketplace_import_status(
)
def get_marketplace_import_jobs(
marketplace: Optional[str] = Query(None, description="Filter by marketplace"),
shop_name: Optional[str] = Query(None, description="Filter by shop name"),
vendor_name: Optional[str] = Query(None, description="Filter by vendor name"),
skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=100),
db: Session = Depends(get_db),
@@ -223,7 +223,7 @@ def get_marketplace_import_jobs(
db=db,
user=current_user,
marketplace=marketplace,
shop_name=shop_name,
vendor_name=vendor_name,
skip=skip,
limit=limit,
)

View File

@@ -1,137 +0,0 @@
# app/api/v1/shop.py
"""
Shop endpoints - simplified with service-level exception handling.
This module provides classes and functions for:
- Shop CRUD operations and management
- Shop product catalog management
- Shop filtering and verification
"""
import logging
from fastapi import APIRouter, Depends, Query
from sqlalchemy.orm import Session
from app.api.deps import get_current_user, get_user_shop
from app.core.database import get_db
from app.services.shop_service import shop_service
from models.schemas.shop import (ShopCreate, ShopListResponse,ShopResponse)
from models.schemas.product import (ProductCreate,ProductResponse)
from models.database.user import User
router = APIRouter()
logger = logging.getLogger(__name__)
@router.post("/shop", response_model=ShopResponse)
def create_shop(
shop_data: ShopCreate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Create a new shop (Protected)."""
shop = shop_service.create_shop(
db=db, shop_data=shop_data, current_user=current_user
)
return ShopResponse.model_validate(shop)
@router.get("/shop", response_model=ShopListResponse)
def get_shops(
skip: int = Query(0, ge=0),
limit: int = Query(100, ge=1, le=1000),
active_only: bool = Query(True),
verified_only: bool = Query(False),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Get shops with filtering (Protected)."""
shops, total = shop_service.get_shops(
db=db,
current_user=current_user,
skip=skip,
limit=limit,
active_only=active_only,
verified_only=verified_only,
)
return ShopListResponse(shops=shops, total=total, skip=skip, limit=limit)
@router.get("/shop/{shop_code}", response_model=ShopResponse)
def get_shop(
shop_code: str,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Get shop details (Protected)."""
shop = shop_service.get_shop_by_code(
db=db, shop_code=shop_code, current_user=current_user
)
return ShopResponse.model_validate(shop)
@router.post("/shop/{shop_code}/products", response_model=ProductResponse)
def add_product_to_shop(
shop_code: str,
product: ProductCreate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Add existing product to shop catalog with shop-specific settings (Protected)."""
# Get and verify shop (using existing dependency)
shop = get_user_shop(shop_code, current_user, db)
# Add product to shop
new_product = shop_service.add_product_to_shop(
db=db, shop=shop, product=product
)
# Return with product details
response = ProductResponse.model_validate(new_product)
response.marketplace_product = new_product.marketplace_product
return response
@router.get("/shop/{shop_code}/products")
def get_products(
shop_code: str,
skip: int = Query(0, ge=0),
limit: int = Query(100, ge=1, le=1000),
active_only: bool = Query(True),
featured_only: bool = Query(False),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Get products in shop catalog (Protected)."""
# Get shop
shop = shop_service.get_shop_by_code(
db=db, shop_code=shop_code, current_user=current_user
)
# Get shop products
vendor_products, total = shop_service.get_products(
db=db,
shop=shop,
current_user=current_user,
skip=skip,
limit=limit,
active_only=active_only,
featured_only=featured_only,
)
# Format response
products = []
for vp in vendor_products:
product_response = ProductResponse.model_validate(vp)
product_response.marketplace_product = vp.marketplace_product
products.append(product_response)
return {
"products": products,
"total": total,
"skip": skip,
"limit": limit,
"shop": ShopResponse.model_validate(shop),
}

View File

@@ -36,7 +36,7 @@ def get_stats(
unique_brands=stats_data["unique_brands"],
unique_categories=stats_data["unique_categories"],
unique_marketplaces=stats_data["unique_marketplaces"],
unique_shops=stats_data["unique_shops"],
unique_vendors=stats_data["unique_vendors"],
total_stock_entries=stats_data["total_stock_entries"],
total_inventory_quantity=stats_data["total_inventory_quantity"],
)
@@ -87,7 +87,7 @@ def get_stats(
unique_brands=stats_data["unique_brands"],
unique_categories=stats_data["unique_categories"],
unique_marketplaces=stats_data["unique_marketplaces"],
unique_shops=stats_data["unique_shops"],
unique_vendors=stats_data["unique_vendors"],
total_stock_entries=stats_data["total_stock_entries"],
total_inventory_quantity=stats_data["total_inventory_quantity"],
)
@@ -104,7 +104,7 @@ def get_marketplace_stats(
MarketplaceStatsResponse(
marketplace=stat["marketplace"],
total_products=stat["total_products"],
unique_shops=stat["unique_shops"],
unique_vendors=stat["unique_vendors"],
unique_brands=stat["unique_brands"],
)
for stat in marketplace_stats

137
app/api/v1/vendor.py Normal file
View File

@@ -0,0 +1,137 @@
# app/api/v1/vendor.py
"""
Vendor endpoints - simplified with service-level exception handling.
This module provides classes and functions for:
- Vendor CRUD operations and management
- Vendor product catalog management
- Vendor filtering and verification
"""
import logging
from fastapi import APIRouter, Depends, Query
from sqlalchemy.orm import Session
from app.api.deps import get_current_user, get_user_vendor
from app.core.database import get_db
from app.services.vendor_service import vendor_service
from models.schemas.vendor import (VendorCreate, VendorListResponse, VendorResponse)
from models.schemas.product import (ProductCreate,ProductResponse)
from models.database.user import User
router = APIRouter()
logger = logging.getLogger(__name__)
@router.post("/vendor", response_model=VendorResponse)
def create_vendor(
vendor_data: VendorCreate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Create a new vendor (Protected)."""
vendor = vendor_service.create_vendor(
db=db, vendor_data=vendor_data, current_user=current_user
)
return VendorResponse.model_validate(vendor)
@router.get("/vendor", response_model=VendorListResponse)
def get_vendors(
skip: int = Query(0, ge=0),
limit: int = Query(100, ge=1, le=1000),
active_only: bool = Query(True),
verified_only: bool = Query(False),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Get vendors with filtering (Protected)."""
vendors, total = vendor_service.get_vendors(
db=db,
current_user=current_user,
skip=skip,
limit=limit,
active_only=active_only,
verified_only=verified_only,
)
return VendorListResponse(vendors=vendors, total=total, skip=skip, limit=limit)
@router.get("/vendor/{vendor_code}", response_model=VendorResponse)
def get_vendor(
vendor_code: str,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Get vendor details (Protected)."""
vendor = vendor_service.get_vendor_by_code(
db=db, vendor_code=vendor_code, current_user=current_user
)
return VendorResponse.model_validate(vendor)
@router.post("/vendor/{vendor_code}/products", response_model=ProductResponse)
def add_product_to_catalog(
vendor_code: str,
product: ProductCreate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Add existing product to vendor catalog with vendor -specific settings (Protected)."""
# Get and verify vendor (using existing dependency)
vendor = get_user_vendor(vendor_code, current_user, db)
# Add product to vendor
new_product = vendor_service.add_product_to_catalog(
db=db, vendor=vendor, product=product
)
# Return with product details
response = ProductResponse.model_validate(new_product)
response.marketplace_product = new_product.marketplace_product
return response
@router.get("/vendor/{vendor_code}/products")
def get_products(
vendor_code: str,
skip: int = Query(0, ge=0),
limit: int = Query(100, ge=1, le=1000),
active_only: bool = Query(True),
featured_only: bool = Query(False),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Get products in vendor catalog (Protected)."""
# Get vendor
vendor = vendor_service.get_vendor_by_code(
db=db, vendor_code=vendor_code, current_user=current_user
)
# Get vendor products
vendor_products, total = vendor_service.get_products(
db=db,
vendor=vendor,
current_user=current_user,
skip=skip,
limit=limit,
active_only=active_only,
featured_only=featured_only,
)
# Format response
products = []
for vp in vendor_products:
product_response = ProductResponse.model_validate(vp)
product_response.marketplace_product = vp.marketplace_product
products.append(product_response)
return {
"products": products,
"total": total,
"skip": skip,
"limit": limit,
"vendor": VendorResponse.model_validate(vendor),
}

View File

@@ -23,13 +23,13 @@ class Settings(BaseSettings):
# Clean description without HTML
description: str = """
Marketplace product import and management system with multi-shop support.
Marketplace product import and management system with multi-vendor support.
**Features:**
- JWT Authentication with role-based access
- Multi-marketplace product import (CSV processing)
- Inventory management across multiple locations
- Shop management with individual configurations
- Vendor management with individual configurations
**Documentation:** Visit /documentation for complete guides
**API Testing:** Use /docs for interactive API exploration

View File

@@ -1,6 +1,6 @@
# app/exceptions/__init__.py
"""
Custom exception classes for the LetzShop API.
Custom exception classes for the LetzVendor API.
This module provides frontend-friendly exceptions with consistent error codes,
messages, and HTTP status mappings.
@@ -48,15 +48,15 @@ from .stock import (
LocationNotFoundException
)
from .shop import (
ShopNotFoundException,
ShopAlreadyExistsException,
ShopNotActiveException,
ShopNotVerifiedException,
UnauthorizedShopAccessException,
InvalidShopDataException,
MaxShopsReachedException,
ShopValidationException,
from .vendor import (
VendorNotFoundException,
VendorAlreadyExistsException,
VendorNotActiveException,
VendorNotVerifiedException,
UnauthorizedVendorAccessException,
InvalidVendorDataException,
MaxVendorsReachedException,
VendorValidationException,
)
from .product import (
@@ -81,7 +81,7 @@ from .marketplace_import_job import (
from .admin import (
UserNotFoundException,
UserStatusChangeException,
ShopVerificationException,
VendorVerificationException,
AdminOperationException,
CannotModifyAdminException,
CannotModifySelfException,
@@ -127,15 +127,15 @@ __all__ = [
"InvalidQuantityException",
"LocationNotFoundException",
# Shop exceptions
"ShopNotFoundException",
"ShopAlreadyExistsException",
"ShopNotActiveException",
"ShopNotVerifiedException",
"UnauthorizedShopAccessException",
"InvalidShopDataException",
"MaxShopsReachedException",
"ShopValidationException",
# Vendor exceptions
"VendorNotFoundException",
"VendorAlreadyExistsException",
"VendorNotActiveException",
"VendorNotVerifiedException",
"UnauthorizedVendorAccessException",
"InvalidVendorDataException",
"MaxVendorsReachedException",
"VendorValidationException",
# Product exceptions
"ProductAlreadyExistsException",
@@ -157,7 +157,7 @@ __all__ = [
# Admin exceptions
"UserNotFoundException",
"UserStatusChangeException",
"ShopVerificationException",
"VendorVerificationException",
"AdminOperationException",
"CannotModifyAdminException",
"CannotModifySelfException",

View File

@@ -57,17 +57,17 @@ class UserStatusChangeException(BusinessLogicException):
)
class ShopVerificationException(BusinessLogicException):
"""Raised when shop verification fails."""
class VendorVerificationException(BusinessLogicException):
"""Raised when vendor verification fails."""
def __init__(
self,
shop_id: int,
vendor_id: int,
reason: str,
current_verification_status: Optional[bool] = None,
):
details = {
"shop_id": shop_id,
"vendor_id": vendor_id,
"reason": reason,
}
@@ -75,8 +75,8 @@ class ShopVerificationException(BusinessLogicException):
details["current_verification_status"] = current_verification_status
super().__init__(
message=f"Shop verification failed for shop {shop_id}: {reason}",
error_code="SHOP_VERIFICATION_FAILED",
message=f"Vendor verification failed for vendor {vendor_id}: {reason}",
error_code="VENDOR_VERIFICATION_FAILED",
details=details,
)

View File

@@ -1,6 +1,6 @@
# app/exceptions/base.py
"""
Base exception classes for the LetzShop application.
Base exception classes for the LetzVendor application.
This module provides classes and functions for:
- Base exception class with consistent error formatting
@@ -12,7 +12,7 @@ from typing import Any, Dict, Optional
class LetzShopException(Exception):
"""Base exception class for all LetzShop custom exceptions."""
"""Base exception class for all LetzVendor custom exceptions."""
def __init__(
self,
@@ -206,6 +206,6 @@ class ServiceUnavailableException(LetzShopException):
status_code=503,
)
# Note: Domain-specific exceptions like ShopNotFoundException, UserNotFoundException, etc.
# are defined in their respective domain modules (shop.py, admin.py, etc.)
# Note: Domain-specific exceptions like VendorNotFoundException, UserNotFoundException, etc.
# are defined in their respective domain modules (vendor.py, admin.py, etc.)
# to keep domain-specific logic separate from base exceptions.

View File

@@ -26,7 +26,7 @@ def setup_exception_handlers(app):
@app.exception_handler(LetzShopException)
async def custom_exception_handler(request: Request, exc: LetzShopException):
"""Handle custom LetzShop exceptions."""
"""Handle custom LetzVendor exceptions."""
logger.error(
f"Custom exception in {request.method} {request.url}: "

View File

@@ -189,12 +189,12 @@ class InvalidMarketplaceException(ValidationException):
class ImportJobAlreadyProcessingException(BusinessLogicException):
"""Raised when trying to start import while another is already processing."""
def __init__(self, shop_code: str, existing_job_id: int):
def __init__(self, vendor_code: str, existing_job_id: int):
super().__init__(
message=f"Import already in progress for shop '{shop_code}'",
message=f"Import already in progress for vendor '{vendor_code}'",
error_code="IMPORT_JOB_ALREADY_PROCESSING",
details={
"shop_code": shop_code,
"vendor_code": vendor_code,
"existing_job_id": existing_job_id,
},
)

View File

@@ -1,6 +1,6 @@
# app/exceptions/shop.py
# app/exceptions/vendor.py
"""
Shop management specific exceptions.
Vendor management specific exceptions.
"""
from .base import (
@@ -9,26 +9,26 @@ from .base import (
)
class ProductAlreadyExistsException(ConflictException):
"""Raised when trying to add a product that already exists in shop."""
"""Raised when trying to add a product that already exists in vendor."""
def __init__(self, shop_code: str, marketplace_product_id: str):
def __init__(self, vendor_code: str, marketplace_product_id: str):
super().__init__(
message=f"MarketplaceProduct '{marketplace_product_id}' already exists in shop '{shop_code}'",
message=f"MarketplaceProduct '{marketplace_product_id}' already exists in vendor '{vendor_code}'",
error_code="PRODUCT_ALREADY_EXISTS",
details={
"shop_code": shop_code,
"vendor_code": vendor_code,
"marketplace_product_id": marketplace_product_id,
},
)
class ProductNotFoundException(ResourceNotFoundException):
"""Raised when a shop product relationship is not found."""
"""Raised when a vendor product relationship is not found."""
def __init__(self, shop_code: str, marketplace_product_id: str):
def __init__(self, vendor_code: str, marketplace_product_id: str):
super().__init__(
resource_type="ShopProduct",
identifier=f"{shop_code}/{marketplace_product_id}",
message=f"MarketplaceProduct '{marketplace_product_id}' not found in shop '{shop_code}'",
resource_type="Product",
identifier=f"{vendor_code}/{marketplace_product_id}",
message=f"MarketplaceProduct '{marketplace_product_id}' not found in vendor '{vendor_code}'",
error_code="PRODUCT_NOT_FOUND",
)

View File

@@ -1,131 +0,0 @@
# app/exceptions/shop.py
"""
Shop management specific exceptions.
"""
from typing import Any, Dict, Optional
from .base import (
ResourceNotFoundException,
ConflictException,
ValidationException,
AuthorizationException,
BusinessLogicException
)
class ShopNotFoundException(ResourceNotFoundException):
"""Raised when a shop is not found."""
def __init__(self, shop_identifier: str, identifier_type: str = "code"):
if identifier_type.lower() == "id":
message = f"Shop with ID '{shop_identifier}' not found"
else:
message = f"Shop with code '{shop_identifier}' not found"
super().__init__(
resource_type="Shop",
identifier=shop_identifier,
message=message,
error_code="SHOP_NOT_FOUND",
)
class ShopAlreadyExistsException(ConflictException):
"""Raised when trying to create a shop that already exists."""
def __init__(self, shop_code: str):
super().__init__(
message=f"Shop with code '{shop_code}' already exists",
error_code="SHOP_ALREADY_EXISTS",
details={"shop_code": shop_code},
)
class ShopNotActiveException(BusinessLogicException):
"""Raised when trying to perform operations on inactive shop."""
def __init__(self, shop_code: str):
super().__init__(
message=f"Shop '{shop_code}' is not active",
error_code="SHOP_NOT_ACTIVE",
details={"shop_code": shop_code},
)
class ShopNotVerifiedException(BusinessLogicException):
"""Raised when trying to perform operations requiring verified shop."""
def __init__(self, shop_code: str):
super().__init__(
message=f"Shop '{shop_code}' is not verified",
error_code="SHOP_NOT_VERIFIED",
details={"shop_code": shop_code},
)
class UnauthorizedShopAccessException(AuthorizationException):
"""Raised when user tries to access shop they don't own."""
def __init__(self, shop_code: str, user_id: Optional[int] = None):
details = {"shop_code": shop_code}
if user_id:
details["user_id"] = user_id
super().__init__(
message=f"Unauthorized access to shop '{shop_code}'",
error_code="UNAUTHORIZED_SHOP_ACCESS",
details=details,
)
class InvalidShopDataException(ValidationException):
"""Raised when shop data is invalid."""
def __init__(
self,
message: str = "Invalid shop data",
field: Optional[str] = None,
details: Optional[Dict[str, Any]] = None,
):
super().__init__(
message=message,
field=field,
details=details,
)
self.error_code = "INVALID_SHOP_DATA"
class MaxShopsReachedException(BusinessLogicException):
"""Raised when user tries to create more shops than allowed."""
def __init__(self, max_shops: int, user_id: Optional[int] = None):
details = {"max_shops": max_shops}
if user_id:
details["user_id"] = user_id
super().__init__(
message=f"Maximum number of shops reached ({max_shops})",
error_code="MAX_SHOPS_REACHED",
details=details,
)
class ShopValidationException(ValidationException):
"""Raised when shop validation fails."""
def __init__(
self,
message: str = "Shop validation failed",
field: Optional[str] = None,
validation_errors: Optional[Dict[str, str]] = None,
):
details = {}
if validation_errors:
details["validation_errors"] = validation_errors
super().__init__(
message=message,
field=field,
details=details,
)
self.error_code = "SHOP_VALIDATION_FAILED"

131
app/exceptions/vendor.py Normal file
View File

@@ -0,0 +1,131 @@
# app/exceptions/vendor.py
"""
Vendor management specific exceptions.
"""
from typing import Any, Dict, Optional
from .base import (
ResourceNotFoundException,
ConflictException,
ValidationException,
AuthorizationException,
BusinessLogicException
)
class VendorNotFoundException(ResourceNotFoundException):
"""Raised when a vendor is not found."""
def __init__(self, vendor_identifier: str, identifier_type: str = "code"):
if identifier_type.lower() == "id":
message = f"Vendor with ID '{vendor_identifier}' not found"
else:
message = f"Vendor with code '{vendor_identifier}' not found"
super().__init__(
resource_type="Vendor",
identifier=vendor_identifier,
message=message,
error_code="VENDOR_NOT_FOUND",
)
class VendorAlreadyExistsException(ConflictException):
"""Raised when trying to create a vendor that already exists."""
def __init__(self, vendor_code: str):
super().__init__(
message=f"Vendor with code '{vendor_code}' already exists",
error_code="VENDOR_ALREADY_EXISTS",
details={"vendor_code": vendor_code},
)
class VendorNotActiveException(BusinessLogicException):
"""Raised when trying to perform operations on inactive vendor."""
def __init__(self, vendor_code: str):
super().__init__(
message=f"Vendor '{vendor_code}' is not active",
error_code="VENDOR_NOT_ACTIVE",
details={"vendor_code": vendor_code},
)
class VendorNotVerifiedException(BusinessLogicException):
"""Raised when trying to perform operations requiring verified vendor."""
def __init__(self, vendor_code: str):
super().__init__(
message=f"Vendor '{vendor_code}' is not verified",
error_code="VENDOR_NOT_VERIFIED",
details={"vendor_code": vendor_code},
)
class UnauthorizedVendorAccessException(AuthorizationException):
"""Raised when user tries to access vendor they don't own."""
def __init__(self, vendor_code: str, user_id: Optional[int] = None):
details = {"vendor_code": vendor_code}
if user_id:
details["user_id"] = user_id
super().__init__(
message=f"Unauthorized access to vendor '{vendor_code}'",
error_code="UNAUTHORIZED_VENDOR_ACCESS",
details=details,
)
class InvalidVendorDataException(ValidationException):
"""Raised when vendor data is invalid."""
def __init__(
self,
message: str = "Invalid vendor data",
field: Optional[str] = None,
details: Optional[Dict[str, Any]] = None,
):
super().__init__(
message=message,
field=field,
details=details,
)
self.error_code = "INVALID_VENDOR_DATA"
class MaxVendorsReachedException(BusinessLogicException):
"""Raised when user tries to create more vendors than allowed."""
def __init__(self, max_vendors: int, user_id: Optional[int] = None):
details = {"max_vendors": max_vendors}
if user_id:
details["user_id"] = user_id
super().__init__(
message=f"Maximum number of vendors reached ({max_vendors})",
error_code="MAX_VENDORS_REACHED",
details=details,
)
class VendorValidationException(ValidationException):
"""Raised when vendor validation fails."""
def __init__(
self,
message: str = "Vendor validation failed",
field: Optional[str] = None,
validation_errors: Optional[Dict[str, str]] = None,
):
details = {}
if validation_errors:
details["validation_errors"] = validation_errors
super().__init__(
message=message,
field=field,
details=details,
)
self.error_code = "VENDOR_VALIDATION_FAILED"

View File

@@ -1,10 +1,10 @@
# app/services/admin_service.py
"""
Admin service for managing users, shops, and import jobs.
Admin service for managing users, vendors, and import jobs.
This module provides classes and functions for:
- User management and status control
- Shop verification and activation
- Vendor verification and activation
- Marketplace import job monitoring
"""
@@ -18,13 +18,13 @@ from app.exceptions import (
UserNotFoundException,
UserStatusChangeException,
CannotModifySelfException,
ShopNotFoundException,
ShopVerificationException,
VendorNotFoundException,
VendorVerificationException,
AdminOperationException,
)
from models.schemas.marketplace_import_job import MarketplaceImportJobResponse
from models.database.marketplace_import_job import MarketplaceImportJob
from models.database.shop import Shop
from models.database.vendor import Vendor
from models.database.user import User
logger = logging.getLogger(__name__)
@@ -101,11 +101,11 @@ class AdminService:
reason="Database update failed"
)
def get_all_shops(
def get_all_vendors(
self, db: Session, skip: int = 0, limit: int = 100
) -> Tuple[List[Shop], int]:
) -> Tuple[List[Vendor], int]:
"""
Get paginated list of all shops with total count.
Get paginated list of all vendors with total count.
Args:
db: Database session
@@ -113,108 +113,108 @@ class AdminService:
limit: Maximum number of records to return
Returns:
Tuple of (shops_list, total_count)
Tuple of (vendors_list, total_count)
"""
try:
total = db.query(Shop).count()
shops = db.query(Shop).offset(skip).limit(limit).all()
return shops, total
total = db.query(Vendor).count()
vendors =db.query(Vendor).offset(skip).limit(limit).all()
return vendors, total
except Exception as e:
logger.error(f"Failed to retrieve shops: {str(e)}")
logger.error(f"Failed to retrieve vendors: {str(e)}")
raise AdminOperationException(
operation="get_all_shops",
operation="get_all_vendors",
reason="Database query failed"
)
def verify_shop(self, db: Session, shop_id: int) -> Tuple[Shop, str]:
def verify_vendor(self, db: Session, vendor_id: int) -> Tuple[Vendor, str]:
"""
Toggle shop verification status.
Toggle vendor verification status.
Args:
db: Database session
shop_id: ID of shop to verify/unverify
vendor_id: ID of vendor to verify/unverify
Returns:
Tuple of (updated_shop, status_message)
Tuple of (updated_vendor, status_message)
Raises:
ShopNotFoundException: If shop not found
ShopVerificationException: If verification fails
VendorNotFoundException: If vendor not found
VendorVerificationException: If verification fails
"""
shop = self._get_shop_by_id_or_raise(db, shop_id)
vendor = self._get_vendor_by_id_or_raise(db, vendor_id)
try:
original_status = shop.is_verified
shop.is_verified = not shop.is_verified
shop.updated_at = datetime.now(timezone.utc)
original_status = vendor.is_verified
vendor.is_verified = not vendor.is_verified
vendor.updated_at = datetime.now(timezone.utc)
# Add verification timestamp if implementing audit trail
if shop.is_verified:
shop.verified_at = datetime.now(timezone.utc)
if vendor.is_verified:
vendor.verified_at = datetime.now(timezone.utc)
db.commit()
db.refresh(shop)
db.refresh(vendor)
status_action = "verified" if shop.is_verified else "unverified"
message = f"Shop {shop.shop_code} has been {status_action}"
status_action = "verified" if vendor.is_verified else "unverified"
message = f"Vendor {vendor.vendor_code} has been {status_action}"
logger.info(message)
return shop, message
return vendor, message
except Exception as e:
db.rollback()
logger.error(f"Failed to verify shop {shop_id}: {str(e)}")
raise ShopVerificationException(
shop_id=shop_id,
logger.error(f"Failed to verify vendor {vendor_id}: {str(e)}")
raise VendorVerificationException(
vendor_id=vendor_id,
reason="Database update failed",
current_verification_status=original_status
)
def toggle_shop_status(self, db: Session, shop_id: int) -> Tuple[Shop, str]:
def toggle_vendor_status(self, db: Session, vendor_id: int) -> Tuple[Vendor, str]:
"""
Toggle shop active status.
Toggle vendor active status.
Args:
db: Database session
shop_id: ID of shop to activate/deactivate
vendor_id: ID of vendor to activate/deactivate
Returns:
Tuple of (updated_shop, status_message)
Tuple of (updated_vendor, status_message)
Raises:
ShopNotFoundException: If shop not found
VendorNotFoundException: If vendor not found
AdminOperationException: If status change fails
"""
shop = self._get_shop_by_id_or_raise(db, shop_id)
vendor = self._get_vendor_by_id_or_raise(db, vendor_id)
try:
original_status = shop.is_active
shop.is_active = not shop.is_active
shop.updated_at = datetime.now(timezone.utc)
original_status = vendor.is_active
vendor.is_active = not vendor.is_active
vendor.updated_at = datetime.now(timezone.utc)
db.commit()
db.refresh(shop)
db.refresh(vendor)
status_action = "activated" if shop.is_active else "deactivated"
message = f"Shop {shop.shop_code} has been {status_action}"
status_action = "activated" if vendor.is_active else "deactivated"
message = f"Vendor {vendor.vendor_code} has been {status_action}"
logger.info(message)
return shop, message
return vendor , message
except Exception as e:
db.rollback()
logger.error(f"Failed to toggle shop {shop_id} status: {str(e)}")
logger.error(f"Failed to toggle vendor {vendor_id} status: {str(e)}")
raise AdminOperationException(
operation="toggle_shop_status",
operation="toggle_vendor_status",
reason="Database update failed",
target_type="shop",
target_id=str(shop_id)
target_type="vendor ",
target_id=str(vendor_id)
)
def get_marketplace_import_jobs(
self,
db: Session,
marketplace: Optional[str] = None,
shop_name: Optional[str] = None,
vendor_name: Optional[str] = None,
status: Optional[str] = None,
skip: int = 0,
limit: int = 100,
@@ -225,7 +225,7 @@ class AdminService:
Args:
db: Database session
marketplace: Filter by marketplace name (case-insensitive partial match)
shop_name: Filter by shop name (case-insensitive partial match)
vendor_name: Filter by vendor name (case-insensitive partial match)
status: Filter by exact status
skip: Number of records to skip
limit: Maximum number of records to return
@@ -241,8 +241,8 @@ class AdminService:
query = query.filter(
MarketplaceImportJob.marketplace.ilike(f"%{marketplace}%")
)
if shop_name:
query = query.filter(MarketplaceImportJob.shop_name.ilike(f"%{shop_name}%"))
if vendor_name:
query = query.filter(MarketplaceImportJob.vendor_name.ilike(f"%{vendor_name}%"))
if status:
query = query.filter(MarketplaceImportJob.status == status)
@@ -283,23 +283,23 @@ class AdminService:
reason="Database query failed"
)
def get_shop_statistics(self, db: Session) -> dict:
"""Get shop statistics for admin dashboard."""
def get_vendor_statistics(self, db: Session) -> dict:
"""Get vendor statistics for admin dashboard."""
try:
total_shops = db.query(Shop).count()
active_shops = db.query(Shop).filter(Shop.is_active == True).count()
verified_shops = db.query(Shop).filter(Shop.is_verified == True).count()
total_vendors = db.query(Vendor).count()
active_vendors = db.query(Vendor).filter(Vendor.is_active == True).count()
verified_vendors = db.query(Vendor).filter(Vendor.is_verified == True).count()
return {
"total_shops": total_shops,
"active_shops": active_shops,
"verified_shops": verified_shops,
"verification_rate": (verified_shops / total_shops * 100) if total_shops > 0 else 0
"total_vendors": total_vendors,
"active_vendors": active_vendors,
"verified_vendors": verified_vendors,
"verification_rate": (verified_vendors / total_vendors * 100) if total_vendors > 0 else 0
}
except Exception as e:
logger.error(f"Failed to get shop statistics: {str(e)}")
logger.error(f"Failed to get vendor statistics: {str(e)}")
raise AdminOperationException(
operation="get_shop_statistics",
operation="get_vendor_statistics",
reason="Database query failed"
)
@@ -311,12 +311,12 @@ class AdminService:
raise UserNotFoundException(str(user_id))
return user
def _get_shop_by_id_or_raise(self, db: Session, shop_id: int) -> Shop:
"""Get shop by ID or raise ShopNotFoundException."""
shop = db.query(Shop).filter(Shop.id == shop_id).first()
if not shop:
raise ShopNotFoundException(str(shop_id), identifier_type="id")
return shop
def _get_vendor_by_id_or_raise(self, db: Session, vendor_id: int) -> Vendor:
"""Get vendor by ID or raise VendorNotFoundException."""
vendor = db.query(Vendor).filter(Vendor.id == vendor_id).first()
if not vendor :
raise VendorNotFoundException(str(vendor_id), identifier_type="id")
return vendor
def _convert_job_to_response(self, job: MarketplaceImportJob) -> MarketplaceImportJobResponse:
"""Convert database model to response schema."""
@@ -324,9 +324,9 @@ class AdminService:
job_id=job.id,
status=job.status,
marketplace=job.marketplace,
shop_id=job.shop.id if job.shop else None,
shop_code=job.shop.shop_code if job.shop else None,
shop_name=job.shop_name,
vendor_id=job.vendor.id if job.vendor else None,
vendor_code=job.vendor.vendor_code if job.vendor else None,
vendor_name=job.vendor_name,
imported=job.imported_count or 0,
updated=job.updated_count or 0,
total_processed=job.total_processed or 0,

View File

@@ -4,7 +4,7 @@ Marketplace service for managing import jobs and marketplace integrations.
This module provides classes and functions for:
- Import job creation and management
- Shop access validation
- Vendor access validation
- Import job status tracking and updates
"""
@@ -16,8 +16,8 @@ from sqlalchemy import func
from sqlalchemy.orm import Session
from app.exceptions import (
ShopNotFoundException,
UnauthorizedShopAccessException,
VendorNotFoundException,
UnauthorizedVendorAccessException,
ImportJobNotFoundException,
ImportJobNotOwnedException,
ImportJobCannotBeCancelledException,
@@ -27,7 +27,7 @@ from app.exceptions import (
from models.schemas.marketplace_import_job import (MarketplaceImportJobResponse,
MarketplaceImportJobRequest)
from models.database.marketplace_import_job import MarketplaceImportJob
from models.database.shop import Shop
from models.database.vendor import Vendor
from models.database.user import User
logger = logging.getLogger(__name__)
@@ -36,44 +36,44 @@ logger = logging.getLogger(__name__)
class MarketplaceImportJobService:
"""Service class for Marketplace operations following the application's service pattern."""
def validate_shop_access(self, db: Session, shop_code: str, user: User) -> Shop:
def validate_vendor_access(self, db: Session, vendor_code: str, user: User) -> Vendor:
"""
Validate that the shop exists and user has access to it.
Validate that the vendor exists and user has access to it.
Args:
db: Database session
shop_code: Shop code to validate
vendor_code: Vendor code to validate
user: User requesting access
Returns:
Shop object if access is valid
Vendor object if access is valid
Raises:
ShopNotFoundException: If shop doesn't exist
UnauthorizedShopAccessException: If user lacks access
VendorNotFoundException: If vendor doesn't exist
UnauthorizedVendorAccessException: If user lacks access
"""
try:
# Use case-insensitive query to handle both uppercase and lowercase codes
shop = (
db.query(Shop)
.filter(func.upper(Shop.shop_code) == shop_code.upper())
vendor = (
db.query(Vendor)
.filter(func.upper(Vendor.vendor_code) == vendor_code.upper())
.first()
)
if not shop:
raise ShopNotFoundException(shop_code)
if not vendor :
raise VendorNotFoundException(vendor_code)
# Check permissions: admin can import for any shop, others only for their own
if user.role != "admin" and shop.owner_id != user.id:
raise UnauthorizedShopAccessException(shop_code, user.id)
# Check permissions: admin can import for any vendor, others only for their own
if user.role != "admin" and vendor.owner_id != user.id:
raise UnauthorizedVendorAccessException(vendor_code, user.id)
return shop
return vendor
except (ShopNotFoundException, UnauthorizedShopAccessException):
except (VendorNotFoundException, UnauthorizedVendorAccessException):
raise # Re-raise custom exceptions
except Exception as e:
logger.error(f"Error validating shop access: {str(e)}")
raise ValidationException("Failed to validate shop access")
logger.error(f"Error validating vendor access: {str(e)}")
raise ValidationException("Failed to validate vendor access")
def create_import_job(
self, db: Session, request: MarketplaceImportJobRequest, user: User
@@ -90,21 +90,21 @@ class MarketplaceImportJobService:
Created MarketplaceImportJob object
Raises:
ShopNotFoundException: If shop doesn't exist
UnauthorizedShopAccessException: If user lacks shop access
VendorNotFoundException: If vendor doesn't exist
UnauthorizedVendorAccessException: If user lacks vendor access
ValidationException: If job creation fails
"""
try:
# Validate shop access first
shop = self.validate_shop_access(db, request.shop_code, user)
# Validate vendor access first
vendor = self.validate_vendor_access(db, request.vendor_code, user)
# Create marketplace import job record
import_job = MarketplaceImportJob(
status="pending",
source_url=request.url,
marketplace=request.marketplace,
shop_id=shop.id, # Foreign key to shops table
shop_name=shop.shop_name, # Use shop.shop_name (the display name)
vendor_id=vendor.id, # Foreign key to vendors table
vendor_name=vendor.vendor_name, # Use vendor.vendor_name (the display name)
user_id=user.id,
created_at=datetime.now(timezone.utc),
)
@@ -115,12 +115,12 @@ class MarketplaceImportJobService:
logger.info(
f"Created marketplace import job {import_job.id}: "
f"{request.marketplace} -> {shop.shop_name} (shop_code: {shop.shop_code}) by user {user.username}"
f"{request.marketplace} -> {vendor.vendor_name} (vendor_code: {vendor.vendor_code}) by user {user.username}"
)
return import_job
except (ShopNotFoundException, UnauthorizedShopAccessException):
except (VendorNotFoundException, UnauthorizedVendorAccessException):
raise # Re-raise custom exceptions
except Exception as e:
db.rollback()
@@ -172,7 +172,7 @@ class MarketplaceImportJobService:
db: Session,
user: User,
marketplace: Optional[str] = None,
shop_name: Optional[str] = None,
vendor_name: Optional[str] = None,
skip: int = 0,
limit: int = 50,
) -> List[MarketplaceImportJob]:
@@ -183,7 +183,7 @@ class MarketplaceImportJobService:
db: Database session
user: User requesting jobs
marketplace: Optional marketplace filter
shop_name: Optional shop name filter
vendor_name: Optional vendor name filter
skip: Number of records to skip
limit: Maximum records to return
@@ -202,8 +202,8 @@ class MarketplaceImportJobService:
query = query.filter(
MarketplaceImportJob.marketplace.ilike(f"%{marketplace}%")
)
if shop_name:
query = query.filter(MarketplaceImportJob.shop_name.ilike(f"%{shop_name}%"))
if vendor_name:
query = query.filter(MarketplaceImportJob.vendor_name.ilike(f"%{vendor_name}%"))
# Order by creation date (newest first) and apply pagination
jobs = (
@@ -319,11 +319,11 @@ class MarketplaceImportJobService:
job_id=job.id,
status=job.status,
marketplace=job.marketplace,
shop_id=job.shop_id,
shop_code=(
job.shop.shop_code if job.shop else None
vendor_id=job.vendor_id,
vendor_code=(
job.vendor.vendor_code if job.vendor else None
), # Add this optional field via relationship
shop_name=job.shop_name,
vendor_name=job.vendor_name,
imported=job.imported_count or 0,
updated=job.updated_count or 0,
total_processed=job.total_processed or 0,

View File

@@ -135,7 +135,7 @@ class MarketplaceProductService:
category: Optional[str] = None,
availability: Optional[str] = None,
marketplace: Optional[str] = None,
shop_name: Optional[str] = None,
vendor_name: Optional[str] = None,
search: Optional[str] = None,
) -> Tuple[List[MarketplaceProduct], int]:
"""
@@ -149,7 +149,7 @@ class MarketplaceProductService:
category: Category filter
availability: Availability filter
marketplace: Marketplace filter
shop_name: Shop name filter
vendor_name: Vendor name filter
search: Search term
Returns:
@@ -167,16 +167,16 @@ class MarketplaceProductService:
query = query.filter(MarketplaceProduct.availability == availability)
if marketplace:
query = query.filter(MarketplaceProduct.marketplace.ilike(f"%{marketplace}%"))
if shop_name:
query = query.filter(MarketplaceProduct.shop_name.ilike(f"%{shop_name}%"))
if vendor_name:
query = query.filter(MarketplaceProduct.vendor_name.ilike(f"%{vendor_name}%"))
if search:
# Search in title, description, marketplace, and shop_name
# Search in title, description, marketplace, and vendor_name
search_term = f"%{search}%"
query = query.filter(
(MarketplaceProduct.title.ilike(search_term))
| (MarketplaceProduct.description.ilike(search_term))
| (MarketplaceProduct.marketplace.ilike(search_term))
| (MarketplaceProduct.shop_name.ilike(search_term))
| (MarketplaceProduct.vendor_name.ilike(search_term))
)
total = query.count()
@@ -311,7 +311,7 @@ class MarketplaceProductService:
self,
db: Session,
marketplace: Optional[str] = None,
shop_name: Optional[str] = None,
vendor_name: Optional[str] = None,
) -> Generator[str, None, None]:
"""
Generate CSV export with streaming for memory efficiency and proper CSV escaping.
@@ -319,7 +319,7 @@ class MarketplaceProductService:
Args:
db: Database session
marketplace: Optional marketplace filter
shop_name: Optional shop name filter
vendor_name: Optional vendor name filter
Yields:
CSV content as strings with proper escaping
@@ -333,7 +333,7 @@ class MarketplaceProductService:
headers = [
"marketplace_product_id", "title", "description", "link", "image_link",
"availability", "price", "currency", "brand", "gtin",
"marketplace", "shop_name"
"marketplace", "vendor_name"
]
writer.writerow(headers)
yield output.getvalue()
@@ -351,8 +351,8 @@ class MarketplaceProductService:
# Apply marketplace filters
if marketplace:
query = query.filter(MarketplaceProduct.marketplace.ilike(f"%{marketplace}%"))
if shop_name:
query = query.filter(MarketplaceProduct.shop_name.ilike(f"%{shop_name}%"))
if vendor_name:
query = query.filter(MarketplaceProduct.vendor_name.ilike(f"%{vendor_name}%"))
products = query.offset(offset).limit(batch_size).all()
if not products:
@@ -372,7 +372,7 @@ class MarketplaceProductService:
product.brand or "",
product.gtin or "",
product.marketplace or "",
product.shop_name or "",
product.vendor_name or "",
]
writer.writerow(row_data)
@@ -413,7 +413,7 @@ class MarketplaceProductService:
normalized = product_data.copy()
# Trim whitespace from string fields
string_fields = ['marketplace_product_id', 'title', 'description', 'brand', 'marketplace', 'shop_name']
string_fields = ['marketplace_product_id', 'title', 'description', 'brand', 'marketplace', 'vendor_name']
for field in string_fields:
if field in normalized and normalized[field]:
normalized[field] = normalized[field].strip()

View File

@@ -1,359 +0,0 @@
# app/services/shop_service.py
"""
Shop service for managing shop operations and product catalog.
This module provides classes and functions for:
- Shop creation and management
- Shop access control and validation
- Shop product catalog operations
- Shop filtering and search
"""
import logging
from typing import List, Optional, Tuple
from sqlalchemy import func
from sqlalchemy.orm import Session
from app.exceptions import (
ShopNotFoundException,
ShopAlreadyExistsException,
UnauthorizedShopAccessException,
InvalidShopDataException,
MarketplaceProductNotFoundException,
ProductAlreadyExistsException,
MaxShopsReachedException,
ValidationException,
)
from models.schemas.shop import ShopCreate
from models.schemas.product import ProductCreate
from models.database.marketplace_product import MarketplaceProduct
from models.database.shop import Shop
from models.database.product import Product
from models.database.user import User
logger = logging.getLogger(__name__)
class ShopService:
"""Service class for shop operations following the application's service pattern."""
def create_shop(
self, db: Session, shop_data: ShopCreate, current_user: User
) -> Shop:
"""
Create a new shop.
Args:
db: Database session
shop_data: Shop creation data
current_user: User creating the shop
Returns:
Created shop object
Raises:
ShopAlreadyExistsException: If shop code already exists
MaxShopsReachedException: If user has reached maximum shops
InvalidShopDataException: If shop data is invalid
"""
try:
# Validate shop data
self._validate_shop_data(shop_data)
# Check user's shop limit (if applicable)
self._check_shop_limit(db, current_user)
# Normalize shop code to uppercase
normalized_shop_code = shop_data.shop_code.upper()
# Check if shop code already exists (case-insensitive check)
if self._shop_code_exists(db, normalized_shop_code):
raise ShopAlreadyExistsException(normalized_shop_code)
# Create shop with uppercase code
shop_dict = shop_data.model_dump()
shop_dict["shop_code"] = normalized_shop_code # Store as uppercase
new_shop = Shop(
**shop_dict,
owner_id=current_user.id,
is_active=True,
is_verified=(current_user.role == "admin"),
)
db.add(new_shop)
db.commit()
db.refresh(new_shop)
logger.info(
f"New shop created: {new_shop.shop_code} by {current_user.username}"
)
return new_shop
except (ShopAlreadyExistsException, MaxShopsReachedException, InvalidShopDataException):
db.rollback()
raise # Re-raise custom exceptions
except Exception as e:
db.rollback()
logger.error(f"Error creating shop: {str(e)}")
raise ValidationException("Failed to create shop")
def get_shops(
self,
db: Session,
current_user: User,
skip: int = 0,
limit: int = 100,
active_only: bool = True,
verified_only: bool = False,
) -> Tuple[List[Shop], int]:
"""
Get shops with filtering.
Args:
db: Database session
current_user: Current user requesting shops
skip: Number of records to skip
limit: Maximum number of records to return
active_only: Filter for active shops only
verified_only: Filter for verified shops only
Returns:
Tuple of (shops_list, total_count)
"""
try:
query = db.query(Shop)
# Non-admin users can only see active and verified shops, plus their own
if current_user.role != "admin":
query = query.filter(
(Shop.is_active == True)
& ((Shop.is_verified == True) | (Shop.owner_id == current_user.id))
)
else:
# Admin can apply filters
if active_only:
query = query.filter(Shop.is_active == True)
if verified_only:
query = query.filter(Shop.is_verified == True)
total = query.count()
shops = query.offset(skip).limit(limit).all()
return shops, total
except Exception as e:
logger.error(f"Error getting shops: {str(e)}")
raise ValidationException("Failed to retrieve shops")
def get_shop_by_code(self, db: Session, shop_code: str, current_user: User) -> Shop:
"""
Get shop by shop code with access control.
Args:
db: Database session
shop_code: Shop code to find
current_user: Current user requesting the shop
Returns:
Shop object
Raises:
ShopNotFoundException: If shop not found
UnauthorizedShopAccessException: If access denied
"""
try:
shop = (
db.query(Shop)
.filter(func.upper(Shop.shop_code) == shop_code.upper())
.first()
)
if not shop:
raise ShopNotFoundException(shop_code)
# Check access permissions
if not self._can_access_shop(shop, current_user):
raise UnauthorizedShopAccessException(shop_code, current_user.id)
return shop
except (ShopNotFoundException, UnauthorizedShopAccessException):
raise # Re-raise custom exceptions
except Exception as e:
logger.error(f"Error getting shop {shop_code}: {str(e)}")
raise ValidationException("Failed to retrieve shop")
def add_product_to_shop(
self, db: Session, shop: Shop, product: ProductCreate
) -> Product:
"""
Add existing product to shop catalog with shop-specific settings.
Args:
db: Database session
shop: Shop to add product to
product: Shop product data
Returns:
Created ShopProduct object
Raises:
MarketplaceProductNotFoundException: If product not found
ProductAlreadyExistsException: If product already in shop
"""
try:
# Check if product exists
marketplace_product = self._get_product_by_id_or_raise(db, product.marketplace_product_id)
# Check if product already in shop
if self._product_in_shop(db, shop.id, marketplace_product.id):
raise ProductAlreadyExistsException(shop.shop_code, product.marketplace_product_id)
# Create shop-product association
new_product = Product(
shop_id=shop.id,
marketplace_product_id=marketplace_product.id,
**product.model_dump(exclude={"marketplace_product_id"}),
)
db.add(new_product)
db.commit()
db.refresh(new_product)
# Load the product relationship
db.refresh(new_product)
logger.info(f"MarketplaceProduct {product.marketplace_product_id} added to shop {shop.shop_code}")
return new_product
except (MarketplaceProductNotFoundException, ProductAlreadyExistsException):
db.rollback()
raise # Re-raise custom exceptions
except Exception as e:
db.rollback()
logger.error(f"Error adding product to shop: {str(e)}")
raise ValidationException("Failed to add product to shop")
def get_products(
self,
db: Session,
shop: Shop,
current_user: User,
skip: int = 0,
limit: int = 100,
active_only: bool = True,
featured_only: bool = False,
) -> Tuple[List[Product], int]:
"""
Get products in shop catalog with filtering.
Args:
db: Database session
shop: Shop to get products from
current_user: Current user requesting products
skip: Number of records to skip
limit: Maximum number of records to return
active_only: Filter for active products only
featured_only: Filter for featured products only
Returns:
Tuple of (products_list, total_count)
Raises:
UnauthorizedShopAccessException: If shop access denied
"""
try:
# Check access permissions
if not self._can_access_shop(shop, current_user):
raise UnauthorizedShopAccessException(shop.shop_code, current_user.id)
# Query shop products
query = db.query(Product).filter(Product.shop_id == shop.id)
if active_only:
query = query.filter(Product.is_active == True)
if featured_only:
query = query.filter(Product.is_featured == True)
total = query.count()
products = query.offset(skip).limit(limit).all()
return products, total
except UnauthorizedShopAccessException:
raise # Re-raise custom exceptions
except Exception as e:
logger.error(f"Error getting shop products: {str(e)}")
raise ValidationException("Failed to retrieve shop products")
# Private helper methods
def _validate_shop_data(self, shop_data: ShopCreate) -> None:
"""Validate shop creation data."""
if not shop_data.shop_code or not shop_data.shop_code.strip():
raise InvalidShopDataException("Shop code is required", field="shop_code")
if not shop_data.shop_name or not shop_data.shop_name.strip():
raise InvalidShopDataException("Shop name is required", field="shop_name")
# Validate shop code format (alphanumeric, underscores, hyphens)
import re
if not re.match(r'^[A-Za-z0-9_-]+$', shop_data.shop_code):
raise InvalidShopDataException(
"Shop code can only contain letters, numbers, underscores, and hyphens",
field="shop_code"
)
def _check_shop_limit(self, db: Session, user: User) -> None:
"""Check if user has reached maximum shop limit."""
if user.role == "admin":
return # Admins have no limit
user_shop_count = db.query(Shop).filter(Shop.owner_id == user.id).count()
max_shops = 5 # Configure this as needed
if user_shop_count >= max_shops:
raise MaxShopsReachedException(max_shops, user.id)
def _shop_code_exists(self, db: Session, shop_code: str) -> bool:
"""Check if shop code already exists (case-insensitive)."""
return (
db.query(Shop)
.filter(func.upper(Shop.shop_code) == shop_code.upper())
.first() is not None
)
def _get_product_by_id_or_raise(self, db: Session, marketplace_product_id: str) -> MarketplaceProduct:
"""Get product by ID or raise exception."""
product = db.query(MarketplaceProduct).filter(MarketplaceProduct.marketplace_product_id == marketplace_product_id).first()
if not product:
raise MarketplaceProductNotFoundException(marketplace_product_id)
return product
def _product_in_shop(self, db: Session, shop_id: int, marketplace_product_id: int) -> bool:
"""Check if product is already in shop."""
return (
db.query(Product)
.filter(
Product.shop_id == shop_id,
Product.marketplace_product_id == marketplace_product_id
)
.first() is not None
)
def _can_access_shop(self, shop: Shop, user: User) -> bool:
"""Check if user can access shop."""
# Admins and owners can always access
if user.role == "admin" or shop.owner_id == user.id:
return True
# Others can only access active and verified shops
return shop.is_active and shop.is_verified
def _is_shop_owner(self, shop: Shop, user: User) -> bool:
"""Check if user is shop owner."""
return shop.owner_id == user.id
# Create service instance following the same pattern as other services
shop_service = ShopService()

View File

@@ -44,7 +44,7 @@ class StatsService:
unique_brands = self._get_unique_brands_count(db)
unique_categories = self._get_unique_categories_count(db)
unique_marketplaces = self._get_unique_marketplaces_count(db)
unique_shops = self._get_unique_shops_count(db)
unique_vendors = self._get_unique_vendors_count(db)
# Stock statistics
stock_stats = self._get_stock_statistics(db)
@@ -54,7 +54,7 @@ class StatsService:
"unique_brands": unique_brands,
"unique_categories": unique_categories,
"unique_marketplaces": unique_marketplaces,
"unique_shops": unique_shops,
"unique_vendors": unique_vendors,
"total_stock_entries": stock_stats["total_stock_entries"],
"total_inventory_quantity": stock_stats["total_inventory_quantity"],
}
@@ -87,7 +87,7 @@ class StatsService:
db.query(
MarketplaceProduct.marketplace,
func.count(MarketplaceProduct.id).label("total_products"),
func.count(func.distinct(MarketplaceProduct.shop_name)).label("unique_shops"),
func.count(func.distinct(MarketplaceProduct.vendor_name)).label("unique_vendors"),
func.count(func.distinct(MarketplaceProduct.brand)).label("unique_brands"),
)
.filter(MarketplaceProduct.marketplace.isnot(None))
@@ -99,7 +99,7 @@ class StatsService:
{
"marketplace": stat.marketplace,
"total_products": stat.total_products,
"unique_shops": stat.unique_shops,
"unique_vendors": stat.unique_vendors,
"unique_brands": stat.unique_brands,
}
for stat in marketplace_stats
@@ -130,7 +130,7 @@ class StatsService:
"unique_brands": self._get_unique_brands_count(db),
"unique_categories": self._get_unique_categories_count(db),
"unique_marketplaces": self._get_unique_marketplaces_count(db),
"unique_shops": self._get_unique_shops_count(db),
"unique_vendors": self._get_unique_vendors_count(db),
"products_with_gtin": self._get_products_with_gtin_count(db),
"products_with_images": self._get_products_with_images_count(db),
}
@@ -175,15 +175,15 @@ class StatsService:
product_count = self._get_products_by_marketplace_count(db, marketplace)
brands = self._get_brands_by_marketplace(db, marketplace)
shops = self._get_shops_by_marketplace(db, marketplace)
vendors =self._get_vendors_by_marketplace(db, marketplace)
return {
"marketplace": marketplace,
"total_products": product_count,
"unique_brands": len(brands),
"unique_shops": len(shops),
"unique_vendors": len(vendors),
"brands": brands,
"shops": shops,
"vendors": vendors,
}
except ValidationException:
@@ -227,11 +227,11 @@ class StatsService:
.count()
)
def _get_unique_shops_count(self, db: Session) -> int:
"""Get count of unique shops."""
def _get_unique_vendors_count(self, db: Session) -> int:
"""Get count of unique vendors."""
return (
db.query(MarketplaceProduct.shop_name)
.filter(MarketplaceProduct.shop_name.isnot(None), MarketplaceProduct.shop_name != "")
db.query(MarketplaceProduct.vendor_name)
.filter(MarketplaceProduct.vendor_name.isnot(None), MarketplaceProduct.vendor_name != "")
.distinct()
.count()
)
@@ -276,19 +276,19 @@ class StatsService:
)
return [brand[0] for brand in brands]
def _get_shops_by_marketplace(self, db: Session, marketplace: str) -> List[str]:
"""Get unique shops for a specific marketplace."""
shops = (
db.query(MarketplaceProduct.shop_name)
def _get_vendors_by_marketplace(self, db: Session, marketplace: str) -> List[str]:
"""Get unique vendors for a specific marketplace."""
vendors =(
db.query(MarketplaceProduct.vendor_name)
.filter(
MarketplaceProduct.marketplace == marketplace,
MarketplaceProduct.shop_name.isnot(None),
MarketplaceProduct.shop_name != "",
MarketplaceProduct.vendor_name.isnot(None),
MarketplaceProduct.vendor_name != "",
)
.distinct()
.all()
)
return [shop[0] for shop in shops]
return [vendor [0] for vendor in vendors]
def _get_products_by_marketplace_count(self, db: Session, marketplace: str) -> int:
"""Get product count for a specific marketplace."""

View File

@@ -0,0 +1,359 @@
# app/services/vendor_service.py
"""
Vendor service for managing vendor operations and product catalog.
This module provides classes and functions for:
- Vendor creation and management
- Vendor access control and validation
- Vendor product catalog operations
- Vendor filtering and search
"""
import logging
from typing import List, Optional, Tuple
from sqlalchemy import func
from sqlalchemy.orm import Session
from app.exceptions import (
VendorNotFoundException,
VendorAlreadyExistsException,
UnauthorizedVendorAccessException,
InvalidVendorDataException,
MarketplaceProductNotFoundException,
ProductAlreadyExistsException,
MaxVendorsReachedException,
ValidationException,
)
from models.schemas.vendor import VendorCreate
from models.schemas.product import ProductCreate
from models.database.marketplace_product import MarketplaceProduct
from models.database.vendor import Vendor
from models.database.product import Product
from models.database.user import User
logger = logging.getLogger(__name__)
class VendorService:
"""Service class for vendor operations following the application's service pattern."""
def create_vendor(
self, db: Session, vendor_data: VendorCreate, current_user: User
) -> Vendor:
"""
Create a new vendor.
Args:
db: Database session
vendor_data: Vendor creation data
current_user: User creating the vendor
Returns:
Created vendor object
Raises:
VendorAlreadyExistsException: If vendor code already exists
MaxVendorsReachedException: If user has reached maximum vendors
InvalidVendorDataException: If vendor data is invalid
"""
try:
# Validate vendor data
self._validate_vendor_data(vendor_data)
# Check user's vendor limit (if applicable)
self._check_vendor_limit(db, current_user)
# Normalize vendor code to uppercase
normalized_vendor_code = vendor_data.vendor_code.upper()
# Check if vendor code already exists (case-insensitive check)
if self._vendor_code_exists(db, normalized_vendor_code):
raise VendorAlreadyExistsException(normalized_vendor_code)
# Create vendor with uppercase code
vendor_dict = vendor_data.model_dump()
vendor_dict["vendor_code"] = normalized_vendor_code # Store as uppercase
new_vendor = Vendor(
**vendor_dict,
owner_id=current_user.id,
is_active=True,
is_verified=(current_user.role == "admin"),
)
db.add(new_vendor)
db.commit()
db.refresh(new_vendor)
logger.info(
f"New vendor created: {new_vendor.vendor_code} by {current_user.username}"
)
return new_vendor
except (VendorAlreadyExistsException, MaxVendorsReachedException, InvalidVendorDataException):
db.rollback()
raise # Re-raise custom exceptions
except Exception as e:
db.rollback()
logger.error(f"Error creating vendor : {str(e)}")
raise ValidationException("Failed to create vendor ")
def get_vendors(
self,
db: Session,
current_user: User,
skip: int = 0,
limit: int = 100,
active_only: bool = True,
verified_only: bool = False,
) -> Tuple[List[Vendor], int]:
"""
Get vendors with filtering.
Args:
db: Database session
current_user: Current user requesting vendors
skip: Number of records to skip
limit: Maximum number of records to return
active_only: Filter for active vendors only
verified_only: Filter for verified vendors only
Returns:
Tuple of (vendors_list, total_count)
"""
try:
query = db.query(Vendor)
# Non-admin users can only see active and verified vendors, plus their own
if current_user.role != "admin":
query = query.filter(
(Vendor.is_active == True)
& ((Vendor.is_verified == True) | (Vendor.owner_id == current_user.id))
)
else:
# Admin can apply filters
if active_only:
query = query.filter(Vendor.is_active == True)
if verified_only:
query = query.filter(Vendor.is_verified == True)
total = query.count()
vendors = query.offset(skip).limit(limit).all()
return vendors, total
except Exception as e:
logger.error(f"Error getting vendors: {str(e)}")
raise ValidationException("Failed to retrieve vendors")
def get_vendor_by_code(self, db: Session, vendor_code: str, current_user: User) -> Vendor:
"""
Get vendor by vendor code with access control.
Args:
db: Database session
vendor_code: Vendor code to find
current_user: Current user requesting the vendor
Returns:
Vendor object
Raises:
VendorNotFoundException: If vendor not found
UnauthorizedVendorAccessException: If access denied
"""
try:
vendor = (
db.query(Vendor)
.filter(func.upper(Vendor.vendor_code) == vendor_code.upper())
.first()
)
if not vendor :
raise VendorNotFoundException(vendor_code)
# Check access permissions
if not self._can_access_vendor(vendor, current_user):
raise UnauthorizedVendorAccessException(vendor_code, current_user.id)
return vendor
except (VendorNotFoundException, UnauthorizedVendorAccessException):
raise # Re-raise custom exceptions
except Exception as e:
logger.error(f"Error getting vendor {vendor_code}: {str(e)}")
raise ValidationException("Failed to retrieve vendor ")
def add_product_to_catalog(
self, db: Session, vendor : Vendor, product: ProductCreate
) -> Product:
"""
Add existing product to vendor catalog with vendor -specific settings.
Args:
db: Database session
vendor : Vendor to add product to
product: Vendor product data
Returns:
Created Product object
Raises:
MarketplaceProductNotFoundException: If product not found
ProductAlreadyExistsException: If product already in vendor
"""
try:
# Check if product exists
marketplace_product = self._get_product_by_id_or_raise(db, product.marketplace_product_id)
# Check if product already in vendor
if self._product_in_catalog(db, vendor.id, marketplace_product.id):
raise ProductAlreadyExistsException(vendor.vendor_code, product.marketplace_product_id)
# Create vendor -product association
new_product = Product(
vendor_id=vendor.id,
marketplace_product_id=marketplace_product.id,
**product.model_dump(exclude={"marketplace_product_id"}),
)
db.add(new_product)
db.commit()
db.refresh(new_product)
# Load the product relationship
db.refresh(new_product)
logger.info(f"MarketplaceProduct {product.marketplace_product_id} added to vendor {vendor.vendor_code}")
return new_product
except (MarketplaceProductNotFoundException, ProductAlreadyExistsException):
db.rollback()
raise # Re-raise custom exceptions
except Exception as e:
db.rollback()
logger.error(f"Error adding product to vendor : {str(e)}")
raise ValidationException("Failed to add product to vendor ")
def get_products(
self,
db: Session,
vendor : Vendor,
current_user: User,
skip: int = 0,
limit: int = 100,
active_only: bool = True,
featured_only: bool = False,
) -> Tuple[List[Product], int]:
"""
Get products in vendor catalog with filtering.
Args:
db: Database session
vendor : Vendor to get products from
current_user: Current user requesting products
skip: Number of records to skip
limit: Maximum number of records to return
active_only: Filter for active products only
featured_only: Filter for featured products only
Returns:
Tuple of (products_list, total_count)
Raises:
UnauthorizedVendorAccessException: If vendor access denied
"""
try:
# Check access permissions
if not self._can_access_vendor(vendor, current_user):
raise UnauthorizedVendorAccessException(vendor.vendor_code, current_user.id)
# Query vendor products
query = db.query(Product).filter(Product.vendor_id == vendor.id)
if active_only:
query = query.filter(Product.is_active == True)
if featured_only:
query = query.filter(Product.is_featured == True)
total = query.count()
products = query.offset(skip).limit(limit).all()
return products, total
except UnauthorizedVendorAccessException:
raise # Re-raise custom exceptions
except Exception as e:
logger.error(f"Error getting vendor products: {str(e)}")
raise ValidationException("Failed to retrieve vendor products")
# Private helper methods
def _validate_vendor_data(self, vendor_data: VendorCreate) -> None:
"""Validate vendor creation data."""
if not vendor_data.vendor_code or not vendor_data.vendor_code.strip():
raise InvalidVendorDataException("Vendor code is required", field="vendor_code")
if not vendor_data.vendor_name or not vendor_data.vendor_name.strip():
raise InvalidVendorDataException("Vendor name is required", field="vendor_name")
# Validate vendor code format (alphanumeric, underscores, hyphens)
import re
if not re.match(r'^[A-Za-z0-9_-]+$', vendor_data.vendor_code):
raise InvalidVendorDataException(
"Vendor code can only contain letters, numbers, underscores, and hyphens",
field="vendor_code"
)
def _check_vendor_limit(self, db: Session, user: User) -> None:
"""Check if user has reached maximum vendor limit."""
if user.role == "admin":
return # Admins have no limit
user_vendor_count = db.query(Vendor).filter(Vendor.owner_id == user.id).count()
max_vendors = 5 # Configure this as needed
if user_vendor_count >= max_vendors:
raise MaxVendorsReachedException(max_vendors, user.id)
def _vendor_code_exists(self, db: Session, vendor_code: str) -> bool:
"""Check if vendor code already exists (case-insensitive)."""
return (
db.query(Vendor)
.filter(func.upper(Vendor.vendor_code) == vendor_code.upper())
.first() is not None
)
def _get_product_by_id_or_raise(self, db: Session, marketplace_product_id: str) -> MarketplaceProduct:
"""Get product by ID or raise exception."""
product = db.query(MarketplaceProduct).filter(MarketplaceProduct.marketplace_product_id == marketplace_product_id).first()
if not product:
raise MarketplaceProductNotFoundException(marketplace_product_id)
return product
def _product_in_catalog(self, db: Session, vendor_id: int, marketplace_product_id: int) -> bool:
"""Check if product is already in vendor."""
return (
db.query(Product)
.filter(
Product.vendor_id == vendor_id,
Product.marketplace_product_id == marketplace_product_id
)
.first() is not None
)
def _can_access_vendor(self, vendor : Vendor, user: User) -> bool:
"""Check if user can access vendor."""
# Admins and owners can always access
if user.role == "admin" or vendor.owner_id == user.id:
return True
# Others can only access active and verified vendors
return vendor.is_active and vendor.is_verified
def _is_vendor_owner(self, vendor : Vendor, user: User) -> bool:
"""Check if user is vendor owner."""
return vendor.owner_id == user.id
# Create service instance following the same pattern as other services
vendor_service = VendorService()

View File

@@ -18,7 +18,7 @@ logger = logging.getLogger(__name__)
async def process_marketplace_import(
job_id: int, url: str, marketplace: str, shop_name: str, batch_size: int = 1000
job_id: int, url: str, marketplace: str, vendor_name: str, batch_size: int = 1000
):
"""Background task to process marketplace CSV import."""
db = SessionLocal()
@@ -44,7 +44,7 @@ async def process_marketplace_import(
# Process CSV
result = await csv_processor.process_marketplace_csv_from_url(
url, marketplace, shop_name, batch_size, db
url, marketplace, vendor_name, batch_size, db
)
# Update job with results

View File

@@ -187,15 +187,15 @@ class CSVProcessor:
return processed_data
async def process_marketplace_csv_from_url(
self, url: str, marketplace: str, shop_name: str, batch_size: int, db: Session
self, url: str, marketplace: str, vendor_name: str, batch_size: int, db: Session
) -> Dict[str, Any]:
"""
Process CSV from URL with marketplace and shop information.
Process CSV from URL with marketplace and vendor information.
Args:
url: URL to the CSV file
marketplace: Name of the marketplace (e.g., 'Letzshop', 'Amazon')
shop_name: Name of the shop
vendor_name: Name of the vendor
batch_size: Number of rows to process in each batch
db: Database session
@@ -203,7 +203,7 @@ class CSVProcessor:
Dictionary with processing results
"""
logger.info(
f"Starting marketplace CSV import from {url} for {marketplace} -> {shop_name}"
f"Starting marketplace CSV import from {url} for {marketplace} -> {vendor_name}"
)
# Download and parse CSV
csv_content = self.download_csv(url)
@@ -220,7 +220,7 @@ class CSVProcessor:
for i in range(0, len(df), batch_size):
batch_df = df.iloc[i : i + batch_size]
batch_result = await self._process_marketplace_batch(
batch_df, marketplace, shop_name, db, i // batch_size + 1
batch_df, marketplace, vendor_name, db, i // batch_size + 1
)
imported += batch_result["imported"]
@@ -235,14 +235,14 @@ class CSVProcessor:
"updated": updated,
"errors": errors,
"marketplace": marketplace,
"shop_name": shop_name,
"vendor_name": vendor_name,
}
async def _process_marketplace_batch(
self,
batch_df: pd.DataFrame,
marketplace: str,
shop_name: str,
vendor_name: str,
db: Session,
batch_num: int,
) -> Dict[str, int]:
@@ -253,7 +253,7 @@ class CSVProcessor:
logger.info(
f"Processing batch {batch_num} with {len(batch_df)} rows for "
f"{marketplace} -> {shop_name}"
f"{marketplace} -> {vendor_name}"
)
for index, row in batch_df.iterrows():
@@ -261,9 +261,9 @@ class CSVProcessor:
# Convert row to dictionary and clean up
product_data = self._clean_row_data(row.to_dict())
# Add marketplace and shop information
# Add marketplace and vendor information
product_data["marketplace"] = marketplace
product_data["shop_name"] = shop_name
product_data["vendor_name"] = vendor_name
# Validate required fields
if not product_data.get("marketplace_product_id"):
@@ -294,7 +294,7 @@ class CSVProcessor:
updated += 1
logger.debug(
f"Updated product {product_data['marketplace_product_id']} for "
f"{marketplace} and shop {shop_name}"
f"{marketplace} and vendor {vendor_name}"
)
else:
# Create new product
@@ -309,7 +309,7 @@ class CSVProcessor:
imported += 1
logger.debug(
f"Imported new product {product_data['marketplace_product_id']} "
f"for {marketplace} and shop {shop_name}"
f"for {marketplace} and vendor {vendor_name}"
)
except Exception as e:

View File

@@ -67,7 +67,7 @@ def health_check(db: Session = Depends(get_db)):
"features": [
"JWT Authentication",
"Marketplace-aware product import",
"Multi-shop product management",
"Multi-vendor product management",
"Stock management with location tracking",
],
"supported_marketplaces": [

View File

@@ -6,7 +6,7 @@ from .database.base import Base
from .database.user import User
from .database.marketplace_product import MarketplaceProduct
from .database.stock import Stock
from .database.shop import Shop
from .database.vendor import Vendor
from .database.product import Product
from .database.marketplace_import_job import MarketplaceImportJob
@@ -19,7 +19,7 @@ __all__ = [
"User",
"MarketplaceProduct",
"Stock",
"Shop",
"Vendor",
"Product",
"MarketplaceImportJob",
"api", # API models namespace

View File

@@ -5,7 +5,7 @@ from .base import Base
from .user import User
from .marketplace_product import MarketplaceProduct
from .stock import Stock
from .shop import Shop
from .vendor import Vendor
from .product import Product
from .marketplace_import_job import MarketplaceImportJob
@@ -14,7 +14,7 @@ __all__ = [
"User",
"MarketplaceProduct",
"Stock",
"Shop",
"Vendor",
"Product",
"MarketplaceImportJob",
]

View File

@@ -20,9 +20,9 @@ class MarketplaceImportJob(Base, TimestampMixin):
marketplace = Column(
String, nullable=False, index=True, default="Letzshop"
) # Index for marketplace filtering
shop_name = Column(String, nullable=False, index=True) # Index for shop filtering
shop_id = Column(
Integer, ForeignKey("shops.id"), nullable=False
vendor_name = Column(String, nullable=False, index=True) # Index for vendor filtering
vendor_id = Column(
Integer, ForeignKey("vendors.id"), nullable=False
) # Add proper foreign key
user_id = Column(
Integer, ForeignKey("users.id"), nullable=False
@@ -44,19 +44,19 @@ class MarketplaceImportJob(Base, TimestampMixin):
# Relationship to user
user = relationship("User", foreign_keys=[user_id])
shop = relationship("Shop", back_populates="marketplace_import_jobs")
vendor = relationship("Vendor", back_populates="marketplace_import_jobs")
# Additional indexes for marketplace import job queries
__table_args__ = (
Index(
"idx_marketplace_import_user_marketplace", "user_id", "marketplace"
), # User's marketplace imports
Index("idx_marketplace_import_shop_status", "status"), # Shop import status
Index("idx_marketplace_import_shop_id", "shop_id"),
Index("idx_marketplace_import_vendor_status", "status"), # Vendor import status
Index("idx_marketplace_import_vendor_id", "vendor_id"),
)
def __repr__(self):
return (
f"<MarketplaceImportJob(id={self.id}, marketplace='{self.marketplace}', shop='{self.shop_name}', "
f"<MarketplaceImportJob(id={self.id}, marketplace='{self.marketplace}', vendor='{self.vendor_name}', "
f"status='{self.status}', imported={self.imported_count})>"
)

View File

@@ -55,7 +55,7 @@ class MarketplaceProduct(Base, TimestampMixin):
marketplace = Column(
String, index=True, nullable=True, default="Letzshop"
) # Index for marketplace filtering
shop_name = Column(String, index=True, nullable=True) # Index for shop filtering
vendor_name = Column(String, index=True, nullable=True) # Index for vendor filtering
# Relationship to stock (one-to-many via GTIN)
stock_entries = relationship(
@@ -69,8 +69,8 @@ class MarketplaceProduct(Base, TimestampMixin):
# Additional indexes for marketplace queries
__table_args__ = (
Index(
"idx_marketplace_shop", "marketplace", "shop_name"
), # Composite index for marketplace+shop queries
"idx_marketplace_vendor", "marketplace", "vendor_name"
), # Composite index for marketplace+vendor queries
Index(
"idx_marketplace_brand", "marketplace", "brand"
), # Composite index for marketplace+brand queries
@@ -79,5 +79,5 @@ class MarketplaceProduct(Base, TimestampMixin):
def __repr__(self):
return (
f"<MarketplaceProduct(marketplace_product_id='{self.marketplace_product_id}', title='{self.title}', marketplace='{self.marketplace}', "
f"shop='{self.shop_name}')>"
f"vendor='{self.vendor_name}')>"
)

View File

@@ -8,11 +8,11 @@ from sqlalchemy.orm import relationship
from app.core.database import Base
from models.database.base import TimestampMixin
class Product(Base):
class Product(Base, TimestampMixin):
__tablename__ = "products"
id = Column(Integer, primary_key=True, index=True)
shop_id = Column(Integer, ForeignKey("shops.id"), nullable=False)
vendor_id = Column(Integer, ForeignKey("vendors.id"), nullable=False)
marketplace_product_id = Column(Integer, ForeignKey("marketplace_products.id"), nullable=False)
# Shop-specific overrides (can override the main product data)
@@ -32,17 +32,13 @@ class Product(Base):
min_quantity = Column(Integer, default=1)
max_quantity = Column(Integer)
# Timestamps
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
# Relationships
shop = relationship("Shop", back_populates="product")
vendor = relationship("Vendor", back_populates="product")
marketplace_product = relationship("MarketplaceProduct", back_populates="product")
# Constraints
__table_args__ = (
UniqueConstraint("shop_id", "marketplace_product_id", name="uq_product"),
Index("idx_product_active", "shop_id", "is_active"),
Index("idx_product_featured", "shop_id", "is_featured"),
UniqueConstraint("vendor_id", "marketplace_product_id", name="uq_product"),
Index("idx_product_active", "vendor_id", "is_active"),
Index("idx_product_featured", "vendor_id", "is_featured"),
)

View File

@@ -18,10 +18,10 @@ class Stock(Base, TimestampMixin):
location = Column(String, nullable=False, index=True)
quantity = Column(Integer, nullable=False, default=0)
reserved_quantity = Column(Integer, default=0) # For orders being processed
shop_id = Column(Integer, ForeignKey("shops.id")) # Optional: shop-specific stock
vendor_id = Column(Integer, ForeignKey("vendors.id")) # Optional: vendor -specific stock
# Relationships
shop = relationship("Shop")
vendor = relationship("Shop")
# Composite unique constraint to prevent duplicate GTIN-location combinations
__table_args__ = (

View File

@@ -15,7 +15,7 @@ class User(Base, TimestampMixin):
email = Column(String, unique=True, index=True, nullable=False)
username = Column(String, unique=True, index=True, nullable=False)
hashed_password = Column(String, nullable=False)
role = Column(String, nullable=False, default="user") # user, admin, shop_owner
role = Column(String, nullable=False, default="user") # user, admin, vendor_owner
is_active = Column(Boolean, default=True, nullable=False)
last_login = Column(DateTime, nullable=True)
@@ -23,7 +23,7 @@ class User(Base, TimestampMixin):
marketplace_import_jobs = relationship(
"MarketplaceImportJob", back_populates="user"
)
owned_shops = relationship("Shop", back_populates="owner")
owned_vendors = relationship("Vendor", back_populates="owner")
def __repr__(self):
return f"<User(username='{self.username}', email='{self.email}', role='{self.role}')>"

View File

@@ -1,7 +1,4 @@
from datetime import datetime
from sqlalchemy import (Boolean, Column, DateTime, Float, ForeignKey, Index,
Integer, String, Text, UniqueConstraint)
from sqlalchemy import (Boolean, Column, ForeignKey, Integer, String, Text)
from sqlalchemy.orm import relationship
# Import Base from the central database module instead of creating a new one
@@ -9,14 +6,14 @@ from app.core.database import Base
from models.database.base import TimestampMixin
class Shop(Base, TimestampMixin):
__tablename__ = "shops"
class Vendor(Base, TimestampMixin):
__tablename__ = "vendors"
id = Column(Integer, primary_key=True, index=True)
shop_code = Column(
vendor_code = Column(
String, unique=True, index=True, nullable=False
) # e.g., "TECHSTORE", "FASHIONHUB"
shop_name = Column(String, nullable=False) # Display name
vendor_name = Column(String, nullable=False) # Display name
description = Column(Text)
owner_id = Column(Integer, ForeignKey("users.id"), nullable=False)
@@ -34,8 +31,8 @@ class Shop(Base, TimestampMixin):
is_verified = Column(Boolean, default=False)
# Relationships
owner = relationship("User", back_populates="owned_shops")
product = relationship("Product", back_populates="shop")
owner = relationship("User", back_populates="owned_vendors")
product = relationship("Product", back_populates="vendor")
marketplace_import_jobs = relationship(
"MarketplaceImportJob", back_populates="shop"
"MarketplaceImportJob", back_populates="vendor"
)

View File

@@ -1,15 +1,14 @@
# models/schemas/__init__.py
"""API models package - Pydantic models for request/response validation."""
from . import auth
# Import API model modules
from . import base
from . import auth
from . import marketplace_product
from . import stock
from . import shop
from . import marketplace_import_job
from . import marketplace_product
from . import stats
from . import stock
from . import vendor
# Common imports for convenience
from .base import * # Base Pydantic models
@@ -18,7 +17,7 @@ __all__ = [
"auth",
"marketplace_product",
"stock",
"shop",
"vendor",
"marketplace_import_job",
"stats",
]

View File

@@ -2,12 +2,15 @@
import re
from datetime import datetime
from typing import Optional
from pydantic import BaseModel, ConfigDict, EmailStr, Field, field_validator
class UserRegister(BaseModel):
email: EmailStr = Field(..., description="Valid email address")
username: str = Field(..., description="Username")
password: str = Field(..., description="Password")
# Keep security validation in Pydantic for auth
@field_validator("username")
@@ -24,6 +27,7 @@ class UserRegister(BaseModel):
raise ValueError("Password must be at least 6 characters long")
return v
class UserLogin(BaseModel):
username: str = Field(..., description="Username")
password: str = Field(..., description="Password")
@@ -33,6 +37,7 @@ class UserLogin(BaseModel):
def validate_username(cls, v):
return v.strip()
class UserResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
@@ -44,6 +49,7 @@ class UserResponse(BaseModel):
created_at: datetime
updated_at: datetime
class LoginResponse(BaseModel):
access_token: str
token_type: str = "bearer"

View File

@@ -6,7 +6,7 @@ from pydantic import BaseModel, Field, field_validator
class MarketplaceImportJobRequest(BaseModel):
url: str = Field(..., description="URL to CSV file from marketplace")
marketplace: str = Field(default="Letzshop", description="Marketplace name")
shop_code: str = Field(..., description="Shop code to associate products with")
vendor_code: str = Field(..., description="Vendor code to associate products with")
batch_size: Optional[int] = Field(1000, description="Processing batch size")
# Removed: gt=0, le=10000 constraints - let service handle
@@ -18,7 +18,7 @@ class MarketplaceImportJobRequest(BaseModel):
raise ValueError("URL must start with http:// or https://")
return v
@field_validator("marketplace", "shop_code")
@field_validator("marketplace", "vendor_code")
@classmethod
def validate_strings(cls, v):
return v.strip()
@@ -27,9 +27,9 @@ class MarketplaceImportJobResponse(BaseModel):
job_id: int
status: str
marketplace: str
shop_id: int
shop_code: Optional[str] = None
shop_name: str
vendor_id: int
vendor_code: Optional[str] = None
vendor_name: str
message: Optional[str] = None
imported: Optional[int] = 0
updated: Optional[int] = 0

View File

@@ -43,7 +43,7 @@ class MarketplaceProductBase(BaseModel):
shipping: Optional[str] = None
currency: Optional[str] = None
marketplace: Optional[str] = None
shop_name: Optional[str] = None
vendor_name: Optional[str] = None
class MarketplaceProductCreate(MarketplaceProductBase):
marketplace_product_id: str = Field(..., description="MarketplaceProduct identifier")

View File

@@ -6,7 +6,7 @@ from pydantic import BaseModel, ConfigDict, Field, field_validator
from models.schemas.marketplace_product import MarketplaceProductResponse
class ProductCreate(BaseModel):
marketplace_product_id: str = Field(..., description="MarketplaceProduct ID to add to shop")
marketplace_product_id: str = Field(..., description="MarketplaceProduct ID to add to vendor ")
product_id: Optional[str] = None
price: Optional[float] = None # Removed: ge=0 constraint
sale_price: Optional[float] = None # Removed: ge=0 constraint
@@ -21,7 +21,7 @@ class ProductCreate(BaseModel):
class ProductResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
shop_id: int
vendor_id: int
marketplace_product: MarketplaceProductResponse
product_id: Optional[str]
price: Optional[float]

View File

@@ -10,7 +10,7 @@ class StatsResponse(BaseModel):
unique_brands: int
unique_categories: int
unique_marketplaces: int = 0
unique_shops: int = 0
unique_vendors: int = 0
total_stock_entries: int = 0
total_inventory_quantity: int = 0
@@ -18,5 +18,5 @@ class StatsResponse(BaseModel):
class MarketplaceStatsResponse(BaseModel):
marketplace: str
total_products: int
unique_shops: int
unique_vendors: int
unique_brands: int

View File

@@ -1,13 +1,13 @@
# shop.py - Keep basic format validation, remove business logic
# vendor.py - Keep basic format validation, remove business logic
import re
from datetime import datetime
from typing import List, Optional
from pydantic import BaseModel, ConfigDict, Field, field_validator
class ShopCreate(BaseModel):
shop_code: str = Field(..., description="Unique shop identifier")
shop_name: str = Field(..., description="Display name of the shop")
description: Optional[str] = Field(None, description="Shop description")
class VendorCreate(BaseModel):
vendor_code: str = Field(..., description="Unique vendor identifier")
vendor_name: str = Field(..., description="Display name of the vendor ")
description: Optional[str] = Field(None, description="Vendor description")
contact_email: Optional[str] = None
contact_phone: Optional[str] = None
website: Optional[str] = None
@@ -23,8 +23,8 @@ class ShopCreate(BaseModel):
raise ValueError("Invalid email format")
return v.lower() if v else v
class ShopUpdate(BaseModel):
shop_name: Optional[str] = None
class VendorUpdate(BaseModel):
vendor_name: Optional[str] = None
description: Optional[str] = None
contact_email: Optional[str] = None
contact_phone: Optional[str] = None
@@ -39,11 +39,11 @@ class ShopUpdate(BaseModel):
raise ValueError("Invalid email format")
return v.lower() if v else v
class ShopResponse(BaseModel):
class VendorResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
shop_code: str
shop_name: str
vendor_code: str
vendor_name: str
description: Optional[str]
owner_id: int
contact_email: Optional[str]
@@ -56,8 +56,8 @@ class ShopResponse(BaseModel):
created_at: datetime
updated_at: datetime
class ShopListResponse(BaseModel):
shops: List[ShopResponse]
class VendorListResponse(BaseModel):
vendors: List[VendorResponse]
total: int
skip: int
limit: int

View File

@@ -36,7 +36,7 @@ markers =
auth: marks tests as authentication and authorization tests
products: marks tests as product management functionality
stock: marks tests as stock and inventory management
shops: marks tests as shop management functionality
vendors: marks tests as vendor management functionality
admin: marks tests as admin functionality and permissions
marketplace: marks tests as marketplace import functionality
stats: marks tests as statistics and reporting

View File

@@ -65,7 +65,7 @@ def verify_database_setup():
# Expected tables from your models
expected_tables = [
'users', 'products', 'stock', 'shops', 'products',
'users', 'products', 'stock', 'vendors', 'products',
'marketplace_import_jobs', 'alembic_version'
]
@@ -133,7 +133,7 @@ def verify_model_structure():
from models.database.user import User
from models.database.marketplace_product import MarketplaceProduct
from models.database.stock import Stock
from models.database.shop import Shop
from models.database.vendor import Vendor
from models.database.product import Product
from models.database.marketplace_import_job import MarketplaceImportJob
@@ -149,7 +149,7 @@ def verify_model_structure():
print("[OK] API models package imported")
# Test specific API model imports
api_modules = ['base', 'auth', 'product', 'stock', 'shop', 'marketplace', 'admin', 'stats']
api_modules = ['base', 'auth', 'product', 'stock', 'vendor ', 'marketplace', 'admin', 'stats']
for module in api_modules:
try:
__import__(f'models.api.{module}')

View File

@@ -10,7 +10,7 @@ from main import app
# Import all models to ensure they're registered with Base metadata
from models.database.marketplace_import_job import MarketplaceImportJob
from models.database.marketplace_product import MarketplaceProduct
from models.database.shop import Shop
from models.database.vendor import Vendor
from models.database.product import Product
from models.database.stock import Stock
from models.database.user import User
@@ -89,7 +89,7 @@ def cleanup():
pytest_plugins = [
"tests.fixtures.auth_fixtures",
"tests.fixtures.marketplace_product_fixtures",
"tests.fixtures.shop_fixtures",
"tests.fixtures.vendor_fixtures",
"tests.fixtures.marketplace_import_job_fixtures",
"tests.fixtures.testing_fixtures",
]

View File

@@ -5,14 +5,14 @@ from models.database.marketplace_import_job import MarketplaceImportJob
@pytest.fixture
def test_marketplace_import_job(db, test_shop, test_user):
def test_marketplace_import_job(db, test_vendor, test_user):
"""Create a test marketplace import job"""
job = MarketplaceImportJob(
marketplace="amazon",
shop_name="Test Import Shop",
vendor_name="Test Import Shop",
status="completed",
source_url="https://test-marketplace.example.com/import",
shop_id=test_shop.id,
vendor_id=test_vendor.id,
user_id=test_user.id,
imported_count=5,
updated_count=3,
@@ -26,14 +26,14 @@ def test_marketplace_import_job(db, test_shop, test_user):
return job
def create_test_marketplace_import_job(db, shop_id, user_id, **kwargs):
def create_test_marketplace_import_job(db, vendor_id, user_id, **kwargs):
"""Helper function to create MarketplaceImportJob with defaults"""
defaults = {
"marketplace": "test",
"shop_name": "Test Shop",
"vendor_name": "Test Shop",
"status": "pending",
"source_url": "https://test.example.com/import",
"shop_id": shop_id,
"vendor_id": vendor_id,
"user_id": user_id,
"imported_count": 0,
"updated_count": 0,

View File

@@ -19,7 +19,7 @@ def test_marketplace_product(db):
gtin="1234567890123",
availability="in stock",
marketplace="Letzshop",
shop_name="TestShop",
vendor_name="TestVendor",
)
db.add(marketplace_product)
db.commit()
@@ -41,7 +41,7 @@ def unique_product(db):
gtin=f"123456789{unique_id[:4]}",
availability="in stock",
marketplace="Letzshop",
shop_name=f"UniqueShop_{unique_id}",
vendor_name=f"UniqueShop_{unique_id}",
google_product_category=f"UniqueCategory_{unique_id}",
)
db.add(marketplace_product)
@@ -65,7 +65,7 @@ def multiple_products(db):
currency="EUR",
brand=f"MultiBrand_{i % 3}", # Create 3 different brands
marketplace=f"MultiMarket_{i % 2}", # Create 2 different marketplaces
shop_name=f"MultiShop_{i}",
vendor_name=f"MultiShop_{i}",
google_product_category=f"MultiCategory_{i % 2}", # Create 2 different categories
gtin=f"1234567890{i}{unique_id[:2]}",
)
@@ -89,7 +89,7 @@ def create_unique_marketplace_product_factory():
"price": "15.99",
"currency": "EUR",
"marketplace": "TestMarket",
"shop_name": "TestShop",
"vendor_name": "TestVendor",
}
defaults.update(kwargs)

View File

@@ -21,11 +21,11 @@ def empty_db(db):
# Clear only the tables that are relevant for admin service testing
# In order to respect foreign key constraints
tables_to_clear = [
"marketplace_import_jobs", # Has foreign keys to shops and users
"products", # Has foreign keys to shops and products
"marketplace_import_jobs", # Has foreign keys to vendors and users
"products", # Has foreign keys to vendors and products
"stock", # Fixed: singular not plural
"products", # Referenced by products
"shops", # Has foreign key to users
"vendors", # Has foreign key to users
"users" # Base table
]

View File

@@ -1,90 +1,90 @@
# tests/fixtures/shop_fixtures.py
# tests/fixtures/vendor_fixtures.py
import uuid
import pytest
from models.database.shop import Shop
from models.database.vendor import Vendor
from models.database.product import Product
from models.database.stock import Stock
@pytest.fixture
def test_shop(db, test_user):
"""Create a test shop with unique shop code"""
def test_vendor(db, test_user):
"""Create a test vendor with unique vendor code"""
unique_id = str(uuid.uuid4())[:8].upper() # Make unique ID uppercase
shop = Shop(
shop_code=f"TESTSHOP_{unique_id}", # Will be all uppercase
shop_name=f"Test Shop {unique_id.lower()}", # Keep display name readable
vendor = Vendor(
vendor_code=f"TESTVENDOR_{unique_id}", # Will be all uppercase
vendor_name=f"Test Vendor {unique_id.lower()}", # Keep display name readable
owner_id=test_user.id,
is_active=True,
is_verified=True,
)
db.add(shop)
db.add(vendor)
db.commit()
db.refresh(shop)
return shop
db.refresh(vendor)
return vendor
@pytest.fixture
def unique_shop(db, test_user):
"""Create a unique shop for tests that need isolated shop data"""
def unique_vendor(db, test_user):
"""Create a unique vendor for tests that need isolated vendor data"""
unique_id = str(uuid.uuid4())[:8]
shop = Shop(
shop_code=f"UNIQUESHOP_{unique_id}",
shop_name=f"Unique Test Shop {unique_id}",
description=f"A unique test shop {unique_id}",
vendor = Vendor(
vendor_code=f"UNIQUEVENDOR_{unique_id}",
vendor_name=f"Unique Test Vendor {unique_id}",
description=f"A unique test vendor {unique_id}",
owner_id=test_user.id,
is_active=True,
is_verified=True,
)
db.add(shop)
db.add(vendor)
db.commit()
db.refresh(shop)
return shop
db.refresh(vendor)
return vendor
@pytest.fixture
def inactive_shop(db, other_user):
"""Create an inactive shop owned by other_user"""
def inactive_vendor(db, other_user):
"""Create an inactive vendor owned by other_user"""
unique_id = str(uuid.uuid4())[:8]
shop = Shop(
shop_code=f"INACTIVE_{unique_id}",
shop_name=f"Inactive Shop {unique_id}",
vendor = Vendor(
vendor_code=f"INACTIVE_{unique_id}",
vendor_name=f"Inactive Vendor {unique_id}",
owner_id=other_user.id,
is_active=False,
is_verified=False,
)
db.add(shop)
db.add(vendor)
db.commit()
db.refresh(shop)
return shop
db.refresh(vendor)
return vendor
@pytest.fixture
def verified_shop(db, other_user):
"""Create a verified shop owned by other_user"""
def verified_vendor(db, other_user):
"""Create a verified vendor owned by other_user"""
unique_id = str(uuid.uuid4())[:8]
shop = Shop(
shop_code=f"VERIFIED_{unique_id}",
shop_name=f"Verified Shop {unique_id}",
vendor = Vendor(
vendor_code=f"VERIFIED_{unique_id}",
vendor_name=f"Verified Vendor {unique_id}",
owner_id=other_user.id,
is_active=True,
is_verified=True,
)
db.add(shop)
db.add(vendor)
db.commit()
db.refresh(shop)
return shop
db.refresh(vendor)
return vendor
@pytest.fixture
def test_product(db, test_shop, unique_product):
"""Create a shop product relationship"""
def test_product(db, test_vendor, unique_product):
"""Create a vendor product relationship"""
product = Product(
shop_id=test_shop.id, marketplace_product_id=unique_product.id, is_active=True
vendor_id=test_vendor.id, marketplace_product_id=unique_product.id, is_active=True
)
# Add optional fields if they exist in your model
if hasattr(Product, "shop_price"):
if hasattr(Product, "price"):
product.price = 24.99
if hasattr(Product, "is_featured"):
product.is_featured = False
@@ -98,7 +98,7 @@ def test_product(db, test_shop, unique_product):
@pytest.fixture
def test_stock(db, test_marketplace_product, test_shop):
def test_stock(db, test_marketplace_product, test_vendor):
"""Create test stock entry"""
unique_id = str(uuid.uuid4())[:8].upper() # Short unique identifier
stock = Stock(
@@ -106,7 +106,7 @@ def test_stock(db, test_marketplace_product, test_shop):
location=f"WAREHOUSE_A_{unique_id}",
quantity=10,
reserved_quantity=0,
shop_id=test_shop.id, # Add shop_id reference
vendor_id=test_vendor.id, # Add vendor_id reference
)
db.add(stock)
db.commit()
@@ -115,7 +115,7 @@ def test_stock(db, test_marketplace_product, test_shop):
@pytest.fixture
def multiple_stocks(db, multiple_products, test_shop):
def multiple_stocks(db, multiple_products, test_vendor):
"""Create multiple stock entries for testing"""
stocks = []
@@ -125,7 +125,7 @@ def multiple_stocks(db, multiple_products, test_shop):
location=f"LOC_{i}",
quantity=10 + (i * 5), # Different quantities
reserved_quantity=i,
shop_id=test_shop.id,
vendor_id=test_vendor.id,
)
stocks.append(stock)
@@ -136,30 +136,30 @@ def multiple_stocks(db, multiple_products, test_shop):
return stocks
def create_unique_shop_factory():
"""Factory function to create unique shops in tests"""
def create_unique_vendor_factory():
"""Factory function to create unique vendors in tests"""
def _create_shop(db, owner_id, **kwargs):
def _create_vendor(db, owner_id, **kwargs):
unique_id = str(uuid.uuid4())[:8]
defaults = {
"shop_code": f"FACTORY_{unique_id}",
"shop_name": f"Factory Shop {unique_id}",
"vendor_code": f"FACTORY_{unique_id}",
"vendor_name": f"Factory Vendor {unique_id}",
"owner_id": owner_id,
"is_active": True,
"is_verified": False,
}
defaults.update(kwargs)
shop = Shop(**defaults)
db.add(shop)
vendor = Vendor(**defaults)
db.add(vendor)
db.commit()
db.refresh(shop)
return shop
db.refresh(vendor)
return vendor
return _create_shop
return _create_vendor
@pytest.fixture
def shop_factory():
"""Fixture that provides a shop factory function"""
return create_unique_shop_factory()
def vendor_factory():
"""Fixture that provides a vendor factory function"""
return create_unique_vendor_factory()

View File

@@ -74,67 +74,67 @@ class TestAdminAPI:
assert data["error_code"] == "USER_STATUS_CHANGE_FAILED"
assert "Cannot modify another admin user" in data["message"]
def test_get_all_shops_admin(self, client, admin_headers, test_shop):
"""Test admin getting all shops"""
response = client.get("/api/v1/admin/shops", headers=admin_headers)
def test_get_all_vendors_admin(self, client, admin_headers, test_vendor):
"""Test admin getting all vendors"""
response = client.get("/api/v1/admin/vendors", headers=admin_headers)
assert response.status_code == 200
data = response.json()
assert data["total"] >= 1
assert len(data["shops"]) >= 1
assert len(data["vendors"]) >= 1
# Check that test_shop is in the response
shop_codes = [
shop["shop_code"] for shop in data["shops"] if "shop_code" in shop
# Check that test_vendor is in the response
vendor_codes = [
vendor ["vendor_code"] for vendor in data["vendors"] if "vendor_code" in vendor
]
assert test_shop.shop_code in shop_codes
assert test_vendor.vendor_code in vendor_codes
def test_get_all_shops_non_admin(self, client, auth_headers):
"""Test non-admin trying to access admin shop endpoint"""
response = client.get("/api/v1/admin/shops", headers=auth_headers)
def test_get_all_vendors_non_admin(self, client, auth_headers):
"""Test non-admin trying to access admin vendor endpoint"""
response = client.get("/api/v1/admin/vendors", headers=auth_headers)
assert response.status_code == 403
data = response.json()
assert data["error_code"] == "ADMIN_REQUIRED"
def test_verify_shop_admin(self, client, admin_headers, test_shop):
"""Test admin verifying/unverifying shop"""
def test_verify_vendor_admin(self, client, admin_headers, test_vendor):
"""Test admin verifying/unverifying vendor """
response = client.put(
f"/api/v1/admin/shops/{test_shop.id}/verify", headers=admin_headers
f"/api/v1/admin/vendors/{test_vendor.id}/verify", headers=admin_headers
)
assert response.status_code == 200
message = response.json()["message"]
assert "verified" in message or "unverified" in message
assert test_shop.shop_code in message
assert test_vendor.vendor_code in message
def test_verify_shop_not_found(self, client, admin_headers):
"""Test admin verifying non-existent shop"""
response = client.put("/api/v1/admin/shops/99999/verify", headers=admin_headers)
def test_verify_vendor_not_found(self, client, admin_headers):
"""Test admin verifying non-existent vendor """
response = client.put("/api/v1/admin/vendors/99999/verify", headers=admin_headers)
assert response.status_code == 404
data = response.json()
assert data["error_code"] == "SHOP_NOT_FOUND"
assert "Shop with ID '99999' not found" in data["message"]
assert data["error_code"] == "VENDOR_NOT_FOUND"
assert "Vendor with ID '99999' not found" in data["message"]
def test_toggle_shop_status_admin(self, client, admin_headers, test_shop):
"""Test admin toggling shop status"""
def test_toggle_vendor_status_admin(self, client, admin_headers, test_vendor):
"""Test admin toggling vendor status"""
response = client.put(
f"/api/v1/admin/shops/{test_shop.id}/status", headers=admin_headers
f"/api/v1/admin/vendors/{test_vendor.id}/status", headers=admin_headers
)
assert response.status_code == 200
message = response.json()["message"]
assert "activated" in message or "deactivated" in message
assert test_shop.shop_code in message
assert test_vendor.vendor_code in message
def test_toggle_shop_status_not_found(self, client, admin_headers):
"""Test admin toggling status for non-existent shop"""
response = client.put("/api/v1/admin/shops/99999/status", headers=admin_headers)
def test_toggle_vendor_status_not_found(self, client, admin_headers):
"""Test admin toggling status for non-existent vendor """
response = client.put("/api/v1/admin/vendors/99999/status", headers=admin_headers)
assert response.status_code == 404
data = response.json()
assert data["error_code"] == "SHOP_NOT_FOUND"
assert data["error_code"] == "VENDOR_NOT_FOUND"
def test_get_marketplace_import_jobs_admin(
self, client, admin_headers, test_marketplace_import_job
@@ -191,17 +191,17 @@ class TestAdminAPI:
assert "activation_rate" in data
assert isinstance(data["total_users"], int)
def test_get_shop_statistics(self, client, admin_headers):
"""Test admin getting shop statistics"""
response = client.get("/api/v1/admin/stats/shops", headers=admin_headers)
def test_get_vendor_statistics(self, client, admin_headers):
"""Test admin getting vendor statistics"""
response = client.get("/api/v1/admin/stats/vendors", headers=admin_headers)
assert response.status_code == 200
data = response.json()
assert "total_shops" in data
assert "active_shops" in data
assert "verified_shops" in data
assert "total_vendors" in data
assert "active_vendors" in data
assert "verified_vendors" in data
assert "verification_rate" in data
assert isinstance(data["total_shops"], int)
assert isinstance(data["total_vendors"], int)
def test_admin_pagination_users(self, client, admin_headers, test_user, test_admin):
"""Test user pagination works correctly"""
@@ -221,14 +221,14 @@ class TestAdminAPI:
data = response.json()
assert len(data) >= 0 # Could be 1 or 0 depending on total users
def test_admin_pagination_shops(self, client, admin_headers, test_shop):
"""Test shop pagination works correctly"""
def test_admin_pagination_vendors(self, client, admin_headers, test_vendor):
"""Test vendor pagination works correctly"""
response = client.get(
"/api/v1/admin/shops?skip=0&limit=1", headers=admin_headers
"/api/v1/admin/vendors?skip=0&limit=1", headers=admin_headers
)
assert response.status_code == 200
data = response.json()
assert data["total"] >= 1
assert len(data["shops"]) >= 0
assert len(data["vendors"]) >= 0
assert "skip" in data
assert "limit" in data

View File

@@ -8,15 +8,15 @@ import pytest
@pytest.mark.api
@pytest.mark.marketplace
class TestMarketplaceImportJobAPI:
def test_import_from_marketplace(self, client, auth_headers, test_shop, test_user):
def test_import_from_marketplace(self, client, auth_headers, test_vendor, test_user):
"""Test marketplace import endpoint - just test job creation"""
# Ensure user owns the shop
test_shop.owner_id = test_user.id
# Ensure user owns the vendor
test_vendor.owner_id = test_user.id
import_data = {
"url": "https://example.com/products.csv",
"marketplace": "TestMarket",
"shop_code": test_shop.shop_code,
"vendor_code": test_vendor.vendor_code,
}
response = client.post(
@@ -28,15 +28,15 @@ class TestMarketplaceImportJobAPI:
assert data["status"] == "pending"
assert data["marketplace"] == "TestMarket"
assert "job_id" in data
assert data["shop_code"] == test_shop.shop_code
assert data["shop_id"] == test_shop.id
assert data["vendor_code"] == test_vendor.vendor_code
assert data["vendor_id"] == test_vendor.id
def test_import_from_marketplace_invalid_shop(self, client, auth_headers):
"""Test marketplace import with invalid shop"""
def test_import_from_marketplace_invalid_vendor(self, client, auth_headers):
"""Test marketplace import with invalid vendor """
import_data = {
"url": "https://example.com/products.csv",
"marketplace": "TestMarket",
"shop_code": "NONEXISTENT",
"vendor_code": "NONEXISTENT",
}
response = client.post(
@@ -45,18 +45,18 @@ class TestMarketplaceImportJobAPI:
assert response.status_code == 404
data = response.json()
assert data["error_code"] == "SHOP_NOT_FOUND"
assert data["error_code"] == "VENDOR_NOT_FOUND"
assert "NONEXISTENT" in data["message"]
def test_import_from_marketplace_unauthorized_shop(self, client, auth_headers, test_shop, other_user):
"""Test marketplace import with unauthorized shop access"""
# Set shop owner to different user
test_shop.owner_id = other_user.id
def test_import_from_marketplace_unauthorized_vendor(self, client, auth_headers, test_vendor, other_user):
"""Test marketplace import with unauthorized vendor access"""
# Set vendor owner to different user
test_vendor.owner_id = other_user.id
import_data = {
"url": "https://example.com/products.csv",
"marketplace": "TestMarket",
"shop_code": test_shop.shop_code,
"vendor_code": test_vendor.vendor_code,
}
response = client.post(
@@ -65,15 +65,15 @@ class TestMarketplaceImportJobAPI:
assert response.status_code == 403
data = response.json()
assert data["error_code"] == "UNAUTHORIZED_SHOP_ACCESS"
assert test_shop.shop_code in data["message"]
assert data["error_code"] == "UNAUTHORIZED_VENDOR_ACCESS"
assert test_vendor.vendor_code in data["message"]
def test_import_from_marketplace_validation_error(self, client, auth_headers):
"""Test marketplace import with invalid request data"""
import_data = {
"url": "", # Empty URL
"marketplace": "", # Empty marketplace
# Missing shop_code
# Missing vendor_code
}
response = client.post(
@@ -85,12 +85,12 @@ class TestMarketplaceImportJobAPI:
assert data["error_code"] == "VALIDATION_ERROR"
assert "Request validation failed" in data["message"]
def test_import_from_marketplace_admin_access(self, client, admin_headers, test_shop):
"""Test that admin can import for any shop"""
def test_import_from_marketplace_admin_access(self, client, admin_headers, test_vendor):
"""Test that admin can import for any vendor """
import_data = {
"url": "https://example.com/products.csv",
"marketplace": "AdminMarket",
"shop_code": test_shop.shop_code,
"vendor_code": test_vendor.vendor_code,
}
response = client.post(
@@ -100,7 +100,7 @@ class TestMarketplaceImportJobAPI:
assert response.status_code == 200
data = response.json()
assert data["marketplace"] == "AdminMarket"
assert data["shop_code"] == test_shop.shop_code
assert data["vendor_code"] == test_vendor.vendor_code
def test_get_marketplace_import_status(self, client, auth_headers, test_marketplace_import_job):
"""Test getting marketplace import status"""
@@ -195,7 +195,7 @@ class TestMarketplaceImportJobAPI:
assert isinstance(data["total_jobs"], int)
assert data["total_jobs"] >= 1
def test_cancel_marketplace_import_job(self, client, auth_headers, test_user, test_shop, db):
def test_cancel_marketplace_import_job(self, client, auth_headers, test_user, test_vendor, db):
"""Test cancelling a marketplace import job"""
# Create a pending job that can be cancelled
from models.database.marketplace_import_job import MarketplaceImportJob
@@ -205,9 +205,9 @@ class TestMarketplaceImportJobAPI:
job = MarketplaceImportJob(
status="pending",
marketplace="TestMarket",
shop_name=f"Test_Shop_{unique_id}",
vendor_name=f"Test_vendor_{unique_id}",
user_id=test_user.id,
shop_id=test_shop.id,
vendor_id=test_vendor.id,
source_url="https://test.example.com/import",
imported_count=0,
updated_count=0,
@@ -256,7 +256,7 @@ class TestMarketplaceImportJobAPI:
assert data["error_code"] == "IMPORT_JOB_CANNOT_BE_CANCELLED"
assert "completed" in data["message"]
def test_delete_marketplace_import_job(self, client, auth_headers, test_user, test_shop, db):
def test_delete_marketplace_import_job(self, client, auth_headers, test_user, test_vendor, db):
"""Test deleting a marketplace import job"""
# Create a completed job that can be deleted
from models.database.marketplace_import_job import MarketplaceImportJob
@@ -266,9 +266,9 @@ class TestMarketplaceImportJobAPI:
job = MarketplaceImportJob(
status="completed",
marketplace="TestMarket",
shop_name=f"Test_Shop_{unique_id}",
vendor_name=f"Test_vendor_{unique_id}",
user_id=test_user.id,
shop_id=test_shop.id,
vendor_id=test_vendor.id,
source_url="https://test.example.com/import",
imported_count=0,
updated_count=0,
@@ -299,7 +299,7 @@ class TestMarketplaceImportJobAPI:
data = response.json()
assert data["error_code"] == "IMPORT_JOB_NOT_FOUND"
def test_delete_marketplace_import_job_cannot_delete(self, client, auth_headers, test_user, test_shop, db):
def test_delete_marketplace_import_job_cannot_delete(self, client, auth_headers, test_user, test_vendor, db):
"""Test deleting a job that cannot be deleted"""
# Create a pending job that cannot be deleted
from models.database.marketplace_import_job import MarketplaceImportJob
@@ -309,9 +309,9 @@ class TestMarketplaceImportJobAPI:
job = MarketplaceImportJob(
status="pending",
marketplace="TestMarket",
shop_name=f"Test_Shop_{unique_id}",
vendor_name=f"Test_vendor_{unique_id}",
user_id=test_user.id,
shop_id=test_shop.id,
vendor_id=test_vendor.id,
source_url="https://test.example.com/import",
imported_count=0,
updated_count=0,
@@ -344,7 +344,7 @@ class TestMarketplaceImportJobAPI:
import_data = {
"url": "https://example.com/products.csv",
"marketplace": "TestMarket",
"shop_code": "TEST_SHOP",
"vendor_code": "TEST_SHOP",
}
response = client.post("/api/v1/marketplace/import-product", json=import_data)
@@ -374,7 +374,7 @@ class TestMarketplaceImportJobAPI:
data = response.json()
assert data["job_id"] == test_marketplace_import_job.id
def test_admin_can_cancel_any_job(self, client, admin_headers, test_user, test_shop, db):
def test_admin_can_cancel_any_job(self, client, admin_headers, test_user, test_vendor, db):
"""Test that admin can cancel any job"""
# Create a pending job owned by different user
from models.database.marketplace_import_job import MarketplaceImportJob
@@ -384,9 +384,9 @@ class TestMarketplaceImportJobAPI:
job = MarketplaceImportJob(
status="pending",
marketplace="TestMarket",
shop_name=f"Test_Shop_{unique_id}",
vendor_name=f"Test_vendor_{unique_id}",
user_id=test_user.id, # Different user
shop_id=test_shop.id,
vendor_id=test_vendor.id,
source_url="https://test.example.com/import",
imported_count=0,
updated_count=0,
@@ -406,16 +406,16 @@ class TestMarketplaceImportJobAPI:
data = response.json()
assert data["status"] == "cancelled"
def test_rate_limiting_applied(self, client, auth_headers, test_shop, test_user):
def test_rate_limiting_applied(self, client, auth_headers, test_vendor, test_user):
"""Test that rate limiting is applied to import endpoint"""
# This test verifies that the rate_limit decorator is present
# Actual rate limiting testing would require multiple requests
test_shop.owner_id = test_user.id
test_vendor.owner_id = test_user.id
import_data = {
"url": "https://example.com/products.csv",
"marketplace": "TestMarket",
"shop_code": test_shop.shop_code,
"vendor_code": test_vendor.vendor_code,
}
response = client.post(

View File

@@ -65,19 +65,19 @@ class TestExportFunctionality:
assert f"EXP1_{unique_suffix}" in csv_content
assert f"EXP2_{unique_suffix}" not in csv_content # Should be filtered out
def test_csv_export_with_shop_filter_success(self, client, auth_headers, db):
"""Test CSV export with shop name filtering successfully"""
def test_csv_export_with_vendor_filter_success(self, client, auth_headers, db):
"""Test CSV export with vendor name filtering successfully"""
unique_suffix = str(uuid.uuid4())[:8]
products = [
MarketplaceProduct(
marketplace_product_id=f"SHOP1_{unique_suffix}",
title=f"Shop1 MarketplaceProduct {unique_suffix}",
shop_name="TestShop1"
vendor_name="TestVendor1"
),
MarketplaceProduct(
marketplace_product_id=f"SHOP2_{unique_suffix}",
title=f"Shop2 MarketplaceProduct {unique_suffix}",
shop_name="TestShop2"
vendor_name="TestVendor2"
),
]
@@ -85,7 +85,7 @@ class TestExportFunctionality:
db.commit()
response = client.get(
"/api/v1/marketplace/product?shop_name=TestShop1", headers=auth_headers
"/api/v1/marketplace/product?vendor_name=TestVendor1", headers=auth_headers
)
assert response.status_code == 200
@@ -94,26 +94,26 @@ class TestExportFunctionality:
assert f"SHOP2_{unique_suffix}" not in csv_content # Should be filtered out
def test_csv_export_with_combined_filters_success(self, client, auth_headers, db):
"""Test CSV export with combined marketplace and shop filters successfully"""
"""Test CSV export with combined marketplace and vendor filters successfully"""
unique_suffix = str(uuid.uuid4())[:8]
products = [
MarketplaceProduct(
marketplace_product_id=f"COMBO1_{unique_suffix}",
title=f"Combo MarketplaceProduct 1 {unique_suffix}",
marketplace="Amazon",
shop_name="TestShop"
vendor_name="TestVendor"
),
MarketplaceProduct(
marketplace_product_id=f"COMBO2_{unique_suffix}",
title=f"Combo MarketplaceProduct 2 {unique_suffix}",
marketplace="eBay",
shop_name="TestShop"
vendor_name="TestVendor"
),
MarketplaceProduct(
marketplace_product_id=f"COMBO3_{unique_suffix}",
title=f"Combo MarketplaceProduct 3 {unique_suffix}",
marketplace="Amazon",
shop_name="OtherShop"
vendor_name="OtherShop"
),
]
@@ -121,7 +121,7 @@ class TestExportFunctionality:
db.commit()
response = client.get(
"/api/v1/marketplace/product?marketplace=Amazon&shop_name=TestShop",
"/api/v1/marketplace/product?marketplace=Amazon&vendor_name=TestVendor",
headers=auth_headers
)
assert response.status_code == 200
@@ -129,7 +129,7 @@ class TestExportFunctionality:
csv_content = response.content.decode("utf-8")
assert f"COMBO1_{unique_suffix}" in csv_content # Matches both filters
assert f"COMBO2_{unique_suffix}" not in csv_content # Wrong marketplace
assert f"COMBO3_{unique_suffix}" not in csv_content # Wrong shop
assert f"COMBO3_{unique_suffix}" not in csv_content # Wrong vendor
def test_csv_export_no_results(self, client, auth_headers):
"""Test CSV export with filters that return no results"""

View File

@@ -2,7 +2,7 @@
import pytest
from models.database.marketplace_product import MarketplaceProduct
from models.database.shop import Shop
from models.database.vendor import Vendor
@pytest.mark.integration
@pytest.mark.api
@@ -181,34 +181,34 @@ class TestPagination:
overlap = set(first_page_ids) & set(second_page_ids)
assert len(overlap) == 0, "Pages should not have overlapping products"
def test_shop_pagination_success(self, client, admin_headers, db, test_user):
"""Test pagination for shop listing successfully"""
def test_vendor_pagination_success(self, client, admin_headers, db, test_user):
"""Test pagination for vendor listing successfully"""
import uuid
unique_suffix = str(uuid.uuid4())[:8]
# Create multiple shops for pagination testing
from models.database.shop import Shop
shops = []
# Create multiple vendors for pagination testing
from models.database.vendor import Vendor
vendors =[]
for i in range(15):
shop = Shop(
shop_code=f"PAGESHOP{i:03d}_{unique_suffix}",
shop_name=f"Pagination Shop {i}",
vendor = Vendor(
vendor_code=f"PAGESHOP{i:03d}_{unique_suffix}",
vendor_name=f"Pagination Vendor {i}",
owner_id=test_user.id,
is_active=True,
)
shops.append(shop)
vendors.append(vendor)
db.add_all(shops)
db.add_all(vendors)
db.commit()
# Test first page (assuming admin endpoint exists)
response = client.get(
"/api/v1/shop?limit=5&skip=0", headers=admin_headers
"/api/v1/vendor ?limit=5&skip=0", headers=admin_headers
)
assert response.status_code == 200
data = response.json()
assert len(data["shops"]) == 5
assert data["total"] >= 15 # At least our test shops
assert len(data["vendors"]) == 5
assert data["total"] >= 15 # At least our test vendors
assert data["skip"] == 0
assert data["limit"] == 5

View File

@@ -1,389 +0,0 @@
# tests/integration/api/v1/test_shop_endpoints.py
import pytest
@pytest.mark.integration
@pytest.mark.api
@pytest.mark.shops
class TestShopsAPI:
def test_create_shop_success(self, client, auth_headers):
"""Test creating a new shop successfully"""
shop_data = {
"shop_code": "NEWSHOP001",
"shop_name": "New Shop",
"description": "A new test shop",
}
response = client.post("/api/v1/shop", headers=auth_headers, json=shop_data)
assert response.status_code == 200
data = response.json()
assert data["shop_code"] == "NEWSHOP001"
assert data["shop_name"] == "New Shop"
assert data["is_active"] is True
def test_create_shop_duplicate_code_returns_conflict(self, client, auth_headers, test_shop):
"""Test creating shop with duplicate code returns ShopAlreadyExistsException"""
shop_data = {
"shop_code": test_shop.shop_code,
"shop_name": "Different Name",
"description": "Different description",
}
response = client.post("/api/v1/shop", headers=auth_headers, json=shop_data)
assert response.status_code == 409
data = response.json()
assert data["error_code"] == "SHOP_ALREADY_EXISTS"
assert data["status_code"] == 409
assert test_shop.shop_code in data["message"]
assert data["details"]["shop_code"] == test_shop.shop_code
def test_create_shop_missing_shop_code_validation_error(self, client, auth_headers):
"""Test creating shop without shop_code returns ValidationException"""
shop_data = {
"shop_name": "Shop without Code",
"description": "Missing shop code",
}
response = client.post("/api/v1/shop", headers=auth_headers, json=shop_data)
assert response.status_code == 422
data = response.json()
assert data["error_code"] == "VALIDATION_ERROR"
assert data["status_code"] == 422
assert "Request validation failed" in data["message"]
assert "validation_errors" in data["details"]
def test_create_shop_empty_shop_name_validation_error(self, client, auth_headers):
"""Test creating shop with empty shop_name returns ShopValidationException"""
shop_data = {
"shop_code": "EMPTYNAME",
"shop_name": "", # Empty shop name
"description": "Shop with empty name",
}
response = client.post("/api/v1/shop", headers=auth_headers, json=shop_data)
assert response.status_code == 422
data = response.json()
assert data["error_code"] == "INVALID_SHOP_DATA"
assert data["status_code"] == 422
assert "Shop name is required" in data["message"]
assert data["details"]["field"] == "shop_name"
def test_create_shop_max_shops_reached_business_logic_error(self, client, auth_headers, db, test_user):
"""Test creating shop when max shops reached returns MaxShopsReachedException"""
# This test would require creating the maximum allowed shops first
# The exact implementation depends on your business rules
# For now, we'll test the structure of what the error should look like
# In a real scenario, you'd create max_shops number of shops first
# Assuming max shops is enforced at service level
# This test validates the expected response structure
pass # Implementation depends on your max_shops business logic
def test_get_shops_success(self, client, auth_headers, test_shop):
"""Test getting shops list successfully"""
response = client.get("/api/v1/shop", headers=auth_headers)
assert response.status_code == 200
data = response.json()
assert data["total"] >= 1
assert len(data["shops"]) >= 1
# Find our test shop
test_shop_found = any(s["shop_code"] == test_shop.shop_code for s in data["shops"])
assert test_shop_found
def test_get_shops_with_filters(self, client, auth_headers, test_shop):
"""Test getting shops with filtering options"""
# Test active_only filter
response = client.get("/api/v1/shop?active_only=true", headers=auth_headers)
assert response.status_code == 200
data = response.json()
for shop in data["shops"]:
assert shop["is_active"] is True
# Test verified_only filter
response = client.get("/api/v1/shop?verified_only=true", headers=auth_headers)
assert response.status_code == 200
# Response should only contain verified shops
def test_get_shop_by_code_success(self, client, auth_headers, test_shop):
"""Test getting specific shop successfully"""
response = client.get(
f"/api/v1/shop/{test_shop.shop_code}", headers=auth_headers
)
assert response.status_code == 200
data = response.json()
assert data["shop_code"] == test_shop.shop_code
assert data["shop_name"] == test_shop.shop_name
def test_get_shop_by_code_not_found(self, client, auth_headers):
"""Test getting nonexistent shop returns ShopNotFoundException"""
response = client.get("/api/v1/shop/NONEXISTENT", headers=auth_headers)
assert response.status_code == 404
data = response.json()
assert data["error_code"] == "SHOP_NOT_FOUND"
assert data["status_code"] == 404
assert "NONEXISTENT" in data["message"]
assert data["details"]["resource_type"] == "Shop"
assert data["details"]["identifier"] == "NONEXISTENT"
def test_get_shop_unauthorized_access(self, client, auth_headers, test_shop, other_user, db):
"""Test accessing shop owned by another user returns UnauthorizedShopAccessException"""
# Change shop owner to other user AND make it unverified/inactive
# so that non-owner users cannot access it
test_shop.owner_id = other_user.id
test_shop.is_verified = False # Make it not publicly accessible
db.commit()
response = client.get(
f"/api/v1/shop/{test_shop.shop_code}", headers=auth_headers
)
assert response.status_code == 403
data = response.json()
assert data["error_code"] == "UNAUTHORIZED_SHOP_ACCESS"
assert data["status_code"] == 403
assert test_shop.shop_code in data["message"]
assert data["details"]["shop_code"] == test_shop.shop_code
def test_get_shop_unauthorized_access_with_inactive_shop(self, client, auth_headers, inactive_shop):
"""Test accessing inactive shop owned by another user returns UnauthorizedShopAccessException"""
# inactive_shop fixture already creates an unverified, inactive shop owned by other_user
response = client.get(
f"/api/v1/shop/{inactive_shop.shop_code}", headers=auth_headers
)
assert response.status_code == 403
data = response.json()
assert data["error_code"] == "UNAUTHORIZED_SHOP_ACCESS"
assert data["status_code"] == 403
assert inactive_shop.shop_code in data["message"]
assert data["details"]["shop_code"] == inactive_shop.shop_code
def test_get_shop_public_access_allowed(self, client, auth_headers, verified_shop):
"""Test accessing verified shop owned by another user is allowed (public access)"""
# verified_shop fixture creates a verified, active shop owned by other_user
# This should allow public access per your business logic
response = client.get(
f"/api/v1/shop/{verified_shop.shop_code}", headers=auth_headers
)
assert response.status_code == 200
data = response.json()
assert data["shop_code"] == verified_shop.shop_code
assert data["shop_name"] == verified_shop.shop_name
def test_add_product_to_shop_success(self, client, auth_headers, test_shop, unique_product):
"""Test adding product to shop successfully"""
product_data = {
"marketplace_product_id": unique_product.marketplace_product_id, # Use string marketplace_product_id, not database id
"price": 29.99,
"is_active": True,
"is_featured": False,
}
response = client.post(
f"/api/v1/shop/{test_shop.shop_code}/products",
headers=auth_headers,
json=product_data
)
assert response.status_code == 200
data = response.json()
# The response structure contains nested product data
assert data["shop_id"] == test_shop.id
assert data["price"] == 29.99
assert data["is_active"] is True
assert data["is_featured"] is False
# MarketplaceProduct details are nested in the 'marketplace_product' field
assert "marketplace_product" in data
assert data["marketplace_product"]["marketplace_product_id"] == unique_product.marketplace_product_id
assert data["marketplace_product"]["id"] == unique_product.id
def test_add_product_to_shop_already_exists_conflict(self, client, auth_headers, test_shop, test_product):
"""Test adding product that already exists in shop returns ProductAlreadyExistsException"""
# test_product fixture already creates a relationship, get the marketplace_product_id string
existing_product = test_product.marketplace_product
product_data = {
"marketplace_product_id": existing_product.marketplace_product_id, # Use string marketplace_product_id
"shop_price": 29.99,
}
response = client.post(
f"/api/v1/shop/{test_shop.shop_code}/products",
headers=auth_headers,
json=product_data
)
assert response.status_code == 409
data = response.json()
assert data["error_code"] == "PRODUCT_ALREADY_EXISTS"
assert data["status_code"] == 409
assert test_shop.shop_code in data["message"]
assert existing_product.marketplace_product_id in data["message"]
def test_add_nonexistent_product_to_shop_not_found(self, client, auth_headers, test_shop):
"""Test adding nonexistent product to shop returns MarketplaceProductNotFoundException"""
product_data = {
"marketplace_product_id": "NONEXISTENT_PRODUCT", # Use string marketplace_product_id that doesn't exist
"shop_price": 29.99,
}
response = client.post(
f"/api/v1/shop/{test_shop.shop_code}/products",
headers=auth_headers,
json=product_data
)
assert response.status_code == 404
data = response.json()
assert data["error_code"] == "PRODUCT_NOT_FOUND"
assert data["status_code"] == 404
assert "NONEXISTENT_PRODUCT" in data["message"]
def test_get_products_success(self, client, auth_headers, test_shop, test_product):
"""Test getting shop products successfully"""
response = client.get(
f"/api/v1/shop/{test_shop.shop_code}/products",
headers=auth_headers
)
assert response.status_code == 200
data = response.json()
assert data["total"] >= 1
assert len(data["products"]) >= 1
assert "shop" in data
assert data["shop"]["shop_code"] == test_shop.shop_code
def test_get_products_with_filters(self, client, auth_headers, test_shop):
"""Test getting shop products with filtering"""
# Test active_only filter
response = client.get(
f"/api/v1/shop/{test_shop.shop_code}/products?active_only=true",
headers=auth_headers
)
assert response.status_code == 200
# Test featured_only filter
response = client.get(
f"/api/v1/shop/{test_shop.shop_code}/products?featured_only=true",
headers=auth_headers
)
assert response.status_code == 200
def test_get_products_from_nonexistent_shop_not_found(self, client, auth_headers):
"""Test getting products from nonexistent shop returns ShopNotFoundException"""
response = client.get(
"/api/v1/shop/NONEXISTENT/products",
headers=auth_headers
)
assert response.status_code == 404
data = response.json()
assert data["error_code"] == "SHOP_NOT_FOUND"
assert data["status_code"] == 404
assert "NONEXISTENT" in data["message"]
def test_shop_not_active_business_logic_error(self, client, auth_headers, test_shop, db):
"""Test accessing inactive shop returns ShopNotActiveException (if enforced)"""
# Set shop to inactive
test_shop.is_active = False
db.commit()
# Depending on your business logic, this might return an error
response = client.get(
f"/api/v1/shop/{test_shop.shop_code}", headers=auth_headers
)
# If your service enforces active shop requirement
if response.status_code == 400:
data = response.json()
assert data["error_code"] == "SHOP_NOT_ACTIVE"
assert data["status_code"] == 400
assert test_shop.shop_code in data["message"]
def test_shop_not_verified_business_logic_error(self, client, auth_headers, test_shop, db):
"""Test operations requiring verification returns ShopNotVerifiedException (if enforced)"""
# Set shop to unverified
test_shop.is_verified = False
db.commit()
# Test adding products (might require verification)
product_data = {
"marketplace_product_id": 1,
"shop_price": 29.99,
}
response = client.post(
f"/api/v1/shop/{test_shop.shop_code}/products",
headers=auth_headers,
json=product_data
)
# If your service requires verification for adding products
if response.status_code == 400:
data = response.json()
assert data["error_code"] == "SHOP_NOT_VERIFIED"
assert data["status_code"] == 400
assert test_shop.shop_code in data["message"]
def test_get_shop_without_auth_returns_invalid_token(self, client):
"""Test that shop endpoints require authentication returns InvalidTokenException"""
response = client.get("/api/v1/shop")
assert response.status_code == 401
data = response.json()
assert data["error_code"] == "INVALID_TOKEN"
assert data["status_code"] == 401
def test_pagination_validation_errors(self, client, auth_headers):
"""Test pagination parameter validation"""
# Test negative skip
response = client.get("/api/v1/shop?skip=-1", headers=auth_headers)
assert response.status_code == 422
data = response.json()
assert data["error_code"] == "VALIDATION_ERROR"
# Test zero limit
response = client.get("/api/v1/shop?limit=0", headers=auth_headers)
assert response.status_code == 422
data = response.json()
assert data["error_code"] == "VALIDATION_ERROR"
# Test excessive limit
response = client.get("/api/v1/shop?limit=10000", headers=auth_headers)
assert response.status_code == 422
data = response.json()
assert data["error_code"] == "VALIDATION_ERROR"
def test_exception_structure_consistency(self, client, auth_headers):
"""Test that all shop exceptions follow the consistent LetzShopException structure"""
# Test with a known error case
response = client.get("/api/v1/shop/NONEXISTENT", headers=auth_headers)
assert response.status_code == 404
data = response.json()
# Verify exception structure matches LetzShopException.to_dict()
required_fields = ["error_code", "message", "status_code"]
for field in required_fields:
assert field in data, f"Missing required field: {field}"
assert isinstance(data["error_code"], str)
assert isinstance(data["message"], str)
assert isinstance(data["status_code"], int)
# Details field should be present for domain-specific exceptions
if "details" in data:
assert isinstance(data["details"], dict)

View File

@@ -15,7 +15,7 @@ class TestStatsAPI:
assert "unique_brands" in data
assert "unique_categories" in data
assert "unique_marketplaces" in data
assert "unique_shops" in data
assert "unique_vendors" in data
assert data["total_products"] >= 1
def test_get_marketplace_stats(self, client, auth_headers, test_marketplace_product):

View File

@@ -0,0 +1,389 @@
# tests/integration/api/v1/test_vendor_endpoints.py
import pytest
@pytest.mark.integration
@pytest.mark.api
@pytest.mark.vendors
class TestVendorsAPI:
def test_create_vendor_success(self, client, auth_headers):
"""Test creating a new vendor successfully"""
vendor_data = {
"vendor_code": "NEWVENDOR001",
"vendor_name": "New Vendor",
"description": "A new test vendor ",
}
response = client.post("/api/v1/vendor ", headers=auth_headers, json=vendor_data)
assert response.status_code == 200
data = response.json()
assert data["vendor_code"] == "NEWVENDOR001"
assert data["vendor_name"] == "New Vendor"
assert data["is_active"] is True
def test_create_vendor_duplicate_code_returns_conflict(self, client, auth_headers, test_vendor):
"""Test creating vendor with duplicate code returns VendorAlreadyExistsException"""
vendor_data = {
"vendor_code": test_vendor.vendor_code,
"vendor_name": "Different Name",
"description": "Different description",
}
response = client.post("/api/v1/vendor ", headers=auth_headers, json=vendor_data)
assert response.status_code == 409
data = response.json()
assert data["error_code"] == "VENDOR_ALREADY_EXISTS"
assert data["status_code"] == 409
assert test_vendor.vendor_code in data["message"]
assert data["details"]["vendor_code"] == test_vendor.vendor_code
def test_create_vendor_missing_vendor_code_validation_error(self, client, auth_headers):
"""Test creating vendor without vendor_code returns ValidationException"""
vendor_data = {
"vendor_name": "Vendor without Code",
"description": "Missing vendor code",
}
response = client.post("/api/v1/vendor ", headers=auth_headers, json=vendor_data)
assert response.status_code == 422
data = response.json()
assert data["error_code"] == "VALIDATION_ERROR"
assert data["status_code"] == 422
assert "Request validation failed" in data["message"]
assert "validation_errors" in data["details"]
def test_create_vendor_empty_vendor_name_validation_error(self, client, auth_headers):
"""Test creating vendor with empty vendor_name returns VendorValidationException"""
vendor_data = {
"vendor_code": "EMPTYNAME",
"vendor_name": "", # Empty vendor name
"description": "Vendor with empty name",
}
response = client.post("/api/v1/vendor ", headers=auth_headers, json=vendor_data)
assert response.status_code == 422
data = response.json()
assert data["error_code"] == "INVALID_VENDOR_DATA"
assert data["status_code"] == 422
assert "Vendor name is required" in data["message"]
assert data["details"]["field"] == "vendor_name"
def test_create_vendor_max_vendors_reached_business_logic_error(self, client, auth_headers, db, test_user):
"""Test creating vendor when max vendors reached returns MaxVendorsReachedException"""
# This test would require creating the maximum allowed vendors first
# The exact implementation depends on your business rules
# For now, we'll test the structure of what the error should look like
# In a real scenario, you'd create max_vendors number of vendors first
# Assuming max vendors is enforced at service level
# This test validates the expected response structure
pass # Implementation depends on your max_vendors business logic
def test_get_vendors_success(self, client, auth_headers, test_vendor):
"""Test getting vendors list successfully"""
response = client.get("/api/v1/vendor ", headers=auth_headers)
assert response.status_code == 200
data = response.json()
assert data["total"] >= 1
assert len(data["vendors"]) >= 1
# Find our test vendor
test_vendor_found = any(s["vendor_code"] == test_vendor.vendor_code for s in data["vendors"])
assert test_vendor_found
def test_get_vendors_with_filters(self, client, auth_headers, test_vendor):
"""Test getting vendors with filtering options"""
# Test active_only filter
response = client.get("/api/v1/vendor ?active_only=true", headers=auth_headers)
assert response.status_code == 200
data = response.json()
for vendor in data["vendors"]:
assert vendor ["is_active"] is True
# Test verified_only filter
response = client.get("/api/v1/vendor ?verified_only=true", headers=auth_headers)
assert response.status_code == 200
# Response should only contain verified vendors
def test_get_vendor_by_code_success(self, client, auth_headers, test_vendor):
"""Test getting specific vendor successfully"""
response = client.get(
f"/api/v1/vendor /{test_vendor.vendor_code}", headers=auth_headers
)
assert response.status_code == 200
data = response.json()
assert data["vendor_code"] == test_vendor.vendor_code
assert data["vendor_name"] == test_vendor.vendor_name
def test_get_vendor_by_code_not_found(self, client, auth_headers):
"""Test getting nonexistent vendor returns VendorNotFoundException"""
response = client.get("/api/v1/vendor /NONEXISTENT", headers=auth_headers)
assert response.status_code == 404
data = response.json()
assert data["error_code"] == "VENDOR_NOT_FOUND"
assert data["status_code"] == 404
assert "NONEXISTENT" in data["message"]
assert data["details"]["resource_type"] == "Vendor"
assert data["details"]["identifier"] == "NONEXISTENT"
def test_get_vendor_unauthorized_access(self, client, auth_headers, test_vendor, other_user, db):
"""Test accessing vendor owned by another user returns UnauthorizedVendorAccessException"""
# Change vendor owner to other user AND make it unverified/inactive
# so that non-owner users cannot access it
test_vendor.owner_id = other_user.id
test_vendor.is_verified = False # Make it not publicly accessible
db.commit()
response = client.get(
f"/api/v1/vendor /{test_vendor.vendor_code}", headers=auth_headers
)
assert response.status_code == 403
data = response.json()
assert data["error_code"] == "UNAUTHORIZED_VENDOR_ACCESS"
assert data["status_code"] == 403
assert test_vendor.vendor_code in data["message"]
assert data["details"]["vendor_code"] == test_vendor.vendor_code
def test_get_vendor_unauthorized_access_with_inactive_vendor(self, client, auth_headers, inactive_vendor):
"""Test accessing inactive vendor owned by another user returns UnauthorizedVendorAccessException"""
# inactive_vendor fixture already creates an unverified, inactive vendor owned by other_user
response = client.get(
f"/api/v1/vendor /{inactive_vendor.vendor_code}", headers=auth_headers
)
assert response.status_code == 403
data = response.json()
assert data["error_code"] == "UNAUTHORIZED_VENDOR_ACCESS"
assert data["status_code"] == 403
assert inactive_vendor.vendor_code in data["message"]
assert data["details"]["vendor_code"] == inactive_vendor.vendor_code
def test_get_vendor_public_access_allowed(self, client, auth_headers, verified_vendor):
"""Test accessing verified vendor owned by another user is allowed (public access)"""
# verified_vendor fixture creates a verified, active vendor owned by other_user
# This should allow public access per your business logic
response = client.get(
f"/api/v1/vendor /{verified_vendor.vendor_code}", headers=auth_headers
)
assert response.status_code == 200
data = response.json()
assert data["vendor_code"] == verified_vendor.vendor_code
assert data["vendor_name"] == verified_vendor.vendor_name
def test_add_product_to_vendor_success(self, client, auth_headers, test_vendor, unique_product):
"""Test adding product to vendor successfully"""
product_data = {
"marketplace_product_id": unique_product.marketplace_product_id, # Use string marketplace_product_id, not database id
"price": 29.99,
"is_active": True,
"is_featured": False,
}
response = client.post(
f"/api/v1/vendor /{test_vendor.vendor_code}/products",
headers=auth_headers,
json=product_data
)
assert response.status_code == 200
data = response.json()
# The response structure contains nested product data
assert data["vendor_id"] == test_vendor.id
assert data["price"] == 29.99
assert data["is_active"] is True
assert data["is_featured"] is False
# MarketplaceProduct details are nested in the 'marketplace_product' field
assert "marketplace_product" in data
assert data["marketplace_product"]["marketplace_product_id"] == unique_product.marketplace_product_id
assert data["marketplace_product"]["id"] == unique_product.id
def test_add_product_to_vendor_already_exists_conflict(self, client, auth_headers, test_vendor, test_product):
"""Test adding product that already exists in vendor returns ProductAlreadyExistsException"""
# test_product fixture already creates a relationship, get the marketplace_product_id string
existing_product = test_product.marketplace_product
product_data = {
"marketplace_product_id": existing_product.marketplace_product_id, # Use string marketplace_product_id
"price": 29.99,
}
response = client.post(
f"/api/v1/vendor /{test_vendor.vendor_code}/products",
headers=auth_headers,
json=product_data
)
assert response.status_code == 409
data = response.json()
assert data["error_code"] == "PRODUCT_ALREADY_EXISTS"
assert data["status_code"] == 409
assert test_vendor.vendor_code in data["message"]
assert existing_product.marketplace_product_id in data["message"]
def test_add_nonexistent_product_to_vendor_not_found(self, client, auth_headers, test_vendor):
"""Test adding nonexistent product to vendor returns MarketplaceProductNotFoundException"""
product_data = {
"marketplace_product_id": "NONEXISTENT_PRODUCT", # Use string marketplace_product_id that doesn't exist
"price": 29.99,
}
response = client.post(
f"/api/v1/vendor /{test_vendor.vendor_code}/products",
headers=auth_headers,
json=product_data
)
assert response.status_code == 404
data = response.json()
assert data["error_code"] == "PRODUCT_NOT_FOUND"
assert data["status_code"] == 404
assert "NONEXISTENT_PRODUCT" in data["message"]
def test_get_products_success(self, client, auth_headers, test_vendor, test_product):
"""Test getting vendor products successfully"""
response = client.get(
f"/api/v1/vendor /{test_vendor.vendor_code}/products",
headers=auth_headers
)
assert response.status_code == 200
data = response.json()
assert data["total"] >= 1
assert len(data["products"]) >= 1
assert "vendor " in data
assert data["vendor "]["vendor_code"] == test_vendor.vendor_code
def test_get_products_with_filters(self, client, auth_headers, test_vendor):
"""Test getting vendor products with filtering"""
# Test active_only filter
response = client.get(
f"/api/v1/vendor /{test_vendor.vendor_code}/products?active_only=true",
headers=auth_headers
)
assert response.status_code == 200
# Test featured_only filter
response = client.get(
f"/api/v1/vendor /{test_vendor.vendor_code}/products?featured_only=true",
headers=auth_headers
)
assert response.status_code == 200
def test_get_products_from_nonexistent_vendor_not_found(self, client, auth_headers):
"""Test getting products from nonexistent vendor returns VendorNotFoundException"""
response = client.get(
"/api/v1/vendor /NONEXISTENT/products",
headers=auth_headers
)
assert response.status_code == 404
data = response.json()
assert data["error_code"] == "VENDOR_NOT_FOUND"
assert data["status_code"] == 404
assert "NONEXISTENT" in data["message"]
def test_vendor_not_active_business_logic_error(self, client, auth_headers, test_vendor, db):
"""Test accessing inactive vendor returns VendorNotActiveException (if enforced)"""
# Set vendor to inactive
test_vendor.is_active = False
db.commit()
# Depending on your business logic, this might return an error
response = client.get(
f"/api/v1/vendor /{test_vendor.vendor_code}", headers=auth_headers
)
# If your service enforces active vendor requirement
if response.status_code == 400:
data = response.json()
assert data["error_code"] == "VENDOR_NOT_ACTIVE"
assert data["status_code"] == 400
assert test_vendor.vendor_code in data["message"]
def test_vendor_not_verified_business_logic_error(self, client, auth_headers, test_vendor, db):
"""Test operations requiring verification returns VendorNotVerifiedException (if enforced)"""
# Set vendor to unverified
test_vendor.is_verified = False
db.commit()
# Test adding products (might require verification)
product_data = {
"marketplace_product_id": 1,
"price": 29.99,
}
response = client.post(
f"/api/v1/vendor /{test_vendor.vendor_code}/products",
headers=auth_headers,
json=product_data
)
# If your service requires verification for adding products
if response.status_code == 400:
data = response.json()
assert data["error_code"] == "VENDOR_NOT_VERIFIED"
assert data["status_code"] == 400
assert test_vendor.vendor_code in data["message"]
def test_get_vendor_without_auth_returns_invalid_token(self, client):
"""Test that vendor endpoints require authentication returns InvalidTokenException"""
response = client.get("/api/v1/vendor ")
assert response.status_code == 401
data = response.json()
assert data["error_code"] == "INVALID_TOKEN"
assert data["status_code"] == 401
def test_pagination_validation_errors(self, client, auth_headers):
"""Test pagination parameter validation"""
# Test negative skip
response = client.get("/api/v1/vendor ?skip=-1", headers=auth_headers)
assert response.status_code == 422
data = response.json()
assert data["error_code"] == "VALIDATION_ERROR"
# Test zero limit
response = client.get("/api/v1/vendor ?limit=0", headers=auth_headers)
assert response.status_code == 422
data = response.json()
assert data["error_code"] == "VALIDATION_ERROR"
# Test excessive limit
response = client.get("/api/v1/vendor ?limit=10000", headers=auth_headers)
assert response.status_code == 422
data = response.json()
assert data["error_code"] == "VALIDATION_ERROR"
def test_exception_structure_consistency(self, client, auth_headers):
"""Test that all vendor exceptions follow the consistent LetzShopException structure"""
# Test with a known error case
response = client.get("/api/v1/vendor /NONEXISTENT", headers=auth_headers)
assert response.status_code == 404
data = response.json()
# Verify exception structure matches LetzShopException.to_dict()
required_fields = ["error_code", "message", "status_code"]
for field in required_fields:
assert field in data, f"Missing required field: {field}"
assert isinstance(data["error_code"], str)
assert isinstance(data["message"], str)
assert isinstance(data["status_code"], int)
# Details field should be present for domain-specific exceptions
if "details" in data:
assert isinstance(data["details"], dict)

View File

@@ -10,10 +10,10 @@ class TestAuthentication:
"""Test that protected endpoints reject unauthenticated requests"""
protected_endpoints = [
"/api/v1/admin/users",
"/api/v1/admin/shops",
"/api/v1/admin/vendors",
"/api/v1/marketplace/import-jobs",
"/api/v1/marketplace/product",
"/api/v1/shop",
"/api/v1/vendor ",
"/api/v1/stats",
"/api/v1/stock",
]

View File

@@ -16,7 +16,7 @@ class TestAuthorization:
"""Test that admin users can access admin endpoints"""
admin_endpoints = [
"/api/v1/admin/users",
"/api/v1/admin/shops",
"/api/v1/admin/vendors",
"/api/v1/admin/marketplace-import-jobs",
]
@@ -36,15 +36,15 @@ class TestAuthorization:
response = client.get(endpoint, headers=auth_headers)
assert response.status_code == 200 # Regular user should have access
def test_shop_owner_access_control(
self, client, auth_headers, test_shop, other_user
def test_vendor_owner_access_control(
self, client, auth_headers, test_vendor, other_user
):
"""Test that users can only access their own shops"""
# Test accessing own shop (should work)
"""Test that users can only access their own vendors"""
# Test accessing own vendor (should work)
response = client.get(
f"/api/v1/shop/{test_shop.shop_code}", headers=auth_headers
f"/api/v1/vendor /{test_vendor.vendor_code}", headers=auth_headers
)
# Response depends on your implementation - could be 200 or 404 if shop doesn't belong to user
# Response depends on your implementation - could be 200 or 404 if vendor doesn't belong to user
# The exact assertion depends on your shop access control implementation
# The exact assertion depends on your vendor access control implementation
assert response.status_code in [200, 403, 404]

View File

@@ -13,15 +13,15 @@ from models.database.marketplace_import_job import MarketplaceImportJob
@pytest.mark.marketplace
class TestBackgroundTasks:
@pytest.mark.asyncio
async def test_marketplace_import_success(self, db, test_user, test_shop):
async def test_marketplace_import_success(self, db, test_user, test_vendor):
"""Test successful marketplace import background task"""
# Create import job
job = MarketplaceImportJob(
status="pending",
source_url="http://example.com/test.csv",
shop_name="TESTSHOP",
vendor_name="TESTSHOP",
marketplace="TestMarket",
shop_id=test_shop.id,
vendor_id=test_vendor.id,
user_id=test_user.id,
)
db.add(job)
@@ -67,15 +67,15 @@ class TestBackgroundTasks:
assert updated_job.completed_at is not None
@pytest.mark.asyncio
async def test_marketplace_import_failure(self, db, test_user, test_shop):
async def test_marketplace_import_failure(self, db, test_user, test_vendor):
"""Test marketplace import failure handling"""
# Create import job
job = MarketplaceImportJob(
status="pending",
source_url="http://example.com/test.csv",
shop_name="TESTSHOP",
vendor_name="TESTSHOP",
marketplace="TestMarket",
shop_id=test_shop.id,
vendor_id=test_vendor.id,
user_id=test_user.id,
)
db.add(job)
@@ -151,15 +151,15 @@ class TestBackgroundTasks:
mock_instance.process_marketplace_csv_from_url.assert_not_called()
@pytest.mark.asyncio
async def test_marketplace_import_with_errors(self, db, test_user, test_shop):
async def test_marketplace_import_with_errors(self, db, test_user, test_vendor):
"""Test marketplace import with some errors"""
# Create import job
job = MarketplaceImportJob(
status="pending",
source_url="http://example.com/test.csv",
shop_name="TESTSHOP",
vendor_name="TESTSHOP",
marketplace="TestMarket",
shop_id=test_shop.id,
vendor_id=test_vendor.id,
user_id=test_user.id,
)
db.add(job)

View File

@@ -61,22 +61,22 @@ class TestIntegrationFlows:
assert response.json()["total"] == 1
def test_product_workflow(self, client, auth_headers):
"""Test shop creation and product management workflow"""
# 1. Create a shop
shop_data = {
"shop_code": "FLOWSHOP",
"shop_name": "Integration Flow Shop",
"description": "Test shop for integration",
"""Test vendor creation and product management workflow"""
# 1. Create a vendor
vendor_data = {
"vendor_code": "FLOWSHOP",
"vendor_name": "Integration Flow Shop",
"description": "Test vendor for integration",
}
response = client.post("/api/v1/shop", headers=auth_headers, json=shop_data)
response = client.post("/api/v1/vendor ", headers=auth_headers, json=vendor_data)
assert response.status_code == 200
shop = response.json()
vendor = response.json()
# 2. Create a product
product_data = {
"marketplace_product_id": "SHOPFLOW001",
"title": "Shop Flow MarketplaceProduct",
"title": "Vendor Flow MarketplaceProduct",
"price": "15.99",
"marketplace": "ShopFlow",
}
@@ -87,11 +87,11 @@ class TestIntegrationFlows:
assert response.status_code == 200
product = response.json()
# 3. Add product to shop (if endpoint exists)
# This would test the shop-product association
# 3. Add product to vendor (if endpoint exists)
# This would test the vendor -product association
# 4. Get shop details
response = client.get(f"/api/v1/shop/{shop['shop_code']}", headers=auth_headers)
# 4. Get vendor details
response = client.get(f"/api/v1/vendor /{vendor ['vendor_code']}", headers=auth_headers)
assert response.status_code == 200
def test_stock_operations_workflow(self, client, auth_headers):

View File

@@ -1,6 +1,6 @@
# tests/system/test_error_handling.py
"""
System tests for error handling across the LetzShop API.
System tests for error handling across the LetzVendor API.
Tests the complete error handling flow from FastAPI through custom exception handlers
to ensure proper HTTP status codes, error structures, and client-friendly responses.
@@ -16,7 +16,7 @@ class TestErrorHandling:
def test_invalid_json_request(self, client, auth_headers):
"""Test handling of malformed JSON requests"""
response = client.post(
"/api/v1/shop",
"/api/v1/vendor ",
headers=auth_headers,
content="{ invalid json syntax"
)
@@ -27,13 +27,13 @@ class TestErrorHandling:
assert data["message"] == "Request validation failed"
assert "validation_errors" in data["details"]
def test_missing_required_fields_shop_creation(self, client, auth_headers):
def test_missing_required_fields_vendor_creation(self, client, auth_headers):
"""Test validation errors for missing required fields"""
# Missing shop_name
# Missing vendor_name
response = client.post(
"/api/v1/shop",
"/api/v1/vendor ",
headers=auth_headers,
json={"shop_code": "TESTSHOP"}
json={"vendor_code": "TESTSHOP"}
)
assert response.status_code == 422
@@ -42,28 +42,28 @@ class TestErrorHandling:
assert data["status_code"] == 422
assert "validation_errors" in data["details"]
def test_invalid_field_format_shop_creation(self, client, auth_headers):
def test_invalid_field_format_vendor_creation(self, client, auth_headers):
"""Test validation errors for invalid field formats"""
# Invalid shop_code format (contains special characters)
# Invalid vendor_code format (contains special characters)
response = client.post(
"/api/v1/shop",
"/api/v1/vendor ",
headers=auth_headers,
json={
"shop_code": "INVALID@SHOP!",
"shop_name": "Test Shop"
"vendor_code": "INVALID@SHOP!",
"vendor_name": "Test Shop"
}
)
assert response.status_code == 422
data = response.json()
assert data["error_code"] == "INVALID_SHOP_DATA"
assert data["error_code"] == "INVALID_VENDOR_DATA"
assert data["status_code"] == 422
assert data["details"]["field"] == "shop_code"
assert data["details"]["field"] == "vendor_code"
assert "letters, numbers, underscores, and hyphens" in data["message"]
def test_missing_authentication_token(self, client):
"""Test authentication required endpoints without token"""
response = client.get("/api/v1/shop")
response = client.get("/api/v1/vendor ")
assert response.status_code == 401
data = response.json()
@@ -73,7 +73,7 @@ class TestErrorHandling:
def test_invalid_authentication_token(self, client):
"""Test endpoints with invalid JWT token"""
headers = {"Authorization": "Bearer invalid_token_here"}
response = client.get("/api/v1/shop", headers=headers)
response = client.get("/api/v1/vendor ", headers=headers)
assert response.status_code == 401
data = response.json()
@@ -85,19 +85,19 @@ class TestErrorHandling:
# This would require creating an expired token for testing
expired_token = "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.expired.token"
headers = {"Authorization": f"Bearer {expired_token}"}
response = client.get("/api/v1/shop", headers=headers)
response = client.get("/api/v1/vendor ", headers=headers)
assert response.status_code == 401
data = response.json()
assert data["status_code"] == 401
def test_shop_not_found(self, client, auth_headers):
"""Test accessing non-existent shop"""
response = client.get("/api/v1/shop/NONEXISTENT", headers=auth_headers)
def test_vendor_not_found(self, client, auth_headers):
"""Test accessing non-existent vendor """
response = client.get("/api/v1/vendor /NONEXISTENT", headers=auth_headers)
assert response.status_code == 404
data = response.json()
assert data["error_code"] == "SHOP_NOT_FOUND"
assert data["error_code"] == "VENDOR_NOT_FOUND"
assert data["status_code"] == 404
assert data["details"]["resource_type"] == "Shop"
assert data["details"]["identifier"] == "NONEXISTENT"
@@ -113,20 +113,20 @@ class TestErrorHandling:
assert data["details"]["resource_type"] == "MarketplaceProduct"
assert data["details"]["identifier"] == "NONEXISTENT"
def test_duplicate_shop_creation(self, client, auth_headers, test_shop):
"""Test creating shop with duplicate shop code"""
shop_data = {
"shop_code": test_shop.shop_code,
"shop_name": "Duplicate Shop"
def test_duplicate_vendor_creation(self, client, auth_headers, test_vendor):
"""Test creating vendor with duplicate vendor code"""
vendor_data = {
"vendor_code": test_vendor.vendor_code,
"vendor_name": "Duplicate Shop"
}
response = client.post("/api/v1/shop", headers=auth_headers, json=shop_data)
response = client.post("/api/v1/vendor ", headers=auth_headers, json=vendor_data)
assert response.status_code == 409
data = response.json()
assert data["error_code"] == "SHOP_ALREADY_EXISTS"
assert data["error_code"] == "VENDOR_ALREADY_EXISTS"
assert data["status_code"] == 409
assert data["details"]["shop_code"] == test_shop.shop_code.upper()
assert data["details"]["vendor_code"] == test_vendor.vendor_code.upper()
def test_duplicate_product_creation(self, client, auth_headers, test_marketplace_product):
"""Test creating product with duplicate product ID"""
@@ -144,15 +144,15 @@ class TestErrorHandling:
assert data["status_code"] == 409
assert data["details"]["marketplace_product_id"] == test_marketplace_product.marketplace_product_id
def test_unauthorized_shop_access(self, client, auth_headers, inactive_shop):
"""Test accessing shop without proper permissions"""
response = client.get(f"/api/v1/shop/{inactive_shop.shop_code}", headers=auth_headers)
def test_unauthorized_vendor_access(self, client, auth_headers, inactive_vendor):
"""Test accessing vendor without proper permissions"""
response = client.get(f"/api/v1/vendor /{inactive_vendor.vendor_code}", headers=auth_headers)
assert response.status_code == 403
data = response.json()
assert data["error_code"] == "UNAUTHORIZED_SHOP_ACCESS"
assert data["error_code"] == "UNAUTHORIZED_VENDOR_ACCESS"
assert data["status_code"] == 403
assert data["details"]["shop_code"] == inactive_shop.shop_code
assert data["details"]["vendor_code"] == inactive_vendor.vendor_code
def test_insufficient_permissions(self, client, auth_headers, admin_only_endpoint="/api/v1/admin/users"):
"""Test accessing admin endpoints with regular user"""
@@ -164,29 +164,29 @@ class TestErrorHandling:
assert data["error_code"] in ["ADMIN_REQUIRED", "INSUFFICIENT_PERMISSIONS"]
assert data["status_code"] == 403
def test_business_logic_violation_max_shops(self, client, auth_headers, monkeypatch):
"""Test business logic violation - creating too many shops"""
# This test would require mocking the shop limit check
# For now, test the error structure when creating multiple shops
shops_created = []
def test_business_logic_violation_max_vendors(self, client, auth_headers, monkeypatch):
"""Test business logic violation - creating too many vendors"""
# This test would require mocking the vendor limit check
# For now, test the error structure when creating multiple vendors
vendors_created = []
for i in range(6): # Assume limit is 5
shop_data = {
"shop_code": f"SHOP{i:03d}",
"shop_name": f"Test Shop {i}"
vendor_data = {
"vendor_code": f"SHOP{i:03d}",
"vendor_name": f"Test Vendor {i}"
}
response = client.post("/api/v1/shop", headers=auth_headers, json=shop_data)
shops_created.append(response)
response = client.post("/api/v1/vendor ", headers=auth_headers, json=vendor_data)
vendors_created.append(response)
# At least one should succeed, and if limit is enforced, later ones should fail
success_count = sum(1 for r in shops_created if r.status_code in [200, 201])
success_count = sum(1 for r in vendors_created if r.status_code in [200, 201])
assert success_count >= 1
# If any failed due to limit, check error structure
failed_responses = [r for r in shops_created if r.status_code == 400]
failed_responses = [r for r in vendors_created if r.status_code == 400]
if failed_responses:
data = failed_responses[0].json()
assert data["error_code"] == "MAX_SHOPS_REACHED"
assert "max_shops" in data["details"]
assert data["error_code"] == "MAX_VENDORS_REACHED"
assert "max_vendors" in data["details"]
def test_validation_error_invalid_gtin(self, client, auth_headers):
"""Test validation error for invalid GTIN format"""
@@ -204,7 +204,7 @@ class TestErrorHandling:
assert data["status_code"] == 422
assert data["details"]["field"] == "gtin"
def test_stock_insufficient_quantity(self, client, auth_headers, test_shop, test_marketplace_product):
def test_stock_insufficient_quantity(self, client, auth_headers, test_vendor, test_marketplace_product):
"""Test business logic error for insufficient stock"""
# First create some stock
stock_data = {
@@ -246,7 +246,7 @@ class TestErrorHandling:
def test_method_not_allowed(self, client, auth_headers):
"""Test 405 for wrong HTTP method on existing endpoints"""
# Try DELETE on an endpoint that only supports GET
response = client.delete("/api/v1/shop", headers=auth_headers)
response = client.delete("/api/v1/vendor ", headers=auth_headers)
assert response.status_code == 405
# FastAPI automatically handles 405 errors
@@ -255,9 +255,9 @@ class TestErrorHandling:
"""Test handling of unsupported content types"""
headers = {**auth_headers, "Content-Type": "application/xml"}
response = client.post(
"/api/v1/shop",
"/api/v1/vendor ",
headers=headers,
content="<shop><code>TEST</code></shop>"
content="<vendor ><code>TEST</code></vendor >"
)
assert response.status_code in [400, 415, 422]
@@ -265,13 +265,13 @@ class TestErrorHandling:
def test_large_payload_handling(self, client, auth_headers):
"""Test handling of unusually large payloads"""
large_description = "x" * 100000 # Very long description
shop_data = {
"shop_code": "LARGESHOP",
"shop_name": "Large Shop",
vendor_data = {
"vendor_code": "LARGESHOP",
"vendor_name": "Large Shop",
"description": large_description
}
response = client.post("/api/v1/shop", headers=auth_headers, json=shop_data)
response = client.post("/api/v1/vendor ", headers=auth_headers, json=vendor_data)
# Should either accept it or reject with appropriate error
assert response.status_code in [200, 201, 413, 422]
@@ -285,7 +285,7 @@ class TestErrorHandling:
# Make rapid requests to potentially trigger rate limiting
responses = []
for _ in range(50): # Aggressive request count
response = client.get("/api/v1/shop", headers=auth_headers)
response = client.get("/api/v1/vendor ", headers=auth_headers)
responses.append(response)
# Check if any rate limiting occurred and verify error structure
@@ -305,12 +305,12 @@ class TestErrorHandling:
response = client.get("/health")
assert response.status_code == 200
def test_marketplace_import_errors(self, client, auth_headers, test_shop):
def test_marketplace_import_errors(self, client, auth_headers, test_vendor):
"""Test marketplace import specific errors"""
# Test invalid marketplace
import_data = {
"marketplace": "INVALID_MARKETPLACE",
"shop_code": test_shop.shop_code
"vendor_code": test_vendor.vendor_code
}
response = client.post("/api/v1/imports", headers=auth_headers, json=import_data)
@@ -344,7 +344,7 @@ class TestErrorHandling:
def test_error_response_consistency(self, client, auth_headers):
"""Test that all error responses follow consistent structure"""
test_cases = [
("/api/v1/shop/NONEXISTENT", 404),
("/api/v1/vendor /NONEXISTENT", 404),
("/api/v1/marketplace/product/NONEXISTENT", 404),
]
@@ -365,7 +365,7 @@ class TestErrorHandling:
def test_cors_error_handling(self, client):
"""Test CORS errors are handled properly"""
# Test preflight request
response = client.options("/api/v1/shop")
response = client.options("/api/v1/vendor ")
# Should either succeed or be handled gracefully
assert response.status_code in [200, 204, 405]
@@ -373,7 +373,7 @@ class TestErrorHandling:
def test_authentication_error_details(self, client):
"""Test authentication error provides helpful details"""
# Test missing Authorization header
response = client.get("/api/v1/shop")
response = client.get("/api/v1/vendor ")
assert response.status_code == 401
data = response.json()
@@ -406,7 +406,7 @@ class TestErrorRecovery:
assert health_response.status_code == 200
# API endpoints may or may not work depending on system state
api_response = client.get("/api/v1/shop", headers=auth_headers)
api_response = client.get("/api/v1/vendor ", headers=auth_headers)
# Should get either data or a proper error, not a crash
assert api_response.status_code in [200, 401, 403, 500, 503]
@@ -416,7 +416,7 @@ class TestErrorRecovery:
with caplog.at_level(logging.ERROR):
# Trigger an error
client.get("/api/v1/shop/NONEXISTENT", headers=auth_headers)
client.get("/api/v1/vendor /NONEXISTENT", headers=auth_headers)
# Check that error was logged (if your app logs 404s as errors)
# Adjust based on your logging configuration

View File

@@ -2,7 +2,7 @@
import pytest
from models.database.marketplace_product import MarketplaceProduct
from models.database.shop import Shop
from models.database.vendor import Vendor
from models.database.stock import Stock
from models.database.user import User
@@ -41,7 +41,7 @@ class TestDatabaseModels:
gtin="1234567890123",
availability="in stock",
marketplace="TestDB",
shop_name="DBTestShop",
vendor_name="DBTestVendor",
)
db.add(marketplace_product)
@@ -65,25 +65,25 @@ class TestDatabaseModels:
assert stock.location == "DB_WAREHOUSE"
assert stock.quantity == 150
def test_shop_model_with_owner(self, db, test_user):
"""Test Shop model with owner relationship"""
shop = Shop(
shop_code="DBTEST",
shop_name="Database Test Shop",
description="Testing shop model",
def test_vendor_model_with_owner(self, db, test_user):
"""Test Vendor model with owner relationship"""
vendor = Vendor(
vendor_code="DBTEST",
vendor_name="Database Test Vendor",
description="Testing vendor model",
owner_id=test_user.id,
is_active=True,
is_verified=False,
)
db.add(shop)
db.add(vendor)
db.commit()
db.refresh(shop)
db.refresh(vendor)
assert shop.id is not None
assert shop.shop_code == "DBTEST"
assert shop.owner_id == test_user.id
assert shop.owner.username == test_user.username
assert vendor.id is not None
assert vendor.vendor_code == "DBTEST"
assert vendor.owner_id == test_user.id
assert vendor.owner.username == test_user.username
def test_database_constraints(self, db):
"""Test database constraints and unique indexes"""

View File

@@ -5,13 +5,13 @@ from app.exceptions import (
UserNotFoundException,
UserStatusChangeException,
CannotModifySelfException,
ShopNotFoundException,
ShopVerificationException,
VendorNotFoundException,
VendorVerificationException,
AdminOperationException,
)
from app.services.admin_service import AdminService
from models.database.marketplace_import_job import MarketplaceImportJob
from models.database.shop import Shop
from models.database.vendor import Vendor
@pytest.mark.unit
@@ -93,80 +93,80 @@ class TestAdminService:
assert exception.error_code == "USER_STATUS_CHANGE_FAILED"
assert "Cannot modify another admin user" in exception.message
# Shop Management Tests
def test_get_all_shops(self, db, test_shop):
"""Test getting all shops with total count"""
shops, total = self.service.get_all_shops(db, skip=0, limit=10)
# Vendor Management Tests
def test_get_all_vendors(self, db, test_vendor):
"""Test getting all vendors with total count"""
vendors, total = self.service.get_all_vendors(db, skip=0, limit=10)
assert total >= 1
assert len(shops) >= 1
shop_codes = [shop.shop_code for shop in shops]
assert test_shop.shop_code in shop_codes
assert len(vendors) >= 1
vendor_codes = [vendor.vendor_code for vendor in vendors]
assert test_vendor.vendor_code in vendor_codes
def test_get_all_shops_with_pagination(self, db, test_shop, verified_shop):
"""Test shop pagination works correctly"""
shops, total = self.service.get_all_shops(db, skip=0, limit=1)
def test_get_all_vendors_with_pagination(self, db, test_vendor, verified_vendor):
"""Test vendor pagination works correctly"""
vendors, total = self.service.get_all_vendors(db, skip=0, limit=1)
assert total >= 2
assert len(shops) == 1
assert len(vendors) == 1
shops_second_page, _ = self.service.get_all_shops(db, skip=1, limit=1)
assert len(shops_second_page) >= 0
if len(shops_second_page) > 0:
assert shops[0].id != shops_second_page[0].id
vendors_second_page, _ = self.service.get_all_vendors(db, skip=1, limit=1)
assert len(vendors_second_page) >= 0
if len(vendors_second_page) > 0:
assert vendors[0].id != vendors_second_page[0].id
def test_verify_shop_mark_verified(self, db, test_shop):
"""Test marking shop as verified"""
# Ensure shop starts unverified
test_shop.is_verified = False
def test_verify_vendor_mark_verified(self, db, test_vendor):
"""Test marking vendor as verified"""
# Ensure vendor starts unverified
test_vendor.is_verified = False
db.commit()
shop, message = self.service.verify_shop(db, test_shop.id)
vendor, message = self.service.verify_vendor(db, test_vendor.id)
assert shop.id == test_shop.id
assert shop.is_verified is True
assert test_shop.shop_code in message
assert vendor.id == test_vendor.id
assert vendor.is_verified is True
assert test_vendor.vendor_code in message
assert "verified" in message
def test_verify_shop_mark_unverified(self, db, verified_shop):
"""Test marking verified shop as unverified"""
shop, message = self.service.verify_shop(db, verified_shop.id)
def test_verify_vendor_mark_unverified(self, db, verified_vendor):
"""Test marking verified vendor as unverified"""
vendor, message = self.service.verify_vendor(db, verified_vendor.id)
assert shop.id == verified_shop.id
assert shop.is_verified is False
assert verified_shop.shop_code in message
assert vendor.id == verified_vendor.id
assert vendor.is_verified is False
assert verified_vendor.vendor_code in message
assert "unverified" in message
def test_verify_shop_not_found(self, db):
"""Test verify shop when shop not found"""
with pytest.raises(ShopNotFoundException) as exc_info:
self.service.verify_shop(db, 99999)
def test_verify_vendor_not_found(self, db):
"""Test verify vendor when vendor not found"""
with pytest.raises(VendorNotFoundException) as exc_info:
self.service.verify_vendor(db, 99999)
exception = exc_info.value
assert exception.error_code == "SHOP_NOT_FOUND"
assert exception.error_code == "VENDOR_NOT_FOUND"
assert "99999" in exception.message
def test_toggle_shop_status_deactivate(self, db, test_shop):
"""Test deactivating a shop"""
original_status = test_shop.is_active
def test_toggle_vendor_status_deactivate(self, db, test_vendor):
"""Test deactivating a vendor """
original_status = test_vendor.is_active
shop, message = self.service.toggle_shop_status(db, test_shop.id)
vendor, message = self.service.toggle_vendor_status(db, test_vendor.id)
assert shop.id == test_shop.id
assert shop.is_active != original_status
assert test_shop.shop_code in message
assert vendor.id == test_vendor.id
assert vendor.is_active != original_status
assert test_vendor.vendor_code in message
if original_status:
assert "deactivated" in message
else:
assert "activated" in message
def test_toggle_shop_status_not_found(self, db):
"""Test toggle shop status when shop not found"""
with pytest.raises(ShopNotFoundException) as exc_info:
self.service.toggle_shop_status(db, 99999)
def test_toggle_vendor_status_not_found(self, db):
"""Test toggle vendor status when vendor not found"""
with pytest.raises(VendorNotFoundException) as exc_info:
self.service.toggle_vendor_status(db, 99999)
exception = exc_info.value
assert exception.error_code == "SHOP_NOT_FOUND"
assert exception.error_code == "VENDOR_NOT_FOUND"
# Marketplace Import Jobs Tests
def test_get_marketplace_import_jobs_no_filters(self, db, test_marketplace_import_job):
@@ -180,7 +180,7 @@ class TestAdminService:
)
assert test_job is not None
assert test_job.marketplace == test_marketplace_import_job.marketplace
assert test_job.shop_name == test_marketplace_import_job.shop_name
assert test_job.vendor_name == test_marketplace_import_job.vendor_name
assert test_job.status == test_marketplace_import_job.status
def test_get_marketplace_import_jobs_with_marketplace_filter(self, db, test_marketplace_import_job):
@@ -193,15 +193,15 @@ class TestAdminService:
for job in result:
assert test_marketplace_import_job.marketplace.lower() in job.marketplace.lower()
def test_get_marketplace_import_jobs_with_shop_filter(self, db, test_marketplace_import_job):
"""Test filtering marketplace import jobs by shop name"""
def test_get_marketplace_import_jobs_with_vendor_filter(self, db, test_marketplace_import_job):
"""Test filtering marketplace import jobs by vendor name"""
result = self.service.get_marketplace_import_jobs(
db, shop_name=test_marketplace_import_job.shop_name, skip=0, limit=10
db, vendor_name=test_marketplace_import_job.vendor_name, skip=0, limit=10
)
assert len(result) >= 1
for job in result:
assert test_marketplace_import_job.shop_name.lower() in job.shop_name.lower()
assert test_marketplace_import_job.vendor_name.lower() in job.vendor_name.lower()
def test_get_marketplace_import_jobs_with_status_filter(self, db, test_marketplace_import_job):
"""Test filtering marketplace import jobs by status"""
@@ -242,21 +242,21 @@ class TestAdminService:
assert stats["total_users"] >= 2 # test_user + test_admin
assert stats["active_users"] + stats["inactive_users"] == stats["total_users"]
def test_get_shop_statistics(self, db, test_shop):
"""Test getting shop statistics"""
stats = self.service.get_shop_statistics(db)
def test_get_vendor_statistics(self, db, test_vendor):
"""Test getting vendor statistics"""
stats = self.service.get_vendor_statistics(db)
assert "total_shops" in stats
assert "active_shops" in stats
assert "verified_shops" in stats
assert "total_vendors" in stats
assert "active_vendors" in stats
assert "verified_vendors" in stats
assert "verification_rate" in stats
assert isinstance(stats["total_shops"], int)
assert isinstance(stats["active_shops"], int)
assert isinstance(stats["verified_shops"], int)
assert isinstance(stats["total_vendors"], int)
assert isinstance(stats["active_vendors"], int)
assert isinstance(stats["verified_vendors"], int)
assert isinstance(stats["verification_rate"], (int, float))
assert stats["total_shops"] >= 1
assert stats["total_vendors"] >= 1
# Error Handling Tests
def test_get_all_users_database_error(self, db_with_error, test_admin):
@@ -268,14 +268,14 @@ class TestAdminService:
assert exception.error_code == "ADMIN_OPERATION_FAILED"
assert "get_all_users" in exception.message
def test_get_all_shops_database_error(self, db_with_error):
"""Test handling database errors in get_all_shops"""
def test_get_all_vendors_database_error(self, db_with_error):
"""Test handling database errors in get_all_vendors"""
with pytest.raises(AdminOperationException) as exc_info:
self.service.get_all_shops(db_with_error, skip=0, limit=10)
self.service.get_all_vendors(db_with_error, skip=0, limit=10)
exception = exc_info.value
assert exception.error_code == "ADMIN_OPERATION_FAILED"
assert "get_all_shops" in exception.message
assert "get_all_vendors" in exception.message
# Edge Cases
def test_get_all_users_empty_database(self, empty_db):
@@ -283,10 +283,10 @@ class TestAdminService:
users = self.service.get_all_users(empty_db, skip=0, limit=10)
assert len(users) == 0
def test_get_all_shops_empty_database(self, empty_db):
"""Test getting shops when database is empty"""
shops, total = self.service.get_all_shops(empty_db, skip=0, limit=10)
assert len(shops) == 0
def test_get_all_vendors_empty_database(self, empty_db):
"""Test getting vendors when database is empty"""
vendors, total = self.service.get_all_vendors(empty_db, skip=0, limit=10)
assert len(vendors) == 0
assert total == 0
def test_user_statistics_empty_database(self, empty_db):
@@ -298,11 +298,11 @@ class TestAdminService:
assert stats["inactive_users"] == 0
assert stats["activation_rate"] == 0
def test_shop_statistics_empty_database(self, empty_db):
"""Test shop statistics when no shops exist"""
stats = self.service.get_shop_statistics(empty_db)
def test_vendor_statistics_empty_database(self, empty_db):
"""Test vendor statistics when no vendors exist"""
stats = self.service.get_vendor_statistics(empty_db)
assert stats["total_shops"] == 0
assert stats["active_shops"] == 0
assert stats["verified_shops"] == 0
assert stats["total_vendors"] == 0
assert stats["active_vendors"] == 0
assert stats["verified_vendors"] == 0
assert stats["verification_rate"] == 0

View File

@@ -10,12 +10,12 @@ from app.exceptions.marketplace_import_job import (
ImportJobCannotBeCancelledException,
ImportJobCannotBeDeletedException,
)
from app.exceptions.shop import ShopNotFoundException, UnauthorizedShopAccessException
from app.exceptions.vendor import VendorNotFoundException, UnauthorizedVendorAccessException
from app.exceptions.base import ValidationException
from app.services.marketplace_import_job_service import MarketplaceImportJobService
from models.schemas.marketplace_import_job import MarketplaceImportJobRequest
from models.database.marketplace_import_job import MarketplaceImportJob
from models.database.shop import Shop
from models.database.vendor import Vendor
from models.database.user import User
@@ -25,107 +25,107 @@ class TestMarketplaceService:
def setup_method(self):
self.service = MarketplaceImportJobService()
def test_validate_shop_access_success(self, db, test_shop, test_user):
"""Test successful shop access validation"""
# Set the shop owner to the test user
test_shop.owner_id = test_user.id
def test_validate_vendor_access_success(self, db, test_vendor, test_user):
"""Test successful vendor access validation"""
# Set the vendor owner to the test user
test_vendor.owner_id = test_user.id
db.commit()
result = self.service.validate_shop_access(db, test_shop.shop_code, test_user)
result = self.service.validate_vendor_access(db, test_vendor.vendor_code, test_user)
assert result.shop_code == test_shop.shop_code
assert result.vendor_code == test_vendor.vendor_code
assert result.owner_id == test_user.id
def test_validate_shop_access_admin_can_access_any_shop(
self, db, test_shop, test_admin
def test_validate_vendor_access_admin_can_access_any_vendor(
self, db, test_vendor, test_admin
):
"""Test that admin users can access any shop"""
result = self.service.validate_shop_access(db, test_shop.shop_code, test_admin)
"""Test that admin users can access any vendor """
result = self.service.validate_vendor_access(db, test_vendor.vendor_code, test_admin)
assert result.shop_code == test_shop.shop_code
assert result.vendor_code == test_vendor.vendor_code
def test_validate_shop_access_shop_not_found(self, db, test_user):
"""Test shop access validation when shop doesn't exist"""
with pytest.raises(ShopNotFoundException) as exc_info:
self.service.validate_shop_access(db, "NONEXISTENT", test_user)
def test_validate_vendor_access_vendor_not_found(self, db, test_user):
"""Test vendor access validation when vendor doesn't exist"""
with pytest.raises(VendorNotFoundException) as exc_info:
self.service.validate_vendor_access(db, "NONEXISTENT", test_user)
exception = exc_info.value
assert exception.error_code == "SHOP_NOT_FOUND"
assert exception.error_code == "VENDOR_NOT_FOUND"
assert exception.status_code == 404
assert "NONEXISTENT" in exception.message
def test_validate_shop_access_permission_denied(
self, db, test_shop, test_user, other_user
def test_validate_vendor_access_permission_denied(
self, db, test_vendor, test_user, other_user
):
"""Test shop access validation when user doesn't own the shop"""
# Set the shop owner to a different user
test_shop.owner_id = other_user.id
"""Test vendor access validation when user doesn't own the vendor """
# Set the vendor owner to a different user
test_vendor.owner_id = other_user.id
db.commit()
with pytest.raises(UnauthorizedShopAccessException) as exc_info:
self.service.validate_shop_access(db, test_shop.shop_code, test_user)
with pytest.raises(UnauthorizedVendorAccessException) as exc_info:
self.service.validate_vendor_access(db, test_vendor.vendor_code, test_user)
exception = exc_info.value
assert exception.error_code == "UNAUTHORIZED_SHOP_ACCESS"
assert exception.error_code == "UNAUTHORIZED_VENDOR_ACCESS"
assert exception.status_code == 403
assert test_shop.shop_code in exception.message
assert test_vendor.vendor_code in exception.message
def test_create_import_job_success(self, db, test_shop, test_user):
def test_create_import_job_success(self, db, test_vendor, test_user):
"""Test successful creation of import job"""
# Set the shop owner to the test user
test_shop.owner_id = test_user.id
# Set the vendor owner to the test user
test_vendor.owner_id = test_user.id
db.commit()
request = MarketplaceImportJobRequest(
url="https://example.com/products.csv",
marketplace="Amazon",
shop_code=test_shop.shop_code,
vendor_code=test_vendor.vendor_code,
batch_size=1000,
)
result = self.service.create_import_job(db, request, test_user)
assert result.marketplace == "Amazon"
assert result.shop_id == test_shop.id
assert result.vendor_id == test_vendor.id
assert result.user_id == test_user.id
assert result.status == "pending"
assert result.source_url == "https://example.com/products.csv"
assert result.shop_name == test_shop.shop_name
assert result.vendor_name == test_vendor.vendor_name
def test_create_import_job_invalid_shop(self, db, test_user):
"""Test import job creation with invalid shop"""
def test_create_import_job_invalid_vendor(self, db, test_user):
"""Test import job creation with invalid vendor """
request = MarketplaceImportJobRequest(
url="https://example.com/products.csv",
marketplace="Amazon",
shop_code="INVALID_SHOP",
vendor_code="INVALID_SHOP",
batch_size=1000,
)
with pytest.raises(ShopNotFoundException) as exc_info:
with pytest.raises(VendorNotFoundException) as exc_info:
self.service.create_import_job(db, request, test_user)
exception = exc_info.value
assert exception.error_code == "SHOP_NOT_FOUND"
assert exception.error_code == "VENDOR_NOT_FOUND"
assert "INVALID_SHOP" in exception.message
def test_create_import_job_unauthorized_access(self, db, test_shop, test_user, other_user):
"""Test import job creation with unauthorized shop access"""
# Set the shop owner to a different user
test_shop.owner_id = other_user.id
def test_create_import_job_unauthorized_access(self, db, test_vendor, test_user, other_user):
"""Test import job creation with unauthorized vendor access"""
# Set the vendor owner to a different user
test_vendor.owner_id = other_user.id
db.commit()
request = MarketplaceImportJobRequest(
url="https://example.com/products.csv",
marketplace="Amazon",
shop_code=test_shop.shop_code,
vendor_code=test_vendor.vendor_code,
batch_size=1000,
)
with pytest.raises(UnauthorizedShopAccessException) as exc_info:
with pytest.raises(UnauthorizedVendorAccessException) as exc_info:
self.service.create_import_job(db, request, test_user)
exception = exc_info.value
assert exception.error_code == "UNAUTHORIZED_SHOP_ACCESS"
assert exception.error_code == "UNAUTHORIZED_VENDOR_ACCESS"
def test_get_import_job_by_id_success(self, db, test_marketplace_import_job, test_user):
"""Test getting import job by ID for job owner"""
@@ -194,7 +194,7 @@ class TestMarketplaceService:
assert len(jobs) >= 1
assert any(job.marketplace == test_marketplace_import_job.marketplace for job in jobs)
def test_get_import_jobs_with_pagination(self, db, test_user, test_shop):
def test_get_import_jobs_with_pagination(self, db, test_user, test_vendor):
"""Test getting import jobs with pagination"""
unique_id = str(uuid.uuid4())[:8]
@@ -203,9 +203,9 @@ class TestMarketplaceService:
job = MarketplaceImportJob(
status="completed",
marketplace=f"Marketplace_{unique_id}_{i}",
shop_name=f"Test_Shop_{unique_id}_{i}",
vendor_name=f"Test_vendor_{unique_id}_{i}",
user_id=test_user.id,
shop_id=test_shop.id,
vendor_id=test_vendor.id,
source_url=f"https://test-{i}.example.com/import",
imported_count=0,
updated_count=0,
@@ -296,7 +296,7 @@ class TestMarketplaceService:
assert response.marketplace == test_marketplace_import_job.marketplace
assert response.imported == (test_marketplace_import_job.imported_count or 0)
def test_cancel_import_job_success(self, db, test_user, test_shop):
def test_cancel_import_job_success(self, db, test_user, test_vendor):
"""Test cancelling a pending import job"""
unique_id = str(uuid.uuid4())[:8]
@@ -304,9 +304,9 @@ class TestMarketplaceService:
job = MarketplaceImportJob(
status="pending",
marketplace="Amazon",
shop_name=f"TEST_SHOP_{unique_id}",
vendor_name=f"TEST_VENDOR_{unique_id}",
user_id=test_user.id,
shop_id=test_shop.id,
vendor_id=test_vendor.id,
source_url="https://test.example.com/import",
imported_count=0,
updated_count=0,
@@ -354,7 +354,7 @@ class TestMarketplaceService:
assert exception.status_code == 400
assert "completed" in exception.message
def test_delete_import_job_success(self, db, test_user, test_shop):
def test_delete_import_job_success(self, db, test_user, test_vendor):
"""Test deleting a completed import job"""
unique_id = str(uuid.uuid4())[:8]
@@ -362,9 +362,9 @@ class TestMarketplaceService:
job = MarketplaceImportJob(
status="completed",
marketplace="Amazon",
shop_name=f"TEST_SHOP_{unique_id}",
vendor_name=f"TEST_VENDOR_{unique_id}",
user_id=test_user.id,
shop_id=test_shop.id,
vendor_id=test_vendor.id,
source_url="https://test.example.com/import",
imported_count=0,
updated_count=0,
@@ -404,7 +404,7 @@ class TestMarketplaceService:
exception = exc_info.value
assert exception.error_code == "IMPORT_JOB_NOT_OWNED"
def test_delete_import_job_invalid_status(self, db, test_user, test_shop):
def test_delete_import_job_invalid_status(self, db, test_user, test_vendor):
"""Test deleting a job that can't be deleted"""
unique_id = str(uuid.uuid4())[:8]
@@ -412,9 +412,9 @@ class TestMarketplaceService:
job = MarketplaceImportJob(
status="pending",
marketplace="Amazon",
shop_name=f"TEST_SHOP_{unique_id}",
vendor_name=f"TEST_VENDOR_{unique_id}",
user_id=test_user.id,
shop_id=test_shop.id,
vendor_id=test_vendor.id,
source_url="https://test.example.com/import",
imported_count=0,
updated_count=0,
@@ -434,25 +434,25 @@ class TestMarketplaceService:
assert "pending" in exception.message
# Test edge cases and error scenarios
def test_validate_shop_access_case_insensitive(self, db, test_shop, test_user):
"""Test shop access validation is case insensitive"""
test_shop.owner_id = test_user.id
def test_validate_vendor_access_case_insensitive(self, db, test_vendor, test_user):
"""Test vendor access validation is case insensitive"""
test_vendor.owner_id = test_user.id
db.commit()
# Test with lowercase shop code
result = self.service.validate_shop_access(db, test_shop.shop_code.lower(), test_user)
assert result.shop_code == test_shop.shop_code
# Test with lowercase vendor code
result = self.service.validate_vendor_access(db, test_vendor.vendor_code.lower(), test_user)
assert result.vendor_code == test_vendor.vendor_code
# Test with uppercase shop code
result = self.service.validate_shop_access(db, test_shop.shop_code.upper(), test_user)
assert result.shop_code == test_shop.shop_code
# Test with uppercase vendor code
result = self.service.validate_vendor_access(db, test_vendor.vendor_code.upper(), test_user)
assert result.vendor_code == test_vendor.vendor_code
def test_create_import_job_database_error(self, db_with_error, test_user):
"""Test import job creation handles database errors"""
request = MarketplaceImportJobRequest(
url="https://example.com/products.csv",
marketplace="Amazon",
shop_code="TEST_SHOP",
vendor_code="TEST_SHOP",
batch_size=1000,
)

View File

@@ -1,365 +0,0 @@
# tests/test_shop_service.py (updated to use custom exceptions)
import pytest
from app.services.shop_service import ShopService
from app.exceptions import (
ShopNotFoundException,
ShopAlreadyExistsException,
UnauthorizedShopAccessException,
InvalidShopDataException,
MarketplaceProductNotFoundException,
ProductAlreadyExistsException,
MaxShopsReachedException,
ValidationException,
)
from models.schemas.shop import ShopCreate
from models.schemas.product import ProductCreate
@pytest.mark.unit
@pytest.mark.shops
class TestShopService:
"""Test suite for ShopService following the application's exception patterns"""
def setup_method(self):
"""Setup method following the same pattern as admin service tests"""
self.service = ShopService()
def test_create_shop_success(self, db, test_user, shop_factory):
"""Test successful shop creation"""
shop_data = ShopCreate(
shop_code="NEWSHOP",
shop_name="New Test Shop",
description="A new test shop",
)
shop = self.service.create_shop(db, shop_data, test_user)
assert shop is not None
assert shop.shop_code == "NEWSHOP"
assert shop.owner_id == test_user.id
assert shop.is_verified is False # Regular user creates unverified shop
def test_create_shop_admin_auto_verify(self, db, test_admin, shop_factory):
"""Test admin creates verified shop automatically"""
shop_data = ShopCreate(shop_code="ADMINSHOP", shop_name="Admin Test Shop")
shop = self.service.create_shop(db, shop_data, test_admin)
assert shop.is_verified is True # Admin creates verified shop
def test_create_shop_duplicate_code(self, db, test_user, test_shop):
"""Test shop creation fails with duplicate shop code"""
shop_data = ShopCreate(
shop_code=test_shop.shop_code, shop_name=test_shop.shop_name
)
with pytest.raises(ShopAlreadyExistsException) as exc_info:
self.service.create_shop(db, shop_data, test_user)
exception = exc_info.value
assert exception.status_code == 409
assert exception.error_code == "SHOP_ALREADY_EXISTS"
assert test_shop.shop_code.upper() in exception.message
assert "shop_code" in exception.details
def test_create_shop_invalid_data_empty_code(self, db, test_user):
"""Test shop creation fails with empty shop code"""
shop_data = ShopCreate(shop_code="", shop_name="Test Shop")
with pytest.raises(InvalidShopDataException) as exc_info:
self.service.create_shop(db, shop_data, test_user)
exception = exc_info.value
assert exception.status_code == 422
assert exception.error_code == "INVALID_SHOP_DATA"
assert exception.details["field"] == "shop_code"
def test_create_shop_invalid_data_empty_name(self, db, test_user):
"""Test shop creation fails with empty shop name"""
shop_data = ShopCreate(shop_code="VALIDCODE", shop_name="")
with pytest.raises(InvalidShopDataException) as exc_info:
self.service.create_shop(db, shop_data, test_user)
exception = exc_info.value
assert exception.error_code == "INVALID_SHOP_DATA"
assert exception.details["field"] == "shop_name"
def test_create_shop_invalid_code_format(self, db, test_user):
"""Test shop creation fails with invalid shop code format"""
shop_data = ShopCreate(shop_code="INVALID@CODE!", shop_name="Test Shop")
with pytest.raises(InvalidShopDataException) as exc_info:
self.service.create_shop(db, shop_data, test_user)
exception = exc_info.value
assert exception.error_code == "INVALID_SHOP_DATA"
assert exception.details["field"] == "shop_code"
assert "letters, numbers, underscores, and hyphens" in exception.message
def test_create_shop_max_shops_reached(self, db, test_user, monkeypatch):
"""Test shop creation fails when user reaches maximum shops"""
# Mock the shop count check to simulate user at limit
def mock_check_shop_limit(self, db, user):
raise MaxShopsReachedException(max_shops=5, user_id=user.id)
monkeypatch.setattr(ShopService, "_check_shop_limit", mock_check_shop_limit)
shop_data = ShopCreate(shop_code="NEWSHOP", shop_name="New Shop")
with pytest.raises(MaxShopsReachedException) as exc_info:
self.service.create_shop(db, shop_data, test_user)
exception = exc_info.value
assert exception.status_code == 400
assert exception.error_code == "MAX_SHOPS_REACHED"
assert exception.details["max_shops"] == 5
assert exception.details["user_id"] == test_user.id
def test_get_shops_regular_user(self, db, test_user, test_shop, inactive_shop):
"""Test regular user can only see active verified shops and own shops"""
shops, total = self.service.get_shops(db, test_user, skip=0, limit=10)
shop_codes = [shop.shop_code for shop in shops]
assert test_shop.shop_code in shop_codes
assert inactive_shop.shop_code not in shop_codes
def test_get_shops_admin_user(
self, db, test_admin, test_shop, inactive_shop, verified_shop
):
"""Test admin user can see all shops with filters"""
shops, total = self.service.get_shops(
db, test_admin, active_only=False, verified_only=False
)
shop_codes = [shop.shop_code for shop in shops]
assert test_shop.shop_code in shop_codes
assert inactive_shop.shop_code in shop_codes
assert verified_shop.shop_code in shop_codes
def test_get_shop_by_code_owner_access(self, db, test_user, test_shop):
"""Test shop owner can access their own shop"""
shop = self.service.get_shop_by_code(db, test_shop.shop_code.lower(), test_user)
assert shop is not None
assert shop.id == test_shop.id
def test_get_shop_by_code_admin_access(self, db, test_admin, test_shop):
"""Test admin can access any shop"""
shop = self.service.get_shop_by_code(
db, test_shop.shop_code.lower(), test_admin
)
assert shop is not None
assert shop.id == test_shop.id
def test_get_shop_by_code_not_found(self, db, test_user):
"""Test shop not found raises proper exception"""
with pytest.raises(ShopNotFoundException) as exc_info:
self.service.get_shop_by_code(db, "NONEXISTENT", test_user)
exception = exc_info.value
assert exception.status_code == 404
assert exception.error_code == "SHOP_NOT_FOUND"
assert exception.details["resource_type"] == "Shop"
assert exception.details["identifier"] == "NONEXISTENT"
def test_get_shop_by_code_access_denied(self, db, test_user, inactive_shop):
"""Test regular user cannot access unverified shop they don't own"""
with pytest.raises(UnauthorizedShopAccessException) as exc_info:
self.service.get_shop_by_code(db, inactive_shop.shop_code, test_user)
exception = exc_info.value
assert exception.status_code == 403
assert exception.error_code == "UNAUTHORIZED_SHOP_ACCESS"
assert exception.details["shop_code"] == inactive_shop.shop_code
assert exception.details["user_id"] == test_user.id
def test_add_product_to_shop_success(self, db, test_shop, unique_product):
"""Test successfully adding product to shop"""
product_data = ProductCreate(
marketplace_product_id=unique_product.marketplace_product_id,
price="15.99",
is_featured=True,
)
product = self.service.add_product_to_shop(
db, test_shop, product_data
)
assert product is not None
assert product.shop_id == test_shop.id
assert product.marketplace_product_id == unique_product.id
def test_add_product_to_shop_product_not_found(self, db, test_shop):
"""Test adding non-existent product to shop fails"""
product_data = ProductCreate(marketplace_product_id="NONEXISTENT", price="15.99")
with pytest.raises(MarketplaceProductNotFoundException) as exc_info:
self.service.add_product_to_shop(db, test_shop, product_data)
exception = exc_info.value
assert exception.status_code == 404
assert exception.error_code == "PRODUCT_NOT_FOUND"
assert exception.details["resource_type"] == "MarketplaceProduct"
assert exception.details["identifier"] == "NONEXISTENT"
def test_add_product_to_shop_already_exists(self, db, test_shop, test_product):
"""Test adding product that's already in shop fails"""
product_data = ProductCreate(
marketplace_product_id=test_product.marketplace_product.marketplace_product_id, price="15.99"
)
with pytest.raises(ProductAlreadyExistsException) as exc_info:
self.service.add_product_to_shop(db, test_shop, product_data)
exception = exc_info.value
assert exception.status_code == 409
assert exception.error_code == "PRODUCT_ALREADY_EXISTS"
assert exception.details["shop_code"] == test_shop.shop_code
assert exception.details["marketplace_product_id"] == test_product.marketplace_product.marketplace_product_id
def test_get_products_owner_access(
self, db, test_user, test_shop, test_product
):
"""Test shop owner can get shop products"""
products, total = self.service.get_products(db, test_shop, test_user)
assert total >= 1
assert len(products) >= 1
product_ids = [p.marketplace_product_id for p in products]
assert test_product.marketplace_product_id in product_ids
def test_get_products_access_denied(self, db, test_user, inactive_shop):
"""Test non-owner cannot access unverified shop products"""
with pytest.raises(UnauthorizedShopAccessException) as exc_info:
self.service.get_products(db, inactive_shop, test_user)
exception = exc_info.value
assert exception.status_code == 403
assert exception.error_code == "UNAUTHORIZED_SHOP_ACCESS"
assert exception.details["shop_code"] == inactive_shop.shop_code
assert exception.details["user_id"] == test_user.id
def test_get_products_with_filters(self, db, test_user, test_shop, test_product):
"""Test getting shop products with various filters"""
# Test active only filter
products, total = self.service.get_products(
db, test_shop, test_user, active_only=True
)
assert all(p.is_active for p in products)
# Test featured only filter
products, total = self.service.get_products(
db, test_shop, test_user, featured_only=True
)
assert all(p.is_featured for p in products)
# Test exception handling for generic errors
def test_create_shop_database_error(self, db, test_user, monkeypatch):
"""Test shop creation handles database errors gracefully"""
def mock_commit():
raise Exception("Database connection failed")
monkeypatch.setattr(db, "commit", mock_commit)
shop_data = ShopCreate(shop_code="NEWSHOP", shop_name="Test Shop")
with pytest.raises(ValidationException) as exc_info:
self.service.create_shop(db, shop_data, test_user)
exception = exc_info.value
assert exception.status_code == 422
assert exception.error_code == "VALIDATION_ERROR"
assert "Failed to create shop" in exception.message
def test_get_shops_database_error(self, db, test_user, monkeypatch):
"""Test get shops handles database errors gracefully"""
def mock_query(*args):
raise Exception("Database query failed")
monkeypatch.setattr(db, "query", mock_query)
with pytest.raises(ValidationException) as exc_info:
self.service.get_shops(db, test_user)
exception = exc_info.value
assert exception.error_code == "VALIDATION_ERROR"
assert "Failed to retrieve shops" in exception.message
def test_add_product_database_error(self, db, test_shop, unique_product, monkeypatch):
"""Test add product handles database errors gracefully"""
def mock_commit():
raise Exception("Database commit failed")
monkeypatch.setattr(db, "commit", mock_commit)
product_data = ProductCreate(
marketplace_product_id=unique_product.marketplace_product_id, price="15.99"
)
with pytest.raises(ValidationException) as exc_info:
self.service.add_product_to_shop(db, test_shop, product_data)
exception = exc_info.value
assert exception.error_code == "VALIDATION_ERROR"
assert "Failed to add product to shop" in exception.message
@pytest.mark.unit
@pytest.mark.shops
class TestShopServiceExceptionDetails:
"""Additional tests focusing specifically on exception structure and details"""
def setup_method(self):
self.service = ShopService()
def test_exception_to_dict_structure(self, db, test_user, test_shop):
"""Test that exceptions can be properly serialized to dict for API responses"""
shop_data = ShopCreate(
shop_code=test_shop.shop_code, shop_name="Duplicate"
)
with pytest.raises(ShopAlreadyExistsException) as exc_info:
self.service.create_shop(db, shop_data, test_user)
exception = exc_info.value
exception_dict = exception.to_dict()
# Verify structure matches expected API response format
assert "error_code" in exception_dict
assert "message" in exception_dict
assert "status_code" in exception_dict
assert "details" in exception_dict
# Verify values
assert exception_dict["error_code"] == "SHOP_ALREADY_EXISTS"
assert exception_dict["status_code"] == 409
assert isinstance(exception_dict["details"], dict)
def test_validation_exception_field_details(self, db, test_user):
"""Test validation exceptions include field-specific details"""
shop_data = ShopCreate(shop_code="", shop_name="Test")
with pytest.raises(InvalidShopDataException) as exc_info:
self.service.create_shop(db, shop_data, test_user)
exception = exc_info.value
assert exception.details["field"] == "shop_code"
assert exception.status_code == 422
assert "required" in exception.message.lower()
def test_authorization_exception_user_details(self, db, test_user, inactive_shop):
"""Test authorization exceptions include user context"""
with pytest.raises(UnauthorizedShopAccessException) as exc_info:
self.service.get_shop_by_code(db, inactive_shop.shop_code, test_user)
exception = exc_info.value
assert exception.details["shop_code"] == inactive_shop.shop_code
assert exception.details["user_id"] == test_user.id
assert "Unauthorized access" in exception.message

View File

@@ -23,7 +23,7 @@ class TestStatsService:
assert "unique_brands" in stats
assert "unique_categories" in stats
assert "unique_marketplaces" in stats
assert "unique_shops" in stats
assert "unique_vendors" in stats
assert "total_stock_entries" in stats
assert "total_inventory_quantity" in stats
@@ -41,7 +41,7 @@ class TestStatsService:
brand="DifferentBrand",
google_product_category="Different Category",
marketplace="Amazon",
shop_name="AmazonShop",
vendor_name="AmazonShop",
price="15.99",
currency="EUR",
),
@@ -51,7 +51,7 @@ class TestStatsService:
brand="ThirdBrand",
google_product_category="Third Category",
marketplace="eBay",
shop_name="eBayShop",
vendor_name="eBayShop",
price="25.99",
currency="USD",
),
@@ -61,7 +61,7 @@ class TestStatsService:
brand="TestBrand", # Same as test_marketplace_product
google_product_category="Different Category",
marketplace="Letzshop", # Same as test_marketplace_product
shop_name="DifferentShop",
vendor_name="DifferentShop",
price="35.99",
currency="EUR",
),
@@ -75,7 +75,7 @@ class TestStatsService:
assert stats["unique_brands"] >= 3 # TestBrand, DifferentBrand, ThirdBrand
assert stats["unique_categories"] >= 2 # At least 2 different categories
assert stats["unique_marketplaces"] >= 3 # Letzshop, Amazon, eBay
assert stats["unique_shops"] >= 3 # At least 3 different shops
assert stats["unique_vendors"] >= 3 # At least 3 different vendors
def test_get_comprehensive_stats_handles_nulls(self, db):
"""Test comprehensive stats handles null/empty values correctly"""
@@ -87,7 +87,7 @@ class TestStatsService:
brand=None, # Null brand
google_product_category=None, # Null category
marketplace=None, # Null marketplace
shop_name=None, # Null shop
vendor_name=None, # Null vendor
price="10.00",
currency="EUR",
),
@@ -97,7 +97,7 @@ class TestStatsService:
brand="", # Empty brand
google_product_category="", # Empty category
marketplace="", # Empty marketplace
shop_name="", # Empty shop
vendor_name="", # Empty vendor
price="15.00",
currency="EUR",
),
@@ -109,11 +109,11 @@ class TestStatsService:
# These products shouldn't contribute to unique counts due to null/empty values
assert stats["total_products"] >= 2
# Brands, categories, marketplaces, shops should not count null/empty values
# Brands, categories, marketplaces, vendors should not count null/empty values
assert isinstance(stats["unique_brands"], int)
assert isinstance(stats["unique_categories"], int)
assert isinstance(stats["unique_marketplaces"], int)
assert isinstance(stats["unique_shops"], int)
assert isinstance(stats["unique_vendors"], int)
def test_get_marketplace_breakdown_stats_basic(self, db, test_marketplace_product):
"""Test getting marketplace breakdown stats with basic data"""
@@ -129,7 +129,7 @@ class TestStatsService:
)
assert test_marketplace_stat is not None
assert test_marketplace_stat["total_products"] >= 1
assert test_marketplace_stat["unique_shops"] >= 1
assert test_marketplace_stat["unique_vendors"] >= 1
assert test_marketplace_stat["unique_brands"] >= 1
def test_get_marketplace_breakdown_stats_multiple_marketplaces(
@@ -143,7 +143,7 @@ class TestStatsService:
title="Amazon MarketplaceProduct 1",
brand="AmazonBrand1",
marketplace="Amazon",
shop_name="AmazonShop1",
vendor_name="AmazonShop1",
price="20.00",
currency="EUR",
),
@@ -152,7 +152,7 @@ class TestStatsService:
title="Amazon MarketplaceProduct 2",
brand="AmazonBrand2",
marketplace="Amazon",
shop_name="AmazonShop2",
vendor_name="AmazonShop2",
price="25.00",
currency="EUR",
),
@@ -161,7 +161,7 @@ class TestStatsService:
title="eBay MarketplaceProduct",
brand="eBayBrand",
marketplace="eBay",
shop_name="eBayShop",
vendor_name="eBayShop",
price="30.00",
currency="USD",
),
@@ -180,13 +180,13 @@ class TestStatsService:
# Check Amazon stats specifically
amazon_stat = next(stat for stat in stats if stat["marketplace"] == "Amazon")
assert amazon_stat["total_products"] == 2
assert amazon_stat["unique_shops"] == 2
assert amazon_stat["unique_vendors"] == 2
assert amazon_stat["unique_brands"] == 2
# Check eBay stats specifically
ebay_stat = next(stat for stat in stats if stat["marketplace"] == "eBay")
assert ebay_stat["total_products"] == 1
assert ebay_stat["unique_shops"] == 1
assert ebay_stat["unique_vendors"] == 1
assert ebay_stat["unique_brands"] == 1
def test_get_marketplace_breakdown_stats_excludes_nulls(self, db):
@@ -196,7 +196,7 @@ class TestStatsService:
marketplace_product_id="NULLMARKET001",
title="MarketplaceProduct without marketplace",
marketplace=None,
shop_name="SomeShop",
vendor_name="SomeShop",
brand="SomeBrand",
price="10.00",
currency="EUR",
@@ -228,7 +228,7 @@ class TestStatsService:
title="Brand MarketplaceProduct 1",
brand="BrandA",
marketplace="Test",
shop_name="TestShop",
vendor_name="TestVendor",
price="10.00",
currency="EUR",
),
@@ -237,7 +237,7 @@ class TestStatsService:
title="Brand MarketplaceProduct 2",
brand="BrandB",
marketplace="Test",
shop_name="TestShop",
vendor_name="TestVendor",
price="15.00",
currency="EUR",
),
@@ -261,7 +261,7 @@ class TestStatsService:
title="Category MarketplaceProduct 1",
google_product_category="Electronics",
marketplace="Test",
shop_name="TestShop",
vendor_name="TestVendor",
price="10.00",
currency="EUR",
),
@@ -270,7 +270,7 @@ class TestStatsService:
title="Category MarketplaceProduct 2",
google_product_category="Books",
marketplace="Test",
shop_name="TestShop",
vendor_name="TestVendor",
price="15.00",
currency="EUR",
),
@@ -291,7 +291,7 @@ class TestStatsService:
marketplace_product_id="MARKET001",
title="Marketplace MarketplaceProduct 1",
marketplace="Amazon",
shop_name="AmazonShop",
vendor_name="AmazonShop",
price="10.00",
currency="EUR",
),
@@ -299,7 +299,7 @@ class TestStatsService:
marketplace_product_id="MARKET002",
title="Marketplace MarketplaceProduct 2",
marketplace="eBay",
shop_name="eBayShop",
vendor_name="eBayShop",
price="15.00",
currency="EUR",
),
@@ -312,23 +312,23 @@ class TestStatsService:
assert count >= 2 # At least Amazon and eBay, plus test_marketplace_product marketplace
assert isinstance(count, int)
def test_get_unique_shops_count(self, db, test_marketplace_product):
"""Test getting unique shops count"""
# Add products with different shop names
def test_get_unique_vendors_count(self, db, test_marketplace_product):
"""Test getting unique vendors count"""
# Add products with different vendor names
products = [
MarketplaceProduct(
marketplace_product_id="SHOP001",
title="Shop MarketplaceProduct 1",
title="Vendor MarketplaceProduct 1",
marketplace="Test",
shop_name="ShopA",
vendor_name="ShopA",
price="10.00",
currency="EUR",
),
MarketplaceProduct(
marketplace_product_id="SHOP002",
title="Shop MarketplaceProduct 2",
title="Vendor MarketplaceProduct 2",
marketplace="Test",
shop_name="ShopB",
vendor_name="ShopB",
price="15.00",
currency="EUR",
),
@@ -336,9 +336,9 @@ class TestStatsService:
db.add_all(products)
db.commit()
count = self.service._get_unique_shops_count(db)
count = self.service._get_unique_vendors_count(db)
assert count >= 2 # At least ShopA and ShopB, plus test_marketplace_product shop
assert count >= 2 # At least ShopA and ShopB, plus test_marketplace_product vendor
assert isinstance(count, int)
def test_get_stock_statistics(self, db, test_stock):
@@ -350,14 +350,14 @@ class TestStatsService:
location="LOCATION2",
quantity=25,
reserved_quantity=5,
shop_id=test_stock.shop_id,
vendor_id=test_stock.vendor_id,
),
Stock(
gtin="1234567890125",
location="LOCATION3",
quantity=0, # Out of stock
reserved_quantity=0,
shop_id=test_stock.shop_id,
vendor_id=test_stock.vendor_id,
),
]
db.add_all(additional_stocks)
@@ -379,7 +379,7 @@ class TestStatsService:
title="Specific MarketplaceProduct 1",
brand="SpecificBrand1",
marketplace="SpecificMarket",
shop_name="SpecificShop1",
vendor_name="SpecificShop1",
price="10.00",
currency="EUR",
),
@@ -388,7 +388,7 @@ class TestStatsService:
title="Specific MarketplaceProduct 2",
brand="SpecificBrand2",
marketplace="SpecificMarket",
shop_name="SpecificShop2",
vendor_name="SpecificShop2",
price="15.00",
currency="EUR",
),
@@ -397,7 +397,7 @@ class TestStatsService:
title="Other MarketplaceProduct",
brand="OtherBrand",
marketplace="OtherMarket",
shop_name="OtherShop",
vendor_name="OtherShop",
price="20.00",
currency="EUR",
),
@@ -412,25 +412,25 @@ class TestStatsService:
assert "SpecificBrand2" in brands
assert "OtherBrand" not in brands
def test_get_shops_by_marketplace(self, db):
"""Test getting shops for a specific marketplace"""
def test_get_vendors_by_marketplace(self, db):
"""Test getting vendors for a specific marketplace"""
# Create products for specific marketplace
marketplace_products = [
MarketplaceProduct(
marketplace_product_id="SHOPTEST001",
title="Shop Test MarketplaceProduct 1",
title="Vendor Test MarketplaceProduct 1",
brand="TestBrand",
marketplace="TestMarketplace",
shop_name="TestShop1",
vendor_name="TestVendor1",
price="10.00",
currency="EUR",
),
MarketplaceProduct(
marketplace_product_id="SHOPTEST002",
title="Shop Test MarketplaceProduct 2",
title="Vendor Test MarketplaceProduct 2",
brand="TestBrand",
marketplace="TestMarketplace",
shop_name="TestShop2",
vendor_name="TestVendor2",
price="15.00",
currency="EUR",
),
@@ -438,11 +438,11 @@ class TestStatsService:
db.add_all(marketplace_products)
db.commit()
shops = self.service._get_shops_by_marketplace(db, "TestMarketplace")
vendors =self.service._get_vendors_by_marketplace(db, "TestMarketplace")
assert len(shops) == 2
assert "TestShop1" in shops
assert "TestShop2" in shops
assert len(vendors) == 2
assert "TestVendor1" in vendors
assert "TestVendor2" in vendors
def test_get_products_by_marketplace(self, db):
"""Test getting product count for a specific marketplace"""
@@ -452,7 +452,7 @@ class TestStatsService:
marketplace_product_id="COUNT001",
title="Count MarketplaceProduct 1",
marketplace="CountMarketplace",
shop_name="CountShop",
vendor_name="CountShop",
price="10.00",
currency="EUR",
),
@@ -460,7 +460,7 @@ class TestStatsService:
marketplace_product_id="COUNT002",
title="Count MarketplaceProduct 2",
marketplace="CountMarketplace",
shop_name="CountShop",
vendor_name="CountShop",
price="15.00",
currency="EUR",
),
@@ -468,7 +468,7 @@ class TestStatsService:
marketplace_product_id="COUNT003",
title="Count MarketplaceProduct 3",
marketplace="CountMarketplace",
shop_name="CountShop",
vendor_name="CountShop",
price="20.00",
currency="EUR",
),
@@ -494,7 +494,7 @@ class TestStatsService:
assert stats["unique_brands"] == 0
assert stats["unique_categories"] == 0
assert stats["unique_marketplaces"] == 0
assert stats["unique_shops"] == 0
assert stats["unique_vendors"] == 0
assert stats["total_stock_entries"] == 0
assert stats["total_inventory_quantity"] == 0

View File

@@ -0,0 +1,365 @@
# tests/test_vendor_service.py (updated to use custom exceptions)
import pytest
from app.services.vendor_service import VendorService
from app.exceptions import (
VendorNotFoundException,
VendorAlreadyExistsException,
UnauthorizedVendorAccessException,
InvalidVendorDataException,
MarketplaceProductNotFoundException,
ProductAlreadyExistsException,
MaxVendorsReachedException,
ValidationException,
)
from models.schemas.vendor import VendorCreate
from models.schemas.product import ProductCreate
@pytest.mark.unit
@pytest.mark.vendors
class TestVendorService:
"""Test suite for ShopService following the application's exception patterns"""
def setup_method(self):
"""Setup method following the same pattern as admin service tests"""
self.service = VendorService()
def test_create_vendor_success(self, db, test_user, vendor_factory):
"""Test successful vendor creation"""
vendor_data = VendorCreate(
vendor_code="NEWVENDOR",
vendor_name="New Test Shop",
description="A new test vendor ",
)
vendor = self.service.create_vendor(db, vendor_data, test_user)
assert vendor is not None
assert vendor.vendor_code == "NEWVENDOR"
assert vendor.owner_id == test_user.id
assert vendor.is_verified is False # Regular user creates unverified vendor
def test_create_vendor_admin_auto_verify(self, db, test_admin, vendor_factory):
"""Test admin creates verified vendor automatically"""
vendor_data = VendorCreate(vendor_code="ADMINSHOP", vendor_name="Admin Test Shop")
vendor = self.service.create_vendor(db, vendor_data, test_admin)
assert vendor.is_verified is True # Admin creates verified vendor
def test_create_vendor_duplicate_code(self, db, test_user, test_vendor):
"""Test vendor creation fails with duplicate vendor code"""
vendor_data = VendorCreate(
vendor_code=test_vendor.vendor_code, vendor_name=test_vendor.vendor_name
)
with pytest.raises(VendorAlreadyExistsException) as exc_info:
self.service.create_vendor(db, vendor_data, test_user)
exception = exc_info.value
assert exception.status_code == 409
assert exception.error_code == "VENDOR_ALREADY_EXISTS"
assert test_vendor.vendor_code.upper() in exception.message
assert "vendor_code" in exception.details
def test_create_vendor_invalid_data_empty_code(self, db, test_user):
"""Test vendor creation fails with empty vendor code"""
vendor_data = VendorCreate(vendor_code="", vendor_name="Test Shop")
with pytest.raises(InvalidVendorDataException) as exc_info:
self.service.create_vendor(db, vendor_data, test_user)
exception = exc_info.value
assert exception.status_code == 422
assert exception.error_code == "INVALID_VENDOR_DATA"
assert exception.details["field"] == "vendor_code"
def test_create_vendor_invalid_data_empty_name(self, db, test_user):
"""Test vendor creation fails with empty vendor name"""
vendor_data = VendorCreate(vendor_code="VALIDCODE", vendor_name="")
with pytest.raises(InvalidVendorDataException) as exc_info:
self.service.create_vendor(db, vendor_data, test_user)
exception = exc_info.value
assert exception.error_code == "INVALID_VENDOR_DATA"
assert exception.details["field"] == "vendor_name"
def test_create_vendor_invalid_code_format(self, db, test_user):
"""Test vendor creation fails with invalid vendor code format"""
vendor_data = VendorCreate(vendor_code="INVALID@CODE!", vendor_name="Test Shop")
with pytest.raises(InvalidVendorDataException) as exc_info:
self.service.create_vendor(db, vendor_data, test_user)
exception = exc_info.value
assert exception.error_code == "INVALID_VENDOR_DATA"
assert exception.details["field"] == "vendor_code"
assert "letters, numbers, underscores, and hyphens" in exception.message
def test_create_vendor_max_vendors_reached(self, db, test_user, monkeypatch):
"""Test vendor creation fails when user reaches maximum vendors"""
# Mock the vendor count check to simulate user at limit
def mock_check_vendor_limit(self, db, user):
raise MaxVendorsReachedException(max_vendors=5, user_id=user.id)
monkeypatch.setattr(VendorService, "_check_vendor_limit", mock_check_vendor_limit)
vendor_data = VendorCreate(vendor_code="NEWVENDOR", vendor_name="New Vendor")
with pytest.raises(MaxVendorsReachedException) as exc_info:
self.service.create_vendor(db, vendor_data, test_user)
exception = exc_info.value
assert exception.status_code == 400
assert exception.error_code == "MAX_VENDORS_REACHED"
assert exception.details["max_vendors"] == 5
assert exception.details["user_id"] == test_user.id
def test_get_vendors_regular_user(self, db, test_user, test_vendor, inactive_vendor):
"""Test regular user can only see active verified vendors and own vendors"""
vendors, total = self.service.get_vendors(db, test_user, skip=0, limit=10)
vendor_codes = [vendor.vendor_code for vendor in vendors]
assert test_vendor.vendor_code in vendor_codes
assert inactive_vendor.vendor_code not in vendor_codes
def test_get_vendors_admin_user(
self, db, test_admin, test_vendor, inactive_vendor, verified_vendor
):
"""Test admin user can see all vendors with filters"""
vendors, total = self.service.get_vendors(
db, test_admin, active_only=False, verified_only=False
)
vendor_codes = [vendor.vendor_code for vendor in vendors]
assert test_vendor.vendor_code in vendor_codes
assert inactive_vendor.vendor_code in vendor_codes
assert verified_vendor.vendor_code in vendor_codes
def test_get_vendor_by_code_owner_access(self, db, test_user, test_vendor):
"""Test vendor owner can access their own vendor """
vendor = self.service.get_vendor_by_code(db, test_vendor.vendor_code.lower(), test_user)
assert vendor is not None
assert vendor.id == test_vendor.id
def test_get_vendor_by_code_admin_access(self, db, test_admin, test_vendor):
"""Test admin can access any vendor """
vendor = self.service.get_vendor_by_code(
db, test_vendor.vendor_code.lower(), test_admin
)
assert vendor is not None
assert vendor.id == test_vendor.id
def test_get_vendor_by_code_not_found(self, db, test_user):
"""Test vendor not found raises proper exception"""
with pytest.raises(VendorNotFoundException) as exc_info:
self.service.get_vendor_by_code(db, "NONEXISTENT", test_user)
exception = exc_info.value
assert exception.status_code == 404
assert exception.error_code == "VENDOR_NOT_FOUND"
assert exception.details["resource_type"] == "Shop"
assert exception.details["identifier"] == "NONEXISTENT"
def test_get_vendor_by_code_access_denied(self, db, test_user, inactive_vendor):
"""Test regular user cannot access unverified vendor they don't own"""
with pytest.raises(UnauthorizedVendorAccessException) as exc_info:
self.service.get_vendor_by_code(db, inactive_vendor.vendor_code, test_user)
exception = exc_info.value
assert exception.status_code == 403
assert exception.error_code == "UNAUTHORIZED_VENDOR_ACCESS"
assert exception.details["vendor_code"] == inactive_vendor.vendor_code
assert exception.details["user_id"] == test_user.id
def test_add_product_to_vendor_success(self, db, test_vendor, unique_product):
"""Test successfully adding product to vendor """
product_data = ProductCreate(
marketplace_product_id=unique_product.marketplace_product_id,
price="15.99",
is_featured=True,
)
product = self.service.add_product_to_catalog(
db, test_vendor, product_data
)
assert product is not None
assert product.vendor_id == test_vendor.id
assert product.marketplace_product_id == unique_product.id
def test_add_product_to_vendor_product_not_found(self, db, test_vendor):
"""Test adding non-existent product to vendor fails"""
product_data = ProductCreate(marketplace_product_id="NONEXISTENT", price="15.99")
with pytest.raises(MarketplaceProductNotFoundException) as exc_info:
self.service.add_product_to_catalog(db, test_vendor, product_data)
exception = exc_info.value
assert exception.status_code == 404
assert exception.error_code == "PRODUCT_NOT_FOUND"
assert exception.details["resource_type"] == "MarketplaceProduct"
assert exception.details["identifier"] == "NONEXISTENT"
def test_add_product_to_vendor_already_exists(self, db, test_vendor, test_product):
"""Test adding product that's already in vendor fails"""
product_data = ProductCreate(
marketplace_product_id=test_product.marketplace_product.marketplace_product_id, price="15.99"
)
with pytest.raises(ProductAlreadyExistsException) as exc_info:
self.service.add_product_to_catalog(db, test_vendor, product_data)
exception = exc_info.value
assert exception.status_code == 409
assert exception.error_code == "PRODUCT_ALREADY_EXISTS"
assert exception.details["vendor_code"] == test_vendor.vendor_code
assert exception.details["marketplace_product_id"] == test_product.marketplace_product.marketplace_product_id
def test_get_products_owner_access(
self, db, test_user, test_vendor, test_product
):
"""Test vendor owner can get vendor products"""
products, total = self.service.get_products(db, test_vendor, test_user)
assert total >= 1
assert len(products) >= 1
product_ids = [p.marketplace_product_id for p in products]
assert test_product.marketplace_product_id in product_ids
def test_get_products_access_denied(self, db, test_user, inactive_vendor):
"""Test non-owner cannot access unverified vendor products"""
with pytest.raises(UnauthorizedVendorAccessException) as exc_info:
self.service.get_products(db, inactive_vendor, test_user)
exception = exc_info.value
assert exception.status_code == 403
assert exception.error_code == "UNAUTHORIZED_VENDOR_ACCESS"
assert exception.details["vendor_code"] == inactive_vendor.vendor_code
assert exception.details["user_id"] == test_user.id
def test_get_products_with_filters(self, db, test_user, test_vendor, test_product):
"""Test getting vendor products with various filters"""
# Test active only filter
products, total = self.service.get_products(
db, test_vendor, test_user, active_only=True
)
assert all(p.is_active for p in products)
# Test featured only filter
products, total = self.service.get_products(
db, test_vendor, test_user, featured_only=True
)
assert all(p.is_featured for p in products)
# Test exception handling for generic errors
def test_create_vendor_database_error(self, db, test_user, monkeypatch):
"""Test vendor creation handles database errors gracefully"""
def mock_commit():
raise Exception("Database connection failed")
monkeypatch.setattr(db, "commit", mock_commit)
vendor_data = VendorCreate(vendor_code="NEWVENDOR", vendor_name="Test Shop")
with pytest.raises(ValidationException) as exc_info:
self.service.create_vendor(db, vendor_data, test_user)
exception = exc_info.value
assert exception.status_code == 422
assert exception.error_code == "VALIDATION_ERROR"
assert "Failed to create vendor " in exception.message
def test_get_vendors_database_error(self, db, test_user, monkeypatch):
"""Test get vendors handles database errors gracefully"""
def mock_query(*args):
raise Exception("Database query failed")
monkeypatch.setattr(db, "query", mock_query)
with pytest.raises(ValidationException) as exc_info:
self.service.get_vendors(db, test_user)
exception = exc_info.value
assert exception.error_code == "VALIDATION_ERROR"
assert "Failed to retrieve vendors" in exception.message
def test_add_product_database_error(self, db, test_vendor, unique_product, monkeypatch):
"""Test add product handles database errors gracefully"""
def mock_commit():
raise Exception("Database commit failed")
monkeypatch.setattr(db, "commit", mock_commit)
product_data = ProductCreate(
marketplace_product_id=unique_product.marketplace_product_id, price="15.99"
)
with pytest.raises(ValidationException) as exc_info:
self.service.add_product_to_catalog(db, test_vendor, product_data)
exception = exc_info.value
assert exception.error_code == "VALIDATION_ERROR"
assert "Failed to add product to vendor " in exception.message
@pytest.mark.unit
@pytest.mark.vendors
class TestVendorServiceExceptionDetails:
"""Additional tests focusing specifically on exception structure and details"""
def setup_method(self):
self.service = VendorService()
def test_exception_to_dict_structure(self, db, test_user, test_vendor):
"""Test that exceptions can be properly serialized to dict for API responses"""
vendor_data = VendorCreate(
vendor_code=test_vendor.vendor_code, vendor_name="Duplicate"
)
with pytest.raises(VendorAlreadyExistsException) as exc_info:
self.service.create_vendor(db, vendor_data, test_user)
exception = exc_info.value
exception_dict = exception.to_dict()
# Verify structure matches expected API response format
assert "error_code" in exception_dict
assert "message" in exception_dict
assert "status_code" in exception_dict
assert "details" in exception_dict
# Verify values
assert exception_dict["error_code"] == "VENDOR_ALREADY_EXISTS"
assert exception_dict["status_code"] == 409
assert isinstance(exception_dict["details"], dict)
def test_validation_exception_field_details(self, db, test_user):
"""Test validation exceptions include field-specific details"""
vendor_data = VendorCreate(vendor_code="", vendor_name="Test")
with pytest.raises(InvalidVendorDataException) as exc_info:
self.service.create_vendor(db, vendor_data, test_user)
exception = exc_info.value
assert exception.details["field"] == "vendor_code"
assert exception.status_code == 422
assert "required" in exception.message.lower()
def test_authorization_exception_user_details(self, db, test_user, inactive_vendor):
"""Test authorization exceptions include user context"""
with pytest.raises(UnauthorizedVendorAccessException) as exc_info:
self.service.get_vendor_by_code(db, inactive_vendor.vendor_code, test_user)
exception = exc_info.value
assert exception.details["vendor_code"] == inactive_vendor.vendor_code
assert exception.details["user_id"] == test_user.id
assert "Unauthorized access" in exception.message

View File

@@ -116,13 +116,13 @@ TEST002,Test MarketplaceProduct 2,15.99,TestMarket"""
"title": ["MarketplaceProduct 1", "MarketplaceProduct 2"],
"price": ["10.99", "15.99"],
"marketplace": ["TestMarket", "TestMarket"],
"shop_name": ["TestShop", "TestShop"],
"vendor_name": ["TestVendor", "TestVendor"],
}
)
mock_parse.return_value = mock_df
result = await self.processor.process_marketplace_csv_from_url(
"http://example.com/test.csv", "TestMarket", "TestShop", 1000, db
"http://example.com/test.csv", "TestMarket", "TestVendor", 1000, db
)
assert "imported" in result