shop product refactoring
This commit is contained in:
@@ -35,10 +35,10 @@ except ImportError as e:
|
||||
print(f" ✗ Stock model failed: {e}")
|
||||
|
||||
try:
|
||||
from models.database.shop import Shop
|
||||
print(" ✓ Shop model imported")
|
||||
from models.database.vendor import Vendor
|
||||
print(" ✓ Vendor model imported")
|
||||
except ImportError as e:
|
||||
print(f" ✗ Shop model failed: {e}")
|
||||
print(f" ✗ Vendor model failed: {e}")
|
||||
|
||||
try:
|
||||
from models.database.product import Product
|
||||
|
||||
@@ -60,13 +60,13 @@ def upgrade() -> None:
|
||||
sa.Column('shipping', sa.String(), nullable=True),
|
||||
sa.Column('currency', sa.String(), nullable=True),
|
||||
sa.Column('marketplace', sa.String(), nullable=True),
|
||||
sa.Column('shop_name', sa.String(), nullable=True),
|
||||
sa.Column('vendor_name', sa.String(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(), nullable=False),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index('idx_marketplace_brand', 'products', ['marketplace', 'brand'], unique=False)
|
||||
op.create_index('idx_marketplace_shop', 'products', ['marketplace', 'shop_name'], unique=False)
|
||||
op.create_index('idx_marketplace_shop', 'products', ['marketplace', 'vendor_name'], unique=False)
|
||||
op.create_index(op.f('ix_products_availability'), 'products', ['availability'], unique=False)
|
||||
op.create_index(op.f('ix_products_brand'), 'products', ['brand'], unique=False)
|
||||
op.create_index(op.f('ix_products_google_product_category'), 'products', ['google_product_category'], unique=False)
|
||||
@@ -74,7 +74,7 @@ def upgrade() -> None:
|
||||
op.create_index(op.f('ix_products_id'), 'products', ['id'], unique=False)
|
||||
op.create_index(op.f('ix_products_marketplace'), 'products', ['marketplace'], unique=False)
|
||||
op.create_index(op.f('ix_products_product_id'), 'products', ['marketplace_product_id'], unique=True)
|
||||
op.create_index(op.f('ix_products_shop_name'), 'products', ['shop_name'], unique=False)
|
||||
op.create_index(op.f('ix_products_vendor_name'), 'products', ['vendor_name'], unique=False)
|
||||
op.create_table('users',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('email', sa.String(), nullable=False),
|
||||
@@ -90,10 +90,10 @@ def upgrade() -> None:
|
||||
op.create_index(op.f('ix_users_email'), 'users', ['email'], unique=True)
|
||||
op.create_index(op.f('ix_users_id'), 'users', ['id'], unique=False)
|
||||
op.create_index(op.f('ix_users_username'), 'users', ['username'], unique=True)
|
||||
op.create_table('shops',
|
||||
op.create_table('vendors',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('shop_code', sa.String(), nullable=False),
|
||||
sa.Column('shop_name', sa.String(), nullable=False),
|
||||
sa.Column('vendor_code', sa.String(), nullable=False),
|
||||
sa.Column('vendor_name', sa.String(), nullable=False),
|
||||
sa.Column('description', sa.Text(), nullable=True),
|
||||
sa.Column('owner_id', sa.Integer(), nullable=False),
|
||||
sa.Column('contact_email', sa.String(), nullable=True),
|
||||
@@ -108,15 +108,15 @@ def upgrade() -> None:
|
||||
sa.ForeignKeyConstraint(['owner_id'], ['users.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_shops_id'), 'shops', ['id'], unique=False)
|
||||
op.create_index(op.f('ix_shops_shop_code'), 'shops', ['shop_code'], unique=True)
|
||||
op.create_index(op.f('ix_vendors_id'), 'vendors', ['id'], unique=False)
|
||||
op.create_index(op.f('ix_vendors_vendor_code'), 'vendors', ['vendor_code'], unique=True)
|
||||
op.create_table('marketplace_import_jobs',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('status', sa.String(), nullable=False),
|
||||
sa.Column('source_url', sa.String(), nullable=False),
|
||||
sa.Column('marketplace', sa.String(), nullable=False),
|
||||
sa.Column('shop_name', sa.String(), nullable=False),
|
||||
sa.Column('shop_id', sa.Integer(), nullable=False),
|
||||
sa.Column('vendor_name', sa.String(), nullable=False),
|
||||
sa.Column('vendor_id', sa.Integer(), nullable=False),
|
||||
sa.Column('user_id', sa.Integer(), nullable=False),
|
||||
sa.Column('imported_count', sa.Integer(), nullable=True),
|
||||
sa.Column('updated_count', sa.Integer(), nullable=True),
|
||||
@@ -126,26 +126,26 @@ def upgrade() -> None:
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||
sa.Column('started_at', sa.DateTime(), nullable=True),
|
||||
sa.Column('completed_at', sa.DateTime(), nullable=True),
|
||||
sa.ForeignKeyConstraint(['shop_id'], ['shops.id'], ),
|
||||
sa.ForeignKeyConstraint(['vendor_id'], ['vendors.id'], ),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index('idx_marketplace_import_shop_id', 'marketplace_import_jobs', ['shop_id'], unique=False)
|
||||
op.create_index('idx_marketplace_import_shop_status', 'marketplace_import_jobs', ['status'], unique=False)
|
||||
op.create_index('idx_marketplace_import_vendor_id', 'marketplace_import_jobs', ['vendor_id'], unique=False)
|
||||
op.create_index('idx_marketplace_import_vendor_status', 'marketplace_import_jobs', ['status'], unique=False)
|
||||
op.create_index('idx_marketplace_import_user_marketplace', 'marketplace_import_jobs', ['user_id', 'marketplace'], unique=False)
|
||||
op.create_index(op.f('ix_marketplace_import_jobs_id'), 'marketplace_import_jobs', ['id'], unique=False)
|
||||
op.create_index(op.f('ix_marketplace_import_jobs_marketplace'), 'marketplace_import_jobs', ['marketplace'], unique=False)
|
||||
op.create_index(op.f('ix_marketplace_import_jobs_shop_name'), 'marketplace_import_jobs', ['shop_name'], unique=False)
|
||||
op.create_table('shop_products',
|
||||
op.create_index(op.f('ix_marketplace_import_jobs_vendor_name'), 'marketplace_import_jobs', ['vendor_name'], unique=False)
|
||||
op.create_table('vendor_products',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('shop_id', sa.Integer(), nullable=False),
|
||||
sa.Column('vendor_id', sa.Integer(), nullable=False),
|
||||
sa.Column('marketplace_product_id', sa.Integer(), nullable=False),
|
||||
sa.Column('shop_product_id', sa.String(), nullable=True),
|
||||
sa.Column('shop_price', sa.Float(), nullable=True),
|
||||
sa.Column('shop_sale_price', sa.Float(), nullable=True),
|
||||
sa.Column('shop_currency', sa.String(), nullable=True),
|
||||
sa.Column('shop_availability', sa.String(), nullable=True),
|
||||
sa.Column('shop_condition', sa.String(), nullable=True),
|
||||
sa.Column('vendor_product_id', sa.String(), nullable=True),
|
||||
sa.Column('price', sa.Float(), nullable=True),
|
||||
sa.Column('vendor_sale_price', sa.Float(), nullable=True),
|
||||
sa.Column('vendor_currency', sa.String(), nullable=True),
|
||||
sa.Column('vendor_availability', sa.String(), nullable=True),
|
||||
sa.Column('vendor_condition', sa.String(), nullable=True),
|
||||
sa.Column('is_featured', sa.Boolean(), nullable=True),
|
||||
sa.Column('is_active', sa.Boolean(), nullable=True),
|
||||
sa.Column('display_order', sa.Integer(), nullable=True),
|
||||
@@ -154,23 +154,23 @@ def upgrade() -> None:
|
||||
sa.Column('created_at', sa.DateTime(), nullable=True),
|
||||
sa.Column('updated_at', sa.DateTime(), nullable=True),
|
||||
sa.ForeignKeyConstraint(['marketplace_product_id'], ['products.id'], ),
|
||||
sa.ForeignKeyConstraint(['shop_id'], ['shops.id'], ),
|
||||
sa.ForeignKeyConstraint(['vendor_id'], ['vendors.id'], ),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('shop_id', 'marketplace_product_id', name='uq_shop_product')
|
||||
sa.UniqueConstraint('vendor_id', 'marketplace_product_id', name='uq_vendor_product')
|
||||
)
|
||||
op.create_index('idx_shop_product_active', 'shop_products', ['shop_id', 'is_active'], unique=False)
|
||||
op.create_index('idx_shop_product_featured', 'shop_products', ['shop_id', 'is_featured'], unique=False)
|
||||
op.create_index(op.f('ix_shop_products_id'), 'shop_products', ['id'], unique=False)
|
||||
op.create_index('idx_vendor_product_active', 'vendor_products', ['vendor_id', 'is_active'], unique=False)
|
||||
op.create_index('idx_vendor_product_featured', 'vendor_products', ['vendor_id', 'is_featured'], unique=False)
|
||||
op.create_index(op.f('ix_vendor_products_id'), 'vendor_products', ['id'], unique=False)
|
||||
op.create_table('stock',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('gtin', sa.String(), nullable=False),
|
||||
sa.Column('location', sa.String(), nullable=False),
|
||||
sa.Column('quantity', sa.Integer(), nullable=False),
|
||||
sa.Column('reserved_quantity', sa.Integer(), nullable=True),
|
||||
sa.Column('shop_id', sa.Integer(), nullable=True),
|
||||
sa.Column('vendor_id', sa.Integer(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(), nullable=False),
|
||||
sa.ForeignKeyConstraint(['shop_id'], ['shops.id'], ),
|
||||
sa.ForeignKeyConstraint(['vendor_id'], ['vendors.id'], ),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('gtin', 'location', name='uq_stock_gtin_location')
|
||||
)
|
||||
@@ -188,25 +188,25 @@ def downgrade() -> None:
|
||||
op.drop_index(op.f('ix_stock_gtin'), table_name='stock')
|
||||
op.drop_index('idx_stock_gtin_location', table_name='stock')
|
||||
op.drop_table('stock')
|
||||
op.drop_index(op.f('ix_shop_products_id'), table_name='shop_products')
|
||||
op.drop_index('idx_shop_product_featured', table_name='shop_products')
|
||||
op.drop_index('idx_shop_product_active', table_name='shop_products')
|
||||
op.drop_table('shop_products')
|
||||
op.drop_index(op.f('ix_marketplace_import_jobs_shop_name'), table_name='marketplace_import_jobs')
|
||||
op.drop_index(op.f('ix_vendor_products_id'), table_name='vendor_products')
|
||||
op.drop_index('idx_vendor_product_featured', table_name='vendor_products')
|
||||
op.drop_index('idx_vendor_product_active', table_name='vendor_products')
|
||||
op.drop_table('vendor_products')
|
||||
op.drop_index(op.f('ix_marketplace_import_jobs_vendor_name'), table_name='marketplace_import_jobs')
|
||||
op.drop_index(op.f('ix_marketplace_import_jobs_marketplace'), table_name='marketplace_import_jobs')
|
||||
op.drop_index(op.f('ix_marketplace_import_jobs_id'), table_name='marketplace_import_jobs')
|
||||
op.drop_index('idx_marketplace_import_user_marketplace', table_name='marketplace_import_jobs')
|
||||
op.drop_index('idx_marketplace_import_shop_status', table_name='marketplace_import_jobs')
|
||||
op.drop_index('idx_marketplace_import_shop_id', table_name='marketplace_import_jobs')
|
||||
op.drop_index('idx_marketplace_import_vendor_status', table_name='marketplace_import_jobs')
|
||||
op.drop_index('idx_marketplace_import_vendor_id', table_name='marketplace_import_jobs')
|
||||
op.drop_table('marketplace_import_jobs')
|
||||
op.drop_index(op.f('ix_shops_shop_code'), table_name='shops')
|
||||
op.drop_index(op.f('ix_shops_id'), table_name='shops')
|
||||
op.drop_table('shops')
|
||||
op.drop_index(op.f('ix_vendors_vendor_code'), table_name='vendors')
|
||||
op.drop_index(op.f('ix_vendors_id'), table_name='vendors')
|
||||
op.drop_table('vendors')
|
||||
op.drop_index(op.f('ix_users_username'), table_name='users')
|
||||
op.drop_index(op.f('ix_users_id'), table_name='users')
|
||||
op.drop_index(op.f('ix_users_email'), table_name='users')
|
||||
op.drop_table('users')
|
||||
op.drop_index(op.f('ix_products_shop_name'), table_name='products')
|
||||
op.drop_index(op.f('ix_products_vendor_name'), table_name='products')
|
||||
op.drop_index(op.f('ix_products_product_id'), table_name='products')
|
||||
op.drop_index(op.f('ix_products_marketplace'), table_name='products')
|
||||
op.drop_index(op.f('ix_products_id'), table_name='products')
|
||||
|
||||
@@ -14,9 +14,9 @@ from sqlalchemy.orm import Session
|
||||
from app.core.database import get_db
|
||||
from middleware.auth import AuthManager
|
||||
from middleware.rate_limiter import RateLimiter
|
||||
from models.database.shop import Shop
|
||||
from models.database.vendor import Vendor
|
||||
from models.database.user import User
|
||||
from app.exceptions import (AdminRequiredException,ShopNotFoundException, UnauthorizedShopAccessException)
|
||||
from app.exceptions import (AdminRequiredException, VendorNotFoundException, UnauthorizedVendorAccessException)
|
||||
|
||||
# Set auto_error=False to prevent automatic 403 responses
|
||||
security = HTTPBearer(auto_error=False)
|
||||
@@ -43,18 +43,18 @@ def get_current_admin_user(current_user: User = Depends(get_current_user)):
|
||||
return auth_manager.require_admin(current_user)
|
||||
|
||||
|
||||
def get_user_shop(
|
||||
shop_code: str,
|
||||
def get_user_vendor(
|
||||
vendor_code: str,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Get shop and verify user ownership."""
|
||||
shop = db.query(Shop).filter(Shop.shop_code == shop_code.upper()).first()
|
||||
if not shop:
|
||||
raise ShopNotFoundException(shop_code)
|
||||
"""Get vendor and verify user ownership."""
|
||||
vendor = db.query(Vendor).filter(Vendor.vendor_code == vendor_code.upper()).first()
|
||||
if not vendor:
|
||||
raise VendorNotFoundException(vendor_code)
|
||||
|
||||
if current_user.role != "admin" and shop.owner_id != current_user.id:
|
||||
raise UnauthorizedShopAccessException(shop_code, current_user.id)
|
||||
if current_user.role != "admin" and vendor.owner_id != current_user.id:
|
||||
raise UnauthorizedVendorAccessException(vendor_code, current_user.id)
|
||||
|
||||
return shop
|
||||
return vendor
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ This module provides classes and functions for:
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.api.v1 import admin, auth, marketplace, shop, stats, stock
|
||||
from app.api.v1 import admin, auth, marketplace, vendor, stats, stock
|
||||
|
||||
api_router = APIRouter()
|
||||
|
||||
@@ -17,6 +17,6 @@ api_router = APIRouter()
|
||||
api_router.include_router(admin.router, tags=["admin"])
|
||||
api_router.include_router(auth.router, tags=["authentication"])
|
||||
api_router.include_router(marketplace.router, tags=["marketplace"])
|
||||
api_router.include_router(shop.router, tags=["shop"])
|
||||
api_router.include_router(vendor.router, tags=["vendor"])
|
||||
api_router.include_router(stats.router, tags=["statistics"])
|
||||
api_router.include_router(stock.router, tags=["stock"])
|
||||
|
||||
@@ -4,7 +4,7 @@ Admin endpoints - simplified with service-level exception handling.
|
||||
|
||||
This module provides classes and functions for:
|
||||
- User management (view, toggle status)
|
||||
- Shop management (view, verify, toggle status)
|
||||
- Vendor management (view, verify, toggle status)
|
||||
- Marketplace import job monitoring
|
||||
- Admin dashboard statistics
|
||||
"""
|
||||
@@ -20,7 +20,7 @@ from app.core.database import get_db
|
||||
from app.services.admin_service import admin_service
|
||||
from models.schemas.auth import UserResponse
|
||||
from models.schemas.marketplace_import_job import MarketplaceImportJobResponse
|
||||
from models.schemas.shop import ShopListResponse
|
||||
from models.schemas.vendor import VendorListResponse
|
||||
from models.database.user import User
|
||||
|
||||
router = APIRouter()
|
||||
@@ -50,37 +50,37 @@ def toggle_user_status(
|
||||
return {"message": message}
|
||||
|
||||
|
||||
@router.get("/admin/shops", response_model=ShopListResponse)
|
||||
def get_all_shops_admin(
|
||||
@router.get("/admin/vendors", response_model=VendorListResponse)
|
||||
def get_all_vendors_admin(
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(100, ge=1, le=1000),
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: User = Depends(get_current_admin_user),
|
||||
):
|
||||
"""Get all shops with admin view (Admin only)."""
|
||||
shops, total = admin_service.get_all_shops(db=db, skip=skip, limit=limit)
|
||||
return ShopListResponse(shops=shops, total=total, skip=skip, limit=limit)
|
||||
"""Get all vendors with admin view (Admin only)."""
|
||||
vendors, total = admin_service.get_all_vendors(db=db, skip=skip, limit=limit)
|
||||
return VendorListResponse(vendors=vendors, total=total, skip=skip, limit=limit)
|
||||
|
||||
|
||||
@router.put("/admin/shops/{shop_id}/verify")
|
||||
def verify_shop(
|
||||
shop_id: int,
|
||||
@router.put("/admin/vendors/{vendor_id}/verify")
|
||||
def verify_vendor(
|
||||
vendor_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: User = Depends(get_current_admin_user),
|
||||
):
|
||||
"""Verify/unverify shop (Admin only)."""
|
||||
shop, message = admin_service.verify_shop(db, shop_id)
|
||||
"""Verify/unverify vendor (Admin only)."""
|
||||
vendor, message = admin_service.verify_vendor(db, vendor_id)
|
||||
return {"message": message}
|
||||
|
||||
|
||||
@router.put("/admin/shops/{shop_id}/status")
|
||||
def toggle_shop_status(
|
||||
shop_id: int,
|
||||
@router.put("/admin/vendors/{vendor_id}/status")
|
||||
def toggle_vendor_status(
|
||||
vendor_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: User = Depends(get_current_admin_user),
|
||||
):
|
||||
"""Toggle shop active status (Admin only)."""
|
||||
shop, message = admin_service.toggle_shop_status(db, shop_id)
|
||||
"""Toggle vendor active status (Admin only)."""
|
||||
vendor, message = admin_service.toggle_vendor_status(db, vendor_id)
|
||||
return {"message": message}
|
||||
|
||||
|
||||
@@ -89,7 +89,7 @@ def toggle_shop_status(
|
||||
)
|
||||
def get_all_marketplace_import_jobs(
|
||||
marketplace: Optional[str] = Query(None),
|
||||
shop_name: Optional[str] = Query(None),
|
||||
vendor_name: Optional[str] = Query(None),
|
||||
status: Optional[str] = Query(None),
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(100, ge=1, le=100),
|
||||
@@ -100,7 +100,7 @@ def get_all_marketplace_import_jobs(
|
||||
return admin_service.get_marketplace_import_jobs(
|
||||
db=db,
|
||||
marketplace=marketplace,
|
||||
shop_name=shop_name,
|
||||
vendor_name=vendor_name,
|
||||
status=status,
|
||||
skip=skip,
|
||||
limit=limit,
|
||||
@@ -116,10 +116,10 @@ def get_user_statistics(
|
||||
return admin_service.get_user_statistics(db)
|
||||
|
||||
|
||||
@router.get("/admin/stats/shops")
|
||||
def get_shop_statistics(
|
||||
@router.get("/admin/stats/vendors")
|
||||
def get_vendor_statistics(
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: User = Depends(get_current_admin_user),
|
||||
):
|
||||
"""Get shop statistics for admin dashboard (Admin only)."""
|
||||
return admin_service.get_shop_statistics(db)
|
||||
"""Get vendor statistics for admin dashboard (Admin only)."""
|
||||
return admin_service.get_vendor_statistics(db)
|
||||
|
||||
@@ -42,7 +42,7 @@ logger = logging.getLogger(__name__)
|
||||
@router.get("/marketplace/product/export-csv")
|
||||
async def export_csv(
|
||||
marketplace: Optional[str] = Query(None, description="Filter by marketplace"),
|
||||
shop_name: Optional[str] = Query(None, description="Filter by shop name"),
|
||||
vendor_name: Optional[str] = Query(None, description="Filter by vendor name"),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
@@ -50,14 +50,14 @@ async def export_csv(
|
||||
|
||||
def generate_csv():
|
||||
return marketplace_product_service.generate_csv_export(
|
||||
db=db, marketplace=marketplace, shop_name=shop_name
|
||||
db=db, marketplace=marketplace, vendor_name=vendor_name
|
||||
)
|
||||
|
||||
filename = "marketplace_products_export"
|
||||
if marketplace:
|
||||
filename += f"_{marketplace}"
|
||||
if shop_name:
|
||||
filename += f"_{shop_name}"
|
||||
if vendor_name:
|
||||
filename += f"_{vendor_name}"
|
||||
filename += ".csv"
|
||||
|
||||
return StreamingResponse(
|
||||
@@ -75,12 +75,12 @@ def get_products(
|
||||
category: Optional[str] = Query(None),
|
||||
availability: Optional[str] = Query(None),
|
||||
marketplace: Optional[str] = Query(None, description="Filter by marketplace"),
|
||||
shop_name: Optional[str] = Query(None, description="Filter by shop name"),
|
||||
vendor_name: Optional[str] = Query(None, description="Filter by vendor name"),
|
||||
search: Optional[str] = Query(None),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Get products with advanced filtering including marketplace and shop (Protected)."""
|
||||
"""Get products with advanced filtering including marketplace and vendor (Protected)."""
|
||||
products, total = marketplace_product_service.get_products_with_filters(
|
||||
db=db,
|
||||
skip=skip,
|
||||
@@ -89,7 +89,7 @@ def get_products(
|
||||
category=category,
|
||||
availability=availability,
|
||||
marketplace=marketplace,
|
||||
shop_name=shop_name,
|
||||
vendor_name=vendor_name,
|
||||
search=search,
|
||||
)
|
||||
|
||||
@@ -166,7 +166,7 @@ async def import_products_from_marketplace(
|
||||
):
|
||||
"""Import products from marketplace CSV with background processing (Protected)."""
|
||||
logger.info(
|
||||
f"Starting marketplace import: {request.marketplace} -> {request.shop_code} by user {current_user.username}"
|
||||
f"Starting marketplace import: {request.marketplace} -> {request.vendor_code} by user {current_user.username}"
|
||||
)
|
||||
|
||||
# Create import job through service
|
||||
@@ -178,7 +178,7 @@ async def import_products_from_marketplace(
|
||||
import_job.id,
|
||||
request.url,
|
||||
request.marketplace,
|
||||
request.shop_code,
|
||||
request.vendor_code,
|
||||
request.batch_size or 1000,
|
||||
)
|
||||
|
||||
@@ -186,9 +186,9 @@ async def import_products_from_marketplace(
|
||||
job_id=import_job.id,
|
||||
status="pending",
|
||||
marketplace=request.marketplace,
|
||||
shop_code=request.shop_code,
|
||||
shop_id=import_job.shop_id,
|
||||
shop_name=import_job.shop_name,
|
||||
vendor_code=request.vendor_code,
|
||||
vendor_id=import_job.vendor_id,
|
||||
vendor_name=import_job.vendor_name,
|
||||
message=f"Marketplace import started from {request.marketplace}. Check status with "
|
||||
f"/import-status/{import_job.id}",
|
||||
)
|
||||
@@ -212,7 +212,7 @@ def get_marketplace_import_status(
|
||||
)
|
||||
def get_marketplace_import_jobs(
|
||||
marketplace: Optional[str] = Query(None, description="Filter by marketplace"),
|
||||
shop_name: Optional[str] = Query(None, description="Filter by shop name"),
|
||||
vendor_name: Optional[str] = Query(None, description="Filter by vendor name"),
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(50, ge=1, le=100),
|
||||
db: Session = Depends(get_db),
|
||||
@@ -223,7 +223,7 @@ def get_marketplace_import_jobs(
|
||||
db=db,
|
||||
user=current_user,
|
||||
marketplace=marketplace,
|
||||
shop_name=shop_name,
|
||||
vendor_name=vendor_name,
|
||||
skip=skip,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
@@ -36,7 +36,7 @@ def get_stats(
|
||||
unique_brands=stats_data["unique_brands"],
|
||||
unique_categories=stats_data["unique_categories"],
|
||||
unique_marketplaces=stats_data["unique_marketplaces"],
|
||||
unique_shops=stats_data["unique_shops"],
|
||||
unique_vendors=stats_data["unique_vendors"],
|
||||
total_stock_entries=stats_data["total_stock_entries"],
|
||||
total_inventory_quantity=stats_data["total_inventory_quantity"],
|
||||
)
|
||||
@@ -87,7 +87,7 @@ def get_stats(
|
||||
unique_brands=stats_data["unique_brands"],
|
||||
unique_categories=stats_data["unique_categories"],
|
||||
unique_marketplaces=stats_data["unique_marketplaces"],
|
||||
unique_shops=stats_data["unique_shops"],
|
||||
unique_vendors=stats_data["unique_vendors"],
|
||||
total_stock_entries=stats_data["total_stock_entries"],
|
||||
total_inventory_quantity=stats_data["total_inventory_quantity"],
|
||||
)
|
||||
@@ -104,7 +104,7 @@ def get_marketplace_stats(
|
||||
MarketplaceStatsResponse(
|
||||
marketplace=stat["marketplace"],
|
||||
total_products=stat["total_products"],
|
||||
unique_shops=stat["unique_shops"],
|
||||
unique_vendors=stat["unique_vendors"],
|
||||
unique_brands=stat["unique_brands"],
|
||||
)
|
||||
for stat in marketplace_stats
|
||||
|
||||
137
app/api/v1/vendor.py
Normal file
137
app/api/v1/vendor.py
Normal 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),
|
||||
}
|
||||
@@ -23,13 +23,13 @@ class Settings(BaseSettings):
|
||||
|
||||
# Clean description without HTML
|
||||
description: str = """
|
||||
Marketplace product import and management system with multi-shop support.
|
||||
Marketplace product import and management system with multi-vendor support.
|
||||
|
||||
**Features:**
|
||||
- JWT Authentication with role-based access
|
||||
- Multi-marketplace product import (CSV processing)
|
||||
- Inventory management across multiple locations
|
||||
- Shop management with individual configurations
|
||||
- Vendor management with individual configurations
|
||||
|
||||
**Documentation:** Visit /documentation for complete guides
|
||||
**API Testing:** Use /docs for interactive API exploration
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# app/exceptions/__init__.py
|
||||
"""
|
||||
Custom exception classes for the LetzShop API.
|
||||
Custom exception classes for the LetzVendor API.
|
||||
|
||||
This module provides frontend-friendly exceptions with consistent error codes,
|
||||
messages, and HTTP status mappings.
|
||||
@@ -48,15 +48,15 @@ from .stock import (
|
||||
LocationNotFoundException
|
||||
)
|
||||
|
||||
from .shop import (
|
||||
ShopNotFoundException,
|
||||
ShopAlreadyExistsException,
|
||||
ShopNotActiveException,
|
||||
ShopNotVerifiedException,
|
||||
UnauthorizedShopAccessException,
|
||||
InvalidShopDataException,
|
||||
MaxShopsReachedException,
|
||||
ShopValidationException,
|
||||
from .vendor import (
|
||||
VendorNotFoundException,
|
||||
VendorAlreadyExistsException,
|
||||
VendorNotActiveException,
|
||||
VendorNotVerifiedException,
|
||||
UnauthorizedVendorAccessException,
|
||||
InvalidVendorDataException,
|
||||
MaxVendorsReachedException,
|
||||
VendorValidationException,
|
||||
)
|
||||
|
||||
from .product import (
|
||||
@@ -81,7 +81,7 @@ from .marketplace_import_job import (
|
||||
from .admin import (
|
||||
UserNotFoundException,
|
||||
UserStatusChangeException,
|
||||
ShopVerificationException,
|
||||
VendorVerificationException,
|
||||
AdminOperationException,
|
||||
CannotModifyAdminException,
|
||||
CannotModifySelfException,
|
||||
@@ -127,15 +127,15 @@ __all__ = [
|
||||
"InvalidQuantityException",
|
||||
"LocationNotFoundException",
|
||||
|
||||
# Shop exceptions
|
||||
"ShopNotFoundException",
|
||||
"ShopAlreadyExistsException",
|
||||
"ShopNotActiveException",
|
||||
"ShopNotVerifiedException",
|
||||
"UnauthorizedShopAccessException",
|
||||
"InvalidShopDataException",
|
||||
"MaxShopsReachedException",
|
||||
"ShopValidationException",
|
||||
# Vendor exceptions
|
||||
"VendorNotFoundException",
|
||||
"VendorAlreadyExistsException",
|
||||
"VendorNotActiveException",
|
||||
"VendorNotVerifiedException",
|
||||
"UnauthorizedVendorAccessException",
|
||||
"InvalidVendorDataException",
|
||||
"MaxVendorsReachedException",
|
||||
"VendorValidationException",
|
||||
|
||||
# Product exceptions
|
||||
"ProductAlreadyExistsException",
|
||||
@@ -157,7 +157,7 @@ __all__ = [
|
||||
# Admin exceptions
|
||||
"UserNotFoundException",
|
||||
"UserStatusChangeException",
|
||||
"ShopVerificationException",
|
||||
"VendorVerificationException",
|
||||
"AdminOperationException",
|
||||
"CannotModifyAdminException",
|
||||
"CannotModifySelfException",
|
||||
|
||||
@@ -57,17 +57,17 @@ class UserStatusChangeException(BusinessLogicException):
|
||||
)
|
||||
|
||||
|
||||
class ShopVerificationException(BusinessLogicException):
|
||||
"""Raised when shop verification fails."""
|
||||
class VendorVerificationException(BusinessLogicException):
|
||||
"""Raised when vendor verification fails."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
shop_id: int,
|
||||
vendor_id: int,
|
||||
reason: str,
|
||||
current_verification_status: Optional[bool] = None,
|
||||
):
|
||||
details = {
|
||||
"shop_id": shop_id,
|
||||
"vendor_id": vendor_id,
|
||||
"reason": reason,
|
||||
}
|
||||
|
||||
@@ -75,8 +75,8 @@ class ShopVerificationException(BusinessLogicException):
|
||||
details["current_verification_status"] = current_verification_status
|
||||
|
||||
super().__init__(
|
||||
message=f"Shop verification failed for shop {shop_id}: {reason}",
|
||||
error_code="SHOP_VERIFICATION_FAILED",
|
||||
message=f"Vendor verification failed for vendor {vendor_id}: {reason}",
|
||||
error_code="VENDOR_VERIFICATION_FAILED",
|
||||
details=details,
|
||||
)
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# app/exceptions/base.py
|
||||
"""
|
||||
Base exception classes for the LetzShop application.
|
||||
Base exception classes for the LetzVendor application.
|
||||
|
||||
This module provides classes and functions for:
|
||||
- Base exception class with consistent error formatting
|
||||
@@ -12,7 +12,7 @@ from typing import Any, Dict, Optional
|
||||
|
||||
|
||||
class LetzShopException(Exception):
|
||||
"""Base exception class for all LetzShop custom exceptions."""
|
||||
"""Base exception class for all LetzVendor custom exceptions."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -206,6 +206,6 @@ class ServiceUnavailableException(LetzShopException):
|
||||
status_code=503,
|
||||
)
|
||||
|
||||
# Note: Domain-specific exceptions like ShopNotFoundException, UserNotFoundException, etc.
|
||||
# are defined in their respective domain modules (shop.py, admin.py, etc.)
|
||||
# Note: Domain-specific exceptions like VendorNotFoundException, UserNotFoundException, etc.
|
||||
# are defined in their respective domain modules (vendor.py, admin.py, etc.)
|
||||
# to keep domain-specific logic separate from base exceptions.
|
||||
|
||||
@@ -26,7 +26,7 @@ def setup_exception_handlers(app):
|
||||
|
||||
@app.exception_handler(LetzShopException)
|
||||
async def custom_exception_handler(request: Request, exc: LetzShopException):
|
||||
"""Handle custom LetzShop exceptions."""
|
||||
"""Handle custom LetzVendor exceptions."""
|
||||
|
||||
logger.error(
|
||||
f"Custom exception in {request.method} {request.url}: "
|
||||
|
||||
@@ -189,12 +189,12 @@ class InvalidMarketplaceException(ValidationException):
|
||||
class ImportJobAlreadyProcessingException(BusinessLogicException):
|
||||
"""Raised when trying to start import while another is already processing."""
|
||||
|
||||
def __init__(self, shop_code: str, existing_job_id: int):
|
||||
def __init__(self, vendor_code: str, existing_job_id: int):
|
||||
super().__init__(
|
||||
message=f"Import already in progress for shop '{shop_code}'",
|
||||
message=f"Import already in progress for vendor '{vendor_code}'",
|
||||
error_code="IMPORT_JOB_ALREADY_PROCESSING",
|
||||
details={
|
||||
"shop_code": shop_code,
|
||||
"vendor_code": vendor_code,
|
||||
"existing_job_id": existing_job_id,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# app/exceptions/shop.py
|
||||
# app/exceptions/vendor.py
|
||||
"""
|
||||
Shop management specific exceptions.
|
||||
Vendor management specific exceptions.
|
||||
"""
|
||||
|
||||
from .base import (
|
||||
@@ -9,26 +9,26 @@ from .base import (
|
||||
)
|
||||
|
||||
class ProductAlreadyExistsException(ConflictException):
|
||||
"""Raised when trying to add a product that already exists in shop."""
|
||||
"""Raised when trying to add a product that already exists in vendor."""
|
||||
|
||||
def __init__(self, shop_code: str, marketplace_product_id: str):
|
||||
def __init__(self, vendor_code: str, marketplace_product_id: str):
|
||||
super().__init__(
|
||||
message=f"MarketplaceProduct '{marketplace_product_id}' already exists in shop '{shop_code}'",
|
||||
message=f"MarketplaceProduct '{marketplace_product_id}' already exists in vendor '{vendor_code}'",
|
||||
error_code="PRODUCT_ALREADY_EXISTS",
|
||||
details={
|
||||
"shop_code": shop_code,
|
||||
"vendor_code": vendor_code,
|
||||
"marketplace_product_id": marketplace_product_id,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class ProductNotFoundException(ResourceNotFoundException):
|
||||
"""Raised when a shop product relationship is not found."""
|
||||
"""Raised when a vendor product relationship is not found."""
|
||||
|
||||
def __init__(self, shop_code: str, marketplace_product_id: str):
|
||||
def __init__(self, vendor_code: str, marketplace_product_id: str):
|
||||
super().__init__(
|
||||
resource_type="ShopProduct",
|
||||
identifier=f"{shop_code}/{marketplace_product_id}",
|
||||
message=f"MarketplaceProduct '{marketplace_product_id}' not found in shop '{shop_code}'",
|
||||
resource_type="Product",
|
||||
identifier=f"{vendor_code}/{marketplace_product_id}",
|
||||
message=f"MarketplaceProduct '{marketplace_product_id}' not found in vendor '{vendor_code}'",
|
||||
error_code="PRODUCT_NOT_FOUND",
|
||||
)
|
||||
|
||||
@@ -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
131
app/exceptions/vendor.py
Normal 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"
|
||||
@@ -1,10 +1,10 @@
|
||||
# app/services/admin_service.py
|
||||
"""
|
||||
Admin service for managing users, shops, and import jobs.
|
||||
Admin service for managing users, vendors, and import jobs.
|
||||
|
||||
This module provides classes and functions for:
|
||||
- User management and status control
|
||||
- Shop verification and activation
|
||||
- Vendor verification and activation
|
||||
- Marketplace import job monitoring
|
||||
"""
|
||||
|
||||
@@ -18,13 +18,13 @@ from app.exceptions import (
|
||||
UserNotFoundException,
|
||||
UserStatusChangeException,
|
||||
CannotModifySelfException,
|
||||
ShopNotFoundException,
|
||||
ShopVerificationException,
|
||||
VendorNotFoundException,
|
||||
VendorVerificationException,
|
||||
AdminOperationException,
|
||||
)
|
||||
from models.schemas.marketplace_import_job import MarketplaceImportJobResponse
|
||||
from models.database.marketplace_import_job import MarketplaceImportJob
|
||||
from models.database.shop import Shop
|
||||
from models.database.vendor import Vendor
|
||||
from models.database.user import User
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -101,11 +101,11 @@ class AdminService:
|
||||
reason="Database update failed"
|
||||
)
|
||||
|
||||
def get_all_shops(
|
||||
def get_all_vendors(
|
||||
self, db: Session, skip: int = 0, limit: int = 100
|
||||
) -> Tuple[List[Shop], int]:
|
||||
) -> Tuple[List[Vendor], int]:
|
||||
"""
|
||||
Get paginated list of all shops with total count.
|
||||
Get paginated list of all vendors with total count.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
@@ -113,108 +113,108 @@ class AdminService:
|
||||
limit: Maximum number of records to return
|
||||
|
||||
Returns:
|
||||
Tuple of (shops_list, total_count)
|
||||
Tuple of (vendors_list, total_count)
|
||||
"""
|
||||
try:
|
||||
total = db.query(Shop).count()
|
||||
shops = db.query(Shop).offset(skip).limit(limit).all()
|
||||
return shops, total
|
||||
total = db.query(Vendor).count()
|
||||
vendors =db.query(Vendor).offset(skip).limit(limit).all()
|
||||
return vendors, total
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to retrieve shops: {str(e)}")
|
||||
logger.error(f"Failed to retrieve vendors: {str(e)}")
|
||||
raise AdminOperationException(
|
||||
operation="get_all_shops",
|
||||
operation="get_all_vendors",
|
||||
reason="Database query failed"
|
||||
)
|
||||
|
||||
def verify_shop(self, db: Session, shop_id: int) -> Tuple[Shop, str]:
|
||||
def verify_vendor(self, db: Session, vendor_id: int) -> Tuple[Vendor, str]:
|
||||
"""
|
||||
Toggle shop verification status.
|
||||
Toggle vendor verification status.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
shop_id: ID of shop to verify/unverify
|
||||
vendor_id: ID of vendor to verify/unverify
|
||||
|
||||
Returns:
|
||||
Tuple of (updated_shop, status_message)
|
||||
Tuple of (updated_vendor, status_message)
|
||||
|
||||
Raises:
|
||||
ShopNotFoundException: If shop not found
|
||||
ShopVerificationException: If verification fails
|
||||
VendorNotFoundException: If vendor not found
|
||||
VendorVerificationException: If verification fails
|
||||
"""
|
||||
shop = self._get_shop_by_id_or_raise(db, shop_id)
|
||||
vendor = self._get_vendor_by_id_or_raise(db, vendor_id)
|
||||
|
||||
try:
|
||||
original_status = shop.is_verified
|
||||
shop.is_verified = not shop.is_verified
|
||||
shop.updated_at = datetime.now(timezone.utc)
|
||||
original_status = vendor.is_verified
|
||||
vendor.is_verified = not vendor.is_verified
|
||||
vendor.updated_at = datetime.now(timezone.utc)
|
||||
|
||||
# Add verification timestamp if implementing audit trail
|
||||
if shop.is_verified:
|
||||
shop.verified_at = datetime.now(timezone.utc)
|
||||
if vendor.is_verified:
|
||||
vendor.verified_at = datetime.now(timezone.utc)
|
||||
|
||||
db.commit()
|
||||
db.refresh(shop)
|
||||
db.refresh(vendor)
|
||||
|
||||
status_action = "verified" if shop.is_verified else "unverified"
|
||||
message = f"Shop {shop.shop_code} has been {status_action}"
|
||||
status_action = "verified" if vendor.is_verified else "unverified"
|
||||
message = f"Vendor {vendor.vendor_code} has been {status_action}"
|
||||
|
||||
logger.info(message)
|
||||
return shop, message
|
||||
return vendor, message
|
||||
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.error(f"Failed to verify shop {shop_id}: {str(e)}")
|
||||
raise ShopVerificationException(
|
||||
shop_id=shop_id,
|
||||
logger.error(f"Failed to verify vendor {vendor_id}: {str(e)}")
|
||||
raise VendorVerificationException(
|
||||
vendor_id=vendor_id,
|
||||
reason="Database update failed",
|
||||
current_verification_status=original_status
|
||||
)
|
||||
|
||||
def toggle_shop_status(self, db: Session, shop_id: int) -> Tuple[Shop, str]:
|
||||
def toggle_vendor_status(self, db: Session, vendor_id: int) -> Tuple[Vendor, str]:
|
||||
"""
|
||||
Toggle shop active status.
|
||||
Toggle vendor active status.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
shop_id: ID of shop to activate/deactivate
|
||||
vendor_id: ID of vendor to activate/deactivate
|
||||
|
||||
Returns:
|
||||
Tuple of (updated_shop, status_message)
|
||||
Tuple of (updated_vendor, status_message)
|
||||
|
||||
Raises:
|
||||
ShopNotFoundException: If shop not found
|
||||
VendorNotFoundException: If vendor not found
|
||||
AdminOperationException: If status change fails
|
||||
"""
|
||||
shop = self._get_shop_by_id_or_raise(db, shop_id)
|
||||
vendor = self._get_vendor_by_id_or_raise(db, vendor_id)
|
||||
|
||||
try:
|
||||
original_status = shop.is_active
|
||||
shop.is_active = not shop.is_active
|
||||
shop.updated_at = datetime.now(timezone.utc)
|
||||
original_status = vendor.is_active
|
||||
vendor.is_active = not vendor.is_active
|
||||
vendor.updated_at = datetime.now(timezone.utc)
|
||||
db.commit()
|
||||
db.refresh(shop)
|
||||
db.refresh(vendor)
|
||||
|
||||
status_action = "activated" if shop.is_active else "deactivated"
|
||||
message = f"Shop {shop.shop_code} has been {status_action}"
|
||||
status_action = "activated" if vendor.is_active else "deactivated"
|
||||
message = f"Vendor {vendor.vendor_code} has been {status_action}"
|
||||
|
||||
logger.info(message)
|
||||
return shop, message
|
||||
return vendor , message
|
||||
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.error(f"Failed to toggle shop {shop_id} status: {str(e)}")
|
||||
logger.error(f"Failed to toggle vendor {vendor_id} status: {str(e)}")
|
||||
raise AdminOperationException(
|
||||
operation="toggle_shop_status",
|
||||
operation="toggle_vendor_status",
|
||||
reason="Database update failed",
|
||||
target_type="shop",
|
||||
target_id=str(shop_id)
|
||||
target_type="vendor ",
|
||||
target_id=str(vendor_id)
|
||||
)
|
||||
|
||||
def get_marketplace_import_jobs(
|
||||
self,
|
||||
db: Session,
|
||||
marketplace: Optional[str] = None,
|
||||
shop_name: Optional[str] = None,
|
||||
vendor_name: Optional[str] = None,
|
||||
status: Optional[str] = None,
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
@@ -225,7 +225,7 @@ class AdminService:
|
||||
Args:
|
||||
db: Database session
|
||||
marketplace: Filter by marketplace name (case-insensitive partial match)
|
||||
shop_name: Filter by shop name (case-insensitive partial match)
|
||||
vendor_name: Filter by vendor name (case-insensitive partial match)
|
||||
status: Filter by exact status
|
||||
skip: Number of records to skip
|
||||
limit: Maximum number of records to return
|
||||
@@ -241,8 +241,8 @@ class AdminService:
|
||||
query = query.filter(
|
||||
MarketplaceImportJob.marketplace.ilike(f"%{marketplace}%")
|
||||
)
|
||||
if shop_name:
|
||||
query = query.filter(MarketplaceImportJob.shop_name.ilike(f"%{shop_name}%"))
|
||||
if vendor_name:
|
||||
query = query.filter(MarketplaceImportJob.vendor_name.ilike(f"%{vendor_name}%"))
|
||||
if status:
|
||||
query = query.filter(MarketplaceImportJob.status == status)
|
||||
|
||||
@@ -283,23 +283,23 @@ class AdminService:
|
||||
reason="Database query failed"
|
||||
)
|
||||
|
||||
def get_shop_statistics(self, db: Session) -> dict:
|
||||
"""Get shop statistics for admin dashboard."""
|
||||
def get_vendor_statistics(self, db: Session) -> dict:
|
||||
"""Get vendor statistics for admin dashboard."""
|
||||
try:
|
||||
total_shops = db.query(Shop).count()
|
||||
active_shops = db.query(Shop).filter(Shop.is_active == True).count()
|
||||
verified_shops = db.query(Shop).filter(Shop.is_verified == True).count()
|
||||
total_vendors = db.query(Vendor).count()
|
||||
active_vendors = db.query(Vendor).filter(Vendor.is_active == True).count()
|
||||
verified_vendors = db.query(Vendor).filter(Vendor.is_verified == True).count()
|
||||
|
||||
return {
|
||||
"total_shops": total_shops,
|
||||
"active_shops": active_shops,
|
||||
"verified_shops": verified_shops,
|
||||
"verification_rate": (verified_shops / total_shops * 100) if total_shops > 0 else 0
|
||||
"total_vendors": total_vendors,
|
||||
"active_vendors": active_vendors,
|
||||
"verified_vendors": verified_vendors,
|
||||
"verification_rate": (verified_vendors / total_vendors * 100) if total_vendors > 0 else 0
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get shop statistics: {str(e)}")
|
||||
logger.error(f"Failed to get vendor statistics: {str(e)}")
|
||||
raise AdminOperationException(
|
||||
operation="get_shop_statistics",
|
||||
operation="get_vendor_statistics",
|
||||
reason="Database query failed"
|
||||
)
|
||||
|
||||
@@ -311,12 +311,12 @@ class AdminService:
|
||||
raise UserNotFoundException(str(user_id))
|
||||
return user
|
||||
|
||||
def _get_shop_by_id_or_raise(self, db: Session, shop_id: int) -> Shop:
|
||||
"""Get shop by ID or raise ShopNotFoundException."""
|
||||
shop = db.query(Shop).filter(Shop.id == shop_id).first()
|
||||
if not shop:
|
||||
raise ShopNotFoundException(str(shop_id), identifier_type="id")
|
||||
return shop
|
||||
def _get_vendor_by_id_or_raise(self, db: Session, vendor_id: int) -> Vendor:
|
||||
"""Get vendor by ID or raise VendorNotFoundException."""
|
||||
vendor = db.query(Vendor).filter(Vendor.id == vendor_id).first()
|
||||
if not vendor :
|
||||
raise VendorNotFoundException(str(vendor_id), identifier_type="id")
|
||||
return vendor
|
||||
|
||||
def _convert_job_to_response(self, job: MarketplaceImportJob) -> MarketplaceImportJobResponse:
|
||||
"""Convert database model to response schema."""
|
||||
@@ -324,9 +324,9 @@ class AdminService:
|
||||
job_id=job.id,
|
||||
status=job.status,
|
||||
marketplace=job.marketplace,
|
||||
shop_id=job.shop.id if job.shop else None,
|
||||
shop_code=job.shop.shop_code if job.shop else None,
|
||||
shop_name=job.shop_name,
|
||||
vendor_id=job.vendor.id if job.vendor else None,
|
||||
vendor_code=job.vendor.vendor_code if job.vendor else None,
|
||||
vendor_name=job.vendor_name,
|
||||
imported=job.imported_count or 0,
|
||||
updated=job.updated_count or 0,
|
||||
total_processed=job.total_processed or 0,
|
||||
|
||||
@@ -4,7 +4,7 @@ Marketplace service for managing import jobs and marketplace integrations.
|
||||
|
||||
This module provides classes and functions for:
|
||||
- Import job creation and management
|
||||
- Shop access validation
|
||||
- Vendor access validation
|
||||
- Import job status tracking and updates
|
||||
"""
|
||||
|
||||
@@ -16,8 +16,8 @@ from sqlalchemy import func
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.exceptions import (
|
||||
ShopNotFoundException,
|
||||
UnauthorizedShopAccessException,
|
||||
VendorNotFoundException,
|
||||
UnauthorizedVendorAccessException,
|
||||
ImportJobNotFoundException,
|
||||
ImportJobNotOwnedException,
|
||||
ImportJobCannotBeCancelledException,
|
||||
@@ -27,7 +27,7 @@ from app.exceptions import (
|
||||
from models.schemas.marketplace_import_job import (MarketplaceImportJobResponse,
|
||||
MarketplaceImportJobRequest)
|
||||
from models.database.marketplace_import_job import MarketplaceImportJob
|
||||
from models.database.shop import Shop
|
||||
from models.database.vendor import Vendor
|
||||
from models.database.user import User
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -36,44 +36,44 @@ logger = logging.getLogger(__name__)
|
||||
class MarketplaceImportJobService:
|
||||
"""Service class for Marketplace operations following the application's service pattern."""
|
||||
|
||||
def validate_shop_access(self, db: Session, shop_code: str, user: User) -> Shop:
|
||||
def validate_vendor_access(self, db: Session, vendor_code: str, user: User) -> Vendor:
|
||||
"""
|
||||
Validate that the shop exists and user has access to it.
|
||||
Validate that the vendor exists and user has access to it.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
shop_code: Shop code to validate
|
||||
vendor_code: Vendor code to validate
|
||||
user: User requesting access
|
||||
|
||||
Returns:
|
||||
Shop object if access is valid
|
||||
Vendor object if access is valid
|
||||
|
||||
Raises:
|
||||
ShopNotFoundException: If shop doesn't exist
|
||||
UnauthorizedShopAccessException: If user lacks access
|
||||
VendorNotFoundException: If vendor doesn't exist
|
||||
UnauthorizedVendorAccessException: If user lacks access
|
||||
"""
|
||||
try:
|
||||
# Use case-insensitive query to handle both uppercase and lowercase codes
|
||||
shop = (
|
||||
db.query(Shop)
|
||||
.filter(func.upper(Shop.shop_code) == shop_code.upper())
|
||||
vendor = (
|
||||
db.query(Vendor)
|
||||
.filter(func.upper(Vendor.vendor_code) == vendor_code.upper())
|
||||
.first()
|
||||
)
|
||||
|
||||
if not shop:
|
||||
raise ShopNotFoundException(shop_code)
|
||||
if not vendor :
|
||||
raise VendorNotFoundException(vendor_code)
|
||||
|
||||
# Check permissions: admin can import for any shop, others only for their own
|
||||
if user.role != "admin" and shop.owner_id != user.id:
|
||||
raise UnauthorizedShopAccessException(shop_code, user.id)
|
||||
# Check permissions: admin can import for any vendor, others only for their own
|
||||
if user.role != "admin" and vendor.owner_id != user.id:
|
||||
raise UnauthorizedVendorAccessException(vendor_code, user.id)
|
||||
|
||||
return shop
|
||||
return vendor
|
||||
|
||||
except (ShopNotFoundException, UnauthorizedShopAccessException):
|
||||
except (VendorNotFoundException, UnauthorizedVendorAccessException):
|
||||
raise # Re-raise custom exceptions
|
||||
except Exception as e:
|
||||
logger.error(f"Error validating shop access: {str(e)}")
|
||||
raise ValidationException("Failed to validate shop access")
|
||||
logger.error(f"Error validating vendor access: {str(e)}")
|
||||
raise ValidationException("Failed to validate vendor access")
|
||||
|
||||
def create_import_job(
|
||||
self, db: Session, request: MarketplaceImportJobRequest, user: User
|
||||
@@ -90,21 +90,21 @@ class MarketplaceImportJobService:
|
||||
Created MarketplaceImportJob object
|
||||
|
||||
Raises:
|
||||
ShopNotFoundException: If shop doesn't exist
|
||||
UnauthorizedShopAccessException: If user lacks shop access
|
||||
VendorNotFoundException: If vendor doesn't exist
|
||||
UnauthorizedVendorAccessException: If user lacks vendor access
|
||||
ValidationException: If job creation fails
|
||||
"""
|
||||
try:
|
||||
# Validate shop access first
|
||||
shop = self.validate_shop_access(db, request.shop_code, user)
|
||||
# Validate vendor access first
|
||||
vendor = self.validate_vendor_access(db, request.vendor_code, user)
|
||||
|
||||
# Create marketplace import job record
|
||||
import_job = MarketplaceImportJob(
|
||||
status="pending",
|
||||
source_url=request.url,
|
||||
marketplace=request.marketplace,
|
||||
shop_id=shop.id, # Foreign key to shops table
|
||||
shop_name=shop.shop_name, # Use shop.shop_name (the display name)
|
||||
vendor_id=vendor.id, # Foreign key to vendors table
|
||||
vendor_name=vendor.vendor_name, # Use vendor.vendor_name (the display name)
|
||||
user_id=user.id,
|
||||
created_at=datetime.now(timezone.utc),
|
||||
)
|
||||
@@ -115,12 +115,12 @@ class MarketplaceImportJobService:
|
||||
|
||||
logger.info(
|
||||
f"Created marketplace import job {import_job.id}: "
|
||||
f"{request.marketplace} -> {shop.shop_name} (shop_code: {shop.shop_code}) by user {user.username}"
|
||||
f"{request.marketplace} -> {vendor.vendor_name} (vendor_code: {vendor.vendor_code}) by user {user.username}"
|
||||
)
|
||||
|
||||
return import_job
|
||||
|
||||
except (ShopNotFoundException, UnauthorizedShopAccessException):
|
||||
except (VendorNotFoundException, UnauthorizedVendorAccessException):
|
||||
raise # Re-raise custom exceptions
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
@@ -172,7 +172,7 @@ class MarketplaceImportJobService:
|
||||
db: Session,
|
||||
user: User,
|
||||
marketplace: Optional[str] = None,
|
||||
shop_name: Optional[str] = None,
|
||||
vendor_name: Optional[str] = None,
|
||||
skip: int = 0,
|
||||
limit: int = 50,
|
||||
) -> List[MarketplaceImportJob]:
|
||||
@@ -183,7 +183,7 @@ class MarketplaceImportJobService:
|
||||
db: Database session
|
||||
user: User requesting jobs
|
||||
marketplace: Optional marketplace filter
|
||||
shop_name: Optional shop name filter
|
||||
vendor_name: Optional vendor name filter
|
||||
skip: Number of records to skip
|
||||
limit: Maximum records to return
|
||||
|
||||
@@ -202,8 +202,8 @@ class MarketplaceImportJobService:
|
||||
query = query.filter(
|
||||
MarketplaceImportJob.marketplace.ilike(f"%{marketplace}%")
|
||||
)
|
||||
if shop_name:
|
||||
query = query.filter(MarketplaceImportJob.shop_name.ilike(f"%{shop_name}%"))
|
||||
if vendor_name:
|
||||
query = query.filter(MarketplaceImportJob.vendor_name.ilike(f"%{vendor_name}%"))
|
||||
|
||||
# Order by creation date (newest first) and apply pagination
|
||||
jobs = (
|
||||
@@ -319,11 +319,11 @@ class MarketplaceImportJobService:
|
||||
job_id=job.id,
|
||||
status=job.status,
|
||||
marketplace=job.marketplace,
|
||||
shop_id=job.shop_id,
|
||||
shop_code=(
|
||||
job.shop.shop_code if job.shop else None
|
||||
vendor_id=job.vendor_id,
|
||||
vendor_code=(
|
||||
job.vendor.vendor_code if job.vendor else None
|
||||
), # Add this optional field via relationship
|
||||
shop_name=job.shop_name,
|
||||
vendor_name=job.vendor_name,
|
||||
imported=job.imported_count or 0,
|
||||
updated=job.updated_count or 0,
|
||||
total_processed=job.total_processed or 0,
|
||||
|
||||
@@ -135,7 +135,7 @@ class MarketplaceProductService:
|
||||
category: Optional[str] = None,
|
||||
availability: Optional[str] = None,
|
||||
marketplace: Optional[str] = None,
|
||||
shop_name: Optional[str] = None,
|
||||
vendor_name: Optional[str] = None,
|
||||
search: Optional[str] = None,
|
||||
) -> Tuple[List[MarketplaceProduct], int]:
|
||||
"""
|
||||
@@ -149,7 +149,7 @@ class MarketplaceProductService:
|
||||
category: Category filter
|
||||
availability: Availability filter
|
||||
marketplace: Marketplace filter
|
||||
shop_name: Shop name filter
|
||||
vendor_name: Vendor name filter
|
||||
search: Search term
|
||||
|
||||
Returns:
|
||||
@@ -167,16 +167,16 @@ class MarketplaceProductService:
|
||||
query = query.filter(MarketplaceProduct.availability == availability)
|
||||
if marketplace:
|
||||
query = query.filter(MarketplaceProduct.marketplace.ilike(f"%{marketplace}%"))
|
||||
if shop_name:
|
||||
query = query.filter(MarketplaceProduct.shop_name.ilike(f"%{shop_name}%"))
|
||||
if vendor_name:
|
||||
query = query.filter(MarketplaceProduct.vendor_name.ilike(f"%{vendor_name}%"))
|
||||
if search:
|
||||
# Search in title, description, marketplace, and shop_name
|
||||
# Search in title, description, marketplace, and vendor_name
|
||||
search_term = f"%{search}%"
|
||||
query = query.filter(
|
||||
(MarketplaceProduct.title.ilike(search_term))
|
||||
| (MarketplaceProduct.description.ilike(search_term))
|
||||
| (MarketplaceProduct.marketplace.ilike(search_term))
|
||||
| (MarketplaceProduct.shop_name.ilike(search_term))
|
||||
| (MarketplaceProduct.vendor_name.ilike(search_term))
|
||||
)
|
||||
|
||||
total = query.count()
|
||||
@@ -311,7 +311,7 @@ class MarketplaceProductService:
|
||||
self,
|
||||
db: Session,
|
||||
marketplace: Optional[str] = None,
|
||||
shop_name: Optional[str] = None,
|
||||
vendor_name: Optional[str] = None,
|
||||
) -> Generator[str, None, None]:
|
||||
"""
|
||||
Generate CSV export with streaming for memory efficiency and proper CSV escaping.
|
||||
@@ -319,7 +319,7 @@ class MarketplaceProductService:
|
||||
Args:
|
||||
db: Database session
|
||||
marketplace: Optional marketplace filter
|
||||
shop_name: Optional shop name filter
|
||||
vendor_name: Optional vendor name filter
|
||||
|
||||
Yields:
|
||||
CSV content as strings with proper escaping
|
||||
@@ -333,7 +333,7 @@ class MarketplaceProductService:
|
||||
headers = [
|
||||
"marketplace_product_id", "title", "description", "link", "image_link",
|
||||
"availability", "price", "currency", "brand", "gtin",
|
||||
"marketplace", "shop_name"
|
||||
"marketplace", "vendor_name"
|
||||
]
|
||||
writer.writerow(headers)
|
||||
yield output.getvalue()
|
||||
@@ -351,8 +351,8 @@ class MarketplaceProductService:
|
||||
# Apply marketplace filters
|
||||
if marketplace:
|
||||
query = query.filter(MarketplaceProduct.marketplace.ilike(f"%{marketplace}%"))
|
||||
if shop_name:
|
||||
query = query.filter(MarketplaceProduct.shop_name.ilike(f"%{shop_name}%"))
|
||||
if vendor_name:
|
||||
query = query.filter(MarketplaceProduct.vendor_name.ilike(f"%{vendor_name}%"))
|
||||
|
||||
products = query.offset(offset).limit(batch_size).all()
|
||||
if not products:
|
||||
@@ -372,7 +372,7 @@ class MarketplaceProductService:
|
||||
product.brand or "",
|
||||
product.gtin or "",
|
||||
product.marketplace or "",
|
||||
product.shop_name or "",
|
||||
product.vendor_name or "",
|
||||
]
|
||||
|
||||
writer.writerow(row_data)
|
||||
@@ -413,7 +413,7 @@ class MarketplaceProductService:
|
||||
normalized = product_data.copy()
|
||||
|
||||
# Trim whitespace from string fields
|
||||
string_fields = ['marketplace_product_id', 'title', 'description', 'brand', 'marketplace', 'shop_name']
|
||||
string_fields = ['marketplace_product_id', 'title', 'description', 'brand', 'marketplace', 'vendor_name']
|
||||
for field in string_fields:
|
||||
if field in normalized and normalized[field]:
|
||||
normalized[field] = normalized[field].strip()
|
||||
|
||||
@@ -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()
|
||||
@@ -44,7 +44,7 @@ class StatsService:
|
||||
unique_brands = self._get_unique_brands_count(db)
|
||||
unique_categories = self._get_unique_categories_count(db)
|
||||
unique_marketplaces = self._get_unique_marketplaces_count(db)
|
||||
unique_shops = self._get_unique_shops_count(db)
|
||||
unique_vendors = self._get_unique_vendors_count(db)
|
||||
|
||||
# Stock statistics
|
||||
stock_stats = self._get_stock_statistics(db)
|
||||
@@ -54,7 +54,7 @@ class StatsService:
|
||||
"unique_brands": unique_brands,
|
||||
"unique_categories": unique_categories,
|
||||
"unique_marketplaces": unique_marketplaces,
|
||||
"unique_shops": unique_shops,
|
||||
"unique_vendors": unique_vendors,
|
||||
"total_stock_entries": stock_stats["total_stock_entries"],
|
||||
"total_inventory_quantity": stock_stats["total_inventory_quantity"],
|
||||
}
|
||||
@@ -87,7 +87,7 @@ class StatsService:
|
||||
db.query(
|
||||
MarketplaceProduct.marketplace,
|
||||
func.count(MarketplaceProduct.id).label("total_products"),
|
||||
func.count(func.distinct(MarketplaceProduct.shop_name)).label("unique_shops"),
|
||||
func.count(func.distinct(MarketplaceProduct.vendor_name)).label("unique_vendors"),
|
||||
func.count(func.distinct(MarketplaceProduct.brand)).label("unique_brands"),
|
||||
)
|
||||
.filter(MarketplaceProduct.marketplace.isnot(None))
|
||||
@@ -99,7 +99,7 @@ class StatsService:
|
||||
{
|
||||
"marketplace": stat.marketplace,
|
||||
"total_products": stat.total_products,
|
||||
"unique_shops": stat.unique_shops,
|
||||
"unique_vendors": stat.unique_vendors,
|
||||
"unique_brands": stat.unique_brands,
|
||||
}
|
||||
for stat in marketplace_stats
|
||||
@@ -130,7 +130,7 @@ class StatsService:
|
||||
"unique_brands": self._get_unique_brands_count(db),
|
||||
"unique_categories": self._get_unique_categories_count(db),
|
||||
"unique_marketplaces": self._get_unique_marketplaces_count(db),
|
||||
"unique_shops": self._get_unique_shops_count(db),
|
||||
"unique_vendors": self._get_unique_vendors_count(db),
|
||||
"products_with_gtin": self._get_products_with_gtin_count(db),
|
||||
"products_with_images": self._get_products_with_images_count(db),
|
||||
}
|
||||
@@ -175,15 +175,15 @@ class StatsService:
|
||||
|
||||
product_count = self._get_products_by_marketplace_count(db, marketplace)
|
||||
brands = self._get_brands_by_marketplace(db, marketplace)
|
||||
shops = self._get_shops_by_marketplace(db, marketplace)
|
||||
vendors =self._get_vendors_by_marketplace(db, marketplace)
|
||||
|
||||
return {
|
||||
"marketplace": marketplace,
|
||||
"total_products": product_count,
|
||||
"unique_brands": len(brands),
|
||||
"unique_shops": len(shops),
|
||||
"unique_vendors": len(vendors),
|
||||
"brands": brands,
|
||||
"shops": shops,
|
||||
"vendors": vendors,
|
||||
}
|
||||
|
||||
except ValidationException:
|
||||
@@ -227,11 +227,11 @@ class StatsService:
|
||||
.count()
|
||||
)
|
||||
|
||||
def _get_unique_shops_count(self, db: Session) -> int:
|
||||
"""Get count of unique shops."""
|
||||
def _get_unique_vendors_count(self, db: Session) -> int:
|
||||
"""Get count of unique vendors."""
|
||||
return (
|
||||
db.query(MarketplaceProduct.shop_name)
|
||||
.filter(MarketplaceProduct.shop_name.isnot(None), MarketplaceProduct.shop_name != "")
|
||||
db.query(MarketplaceProduct.vendor_name)
|
||||
.filter(MarketplaceProduct.vendor_name.isnot(None), MarketplaceProduct.vendor_name != "")
|
||||
.distinct()
|
||||
.count()
|
||||
)
|
||||
@@ -276,19 +276,19 @@ class StatsService:
|
||||
)
|
||||
return [brand[0] for brand in brands]
|
||||
|
||||
def _get_shops_by_marketplace(self, db: Session, marketplace: str) -> List[str]:
|
||||
"""Get unique shops for a specific marketplace."""
|
||||
shops = (
|
||||
db.query(MarketplaceProduct.shop_name)
|
||||
def _get_vendors_by_marketplace(self, db: Session, marketplace: str) -> List[str]:
|
||||
"""Get unique vendors for a specific marketplace."""
|
||||
vendors =(
|
||||
db.query(MarketplaceProduct.vendor_name)
|
||||
.filter(
|
||||
MarketplaceProduct.marketplace == marketplace,
|
||||
MarketplaceProduct.shop_name.isnot(None),
|
||||
MarketplaceProduct.shop_name != "",
|
||||
MarketplaceProduct.vendor_name.isnot(None),
|
||||
MarketplaceProduct.vendor_name != "",
|
||||
)
|
||||
.distinct()
|
||||
.all()
|
||||
)
|
||||
return [shop[0] for shop in shops]
|
||||
return [vendor [0] for vendor in vendors]
|
||||
|
||||
def _get_products_by_marketplace_count(self, db: Session, marketplace: str) -> int:
|
||||
"""Get product count for a specific marketplace."""
|
||||
|
||||
359
app/services/vendor_service.py
Normal file
359
app/services/vendor_service.py
Normal 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()
|
||||
@@ -18,7 +18,7 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def process_marketplace_import(
|
||||
job_id: int, url: str, marketplace: str, shop_name: str, batch_size: int = 1000
|
||||
job_id: int, url: str, marketplace: str, vendor_name: str, batch_size: int = 1000
|
||||
):
|
||||
"""Background task to process marketplace CSV import."""
|
||||
db = SessionLocal()
|
||||
@@ -44,7 +44,7 @@ async def process_marketplace_import(
|
||||
|
||||
# Process CSV
|
||||
result = await csv_processor.process_marketplace_csv_from_url(
|
||||
url, marketplace, shop_name, batch_size, db
|
||||
url, marketplace, vendor_name, batch_size, db
|
||||
)
|
||||
|
||||
# Update job with results
|
||||
|
||||
@@ -187,15 +187,15 @@ class CSVProcessor:
|
||||
return processed_data
|
||||
|
||||
async def process_marketplace_csv_from_url(
|
||||
self, url: str, marketplace: str, shop_name: str, batch_size: int, db: Session
|
||||
self, url: str, marketplace: str, vendor_name: str, batch_size: int, db: Session
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Process CSV from URL with marketplace and shop information.
|
||||
Process CSV from URL with marketplace and vendor information.
|
||||
|
||||
Args:
|
||||
url: URL to the CSV file
|
||||
marketplace: Name of the marketplace (e.g., 'Letzshop', 'Amazon')
|
||||
shop_name: Name of the shop
|
||||
vendor_name: Name of the vendor
|
||||
batch_size: Number of rows to process in each batch
|
||||
db: Database session
|
||||
|
||||
@@ -203,7 +203,7 @@ class CSVProcessor:
|
||||
Dictionary with processing results
|
||||
"""
|
||||
logger.info(
|
||||
f"Starting marketplace CSV import from {url} for {marketplace} -> {shop_name}"
|
||||
f"Starting marketplace CSV import from {url} for {marketplace} -> {vendor_name}"
|
||||
)
|
||||
# Download and parse CSV
|
||||
csv_content = self.download_csv(url)
|
||||
@@ -220,7 +220,7 @@ class CSVProcessor:
|
||||
for i in range(0, len(df), batch_size):
|
||||
batch_df = df.iloc[i : i + batch_size]
|
||||
batch_result = await self._process_marketplace_batch(
|
||||
batch_df, marketplace, shop_name, db, i // batch_size + 1
|
||||
batch_df, marketplace, vendor_name, db, i // batch_size + 1
|
||||
)
|
||||
|
||||
imported += batch_result["imported"]
|
||||
@@ -235,14 +235,14 @@ class CSVProcessor:
|
||||
"updated": updated,
|
||||
"errors": errors,
|
||||
"marketplace": marketplace,
|
||||
"shop_name": shop_name,
|
||||
"vendor_name": vendor_name,
|
||||
}
|
||||
|
||||
async def _process_marketplace_batch(
|
||||
self,
|
||||
batch_df: pd.DataFrame,
|
||||
marketplace: str,
|
||||
shop_name: str,
|
||||
vendor_name: str,
|
||||
db: Session,
|
||||
batch_num: int,
|
||||
) -> Dict[str, int]:
|
||||
@@ -253,7 +253,7 @@ class CSVProcessor:
|
||||
|
||||
logger.info(
|
||||
f"Processing batch {batch_num} with {len(batch_df)} rows for "
|
||||
f"{marketplace} -> {shop_name}"
|
||||
f"{marketplace} -> {vendor_name}"
|
||||
)
|
||||
|
||||
for index, row in batch_df.iterrows():
|
||||
@@ -261,9 +261,9 @@ class CSVProcessor:
|
||||
# Convert row to dictionary and clean up
|
||||
product_data = self._clean_row_data(row.to_dict())
|
||||
|
||||
# Add marketplace and shop information
|
||||
# Add marketplace and vendor information
|
||||
product_data["marketplace"] = marketplace
|
||||
product_data["shop_name"] = shop_name
|
||||
product_data["vendor_name"] = vendor_name
|
||||
|
||||
# Validate required fields
|
||||
if not product_data.get("marketplace_product_id"):
|
||||
@@ -294,7 +294,7 @@ class CSVProcessor:
|
||||
updated += 1
|
||||
logger.debug(
|
||||
f"Updated product {product_data['marketplace_product_id']} for "
|
||||
f"{marketplace} and shop {shop_name}"
|
||||
f"{marketplace} and vendor {vendor_name}"
|
||||
)
|
||||
else:
|
||||
# Create new product
|
||||
@@ -309,7 +309,7 @@ class CSVProcessor:
|
||||
imported += 1
|
||||
logger.debug(
|
||||
f"Imported new product {product_data['marketplace_product_id']} "
|
||||
f"for {marketplace} and shop {shop_name}"
|
||||
f"for {marketplace} and vendor {vendor_name}"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
|
||||
2
main.py
2
main.py
@@ -67,7 +67,7 @@ def health_check(db: Session = Depends(get_db)):
|
||||
"features": [
|
||||
"JWT Authentication",
|
||||
"Marketplace-aware product import",
|
||||
"Multi-shop product management",
|
||||
"Multi-vendor product management",
|
||||
"Stock management with location tracking",
|
||||
],
|
||||
"supported_marketplaces": [
|
||||
|
||||
@@ -6,7 +6,7 @@ from .database.base import Base
|
||||
from .database.user import User
|
||||
from .database.marketplace_product import MarketplaceProduct
|
||||
from .database.stock import Stock
|
||||
from .database.shop import Shop
|
||||
from .database.vendor import Vendor
|
||||
from .database.product import Product
|
||||
from .database.marketplace_import_job import MarketplaceImportJob
|
||||
|
||||
@@ -19,7 +19,7 @@ __all__ = [
|
||||
"User",
|
||||
"MarketplaceProduct",
|
||||
"Stock",
|
||||
"Shop",
|
||||
"Vendor",
|
||||
"Product",
|
||||
"MarketplaceImportJob",
|
||||
"api", # API models namespace
|
||||
|
||||
@@ -5,7 +5,7 @@ from .base import Base
|
||||
from .user import User
|
||||
from .marketplace_product import MarketplaceProduct
|
||||
from .stock import Stock
|
||||
from .shop import Shop
|
||||
from .vendor import Vendor
|
||||
from .product import Product
|
||||
from .marketplace_import_job import MarketplaceImportJob
|
||||
|
||||
@@ -14,7 +14,7 @@ __all__ = [
|
||||
"User",
|
||||
"MarketplaceProduct",
|
||||
"Stock",
|
||||
"Shop",
|
||||
"Vendor",
|
||||
"Product",
|
||||
"MarketplaceImportJob",
|
||||
]
|
||||
|
||||
@@ -20,9 +20,9 @@ class MarketplaceImportJob(Base, TimestampMixin):
|
||||
marketplace = Column(
|
||||
String, nullable=False, index=True, default="Letzshop"
|
||||
) # Index for marketplace filtering
|
||||
shop_name = Column(String, nullable=False, index=True) # Index for shop filtering
|
||||
shop_id = Column(
|
||||
Integer, ForeignKey("shops.id"), nullable=False
|
||||
vendor_name = Column(String, nullable=False, index=True) # Index for vendor filtering
|
||||
vendor_id = Column(
|
||||
Integer, ForeignKey("vendors.id"), nullable=False
|
||||
) # Add proper foreign key
|
||||
user_id = Column(
|
||||
Integer, ForeignKey("users.id"), nullable=False
|
||||
@@ -44,19 +44,19 @@ class MarketplaceImportJob(Base, TimestampMixin):
|
||||
|
||||
# Relationship to user
|
||||
user = relationship("User", foreign_keys=[user_id])
|
||||
shop = relationship("Shop", back_populates="marketplace_import_jobs")
|
||||
vendor = relationship("Vendor", back_populates="marketplace_import_jobs")
|
||||
|
||||
# Additional indexes for marketplace import job queries
|
||||
__table_args__ = (
|
||||
Index(
|
||||
"idx_marketplace_import_user_marketplace", "user_id", "marketplace"
|
||||
), # User's marketplace imports
|
||||
Index("idx_marketplace_import_shop_status", "status"), # Shop import status
|
||||
Index("idx_marketplace_import_shop_id", "shop_id"),
|
||||
Index("idx_marketplace_import_vendor_status", "status"), # Vendor import status
|
||||
Index("idx_marketplace_import_vendor_id", "vendor_id"),
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return (
|
||||
f"<MarketplaceImportJob(id={self.id}, marketplace='{self.marketplace}', shop='{self.shop_name}', "
|
||||
f"<MarketplaceImportJob(id={self.id}, marketplace='{self.marketplace}', vendor='{self.vendor_name}', "
|
||||
f"status='{self.status}', imported={self.imported_count})>"
|
||||
)
|
||||
|
||||
@@ -55,7 +55,7 @@ class MarketplaceProduct(Base, TimestampMixin):
|
||||
marketplace = Column(
|
||||
String, index=True, nullable=True, default="Letzshop"
|
||||
) # Index for marketplace filtering
|
||||
shop_name = Column(String, index=True, nullable=True) # Index for shop filtering
|
||||
vendor_name = Column(String, index=True, nullable=True) # Index for vendor filtering
|
||||
|
||||
# Relationship to stock (one-to-many via GTIN)
|
||||
stock_entries = relationship(
|
||||
@@ -69,8 +69,8 @@ class MarketplaceProduct(Base, TimestampMixin):
|
||||
# Additional indexes for marketplace queries
|
||||
__table_args__ = (
|
||||
Index(
|
||||
"idx_marketplace_shop", "marketplace", "shop_name"
|
||||
), # Composite index for marketplace+shop queries
|
||||
"idx_marketplace_vendor", "marketplace", "vendor_name"
|
||||
), # Composite index for marketplace+vendor queries
|
||||
Index(
|
||||
"idx_marketplace_brand", "marketplace", "brand"
|
||||
), # Composite index for marketplace+brand queries
|
||||
@@ -79,5 +79,5 @@ class MarketplaceProduct(Base, TimestampMixin):
|
||||
def __repr__(self):
|
||||
return (
|
||||
f"<MarketplaceProduct(marketplace_product_id='{self.marketplace_product_id}', title='{self.title}', marketplace='{self.marketplace}', "
|
||||
f"shop='{self.shop_name}')>"
|
||||
f"vendor='{self.vendor_name}')>"
|
||||
)
|
||||
|
||||
@@ -8,11 +8,11 @@ from sqlalchemy.orm import relationship
|
||||
from app.core.database import Base
|
||||
from models.database.base import TimestampMixin
|
||||
|
||||
class Product(Base):
|
||||
class Product(Base, TimestampMixin):
|
||||
__tablename__ = "products"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
shop_id = Column(Integer, ForeignKey("shops.id"), nullable=False)
|
||||
vendor_id = Column(Integer, ForeignKey("vendors.id"), nullable=False)
|
||||
marketplace_product_id = Column(Integer, ForeignKey("marketplace_products.id"), nullable=False)
|
||||
|
||||
# Shop-specific overrides (can override the main product data)
|
||||
@@ -32,17 +32,13 @@ class Product(Base):
|
||||
min_quantity = Column(Integer, default=1)
|
||||
max_quantity = Column(Integer)
|
||||
|
||||
# Timestamps
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
# Relationships
|
||||
shop = relationship("Shop", back_populates="product")
|
||||
vendor = relationship("Vendor", back_populates="product")
|
||||
marketplace_product = relationship("MarketplaceProduct", back_populates="product")
|
||||
|
||||
# Constraints
|
||||
__table_args__ = (
|
||||
UniqueConstraint("shop_id", "marketplace_product_id", name="uq_product"),
|
||||
Index("idx_product_active", "shop_id", "is_active"),
|
||||
Index("idx_product_featured", "shop_id", "is_featured"),
|
||||
UniqueConstraint("vendor_id", "marketplace_product_id", name="uq_product"),
|
||||
Index("idx_product_active", "vendor_id", "is_active"),
|
||||
Index("idx_product_featured", "vendor_id", "is_featured"),
|
||||
)
|
||||
|
||||
@@ -18,10 +18,10 @@ class Stock(Base, TimestampMixin):
|
||||
location = Column(String, nullable=False, index=True)
|
||||
quantity = Column(Integer, nullable=False, default=0)
|
||||
reserved_quantity = Column(Integer, default=0) # For orders being processed
|
||||
shop_id = Column(Integer, ForeignKey("shops.id")) # Optional: shop-specific stock
|
||||
vendor_id = Column(Integer, ForeignKey("vendors.id")) # Optional: vendor -specific stock
|
||||
|
||||
# Relationships
|
||||
shop = relationship("Shop")
|
||||
vendor = relationship("Shop")
|
||||
|
||||
# Composite unique constraint to prevent duplicate GTIN-location combinations
|
||||
__table_args__ = (
|
||||
|
||||
@@ -15,7 +15,7 @@ class User(Base, TimestampMixin):
|
||||
email = Column(String, unique=True, index=True, nullable=False)
|
||||
username = Column(String, unique=True, index=True, nullable=False)
|
||||
hashed_password = Column(String, nullable=False)
|
||||
role = Column(String, nullable=False, default="user") # user, admin, shop_owner
|
||||
role = Column(String, nullable=False, default="user") # user, admin, vendor_owner
|
||||
is_active = Column(Boolean, default=True, nullable=False)
|
||||
last_login = Column(DateTime, nullable=True)
|
||||
|
||||
@@ -23,7 +23,7 @@ class User(Base, TimestampMixin):
|
||||
marketplace_import_jobs = relationship(
|
||||
"MarketplaceImportJob", back_populates="user"
|
||||
)
|
||||
owned_shops = relationship("Shop", back_populates="owner")
|
||||
owned_vendors = relationship("Vendor", back_populates="owner")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<User(username='{self.username}', email='{self.email}', role='{self.role}')>"
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import (Boolean, Column, DateTime, Float, ForeignKey, Index,
|
||||
Integer, String, Text, UniqueConstraint)
|
||||
from sqlalchemy import (Boolean, Column, ForeignKey, Integer, String, Text)
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
# Import Base from the central database module instead of creating a new one
|
||||
@@ -9,14 +6,14 @@ from app.core.database import Base
|
||||
from models.database.base import TimestampMixin
|
||||
|
||||
|
||||
class Shop(Base, TimestampMixin):
|
||||
__tablename__ = "shops"
|
||||
class Vendor(Base, TimestampMixin):
|
||||
__tablename__ = "vendors"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
shop_code = Column(
|
||||
vendor_code = Column(
|
||||
String, unique=True, index=True, nullable=False
|
||||
) # e.g., "TECHSTORE", "FASHIONHUB"
|
||||
shop_name = Column(String, nullable=False) # Display name
|
||||
vendor_name = Column(String, nullable=False) # Display name
|
||||
description = Column(Text)
|
||||
owner_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
|
||||
@@ -34,8 +31,8 @@ class Shop(Base, TimestampMixin):
|
||||
is_verified = Column(Boolean, default=False)
|
||||
|
||||
# Relationships
|
||||
owner = relationship("User", back_populates="owned_shops")
|
||||
product = relationship("Product", back_populates="shop")
|
||||
owner = relationship("User", back_populates="owned_vendors")
|
||||
product = relationship("Product", back_populates="vendor")
|
||||
marketplace_import_jobs = relationship(
|
||||
"MarketplaceImportJob", back_populates="shop"
|
||||
"MarketplaceImportJob", back_populates="vendor"
|
||||
)
|
||||
@@ -1,15 +1,14 @@
|
||||
# models/schemas/__init__.py
|
||||
"""API models package - Pydantic models for request/response validation."""
|
||||
|
||||
from . import auth
|
||||
# Import API model modules
|
||||
from . import base
|
||||
from . import auth
|
||||
from . import marketplace_product
|
||||
from . import stock
|
||||
from . import shop
|
||||
from . import marketplace_import_job
|
||||
from . import marketplace_product
|
||||
from . import stats
|
||||
|
||||
from . import stock
|
||||
from . import vendor
|
||||
# Common imports for convenience
|
||||
from .base import * # Base Pydantic models
|
||||
|
||||
@@ -18,7 +17,7 @@ __all__ = [
|
||||
"auth",
|
||||
"marketplace_product",
|
||||
"stock",
|
||||
"shop",
|
||||
"vendor",
|
||||
"marketplace_import_job",
|
||||
"stats",
|
||||
]
|
||||
|
||||
@@ -2,12 +2,15 @@
|
||||
import re
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, EmailStr, Field, field_validator
|
||||
|
||||
|
||||
class UserRegister(BaseModel):
|
||||
email: EmailStr = Field(..., description="Valid email address")
|
||||
username: str = Field(..., description="Username")
|
||||
password: str = Field(..., description="Password")
|
||||
|
||||
# Keep security validation in Pydantic for auth
|
||||
|
||||
@field_validator("username")
|
||||
@@ -24,6 +27,7 @@ class UserRegister(BaseModel):
|
||||
raise ValueError("Password must be at least 6 characters long")
|
||||
return v
|
||||
|
||||
|
||||
class UserLogin(BaseModel):
|
||||
username: str = Field(..., description="Username")
|
||||
password: str = Field(..., description="Password")
|
||||
@@ -33,6 +37,7 @@ class UserLogin(BaseModel):
|
||||
def validate_username(cls, v):
|
||||
return v.strip()
|
||||
|
||||
|
||||
class UserResponse(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
id: int
|
||||
@@ -44,6 +49,7 @@ class UserResponse(BaseModel):
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class LoginResponse(BaseModel):
|
||||
access_token: str
|
||||
token_type: str = "bearer"
|
||||
|
||||
@@ -6,7 +6,7 @@ from pydantic import BaseModel, Field, field_validator
|
||||
class MarketplaceImportJobRequest(BaseModel):
|
||||
url: str = Field(..., description="URL to CSV file from marketplace")
|
||||
marketplace: str = Field(default="Letzshop", description="Marketplace name")
|
||||
shop_code: str = Field(..., description="Shop code to associate products with")
|
||||
vendor_code: str = Field(..., description="Vendor code to associate products with")
|
||||
batch_size: Optional[int] = Field(1000, description="Processing batch size")
|
||||
# Removed: gt=0, le=10000 constraints - let service handle
|
||||
|
||||
@@ -18,7 +18,7 @@ class MarketplaceImportJobRequest(BaseModel):
|
||||
raise ValueError("URL must start with http:// or https://")
|
||||
return v
|
||||
|
||||
@field_validator("marketplace", "shop_code")
|
||||
@field_validator("marketplace", "vendor_code")
|
||||
@classmethod
|
||||
def validate_strings(cls, v):
|
||||
return v.strip()
|
||||
@@ -27,9 +27,9 @@ class MarketplaceImportJobResponse(BaseModel):
|
||||
job_id: int
|
||||
status: str
|
||||
marketplace: str
|
||||
shop_id: int
|
||||
shop_code: Optional[str] = None
|
||||
shop_name: str
|
||||
vendor_id: int
|
||||
vendor_code: Optional[str] = None
|
||||
vendor_name: str
|
||||
message: Optional[str] = None
|
||||
imported: Optional[int] = 0
|
||||
updated: Optional[int] = 0
|
||||
|
||||
@@ -43,7 +43,7 @@ class MarketplaceProductBase(BaseModel):
|
||||
shipping: Optional[str] = None
|
||||
currency: Optional[str] = None
|
||||
marketplace: Optional[str] = None
|
||||
shop_name: Optional[str] = None
|
||||
vendor_name: Optional[str] = None
|
||||
|
||||
class MarketplaceProductCreate(MarketplaceProductBase):
|
||||
marketplace_product_id: str = Field(..., description="MarketplaceProduct identifier")
|
||||
|
||||
@@ -6,7 +6,7 @@ from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
from models.schemas.marketplace_product import MarketplaceProductResponse
|
||||
|
||||
class ProductCreate(BaseModel):
|
||||
marketplace_product_id: str = Field(..., description="MarketplaceProduct ID to add to shop")
|
||||
marketplace_product_id: str = Field(..., description="MarketplaceProduct ID to add to vendor ")
|
||||
product_id: Optional[str] = None
|
||||
price: Optional[float] = None # Removed: ge=0 constraint
|
||||
sale_price: Optional[float] = None # Removed: ge=0 constraint
|
||||
@@ -21,7 +21,7 @@ class ProductCreate(BaseModel):
|
||||
class ProductResponse(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
id: int
|
||||
shop_id: int
|
||||
vendor_id: int
|
||||
marketplace_product: MarketplaceProductResponse
|
||||
product_id: Optional[str]
|
||||
price: Optional[float]
|
||||
|
||||
@@ -10,7 +10,7 @@ class StatsResponse(BaseModel):
|
||||
unique_brands: int
|
||||
unique_categories: int
|
||||
unique_marketplaces: int = 0
|
||||
unique_shops: int = 0
|
||||
unique_vendors: int = 0
|
||||
total_stock_entries: int = 0
|
||||
total_inventory_quantity: int = 0
|
||||
|
||||
@@ -18,5 +18,5 @@ class StatsResponse(BaseModel):
|
||||
class MarketplaceStatsResponse(BaseModel):
|
||||
marketplace: str
|
||||
total_products: int
|
||||
unique_shops: int
|
||||
unique_vendors: int
|
||||
unique_brands: int
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
# shop.py - Keep basic format validation, remove business logic
|
||||
# vendor.py - Keep basic format validation, remove business logic
|
||||
import re
|
||||
from datetime import datetime
|
||||
from typing import List, Optional
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
|
||||
class ShopCreate(BaseModel):
|
||||
shop_code: str = Field(..., description="Unique shop identifier")
|
||||
shop_name: str = Field(..., description="Display name of the shop")
|
||||
description: Optional[str] = Field(None, description="Shop description")
|
||||
class VendorCreate(BaseModel):
|
||||
vendor_code: str = Field(..., description="Unique vendor identifier")
|
||||
vendor_name: str = Field(..., description="Display name of the vendor ")
|
||||
description: Optional[str] = Field(None, description="Vendor description")
|
||||
contact_email: Optional[str] = None
|
||||
contact_phone: Optional[str] = None
|
||||
website: Optional[str] = None
|
||||
@@ -23,8 +23,8 @@ class ShopCreate(BaseModel):
|
||||
raise ValueError("Invalid email format")
|
||||
return v.lower() if v else v
|
||||
|
||||
class ShopUpdate(BaseModel):
|
||||
shop_name: Optional[str] = None
|
||||
class VendorUpdate(BaseModel):
|
||||
vendor_name: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
contact_email: Optional[str] = None
|
||||
contact_phone: Optional[str] = None
|
||||
@@ -39,11 +39,11 @@ class ShopUpdate(BaseModel):
|
||||
raise ValueError("Invalid email format")
|
||||
return v.lower() if v else v
|
||||
|
||||
class ShopResponse(BaseModel):
|
||||
class VendorResponse(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
id: int
|
||||
shop_code: str
|
||||
shop_name: str
|
||||
vendor_code: str
|
||||
vendor_name: str
|
||||
description: Optional[str]
|
||||
owner_id: int
|
||||
contact_email: Optional[str]
|
||||
@@ -56,8 +56,8 @@ class ShopResponse(BaseModel):
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
class ShopListResponse(BaseModel):
|
||||
shops: List[ShopResponse]
|
||||
class VendorListResponse(BaseModel):
|
||||
vendors: List[VendorResponse]
|
||||
total: int
|
||||
skip: int
|
||||
limit: int
|
||||
@@ -36,7 +36,7 @@ markers =
|
||||
auth: marks tests as authentication and authorization tests
|
||||
products: marks tests as product management functionality
|
||||
stock: marks tests as stock and inventory management
|
||||
shops: marks tests as shop management functionality
|
||||
vendors: marks tests as vendor management functionality
|
||||
admin: marks tests as admin functionality and permissions
|
||||
marketplace: marks tests as marketplace import functionality
|
||||
stats: marks tests as statistics and reporting
|
||||
|
||||
@@ -65,7 +65,7 @@ def verify_database_setup():
|
||||
|
||||
# Expected tables from your models
|
||||
expected_tables = [
|
||||
'users', 'products', 'stock', 'shops', 'products',
|
||||
'users', 'products', 'stock', 'vendors', 'products',
|
||||
'marketplace_import_jobs', 'alembic_version'
|
||||
]
|
||||
|
||||
@@ -133,7 +133,7 @@ def verify_model_structure():
|
||||
from models.database.user import User
|
||||
from models.database.marketplace_product import MarketplaceProduct
|
||||
from models.database.stock import Stock
|
||||
from models.database.shop import Shop
|
||||
from models.database.vendor import Vendor
|
||||
from models.database.product import Product
|
||||
from models.database.marketplace_import_job import MarketplaceImportJob
|
||||
|
||||
@@ -149,7 +149,7 @@ def verify_model_structure():
|
||||
print("[OK] API models package imported")
|
||||
|
||||
# Test specific API model imports
|
||||
api_modules = ['base', 'auth', 'product', 'stock', 'shop', 'marketplace', 'admin', 'stats']
|
||||
api_modules = ['base', 'auth', 'product', 'stock', 'vendor ', 'marketplace', 'admin', 'stats']
|
||||
for module in api_modules:
|
||||
try:
|
||||
__import__(f'models.api.{module}')
|
||||
|
||||
@@ -10,7 +10,7 @@ from main import app
|
||||
# Import all models to ensure they're registered with Base metadata
|
||||
from models.database.marketplace_import_job import MarketplaceImportJob
|
||||
from models.database.marketplace_product import MarketplaceProduct
|
||||
from models.database.shop import Shop
|
||||
from models.database.vendor import Vendor
|
||||
from models.database.product import Product
|
||||
from models.database.stock import Stock
|
||||
from models.database.user import User
|
||||
@@ -89,7 +89,7 @@ def cleanup():
|
||||
pytest_plugins = [
|
||||
"tests.fixtures.auth_fixtures",
|
||||
"tests.fixtures.marketplace_product_fixtures",
|
||||
"tests.fixtures.shop_fixtures",
|
||||
"tests.fixtures.vendor_fixtures",
|
||||
"tests.fixtures.marketplace_import_job_fixtures",
|
||||
"tests.fixtures.testing_fixtures",
|
||||
]
|
||||
|
||||
@@ -5,14 +5,14 @@ from models.database.marketplace_import_job import MarketplaceImportJob
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_marketplace_import_job(db, test_shop, test_user):
|
||||
def test_marketplace_import_job(db, test_vendor, test_user):
|
||||
"""Create a test marketplace import job"""
|
||||
job = MarketplaceImportJob(
|
||||
marketplace="amazon",
|
||||
shop_name="Test Import Shop",
|
||||
vendor_name="Test Import Shop",
|
||||
status="completed",
|
||||
source_url="https://test-marketplace.example.com/import",
|
||||
shop_id=test_shop.id,
|
||||
vendor_id=test_vendor.id,
|
||||
user_id=test_user.id,
|
||||
imported_count=5,
|
||||
updated_count=3,
|
||||
@@ -26,14 +26,14 @@ def test_marketplace_import_job(db, test_shop, test_user):
|
||||
return job
|
||||
|
||||
|
||||
def create_test_marketplace_import_job(db, shop_id, user_id, **kwargs):
|
||||
def create_test_marketplace_import_job(db, vendor_id, user_id, **kwargs):
|
||||
"""Helper function to create MarketplaceImportJob with defaults"""
|
||||
defaults = {
|
||||
"marketplace": "test",
|
||||
"shop_name": "Test Shop",
|
||||
"vendor_name": "Test Shop",
|
||||
"status": "pending",
|
||||
"source_url": "https://test.example.com/import",
|
||||
"shop_id": shop_id,
|
||||
"vendor_id": vendor_id,
|
||||
"user_id": user_id,
|
||||
"imported_count": 0,
|
||||
"updated_count": 0,
|
||||
|
||||
@@ -19,7 +19,7 @@ def test_marketplace_product(db):
|
||||
gtin="1234567890123",
|
||||
availability="in stock",
|
||||
marketplace="Letzshop",
|
||||
shop_name="TestShop",
|
||||
vendor_name="TestVendor",
|
||||
)
|
||||
db.add(marketplace_product)
|
||||
db.commit()
|
||||
@@ -41,7 +41,7 @@ def unique_product(db):
|
||||
gtin=f"123456789{unique_id[:4]}",
|
||||
availability="in stock",
|
||||
marketplace="Letzshop",
|
||||
shop_name=f"UniqueShop_{unique_id}",
|
||||
vendor_name=f"UniqueShop_{unique_id}",
|
||||
google_product_category=f"UniqueCategory_{unique_id}",
|
||||
)
|
||||
db.add(marketplace_product)
|
||||
@@ -65,7 +65,7 @@ def multiple_products(db):
|
||||
currency="EUR",
|
||||
brand=f"MultiBrand_{i % 3}", # Create 3 different brands
|
||||
marketplace=f"MultiMarket_{i % 2}", # Create 2 different marketplaces
|
||||
shop_name=f"MultiShop_{i}",
|
||||
vendor_name=f"MultiShop_{i}",
|
||||
google_product_category=f"MultiCategory_{i % 2}", # Create 2 different categories
|
||||
gtin=f"1234567890{i}{unique_id[:2]}",
|
||||
)
|
||||
@@ -89,7 +89,7 @@ def create_unique_marketplace_product_factory():
|
||||
"price": "15.99",
|
||||
"currency": "EUR",
|
||||
"marketplace": "TestMarket",
|
||||
"shop_name": "TestShop",
|
||||
"vendor_name": "TestVendor",
|
||||
}
|
||||
defaults.update(kwargs)
|
||||
|
||||
|
||||
6
tests/fixtures/testing_fixtures.py
vendored
6
tests/fixtures/testing_fixtures.py
vendored
@@ -21,11 +21,11 @@ def empty_db(db):
|
||||
# Clear only the tables that are relevant for admin service testing
|
||||
# In order to respect foreign key constraints
|
||||
tables_to_clear = [
|
||||
"marketplace_import_jobs", # Has foreign keys to shops and users
|
||||
"products", # Has foreign keys to shops and products
|
||||
"marketplace_import_jobs", # Has foreign keys to vendors and users
|
||||
"products", # Has foreign keys to vendors and products
|
||||
"stock", # Fixed: singular not plural
|
||||
"products", # Referenced by products
|
||||
"shops", # Has foreign key to users
|
||||
"vendors", # Has foreign key to users
|
||||
"users" # Base table
|
||||
]
|
||||
|
||||
|
||||
@@ -1,90 +1,90 @@
|
||||
# tests/fixtures/shop_fixtures.py
|
||||
# tests/fixtures/vendor_fixtures.py
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
|
||||
from models.database.shop import Shop
|
||||
from models.database.vendor import Vendor
|
||||
from models.database.product import Product
|
||||
from models.database.stock import Stock
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_shop(db, test_user):
|
||||
"""Create a test shop with unique shop code"""
|
||||
def test_vendor(db, test_user):
|
||||
"""Create a test vendor with unique vendor code"""
|
||||
unique_id = str(uuid.uuid4())[:8].upper() # Make unique ID uppercase
|
||||
shop = Shop(
|
||||
shop_code=f"TESTSHOP_{unique_id}", # Will be all uppercase
|
||||
shop_name=f"Test Shop {unique_id.lower()}", # Keep display name readable
|
||||
vendor = Vendor(
|
||||
vendor_code=f"TESTVENDOR_{unique_id}", # Will be all uppercase
|
||||
vendor_name=f"Test Vendor {unique_id.lower()}", # Keep display name readable
|
||||
owner_id=test_user.id,
|
||||
is_active=True,
|
||||
is_verified=True,
|
||||
)
|
||||
db.add(shop)
|
||||
db.add(vendor)
|
||||
db.commit()
|
||||
db.refresh(shop)
|
||||
return shop
|
||||
db.refresh(vendor)
|
||||
return vendor
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def unique_shop(db, test_user):
|
||||
"""Create a unique shop for tests that need isolated shop data"""
|
||||
def unique_vendor(db, test_user):
|
||||
"""Create a unique vendor for tests that need isolated vendor data"""
|
||||
unique_id = str(uuid.uuid4())[:8]
|
||||
shop = Shop(
|
||||
shop_code=f"UNIQUESHOP_{unique_id}",
|
||||
shop_name=f"Unique Test Shop {unique_id}",
|
||||
description=f"A unique test shop {unique_id}",
|
||||
vendor = Vendor(
|
||||
vendor_code=f"UNIQUEVENDOR_{unique_id}",
|
||||
vendor_name=f"Unique Test Vendor {unique_id}",
|
||||
description=f"A unique test vendor {unique_id}",
|
||||
owner_id=test_user.id,
|
||||
is_active=True,
|
||||
is_verified=True,
|
||||
)
|
||||
db.add(shop)
|
||||
db.add(vendor)
|
||||
db.commit()
|
||||
db.refresh(shop)
|
||||
return shop
|
||||
db.refresh(vendor)
|
||||
return vendor
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def inactive_shop(db, other_user):
|
||||
"""Create an inactive shop owned by other_user"""
|
||||
def inactive_vendor(db, other_user):
|
||||
"""Create an inactive vendor owned by other_user"""
|
||||
unique_id = str(uuid.uuid4())[:8]
|
||||
shop = Shop(
|
||||
shop_code=f"INACTIVE_{unique_id}",
|
||||
shop_name=f"Inactive Shop {unique_id}",
|
||||
vendor = Vendor(
|
||||
vendor_code=f"INACTIVE_{unique_id}",
|
||||
vendor_name=f"Inactive Vendor {unique_id}",
|
||||
owner_id=other_user.id,
|
||||
is_active=False,
|
||||
is_verified=False,
|
||||
)
|
||||
db.add(shop)
|
||||
db.add(vendor)
|
||||
db.commit()
|
||||
db.refresh(shop)
|
||||
return shop
|
||||
db.refresh(vendor)
|
||||
return vendor
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def verified_shop(db, other_user):
|
||||
"""Create a verified shop owned by other_user"""
|
||||
def verified_vendor(db, other_user):
|
||||
"""Create a verified vendor owned by other_user"""
|
||||
unique_id = str(uuid.uuid4())[:8]
|
||||
shop = Shop(
|
||||
shop_code=f"VERIFIED_{unique_id}",
|
||||
shop_name=f"Verified Shop {unique_id}",
|
||||
vendor = Vendor(
|
||||
vendor_code=f"VERIFIED_{unique_id}",
|
||||
vendor_name=f"Verified Vendor {unique_id}",
|
||||
owner_id=other_user.id,
|
||||
is_active=True,
|
||||
is_verified=True,
|
||||
)
|
||||
db.add(shop)
|
||||
db.add(vendor)
|
||||
db.commit()
|
||||
db.refresh(shop)
|
||||
return shop
|
||||
db.refresh(vendor)
|
||||
return vendor
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_product(db, test_shop, unique_product):
|
||||
"""Create a shop product relationship"""
|
||||
def test_product(db, test_vendor, unique_product):
|
||||
"""Create a vendor product relationship"""
|
||||
product = Product(
|
||||
shop_id=test_shop.id, marketplace_product_id=unique_product.id, is_active=True
|
||||
vendor_id=test_vendor.id, marketplace_product_id=unique_product.id, is_active=True
|
||||
)
|
||||
# Add optional fields if they exist in your model
|
||||
if hasattr(Product, "shop_price"):
|
||||
if hasattr(Product, "price"):
|
||||
product.price = 24.99
|
||||
if hasattr(Product, "is_featured"):
|
||||
product.is_featured = False
|
||||
@@ -98,7 +98,7 @@ def test_product(db, test_shop, unique_product):
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_stock(db, test_marketplace_product, test_shop):
|
||||
def test_stock(db, test_marketplace_product, test_vendor):
|
||||
"""Create test stock entry"""
|
||||
unique_id = str(uuid.uuid4())[:8].upper() # Short unique identifier
|
||||
stock = Stock(
|
||||
@@ -106,7 +106,7 @@ def test_stock(db, test_marketplace_product, test_shop):
|
||||
location=f"WAREHOUSE_A_{unique_id}",
|
||||
quantity=10,
|
||||
reserved_quantity=0,
|
||||
shop_id=test_shop.id, # Add shop_id reference
|
||||
vendor_id=test_vendor.id, # Add vendor_id reference
|
||||
)
|
||||
db.add(stock)
|
||||
db.commit()
|
||||
@@ -115,7 +115,7 @@ def test_stock(db, test_marketplace_product, test_shop):
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def multiple_stocks(db, multiple_products, test_shop):
|
||||
def multiple_stocks(db, multiple_products, test_vendor):
|
||||
"""Create multiple stock entries for testing"""
|
||||
stocks = []
|
||||
|
||||
@@ -125,7 +125,7 @@ def multiple_stocks(db, multiple_products, test_shop):
|
||||
location=f"LOC_{i}",
|
||||
quantity=10 + (i * 5), # Different quantities
|
||||
reserved_quantity=i,
|
||||
shop_id=test_shop.id,
|
||||
vendor_id=test_vendor.id,
|
||||
)
|
||||
stocks.append(stock)
|
||||
|
||||
@@ -136,30 +136,30 @@ def multiple_stocks(db, multiple_products, test_shop):
|
||||
return stocks
|
||||
|
||||
|
||||
def create_unique_shop_factory():
|
||||
"""Factory function to create unique shops in tests"""
|
||||
def create_unique_vendor_factory():
|
||||
"""Factory function to create unique vendors in tests"""
|
||||
|
||||
def _create_shop(db, owner_id, **kwargs):
|
||||
def _create_vendor(db, owner_id, **kwargs):
|
||||
unique_id = str(uuid.uuid4())[:8]
|
||||
defaults = {
|
||||
"shop_code": f"FACTORY_{unique_id}",
|
||||
"shop_name": f"Factory Shop {unique_id}",
|
||||
"vendor_code": f"FACTORY_{unique_id}",
|
||||
"vendor_name": f"Factory Vendor {unique_id}",
|
||||
"owner_id": owner_id,
|
||||
"is_active": True,
|
||||
"is_verified": False,
|
||||
}
|
||||
defaults.update(kwargs)
|
||||
|
||||
shop = Shop(**defaults)
|
||||
db.add(shop)
|
||||
vendor = Vendor(**defaults)
|
||||
db.add(vendor)
|
||||
db.commit()
|
||||
db.refresh(shop)
|
||||
return shop
|
||||
db.refresh(vendor)
|
||||
return vendor
|
||||
|
||||
return _create_shop
|
||||
return _create_vendor
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def shop_factory():
|
||||
"""Fixture that provides a shop factory function"""
|
||||
return create_unique_shop_factory()
|
||||
def vendor_factory():
|
||||
"""Fixture that provides a vendor factory function"""
|
||||
return create_unique_vendor_factory()
|
||||
@@ -74,67 +74,67 @@ class TestAdminAPI:
|
||||
assert data["error_code"] == "USER_STATUS_CHANGE_FAILED"
|
||||
assert "Cannot modify another admin user" in data["message"]
|
||||
|
||||
def test_get_all_shops_admin(self, client, admin_headers, test_shop):
|
||||
"""Test admin getting all shops"""
|
||||
response = client.get("/api/v1/admin/shops", headers=admin_headers)
|
||||
def test_get_all_vendors_admin(self, client, admin_headers, test_vendor):
|
||||
"""Test admin getting all vendors"""
|
||||
response = client.get("/api/v1/admin/vendors", headers=admin_headers)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["total"] >= 1
|
||||
assert len(data["shops"]) >= 1
|
||||
assert len(data["vendors"]) >= 1
|
||||
|
||||
# Check that test_shop is in the response
|
||||
shop_codes = [
|
||||
shop["shop_code"] for shop in data["shops"] if "shop_code" in shop
|
||||
# Check that test_vendor is in the response
|
||||
vendor_codes = [
|
||||
vendor ["vendor_code"] for vendor in data["vendors"] if "vendor_code" in vendor
|
||||
]
|
||||
assert test_shop.shop_code in shop_codes
|
||||
assert test_vendor.vendor_code in vendor_codes
|
||||
|
||||
def test_get_all_shops_non_admin(self, client, auth_headers):
|
||||
"""Test non-admin trying to access admin shop endpoint"""
|
||||
response = client.get("/api/v1/admin/shops", headers=auth_headers)
|
||||
def test_get_all_vendors_non_admin(self, client, auth_headers):
|
||||
"""Test non-admin trying to access admin vendor endpoint"""
|
||||
response = client.get("/api/v1/admin/vendors", headers=auth_headers)
|
||||
|
||||
assert response.status_code == 403
|
||||
data = response.json()
|
||||
assert data["error_code"] == "ADMIN_REQUIRED"
|
||||
|
||||
def test_verify_shop_admin(self, client, admin_headers, test_shop):
|
||||
"""Test admin verifying/unverifying shop"""
|
||||
def test_verify_vendor_admin(self, client, admin_headers, test_vendor):
|
||||
"""Test admin verifying/unverifying vendor """
|
||||
response = client.put(
|
||||
f"/api/v1/admin/shops/{test_shop.id}/verify", headers=admin_headers
|
||||
f"/api/v1/admin/vendors/{test_vendor.id}/verify", headers=admin_headers
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
message = response.json()["message"]
|
||||
assert "verified" in message or "unverified" in message
|
||||
assert test_shop.shop_code in message
|
||||
assert test_vendor.vendor_code in message
|
||||
|
||||
def test_verify_shop_not_found(self, client, admin_headers):
|
||||
"""Test admin verifying non-existent shop"""
|
||||
response = client.put("/api/v1/admin/shops/99999/verify", headers=admin_headers)
|
||||
def test_verify_vendor_not_found(self, client, admin_headers):
|
||||
"""Test admin verifying non-existent vendor """
|
||||
response = client.put("/api/v1/admin/vendors/99999/verify", headers=admin_headers)
|
||||
|
||||
assert response.status_code == 404
|
||||
data = response.json()
|
||||
assert data["error_code"] == "SHOP_NOT_FOUND"
|
||||
assert "Shop with ID '99999' not found" in data["message"]
|
||||
assert data["error_code"] == "VENDOR_NOT_FOUND"
|
||||
assert "Vendor with ID '99999' not found" in data["message"]
|
||||
|
||||
def test_toggle_shop_status_admin(self, client, admin_headers, test_shop):
|
||||
"""Test admin toggling shop status"""
|
||||
def test_toggle_vendor_status_admin(self, client, admin_headers, test_vendor):
|
||||
"""Test admin toggling vendor status"""
|
||||
response = client.put(
|
||||
f"/api/v1/admin/shops/{test_shop.id}/status", headers=admin_headers
|
||||
f"/api/v1/admin/vendors/{test_vendor.id}/status", headers=admin_headers
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
message = response.json()["message"]
|
||||
assert "activated" in message or "deactivated" in message
|
||||
assert test_shop.shop_code in message
|
||||
assert test_vendor.vendor_code in message
|
||||
|
||||
def test_toggle_shop_status_not_found(self, client, admin_headers):
|
||||
"""Test admin toggling status for non-existent shop"""
|
||||
response = client.put("/api/v1/admin/shops/99999/status", headers=admin_headers)
|
||||
def test_toggle_vendor_status_not_found(self, client, admin_headers):
|
||||
"""Test admin toggling status for non-existent vendor """
|
||||
response = client.put("/api/v1/admin/vendors/99999/status", headers=admin_headers)
|
||||
|
||||
assert response.status_code == 404
|
||||
data = response.json()
|
||||
assert data["error_code"] == "SHOP_NOT_FOUND"
|
||||
assert data["error_code"] == "VENDOR_NOT_FOUND"
|
||||
|
||||
def test_get_marketplace_import_jobs_admin(
|
||||
self, client, admin_headers, test_marketplace_import_job
|
||||
@@ -191,17 +191,17 @@ class TestAdminAPI:
|
||||
assert "activation_rate" in data
|
||||
assert isinstance(data["total_users"], int)
|
||||
|
||||
def test_get_shop_statistics(self, client, admin_headers):
|
||||
"""Test admin getting shop statistics"""
|
||||
response = client.get("/api/v1/admin/stats/shops", headers=admin_headers)
|
||||
def test_get_vendor_statistics(self, client, admin_headers):
|
||||
"""Test admin getting vendor statistics"""
|
||||
response = client.get("/api/v1/admin/stats/vendors", headers=admin_headers)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "total_shops" in data
|
||||
assert "active_shops" in data
|
||||
assert "verified_shops" in data
|
||||
assert "total_vendors" in data
|
||||
assert "active_vendors" in data
|
||||
assert "verified_vendors" in data
|
||||
assert "verification_rate" in data
|
||||
assert isinstance(data["total_shops"], int)
|
||||
assert isinstance(data["total_vendors"], int)
|
||||
|
||||
def test_admin_pagination_users(self, client, admin_headers, test_user, test_admin):
|
||||
"""Test user pagination works correctly"""
|
||||
@@ -221,14 +221,14 @@ class TestAdminAPI:
|
||||
data = response.json()
|
||||
assert len(data) >= 0 # Could be 1 or 0 depending on total users
|
||||
|
||||
def test_admin_pagination_shops(self, client, admin_headers, test_shop):
|
||||
"""Test shop pagination works correctly"""
|
||||
def test_admin_pagination_vendors(self, client, admin_headers, test_vendor):
|
||||
"""Test vendor pagination works correctly"""
|
||||
response = client.get(
|
||||
"/api/v1/admin/shops?skip=0&limit=1", headers=admin_headers
|
||||
"/api/v1/admin/vendors?skip=0&limit=1", headers=admin_headers
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["total"] >= 1
|
||||
assert len(data["shops"]) >= 0
|
||||
assert len(data["vendors"]) >= 0
|
||||
assert "skip" in data
|
||||
assert "limit" in data
|
||||
|
||||
@@ -8,15 +8,15 @@ import pytest
|
||||
@pytest.mark.api
|
||||
@pytest.mark.marketplace
|
||||
class TestMarketplaceImportJobAPI:
|
||||
def test_import_from_marketplace(self, client, auth_headers, test_shop, test_user):
|
||||
def test_import_from_marketplace(self, client, auth_headers, test_vendor, test_user):
|
||||
"""Test marketplace import endpoint - just test job creation"""
|
||||
# Ensure user owns the shop
|
||||
test_shop.owner_id = test_user.id
|
||||
# Ensure user owns the vendor
|
||||
test_vendor.owner_id = test_user.id
|
||||
|
||||
import_data = {
|
||||
"url": "https://example.com/products.csv",
|
||||
"marketplace": "TestMarket",
|
||||
"shop_code": test_shop.shop_code,
|
||||
"vendor_code": test_vendor.vendor_code,
|
||||
}
|
||||
|
||||
response = client.post(
|
||||
@@ -28,15 +28,15 @@ class TestMarketplaceImportJobAPI:
|
||||
assert data["status"] == "pending"
|
||||
assert data["marketplace"] == "TestMarket"
|
||||
assert "job_id" in data
|
||||
assert data["shop_code"] == test_shop.shop_code
|
||||
assert data["shop_id"] == test_shop.id
|
||||
assert data["vendor_code"] == test_vendor.vendor_code
|
||||
assert data["vendor_id"] == test_vendor.id
|
||||
|
||||
def test_import_from_marketplace_invalid_shop(self, client, auth_headers):
|
||||
"""Test marketplace import with invalid shop"""
|
||||
def test_import_from_marketplace_invalid_vendor(self, client, auth_headers):
|
||||
"""Test marketplace import with invalid vendor """
|
||||
import_data = {
|
||||
"url": "https://example.com/products.csv",
|
||||
"marketplace": "TestMarket",
|
||||
"shop_code": "NONEXISTENT",
|
||||
"vendor_code": "NONEXISTENT",
|
||||
}
|
||||
|
||||
response = client.post(
|
||||
@@ -45,18 +45,18 @@ class TestMarketplaceImportJobAPI:
|
||||
|
||||
assert response.status_code == 404
|
||||
data = response.json()
|
||||
assert data["error_code"] == "SHOP_NOT_FOUND"
|
||||
assert data["error_code"] == "VENDOR_NOT_FOUND"
|
||||
assert "NONEXISTENT" in data["message"]
|
||||
|
||||
def test_import_from_marketplace_unauthorized_shop(self, client, auth_headers, test_shop, other_user):
|
||||
"""Test marketplace import with unauthorized shop access"""
|
||||
# Set shop owner to different user
|
||||
test_shop.owner_id = other_user.id
|
||||
def test_import_from_marketplace_unauthorized_vendor(self, client, auth_headers, test_vendor, other_user):
|
||||
"""Test marketplace import with unauthorized vendor access"""
|
||||
# Set vendor owner to different user
|
||||
test_vendor.owner_id = other_user.id
|
||||
|
||||
import_data = {
|
||||
"url": "https://example.com/products.csv",
|
||||
"marketplace": "TestMarket",
|
||||
"shop_code": test_shop.shop_code,
|
||||
"vendor_code": test_vendor.vendor_code,
|
||||
}
|
||||
|
||||
response = client.post(
|
||||
@@ -65,15 +65,15 @@ class TestMarketplaceImportJobAPI:
|
||||
|
||||
assert response.status_code == 403
|
||||
data = response.json()
|
||||
assert data["error_code"] == "UNAUTHORIZED_SHOP_ACCESS"
|
||||
assert test_shop.shop_code in data["message"]
|
||||
assert data["error_code"] == "UNAUTHORIZED_VENDOR_ACCESS"
|
||||
assert test_vendor.vendor_code in data["message"]
|
||||
|
||||
def test_import_from_marketplace_validation_error(self, client, auth_headers):
|
||||
"""Test marketplace import with invalid request data"""
|
||||
import_data = {
|
||||
"url": "", # Empty URL
|
||||
"marketplace": "", # Empty marketplace
|
||||
# Missing shop_code
|
||||
# Missing vendor_code
|
||||
}
|
||||
|
||||
response = client.post(
|
||||
@@ -85,12 +85,12 @@ class TestMarketplaceImportJobAPI:
|
||||
assert data["error_code"] == "VALIDATION_ERROR"
|
||||
assert "Request validation failed" in data["message"]
|
||||
|
||||
def test_import_from_marketplace_admin_access(self, client, admin_headers, test_shop):
|
||||
"""Test that admin can import for any shop"""
|
||||
def test_import_from_marketplace_admin_access(self, client, admin_headers, test_vendor):
|
||||
"""Test that admin can import for any vendor """
|
||||
import_data = {
|
||||
"url": "https://example.com/products.csv",
|
||||
"marketplace": "AdminMarket",
|
||||
"shop_code": test_shop.shop_code,
|
||||
"vendor_code": test_vendor.vendor_code,
|
||||
}
|
||||
|
||||
response = client.post(
|
||||
@@ -100,7 +100,7 @@ class TestMarketplaceImportJobAPI:
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["marketplace"] == "AdminMarket"
|
||||
assert data["shop_code"] == test_shop.shop_code
|
||||
assert data["vendor_code"] == test_vendor.vendor_code
|
||||
|
||||
def test_get_marketplace_import_status(self, client, auth_headers, test_marketplace_import_job):
|
||||
"""Test getting marketplace import status"""
|
||||
@@ -195,7 +195,7 @@ class TestMarketplaceImportJobAPI:
|
||||
assert isinstance(data["total_jobs"], int)
|
||||
assert data["total_jobs"] >= 1
|
||||
|
||||
def test_cancel_marketplace_import_job(self, client, auth_headers, test_user, test_shop, db):
|
||||
def test_cancel_marketplace_import_job(self, client, auth_headers, test_user, test_vendor, db):
|
||||
"""Test cancelling a marketplace import job"""
|
||||
# Create a pending job that can be cancelled
|
||||
from models.database.marketplace_import_job import MarketplaceImportJob
|
||||
@@ -205,9 +205,9 @@ class TestMarketplaceImportJobAPI:
|
||||
job = MarketplaceImportJob(
|
||||
status="pending",
|
||||
marketplace="TestMarket",
|
||||
shop_name=f"Test_Shop_{unique_id}",
|
||||
vendor_name=f"Test_vendor_{unique_id}",
|
||||
user_id=test_user.id,
|
||||
shop_id=test_shop.id,
|
||||
vendor_id=test_vendor.id,
|
||||
source_url="https://test.example.com/import",
|
||||
imported_count=0,
|
||||
updated_count=0,
|
||||
@@ -256,7 +256,7 @@ class TestMarketplaceImportJobAPI:
|
||||
assert data["error_code"] == "IMPORT_JOB_CANNOT_BE_CANCELLED"
|
||||
assert "completed" in data["message"]
|
||||
|
||||
def test_delete_marketplace_import_job(self, client, auth_headers, test_user, test_shop, db):
|
||||
def test_delete_marketplace_import_job(self, client, auth_headers, test_user, test_vendor, db):
|
||||
"""Test deleting a marketplace import job"""
|
||||
# Create a completed job that can be deleted
|
||||
from models.database.marketplace_import_job import MarketplaceImportJob
|
||||
@@ -266,9 +266,9 @@ class TestMarketplaceImportJobAPI:
|
||||
job = MarketplaceImportJob(
|
||||
status="completed",
|
||||
marketplace="TestMarket",
|
||||
shop_name=f"Test_Shop_{unique_id}",
|
||||
vendor_name=f"Test_vendor_{unique_id}",
|
||||
user_id=test_user.id,
|
||||
shop_id=test_shop.id,
|
||||
vendor_id=test_vendor.id,
|
||||
source_url="https://test.example.com/import",
|
||||
imported_count=0,
|
||||
updated_count=0,
|
||||
@@ -299,7 +299,7 @@ class TestMarketplaceImportJobAPI:
|
||||
data = response.json()
|
||||
assert data["error_code"] == "IMPORT_JOB_NOT_FOUND"
|
||||
|
||||
def test_delete_marketplace_import_job_cannot_delete(self, client, auth_headers, test_user, test_shop, db):
|
||||
def test_delete_marketplace_import_job_cannot_delete(self, client, auth_headers, test_user, test_vendor, db):
|
||||
"""Test deleting a job that cannot be deleted"""
|
||||
# Create a pending job that cannot be deleted
|
||||
from models.database.marketplace_import_job import MarketplaceImportJob
|
||||
@@ -309,9 +309,9 @@ class TestMarketplaceImportJobAPI:
|
||||
job = MarketplaceImportJob(
|
||||
status="pending",
|
||||
marketplace="TestMarket",
|
||||
shop_name=f"Test_Shop_{unique_id}",
|
||||
vendor_name=f"Test_vendor_{unique_id}",
|
||||
user_id=test_user.id,
|
||||
shop_id=test_shop.id,
|
||||
vendor_id=test_vendor.id,
|
||||
source_url="https://test.example.com/import",
|
||||
imported_count=0,
|
||||
updated_count=0,
|
||||
@@ -344,7 +344,7 @@ class TestMarketplaceImportJobAPI:
|
||||
import_data = {
|
||||
"url": "https://example.com/products.csv",
|
||||
"marketplace": "TestMarket",
|
||||
"shop_code": "TEST_SHOP",
|
||||
"vendor_code": "TEST_SHOP",
|
||||
}
|
||||
|
||||
response = client.post("/api/v1/marketplace/import-product", json=import_data)
|
||||
@@ -374,7 +374,7 @@ class TestMarketplaceImportJobAPI:
|
||||
data = response.json()
|
||||
assert data["job_id"] == test_marketplace_import_job.id
|
||||
|
||||
def test_admin_can_cancel_any_job(self, client, admin_headers, test_user, test_shop, db):
|
||||
def test_admin_can_cancel_any_job(self, client, admin_headers, test_user, test_vendor, db):
|
||||
"""Test that admin can cancel any job"""
|
||||
# Create a pending job owned by different user
|
||||
from models.database.marketplace_import_job import MarketplaceImportJob
|
||||
@@ -384,9 +384,9 @@ class TestMarketplaceImportJobAPI:
|
||||
job = MarketplaceImportJob(
|
||||
status="pending",
|
||||
marketplace="TestMarket",
|
||||
shop_name=f"Test_Shop_{unique_id}",
|
||||
vendor_name=f"Test_vendor_{unique_id}",
|
||||
user_id=test_user.id, # Different user
|
||||
shop_id=test_shop.id,
|
||||
vendor_id=test_vendor.id,
|
||||
source_url="https://test.example.com/import",
|
||||
imported_count=0,
|
||||
updated_count=0,
|
||||
@@ -406,16 +406,16 @@ class TestMarketplaceImportJobAPI:
|
||||
data = response.json()
|
||||
assert data["status"] == "cancelled"
|
||||
|
||||
def test_rate_limiting_applied(self, client, auth_headers, test_shop, test_user):
|
||||
def test_rate_limiting_applied(self, client, auth_headers, test_vendor, test_user):
|
||||
"""Test that rate limiting is applied to import endpoint"""
|
||||
# This test verifies that the rate_limit decorator is present
|
||||
# Actual rate limiting testing would require multiple requests
|
||||
test_shop.owner_id = test_user.id
|
||||
test_vendor.owner_id = test_user.id
|
||||
|
||||
import_data = {
|
||||
"url": "https://example.com/products.csv",
|
||||
"marketplace": "TestMarket",
|
||||
"shop_code": test_shop.shop_code,
|
||||
"vendor_code": test_vendor.vendor_code,
|
||||
}
|
||||
|
||||
response = client.post(
|
||||
|
||||
@@ -65,19 +65,19 @@ class TestExportFunctionality:
|
||||
assert f"EXP1_{unique_suffix}" in csv_content
|
||||
assert f"EXP2_{unique_suffix}" not in csv_content # Should be filtered out
|
||||
|
||||
def test_csv_export_with_shop_filter_success(self, client, auth_headers, db):
|
||||
"""Test CSV export with shop name filtering successfully"""
|
||||
def test_csv_export_with_vendor_filter_success(self, client, auth_headers, db):
|
||||
"""Test CSV export with vendor name filtering successfully"""
|
||||
unique_suffix = str(uuid.uuid4())[:8]
|
||||
products = [
|
||||
MarketplaceProduct(
|
||||
marketplace_product_id=f"SHOP1_{unique_suffix}",
|
||||
title=f"Shop1 MarketplaceProduct {unique_suffix}",
|
||||
shop_name="TestShop1"
|
||||
vendor_name="TestVendor1"
|
||||
),
|
||||
MarketplaceProduct(
|
||||
marketplace_product_id=f"SHOP2_{unique_suffix}",
|
||||
title=f"Shop2 MarketplaceProduct {unique_suffix}",
|
||||
shop_name="TestShop2"
|
||||
vendor_name="TestVendor2"
|
||||
),
|
||||
]
|
||||
|
||||
@@ -85,7 +85,7 @@ class TestExportFunctionality:
|
||||
db.commit()
|
||||
|
||||
response = client.get(
|
||||
"/api/v1/marketplace/product?shop_name=TestShop1", headers=auth_headers
|
||||
"/api/v1/marketplace/product?vendor_name=TestVendor1", headers=auth_headers
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
@@ -94,26 +94,26 @@ class TestExportFunctionality:
|
||||
assert f"SHOP2_{unique_suffix}" not in csv_content # Should be filtered out
|
||||
|
||||
def test_csv_export_with_combined_filters_success(self, client, auth_headers, db):
|
||||
"""Test CSV export with combined marketplace and shop filters successfully"""
|
||||
"""Test CSV export with combined marketplace and vendor filters successfully"""
|
||||
unique_suffix = str(uuid.uuid4())[:8]
|
||||
products = [
|
||||
MarketplaceProduct(
|
||||
marketplace_product_id=f"COMBO1_{unique_suffix}",
|
||||
title=f"Combo MarketplaceProduct 1 {unique_suffix}",
|
||||
marketplace="Amazon",
|
||||
shop_name="TestShop"
|
||||
vendor_name="TestVendor"
|
||||
),
|
||||
MarketplaceProduct(
|
||||
marketplace_product_id=f"COMBO2_{unique_suffix}",
|
||||
title=f"Combo MarketplaceProduct 2 {unique_suffix}",
|
||||
marketplace="eBay",
|
||||
shop_name="TestShop"
|
||||
vendor_name="TestVendor"
|
||||
),
|
||||
MarketplaceProduct(
|
||||
marketplace_product_id=f"COMBO3_{unique_suffix}",
|
||||
title=f"Combo MarketplaceProduct 3 {unique_suffix}",
|
||||
marketplace="Amazon",
|
||||
shop_name="OtherShop"
|
||||
vendor_name="OtherShop"
|
||||
),
|
||||
]
|
||||
|
||||
@@ -121,7 +121,7 @@ class TestExportFunctionality:
|
||||
db.commit()
|
||||
|
||||
response = client.get(
|
||||
"/api/v1/marketplace/product?marketplace=Amazon&shop_name=TestShop",
|
||||
"/api/v1/marketplace/product?marketplace=Amazon&vendor_name=TestVendor",
|
||||
headers=auth_headers
|
||||
)
|
||||
assert response.status_code == 200
|
||||
@@ -129,7 +129,7 @@ class TestExportFunctionality:
|
||||
csv_content = response.content.decode("utf-8")
|
||||
assert f"COMBO1_{unique_suffix}" in csv_content # Matches both filters
|
||||
assert f"COMBO2_{unique_suffix}" not in csv_content # Wrong marketplace
|
||||
assert f"COMBO3_{unique_suffix}" not in csv_content # Wrong shop
|
||||
assert f"COMBO3_{unique_suffix}" not in csv_content # Wrong vendor
|
||||
|
||||
def test_csv_export_no_results(self, client, auth_headers):
|
||||
"""Test CSV export with filters that return no results"""
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import pytest
|
||||
|
||||
from models.database.marketplace_product import MarketplaceProduct
|
||||
from models.database.shop import Shop
|
||||
from models.database.vendor import Vendor
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.api
|
||||
@@ -181,34 +181,34 @@ class TestPagination:
|
||||
overlap = set(first_page_ids) & set(second_page_ids)
|
||||
assert len(overlap) == 0, "Pages should not have overlapping products"
|
||||
|
||||
def test_shop_pagination_success(self, client, admin_headers, db, test_user):
|
||||
"""Test pagination for shop listing successfully"""
|
||||
def test_vendor_pagination_success(self, client, admin_headers, db, test_user):
|
||||
"""Test pagination for vendor listing successfully"""
|
||||
import uuid
|
||||
unique_suffix = str(uuid.uuid4())[:8]
|
||||
|
||||
# Create multiple shops for pagination testing
|
||||
from models.database.shop import Shop
|
||||
shops = []
|
||||
# Create multiple vendors for pagination testing
|
||||
from models.database.vendor import Vendor
|
||||
vendors =[]
|
||||
for i in range(15):
|
||||
shop = Shop(
|
||||
shop_code=f"PAGESHOP{i:03d}_{unique_suffix}",
|
||||
shop_name=f"Pagination Shop {i}",
|
||||
vendor = Vendor(
|
||||
vendor_code=f"PAGESHOP{i:03d}_{unique_suffix}",
|
||||
vendor_name=f"Pagination Vendor {i}",
|
||||
owner_id=test_user.id,
|
||||
is_active=True,
|
||||
)
|
||||
shops.append(shop)
|
||||
vendors.append(vendor)
|
||||
|
||||
db.add_all(shops)
|
||||
db.add_all(vendors)
|
||||
db.commit()
|
||||
|
||||
# Test first page (assuming admin endpoint exists)
|
||||
response = client.get(
|
||||
"/api/v1/shop?limit=5&skip=0", headers=admin_headers
|
||||
"/api/v1/vendor ?limit=5&skip=0", headers=admin_headers
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert len(data["shops"]) == 5
|
||||
assert data["total"] >= 15 # At least our test shops
|
||||
assert len(data["vendors"]) == 5
|
||||
assert data["total"] >= 15 # At least our test vendors
|
||||
assert data["skip"] == 0
|
||||
assert data["limit"] == 5
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -15,7 +15,7 @@ class TestStatsAPI:
|
||||
assert "unique_brands" in data
|
||||
assert "unique_categories" in data
|
||||
assert "unique_marketplaces" in data
|
||||
assert "unique_shops" in data
|
||||
assert "unique_vendors" in data
|
||||
assert data["total_products"] >= 1
|
||||
|
||||
def test_get_marketplace_stats(self, client, auth_headers, test_marketplace_product):
|
||||
|
||||
389
tests/integration/api/v1/test_vendor_endpoints.py
Normal file
389
tests/integration/api/v1/test_vendor_endpoints.py
Normal 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)
|
||||
@@ -10,10 +10,10 @@ class TestAuthentication:
|
||||
"""Test that protected endpoints reject unauthenticated requests"""
|
||||
protected_endpoints = [
|
||||
"/api/v1/admin/users",
|
||||
"/api/v1/admin/shops",
|
||||
"/api/v1/admin/vendors",
|
||||
"/api/v1/marketplace/import-jobs",
|
||||
"/api/v1/marketplace/product",
|
||||
"/api/v1/shop",
|
||||
"/api/v1/vendor ",
|
||||
"/api/v1/stats",
|
||||
"/api/v1/stock",
|
||||
]
|
||||
|
||||
@@ -16,7 +16,7 @@ class TestAuthorization:
|
||||
"""Test that admin users can access admin endpoints"""
|
||||
admin_endpoints = [
|
||||
"/api/v1/admin/users",
|
||||
"/api/v1/admin/shops",
|
||||
"/api/v1/admin/vendors",
|
||||
"/api/v1/admin/marketplace-import-jobs",
|
||||
]
|
||||
|
||||
@@ -36,15 +36,15 @@ class TestAuthorization:
|
||||
response = client.get(endpoint, headers=auth_headers)
|
||||
assert response.status_code == 200 # Regular user should have access
|
||||
|
||||
def test_shop_owner_access_control(
|
||||
self, client, auth_headers, test_shop, other_user
|
||||
def test_vendor_owner_access_control(
|
||||
self, client, auth_headers, test_vendor, other_user
|
||||
):
|
||||
"""Test that users can only access their own shops"""
|
||||
# Test accessing own shop (should work)
|
||||
"""Test that users can only access their own vendors"""
|
||||
# Test accessing own vendor (should work)
|
||||
response = client.get(
|
||||
f"/api/v1/shop/{test_shop.shop_code}", headers=auth_headers
|
||||
f"/api/v1/vendor /{test_vendor.vendor_code}", headers=auth_headers
|
||||
)
|
||||
# Response depends on your implementation - could be 200 or 404 if shop doesn't belong to user
|
||||
# Response depends on your implementation - could be 200 or 404 if vendor doesn't belong to user
|
||||
|
||||
# The exact assertion depends on your shop access control implementation
|
||||
# The exact assertion depends on your vendor access control implementation
|
||||
assert response.status_code in [200, 403, 404]
|
||||
|
||||
@@ -13,15 +13,15 @@ from models.database.marketplace_import_job import MarketplaceImportJob
|
||||
@pytest.mark.marketplace
|
||||
class TestBackgroundTasks:
|
||||
@pytest.mark.asyncio
|
||||
async def test_marketplace_import_success(self, db, test_user, test_shop):
|
||||
async def test_marketplace_import_success(self, db, test_user, test_vendor):
|
||||
"""Test successful marketplace import background task"""
|
||||
# Create import job
|
||||
job = MarketplaceImportJob(
|
||||
status="pending",
|
||||
source_url="http://example.com/test.csv",
|
||||
shop_name="TESTSHOP",
|
||||
vendor_name="TESTSHOP",
|
||||
marketplace="TestMarket",
|
||||
shop_id=test_shop.id,
|
||||
vendor_id=test_vendor.id,
|
||||
user_id=test_user.id,
|
||||
)
|
||||
db.add(job)
|
||||
@@ -67,15 +67,15 @@ class TestBackgroundTasks:
|
||||
assert updated_job.completed_at is not None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_marketplace_import_failure(self, db, test_user, test_shop):
|
||||
async def test_marketplace_import_failure(self, db, test_user, test_vendor):
|
||||
"""Test marketplace import failure handling"""
|
||||
# Create import job
|
||||
job = MarketplaceImportJob(
|
||||
status="pending",
|
||||
source_url="http://example.com/test.csv",
|
||||
shop_name="TESTSHOP",
|
||||
vendor_name="TESTSHOP",
|
||||
marketplace="TestMarket",
|
||||
shop_id=test_shop.id,
|
||||
vendor_id=test_vendor.id,
|
||||
user_id=test_user.id,
|
||||
)
|
||||
db.add(job)
|
||||
@@ -151,15 +151,15 @@ class TestBackgroundTasks:
|
||||
mock_instance.process_marketplace_csv_from_url.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_marketplace_import_with_errors(self, db, test_user, test_shop):
|
||||
async def test_marketplace_import_with_errors(self, db, test_user, test_vendor):
|
||||
"""Test marketplace import with some errors"""
|
||||
# Create import job
|
||||
job = MarketplaceImportJob(
|
||||
status="pending",
|
||||
source_url="http://example.com/test.csv",
|
||||
shop_name="TESTSHOP",
|
||||
vendor_name="TESTSHOP",
|
||||
marketplace="TestMarket",
|
||||
shop_id=test_shop.id,
|
||||
vendor_id=test_vendor.id,
|
||||
user_id=test_user.id,
|
||||
)
|
||||
db.add(job)
|
||||
|
||||
@@ -61,22 +61,22 @@ class TestIntegrationFlows:
|
||||
assert response.json()["total"] == 1
|
||||
|
||||
def test_product_workflow(self, client, auth_headers):
|
||||
"""Test shop creation and product management workflow"""
|
||||
# 1. Create a shop
|
||||
shop_data = {
|
||||
"shop_code": "FLOWSHOP",
|
||||
"shop_name": "Integration Flow Shop",
|
||||
"description": "Test shop for integration",
|
||||
"""Test vendor creation and product management workflow"""
|
||||
# 1. Create a vendor
|
||||
vendor_data = {
|
||||
"vendor_code": "FLOWSHOP",
|
||||
"vendor_name": "Integration Flow Shop",
|
||||
"description": "Test vendor for integration",
|
||||
}
|
||||
|
||||
response = client.post("/api/v1/shop", headers=auth_headers, json=shop_data)
|
||||
response = client.post("/api/v1/vendor ", headers=auth_headers, json=vendor_data)
|
||||
assert response.status_code == 200
|
||||
shop = response.json()
|
||||
vendor = response.json()
|
||||
|
||||
# 2. Create a product
|
||||
product_data = {
|
||||
"marketplace_product_id": "SHOPFLOW001",
|
||||
"title": "Shop Flow MarketplaceProduct",
|
||||
"title": "Vendor Flow MarketplaceProduct",
|
||||
"price": "15.99",
|
||||
"marketplace": "ShopFlow",
|
||||
}
|
||||
@@ -87,11 +87,11 @@ class TestIntegrationFlows:
|
||||
assert response.status_code == 200
|
||||
product = response.json()
|
||||
|
||||
# 3. Add product to shop (if endpoint exists)
|
||||
# This would test the shop-product association
|
||||
# 3. Add product to vendor (if endpoint exists)
|
||||
# This would test the vendor -product association
|
||||
|
||||
# 4. Get shop details
|
||||
response = client.get(f"/api/v1/shop/{shop['shop_code']}", headers=auth_headers)
|
||||
# 4. Get vendor details
|
||||
response = client.get(f"/api/v1/vendor /{vendor ['vendor_code']}", headers=auth_headers)
|
||||
assert response.status_code == 200
|
||||
|
||||
def test_stock_operations_workflow(self, client, auth_headers):
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# tests/system/test_error_handling.py
|
||||
"""
|
||||
System tests for error handling across the LetzShop API.
|
||||
System tests for error handling across the LetzVendor API.
|
||||
|
||||
Tests the complete error handling flow from FastAPI through custom exception handlers
|
||||
to ensure proper HTTP status codes, error structures, and client-friendly responses.
|
||||
@@ -16,7 +16,7 @@ class TestErrorHandling:
|
||||
def test_invalid_json_request(self, client, auth_headers):
|
||||
"""Test handling of malformed JSON requests"""
|
||||
response = client.post(
|
||||
"/api/v1/shop",
|
||||
"/api/v1/vendor ",
|
||||
headers=auth_headers,
|
||||
content="{ invalid json syntax"
|
||||
)
|
||||
@@ -27,13 +27,13 @@ class TestErrorHandling:
|
||||
assert data["message"] == "Request validation failed"
|
||||
assert "validation_errors" in data["details"]
|
||||
|
||||
def test_missing_required_fields_shop_creation(self, client, auth_headers):
|
||||
def test_missing_required_fields_vendor_creation(self, client, auth_headers):
|
||||
"""Test validation errors for missing required fields"""
|
||||
# Missing shop_name
|
||||
# Missing vendor_name
|
||||
response = client.post(
|
||||
"/api/v1/shop",
|
||||
"/api/v1/vendor ",
|
||||
headers=auth_headers,
|
||||
json={"shop_code": "TESTSHOP"}
|
||||
json={"vendor_code": "TESTSHOP"}
|
||||
)
|
||||
|
||||
assert response.status_code == 422
|
||||
@@ -42,28 +42,28 @@ class TestErrorHandling:
|
||||
assert data["status_code"] == 422
|
||||
assert "validation_errors" in data["details"]
|
||||
|
||||
def test_invalid_field_format_shop_creation(self, client, auth_headers):
|
||||
def test_invalid_field_format_vendor_creation(self, client, auth_headers):
|
||||
"""Test validation errors for invalid field formats"""
|
||||
# Invalid shop_code format (contains special characters)
|
||||
# Invalid vendor_code format (contains special characters)
|
||||
response = client.post(
|
||||
"/api/v1/shop",
|
||||
"/api/v1/vendor ",
|
||||
headers=auth_headers,
|
||||
json={
|
||||
"shop_code": "INVALID@SHOP!",
|
||||
"shop_name": "Test Shop"
|
||||
"vendor_code": "INVALID@SHOP!",
|
||||
"vendor_name": "Test Shop"
|
||||
}
|
||||
)
|
||||
|
||||
assert response.status_code == 422
|
||||
data = response.json()
|
||||
assert data["error_code"] == "INVALID_SHOP_DATA"
|
||||
assert data["error_code"] == "INVALID_VENDOR_DATA"
|
||||
assert data["status_code"] == 422
|
||||
assert data["details"]["field"] == "shop_code"
|
||||
assert data["details"]["field"] == "vendor_code"
|
||||
assert "letters, numbers, underscores, and hyphens" in data["message"]
|
||||
|
||||
def test_missing_authentication_token(self, client):
|
||||
"""Test authentication required endpoints without token"""
|
||||
response = client.get("/api/v1/shop")
|
||||
response = client.get("/api/v1/vendor ")
|
||||
|
||||
assert response.status_code == 401
|
||||
data = response.json()
|
||||
@@ -73,7 +73,7 @@ class TestErrorHandling:
|
||||
def test_invalid_authentication_token(self, client):
|
||||
"""Test endpoints with invalid JWT token"""
|
||||
headers = {"Authorization": "Bearer invalid_token_here"}
|
||||
response = client.get("/api/v1/shop", headers=headers)
|
||||
response = client.get("/api/v1/vendor ", headers=headers)
|
||||
|
||||
assert response.status_code == 401
|
||||
data = response.json()
|
||||
@@ -85,19 +85,19 @@ class TestErrorHandling:
|
||||
# This would require creating an expired token for testing
|
||||
expired_token = "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.expired.token"
|
||||
headers = {"Authorization": f"Bearer {expired_token}"}
|
||||
response = client.get("/api/v1/shop", headers=headers)
|
||||
response = client.get("/api/v1/vendor ", headers=headers)
|
||||
|
||||
assert response.status_code == 401
|
||||
data = response.json()
|
||||
assert data["status_code"] == 401
|
||||
|
||||
def test_shop_not_found(self, client, auth_headers):
|
||||
"""Test accessing non-existent shop"""
|
||||
response = client.get("/api/v1/shop/NONEXISTENT", headers=auth_headers)
|
||||
def test_vendor_not_found(self, client, auth_headers):
|
||||
"""Test accessing non-existent vendor """
|
||||
response = client.get("/api/v1/vendor /NONEXISTENT", headers=auth_headers)
|
||||
|
||||
assert response.status_code == 404
|
||||
data = response.json()
|
||||
assert data["error_code"] == "SHOP_NOT_FOUND"
|
||||
assert data["error_code"] == "VENDOR_NOT_FOUND"
|
||||
assert data["status_code"] == 404
|
||||
assert data["details"]["resource_type"] == "Shop"
|
||||
assert data["details"]["identifier"] == "NONEXISTENT"
|
||||
@@ -113,20 +113,20 @@ class TestErrorHandling:
|
||||
assert data["details"]["resource_type"] == "MarketplaceProduct"
|
||||
assert data["details"]["identifier"] == "NONEXISTENT"
|
||||
|
||||
def test_duplicate_shop_creation(self, client, auth_headers, test_shop):
|
||||
"""Test creating shop with duplicate shop code"""
|
||||
shop_data = {
|
||||
"shop_code": test_shop.shop_code,
|
||||
"shop_name": "Duplicate Shop"
|
||||
def test_duplicate_vendor_creation(self, client, auth_headers, test_vendor):
|
||||
"""Test creating vendor with duplicate vendor code"""
|
||||
vendor_data = {
|
||||
"vendor_code": test_vendor.vendor_code,
|
||||
"vendor_name": "Duplicate Shop"
|
||||
}
|
||||
|
||||
response = client.post("/api/v1/shop", headers=auth_headers, json=shop_data)
|
||||
response = client.post("/api/v1/vendor ", headers=auth_headers, json=vendor_data)
|
||||
|
||||
assert response.status_code == 409
|
||||
data = response.json()
|
||||
assert data["error_code"] == "SHOP_ALREADY_EXISTS"
|
||||
assert data["error_code"] == "VENDOR_ALREADY_EXISTS"
|
||||
assert data["status_code"] == 409
|
||||
assert data["details"]["shop_code"] == test_shop.shop_code.upper()
|
||||
assert data["details"]["vendor_code"] == test_vendor.vendor_code.upper()
|
||||
|
||||
def test_duplicate_product_creation(self, client, auth_headers, test_marketplace_product):
|
||||
"""Test creating product with duplicate product ID"""
|
||||
@@ -144,15 +144,15 @@ class TestErrorHandling:
|
||||
assert data["status_code"] == 409
|
||||
assert data["details"]["marketplace_product_id"] == test_marketplace_product.marketplace_product_id
|
||||
|
||||
def test_unauthorized_shop_access(self, client, auth_headers, inactive_shop):
|
||||
"""Test accessing shop without proper permissions"""
|
||||
response = client.get(f"/api/v1/shop/{inactive_shop.shop_code}", headers=auth_headers)
|
||||
def test_unauthorized_vendor_access(self, client, auth_headers, inactive_vendor):
|
||||
"""Test accessing vendor without proper permissions"""
|
||||
response = client.get(f"/api/v1/vendor /{inactive_vendor.vendor_code}", headers=auth_headers)
|
||||
|
||||
assert response.status_code == 403
|
||||
data = response.json()
|
||||
assert data["error_code"] == "UNAUTHORIZED_SHOP_ACCESS"
|
||||
assert data["error_code"] == "UNAUTHORIZED_VENDOR_ACCESS"
|
||||
assert data["status_code"] == 403
|
||||
assert data["details"]["shop_code"] == inactive_shop.shop_code
|
||||
assert data["details"]["vendor_code"] == inactive_vendor.vendor_code
|
||||
|
||||
def test_insufficient_permissions(self, client, auth_headers, admin_only_endpoint="/api/v1/admin/users"):
|
||||
"""Test accessing admin endpoints with regular user"""
|
||||
@@ -164,29 +164,29 @@ class TestErrorHandling:
|
||||
assert data["error_code"] in ["ADMIN_REQUIRED", "INSUFFICIENT_PERMISSIONS"]
|
||||
assert data["status_code"] == 403
|
||||
|
||||
def test_business_logic_violation_max_shops(self, client, auth_headers, monkeypatch):
|
||||
"""Test business logic violation - creating too many shops"""
|
||||
# This test would require mocking the shop limit check
|
||||
# For now, test the error structure when creating multiple shops
|
||||
shops_created = []
|
||||
def test_business_logic_violation_max_vendors(self, client, auth_headers, monkeypatch):
|
||||
"""Test business logic violation - creating too many vendors"""
|
||||
# This test would require mocking the vendor limit check
|
||||
# For now, test the error structure when creating multiple vendors
|
||||
vendors_created = []
|
||||
for i in range(6): # Assume limit is 5
|
||||
shop_data = {
|
||||
"shop_code": f"SHOP{i:03d}",
|
||||
"shop_name": f"Test Shop {i}"
|
||||
vendor_data = {
|
||||
"vendor_code": f"SHOP{i:03d}",
|
||||
"vendor_name": f"Test Vendor {i}"
|
||||
}
|
||||
response = client.post("/api/v1/shop", headers=auth_headers, json=shop_data)
|
||||
shops_created.append(response)
|
||||
response = client.post("/api/v1/vendor ", headers=auth_headers, json=vendor_data)
|
||||
vendors_created.append(response)
|
||||
|
||||
# At least one should succeed, and if limit is enforced, later ones should fail
|
||||
success_count = sum(1 for r in shops_created if r.status_code in [200, 201])
|
||||
success_count = sum(1 for r in vendors_created if r.status_code in [200, 201])
|
||||
assert success_count >= 1
|
||||
|
||||
# If any failed due to limit, check error structure
|
||||
failed_responses = [r for r in shops_created if r.status_code == 400]
|
||||
failed_responses = [r for r in vendors_created if r.status_code == 400]
|
||||
if failed_responses:
|
||||
data = failed_responses[0].json()
|
||||
assert data["error_code"] == "MAX_SHOPS_REACHED"
|
||||
assert "max_shops" in data["details"]
|
||||
assert data["error_code"] == "MAX_VENDORS_REACHED"
|
||||
assert "max_vendors" in data["details"]
|
||||
|
||||
def test_validation_error_invalid_gtin(self, client, auth_headers):
|
||||
"""Test validation error for invalid GTIN format"""
|
||||
@@ -204,7 +204,7 @@ class TestErrorHandling:
|
||||
assert data["status_code"] == 422
|
||||
assert data["details"]["field"] == "gtin"
|
||||
|
||||
def test_stock_insufficient_quantity(self, client, auth_headers, test_shop, test_marketplace_product):
|
||||
def test_stock_insufficient_quantity(self, client, auth_headers, test_vendor, test_marketplace_product):
|
||||
"""Test business logic error for insufficient stock"""
|
||||
# First create some stock
|
||||
stock_data = {
|
||||
@@ -246,7 +246,7 @@ class TestErrorHandling:
|
||||
def test_method_not_allowed(self, client, auth_headers):
|
||||
"""Test 405 for wrong HTTP method on existing endpoints"""
|
||||
# Try DELETE on an endpoint that only supports GET
|
||||
response = client.delete("/api/v1/shop", headers=auth_headers)
|
||||
response = client.delete("/api/v1/vendor ", headers=auth_headers)
|
||||
|
||||
assert response.status_code == 405
|
||||
# FastAPI automatically handles 405 errors
|
||||
@@ -255,9 +255,9 @@ class TestErrorHandling:
|
||||
"""Test handling of unsupported content types"""
|
||||
headers = {**auth_headers, "Content-Type": "application/xml"}
|
||||
response = client.post(
|
||||
"/api/v1/shop",
|
||||
"/api/v1/vendor ",
|
||||
headers=headers,
|
||||
content="<shop><code>TEST</code></shop>"
|
||||
content="<vendor ><code>TEST</code></vendor >"
|
||||
)
|
||||
|
||||
assert response.status_code in [400, 415, 422]
|
||||
@@ -265,13 +265,13 @@ class TestErrorHandling:
|
||||
def test_large_payload_handling(self, client, auth_headers):
|
||||
"""Test handling of unusually large payloads"""
|
||||
large_description = "x" * 100000 # Very long description
|
||||
shop_data = {
|
||||
"shop_code": "LARGESHOP",
|
||||
"shop_name": "Large Shop",
|
||||
vendor_data = {
|
||||
"vendor_code": "LARGESHOP",
|
||||
"vendor_name": "Large Shop",
|
||||
"description": large_description
|
||||
}
|
||||
|
||||
response = client.post("/api/v1/shop", headers=auth_headers, json=shop_data)
|
||||
response = client.post("/api/v1/vendor ", headers=auth_headers, json=vendor_data)
|
||||
|
||||
# Should either accept it or reject with appropriate error
|
||||
assert response.status_code in [200, 201, 413, 422]
|
||||
@@ -285,7 +285,7 @@ class TestErrorHandling:
|
||||
# Make rapid requests to potentially trigger rate limiting
|
||||
responses = []
|
||||
for _ in range(50): # Aggressive request count
|
||||
response = client.get("/api/v1/shop", headers=auth_headers)
|
||||
response = client.get("/api/v1/vendor ", headers=auth_headers)
|
||||
responses.append(response)
|
||||
|
||||
# Check if any rate limiting occurred and verify error structure
|
||||
@@ -305,12 +305,12 @@ class TestErrorHandling:
|
||||
response = client.get("/health")
|
||||
assert response.status_code == 200
|
||||
|
||||
def test_marketplace_import_errors(self, client, auth_headers, test_shop):
|
||||
def test_marketplace_import_errors(self, client, auth_headers, test_vendor):
|
||||
"""Test marketplace import specific errors"""
|
||||
# Test invalid marketplace
|
||||
import_data = {
|
||||
"marketplace": "INVALID_MARKETPLACE",
|
||||
"shop_code": test_shop.shop_code
|
||||
"vendor_code": test_vendor.vendor_code
|
||||
}
|
||||
|
||||
response = client.post("/api/v1/imports", headers=auth_headers, json=import_data)
|
||||
@@ -344,7 +344,7 @@ class TestErrorHandling:
|
||||
def test_error_response_consistency(self, client, auth_headers):
|
||||
"""Test that all error responses follow consistent structure"""
|
||||
test_cases = [
|
||||
("/api/v1/shop/NONEXISTENT", 404),
|
||||
("/api/v1/vendor /NONEXISTENT", 404),
|
||||
("/api/v1/marketplace/product/NONEXISTENT", 404),
|
||||
]
|
||||
|
||||
@@ -365,7 +365,7 @@ class TestErrorHandling:
|
||||
def test_cors_error_handling(self, client):
|
||||
"""Test CORS errors are handled properly"""
|
||||
# Test preflight request
|
||||
response = client.options("/api/v1/shop")
|
||||
response = client.options("/api/v1/vendor ")
|
||||
|
||||
# Should either succeed or be handled gracefully
|
||||
assert response.status_code in [200, 204, 405]
|
||||
@@ -373,7 +373,7 @@ class TestErrorHandling:
|
||||
def test_authentication_error_details(self, client):
|
||||
"""Test authentication error provides helpful details"""
|
||||
# Test missing Authorization header
|
||||
response = client.get("/api/v1/shop")
|
||||
response = client.get("/api/v1/vendor ")
|
||||
|
||||
assert response.status_code == 401
|
||||
data = response.json()
|
||||
@@ -406,7 +406,7 @@ class TestErrorRecovery:
|
||||
assert health_response.status_code == 200
|
||||
|
||||
# API endpoints may or may not work depending on system state
|
||||
api_response = client.get("/api/v1/shop", headers=auth_headers)
|
||||
api_response = client.get("/api/v1/vendor ", headers=auth_headers)
|
||||
# Should get either data or a proper error, not a crash
|
||||
assert api_response.status_code in [200, 401, 403, 500, 503]
|
||||
|
||||
@@ -416,7 +416,7 @@ class TestErrorRecovery:
|
||||
|
||||
with caplog.at_level(logging.ERROR):
|
||||
# Trigger an error
|
||||
client.get("/api/v1/shop/NONEXISTENT", headers=auth_headers)
|
||||
client.get("/api/v1/vendor /NONEXISTENT", headers=auth_headers)
|
||||
|
||||
# Check that error was logged (if your app logs 404s as errors)
|
||||
# Adjust based on your logging configuration
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import pytest
|
||||
|
||||
from models.database.marketplace_product import MarketplaceProduct
|
||||
from models.database.shop import Shop
|
||||
from models.database.vendor import Vendor
|
||||
from models.database.stock import Stock
|
||||
from models.database.user import User
|
||||
|
||||
@@ -41,7 +41,7 @@ class TestDatabaseModels:
|
||||
gtin="1234567890123",
|
||||
availability="in stock",
|
||||
marketplace="TestDB",
|
||||
shop_name="DBTestShop",
|
||||
vendor_name="DBTestVendor",
|
||||
)
|
||||
|
||||
db.add(marketplace_product)
|
||||
@@ -65,25 +65,25 @@ class TestDatabaseModels:
|
||||
assert stock.location == "DB_WAREHOUSE"
|
||||
assert stock.quantity == 150
|
||||
|
||||
def test_shop_model_with_owner(self, db, test_user):
|
||||
"""Test Shop model with owner relationship"""
|
||||
shop = Shop(
|
||||
shop_code="DBTEST",
|
||||
shop_name="Database Test Shop",
|
||||
description="Testing shop model",
|
||||
def test_vendor_model_with_owner(self, db, test_user):
|
||||
"""Test Vendor model with owner relationship"""
|
||||
vendor = Vendor(
|
||||
vendor_code="DBTEST",
|
||||
vendor_name="Database Test Vendor",
|
||||
description="Testing vendor model",
|
||||
owner_id=test_user.id,
|
||||
is_active=True,
|
||||
is_verified=False,
|
||||
)
|
||||
|
||||
db.add(shop)
|
||||
db.add(vendor)
|
||||
db.commit()
|
||||
db.refresh(shop)
|
||||
db.refresh(vendor)
|
||||
|
||||
assert shop.id is not None
|
||||
assert shop.shop_code == "DBTEST"
|
||||
assert shop.owner_id == test_user.id
|
||||
assert shop.owner.username == test_user.username
|
||||
assert vendor.id is not None
|
||||
assert vendor.vendor_code == "DBTEST"
|
||||
assert vendor.owner_id == test_user.id
|
||||
assert vendor.owner.username == test_user.username
|
||||
|
||||
def test_database_constraints(self, db):
|
||||
"""Test database constraints and unique indexes"""
|
||||
|
||||
@@ -5,13 +5,13 @@ from app.exceptions import (
|
||||
UserNotFoundException,
|
||||
UserStatusChangeException,
|
||||
CannotModifySelfException,
|
||||
ShopNotFoundException,
|
||||
ShopVerificationException,
|
||||
VendorNotFoundException,
|
||||
VendorVerificationException,
|
||||
AdminOperationException,
|
||||
)
|
||||
from app.services.admin_service import AdminService
|
||||
from models.database.marketplace_import_job import MarketplaceImportJob
|
||||
from models.database.shop import Shop
|
||||
from models.database.vendor import Vendor
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@@ -93,80 +93,80 @@ class TestAdminService:
|
||||
assert exception.error_code == "USER_STATUS_CHANGE_FAILED"
|
||||
assert "Cannot modify another admin user" in exception.message
|
||||
|
||||
# Shop Management Tests
|
||||
def test_get_all_shops(self, db, test_shop):
|
||||
"""Test getting all shops with total count"""
|
||||
shops, total = self.service.get_all_shops(db, skip=0, limit=10)
|
||||
# Vendor Management Tests
|
||||
def test_get_all_vendors(self, db, test_vendor):
|
||||
"""Test getting all vendors with total count"""
|
||||
vendors, total = self.service.get_all_vendors(db, skip=0, limit=10)
|
||||
|
||||
assert total >= 1
|
||||
assert len(shops) >= 1
|
||||
shop_codes = [shop.shop_code for shop in shops]
|
||||
assert test_shop.shop_code in shop_codes
|
||||
assert len(vendors) >= 1
|
||||
vendor_codes = [vendor.vendor_code for vendor in vendors]
|
||||
assert test_vendor.vendor_code in vendor_codes
|
||||
|
||||
def test_get_all_shops_with_pagination(self, db, test_shop, verified_shop):
|
||||
"""Test shop pagination works correctly"""
|
||||
shops, total = self.service.get_all_shops(db, skip=0, limit=1)
|
||||
def test_get_all_vendors_with_pagination(self, db, test_vendor, verified_vendor):
|
||||
"""Test vendor pagination works correctly"""
|
||||
vendors, total = self.service.get_all_vendors(db, skip=0, limit=1)
|
||||
|
||||
assert total >= 2
|
||||
assert len(shops) == 1
|
||||
assert len(vendors) == 1
|
||||
|
||||
shops_second_page, _ = self.service.get_all_shops(db, skip=1, limit=1)
|
||||
assert len(shops_second_page) >= 0
|
||||
if len(shops_second_page) > 0:
|
||||
assert shops[0].id != shops_second_page[0].id
|
||||
vendors_second_page, _ = self.service.get_all_vendors(db, skip=1, limit=1)
|
||||
assert len(vendors_second_page) >= 0
|
||||
if len(vendors_second_page) > 0:
|
||||
assert vendors[0].id != vendors_second_page[0].id
|
||||
|
||||
def test_verify_shop_mark_verified(self, db, test_shop):
|
||||
"""Test marking shop as verified"""
|
||||
# Ensure shop starts unverified
|
||||
test_shop.is_verified = False
|
||||
def test_verify_vendor_mark_verified(self, db, test_vendor):
|
||||
"""Test marking vendor as verified"""
|
||||
# Ensure vendor starts unverified
|
||||
test_vendor.is_verified = False
|
||||
db.commit()
|
||||
|
||||
shop, message = self.service.verify_shop(db, test_shop.id)
|
||||
vendor, message = self.service.verify_vendor(db, test_vendor.id)
|
||||
|
||||
assert shop.id == test_shop.id
|
||||
assert shop.is_verified is True
|
||||
assert test_shop.shop_code in message
|
||||
assert vendor.id == test_vendor.id
|
||||
assert vendor.is_verified is True
|
||||
assert test_vendor.vendor_code in message
|
||||
assert "verified" in message
|
||||
|
||||
def test_verify_shop_mark_unverified(self, db, verified_shop):
|
||||
"""Test marking verified shop as unverified"""
|
||||
shop, message = self.service.verify_shop(db, verified_shop.id)
|
||||
def test_verify_vendor_mark_unverified(self, db, verified_vendor):
|
||||
"""Test marking verified vendor as unverified"""
|
||||
vendor, message = self.service.verify_vendor(db, verified_vendor.id)
|
||||
|
||||
assert shop.id == verified_shop.id
|
||||
assert shop.is_verified is False
|
||||
assert verified_shop.shop_code in message
|
||||
assert vendor.id == verified_vendor.id
|
||||
assert vendor.is_verified is False
|
||||
assert verified_vendor.vendor_code in message
|
||||
assert "unverified" in message
|
||||
|
||||
def test_verify_shop_not_found(self, db):
|
||||
"""Test verify shop when shop not found"""
|
||||
with pytest.raises(ShopNotFoundException) as exc_info:
|
||||
self.service.verify_shop(db, 99999)
|
||||
def test_verify_vendor_not_found(self, db):
|
||||
"""Test verify vendor when vendor not found"""
|
||||
with pytest.raises(VendorNotFoundException) as exc_info:
|
||||
self.service.verify_vendor(db, 99999)
|
||||
|
||||
exception = exc_info.value
|
||||
assert exception.error_code == "SHOP_NOT_FOUND"
|
||||
assert exception.error_code == "VENDOR_NOT_FOUND"
|
||||
assert "99999" in exception.message
|
||||
|
||||
def test_toggle_shop_status_deactivate(self, db, test_shop):
|
||||
"""Test deactivating a shop"""
|
||||
original_status = test_shop.is_active
|
||||
def test_toggle_vendor_status_deactivate(self, db, test_vendor):
|
||||
"""Test deactivating a vendor """
|
||||
original_status = test_vendor.is_active
|
||||
|
||||
shop, message = self.service.toggle_shop_status(db, test_shop.id)
|
||||
vendor, message = self.service.toggle_vendor_status(db, test_vendor.id)
|
||||
|
||||
assert shop.id == test_shop.id
|
||||
assert shop.is_active != original_status
|
||||
assert test_shop.shop_code in message
|
||||
assert vendor.id == test_vendor.id
|
||||
assert vendor.is_active != original_status
|
||||
assert test_vendor.vendor_code in message
|
||||
if original_status:
|
||||
assert "deactivated" in message
|
||||
else:
|
||||
assert "activated" in message
|
||||
|
||||
def test_toggle_shop_status_not_found(self, db):
|
||||
"""Test toggle shop status when shop not found"""
|
||||
with pytest.raises(ShopNotFoundException) as exc_info:
|
||||
self.service.toggle_shop_status(db, 99999)
|
||||
def test_toggle_vendor_status_not_found(self, db):
|
||||
"""Test toggle vendor status when vendor not found"""
|
||||
with pytest.raises(VendorNotFoundException) as exc_info:
|
||||
self.service.toggle_vendor_status(db, 99999)
|
||||
|
||||
exception = exc_info.value
|
||||
assert exception.error_code == "SHOP_NOT_FOUND"
|
||||
assert exception.error_code == "VENDOR_NOT_FOUND"
|
||||
|
||||
# Marketplace Import Jobs Tests
|
||||
def test_get_marketplace_import_jobs_no_filters(self, db, test_marketplace_import_job):
|
||||
@@ -180,7 +180,7 @@ class TestAdminService:
|
||||
)
|
||||
assert test_job is not None
|
||||
assert test_job.marketplace == test_marketplace_import_job.marketplace
|
||||
assert test_job.shop_name == test_marketplace_import_job.shop_name
|
||||
assert test_job.vendor_name == test_marketplace_import_job.vendor_name
|
||||
assert test_job.status == test_marketplace_import_job.status
|
||||
|
||||
def test_get_marketplace_import_jobs_with_marketplace_filter(self, db, test_marketplace_import_job):
|
||||
@@ -193,15 +193,15 @@ class TestAdminService:
|
||||
for job in result:
|
||||
assert test_marketplace_import_job.marketplace.lower() in job.marketplace.lower()
|
||||
|
||||
def test_get_marketplace_import_jobs_with_shop_filter(self, db, test_marketplace_import_job):
|
||||
"""Test filtering marketplace import jobs by shop name"""
|
||||
def test_get_marketplace_import_jobs_with_vendor_filter(self, db, test_marketplace_import_job):
|
||||
"""Test filtering marketplace import jobs by vendor name"""
|
||||
result = self.service.get_marketplace_import_jobs(
|
||||
db, shop_name=test_marketplace_import_job.shop_name, skip=0, limit=10
|
||||
db, vendor_name=test_marketplace_import_job.vendor_name, skip=0, limit=10
|
||||
)
|
||||
|
||||
assert len(result) >= 1
|
||||
for job in result:
|
||||
assert test_marketplace_import_job.shop_name.lower() in job.shop_name.lower()
|
||||
assert test_marketplace_import_job.vendor_name.lower() in job.vendor_name.lower()
|
||||
|
||||
def test_get_marketplace_import_jobs_with_status_filter(self, db, test_marketplace_import_job):
|
||||
"""Test filtering marketplace import jobs by status"""
|
||||
@@ -242,21 +242,21 @@ class TestAdminService:
|
||||
assert stats["total_users"] >= 2 # test_user + test_admin
|
||||
assert stats["active_users"] + stats["inactive_users"] == stats["total_users"]
|
||||
|
||||
def test_get_shop_statistics(self, db, test_shop):
|
||||
"""Test getting shop statistics"""
|
||||
stats = self.service.get_shop_statistics(db)
|
||||
def test_get_vendor_statistics(self, db, test_vendor):
|
||||
"""Test getting vendor statistics"""
|
||||
stats = self.service.get_vendor_statistics(db)
|
||||
|
||||
assert "total_shops" in stats
|
||||
assert "active_shops" in stats
|
||||
assert "verified_shops" in stats
|
||||
assert "total_vendors" in stats
|
||||
assert "active_vendors" in stats
|
||||
assert "verified_vendors" in stats
|
||||
assert "verification_rate" in stats
|
||||
|
||||
assert isinstance(stats["total_shops"], int)
|
||||
assert isinstance(stats["active_shops"], int)
|
||||
assert isinstance(stats["verified_shops"], int)
|
||||
assert isinstance(stats["total_vendors"], int)
|
||||
assert isinstance(stats["active_vendors"], int)
|
||||
assert isinstance(stats["verified_vendors"], int)
|
||||
assert isinstance(stats["verification_rate"], (int, float))
|
||||
|
||||
assert stats["total_shops"] >= 1
|
||||
assert stats["total_vendors"] >= 1
|
||||
|
||||
# Error Handling Tests
|
||||
def test_get_all_users_database_error(self, db_with_error, test_admin):
|
||||
@@ -268,14 +268,14 @@ class TestAdminService:
|
||||
assert exception.error_code == "ADMIN_OPERATION_FAILED"
|
||||
assert "get_all_users" in exception.message
|
||||
|
||||
def test_get_all_shops_database_error(self, db_with_error):
|
||||
"""Test handling database errors in get_all_shops"""
|
||||
def test_get_all_vendors_database_error(self, db_with_error):
|
||||
"""Test handling database errors in get_all_vendors"""
|
||||
with pytest.raises(AdminOperationException) as exc_info:
|
||||
self.service.get_all_shops(db_with_error, skip=0, limit=10)
|
||||
self.service.get_all_vendors(db_with_error, skip=0, limit=10)
|
||||
|
||||
exception = exc_info.value
|
||||
assert exception.error_code == "ADMIN_OPERATION_FAILED"
|
||||
assert "get_all_shops" in exception.message
|
||||
assert "get_all_vendors" in exception.message
|
||||
|
||||
# Edge Cases
|
||||
def test_get_all_users_empty_database(self, empty_db):
|
||||
@@ -283,10 +283,10 @@ class TestAdminService:
|
||||
users = self.service.get_all_users(empty_db, skip=0, limit=10)
|
||||
assert len(users) == 0
|
||||
|
||||
def test_get_all_shops_empty_database(self, empty_db):
|
||||
"""Test getting shops when database is empty"""
|
||||
shops, total = self.service.get_all_shops(empty_db, skip=0, limit=10)
|
||||
assert len(shops) == 0
|
||||
def test_get_all_vendors_empty_database(self, empty_db):
|
||||
"""Test getting vendors when database is empty"""
|
||||
vendors, total = self.service.get_all_vendors(empty_db, skip=0, limit=10)
|
||||
assert len(vendors) == 0
|
||||
assert total == 0
|
||||
|
||||
def test_user_statistics_empty_database(self, empty_db):
|
||||
@@ -298,11 +298,11 @@ class TestAdminService:
|
||||
assert stats["inactive_users"] == 0
|
||||
assert stats["activation_rate"] == 0
|
||||
|
||||
def test_shop_statistics_empty_database(self, empty_db):
|
||||
"""Test shop statistics when no shops exist"""
|
||||
stats = self.service.get_shop_statistics(empty_db)
|
||||
def test_vendor_statistics_empty_database(self, empty_db):
|
||||
"""Test vendor statistics when no vendors exist"""
|
||||
stats = self.service.get_vendor_statistics(empty_db)
|
||||
|
||||
assert stats["total_shops"] == 0
|
||||
assert stats["active_shops"] == 0
|
||||
assert stats["verified_shops"] == 0
|
||||
assert stats["total_vendors"] == 0
|
||||
assert stats["active_vendors"] == 0
|
||||
assert stats["verified_vendors"] == 0
|
||||
assert stats["verification_rate"] == 0
|
||||
|
||||
@@ -10,12 +10,12 @@ from app.exceptions.marketplace_import_job import (
|
||||
ImportJobCannotBeCancelledException,
|
||||
ImportJobCannotBeDeletedException,
|
||||
)
|
||||
from app.exceptions.shop import ShopNotFoundException, UnauthorizedShopAccessException
|
||||
from app.exceptions.vendor import VendorNotFoundException, UnauthorizedVendorAccessException
|
||||
from app.exceptions.base import ValidationException
|
||||
from app.services.marketplace_import_job_service import MarketplaceImportJobService
|
||||
from models.schemas.marketplace_import_job import MarketplaceImportJobRequest
|
||||
from models.database.marketplace_import_job import MarketplaceImportJob
|
||||
from models.database.shop import Shop
|
||||
from models.database.vendor import Vendor
|
||||
from models.database.user import User
|
||||
|
||||
|
||||
@@ -25,107 +25,107 @@ class TestMarketplaceService:
|
||||
def setup_method(self):
|
||||
self.service = MarketplaceImportJobService()
|
||||
|
||||
def test_validate_shop_access_success(self, db, test_shop, test_user):
|
||||
"""Test successful shop access validation"""
|
||||
# Set the shop owner to the test user
|
||||
test_shop.owner_id = test_user.id
|
||||
def test_validate_vendor_access_success(self, db, test_vendor, test_user):
|
||||
"""Test successful vendor access validation"""
|
||||
# Set the vendor owner to the test user
|
||||
test_vendor.owner_id = test_user.id
|
||||
db.commit()
|
||||
|
||||
result = self.service.validate_shop_access(db, test_shop.shop_code, test_user)
|
||||
result = self.service.validate_vendor_access(db, test_vendor.vendor_code, test_user)
|
||||
|
||||
assert result.shop_code == test_shop.shop_code
|
||||
assert result.vendor_code == test_vendor.vendor_code
|
||||
assert result.owner_id == test_user.id
|
||||
|
||||
def test_validate_shop_access_admin_can_access_any_shop(
|
||||
self, db, test_shop, test_admin
|
||||
def test_validate_vendor_access_admin_can_access_any_vendor(
|
||||
self, db, test_vendor, test_admin
|
||||
):
|
||||
"""Test that admin users can access any shop"""
|
||||
result = self.service.validate_shop_access(db, test_shop.shop_code, test_admin)
|
||||
"""Test that admin users can access any vendor """
|
||||
result = self.service.validate_vendor_access(db, test_vendor.vendor_code, test_admin)
|
||||
|
||||
assert result.shop_code == test_shop.shop_code
|
||||
assert result.vendor_code == test_vendor.vendor_code
|
||||
|
||||
def test_validate_shop_access_shop_not_found(self, db, test_user):
|
||||
"""Test shop access validation when shop doesn't exist"""
|
||||
with pytest.raises(ShopNotFoundException) as exc_info:
|
||||
self.service.validate_shop_access(db, "NONEXISTENT", test_user)
|
||||
def test_validate_vendor_access_vendor_not_found(self, db, test_user):
|
||||
"""Test vendor access validation when vendor doesn't exist"""
|
||||
with pytest.raises(VendorNotFoundException) as exc_info:
|
||||
self.service.validate_vendor_access(db, "NONEXISTENT", test_user)
|
||||
|
||||
exception = exc_info.value
|
||||
assert exception.error_code == "SHOP_NOT_FOUND"
|
||||
assert exception.error_code == "VENDOR_NOT_FOUND"
|
||||
assert exception.status_code == 404
|
||||
assert "NONEXISTENT" in exception.message
|
||||
|
||||
def test_validate_shop_access_permission_denied(
|
||||
self, db, test_shop, test_user, other_user
|
||||
def test_validate_vendor_access_permission_denied(
|
||||
self, db, test_vendor, test_user, other_user
|
||||
):
|
||||
"""Test shop access validation when user doesn't own the shop"""
|
||||
# Set the shop owner to a different user
|
||||
test_shop.owner_id = other_user.id
|
||||
"""Test vendor access validation when user doesn't own the vendor """
|
||||
# Set the vendor owner to a different user
|
||||
test_vendor.owner_id = other_user.id
|
||||
db.commit()
|
||||
|
||||
with pytest.raises(UnauthorizedShopAccessException) as exc_info:
|
||||
self.service.validate_shop_access(db, test_shop.shop_code, test_user)
|
||||
with pytest.raises(UnauthorizedVendorAccessException) as exc_info:
|
||||
self.service.validate_vendor_access(db, test_vendor.vendor_code, test_user)
|
||||
|
||||
exception = exc_info.value
|
||||
assert exception.error_code == "UNAUTHORIZED_SHOP_ACCESS"
|
||||
assert exception.error_code == "UNAUTHORIZED_VENDOR_ACCESS"
|
||||
assert exception.status_code == 403
|
||||
assert test_shop.shop_code in exception.message
|
||||
assert test_vendor.vendor_code in exception.message
|
||||
|
||||
def test_create_import_job_success(self, db, test_shop, test_user):
|
||||
def test_create_import_job_success(self, db, test_vendor, test_user):
|
||||
"""Test successful creation of import job"""
|
||||
# Set the shop owner to the test user
|
||||
test_shop.owner_id = test_user.id
|
||||
# Set the vendor owner to the test user
|
||||
test_vendor.owner_id = test_user.id
|
||||
db.commit()
|
||||
|
||||
request = MarketplaceImportJobRequest(
|
||||
url="https://example.com/products.csv",
|
||||
marketplace="Amazon",
|
||||
shop_code=test_shop.shop_code,
|
||||
vendor_code=test_vendor.vendor_code,
|
||||
batch_size=1000,
|
||||
)
|
||||
|
||||
result = self.service.create_import_job(db, request, test_user)
|
||||
|
||||
assert result.marketplace == "Amazon"
|
||||
assert result.shop_id == test_shop.id
|
||||
assert result.vendor_id == test_vendor.id
|
||||
assert result.user_id == test_user.id
|
||||
assert result.status == "pending"
|
||||
assert result.source_url == "https://example.com/products.csv"
|
||||
assert result.shop_name == test_shop.shop_name
|
||||
assert result.vendor_name == test_vendor.vendor_name
|
||||
|
||||
def test_create_import_job_invalid_shop(self, db, test_user):
|
||||
"""Test import job creation with invalid shop"""
|
||||
def test_create_import_job_invalid_vendor(self, db, test_user):
|
||||
"""Test import job creation with invalid vendor """
|
||||
request = MarketplaceImportJobRequest(
|
||||
url="https://example.com/products.csv",
|
||||
marketplace="Amazon",
|
||||
shop_code="INVALID_SHOP",
|
||||
vendor_code="INVALID_SHOP",
|
||||
batch_size=1000,
|
||||
)
|
||||
|
||||
with pytest.raises(ShopNotFoundException) as exc_info:
|
||||
with pytest.raises(VendorNotFoundException) as exc_info:
|
||||
self.service.create_import_job(db, request, test_user)
|
||||
|
||||
exception = exc_info.value
|
||||
assert exception.error_code == "SHOP_NOT_FOUND"
|
||||
assert exception.error_code == "VENDOR_NOT_FOUND"
|
||||
assert "INVALID_SHOP" in exception.message
|
||||
|
||||
def test_create_import_job_unauthorized_access(self, db, test_shop, test_user, other_user):
|
||||
"""Test import job creation with unauthorized shop access"""
|
||||
# Set the shop owner to a different user
|
||||
test_shop.owner_id = other_user.id
|
||||
def test_create_import_job_unauthorized_access(self, db, test_vendor, test_user, other_user):
|
||||
"""Test import job creation with unauthorized vendor access"""
|
||||
# Set the vendor owner to a different user
|
||||
test_vendor.owner_id = other_user.id
|
||||
db.commit()
|
||||
|
||||
request = MarketplaceImportJobRequest(
|
||||
url="https://example.com/products.csv",
|
||||
marketplace="Amazon",
|
||||
shop_code=test_shop.shop_code,
|
||||
vendor_code=test_vendor.vendor_code,
|
||||
batch_size=1000,
|
||||
)
|
||||
|
||||
with pytest.raises(UnauthorizedShopAccessException) as exc_info:
|
||||
with pytest.raises(UnauthorizedVendorAccessException) as exc_info:
|
||||
self.service.create_import_job(db, request, test_user)
|
||||
|
||||
exception = exc_info.value
|
||||
assert exception.error_code == "UNAUTHORIZED_SHOP_ACCESS"
|
||||
assert exception.error_code == "UNAUTHORIZED_VENDOR_ACCESS"
|
||||
|
||||
def test_get_import_job_by_id_success(self, db, test_marketplace_import_job, test_user):
|
||||
"""Test getting import job by ID for job owner"""
|
||||
@@ -194,7 +194,7 @@ class TestMarketplaceService:
|
||||
assert len(jobs) >= 1
|
||||
assert any(job.marketplace == test_marketplace_import_job.marketplace for job in jobs)
|
||||
|
||||
def test_get_import_jobs_with_pagination(self, db, test_user, test_shop):
|
||||
def test_get_import_jobs_with_pagination(self, db, test_user, test_vendor):
|
||||
"""Test getting import jobs with pagination"""
|
||||
unique_id = str(uuid.uuid4())[:8]
|
||||
|
||||
@@ -203,9 +203,9 @@ class TestMarketplaceService:
|
||||
job = MarketplaceImportJob(
|
||||
status="completed",
|
||||
marketplace=f"Marketplace_{unique_id}_{i}",
|
||||
shop_name=f"Test_Shop_{unique_id}_{i}",
|
||||
vendor_name=f"Test_vendor_{unique_id}_{i}",
|
||||
user_id=test_user.id,
|
||||
shop_id=test_shop.id,
|
||||
vendor_id=test_vendor.id,
|
||||
source_url=f"https://test-{i}.example.com/import",
|
||||
imported_count=0,
|
||||
updated_count=0,
|
||||
@@ -296,7 +296,7 @@ class TestMarketplaceService:
|
||||
assert response.marketplace == test_marketplace_import_job.marketplace
|
||||
assert response.imported == (test_marketplace_import_job.imported_count or 0)
|
||||
|
||||
def test_cancel_import_job_success(self, db, test_user, test_shop):
|
||||
def test_cancel_import_job_success(self, db, test_user, test_vendor):
|
||||
"""Test cancelling a pending import job"""
|
||||
unique_id = str(uuid.uuid4())[:8]
|
||||
|
||||
@@ -304,9 +304,9 @@ class TestMarketplaceService:
|
||||
job = MarketplaceImportJob(
|
||||
status="pending",
|
||||
marketplace="Amazon",
|
||||
shop_name=f"TEST_SHOP_{unique_id}",
|
||||
vendor_name=f"TEST_VENDOR_{unique_id}",
|
||||
user_id=test_user.id,
|
||||
shop_id=test_shop.id,
|
||||
vendor_id=test_vendor.id,
|
||||
source_url="https://test.example.com/import",
|
||||
imported_count=0,
|
||||
updated_count=0,
|
||||
@@ -354,7 +354,7 @@ class TestMarketplaceService:
|
||||
assert exception.status_code == 400
|
||||
assert "completed" in exception.message
|
||||
|
||||
def test_delete_import_job_success(self, db, test_user, test_shop):
|
||||
def test_delete_import_job_success(self, db, test_user, test_vendor):
|
||||
"""Test deleting a completed import job"""
|
||||
unique_id = str(uuid.uuid4())[:8]
|
||||
|
||||
@@ -362,9 +362,9 @@ class TestMarketplaceService:
|
||||
job = MarketplaceImportJob(
|
||||
status="completed",
|
||||
marketplace="Amazon",
|
||||
shop_name=f"TEST_SHOP_{unique_id}",
|
||||
vendor_name=f"TEST_VENDOR_{unique_id}",
|
||||
user_id=test_user.id,
|
||||
shop_id=test_shop.id,
|
||||
vendor_id=test_vendor.id,
|
||||
source_url="https://test.example.com/import",
|
||||
imported_count=0,
|
||||
updated_count=0,
|
||||
@@ -404,7 +404,7 @@ class TestMarketplaceService:
|
||||
exception = exc_info.value
|
||||
assert exception.error_code == "IMPORT_JOB_NOT_OWNED"
|
||||
|
||||
def test_delete_import_job_invalid_status(self, db, test_user, test_shop):
|
||||
def test_delete_import_job_invalid_status(self, db, test_user, test_vendor):
|
||||
"""Test deleting a job that can't be deleted"""
|
||||
unique_id = str(uuid.uuid4())[:8]
|
||||
|
||||
@@ -412,9 +412,9 @@ class TestMarketplaceService:
|
||||
job = MarketplaceImportJob(
|
||||
status="pending",
|
||||
marketplace="Amazon",
|
||||
shop_name=f"TEST_SHOP_{unique_id}",
|
||||
vendor_name=f"TEST_VENDOR_{unique_id}",
|
||||
user_id=test_user.id,
|
||||
shop_id=test_shop.id,
|
||||
vendor_id=test_vendor.id,
|
||||
source_url="https://test.example.com/import",
|
||||
imported_count=0,
|
||||
updated_count=0,
|
||||
@@ -434,25 +434,25 @@ class TestMarketplaceService:
|
||||
assert "pending" in exception.message
|
||||
|
||||
# Test edge cases and error scenarios
|
||||
def test_validate_shop_access_case_insensitive(self, db, test_shop, test_user):
|
||||
"""Test shop access validation is case insensitive"""
|
||||
test_shop.owner_id = test_user.id
|
||||
def test_validate_vendor_access_case_insensitive(self, db, test_vendor, test_user):
|
||||
"""Test vendor access validation is case insensitive"""
|
||||
test_vendor.owner_id = test_user.id
|
||||
db.commit()
|
||||
|
||||
# Test with lowercase shop code
|
||||
result = self.service.validate_shop_access(db, test_shop.shop_code.lower(), test_user)
|
||||
assert result.shop_code == test_shop.shop_code
|
||||
# Test with lowercase vendor code
|
||||
result = self.service.validate_vendor_access(db, test_vendor.vendor_code.lower(), test_user)
|
||||
assert result.vendor_code == test_vendor.vendor_code
|
||||
|
||||
# Test with uppercase shop code
|
||||
result = self.service.validate_shop_access(db, test_shop.shop_code.upper(), test_user)
|
||||
assert result.shop_code == test_shop.shop_code
|
||||
# Test with uppercase vendor code
|
||||
result = self.service.validate_vendor_access(db, test_vendor.vendor_code.upper(), test_user)
|
||||
assert result.vendor_code == test_vendor.vendor_code
|
||||
|
||||
def test_create_import_job_database_error(self, db_with_error, test_user):
|
||||
"""Test import job creation handles database errors"""
|
||||
request = MarketplaceImportJobRequest(
|
||||
url="https://example.com/products.csv",
|
||||
marketplace="Amazon",
|
||||
shop_code="TEST_SHOP",
|
||||
vendor_code="TEST_SHOP",
|
||||
batch_size=1000,
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -23,7 +23,7 @@ class TestStatsService:
|
||||
assert "unique_brands" in stats
|
||||
assert "unique_categories" in stats
|
||||
assert "unique_marketplaces" in stats
|
||||
assert "unique_shops" in stats
|
||||
assert "unique_vendors" in stats
|
||||
assert "total_stock_entries" in stats
|
||||
assert "total_inventory_quantity" in stats
|
||||
|
||||
@@ -41,7 +41,7 @@ class TestStatsService:
|
||||
brand="DifferentBrand",
|
||||
google_product_category="Different Category",
|
||||
marketplace="Amazon",
|
||||
shop_name="AmazonShop",
|
||||
vendor_name="AmazonShop",
|
||||
price="15.99",
|
||||
currency="EUR",
|
||||
),
|
||||
@@ -51,7 +51,7 @@ class TestStatsService:
|
||||
brand="ThirdBrand",
|
||||
google_product_category="Third Category",
|
||||
marketplace="eBay",
|
||||
shop_name="eBayShop",
|
||||
vendor_name="eBayShop",
|
||||
price="25.99",
|
||||
currency="USD",
|
||||
),
|
||||
@@ -61,7 +61,7 @@ class TestStatsService:
|
||||
brand="TestBrand", # Same as test_marketplace_product
|
||||
google_product_category="Different Category",
|
||||
marketplace="Letzshop", # Same as test_marketplace_product
|
||||
shop_name="DifferentShop",
|
||||
vendor_name="DifferentShop",
|
||||
price="35.99",
|
||||
currency="EUR",
|
||||
),
|
||||
@@ -75,7 +75,7 @@ class TestStatsService:
|
||||
assert stats["unique_brands"] >= 3 # TestBrand, DifferentBrand, ThirdBrand
|
||||
assert stats["unique_categories"] >= 2 # At least 2 different categories
|
||||
assert stats["unique_marketplaces"] >= 3 # Letzshop, Amazon, eBay
|
||||
assert stats["unique_shops"] >= 3 # At least 3 different shops
|
||||
assert stats["unique_vendors"] >= 3 # At least 3 different vendors
|
||||
|
||||
def test_get_comprehensive_stats_handles_nulls(self, db):
|
||||
"""Test comprehensive stats handles null/empty values correctly"""
|
||||
@@ -87,7 +87,7 @@ class TestStatsService:
|
||||
brand=None, # Null brand
|
||||
google_product_category=None, # Null category
|
||||
marketplace=None, # Null marketplace
|
||||
shop_name=None, # Null shop
|
||||
vendor_name=None, # Null vendor
|
||||
price="10.00",
|
||||
currency="EUR",
|
||||
),
|
||||
@@ -97,7 +97,7 @@ class TestStatsService:
|
||||
brand="", # Empty brand
|
||||
google_product_category="", # Empty category
|
||||
marketplace="", # Empty marketplace
|
||||
shop_name="", # Empty shop
|
||||
vendor_name="", # Empty vendor
|
||||
price="15.00",
|
||||
currency="EUR",
|
||||
),
|
||||
@@ -109,11 +109,11 @@ class TestStatsService:
|
||||
|
||||
# These products shouldn't contribute to unique counts due to null/empty values
|
||||
assert stats["total_products"] >= 2
|
||||
# Brands, categories, marketplaces, shops should not count null/empty values
|
||||
# Brands, categories, marketplaces, vendors should not count null/empty values
|
||||
assert isinstance(stats["unique_brands"], int)
|
||||
assert isinstance(stats["unique_categories"], int)
|
||||
assert isinstance(stats["unique_marketplaces"], int)
|
||||
assert isinstance(stats["unique_shops"], int)
|
||||
assert isinstance(stats["unique_vendors"], int)
|
||||
|
||||
def test_get_marketplace_breakdown_stats_basic(self, db, test_marketplace_product):
|
||||
"""Test getting marketplace breakdown stats with basic data"""
|
||||
@@ -129,7 +129,7 @@ class TestStatsService:
|
||||
)
|
||||
assert test_marketplace_stat is not None
|
||||
assert test_marketplace_stat["total_products"] >= 1
|
||||
assert test_marketplace_stat["unique_shops"] >= 1
|
||||
assert test_marketplace_stat["unique_vendors"] >= 1
|
||||
assert test_marketplace_stat["unique_brands"] >= 1
|
||||
|
||||
def test_get_marketplace_breakdown_stats_multiple_marketplaces(
|
||||
@@ -143,7 +143,7 @@ class TestStatsService:
|
||||
title="Amazon MarketplaceProduct 1",
|
||||
brand="AmazonBrand1",
|
||||
marketplace="Amazon",
|
||||
shop_name="AmazonShop1",
|
||||
vendor_name="AmazonShop1",
|
||||
price="20.00",
|
||||
currency="EUR",
|
||||
),
|
||||
@@ -152,7 +152,7 @@ class TestStatsService:
|
||||
title="Amazon MarketplaceProduct 2",
|
||||
brand="AmazonBrand2",
|
||||
marketplace="Amazon",
|
||||
shop_name="AmazonShop2",
|
||||
vendor_name="AmazonShop2",
|
||||
price="25.00",
|
||||
currency="EUR",
|
||||
),
|
||||
@@ -161,7 +161,7 @@ class TestStatsService:
|
||||
title="eBay MarketplaceProduct",
|
||||
brand="eBayBrand",
|
||||
marketplace="eBay",
|
||||
shop_name="eBayShop",
|
||||
vendor_name="eBayShop",
|
||||
price="30.00",
|
||||
currency="USD",
|
||||
),
|
||||
@@ -180,13 +180,13 @@ class TestStatsService:
|
||||
# Check Amazon stats specifically
|
||||
amazon_stat = next(stat for stat in stats if stat["marketplace"] == "Amazon")
|
||||
assert amazon_stat["total_products"] == 2
|
||||
assert amazon_stat["unique_shops"] == 2
|
||||
assert amazon_stat["unique_vendors"] == 2
|
||||
assert amazon_stat["unique_brands"] == 2
|
||||
|
||||
# Check eBay stats specifically
|
||||
ebay_stat = next(stat for stat in stats if stat["marketplace"] == "eBay")
|
||||
assert ebay_stat["total_products"] == 1
|
||||
assert ebay_stat["unique_shops"] == 1
|
||||
assert ebay_stat["unique_vendors"] == 1
|
||||
assert ebay_stat["unique_brands"] == 1
|
||||
|
||||
def test_get_marketplace_breakdown_stats_excludes_nulls(self, db):
|
||||
@@ -196,7 +196,7 @@ class TestStatsService:
|
||||
marketplace_product_id="NULLMARKET001",
|
||||
title="MarketplaceProduct without marketplace",
|
||||
marketplace=None,
|
||||
shop_name="SomeShop",
|
||||
vendor_name="SomeShop",
|
||||
brand="SomeBrand",
|
||||
price="10.00",
|
||||
currency="EUR",
|
||||
@@ -228,7 +228,7 @@ class TestStatsService:
|
||||
title="Brand MarketplaceProduct 1",
|
||||
brand="BrandA",
|
||||
marketplace="Test",
|
||||
shop_name="TestShop",
|
||||
vendor_name="TestVendor",
|
||||
price="10.00",
|
||||
currency="EUR",
|
||||
),
|
||||
@@ -237,7 +237,7 @@ class TestStatsService:
|
||||
title="Brand MarketplaceProduct 2",
|
||||
brand="BrandB",
|
||||
marketplace="Test",
|
||||
shop_name="TestShop",
|
||||
vendor_name="TestVendor",
|
||||
price="15.00",
|
||||
currency="EUR",
|
||||
),
|
||||
@@ -261,7 +261,7 @@ class TestStatsService:
|
||||
title="Category MarketplaceProduct 1",
|
||||
google_product_category="Electronics",
|
||||
marketplace="Test",
|
||||
shop_name="TestShop",
|
||||
vendor_name="TestVendor",
|
||||
price="10.00",
|
||||
currency="EUR",
|
||||
),
|
||||
@@ -270,7 +270,7 @@ class TestStatsService:
|
||||
title="Category MarketplaceProduct 2",
|
||||
google_product_category="Books",
|
||||
marketplace="Test",
|
||||
shop_name="TestShop",
|
||||
vendor_name="TestVendor",
|
||||
price="15.00",
|
||||
currency="EUR",
|
||||
),
|
||||
@@ -291,7 +291,7 @@ class TestStatsService:
|
||||
marketplace_product_id="MARKET001",
|
||||
title="Marketplace MarketplaceProduct 1",
|
||||
marketplace="Amazon",
|
||||
shop_name="AmazonShop",
|
||||
vendor_name="AmazonShop",
|
||||
price="10.00",
|
||||
currency="EUR",
|
||||
),
|
||||
@@ -299,7 +299,7 @@ class TestStatsService:
|
||||
marketplace_product_id="MARKET002",
|
||||
title="Marketplace MarketplaceProduct 2",
|
||||
marketplace="eBay",
|
||||
shop_name="eBayShop",
|
||||
vendor_name="eBayShop",
|
||||
price="15.00",
|
||||
currency="EUR",
|
||||
),
|
||||
@@ -312,23 +312,23 @@ class TestStatsService:
|
||||
assert count >= 2 # At least Amazon and eBay, plus test_marketplace_product marketplace
|
||||
assert isinstance(count, int)
|
||||
|
||||
def test_get_unique_shops_count(self, db, test_marketplace_product):
|
||||
"""Test getting unique shops count"""
|
||||
# Add products with different shop names
|
||||
def test_get_unique_vendors_count(self, db, test_marketplace_product):
|
||||
"""Test getting unique vendors count"""
|
||||
# Add products with different vendor names
|
||||
products = [
|
||||
MarketplaceProduct(
|
||||
marketplace_product_id="SHOP001",
|
||||
title="Shop MarketplaceProduct 1",
|
||||
title="Vendor MarketplaceProduct 1",
|
||||
marketplace="Test",
|
||||
shop_name="ShopA",
|
||||
vendor_name="ShopA",
|
||||
price="10.00",
|
||||
currency="EUR",
|
||||
),
|
||||
MarketplaceProduct(
|
||||
marketplace_product_id="SHOP002",
|
||||
title="Shop MarketplaceProduct 2",
|
||||
title="Vendor MarketplaceProduct 2",
|
||||
marketplace="Test",
|
||||
shop_name="ShopB",
|
||||
vendor_name="ShopB",
|
||||
price="15.00",
|
||||
currency="EUR",
|
||||
),
|
||||
@@ -336,9 +336,9 @@ class TestStatsService:
|
||||
db.add_all(products)
|
||||
db.commit()
|
||||
|
||||
count = self.service._get_unique_shops_count(db)
|
||||
count = self.service._get_unique_vendors_count(db)
|
||||
|
||||
assert count >= 2 # At least ShopA and ShopB, plus test_marketplace_product shop
|
||||
assert count >= 2 # At least ShopA and ShopB, plus test_marketplace_product vendor
|
||||
assert isinstance(count, int)
|
||||
|
||||
def test_get_stock_statistics(self, db, test_stock):
|
||||
@@ -350,14 +350,14 @@ class TestStatsService:
|
||||
location="LOCATION2",
|
||||
quantity=25,
|
||||
reserved_quantity=5,
|
||||
shop_id=test_stock.shop_id,
|
||||
vendor_id=test_stock.vendor_id,
|
||||
),
|
||||
Stock(
|
||||
gtin="1234567890125",
|
||||
location="LOCATION3",
|
||||
quantity=0, # Out of stock
|
||||
reserved_quantity=0,
|
||||
shop_id=test_stock.shop_id,
|
||||
vendor_id=test_stock.vendor_id,
|
||||
),
|
||||
]
|
||||
db.add_all(additional_stocks)
|
||||
@@ -379,7 +379,7 @@ class TestStatsService:
|
||||
title="Specific MarketplaceProduct 1",
|
||||
brand="SpecificBrand1",
|
||||
marketplace="SpecificMarket",
|
||||
shop_name="SpecificShop1",
|
||||
vendor_name="SpecificShop1",
|
||||
price="10.00",
|
||||
currency="EUR",
|
||||
),
|
||||
@@ -388,7 +388,7 @@ class TestStatsService:
|
||||
title="Specific MarketplaceProduct 2",
|
||||
brand="SpecificBrand2",
|
||||
marketplace="SpecificMarket",
|
||||
shop_name="SpecificShop2",
|
||||
vendor_name="SpecificShop2",
|
||||
price="15.00",
|
||||
currency="EUR",
|
||||
),
|
||||
@@ -397,7 +397,7 @@ class TestStatsService:
|
||||
title="Other MarketplaceProduct",
|
||||
brand="OtherBrand",
|
||||
marketplace="OtherMarket",
|
||||
shop_name="OtherShop",
|
||||
vendor_name="OtherShop",
|
||||
price="20.00",
|
||||
currency="EUR",
|
||||
),
|
||||
@@ -412,25 +412,25 @@ class TestStatsService:
|
||||
assert "SpecificBrand2" in brands
|
||||
assert "OtherBrand" not in brands
|
||||
|
||||
def test_get_shops_by_marketplace(self, db):
|
||||
"""Test getting shops for a specific marketplace"""
|
||||
def test_get_vendors_by_marketplace(self, db):
|
||||
"""Test getting vendors for a specific marketplace"""
|
||||
# Create products for specific marketplace
|
||||
marketplace_products = [
|
||||
MarketplaceProduct(
|
||||
marketplace_product_id="SHOPTEST001",
|
||||
title="Shop Test MarketplaceProduct 1",
|
||||
title="Vendor Test MarketplaceProduct 1",
|
||||
brand="TestBrand",
|
||||
marketplace="TestMarketplace",
|
||||
shop_name="TestShop1",
|
||||
vendor_name="TestVendor1",
|
||||
price="10.00",
|
||||
currency="EUR",
|
||||
),
|
||||
MarketplaceProduct(
|
||||
marketplace_product_id="SHOPTEST002",
|
||||
title="Shop Test MarketplaceProduct 2",
|
||||
title="Vendor Test MarketplaceProduct 2",
|
||||
brand="TestBrand",
|
||||
marketplace="TestMarketplace",
|
||||
shop_name="TestShop2",
|
||||
vendor_name="TestVendor2",
|
||||
price="15.00",
|
||||
currency="EUR",
|
||||
),
|
||||
@@ -438,11 +438,11 @@ class TestStatsService:
|
||||
db.add_all(marketplace_products)
|
||||
db.commit()
|
||||
|
||||
shops = self.service._get_shops_by_marketplace(db, "TestMarketplace")
|
||||
vendors =self.service._get_vendors_by_marketplace(db, "TestMarketplace")
|
||||
|
||||
assert len(shops) == 2
|
||||
assert "TestShop1" in shops
|
||||
assert "TestShop2" in shops
|
||||
assert len(vendors) == 2
|
||||
assert "TestVendor1" in vendors
|
||||
assert "TestVendor2" in vendors
|
||||
|
||||
def test_get_products_by_marketplace(self, db):
|
||||
"""Test getting product count for a specific marketplace"""
|
||||
@@ -452,7 +452,7 @@ class TestStatsService:
|
||||
marketplace_product_id="COUNT001",
|
||||
title="Count MarketplaceProduct 1",
|
||||
marketplace="CountMarketplace",
|
||||
shop_name="CountShop",
|
||||
vendor_name="CountShop",
|
||||
price="10.00",
|
||||
currency="EUR",
|
||||
),
|
||||
@@ -460,7 +460,7 @@ class TestStatsService:
|
||||
marketplace_product_id="COUNT002",
|
||||
title="Count MarketplaceProduct 2",
|
||||
marketplace="CountMarketplace",
|
||||
shop_name="CountShop",
|
||||
vendor_name="CountShop",
|
||||
price="15.00",
|
||||
currency="EUR",
|
||||
),
|
||||
@@ -468,7 +468,7 @@ class TestStatsService:
|
||||
marketplace_product_id="COUNT003",
|
||||
title="Count MarketplaceProduct 3",
|
||||
marketplace="CountMarketplace",
|
||||
shop_name="CountShop",
|
||||
vendor_name="CountShop",
|
||||
price="20.00",
|
||||
currency="EUR",
|
||||
),
|
||||
@@ -494,7 +494,7 @@ class TestStatsService:
|
||||
assert stats["unique_brands"] == 0
|
||||
assert stats["unique_categories"] == 0
|
||||
assert stats["unique_marketplaces"] == 0
|
||||
assert stats["unique_shops"] == 0
|
||||
assert stats["unique_vendors"] == 0
|
||||
assert stats["total_stock_entries"] == 0
|
||||
assert stats["total_inventory_quantity"] == 0
|
||||
|
||||
|
||||
365
tests/unit/services/test_vendor_service.py
Normal file
365
tests/unit/services/test_vendor_service.py
Normal 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
|
||||
@@ -116,13 +116,13 @@ TEST002,Test MarketplaceProduct 2,15.99,TestMarket"""
|
||||
"title": ["MarketplaceProduct 1", "MarketplaceProduct 2"],
|
||||
"price": ["10.99", "15.99"],
|
||||
"marketplace": ["TestMarket", "TestMarket"],
|
||||
"shop_name": ["TestShop", "TestShop"],
|
||||
"vendor_name": ["TestVendor", "TestVendor"],
|
||||
}
|
||||
)
|
||||
mock_parse.return_value = mock_df
|
||||
|
||||
result = await self.processor.process_marketplace_csv_from_url(
|
||||
"http://example.com/test.csv", "TestMarket", "TestShop", 1000, db
|
||||
"http://example.com/test.csv", "TestMarket", "TestVendor", 1000, db
|
||||
)
|
||||
|
||||
assert "imported" in result
|
||||
|
||||
Reference in New Issue
Block a user