From 0114b6c46e50b60c24e9124a9965ccdbeeec64f7 Mon Sep 17 00:00:00 2001 From: Samir Boulahtit Date: Sat, 4 Oct 2025 23:38:53 +0200 Subject: [PATCH] shop product refactoring --- alembic/env.py | 6 +- .../dbe48f596a44_initial_complete_schema.py | 80 ++-- app/api/deps.py | 22 +- app/api/main.py | 4 +- app/api/v1/admin.py | 46 +-- app/api/v1/marketplace.py | 28 +- app/api/v1/shop.py | 137 ------ app/api/v1/stats.py | 6 +- app/api/v1/vendor.py | 137 ++++++ app/core/config.py | 4 +- app/exceptions/__init__.py | 42 +- app/exceptions/admin.py | 12 +- app/exceptions/base.py | 8 +- app/exceptions/handler.py | 2 +- app/exceptions/marketplace_import_job.py | 6 +- app/exceptions/product.py | 22 +- app/exceptions/shop.py | 131 ------ app/exceptions/vendor.py | 131 ++++++ app/services/admin_service.py | 148 +++---- .../marketplace_import_job_service.py | 76 ++-- app/services/marketplace_product_service.py | 26 +- app/services/shop_service.py | 359 ---------------- app/services/stats_service.py | 38 +- app/services/vendor_service.py | 359 ++++++++++++++++ app/tasks/background_tasks.py | 4 +- app/utils/csv_processor.py | 24 +- main.py | 2 +- models/__init__.py | 4 +- models/database/__init__.py | 4 +- models/database/marketplace_import_job.py | 14 +- models/database/marketplace_product.py | 8 +- models/database/product.py | 16 +- models/database/stock.py | 4 +- models/database/user.py | 4 +- models/database/{shop.py => vendor.py} | 19 +- models/schemas/__init__.py | 11 +- models/schemas/auth.py | 6 + models/schemas/marketplace_import_job.py | 10 +- models/schemas/marketplace_product.py | 2 +- models/schemas/product.py | 4 +- models/schemas/stats.py | 4 +- models/schemas/{shop.py => vendor.py} | 24 +- pytest.ini | 2 +- scripts/verify_setup.py | 6 +- tests/conftest.py | 4 +- .../marketplace_import_job_fixtures.py | 12 +- .../fixtures/marketplace_product_fixtures.py | 8 +- tests/fixtures/testing_fixtures.py | 6 +- .../{shop_fixtures.py => vendor_fixtures.py} | 112 ++--- .../api/v1/test_admin_endpoints.py | 78 ++-- .../test_marketplace_import_job_endpoints.py | 76 ++-- .../api/v1/test_marketplace_product_export.py | 22 +- tests/integration/api/v1/test_pagination.py | 28 +- .../integration/api/v1/test_shop_endpoints.py | 389 ------------------ .../api/v1/test_stats_endpoints.py | 2 +- .../api/v1/test_vendor_endpoints.py | 389 ++++++++++++++++++ .../security/test_authentication.py | 4 +- .../security/test_authorization.py | 16 +- .../tasks/test_background_tasks.py | 18 +- .../integration/workflows/test_integration.py | 26 +- tests/system/test_error_handling.py | 126 +++--- tests/unit/models/test_database_models.py | 28 +- tests/unit/services/test_admin_service.py | 156 +++---- .../unit/services/test_marketplace_service.py | 134 +++--- tests/unit/services/test_shop_service.py | 365 ---------------- tests/unit/services/test_stats_service.py | 100 ++--- tests/unit/services/test_vendor_service.py | 365 ++++++++++++++++ tests/unit/utils/test_csv_processor.py | 4 +- 68 files changed, 2234 insertions(+), 2236 deletions(-) delete mode 100644 app/api/v1/shop.py create mode 100644 app/api/v1/vendor.py delete mode 100644 app/exceptions/shop.py create mode 100644 app/exceptions/vendor.py delete mode 100644 app/services/shop_service.py create mode 100644 app/services/vendor_service.py rename models/database/{shop.py => vendor.py} (63%) rename models/schemas/{shop.py => vendor.py} (74%) rename tests/fixtures/{shop_fixtures.py => vendor_fixtures.py} (50%) delete mode 100644 tests/integration/api/v1/test_shop_endpoints.py create mode 100644 tests/integration/api/v1/test_vendor_endpoints.py delete mode 100644 tests/unit/services/test_shop_service.py create mode 100644 tests/unit/services/test_vendor_service.py diff --git a/alembic/env.py b/alembic/env.py index a35d43ef..fbf03e2d 100644 --- a/alembic/env.py +++ b/alembic/env.py @@ -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 diff --git a/alembic/versions/dbe48f596a44_initial_complete_schema.py b/alembic/versions/dbe48f596a44_initial_complete_schema.py index d95cf015..8c8260df 100644 --- a/alembic/versions/dbe48f596a44_initial_complete_schema.py +++ b/alembic/versions/dbe48f596a44_initial_complete_schema.py @@ -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') diff --git a/app/api/deps.py b/app/api/deps.py index 8b0f9935..3bda7026 100644 --- a/app/api/deps.py +++ b/app/api/deps.py @@ -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 diff --git a/app/api/main.py b/app/api/main.py index 31d9bed2..baab37e0 100644 --- a/app/api/main.py +++ b/app/api/main.py @@ -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"]) diff --git a/app/api/v1/admin.py b/app/api/v1/admin.py index 67cef1be..2fdeffc4 100644 --- a/app/api/v1/admin.py +++ b/app/api/v1/admin.py @@ -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) diff --git a/app/api/v1/marketplace.py b/app/api/v1/marketplace.py index 90b879ae..48764d07 100644 --- a/app/api/v1/marketplace.py +++ b/app/api/v1/marketplace.py @@ -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, ) diff --git a/app/api/v1/shop.py b/app/api/v1/shop.py deleted file mode 100644 index ae904505..00000000 --- a/app/api/v1/shop.py +++ /dev/null @@ -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), - } diff --git a/app/api/v1/stats.py b/app/api/v1/stats.py index e3709c4b..9aa05ea2 100644 --- a/app/api/v1/stats.py +++ b/app/api/v1/stats.py @@ -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 diff --git a/app/api/v1/vendor.py b/app/api/v1/vendor.py new file mode 100644 index 00000000..81e8336b --- /dev/null +++ b/app/api/v1/vendor.py @@ -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), + } diff --git a/app/core/config.py b/app/core/config.py index 8067a686..adf418ba 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -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 diff --git a/app/exceptions/__init__.py b/app/exceptions/__init__.py index ea25d9d8..dd74f997 100644 --- a/app/exceptions/__init__.py +++ b/app/exceptions/__init__.py @@ -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", diff --git a/app/exceptions/admin.py b/app/exceptions/admin.py index 4f113e15..2f9ab138 100644 --- a/app/exceptions/admin.py +++ b/app/exceptions/admin.py @@ -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, ) diff --git a/app/exceptions/base.py b/app/exceptions/base.py index 050c810f..f361788c 100644 --- a/app/exceptions/base.py +++ b/app/exceptions/base.py @@ -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. diff --git a/app/exceptions/handler.py b/app/exceptions/handler.py index 1f171b44..b8bc2142 100644 --- a/app/exceptions/handler.py +++ b/app/exceptions/handler.py @@ -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}: " diff --git a/app/exceptions/marketplace_import_job.py b/app/exceptions/marketplace_import_job.py index 1773ce6b..c383579e 100644 --- a/app/exceptions/marketplace_import_job.py +++ b/app/exceptions/marketplace_import_job.py @@ -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, }, ) diff --git a/app/exceptions/product.py b/app/exceptions/product.py index 8086f428..2e782f31 100644 --- a/app/exceptions/product.py +++ b/app/exceptions/product.py @@ -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", ) diff --git a/app/exceptions/shop.py b/app/exceptions/shop.py deleted file mode 100644 index d2015e9b..00000000 --- a/app/exceptions/shop.py +++ /dev/null @@ -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" diff --git a/app/exceptions/vendor.py b/app/exceptions/vendor.py new file mode 100644 index 00000000..0b7848e1 --- /dev/null +++ b/app/exceptions/vendor.py @@ -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" diff --git a/app/services/admin_service.py b/app/services/admin_service.py index 27ca8a82..262cc589 100644 --- a/app/services/admin_service.py +++ b/app/services/admin_service.py @@ -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, diff --git a/app/services/marketplace_import_job_service.py b/app/services/marketplace_import_job_service.py index 2f56abbf..76cdded4 100644 --- a/app/services/marketplace_import_job_service.py +++ b/app/services/marketplace_import_job_service.py @@ -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, diff --git a/app/services/marketplace_product_service.py b/app/services/marketplace_product_service.py index 628decac..db4ebbbc 100644 --- a/app/services/marketplace_product_service.py +++ b/app/services/marketplace_product_service.py @@ -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() diff --git a/app/services/shop_service.py b/app/services/shop_service.py deleted file mode 100644 index 8636e303..00000000 --- a/app/services/shop_service.py +++ /dev/null @@ -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() diff --git a/app/services/stats_service.py b/app/services/stats_service.py index 5c577414..eeb607bf 100644 --- a/app/services/stats_service.py +++ b/app/services/stats_service.py @@ -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.""" diff --git a/app/services/vendor_service.py b/app/services/vendor_service.py new file mode 100644 index 00000000..55ea25a9 --- /dev/null +++ b/app/services/vendor_service.py @@ -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() diff --git a/app/tasks/background_tasks.py b/app/tasks/background_tasks.py index 703e3da2..c44322ea 100644 --- a/app/tasks/background_tasks.py +++ b/app/tasks/background_tasks.py @@ -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 diff --git a/app/utils/csv_processor.py b/app/utils/csv_processor.py index f37e6f75..4f705f70 100644 --- a/app/utils/csv_processor.py +++ b/app/utils/csv_processor.py @@ -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: diff --git a/main.py b/main.py index 5429f4cf..9357bf67 100644 --- a/main.py +++ b/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": [ diff --git a/models/__init__.py b/models/__init__.py index c4688d89..36e7645e 100644 --- a/models/__init__.py +++ b/models/__init__.py @@ -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 diff --git a/models/database/__init__.py b/models/database/__init__.py index bb506941..3a5b1698 100644 --- a/models/database/__init__.py +++ b/models/database/__init__.py @@ -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", ] diff --git a/models/database/marketplace_import_job.py b/models/database/marketplace_import_job.py index 874851e3..aa398c40 100644 --- a/models/database/marketplace_import_job.py +++ b/models/database/marketplace_import_job.py @@ -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"" ) diff --git a/models/database/marketplace_product.py b/models/database/marketplace_product.py index 0b7e24ad..19605ab6 100644 --- a/models/database/marketplace_product.py +++ b/models/database/marketplace_product.py @@ -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"" + f"vendor='{self.vendor_name}')>" ) diff --git a/models/database/product.py b/models/database/product.py index a2bf8ab1..54626d06 100644 --- a/models/database/product.py +++ b/models/database/product.py @@ -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"), ) diff --git a/models/database/stock.py b/models/database/stock.py index e9a15d0f..177354da 100644 --- a/models/database/stock.py +++ b/models/database/stock.py @@ -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__ = ( diff --git a/models/database/user.py b/models/database/user.py index 8e2f528e..1ae33986 100644 --- a/models/database/user.py +++ b/models/database/user.py @@ -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"" diff --git a/models/database/shop.py b/models/database/vendor.py similarity index 63% rename from models/database/shop.py rename to models/database/vendor.py index c75629aa..fdfb280e 100644 --- a/models/database/shop.py +++ b/models/database/vendor.py @@ -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" ) diff --git a/models/schemas/__init__.py b/models/schemas/__init__.py index 200c7064..d60f47f7 100644 --- a/models/schemas/__init__.py +++ b/models/schemas/__init__.py @@ -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", ] diff --git a/models/schemas/auth.py b/models/schemas/auth.py index f06ef49b..6c02d880 100644 --- a/models/schemas/auth.py +++ b/models/schemas/auth.py @@ -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" diff --git a/models/schemas/marketplace_import_job.py b/models/schemas/marketplace_import_job.py index 5bb73ff6..ac4a6bac 100644 --- a/models/schemas/marketplace_import_job.py +++ b/models/schemas/marketplace_import_job.py @@ -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 diff --git a/models/schemas/marketplace_product.py b/models/schemas/marketplace_product.py index 40331444..f50bc257 100644 --- a/models/schemas/marketplace_product.py +++ b/models/schemas/marketplace_product.py @@ -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") diff --git a/models/schemas/product.py b/models/schemas/product.py index 1e6b52ee..c42e5d28 100644 --- a/models/schemas/product.py +++ b/models/schemas/product.py @@ -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] diff --git a/models/schemas/stats.py b/models/schemas/stats.py index 5aa7c478..eca319e0 100644 --- a/models/schemas/stats.py +++ b/models/schemas/stats.py @@ -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 diff --git a/models/schemas/shop.py b/models/schemas/vendor.py similarity index 74% rename from models/schemas/shop.py rename to models/schemas/vendor.py index c5651eb4..13f83fca 100644 --- a/models/schemas/shop.py +++ b/models/schemas/vendor.py @@ -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 diff --git a/pytest.ini b/pytest.ini index a3864354..85055ffb 100644 --- a/pytest.ini +++ b/pytest.ini @@ -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 diff --git a/scripts/verify_setup.py b/scripts/verify_setup.py index b9c12fbd..195d9e77 100644 --- a/scripts/verify_setup.py +++ b/scripts/verify_setup.py @@ -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}') diff --git a/tests/conftest.py b/tests/conftest.py index 8381cf8d..cd6030a4 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -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", ] diff --git a/tests/fixtures/marketplace_import_job_fixtures.py b/tests/fixtures/marketplace_import_job_fixtures.py index e59ea1f7..4f03ac4f 100644 --- a/tests/fixtures/marketplace_import_job_fixtures.py +++ b/tests/fixtures/marketplace_import_job_fixtures.py @@ -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, diff --git a/tests/fixtures/marketplace_product_fixtures.py b/tests/fixtures/marketplace_product_fixtures.py index 5f468e50..2564f92f 100644 --- a/tests/fixtures/marketplace_product_fixtures.py +++ b/tests/fixtures/marketplace_product_fixtures.py @@ -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) diff --git a/tests/fixtures/testing_fixtures.py b/tests/fixtures/testing_fixtures.py index ee1524ae..d6bb9a76 100644 --- a/tests/fixtures/testing_fixtures.py +++ b/tests/fixtures/testing_fixtures.py @@ -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 ] diff --git a/tests/fixtures/shop_fixtures.py b/tests/fixtures/vendor_fixtures.py similarity index 50% rename from tests/fixtures/shop_fixtures.py rename to tests/fixtures/vendor_fixtures.py index ab7ba911..74b74c84 100644 --- a/tests/fixtures/shop_fixtures.py +++ b/tests/fixtures/vendor_fixtures.py @@ -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() diff --git a/tests/integration/api/v1/test_admin_endpoints.py b/tests/integration/api/v1/test_admin_endpoints.py index e83cb753..61b940a1 100644 --- a/tests/integration/api/v1/test_admin_endpoints.py +++ b/tests/integration/api/v1/test_admin_endpoints.py @@ -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 diff --git a/tests/integration/api/v1/test_marketplace_import_job_endpoints.py b/tests/integration/api/v1/test_marketplace_import_job_endpoints.py index 8c92b333..1fe743d9 100644 --- a/tests/integration/api/v1/test_marketplace_import_job_endpoints.py +++ b/tests/integration/api/v1/test_marketplace_import_job_endpoints.py @@ -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( diff --git a/tests/integration/api/v1/test_marketplace_product_export.py b/tests/integration/api/v1/test_marketplace_product_export.py index 49a9ee17..8e5c86d9 100644 --- a/tests/integration/api/v1/test_marketplace_product_export.py +++ b/tests/integration/api/v1/test_marketplace_product_export.py @@ -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""" diff --git a/tests/integration/api/v1/test_pagination.py b/tests/integration/api/v1/test_pagination.py index de735099..3ae9db5e 100644 --- a/tests/integration/api/v1/test_pagination.py +++ b/tests/integration/api/v1/test_pagination.py @@ -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 diff --git a/tests/integration/api/v1/test_shop_endpoints.py b/tests/integration/api/v1/test_shop_endpoints.py deleted file mode 100644 index 83db1d3c..00000000 --- a/tests/integration/api/v1/test_shop_endpoints.py +++ /dev/null @@ -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) diff --git a/tests/integration/api/v1/test_stats_endpoints.py b/tests/integration/api/v1/test_stats_endpoints.py index 25cba339..eade22f2 100644 --- a/tests/integration/api/v1/test_stats_endpoints.py +++ b/tests/integration/api/v1/test_stats_endpoints.py @@ -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): diff --git a/tests/integration/api/v1/test_vendor_endpoints.py b/tests/integration/api/v1/test_vendor_endpoints.py new file mode 100644 index 00000000..23e69609 --- /dev/null +++ b/tests/integration/api/v1/test_vendor_endpoints.py @@ -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) diff --git a/tests/integration/security/test_authentication.py b/tests/integration/security/test_authentication.py index 6ae0cd3e..01836bbe 100644 --- a/tests/integration/security/test_authentication.py +++ b/tests/integration/security/test_authentication.py @@ -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", ] diff --git a/tests/integration/security/test_authorization.py b/tests/integration/security/test_authorization.py index 4d0cedb2..6b710be5 100644 --- a/tests/integration/security/test_authorization.py +++ b/tests/integration/security/test_authorization.py @@ -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] diff --git a/tests/integration/tasks/test_background_tasks.py b/tests/integration/tasks/test_background_tasks.py index 37fe0547..55f0fd70 100644 --- a/tests/integration/tasks/test_background_tasks.py +++ b/tests/integration/tasks/test_background_tasks.py @@ -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) diff --git a/tests/integration/workflows/test_integration.py b/tests/integration/workflows/test_integration.py index a8206726..95453117 100644 --- a/tests/integration/workflows/test_integration.py +++ b/tests/integration/workflows/test_integration.py @@ -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): diff --git a/tests/system/test_error_handling.py b/tests/system/test_error_handling.py index 8cb07bf9..9b9f1b32 100644 --- a/tests/system/test_error_handling.py +++ b/tests/system/test_error_handling.py @@ -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="TEST" + content="TEST" ) 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 diff --git a/tests/unit/models/test_database_models.py b/tests/unit/models/test_database_models.py index 13e77d71..2970b7c3 100644 --- a/tests/unit/models/test_database_models.py +++ b/tests/unit/models/test_database_models.py @@ -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""" diff --git a/tests/unit/services/test_admin_service.py b/tests/unit/services/test_admin_service.py index ebaa6861..89eba5b6 100644 --- a/tests/unit/services/test_admin_service.py +++ b/tests/unit/services/test_admin_service.py @@ -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 diff --git a/tests/unit/services/test_marketplace_service.py b/tests/unit/services/test_marketplace_service.py index 16c9fb2d..a83df00f 100644 --- a/tests/unit/services/test_marketplace_service.py +++ b/tests/unit/services/test_marketplace_service.py @@ -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, ) diff --git a/tests/unit/services/test_shop_service.py b/tests/unit/services/test_shop_service.py deleted file mode 100644 index 9df0bf8d..00000000 --- a/tests/unit/services/test_shop_service.py +++ /dev/null @@ -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 diff --git a/tests/unit/services/test_stats_service.py b/tests/unit/services/test_stats_service.py index 230eab3e..6e802edb 100644 --- a/tests/unit/services/test_stats_service.py +++ b/tests/unit/services/test_stats_service.py @@ -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 diff --git a/tests/unit/services/test_vendor_service.py b/tests/unit/services/test_vendor_service.py new file mode 100644 index 00000000..36f6a2b7 --- /dev/null +++ b/tests/unit/services/test_vendor_service.py @@ -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 diff --git a/tests/unit/utils/test_csv_processor.py b/tests/unit/utils/test_csv_processor.py index 5dc728e9..dd247c48 100644 --- a/tests/unit/utils/test_csv_processor.py +++ b/tests/unit/utils/test_csv_processor.py @@ -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