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}") print(f" ✗ Stock model failed: {e}")
try: try:
from models.database.shop import Shop from models.database.vendor import Vendor
print("Shop model imported") print("Vendor model imported")
except ImportError as e: except ImportError as e:
print(f"Shop model failed: {e}") print(f"Vendor model failed: {e}")
try: try:
from models.database.product import Product from models.database.product import Product

View File

@@ -60,13 +60,13 @@ def upgrade() -> None:
sa.Column('shipping', sa.String(), nullable=True), sa.Column('shipping', sa.String(), nullable=True),
sa.Column('currency', sa.String(), nullable=True), sa.Column('currency', sa.String(), nullable=True),
sa.Column('marketplace', 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('created_at', sa.DateTime(), nullable=False),
sa.Column('updated_at', sa.DateTime(), nullable=False), sa.Column('updated_at', sa.DateTime(), nullable=False),
sa.PrimaryKeyConstraint('id') sa.PrimaryKeyConstraint('id')
) )
op.create_index('idx_marketplace_brand', 'products', ['marketplace', 'brand'], unique=False) 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_availability'), 'products', ['availability'], unique=False)
op.create_index(op.f('ix_products_brand'), 'products', ['brand'], 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) 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_id'), 'products', ['id'], unique=False)
op.create_index(op.f('ix_products_marketplace'), 'products', ['marketplace'], 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_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', op.create_table('users',
sa.Column('id', sa.Integer(), nullable=False), sa.Column('id', sa.Integer(), nullable=False),
sa.Column('email', sa.String(), 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_email'), 'users', ['email'], unique=True)
op.create_index(op.f('ix_users_id'), 'users', ['id'], unique=False) 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_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('id', sa.Integer(), nullable=False),
sa.Column('shop_code', sa.String(), nullable=False), sa.Column('vendor_code', sa.String(), nullable=False),
sa.Column('shop_name', sa.String(), nullable=False), sa.Column('vendor_name', sa.String(), nullable=False),
sa.Column('description', sa.Text(), nullable=True), sa.Column('description', sa.Text(), nullable=True),
sa.Column('owner_id', sa.Integer(), nullable=False), sa.Column('owner_id', sa.Integer(), nullable=False),
sa.Column('contact_email', sa.String(), nullable=True), sa.Column('contact_email', sa.String(), nullable=True),
@@ -108,15 +108,15 @@ def upgrade() -> None:
sa.ForeignKeyConstraint(['owner_id'], ['users.id'], ), sa.ForeignKeyConstraint(['owner_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id') sa.PrimaryKeyConstraint('id')
) )
op.create_index(op.f('ix_shops_id'), 'shops', ['id'], unique=False) op.create_index(op.f('ix_vendors_id'), 'vendors', ['id'], unique=False)
op.create_index(op.f('ix_shops_shop_code'), 'shops', ['shop_code'], unique=True) op.create_index(op.f('ix_vendors_vendor_code'), 'vendors', ['vendor_code'], unique=True)
op.create_table('marketplace_import_jobs', op.create_table('marketplace_import_jobs',
sa.Column('id', sa.Integer(), nullable=False), sa.Column('id', sa.Integer(), nullable=False),
sa.Column('status', sa.String(), nullable=False), sa.Column('status', sa.String(), nullable=False),
sa.Column('source_url', sa.String(), nullable=False), sa.Column('source_url', sa.String(), nullable=False),
sa.Column('marketplace', sa.String(), nullable=False), sa.Column('marketplace', sa.String(), nullable=False),
sa.Column('shop_name', sa.String(), nullable=False), sa.Column('vendor_name', sa.String(), nullable=False),
sa.Column('shop_id', sa.Integer(), nullable=False), sa.Column('vendor_id', sa.Integer(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False), sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('imported_count', sa.Integer(), nullable=True), sa.Column('imported_count', sa.Integer(), nullable=True),
sa.Column('updated_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('created_at', sa.DateTime(), nullable=False),
sa.Column('started_at', sa.DateTime(), nullable=True), sa.Column('started_at', sa.DateTime(), nullable=True),
sa.Column('completed_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.ForeignKeyConstraint(['user_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('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_vendor_id', 'marketplace_import_jobs', ['vendor_id'], unique=False)
op.create_index('idx_marketplace_import_shop_status', 'marketplace_import_jobs', ['status'], 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('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_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_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_index(op.f('ix_marketplace_import_jobs_vendor_name'), 'marketplace_import_jobs', ['vendor_name'], unique=False)
op.create_table('shop_products', op.create_table('vendor_products',
sa.Column('id', sa.Integer(), nullable=False), 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('marketplace_product_id', sa.Integer(), nullable=False),
sa.Column('shop_product_id', sa.String(), nullable=True), sa.Column('vendor_product_id', sa.String(), nullable=True),
sa.Column('shop_price', sa.Float(), nullable=True), sa.Column('price', sa.Float(), nullable=True),
sa.Column('shop_sale_price', sa.Float(), nullable=True), sa.Column('vendor_sale_price', sa.Float(), nullable=True),
sa.Column('shop_currency', sa.String(), nullable=True), sa.Column('vendor_currency', sa.String(), nullable=True),
sa.Column('shop_availability', sa.String(), nullable=True), sa.Column('vendor_availability', sa.String(), nullable=True),
sa.Column('shop_condition', sa.String(), nullable=True), sa.Column('vendor_condition', sa.String(), nullable=True),
sa.Column('is_featured', sa.Boolean(), nullable=True), sa.Column('is_featured', sa.Boolean(), nullable=True),
sa.Column('is_active', sa.Boolean(), nullable=True), sa.Column('is_active', sa.Boolean(), nullable=True),
sa.Column('display_order', sa.Integer(), 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('created_at', sa.DateTime(), nullable=True),
sa.Column('updated_at', sa.DateTime(), nullable=True), sa.Column('updated_at', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(['marketplace_product_id'], ['products.id'], ), sa.ForeignKeyConstraint(['marketplace_product_id'], ['products.id'], ),
sa.ForeignKeyConstraint(['shop_id'], ['shops.id'], ), sa.ForeignKeyConstraint(['vendor_id'], ['vendors.id'], ),
sa.PrimaryKeyConstraint('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_vendor_product_active', 'vendor_products', ['vendor_id', 'is_active'], unique=False)
op.create_index('idx_shop_product_featured', 'shop_products', ['shop_id', 'is_featured'], unique=False) op.create_index('idx_vendor_product_featured', 'vendor_products', ['vendor_id', 'is_featured'], unique=False)
op.create_index(op.f('ix_shop_products_id'), 'shop_products', ['id'], unique=False) op.create_index(op.f('ix_vendor_products_id'), 'vendor_products', ['id'], unique=False)
op.create_table('stock', op.create_table('stock',
sa.Column('id', sa.Integer(), nullable=False), sa.Column('id', sa.Integer(), nullable=False),
sa.Column('gtin', sa.String(), nullable=False), sa.Column('gtin', sa.String(), nullable=False),
sa.Column('location', sa.String(), nullable=False), sa.Column('location', sa.String(), nullable=False),
sa.Column('quantity', sa.Integer(), nullable=False), sa.Column('quantity', sa.Integer(), nullable=False),
sa.Column('reserved_quantity', sa.Integer(), nullable=True), 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('created_at', sa.DateTime(), nullable=False),
sa.Column('updated_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.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('gtin', 'location', name='uq_stock_gtin_location') 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(op.f('ix_stock_gtin'), table_name='stock')
op.drop_index('idx_stock_gtin_location', table_name='stock') op.drop_index('idx_stock_gtin_location', table_name='stock')
op.drop_table('stock') op.drop_table('stock')
op.drop_index(op.f('ix_shop_products_id'), table_name='shop_products') op.drop_index(op.f('ix_vendor_products_id'), table_name='vendor_products')
op.drop_index('idx_shop_product_featured', table_name='shop_products') op.drop_index('idx_vendor_product_featured', table_name='vendor_products')
op.drop_index('idx_shop_product_active', table_name='shop_products') op.drop_index('idx_vendor_product_active', table_name='vendor_products')
op.drop_table('shop_products') op.drop_table('vendor_products')
op.drop_index(op.f('ix_marketplace_import_jobs_shop_name'), table_name='marketplace_import_jobs') 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_marketplace'), table_name='marketplace_import_jobs')
op.drop_index(op.f('ix_marketplace_import_jobs_id'), 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_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_vendor_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_id', table_name='marketplace_import_jobs')
op.drop_table('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_vendors_vendor_code'), table_name='vendors')
op.drop_index(op.f('ix_shops_id'), table_name='shops') op.drop_index(op.f('ix_vendors_id'), table_name='vendors')
op.drop_table('shops') op.drop_table('vendors')
op.drop_index(op.f('ix_users_username'), table_name='users') 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_id'), table_name='users')
op.drop_index(op.f('ix_users_email'), table_name='users') op.drop_index(op.f('ix_users_email'), table_name='users')
op.drop_table('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_product_id'), table_name='products')
op.drop_index(op.f('ix_products_marketplace'), 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') 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 app.core.database import get_db
from middleware.auth import AuthManager from middleware.auth import AuthManager
from middleware.rate_limiter import RateLimiter 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 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 # Set auto_error=False to prevent automatic 403 responses
security = HTTPBearer(auto_error=False) 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) return auth_manager.require_admin(current_user)
def get_user_shop( def get_user_vendor(
shop_code: str, vendor_code: str,
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
"""Get shop and verify user ownership.""" """Get vendor and verify user ownership."""
shop = db.query(Shop).filter(Shop.shop_code == shop_code.upper()).first() vendor = db.query(Vendor).filter(Vendor.vendor_code == vendor_code.upper()).first()
if not shop: if not vendor:
raise ShopNotFoundException(shop_code) raise VendorNotFoundException(vendor_code)
if current_user.role != "admin" and shop.owner_id != current_user.id: if current_user.role != "admin" and vendor.owner_id != current_user.id:
raise UnauthorizedShopAccessException(shop_code, 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 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() api_router = APIRouter()
@@ -17,6 +17,6 @@ api_router = APIRouter()
api_router.include_router(admin.router, tags=["admin"]) api_router.include_router(admin.router, tags=["admin"])
api_router.include_router(auth.router, tags=["authentication"]) api_router.include_router(auth.router, tags=["authentication"])
api_router.include_router(marketplace.router, tags=["marketplace"]) 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(stats.router, tags=["statistics"])
api_router.include_router(stock.router, tags=["stock"]) 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: This module provides classes and functions for:
- User management (view, toggle status) - User management (view, toggle status)
- Shop management (view, verify, toggle status) - Vendor management (view, verify, toggle status)
- Marketplace import job monitoring - Marketplace import job monitoring
- Admin dashboard statistics - Admin dashboard statistics
""" """
@@ -20,7 +20,7 @@ from app.core.database import get_db
from app.services.admin_service import admin_service from app.services.admin_service import admin_service
from models.schemas.auth import UserResponse from models.schemas.auth import UserResponse
from models.schemas.marketplace_import_job import MarketplaceImportJobResponse 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 from models.database.user import User
router = APIRouter() router = APIRouter()
@@ -50,37 +50,37 @@ def toggle_user_status(
return {"message": message} return {"message": message}
@router.get("/admin/shops", response_model=ShopListResponse) @router.get("/admin/vendors", response_model=VendorListResponse)
def get_all_shops_admin( def get_all_vendors_admin(
skip: int = Query(0, ge=0), skip: int = Query(0, ge=0),
limit: int = Query(100, ge=1, le=1000), limit: int = Query(100, ge=1, le=1000),
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_admin: User = Depends(get_current_admin_user), current_admin: User = Depends(get_current_admin_user),
): ):
"""Get all shops with admin view (Admin only).""" """Get all vendors with admin view (Admin only)."""
shops, total = admin_service.get_all_shops(db=db, skip=skip, limit=limit) vendors, total = admin_service.get_all_vendors(db=db, skip=skip, limit=limit)
return ShopListResponse(shops=shops, total=total, skip=skip, limit=limit) return VendorListResponse(vendors=vendors, total=total, skip=skip, limit=limit)
@router.put("/admin/shops/{shop_id}/verify") @router.put("/admin/vendors/{vendor_id}/verify")
def verify_shop( def verify_vendor(
shop_id: int, vendor_id: int,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_admin: User = Depends(get_current_admin_user), current_admin: User = Depends(get_current_admin_user),
): ):
"""Verify/unverify shop (Admin only).""" """Verify/unverify vendor (Admin only)."""
shop, message = admin_service.verify_shop(db, shop_id) vendor, message = admin_service.verify_vendor(db, vendor_id)
return {"message": message} return {"message": message}
@router.put("/admin/shops/{shop_id}/status") @router.put("/admin/vendors/{vendor_id}/status")
def toggle_shop_status( def toggle_vendor_status(
shop_id: int, vendor_id: int,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_admin: User = Depends(get_current_admin_user), current_admin: User = Depends(get_current_admin_user),
): ):
"""Toggle shop active status (Admin only).""" """Toggle vendor active status (Admin only)."""
shop, message = admin_service.toggle_shop_status(db, shop_id) vendor, message = admin_service.toggle_vendor_status(db, vendor_id)
return {"message": message} return {"message": message}
@@ -89,7 +89,7 @@ def toggle_shop_status(
) )
def get_all_marketplace_import_jobs( def get_all_marketplace_import_jobs(
marketplace: Optional[str] = Query(None), marketplace: Optional[str] = Query(None),
shop_name: Optional[str] = Query(None), vendor_name: Optional[str] = Query(None),
status: Optional[str] = Query(None), status: Optional[str] = Query(None),
skip: int = Query(0, ge=0), skip: int = Query(0, ge=0),
limit: int = Query(100, ge=1, le=100), 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( return admin_service.get_marketplace_import_jobs(
db=db, db=db,
marketplace=marketplace, marketplace=marketplace,
shop_name=shop_name, vendor_name=vendor_name,
status=status, status=status,
skip=skip, skip=skip,
limit=limit, limit=limit,
@@ -116,10 +116,10 @@ def get_user_statistics(
return admin_service.get_user_statistics(db) return admin_service.get_user_statistics(db)
@router.get("/admin/stats/shops") @router.get("/admin/stats/vendors")
def get_shop_statistics( def get_vendor_statistics(
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_admin: User = Depends(get_current_admin_user), current_admin: User = Depends(get_current_admin_user),
): ):
"""Get shop statistics for admin dashboard (Admin only).""" """Get vendor statistics for admin dashboard (Admin only)."""
return admin_service.get_shop_statistics(db) return admin_service.get_vendor_statistics(db)

View File

@@ -42,7 +42,7 @@ logger = logging.getLogger(__name__)
@router.get("/marketplace/product/export-csv") @router.get("/marketplace/product/export-csv")
async def export_csv( async def export_csv(
marketplace: Optional[str] = Query(None, description="Filter by marketplace"), 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), db: Session = Depends(get_db),
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
): ):
@@ -50,14 +50,14 @@ async def export_csv(
def generate_csv(): def generate_csv():
return marketplace_product_service.generate_csv_export( 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" filename = "marketplace_products_export"
if marketplace: if marketplace:
filename += f"_{marketplace}" filename += f"_{marketplace}"
if shop_name: if vendor_name:
filename += f"_{shop_name}" filename += f"_{vendor_name}"
filename += ".csv" filename += ".csv"
return StreamingResponse( return StreamingResponse(
@@ -75,12 +75,12 @@ def get_products(
category: Optional[str] = Query(None), category: Optional[str] = Query(None),
availability: Optional[str] = Query(None), availability: Optional[str] = Query(None),
marketplace: Optional[str] = Query(None, description="Filter by marketplace"), 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), search: Optional[str] = Query(None),
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(get_current_user), 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( products, total = marketplace_product_service.get_products_with_filters(
db=db, db=db,
skip=skip, skip=skip,
@@ -89,7 +89,7 @@ def get_products(
category=category, category=category,
availability=availability, availability=availability,
marketplace=marketplace, marketplace=marketplace,
shop_name=shop_name, vendor_name=vendor_name,
search=search, search=search,
) )
@@ -166,7 +166,7 @@ async def import_products_from_marketplace(
): ):
"""Import products from marketplace CSV with background processing (Protected).""" """Import products from marketplace CSV with background processing (Protected)."""
logger.info( 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 # Create import job through service
@@ -178,7 +178,7 @@ async def import_products_from_marketplace(
import_job.id, import_job.id,
request.url, request.url,
request.marketplace, request.marketplace,
request.shop_code, request.vendor_code,
request.batch_size or 1000, request.batch_size or 1000,
) )
@@ -186,9 +186,9 @@ async def import_products_from_marketplace(
job_id=import_job.id, job_id=import_job.id,
status="pending", status="pending",
marketplace=request.marketplace, marketplace=request.marketplace,
shop_code=request.shop_code, vendor_code=request.vendor_code,
shop_id=import_job.shop_id, vendor_id=import_job.vendor_id,
shop_name=import_job.shop_name, vendor_name=import_job.vendor_name,
message=f"Marketplace import started from {request.marketplace}. Check status with " message=f"Marketplace import started from {request.marketplace}. Check status with "
f"/import-status/{import_job.id}", f"/import-status/{import_job.id}",
) )
@@ -212,7 +212,7 @@ def get_marketplace_import_status(
) )
def get_marketplace_import_jobs( def get_marketplace_import_jobs(
marketplace: Optional[str] = Query(None, description="Filter by marketplace"), 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), skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=100), limit: int = Query(50, ge=1, le=100),
db: Session = Depends(get_db), db: Session = Depends(get_db),
@@ -223,7 +223,7 @@ def get_marketplace_import_jobs(
db=db, db=db,
user=current_user, user=current_user,
marketplace=marketplace, marketplace=marketplace,
shop_name=shop_name, vendor_name=vendor_name,
skip=skip, skip=skip,
limit=limit, 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_brands=stats_data["unique_brands"],
unique_categories=stats_data["unique_categories"], unique_categories=stats_data["unique_categories"],
unique_marketplaces=stats_data["unique_marketplaces"], 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_stock_entries=stats_data["total_stock_entries"],
total_inventory_quantity=stats_data["total_inventory_quantity"], total_inventory_quantity=stats_data["total_inventory_quantity"],
) )
@@ -87,7 +87,7 @@ def get_stats(
unique_brands=stats_data["unique_brands"], unique_brands=stats_data["unique_brands"],
unique_categories=stats_data["unique_categories"], unique_categories=stats_data["unique_categories"],
unique_marketplaces=stats_data["unique_marketplaces"], 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_stock_entries=stats_data["total_stock_entries"],
total_inventory_quantity=stats_data["total_inventory_quantity"], total_inventory_quantity=stats_data["total_inventory_quantity"],
) )
@@ -104,7 +104,7 @@ def get_marketplace_stats(
MarketplaceStatsResponse( MarketplaceStatsResponse(
marketplace=stat["marketplace"], marketplace=stat["marketplace"],
total_products=stat["total_products"], total_products=stat["total_products"],
unique_shops=stat["unique_shops"], unique_vendors=stat["unique_vendors"],
unique_brands=stat["unique_brands"], unique_brands=stat["unique_brands"],
) )
for stat in marketplace_stats 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 # Clean description without HTML
description: str = """ description: str = """
Marketplace product import and management system with multi-shop support. Marketplace product import and management system with multi-vendor support.
**Features:** **Features:**
- JWT Authentication with role-based access - JWT Authentication with role-based access
- Multi-marketplace product import (CSV processing) - Multi-marketplace product import (CSV processing)
- Inventory management across multiple locations - Inventory management across multiple locations
- Shop management with individual configurations - Vendor management with individual configurations
**Documentation:** Visit /documentation for complete guides **Documentation:** Visit /documentation for complete guides
**API Testing:** Use /docs for interactive API exploration **API Testing:** Use /docs for interactive API exploration

View File

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

View File

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

View File

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

View File

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

View File

@@ -189,12 +189,12 @@ class InvalidMarketplaceException(ValidationException):
class ImportJobAlreadyProcessingException(BusinessLogicException): class ImportJobAlreadyProcessingException(BusinessLogicException):
"""Raised when trying to start import while another is already processing.""" """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__( 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", error_code="IMPORT_JOB_ALREADY_PROCESSING",
details={ details={
"shop_code": shop_code, "vendor_code": vendor_code,
"existing_job_id": existing_job_id, "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 ( from .base import (
@@ -9,26 +9,26 @@ from .base import (
) )
class ProductAlreadyExistsException(ConflictException): 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__( 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", error_code="PRODUCT_ALREADY_EXISTS",
details={ details={
"shop_code": shop_code, "vendor_code": vendor_code,
"marketplace_product_id": marketplace_product_id, "marketplace_product_id": marketplace_product_id,
}, },
) )
class ProductNotFoundException(ResourceNotFoundException): 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__( super().__init__(
resource_type="ShopProduct", resource_type="Product",
identifier=f"{shop_code}/{marketplace_product_id}", identifier=f"{vendor_code}/{marketplace_product_id}",
message=f"MarketplaceProduct '{marketplace_product_id}' not found in shop '{shop_code}'", message=f"MarketplaceProduct '{marketplace_product_id}' not found in vendor '{vendor_code}'",
error_code="PRODUCT_NOT_FOUND", 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 # 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: This module provides classes and functions for:
- User management and status control - User management and status control
- Shop verification and activation - Vendor verification and activation
- Marketplace import job monitoring - Marketplace import job monitoring
""" """
@@ -18,13 +18,13 @@ from app.exceptions import (
UserNotFoundException, UserNotFoundException,
UserStatusChangeException, UserStatusChangeException,
CannotModifySelfException, CannotModifySelfException,
ShopNotFoundException, VendorNotFoundException,
ShopVerificationException, VendorVerificationException,
AdminOperationException, AdminOperationException,
) )
from models.schemas.marketplace_import_job import MarketplaceImportJobResponse from models.schemas.marketplace_import_job import MarketplaceImportJobResponse
from models.database.marketplace_import_job import MarketplaceImportJob 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 from models.database.user import User
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -101,11 +101,11 @@ class AdminService:
reason="Database update failed" reason="Database update failed"
) )
def get_all_shops( def get_all_vendors(
self, db: Session, skip: int = 0, limit: int = 100 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: Args:
db: Database session db: Database session
@@ -113,108 +113,108 @@ class AdminService:
limit: Maximum number of records to return limit: Maximum number of records to return
Returns: Returns:
Tuple of (shops_list, total_count) Tuple of (vendors_list, total_count)
""" """
try: try:
total = db.query(Shop).count() total = db.query(Vendor).count()
shops = db.query(Shop).offset(skip).limit(limit).all() vendors =db.query(Vendor).offset(skip).limit(limit).all()
return shops, total return vendors, total
except Exception as e: except Exception as e:
logger.error(f"Failed to retrieve shops: {str(e)}") logger.error(f"Failed to retrieve vendors: {str(e)}")
raise AdminOperationException( raise AdminOperationException(
operation="get_all_shops", operation="get_all_vendors",
reason="Database query failed" 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: Args:
db: Database session db: Database session
shop_id: ID of shop to verify/unverify vendor_id: ID of vendor to verify/unverify
Returns: Returns:
Tuple of (updated_shop, status_message) Tuple of (updated_vendor, status_message)
Raises: Raises:
ShopNotFoundException: If shop not found VendorNotFoundException: If vendor not found
ShopVerificationException: If verification fails 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: try:
original_status = shop.is_verified original_status = vendor.is_verified
shop.is_verified = not shop.is_verified vendor.is_verified = not vendor.is_verified
shop.updated_at = datetime.now(timezone.utc) vendor.updated_at = datetime.now(timezone.utc)
# Add verification timestamp if implementing audit trail # Add verification timestamp if implementing audit trail
if shop.is_verified: if vendor.is_verified:
shop.verified_at = datetime.now(timezone.utc) vendor.verified_at = datetime.now(timezone.utc)
db.commit() db.commit()
db.refresh(shop) db.refresh(vendor)
status_action = "verified" if shop.is_verified else "unverified" status_action = "verified" if vendor.is_verified else "unverified"
message = f"Shop {shop.shop_code} has been {status_action}" message = f"Vendor {vendor.vendor_code} has been {status_action}"
logger.info(message) logger.info(message)
return shop, message return vendor, message
except Exception as e: except Exception as e:
db.rollback() db.rollback()
logger.error(f"Failed to verify shop {shop_id}: {str(e)}") logger.error(f"Failed to verify vendor {vendor_id}: {str(e)}")
raise ShopVerificationException( raise VendorVerificationException(
shop_id=shop_id, vendor_id=vendor_id,
reason="Database update failed", reason="Database update failed",
current_verification_status=original_status 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: Args:
db: Database session db: Database session
shop_id: ID of shop to activate/deactivate vendor_id: ID of vendor to activate/deactivate
Returns: Returns:
Tuple of (updated_shop, status_message) Tuple of (updated_vendor, status_message)
Raises: Raises:
ShopNotFoundException: If shop not found VendorNotFoundException: If vendor not found
AdminOperationException: If status change fails 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: try:
original_status = shop.is_active original_status = vendor.is_active
shop.is_active = not shop.is_active vendor.is_active = not vendor.is_active
shop.updated_at = datetime.now(timezone.utc) vendor.updated_at = datetime.now(timezone.utc)
db.commit() db.commit()
db.refresh(shop) db.refresh(vendor)
status_action = "activated" if shop.is_active else "deactivated" status_action = "activated" if vendor.is_active else "deactivated"
message = f"Shop {shop.shop_code} has been {status_action}" message = f"Vendor {vendor.vendor_code} has been {status_action}"
logger.info(message) logger.info(message)
return shop, message return vendor , message
except Exception as e: except Exception as e:
db.rollback() 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( raise AdminOperationException(
operation="toggle_shop_status", operation="toggle_vendor_status",
reason="Database update failed", reason="Database update failed",
target_type="shop", target_type="vendor ",
target_id=str(shop_id) target_id=str(vendor_id)
) )
def get_marketplace_import_jobs( def get_marketplace_import_jobs(
self, self,
db: Session, db: Session,
marketplace: Optional[str] = None, marketplace: Optional[str] = None,
shop_name: Optional[str] = None, vendor_name: Optional[str] = None,
status: Optional[str] = None, status: Optional[str] = None,
skip: int = 0, skip: int = 0,
limit: int = 100, limit: int = 100,
@@ -225,7 +225,7 @@ class AdminService:
Args: Args:
db: Database session db: Database session
marketplace: Filter by marketplace name (case-insensitive partial match) 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 status: Filter by exact status
skip: Number of records to skip skip: Number of records to skip
limit: Maximum number of records to return limit: Maximum number of records to return
@@ -241,8 +241,8 @@ class AdminService:
query = query.filter( query = query.filter(
MarketplaceImportJob.marketplace.ilike(f"%{marketplace}%") MarketplaceImportJob.marketplace.ilike(f"%{marketplace}%")
) )
if shop_name: if vendor_name:
query = query.filter(MarketplaceImportJob.shop_name.ilike(f"%{shop_name}%")) query = query.filter(MarketplaceImportJob.vendor_name.ilike(f"%{vendor_name}%"))
if status: if status:
query = query.filter(MarketplaceImportJob.status == status) query = query.filter(MarketplaceImportJob.status == status)
@@ -283,23 +283,23 @@ class AdminService:
reason="Database query failed" reason="Database query failed"
) )
def get_shop_statistics(self, db: Session) -> dict: def get_vendor_statistics(self, db: Session) -> dict:
"""Get shop statistics for admin dashboard.""" """Get vendor statistics for admin dashboard."""
try: try:
total_shops = db.query(Shop).count() total_vendors = db.query(Vendor).count()
active_shops = db.query(Shop).filter(Shop.is_active == True).count() active_vendors = db.query(Vendor).filter(Vendor.is_active == True).count()
verified_shops = db.query(Shop).filter(Shop.is_verified == True).count() verified_vendors = db.query(Vendor).filter(Vendor.is_verified == True).count()
return { return {
"total_shops": total_shops, "total_vendors": total_vendors,
"active_shops": active_shops, "active_vendors": active_vendors,
"verified_shops": verified_shops, "verified_vendors": verified_vendors,
"verification_rate": (verified_shops / total_shops * 100) if total_shops > 0 else 0 "verification_rate": (verified_vendors / total_vendors * 100) if total_vendors > 0 else 0
} }
except Exception as e: 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( raise AdminOperationException(
operation="get_shop_statistics", operation="get_vendor_statistics",
reason="Database query failed" reason="Database query failed"
) )
@@ -311,12 +311,12 @@ class AdminService:
raise UserNotFoundException(str(user_id)) raise UserNotFoundException(str(user_id))
return user return user
def _get_shop_by_id_or_raise(self, db: Session, shop_id: int) -> Shop: def _get_vendor_by_id_or_raise(self, db: Session, vendor_id: int) -> Vendor:
"""Get shop by ID or raise ShopNotFoundException.""" """Get vendor by ID or raise VendorNotFoundException."""
shop = db.query(Shop).filter(Shop.id == shop_id).first() vendor = db.query(Vendor).filter(Vendor.id == vendor_id).first()
if not shop: if not vendor :
raise ShopNotFoundException(str(shop_id), identifier_type="id") raise VendorNotFoundException(str(vendor_id), identifier_type="id")
return shop return vendor
def _convert_job_to_response(self, job: MarketplaceImportJob) -> MarketplaceImportJobResponse: def _convert_job_to_response(self, job: MarketplaceImportJob) -> MarketplaceImportJobResponse:
"""Convert database model to response schema.""" """Convert database model to response schema."""
@@ -324,9 +324,9 @@ class AdminService:
job_id=job.id, job_id=job.id,
status=job.status, status=job.status,
marketplace=job.marketplace, marketplace=job.marketplace,
shop_id=job.shop.id if job.shop else None, vendor_id=job.vendor.id if job.vendor else None,
shop_code=job.shop.shop_code if job.shop else None, vendor_code=job.vendor.vendor_code if job.vendor else None,
shop_name=job.shop_name, vendor_name=job.vendor_name,
imported=job.imported_count or 0, imported=job.imported_count or 0,
updated=job.updated_count or 0, updated=job.updated_count or 0,
total_processed=job.total_processed 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: This module provides classes and functions for:
- Import job creation and management - Import job creation and management
- Shop access validation - Vendor access validation
- Import job status tracking and updates - Import job status tracking and updates
""" """
@@ -16,8 +16,8 @@ from sqlalchemy import func
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.exceptions import ( from app.exceptions import (
ShopNotFoundException, VendorNotFoundException,
UnauthorizedShopAccessException, UnauthorizedVendorAccessException,
ImportJobNotFoundException, ImportJobNotFoundException,
ImportJobNotOwnedException, ImportJobNotOwnedException,
ImportJobCannotBeCancelledException, ImportJobCannotBeCancelledException,
@@ -27,7 +27,7 @@ from app.exceptions import (
from models.schemas.marketplace_import_job import (MarketplaceImportJobResponse, from models.schemas.marketplace_import_job import (MarketplaceImportJobResponse,
MarketplaceImportJobRequest) MarketplaceImportJobRequest)
from models.database.marketplace_import_job import MarketplaceImportJob 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 from models.database.user import User
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -36,44 +36,44 @@ logger = logging.getLogger(__name__)
class MarketplaceImportJobService: class MarketplaceImportJobService:
"""Service class for Marketplace operations following the application's service pattern.""" """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: Args:
db: Database session db: Database session
shop_code: Shop code to validate vendor_code: Vendor code to validate
user: User requesting access user: User requesting access
Returns: Returns:
Shop object if access is valid Vendor object if access is valid
Raises: Raises:
ShopNotFoundException: If shop doesn't exist VendorNotFoundException: If vendor doesn't exist
UnauthorizedShopAccessException: If user lacks access UnauthorizedVendorAccessException: If user lacks access
""" """
try: try:
# Use case-insensitive query to handle both uppercase and lowercase codes # Use case-insensitive query to handle both uppercase and lowercase codes
shop = ( vendor = (
db.query(Shop) db.query(Vendor)
.filter(func.upper(Shop.shop_code) == shop_code.upper()) .filter(func.upper(Vendor.vendor_code) == vendor_code.upper())
.first() .first()
) )
if not shop: if not vendor :
raise ShopNotFoundException(shop_code) raise VendorNotFoundException(vendor_code)
# Check permissions: admin can import for any shop, others only for their own # Check permissions: admin can import for any vendor, others only for their own
if user.role != "admin" and shop.owner_id != user.id: if user.role != "admin" and vendor.owner_id != user.id:
raise UnauthorizedShopAccessException(shop_code, user.id) raise UnauthorizedVendorAccessException(vendor_code, user.id)
return shop return vendor
except (ShopNotFoundException, UnauthorizedShopAccessException): except (VendorNotFoundException, UnauthorizedVendorAccessException):
raise # Re-raise custom exceptions raise # Re-raise custom exceptions
except Exception as e: except Exception as e:
logger.error(f"Error validating shop access: {str(e)}") logger.error(f"Error validating vendor access: {str(e)}")
raise ValidationException("Failed to validate shop access") raise ValidationException("Failed to validate vendor access")
def create_import_job( def create_import_job(
self, db: Session, request: MarketplaceImportJobRequest, user: User self, db: Session, request: MarketplaceImportJobRequest, user: User
@@ -90,21 +90,21 @@ class MarketplaceImportJobService:
Created MarketplaceImportJob object Created MarketplaceImportJob object
Raises: Raises:
ShopNotFoundException: If shop doesn't exist VendorNotFoundException: If vendor doesn't exist
UnauthorizedShopAccessException: If user lacks shop access UnauthorizedVendorAccessException: If user lacks vendor access
ValidationException: If job creation fails ValidationException: If job creation fails
""" """
try: try:
# Validate shop access first # Validate vendor access first
shop = self.validate_shop_access(db, request.shop_code, user) vendor = self.validate_vendor_access(db, request.vendor_code, user)
# Create marketplace import job record # Create marketplace import job record
import_job = MarketplaceImportJob( import_job = MarketplaceImportJob(
status="pending", status="pending",
source_url=request.url, source_url=request.url,
marketplace=request.marketplace, marketplace=request.marketplace,
shop_id=shop.id, # Foreign key to shops table vendor_id=vendor.id, # Foreign key to vendors table
shop_name=shop.shop_name, # Use shop.shop_name (the display name) vendor_name=vendor.vendor_name, # Use vendor.vendor_name (the display name)
user_id=user.id, user_id=user.id,
created_at=datetime.now(timezone.utc), created_at=datetime.now(timezone.utc),
) )
@@ -115,12 +115,12 @@ class MarketplaceImportJobService:
logger.info( logger.info(
f"Created marketplace import job {import_job.id}: " 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 return import_job
except (ShopNotFoundException, UnauthorizedShopAccessException): except (VendorNotFoundException, UnauthorizedVendorAccessException):
raise # Re-raise custom exceptions raise # Re-raise custom exceptions
except Exception as e: except Exception as e:
db.rollback() db.rollback()
@@ -172,7 +172,7 @@ class MarketplaceImportJobService:
db: Session, db: Session,
user: User, user: User,
marketplace: Optional[str] = None, marketplace: Optional[str] = None,
shop_name: Optional[str] = None, vendor_name: Optional[str] = None,
skip: int = 0, skip: int = 0,
limit: int = 50, limit: int = 50,
) -> List[MarketplaceImportJob]: ) -> List[MarketplaceImportJob]:
@@ -183,7 +183,7 @@ class MarketplaceImportJobService:
db: Database session db: Database session
user: User requesting jobs user: User requesting jobs
marketplace: Optional marketplace filter marketplace: Optional marketplace filter
shop_name: Optional shop name filter vendor_name: Optional vendor name filter
skip: Number of records to skip skip: Number of records to skip
limit: Maximum records to return limit: Maximum records to return
@@ -202,8 +202,8 @@ class MarketplaceImportJobService:
query = query.filter( query = query.filter(
MarketplaceImportJob.marketplace.ilike(f"%{marketplace}%") MarketplaceImportJob.marketplace.ilike(f"%{marketplace}%")
) )
if shop_name: if vendor_name:
query = query.filter(MarketplaceImportJob.shop_name.ilike(f"%{shop_name}%")) query = query.filter(MarketplaceImportJob.vendor_name.ilike(f"%{vendor_name}%"))
# Order by creation date (newest first) and apply pagination # Order by creation date (newest first) and apply pagination
jobs = ( jobs = (
@@ -319,11 +319,11 @@ class MarketplaceImportJobService:
job_id=job.id, job_id=job.id,
status=job.status, status=job.status,
marketplace=job.marketplace, marketplace=job.marketplace,
shop_id=job.shop_id, vendor_id=job.vendor_id,
shop_code=( vendor_code=(
job.shop.shop_code if job.shop else None job.vendor.vendor_code if job.vendor else None
), # Add this optional field via relationship ), # Add this optional field via relationship
shop_name=job.shop_name, vendor_name=job.vendor_name,
imported=job.imported_count or 0, imported=job.imported_count or 0,
updated=job.updated_count or 0, updated=job.updated_count or 0,
total_processed=job.total_processed or 0, total_processed=job.total_processed or 0,

View File

@@ -135,7 +135,7 @@ class MarketplaceProductService:
category: Optional[str] = None, category: Optional[str] = None,
availability: Optional[str] = None, availability: Optional[str] = None,
marketplace: Optional[str] = None, marketplace: Optional[str] = None,
shop_name: Optional[str] = None, vendor_name: Optional[str] = None,
search: Optional[str] = None, search: Optional[str] = None,
) -> Tuple[List[MarketplaceProduct], int]: ) -> Tuple[List[MarketplaceProduct], int]:
""" """
@@ -149,7 +149,7 @@ class MarketplaceProductService:
category: Category filter category: Category filter
availability: Availability filter availability: Availability filter
marketplace: Marketplace filter marketplace: Marketplace filter
shop_name: Shop name filter vendor_name: Vendor name filter
search: Search term search: Search term
Returns: Returns:
@@ -167,16 +167,16 @@ class MarketplaceProductService:
query = query.filter(MarketplaceProduct.availability == availability) query = query.filter(MarketplaceProduct.availability == availability)
if marketplace: if marketplace:
query = query.filter(MarketplaceProduct.marketplace.ilike(f"%{marketplace}%")) query = query.filter(MarketplaceProduct.marketplace.ilike(f"%{marketplace}%"))
if shop_name: if vendor_name:
query = query.filter(MarketplaceProduct.shop_name.ilike(f"%{shop_name}%")) query = query.filter(MarketplaceProduct.vendor_name.ilike(f"%{vendor_name}%"))
if search: if search:
# Search in title, description, marketplace, and shop_name # Search in title, description, marketplace, and vendor_name
search_term = f"%{search}%" search_term = f"%{search}%"
query = query.filter( query = query.filter(
(MarketplaceProduct.title.ilike(search_term)) (MarketplaceProduct.title.ilike(search_term))
| (MarketplaceProduct.description.ilike(search_term)) | (MarketplaceProduct.description.ilike(search_term))
| (MarketplaceProduct.marketplace.ilike(search_term)) | (MarketplaceProduct.marketplace.ilike(search_term))
| (MarketplaceProduct.shop_name.ilike(search_term)) | (MarketplaceProduct.vendor_name.ilike(search_term))
) )
total = query.count() total = query.count()
@@ -311,7 +311,7 @@ class MarketplaceProductService:
self, self,
db: Session, db: Session,
marketplace: Optional[str] = None, marketplace: Optional[str] = None,
shop_name: Optional[str] = None, vendor_name: Optional[str] = None,
) -> Generator[str, None, None]: ) -> Generator[str, None, None]:
""" """
Generate CSV export with streaming for memory efficiency and proper CSV escaping. Generate CSV export with streaming for memory efficiency and proper CSV escaping.
@@ -319,7 +319,7 @@ class MarketplaceProductService:
Args: Args:
db: Database session db: Database session
marketplace: Optional marketplace filter marketplace: Optional marketplace filter
shop_name: Optional shop name filter vendor_name: Optional vendor name filter
Yields: Yields:
CSV content as strings with proper escaping CSV content as strings with proper escaping
@@ -333,7 +333,7 @@ class MarketplaceProductService:
headers = [ headers = [
"marketplace_product_id", "title", "description", "link", "image_link", "marketplace_product_id", "title", "description", "link", "image_link",
"availability", "price", "currency", "brand", "gtin", "availability", "price", "currency", "brand", "gtin",
"marketplace", "shop_name" "marketplace", "vendor_name"
] ]
writer.writerow(headers) writer.writerow(headers)
yield output.getvalue() yield output.getvalue()
@@ -351,8 +351,8 @@ class MarketplaceProductService:
# Apply marketplace filters # Apply marketplace filters
if marketplace: if marketplace:
query = query.filter(MarketplaceProduct.marketplace.ilike(f"%{marketplace}%")) query = query.filter(MarketplaceProduct.marketplace.ilike(f"%{marketplace}%"))
if shop_name: if vendor_name:
query = query.filter(MarketplaceProduct.shop_name.ilike(f"%{shop_name}%")) query = query.filter(MarketplaceProduct.vendor_name.ilike(f"%{vendor_name}%"))
products = query.offset(offset).limit(batch_size).all() products = query.offset(offset).limit(batch_size).all()
if not products: if not products:
@@ -372,7 +372,7 @@ class MarketplaceProductService:
product.brand or "", product.brand or "",
product.gtin or "", product.gtin or "",
product.marketplace or "", product.marketplace or "",
product.shop_name or "", product.vendor_name or "",
] ]
writer.writerow(row_data) writer.writerow(row_data)
@@ -413,7 +413,7 @@ class MarketplaceProductService:
normalized = product_data.copy() normalized = product_data.copy()
# Trim whitespace from string fields # 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: for field in string_fields:
if field in normalized and normalized[field]: if field in normalized and normalized[field]:
normalized[field] = normalized[field].strip() 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_brands = self._get_unique_brands_count(db)
unique_categories = self._get_unique_categories_count(db) unique_categories = self._get_unique_categories_count(db)
unique_marketplaces = self._get_unique_marketplaces_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 statistics
stock_stats = self._get_stock_statistics(db) stock_stats = self._get_stock_statistics(db)
@@ -54,7 +54,7 @@ class StatsService:
"unique_brands": unique_brands, "unique_brands": unique_brands,
"unique_categories": unique_categories, "unique_categories": unique_categories,
"unique_marketplaces": unique_marketplaces, "unique_marketplaces": unique_marketplaces,
"unique_shops": unique_shops, "unique_vendors": unique_vendors,
"total_stock_entries": stock_stats["total_stock_entries"], "total_stock_entries": stock_stats["total_stock_entries"],
"total_inventory_quantity": stock_stats["total_inventory_quantity"], "total_inventory_quantity": stock_stats["total_inventory_quantity"],
} }
@@ -87,7 +87,7 @@ class StatsService:
db.query( db.query(
MarketplaceProduct.marketplace, MarketplaceProduct.marketplace,
func.count(MarketplaceProduct.id).label("total_products"), 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"), func.count(func.distinct(MarketplaceProduct.brand)).label("unique_brands"),
) )
.filter(MarketplaceProduct.marketplace.isnot(None)) .filter(MarketplaceProduct.marketplace.isnot(None))
@@ -99,7 +99,7 @@ class StatsService:
{ {
"marketplace": stat.marketplace, "marketplace": stat.marketplace,
"total_products": stat.total_products, "total_products": stat.total_products,
"unique_shops": stat.unique_shops, "unique_vendors": stat.unique_vendors,
"unique_brands": stat.unique_brands, "unique_brands": stat.unique_brands,
} }
for stat in marketplace_stats for stat in marketplace_stats
@@ -130,7 +130,7 @@ class StatsService:
"unique_brands": self._get_unique_brands_count(db), "unique_brands": self._get_unique_brands_count(db),
"unique_categories": self._get_unique_categories_count(db), "unique_categories": self._get_unique_categories_count(db),
"unique_marketplaces": self._get_unique_marketplaces_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_gtin": self._get_products_with_gtin_count(db),
"products_with_images": self._get_products_with_images_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) product_count = self._get_products_by_marketplace_count(db, marketplace)
brands = self._get_brands_by_marketplace(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 { return {
"marketplace": marketplace, "marketplace": marketplace,
"total_products": product_count, "total_products": product_count,
"unique_brands": len(brands), "unique_brands": len(brands),
"unique_shops": len(shops), "unique_vendors": len(vendors),
"brands": brands, "brands": brands,
"shops": shops, "vendors": vendors,
} }
except ValidationException: except ValidationException:
@@ -227,11 +227,11 @@ class StatsService:
.count() .count()
) )
def _get_unique_shops_count(self, db: Session) -> int: def _get_unique_vendors_count(self, db: Session) -> int:
"""Get count of unique shops.""" """Get count of unique vendors."""
return ( return (
db.query(MarketplaceProduct.shop_name) db.query(MarketplaceProduct.vendor_name)
.filter(MarketplaceProduct.shop_name.isnot(None), MarketplaceProduct.shop_name != "") .filter(MarketplaceProduct.vendor_name.isnot(None), MarketplaceProduct.vendor_name != "")
.distinct() .distinct()
.count() .count()
) )
@@ -276,19 +276,19 @@ class StatsService:
) )
return [brand[0] for brand in brands] return [brand[0] for brand in brands]
def _get_shops_by_marketplace(self, db: Session, marketplace: str) -> List[str]: def _get_vendors_by_marketplace(self, db: Session, marketplace: str) -> List[str]:
"""Get unique shops for a specific marketplace.""" """Get unique vendors for a specific marketplace."""
shops = ( vendors =(
db.query(MarketplaceProduct.shop_name) db.query(MarketplaceProduct.vendor_name)
.filter( .filter(
MarketplaceProduct.marketplace == marketplace, MarketplaceProduct.marketplace == marketplace,
MarketplaceProduct.shop_name.isnot(None), MarketplaceProduct.vendor_name.isnot(None),
MarketplaceProduct.shop_name != "", MarketplaceProduct.vendor_name != "",
) )
.distinct() .distinct()
.all() .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: def _get_products_by_marketplace_count(self, db: Session, marketplace: str) -> int:
"""Get product count for a specific marketplace.""" """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( 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.""" """Background task to process marketplace CSV import."""
db = SessionLocal() db = SessionLocal()
@@ -44,7 +44,7 @@ async def process_marketplace_import(
# Process CSV # Process CSV
result = await csv_processor.process_marketplace_csv_from_url( 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 # Update job with results

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -55,7 +55,7 @@ class MarketplaceProduct(Base, TimestampMixin):
marketplace = Column( marketplace = Column(
String, index=True, nullable=True, default="Letzshop" String, index=True, nullable=True, default="Letzshop"
) # Index for marketplace filtering ) # 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) # Relationship to stock (one-to-many via GTIN)
stock_entries = relationship( stock_entries = relationship(
@@ -69,8 +69,8 @@ class MarketplaceProduct(Base, TimestampMixin):
# Additional indexes for marketplace queries # Additional indexes for marketplace queries
__table_args__ = ( __table_args__ = (
Index( Index(
"idx_marketplace_shop", "marketplace", "shop_name" "idx_marketplace_vendor", "marketplace", "vendor_name"
), # Composite index for marketplace+shop queries ), # Composite index for marketplace+vendor queries
Index( Index(
"idx_marketplace_brand", "marketplace", "brand" "idx_marketplace_brand", "marketplace", "brand"
), # Composite index for marketplace+brand queries ), # Composite index for marketplace+brand queries
@@ -79,5 +79,5 @@ class MarketplaceProduct(Base, TimestampMixin):
def __repr__(self): def __repr__(self):
return ( return (
f"<MarketplaceProduct(marketplace_product_id='{self.marketplace_product_id}', title='{self.title}', marketplace='{self.marketplace}', " 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 app.core.database import Base
from models.database.base import TimestampMixin from models.database.base import TimestampMixin
class Product(Base): class Product(Base, TimestampMixin):
__tablename__ = "products" __tablename__ = "products"
id = Column(Integer, primary_key=True, index=True) 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) marketplace_product_id = Column(Integer, ForeignKey("marketplace_products.id"), nullable=False)
# Shop-specific overrides (can override the main product data) # Shop-specific overrides (can override the main product data)
@@ -32,17 +32,13 @@ class Product(Base):
min_quantity = Column(Integer, default=1) min_quantity = Column(Integer, default=1)
max_quantity = Column(Integer) max_quantity = Column(Integer)
# Timestamps
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
# Relationships # Relationships
shop = relationship("Shop", back_populates="product") vendor = relationship("Vendor", back_populates="product")
marketplace_product = relationship("MarketplaceProduct", back_populates="product") marketplace_product = relationship("MarketplaceProduct", back_populates="product")
# Constraints # Constraints
__table_args__ = ( __table_args__ = (
UniqueConstraint("shop_id", "marketplace_product_id", name="uq_product"), UniqueConstraint("vendor_id", "marketplace_product_id", name="uq_product"),
Index("idx_product_active", "shop_id", "is_active"), Index("idx_product_active", "vendor_id", "is_active"),
Index("idx_product_featured", "shop_id", "is_featured"), 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) location = Column(String, nullable=False, index=True)
quantity = Column(Integer, nullable=False, default=0) quantity = Column(Integer, nullable=False, default=0)
reserved_quantity = Column(Integer, default=0) # For orders being processed 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 # Relationships
shop = relationship("Shop") vendor = relationship("Shop")
# Composite unique constraint to prevent duplicate GTIN-location combinations # Composite unique constraint to prevent duplicate GTIN-location combinations
__table_args__ = ( __table_args__ = (

View File

@@ -15,7 +15,7 @@ class User(Base, TimestampMixin):
email = Column(String, unique=True, index=True, nullable=False) email = Column(String, unique=True, index=True, nullable=False)
username = Column(String, unique=True, index=True, nullable=False) username = Column(String, unique=True, index=True, nullable=False)
hashed_password = Column(String, 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) is_active = Column(Boolean, default=True, nullable=False)
last_login = Column(DateTime, nullable=True) last_login = Column(DateTime, nullable=True)
@@ -23,7 +23,7 @@ class User(Base, TimestampMixin):
marketplace_import_jobs = relationship( marketplace_import_jobs = relationship(
"MarketplaceImportJob", back_populates="user" "MarketplaceImportJob", back_populates="user"
) )
owned_shops = relationship("Shop", back_populates="owner") owned_vendors = relationship("Vendor", back_populates="owner")
def __repr__(self): def __repr__(self):
return f"<User(username='{self.username}', email='{self.email}', role='{self.role}')>" 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, ForeignKey, Integer, String, Text)
from sqlalchemy import (Boolean, Column, DateTime, Float, ForeignKey, Index,
Integer, String, Text, UniqueConstraint)
from sqlalchemy.orm import relationship from sqlalchemy.orm import relationship
# Import Base from the central database module instead of creating a new one # 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 from models.database.base import TimestampMixin
class Shop(Base, TimestampMixin): class Vendor(Base, TimestampMixin):
__tablename__ = "shops" __tablename__ = "vendors"
id = Column(Integer, primary_key=True, index=True) id = Column(Integer, primary_key=True, index=True)
shop_code = Column( vendor_code = Column(
String, unique=True, index=True, nullable=False String, unique=True, index=True, nullable=False
) # e.g., "TECHSTORE", "FASHIONHUB" ) # e.g., "TECHSTORE", "FASHIONHUB"
shop_name = Column(String, nullable=False) # Display name vendor_name = Column(String, nullable=False) # Display name
description = Column(Text) description = Column(Text)
owner_id = Column(Integer, ForeignKey("users.id"), nullable=False) owner_id = Column(Integer, ForeignKey("users.id"), nullable=False)
@@ -34,8 +31,8 @@ class Shop(Base, TimestampMixin):
is_verified = Column(Boolean, default=False) is_verified = Column(Boolean, default=False)
# Relationships # Relationships
owner = relationship("User", back_populates="owned_shops") owner = relationship("User", back_populates="owned_vendors")
product = relationship("Product", back_populates="shop") product = relationship("Product", back_populates="vendor")
marketplace_import_jobs = relationship( marketplace_import_jobs = relationship(
"MarketplaceImportJob", back_populates="shop" "MarketplaceImportJob", back_populates="vendor"
) )

View File

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

View File

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

View File

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

View File

@@ -43,7 +43,7 @@ class MarketplaceProductBase(BaseModel):
shipping: Optional[str] = None shipping: Optional[str] = None
currency: Optional[str] = None currency: Optional[str] = None
marketplace: Optional[str] = None marketplace: Optional[str] = None
shop_name: Optional[str] = None vendor_name: Optional[str] = None
class MarketplaceProductCreate(MarketplaceProductBase): class MarketplaceProductCreate(MarketplaceProductBase):
marketplace_product_id: str = Field(..., description="MarketplaceProduct identifier") 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 from models.schemas.marketplace_product import MarketplaceProductResponse
class ProductCreate(BaseModel): 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 product_id: Optional[str] = None
price: Optional[float] = None # Removed: ge=0 constraint price: Optional[float] = None # Removed: ge=0 constraint
sale_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): class ProductResponse(BaseModel):
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
id: int id: int
shop_id: int vendor_id: int
marketplace_product: MarketplaceProductResponse marketplace_product: MarketplaceProductResponse
product_id: Optional[str] product_id: Optional[str]
price: Optional[float] price: Optional[float]

View File

@@ -10,7 +10,7 @@ class StatsResponse(BaseModel):
unique_brands: int unique_brands: int
unique_categories: int unique_categories: int
unique_marketplaces: int = 0 unique_marketplaces: int = 0
unique_shops: int = 0 unique_vendors: int = 0
total_stock_entries: int = 0 total_stock_entries: int = 0
total_inventory_quantity: int = 0 total_inventory_quantity: int = 0
@@ -18,5 +18,5 @@ class StatsResponse(BaseModel):
class MarketplaceStatsResponse(BaseModel): class MarketplaceStatsResponse(BaseModel):
marketplace: str marketplace: str
total_products: int total_products: int
unique_shops: int unique_vendors: int
unique_brands: 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 import re
from datetime import datetime from datetime import datetime
from typing import List, Optional from typing import List, Optional
from pydantic import BaseModel, ConfigDict, Field, field_validator from pydantic import BaseModel, ConfigDict, Field, field_validator
class ShopCreate(BaseModel): class VendorCreate(BaseModel):
shop_code: str = Field(..., description="Unique shop identifier") vendor_code: str = Field(..., description="Unique vendor identifier")
shop_name: str = Field(..., description="Display name of the shop") vendor_name: str = Field(..., description="Display name of the vendor ")
description: Optional[str] = Field(None, description="Shop description") description: Optional[str] = Field(None, description="Vendor description")
contact_email: Optional[str] = None contact_email: Optional[str] = None
contact_phone: Optional[str] = None contact_phone: Optional[str] = None
website: Optional[str] = None website: Optional[str] = None
@@ -23,8 +23,8 @@ class ShopCreate(BaseModel):
raise ValueError("Invalid email format") raise ValueError("Invalid email format")
return v.lower() if v else v return v.lower() if v else v
class ShopUpdate(BaseModel): class VendorUpdate(BaseModel):
shop_name: Optional[str] = None vendor_name: Optional[str] = None
description: Optional[str] = None description: Optional[str] = None
contact_email: Optional[str] = None contact_email: Optional[str] = None
contact_phone: Optional[str] = None contact_phone: Optional[str] = None
@@ -39,11 +39,11 @@ class ShopUpdate(BaseModel):
raise ValueError("Invalid email format") raise ValueError("Invalid email format")
return v.lower() if v else v return v.lower() if v else v
class ShopResponse(BaseModel): class VendorResponse(BaseModel):
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
id: int id: int
shop_code: str vendor_code: str
shop_name: str vendor_name: str
description: Optional[str] description: Optional[str]
owner_id: int owner_id: int
contact_email: Optional[str] contact_email: Optional[str]
@@ -56,8 +56,8 @@ class ShopResponse(BaseModel):
created_at: datetime created_at: datetime
updated_at: datetime updated_at: datetime
class ShopListResponse(BaseModel): class VendorListResponse(BaseModel):
shops: List[ShopResponse] vendors: List[VendorResponse]
total: int total: int
skip: int skip: int
limit: int limit: int

View File

@@ -36,7 +36,7 @@ markers =
auth: marks tests as authentication and authorization tests auth: marks tests as authentication and authorization tests
products: marks tests as product management functionality products: marks tests as product management functionality
stock: marks tests as stock and inventory management 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 admin: marks tests as admin functionality and permissions
marketplace: marks tests as marketplace import functionality marketplace: marks tests as marketplace import functionality
stats: marks tests as statistics and reporting stats: marks tests as statistics and reporting

View File

@@ -65,7 +65,7 @@ def verify_database_setup():
# Expected tables from your models # Expected tables from your models
expected_tables = [ expected_tables = [
'users', 'products', 'stock', 'shops', 'products', 'users', 'products', 'stock', 'vendors', 'products',
'marketplace_import_jobs', 'alembic_version' 'marketplace_import_jobs', 'alembic_version'
] ]
@@ -133,7 +133,7 @@ def verify_model_structure():
from models.database.user import User from models.database.user import User
from models.database.marketplace_product import MarketplaceProduct from models.database.marketplace_product import MarketplaceProduct
from models.database.stock import Stock 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.product import Product
from models.database.marketplace_import_job import MarketplaceImportJob from models.database.marketplace_import_job import MarketplaceImportJob
@@ -149,7 +149,7 @@ def verify_model_structure():
print("[OK] API models package imported") print("[OK] API models package imported")
# Test specific API model imports # 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: for module in api_modules:
try: try:
__import__(f'models.api.{module}') __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 # Import all models to ensure they're registered with Base metadata
from models.database.marketplace_import_job import MarketplaceImportJob from models.database.marketplace_import_job import MarketplaceImportJob
from models.database.marketplace_product import MarketplaceProduct 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.product import Product
from models.database.stock import Stock from models.database.stock import Stock
from models.database.user import User from models.database.user import User
@@ -89,7 +89,7 @@ def cleanup():
pytest_plugins = [ pytest_plugins = [
"tests.fixtures.auth_fixtures", "tests.fixtures.auth_fixtures",
"tests.fixtures.marketplace_product_fixtures", "tests.fixtures.marketplace_product_fixtures",
"tests.fixtures.shop_fixtures", "tests.fixtures.vendor_fixtures",
"tests.fixtures.marketplace_import_job_fixtures", "tests.fixtures.marketplace_import_job_fixtures",
"tests.fixtures.testing_fixtures", "tests.fixtures.testing_fixtures",
] ]

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,7 +2,7 @@
import pytest import pytest
from models.database.marketplace_product import MarketplaceProduct from models.database.marketplace_product import MarketplaceProduct
from models.database.shop import Shop from models.database.vendor import Vendor
@pytest.mark.integration @pytest.mark.integration
@pytest.mark.api @pytest.mark.api
@@ -181,34 +181,34 @@ class TestPagination:
overlap = set(first_page_ids) & set(second_page_ids) overlap = set(first_page_ids) & set(second_page_ids)
assert len(overlap) == 0, "Pages should not have overlapping products" assert len(overlap) == 0, "Pages should not have overlapping products"
def test_shop_pagination_success(self, client, admin_headers, db, test_user): def test_vendor_pagination_success(self, client, admin_headers, db, test_user):
"""Test pagination for shop listing successfully""" """Test pagination for vendor listing successfully"""
import uuid import uuid
unique_suffix = str(uuid.uuid4())[:8] unique_suffix = str(uuid.uuid4())[:8]
# Create multiple shops for pagination testing # Create multiple vendors for pagination testing
from models.database.shop import Shop from models.database.vendor import Vendor
shops = [] vendors =[]
for i in range(15): for i in range(15):
shop = Shop( vendor = Vendor(
shop_code=f"PAGESHOP{i:03d}_{unique_suffix}", vendor_code=f"PAGESHOP{i:03d}_{unique_suffix}",
shop_name=f"Pagination Shop {i}", vendor_name=f"Pagination Vendor {i}",
owner_id=test_user.id, owner_id=test_user.id,
is_active=True, is_active=True,
) )
shops.append(shop) vendors.append(vendor)
db.add_all(shops) db.add_all(vendors)
db.commit() db.commit()
# Test first page (assuming admin endpoint exists) # Test first page (assuming admin endpoint exists)
response = client.get( 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 assert response.status_code == 200
data = response.json() data = response.json()
assert len(data["shops"]) == 5 assert len(data["vendors"]) == 5
assert data["total"] >= 15 # At least our test shops assert data["total"] >= 15 # At least our test vendors
assert data["skip"] == 0 assert data["skip"] == 0
assert data["limit"] == 5 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_brands" in data
assert "unique_categories" in data assert "unique_categories" in data
assert "unique_marketplaces" in data assert "unique_marketplaces" in data
assert "unique_shops" in data assert "unique_vendors" in data
assert data["total_products"] >= 1 assert data["total_products"] >= 1
def test_get_marketplace_stats(self, client, auth_headers, test_marketplace_product): 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""" """Test that protected endpoints reject unauthenticated requests"""
protected_endpoints = [ protected_endpoints = [
"/api/v1/admin/users", "/api/v1/admin/users",
"/api/v1/admin/shops", "/api/v1/admin/vendors",
"/api/v1/marketplace/import-jobs", "/api/v1/marketplace/import-jobs",
"/api/v1/marketplace/product", "/api/v1/marketplace/product",
"/api/v1/shop", "/api/v1/vendor ",
"/api/v1/stats", "/api/v1/stats",
"/api/v1/stock", "/api/v1/stock",
] ]

View File

@@ -16,7 +16,7 @@ class TestAuthorization:
"""Test that admin users can access admin endpoints""" """Test that admin users can access admin endpoints"""
admin_endpoints = [ admin_endpoints = [
"/api/v1/admin/users", "/api/v1/admin/users",
"/api/v1/admin/shops", "/api/v1/admin/vendors",
"/api/v1/admin/marketplace-import-jobs", "/api/v1/admin/marketplace-import-jobs",
] ]
@@ -36,15 +36,15 @@ class TestAuthorization:
response = client.get(endpoint, headers=auth_headers) response = client.get(endpoint, headers=auth_headers)
assert response.status_code == 200 # Regular user should have access assert response.status_code == 200 # Regular user should have access
def test_shop_owner_access_control( def test_vendor_owner_access_control(
self, client, auth_headers, test_shop, other_user self, client, auth_headers, test_vendor, other_user
): ):
"""Test that users can only access their own shops""" """Test that users can only access their own vendors"""
# Test accessing own shop (should work) # Test accessing own vendor (should work)
response = client.get( 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] 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 @pytest.mark.marketplace
class TestBackgroundTasks: class TestBackgroundTasks:
@pytest.mark.asyncio @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""" """Test successful marketplace import background task"""
# Create import job # Create import job
job = MarketplaceImportJob( job = MarketplaceImportJob(
status="pending", status="pending",
source_url="http://example.com/test.csv", source_url="http://example.com/test.csv",
shop_name="TESTSHOP", vendor_name="TESTSHOP",
marketplace="TestMarket", marketplace="TestMarket",
shop_id=test_shop.id, vendor_id=test_vendor.id,
user_id=test_user.id, user_id=test_user.id,
) )
db.add(job) db.add(job)
@@ -67,15 +67,15 @@ class TestBackgroundTasks:
assert updated_job.completed_at is not None assert updated_job.completed_at is not None
@pytest.mark.asyncio @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""" """Test marketplace import failure handling"""
# Create import job # Create import job
job = MarketplaceImportJob( job = MarketplaceImportJob(
status="pending", status="pending",
source_url="http://example.com/test.csv", source_url="http://example.com/test.csv",
shop_name="TESTSHOP", vendor_name="TESTSHOP",
marketplace="TestMarket", marketplace="TestMarket",
shop_id=test_shop.id, vendor_id=test_vendor.id,
user_id=test_user.id, user_id=test_user.id,
) )
db.add(job) db.add(job)
@@ -151,15 +151,15 @@ class TestBackgroundTasks:
mock_instance.process_marketplace_csv_from_url.assert_not_called() mock_instance.process_marketplace_csv_from_url.assert_not_called()
@pytest.mark.asyncio @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""" """Test marketplace import with some errors"""
# Create import job # Create import job
job = MarketplaceImportJob( job = MarketplaceImportJob(
status="pending", status="pending",
source_url="http://example.com/test.csv", source_url="http://example.com/test.csv",
shop_name="TESTSHOP", vendor_name="TESTSHOP",
marketplace="TestMarket", marketplace="TestMarket",
shop_id=test_shop.id, vendor_id=test_vendor.id,
user_id=test_user.id, user_id=test_user.id,
) )
db.add(job) db.add(job)

View File

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

View File

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

View File

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

View File

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

View File

@@ -10,12 +10,12 @@ from app.exceptions.marketplace_import_job import (
ImportJobCannotBeCancelledException, ImportJobCannotBeCancelledException,
ImportJobCannotBeDeletedException, ImportJobCannotBeDeletedException,
) )
from app.exceptions.shop import ShopNotFoundException, UnauthorizedShopAccessException from app.exceptions.vendor import VendorNotFoundException, UnauthorizedVendorAccessException
from app.exceptions.base import ValidationException from app.exceptions.base import ValidationException
from app.services.marketplace_import_job_service import MarketplaceImportJobService from app.services.marketplace_import_job_service import MarketplaceImportJobService
from models.schemas.marketplace_import_job import MarketplaceImportJobRequest from models.schemas.marketplace_import_job import MarketplaceImportJobRequest
from models.database.marketplace_import_job import MarketplaceImportJob 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 from models.database.user import User
@@ -25,107 +25,107 @@ class TestMarketplaceService:
def setup_method(self): def setup_method(self):
self.service = MarketplaceImportJobService() self.service = MarketplaceImportJobService()
def test_validate_shop_access_success(self, db, test_shop, test_user): def test_validate_vendor_access_success(self, db, test_vendor, test_user):
"""Test successful shop access validation""" """Test successful vendor access validation"""
# Set the shop owner to the test user # Set the vendor owner to the test user
test_shop.owner_id = test_user.id test_vendor.owner_id = test_user.id
db.commit() 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 assert result.owner_id == test_user.id
def test_validate_shop_access_admin_can_access_any_shop( def test_validate_vendor_access_admin_can_access_any_vendor(
self, db, test_shop, test_admin self, db, test_vendor, test_admin
): ):
"""Test that admin users can access any shop""" """Test that admin users can access any vendor """
result = self.service.validate_shop_access(db, test_shop.shop_code, test_admin) 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): def test_validate_vendor_access_vendor_not_found(self, db, test_user):
"""Test shop access validation when shop doesn't exist""" """Test vendor access validation when vendor doesn't exist"""
with pytest.raises(ShopNotFoundException) as exc_info: with pytest.raises(VendorNotFoundException) as exc_info:
self.service.validate_shop_access(db, "NONEXISTENT", test_user) self.service.validate_vendor_access(db, "NONEXISTENT", test_user)
exception = exc_info.value exception = exc_info.value
assert exception.error_code == "SHOP_NOT_FOUND" assert exception.error_code == "VENDOR_NOT_FOUND"
assert exception.status_code == 404 assert exception.status_code == 404
assert "NONEXISTENT" in exception.message assert "NONEXISTENT" in exception.message
def test_validate_shop_access_permission_denied( def test_validate_vendor_access_permission_denied(
self, db, test_shop, test_user, other_user self, db, test_vendor, test_user, other_user
): ):
"""Test shop access validation when user doesn't own the shop""" """Test vendor access validation when user doesn't own the vendor """
# Set the shop owner to a different user # Set the vendor owner to a different user
test_shop.owner_id = other_user.id test_vendor.owner_id = other_user.id
db.commit() db.commit()
with pytest.raises(UnauthorizedShopAccessException) as exc_info: with pytest.raises(UnauthorizedVendorAccessException) as exc_info:
self.service.validate_shop_access(db, test_shop.shop_code, test_user) self.service.validate_vendor_access(db, test_vendor.vendor_code, test_user)
exception = exc_info.value exception = exc_info.value
assert exception.error_code == "UNAUTHORIZED_SHOP_ACCESS" assert exception.error_code == "UNAUTHORIZED_VENDOR_ACCESS"
assert exception.status_code == 403 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""" """Test successful creation of import job"""
# Set the shop owner to the test user # Set the vendor owner to the test user
test_shop.owner_id = test_user.id test_vendor.owner_id = test_user.id
db.commit() db.commit()
request = MarketplaceImportJobRequest( request = MarketplaceImportJobRequest(
url="https://example.com/products.csv", url="https://example.com/products.csv",
marketplace="Amazon", marketplace="Amazon",
shop_code=test_shop.shop_code, vendor_code=test_vendor.vendor_code,
batch_size=1000, batch_size=1000,
) )
result = self.service.create_import_job(db, request, test_user) result = self.service.create_import_job(db, request, test_user)
assert result.marketplace == "Amazon" 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.user_id == test_user.id
assert result.status == "pending" assert result.status == "pending"
assert result.source_url == "https://example.com/products.csv" 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): def test_create_import_job_invalid_vendor(self, db, test_user):
"""Test import job creation with invalid shop""" """Test import job creation with invalid vendor """
request = MarketplaceImportJobRequest( request = MarketplaceImportJobRequest(
url="https://example.com/products.csv", url="https://example.com/products.csv",
marketplace="Amazon", marketplace="Amazon",
shop_code="INVALID_SHOP", vendor_code="INVALID_SHOP",
batch_size=1000, 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) self.service.create_import_job(db, request, test_user)
exception = exc_info.value exception = exc_info.value
assert exception.error_code == "SHOP_NOT_FOUND" assert exception.error_code == "VENDOR_NOT_FOUND"
assert "INVALID_SHOP" in exception.message assert "INVALID_SHOP" in exception.message
def test_create_import_job_unauthorized_access(self, db, test_shop, test_user, other_user): def test_create_import_job_unauthorized_access(self, db, test_vendor, test_user, other_user):
"""Test import job creation with unauthorized shop access""" """Test import job creation with unauthorized vendor access"""
# Set the shop owner to a different user # Set the vendor owner to a different user
test_shop.owner_id = other_user.id test_vendor.owner_id = other_user.id
db.commit() db.commit()
request = MarketplaceImportJobRequest( request = MarketplaceImportJobRequest(
url="https://example.com/products.csv", url="https://example.com/products.csv",
marketplace="Amazon", marketplace="Amazon",
shop_code=test_shop.shop_code, vendor_code=test_vendor.vendor_code,
batch_size=1000, 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) self.service.create_import_job(db, request, test_user)
exception = exc_info.value 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): 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""" """Test getting import job by ID for job owner"""
@@ -194,7 +194,7 @@ class TestMarketplaceService:
assert len(jobs) >= 1 assert len(jobs) >= 1
assert any(job.marketplace == test_marketplace_import_job.marketplace for job in jobs) 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""" """Test getting import jobs with pagination"""
unique_id = str(uuid.uuid4())[:8] unique_id = str(uuid.uuid4())[:8]
@@ -203,9 +203,9 @@ class TestMarketplaceService:
job = MarketplaceImportJob( job = MarketplaceImportJob(
status="completed", status="completed",
marketplace=f"Marketplace_{unique_id}_{i}", 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, user_id=test_user.id,
shop_id=test_shop.id, vendor_id=test_vendor.id,
source_url=f"https://test-{i}.example.com/import", source_url=f"https://test-{i}.example.com/import",
imported_count=0, imported_count=0,
updated_count=0, updated_count=0,
@@ -296,7 +296,7 @@ class TestMarketplaceService:
assert response.marketplace == test_marketplace_import_job.marketplace assert response.marketplace == test_marketplace_import_job.marketplace
assert response.imported == (test_marketplace_import_job.imported_count or 0) 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""" """Test cancelling a pending import job"""
unique_id = str(uuid.uuid4())[:8] unique_id = str(uuid.uuid4())[:8]
@@ -304,9 +304,9 @@ class TestMarketplaceService:
job = MarketplaceImportJob( job = MarketplaceImportJob(
status="pending", status="pending",
marketplace="Amazon", marketplace="Amazon",
shop_name=f"TEST_SHOP_{unique_id}", vendor_name=f"TEST_VENDOR_{unique_id}",
user_id=test_user.id, user_id=test_user.id,
shop_id=test_shop.id, vendor_id=test_vendor.id,
source_url="https://test.example.com/import", source_url="https://test.example.com/import",
imported_count=0, imported_count=0,
updated_count=0, updated_count=0,
@@ -354,7 +354,7 @@ class TestMarketplaceService:
assert exception.status_code == 400 assert exception.status_code == 400
assert "completed" in exception.message 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""" """Test deleting a completed import job"""
unique_id = str(uuid.uuid4())[:8] unique_id = str(uuid.uuid4())[:8]
@@ -362,9 +362,9 @@ class TestMarketplaceService:
job = MarketplaceImportJob( job = MarketplaceImportJob(
status="completed", status="completed",
marketplace="Amazon", marketplace="Amazon",
shop_name=f"TEST_SHOP_{unique_id}", vendor_name=f"TEST_VENDOR_{unique_id}",
user_id=test_user.id, user_id=test_user.id,
shop_id=test_shop.id, vendor_id=test_vendor.id,
source_url="https://test.example.com/import", source_url="https://test.example.com/import",
imported_count=0, imported_count=0,
updated_count=0, updated_count=0,
@@ -404,7 +404,7 @@ class TestMarketplaceService:
exception = exc_info.value exception = exc_info.value
assert exception.error_code == "IMPORT_JOB_NOT_OWNED" 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""" """Test deleting a job that can't be deleted"""
unique_id = str(uuid.uuid4())[:8] unique_id = str(uuid.uuid4())[:8]
@@ -412,9 +412,9 @@ class TestMarketplaceService:
job = MarketplaceImportJob( job = MarketplaceImportJob(
status="pending", status="pending",
marketplace="Amazon", marketplace="Amazon",
shop_name=f"TEST_SHOP_{unique_id}", vendor_name=f"TEST_VENDOR_{unique_id}",
user_id=test_user.id, user_id=test_user.id,
shop_id=test_shop.id, vendor_id=test_vendor.id,
source_url="https://test.example.com/import", source_url="https://test.example.com/import",
imported_count=0, imported_count=0,
updated_count=0, updated_count=0,
@@ -434,25 +434,25 @@ class TestMarketplaceService:
assert "pending" in exception.message assert "pending" in exception.message
# Test edge cases and error scenarios # Test edge cases and error scenarios
def test_validate_shop_access_case_insensitive(self, db, test_shop, test_user): def test_validate_vendor_access_case_insensitive(self, db, test_vendor, test_user):
"""Test shop access validation is case insensitive""" """Test vendor access validation is case insensitive"""
test_shop.owner_id = test_user.id test_vendor.owner_id = test_user.id
db.commit() db.commit()
# Test with lowercase shop code # Test with lowercase vendor code
result = self.service.validate_shop_access(db, test_shop.shop_code.lower(), test_user) result = self.service.validate_vendor_access(db, test_vendor.vendor_code.lower(), test_user)
assert result.shop_code == test_shop.shop_code assert result.vendor_code == test_vendor.vendor_code
# Test with uppercase shop code # Test with uppercase vendor code
result = self.service.validate_shop_access(db, test_shop.shop_code.upper(), test_user) result = self.service.validate_vendor_access(db, test_vendor.vendor_code.upper(), test_user)
assert result.shop_code == test_shop.shop_code assert result.vendor_code == test_vendor.vendor_code
def test_create_import_job_database_error(self, db_with_error, test_user): def test_create_import_job_database_error(self, db_with_error, test_user):
"""Test import job creation handles database errors""" """Test import job creation handles database errors"""
request = MarketplaceImportJobRequest( request = MarketplaceImportJobRequest(
url="https://example.com/products.csv", url="https://example.com/products.csv",
marketplace="Amazon", marketplace="Amazon",
shop_code="TEST_SHOP", vendor_code="TEST_SHOP",
batch_size=1000, 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_brands" in stats
assert "unique_categories" in stats assert "unique_categories" in stats
assert "unique_marketplaces" 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_stock_entries" in stats
assert "total_inventory_quantity" in stats assert "total_inventory_quantity" in stats
@@ -41,7 +41,7 @@ class TestStatsService:
brand="DifferentBrand", brand="DifferentBrand",
google_product_category="Different Category", google_product_category="Different Category",
marketplace="Amazon", marketplace="Amazon",
shop_name="AmazonShop", vendor_name="AmazonShop",
price="15.99", price="15.99",
currency="EUR", currency="EUR",
), ),
@@ -51,7 +51,7 @@ class TestStatsService:
brand="ThirdBrand", brand="ThirdBrand",
google_product_category="Third Category", google_product_category="Third Category",
marketplace="eBay", marketplace="eBay",
shop_name="eBayShop", vendor_name="eBayShop",
price="25.99", price="25.99",
currency="USD", currency="USD",
), ),
@@ -61,7 +61,7 @@ class TestStatsService:
brand="TestBrand", # Same as test_marketplace_product brand="TestBrand", # Same as test_marketplace_product
google_product_category="Different Category", google_product_category="Different Category",
marketplace="Letzshop", # Same as test_marketplace_product marketplace="Letzshop", # Same as test_marketplace_product
shop_name="DifferentShop", vendor_name="DifferentShop",
price="35.99", price="35.99",
currency="EUR", currency="EUR",
), ),
@@ -75,7 +75,7 @@ class TestStatsService:
assert stats["unique_brands"] >= 3 # TestBrand, DifferentBrand, ThirdBrand assert stats["unique_brands"] >= 3 # TestBrand, DifferentBrand, ThirdBrand
assert stats["unique_categories"] >= 2 # At least 2 different categories assert stats["unique_categories"] >= 2 # At least 2 different categories
assert stats["unique_marketplaces"] >= 3 # Letzshop, Amazon, eBay 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): def test_get_comprehensive_stats_handles_nulls(self, db):
"""Test comprehensive stats handles null/empty values correctly""" """Test comprehensive stats handles null/empty values correctly"""
@@ -87,7 +87,7 @@ class TestStatsService:
brand=None, # Null brand brand=None, # Null brand
google_product_category=None, # Null category google_product_category=None, # Null category
marketplace=None, # Null marketplace marketplace=None, # Null marketplace
shop_name=None, # Null shop vendor_name=None, # Null vendor
price="10.00", price="10.00",
currency="EUR", currency="EUR",
), ),
@@ -97,7 +97,7 @@ class TestStatsService:
brand="", # Empty brand brand="", # Empty brand
google_product_category="", # Empty category google_product_category="", # Empty category
marketplace="", # Empty marketplace marketplace="", # Empty marketplace
shop_name="", # Empty shop vendor_name="", # Empty vendor
price="15.00", price="15.00",
currency="EUR", currency="EUR",
), ),
@@ -109,11 +109,11 @@ class TestStatsService:
# These products shouldn't contribute to unique counts due to null/empty values # These products shouldn't contribute to unique counts due to null/empty values
assert stats["total_products"] >= 2 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_brands"], int)
assert isinstance(stats["unique_categories"], int) assert isinstance(stats["unique_categories"], int)
assert isinstance(stats["unique_marketplaces"], 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): def test_get_marketplace_breakdown_stats_basic(self, db, test_marketplace_product):
"""Test getting marketplace breakdown stats with basic data""" """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 is not None
assert test_marketplace_stat["total_products"] >= 1 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 assert test_marketplace_stat["unique_brands"] >= 1
def test_get_marketplace_breakdown_stats_multiple_marketplaces( def test_get_marketplace_breakdown_stats_multiple_marketplaces(
@@ -143,7 +143,7 @@ class TestStatsService:
title="Amazon MarketplaceProduct 1", title="Amazon MarketplaceProduct 1",
brand="AmazonBrand1", brand="AmazonBrand1",
marketplace="Amazon", marketplace="Amazon",
shop_name="AmazonShop1", vendor_name="AmazonShop1",
price="20.00", price="20.00",
currency="EUR", currency="EUR",
), ),
@@ -152,7 +152,7 @@ class TestStatsService:
title="Amazon MarketplaceProduct 2", title="Amazon MarketplaceProduct 2",
brand="AmazonBrand2", brand="AmazonBrand2",
marketplace="Amazon", marketplace="Amazon",
shop_name="AmazonShop2", vendor_name="AmazonShop2",
price="25.00", price="25.00",
currency="EUR", currency="EUR",
), ),
@@ -161,7 +161,7 @@ class TestStatsService:
title="eBay MarketplaceProduct", title="eBay MarketplaceProduct",
brand="eBayBrand", brand="eBayBrand",
marketplace="eBay", marketplace="eBay",
shop_name="eBayShop", vendor_name="eBayShop",
price="30.00", price="30.00",
currency="USD", currency="USD",
), ),
@@ -180,13 +180,13 @@ class TestStatsService:
# Check Amazon stats specifically # Check Amazon stats specifically
amazon_stat = next(stat for stat in stats if stat["marketplace"] == "Amazon") amazon_stat = next(stat for stat in stats if stat["marketplace"] == "Amazon")
assert amazon_stat["total_products"] == 2 assert amazon_stat["total_products"] == 2
assert amazon_stat["unique_shops"] == 2 assert amazon_stat["unique_vendors"] == 2
assert amazon_stat["unique_brands"] == 2 assert amazon_stat["unique_brands"] == 2
# Check eBay stats specifically # Check eBay stats specifically
ebay_stat = next(stat for stat in stats if stat["marketplace"] == "eBay") ebay_stat = next(stat for stat in stats if stat["marketplace"] == "eBay")
assert ebay_stat["total_products"] == 1 assert ebay_stat["total_products"] == 1
assert ebay_stat["unique_shops"] == 1 assert ebay_stat["unique_vendors"] == 1
assert ebay_stat["unique_brands"] == 1 assert ebay_stat["unique_brands"] == 1
def test_get_marketplace_breakdown_stats_excludes_nulls(self, db): def test_get_marketplace_breakdown_stats_excludes_nulls(self, db):
@@ -196,7 +196,7 @@ class TestStatsService:
marketplace_product_id="NULLMARKET001", marketplace_product_id="NULLMARKET001",
title="MarketplaceProduct without marketplace", title="MarketplaceProduct without marketplace",
marketplace=None, marketplace=None,
shop_name="SomeShop", vendor_name="SomeShop",
brand="SomeBrand", brand="SomeBrand",
price="10.00", price="10.00",
currency="EUR", currency="EUR",
@@ -228,7 +228,7 @@ class TestStatsService:
title="Brand MarketplaceProduct 1", title="Brand MarketplaceProduct 1",
brand="BrandA", brand="BrandA",
marketplace="Test", marketplace="Test",
shop_name="TestShop", vendor_name="TestVendor",
price="10.00", price="10.00",
currency="EUR", currency="EUR",
), ),
@@ -237,7 +237,7 @@ class TestStatsService:
title="Brand MarketplaceProduct 2", title="Brand MarketplaceProduct 2",
brand="BrandB", brand="BrandB",
marketplace="Test", marketplace="Test",
shop_name="TestShop", vendor_name="TestVendor",
price="15.00", price="15.00",
currency="EUR", currency="EUR",
), ),
@@ -261,7 +261,7 @@ class TestStatsService:
title="Category MarketplaceProduct 1", title="Category MarketplaceProduct 1",
google_product_category="Electronics", google_product_category="Electronics",
marketplace="Test", marketplace="Test",
shop_name="TestShop", vendor_name="TestVendor",
price="10.00", price="10.00",
currency="EUR", currency="EUR",
), ),
@@ -270,7 +270,7 @@ class TestStatsService:
title="Category MarketplaceProduct 2", title="Category MarketplaceProduct 2",
google_product_category="Books", google_product_category="Books",
marketplace="Test", marketplace="Test",
shop_name="TestShop", vendor_name="TestVendor",
price="15.00", price="15.00",
currency="EUR", currency="EUR",
), ),
@@ -291,7 +291,7 @@ class TestStatsService:
marketplace_product_id="MARKET001", marketplace_product_id="MARKET001",
title="Marketplace MarketplaceProduct 1", title="Marketplace MarketplaceProduct 1",
marketplace="Amazon", marketplace="Amazon",
shop_name="AmazonShop", vendor_name="AmazonShop",
price="10.00", price="10.00",
currency="EUR", currency="EUR",
), ),
@@ -299,7 +299,7 @@ class TestStatsService:
marketplace_product_id="MARKET002", marketplace_product_id="MARKET002",
title="Marketplace MarketplaceProduct 2", title="Marketplace MarketplaceProduct 2",
marketplace="eBay", marketplace="eBay",
shop_name="eBayShop", vendor_name="eBayShop",
price="15.00", price="15.00",
currency="EUR", currency="EUR",
), ),
@@ -312,23 +312,23 @@ class TestStatsService:
assert count >= 2 # At least Amazon and eBay, plus test_marketplace_product marketplace assert count >= 2 # At least Amazon and eBay, plus test_marketplace_product marketplace
assert isinstance(count, int) assert isinstance(count, int)
def test_get_unique_shops_count(self, db, test_marketplace_product): def test_get_unique_vendors_count(self, db, test_marketplace_product):
"""Test getting unique shops count""" """Test getting unique vendors count"""
# Add products with different shop names # Add products with different vendor names
products = [ products = [
MarketplaceProduct( MarketplaceProduct(
marketplace_product_id="SHOP001", marketplace_product_id="SHOP001",
title="Shop MarketplaceProduct 1", title="Vendor MarketplaceProduct 1",
marketplace="Test", marketplace="Test",
shop_name="ShopA", vendor_name="ShopA",
price="10.00", price="10.00",
currency="EUR", currency="EUR",
), ),
MarketplaceProduct( MarketplaceProduct(
marketplace_product_id="SHOP002", marketplace_product_id="SHOP002",
title="Shop MarketplaceProduct 2", title="Vendor MarketplaceProduct 2",
marketplace="Test", marketplace="Test",
shop_name="ShopB", vendor_name="ShopB",
price="15.00", price="15.00",
currency="EUR", currency="EUR",
), ),
@@ -336,9 +336,9 @@ class TestStatsService:
db.add_all(products) db.add_all(products)
db.commit() 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) assert isinstance(count, int)
def test_get_stock_statistics(self, db, test_stock): def test_get_stock_statistics(self, db, test_stock):
@@ -350,14 +350,14 @@ class TestStatsService:
location="LOCATION2", location="LOCATION2",
quantity=25, quantity=25,
reserved_quantity=5, reserved_quantity=5,
shop_id=test_stock.shop_id, vendor_id=test_stock.vendor_id,
), ),
Stock( Stock(
gtin="1234567890125", gtin="1234567890125",
location="LOCATION3", location="LOCATION3",
quantity=0, # Out of stock quantity=0, # Out of stock
reserved_quantity=0, reserved_quantity=0,
shop_id=test_stock.shop_id, vendor_id=test_stock.vendor_id,
), ),
] ]
db.add_all(additional_stocks) db.add_all(additional_stocks)
@@ -379,7 +379,7 @@ class TestStatsService:
title="Specific MarketplaceProduct 1", title="Specific MarketplaceProduct 1",
brand="SpecificBrand1", brand="SpecificBrand1",
marketplace="SpecificMarket", marketplace="SpecificMarket",
shop_name="SpecificShop1", vendor_name="SpecificShop1",
price="10.00", price="10.00",
currency="EUR", currency="EUR",
), ),
@@ -388,7 +388,7 @@ class TestStatsService:
title="Specific MarketplaceProduct 2", title="Specific MarketplaceProduct 2",
brand="SpecificBrand2", brand="SpecificBrand2",
marketplace="SpecificMarket", marketplace="SpecificMarket",
shop_name="SpecificShop2", vendor_name="SpecificShop2",
price="15.00", price="15.00",
currency="EUR", currency="EUR",
), ),
@@ -397,7 +397,7 @@ class TestStatsService:
title="Other MarketplaceProduct", title="Other MarketplaceProduct",
brand="OtherBrand", brand="OtherBrand",
marketplace="OtherMarket", marketplace="OtherMarket",
shop_name="OtherShop", vendor_name="OtherShop",
price="20.00", price="20.00",
currency="EUR", currency="EUR",
), ),
@@ -412,25 +412,25 @@ class TestStatsService:
assert "SpecificBrand2" in brands assert "SpecificBrand2" in brands
assert "OtherBrand" not in brands assert "OtherBrand" not in brands
def test_get_shops_by_marketplace(self, db): def test_get_vendors_by_marketplace(self, db):
"""Test getting shops for a specific marketplace""" """Test getting vendors for a specific marketplace"""
# Create products for specific marketplace # Create products for specific marketplace
marketplace_products = [ marketplace_products = [
MarketplaceProduct( MarketplaceProduct(
marketplace_product_id="SHOPTEST001", marketplace_product_id="SHOPTEST001",
title="Shop Test MarketplaceProduct 1", title="Vendor Test MarketplaceProduct 1",
brand="TestBrand", brand="TestBrand",
marketplace="TestMarketplace", marketplace="TestMarketplace",
shop_name="TestShop1", vendor_name="TestVendor1",
price="10.00", price="10.00",
currency="EUR", currency="EUR",
), ),
MarketplaceProduct( MarketplaceProduct(
marketplace_product_id="SHOPTEST002", marketplace_product_id="SHOPTEST002",
title="Shop Test MarketplaceProduct 2", title="Vendor Test MarketplaceProduct 2",
brand="TestBrand", brand="TestBrand",
marketplace="TestMarketplace", marketplace="TestMarketplace",
shop_name="TestShop2", vendor_name="TestVendor2",
price="15.00", price="15.00",
currency="EUR", currency="EUR",
), ),
@@ -438,11 +438,11 @@ class TestStatsService:
db.add_all(marketplace_products) db.add_all(marketplace_products)
db.commit() 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 len(vendors) == 2
assert "TestShop1" in shops assert "TestVendor1" in vendors
assert "TestShop2" in shops assert "TestVendor2" in vendors
def test_get_products_by_marketplace(self, db): def test_get_products_by_marketplace(self, db):
"""Test getting product count for a specific marketplace""" """Test getting product count for a specific marketplace"""
@@ -452,7 +452,7 @@ class TestStatsService:
marketplace_product_id="COUNT001", marketplace_product_id="COUNT001",
title="Count MarketplaceProduct 1", title="Count MarketplaceProduct 1",
marketplace="CountMarketplace", marketplace="CountMarketplace",
shop_name="CountShop", vendor_name="CountShop",
price="10.00", price="10.00",
currency="EUR", currency="EUR",
), ),
@@ -460,7 +460,7 @@ class TestStatsService:
marketplace_product_id="COUNT002", marketplace_product_id="COUNT002",
title="Count MarketplaceProduct 2", title="Count MarketplaceProduct 2",
marketplace="CountMarketplace", marketplace="CountMarketplace",
shop_name="CountShop", vendor_name="CountShop",
price="15.00", price="15.00",
currency="EUR", currency="EUR",
), ),
@@ -468,7 +468,7 @@ class TestStatsService:
marketplace_product_id="COUNT003", marketplace_product_id="COUNT003",
title="Count MarketplaceProduct 3", title="Count MarketplaceProduct 3",
marketplace="CountMarketplace", marketplace="CountMarketplace",
shop_name="CountShop", vendor_name="CountShop",
price="20.00", price="20.00",
currency="EUR", currency="EUR",
), ),
@@ -494,7 +494,7 @@ class TestStatsService:
assert stats["unique_brands"] == 0 assert stats["unique_brands"] == 0
assert stats["unique_categories"] == 0 assert stats["unique_categories"] == 0
assert stats["unique_marketplaces"] == 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_stock_entries"] == 0
assert stats["total_inventory_quantity"] == 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"], "title": ["MarketplaceProduct 1", "MarketplaceProduct 2"],
"price": ["10.99", "15.99"], "price": ["10.99", "15.99"],
"marketplace": ["TestMarket", "TestMarket"], "marketplace": ["TestMarket", "TestMarket"],
"shop_name": ["TestShop", "TestShop"], "vendor_name": ["TestVendor", "TestVendor"],
} }
) )
mock_parse.return_value = mock_df mock_parse.return_value = mock_df
result = await self.processor.process_marketplace_csv_from_url( 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 assert "imported" in result