From cea88a46c5bc349f2182c9290cb9ff4269fff08c Mon Sep 17 00:00:00 2001 From: Samir Boulahtit Date: Wed, 24 Sep 2025 22:28:44 +0200 Subject: [PATCH] Marketplace tests update --- app/api/v1/product.py | 2 +- app/exceptions/handler.py | 20 +- app/services/admin_service.py | 10 +- app/services/marketplace_service.py | 6 +- app/services/product_service.py | 4 +- app/services/stock_service.py | 10 +- app/tasks/background_tasks.py | 8 +- app/utils/csv_processor.py | 4 +- main.py | 4 +- middleware/auth.py | 8 +- middleware/rate_limiter.py | 10 +- tests/fixtures/shop_fixtures.py | 6 +- .../integration/api/v1/test_auth_endpoints.py | 7 +- tests/integration/api/v1/test_export.py | 6 +- .../api/v1/test_marketplace_endpoints.py | 386 +++++++++++++++++- .../unit/services/test_marketplace_service.py | 195 +++++++-- 16 files changed, 613 insertions(+), 73 deletions(-) diff --git a/app/api/v1/product.py b/app/api/v1/product.py index 034d55f7..f24feddf 100644 --- a/app/api/v1/product.py +++ b/app/api/v1/product.py @@ -113,7 +113,7 @@ def delete_product( return {"message": "Product and associated stock deleted successfully"} -@router.get("/export-csv") +@router.get("/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"), diff --git a/app/exceptions/handler.py b/app/exceptions/handler.py index 7a6ca7ad..4077857b 100644 --- a/app/exceptions/handler.py +++ b/app/exceptions/handler.py @@ -85,6 +85,24 @@ def setup_exception_handlers(app): } ) + # Clean up validation errors to ensure JSON serializability + clean_errors = [] + for error in exc.errors(): + clean_error = {} + for key, value in error.items(): + if key == 'ctx' and isinstance(value, dict): + # Handle the 'ctx' field that contains ValueError objects + clean_ctx = {} + for ctx_key, ctx_value in value.items(): + if isinstance(ctx_value, Exception): + clean_ctx[ctx_key] = str(ctx_value) # Convert exception to string + else: + clean_ctx[ctx_key] = ctx_value + clean_error[key] = clean_ctx + else: + clean_error[key] = value + clean_errors.append(clean_error) + return JSONResponse( status_code=422, content={ @@ -92,7 +110,7 @@ def setup_exception_handlers(app): "message": "Request validation failed", "status_code": 422, "details": { - "validation_errors": exc.errors() + "validation_errors": clean_errors # Use cleaned errors } } ) diff --git a/app/services/admin_service.py b/app/services/admin_service.py index 509447b7..f362517d 100644 --- a/app/services/admin_service.py +++ b/app/services/admin_service.py @@ -9,7 +9,7 @@ This module provides classes and functions for: """ import logging -from datetime import datetime +from datetime import datetime, timezone from typing import List, Optional, Tuple from sqlalchemy.orm import Session @@ -81,7 +81,7 @@ class AdminService: try: original_status = user.is_active user.is_active = not user.is_active - user.updated_at = datetime.utcnow() + user.updated_at = datetime.now(timezone.utc) db.commit() db.refresh(user) @@ -146,11 +146,11 @@ class AdminService: try: original_status = shop.is_verified shop.is_verified = not shop.is_verified - shop.updated_at = datetime.utcnow() + shop.updated_at = datetime.now(timezone.utc) # Add verification timestamp if implementing audit trail if shop.is_verified: - shop.verified_at = datetime.utcnow() + shop.verified_at = datetime.now(timezone.utc) db.commit() db.refresh(shop) @@ -190,7 +190,7 @@ class AdminService: try: original_status = shop.is_active shop.is_active = not shop.is_active - shop.updated_at = datetime.utcnow() + shop.updated_at = datetime.now(timezone.utc) db.commit() db.refresh(shop) diff --git a/app/services/marketplace_service.py b/app/services/marketplace_service.py index 636b3e8c..bb331a65 100644 --- a/app/services/marketplace_service.py +++ b/app/services/marketplace_service.py @@ -9,7 +9,7 @@ This module provides classes and functions for: """ import logging -from datetime import datetime +from datetime import datetime, timezone from typing import List, Optional from sqlalchemy import func @@ -106,7 +106,7 @@ class MarketplaceService: shop_id=shop.id, # Foreign key to shops table shop_name=shop.shop_name, # Use shop.shop_name (the display name) user_id=user.id, - created_at=datetime.utcnow(), + created_at=datetime.now(timezone.utc), ) db.add(import_job) @@ -360,7 +360,7 @@ class MarketplaceService: raise ImportJobCannotBeCancelledException(job_id, job.status) job.status = "cancelled" - job.completed_at = datetime.utcnow() + job.completed_at = datetime.now(timezone.utc) db.commit() db.refresh(job) diff --git a/app/services/product_service.py b/app/services/product_service.py index 1b49ea7c..90d316e2 100644 --- a/app/services/product_service.py +++ b/app/services/product_service.py @@ -10,7 +10,7 @@ This module provides classes and functions for: """ import logging -from datetime import datetime +from datetime import datetime, timezone from typing import Generator, List, Optional, Tuple from sqlalchemy.exc import IntegrityError @@ -250,7 +250,7 @@ class ProductService: for key, value in update_data.items(): setattr(product, key, value) - product.updated_at = datetime.utcnow() + product.updated_at = datetime.now(timezone.utc) db.commit() db.refresh(product) diff --git a/app/services/stock_service.py b/app/services/stock_service.py index fe9aa52a..95d0b7ac 100644 --- a/app/services/stock_service.py +++ b/app/services/stock_service.py @@ -10,7 +10,7 @@ This module provides classes and functions for: """ import logging -from datetime import datetime +from datetime import datetime, timezone from typing import List, Optional from sqlalchemy.orm import Session @@ -68,7 +68,7 @@ class StockService: # Update existing stock (SET to exact quantity) old_quantity = existing_stock.quantity existing_stock.quantity = stock_data.quantity - existing_stock.updated_at = datetime.utcnow() + existing_stock.updated_at = datetime.now(timezone.utc) db.commit() db.refresh(existing_stock) @@ -128,7 +128,7 @@ class StockService: # Add to existing stock old_quantity = existing_stock.quantity existing_stock.quantity += stock_data.quantity - existing_stock.updated_at = datetime.utcnow() + existing_stock.updated_at = datetime.now(timezone.utc) db.commit() db.refresh(existing_stock) @@ -207,7 +207,7 @@ class StockService: raise NegativeStockException(normalized_gtin, location, new_quantity) existing_stock.quantity = new_quantity - existing_stock.updated_at = datetime.utcnow() + existing_stock.updated_at = datetime.now(timezone.utc) db.commit() db.refresh(existing_stock) @@ -381,7 +381,7 @@ class StockService: self._validate_quantity(stock_update.quantity, allow_zero=True) stock_entry.quantity = stock_update.quantity - stock_entry.updated_at = datetime.utcnow() + stock_entry.updated_at = datetime.now(timezone.utc) db.commit() db.refresh(stock_entry) diff --git a/app/tasks/background_tasks.py b/app/tasks/background_tasks.py index 7fd779c2..83aeab91 100644 --- a/app/tasks/background_tasks.py +++ b/app/tasks/background_tasks.py @@ -8,7 +8,7 @@ This module provides classes and functions for: """ import logging -from datetime import datetime +from datetime import datetime, timezone from app.core.database import SessionLocal from models.database.marketplace import MarketplaceImportJob @@ -37,7 +37,7 @@ async def process_marketplace_import( return job.status = "processing" - job.started_at = datetime.utcnow() + job.started_at = datetime.now(timezone.utc) db.commit() logger.info(f"Processing import: Job {job_id}, Marketplace: {marketplace}") @@ -49,7 +49,7 @@ async def process_marketplace_import( # Update job with results job.status = "completed" - job.completed_at = datetime.utcnow() + job.completed_at = datetime.now(timezone.utc) job.imported_count = result["imported"] job.updated_count = result["updated"] job.error_count = result.get("errors", 0) @@ -68,7 +68,7 @@ async def process_marketplace_import( try: job.status = "failed" job.error_message = str(e) - job.completed_at = datetime.utcnow() + job.completed_at = datetime.now(timezone.utc) db.commit() except Exception as commit_error: logger.error(f"Failed to update job status: {commit_error}") diff --git a/app/utils/csv_processor.py b/app/utils/csv_processor.py index cd6e01ee..331793e7 100644 --- a/app/utils/csv_processor.py +++ b/app/utils/csv_processor.py @@ -8,7 +8,7 @@ This module provides classes and functions for: """ import logging -from datetime import datetime +from datetime import datetime, timezone from io import StringIO from typing import Any, Dict @@ -290,7 +290,7 @@ class CSVProcessor: existing_product, key ): setattr(existing_product, key, value) - existing_product.updated_at = datetime.utcnow() + existing_product.updated_at = datetime.now(timezone.utc) updated += 1 logger.debug( f"Updated product {product_data['product_id']} for " diff --git a/main.py b/main.py index d1fa1239..5429f4cf 100644 --- a/main.py +++ b/main.py @@ -1,5 +1,5 @@ import logging -from datetime import datetime +from datetime import datetime, timezone from fastapi import Depends, FastAPI, HTTPException from fastapi.middleware.cors import CORSMiddleware @@ -56,7 +56,7 @@ def health_check(db: Session = Depends(get_db)): db.execute(text("SELECT 1")) return { "status": "healthy", - "timestamp": datetime.utcnow(), + "timestamp": datetime.now(timezone.utc), "message": f"{settings.project_name} v{settings.version}", "docs": { "swagger": "/docs", diff --git a/middleware/auth.py b/middleware/auth.py index 29d46d89..5f85c13e 100644 --- a/middleware/auth.py +++ b/middleware/auth.py @@ -9,7 +9,7 @@ This module provides classes and functions for: import logging import os -from datetime import datetime, timedelta +from datetime import datetime, timedelta, timezone from typing import Any, Dict, Optional from fastapi import HTTPException @@ -71,7 +71,7 @@ class AuthManager: def create_access_token(self, user: User) -> Dict[str, Any]: """Create JWT access token for user.""" expires_delta = timedelta(minutes=self.token_expire_minutes) - expire = datetime.utcnow() + expires_delta + expire = datetime.now(timezone.utc) + expires_delta payload = { "sub": str(user.id), @@ -79,7 +79,7 @@ class AuthManager: "email": user.email, "role": user.role, "exp": expire, - "iat": datetime.utcnow(), + "iat": datetime.now(timezone.utc), } token = jwt.encode(payload, self.secret_key, algorithm=self.algorithm) @@ -100,7 +100,7 @@ class AuthManager: if exp is None: raise InvalidTokenException("Token missing expiration") - if datetime.utcnow() > datetime.fromtimestamp(exp): + if datetime.now(timezone.utc) > datetime.fromtimestamp(exp): raise TokenExpiredException() # Extract user data diff --git a/middleware/rate_limiter.py b/middleware/rate_limiter.py index 6a2dfb0f..f9261885 100644 --- a/middleware/rate_limiter.py +++ b/middleware/rate_limiter.py @@ -9,7 +9,7 @@ This module provides classes and functions for: import logging from collections import defaultdict, deque -from datetime import datetime, timedelta +from datetime import datetime, timedelta, timezone from typing import Dict logger = logging.getLogger(__name__) @@ -23,7 +23,7 @@ class RateLimiter: # Dictionary to store request timestamps for each client self.clients: Dict[str, deque] = defaultdict(lambda: deque()) self.cleanup_interval = 3600 # Clean up old entries every hour - self.last_cleanup = datetime.utcnow() + self.last_cleanup = datetime.now(timezone.utc) def allow_request( self, client_id: str, max_requests: int, window_seconds: int @@ -33,7 +33,7 @@ class RateLimiter: Uses sliding window algorithm """ - now = datetime.utcnow() + now = datetime.now(timezone.utc) window_start = now - timedelta(seconds=window_seconds) # Clean up old entries periodically @@ -60,7 +60,7 @@ class RateLimiter: def _cleanup_old_entries(self): """Clean up old entries to prevent memory leaks.""" - cutoff_time = datetime.utcnow() - timedelta(hours=24) + cutoff_time = datetime.now(timezone.utc) - timedelta(hours=24) clients_to_remove = [] for client_id, requests in self.clients.items(): @@ -84,7 +84,7 @@ class RateLimiter: """Get statistics for a specific client.""" client_requests = self.clients.get(client_id, deque()) - now = datetime.utcnow() + now = datetime.now(timezone.utc) hour_ago = now - timedelta(hours=1) day_ago = now - timedelta(days=1) diff --git a/tests/fixtures/shop_fixtures.py b/tests/fixtures/shop_fixtures.py index 0bb492ee..43d775f3 100644 --- a/tests/fixtures/shop_fixtures.py +++ b/tests/fixtures/shop_fixtures.py @@ -10,10 +10,10 @@ from models.database.stock import Stock @pytest.fixture def test_shop(db, test_user): """Create a test shop with unique shop code""" - unique_id = str(uuid.uuid4())[:8] # Short unique identifier + unique_id = str(uuid.uuid4())[:8].upper() # Make unique ID uppercase shop = Shop( - shop_code=f"TESTSHOP_{unique_id}", - shop_name=f"Test Shop {unique_id}", + shop_code=f"TESTSHOP_{unique_id}", # Will be all uppercase + shop_name=f"Test Shop {unique_id.lower()}", # Keep display name readable owner_id=test_user.id, is_active=True, is_verified=True, diff --git a/tests/integration/api/v1/test_auth_endpoints.py b/tests/integration/api/v1/test_auth_endpoints.py index 2bbe709d..46b2c395 100644 --- a/tests/integration/api/v1/test_auth_endpoints.py +++ b/tests/integration/api/v1/test_auth_endpoints.py @@ -1,7 +1,8 @@ # tests/integration/api/v1/test_auth_endpoints.py import pytest from jose import jwt -from datetime import datetime, timedelta +from datetime import datetime, timedelta, timezone + @pytest.mark.integration @pytest.mark.api @@ -195,8 +196,8 @@ class TestAuthenticationAPI: "username": test_user.username, "email": test_user.email, "role": test_user.role, - "exp": datetime.utcnow() - timedelta(hours=1), - "iat": datetime.utcnow() - timedelta(hours=2), + "exp": datetime.now(timezone.utc) - timedelta(hours=1), + "iat": datetime.now(timezone.utc) - timedelta(hours=2), } expired_token = jwt.encode( diff --git a/tests/integration/api/v1/test_export.py b/tests/integration/api/v1/test_export.py index efe979f7..6a0e2f44 100644 --- a/tests/integration/api/v1/test_export.py +++ b/tests/integration/api/v1/test_export.py @@ -13,7 +13,7 @@ from models.database.product import Product class TestExportFunctionality: def test_csv_export_basic(self, client, auth_headers, test_product): """Test basic CSV export functionality""" - response = client.get("/api/v1/export-csv", headers=auth_headers) + response = client.get("/api/v1/product/export-csv", headers=auth_headers) assert response.status_code == 200 assert response.headers["content-type"] == "text/csv; charset=utf-8" @@ -40,7 +40,7 @@ class TestExportFunctionality: db.commit() response = client.get( - "/api/v1/export-csv?marketplace=Amazon", headers=auth_headers + "/api/v1/product/export-csv?marketplace=Amazon", headers=auth_headers ) assert response.status_code == 200 @@ -66,7 +66,7 @@ class TestExportFunctionality: import time start_time = time.time() - response = client.get("/api/v1/export-csv", headers=auth_headers) + response = client.get("/api/v1/product/export-csv", headers=auth_headers) end_time = time.time() assert response.status_code == 200 diff --git a/tests/integration/api/v1/test_marketplace_endpoints.py b/tests/integration/api/v1/test_marketplace_endpoints.py index dd4025c0..54860199 100644 --- a/tests/integration/api/v1/test_marketplace_endpoints.py +++ b/tests/integration/api/v1/test_marketplace_endpoints.py @@ -4,9 +4,14 @@ from unittest.mock import AsyncMock, patch import pytest +@pytest.mark.integration +@pytest.mark.api +@pytest.mark.marketplace class TestMarketplaceAPI: - def test_import_from_marketplace(self, client, auth_headers, test_shop): + def test_import_from_marketplace(self, client, auth_headers, test_shop, test_user): """Test marketplace import endpoint - just test job creation""" + # Ensure user owns the shop + test_shop.owner_id = test_user.id import_data = { "url": "https://example.com/products.csv", @@ -23,8 +28,8 @@ class TestMarketplaceAPI: assert data["status"] == "pending" assert data["marketplace"] == "TestMarket" assert "job_id" in data - - # Don't test the background task here - test it separately + assert data["shop_code"] == test_shop.shop_code + assert data["shop_id"] == test_shop.id def test_import_from_marketplace_invalid_shop(self, client, auth_headers): """Test marketplace import with invalid shop""" @@ -39,16 +44,383 @@ class TestMarketplaceAPI: ) assert response.status_code == 404 - assert "Shop not found" in response.json()["detail"] + data = response.json() + assert data["error_code"] == "SHOP_NOT_FOUND" + assert "NONEXISTENT" in data["message"] - def test_get_marketplace_import_jobs(self, client, auth_headers): + 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 + + import_data = { + "url": "https://example.com/products.csv", + "marketplace": "TestMarket", + "shop_code": test_shop.shop_code, + } + + response = client.post( + "/api/v1/marketplace/import-product", headers=auth_headers, json=import_data + ) + + assert response.status_code == 403 + data = response.json() + assert data["error_code"] == "UNAUTHORIZED_SHOP_ACCESS" + assert test_shop.shop_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 + } + + response = client.post( + "/api/v1/marketplace/import-product", headers=auth_headers, json=import_data + ) + + assert response.status_code == 422 + data = response.json() + 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""" + import_data = { + "url": "https://example.com/products.csv", + "marketplace": "AdminMarket", + "shop_code": test_shop.shop_code, + } + + response = client.post( + "/api/v1/marketplace/import-product", headers=admin_headers, json=import_data + ) + + assert response.status_code == 200 + data = response.json() + assert data["marketplace"] == "AdminMarket" + assert data["shop_code"] == test_shop.shop_code + + def test_get_marketplace_import_status(self, client, auth_headers, test_marketplace_job): + """Test getting marketplace import status""" + response = client.get( + f"/api/v1/marketplace/import-status/{test_marketplace_job.id}", + headers=auth_headers + ) + + assert response.status_code == 200 + data = response.json() + assert data["job_id"] == test_marketplace_job.id + assert data["status"] == test_marketplace_job.status + assert data["marketplace"] == test_marketplace_job.marketplace + + def test_get_marketplace_import_status_not_found(self, client, auth_headers): + """Test getting status of non-existent import job""" + response = client.get( + "/api/v1/marketplace/import-status/99999", + headers=auth_headers + ) + + assert response.status_code == 404 + data = response.json() + assert data["error_code"] == "IMPORT_JOB_NOT_FOUND" + assert "99999" in data["message"] + + def test_get_marketplace_import_status_unauthorized(self, client, auth_headers, test_marketplace_job, other_user): + """Test getting status of unauthorized import job""" + # Change job owner to other user + test_marketplace_job.user_id = other_user.id + + response = client.get( + f"/api/v1/marketplace/import-status/{test_marketplace_job.id}", + headers=auth_headers + ) + + assert response.status_code == 403 + data = response.json() + assert data["error_code"] == "IMPORT_JOB_NOT_OWNED" + + def test_get_marketplace_import_jobs(self, client, auth_headers, test_marketplace_job): """Test getting marketplace import jobs""" response = client.get("/api/v1/marketplace/import-jobs", headers=auth_headers) assert response.status_code == 200 - assert isinstance(response.json(), list) + data = response.json() + assert isinstance(data, list) + assert len(data) >= 1 + + # Find our test job in the results + job_ids = [job["job_id"] for job in data] + assert test_marketplace_job.id in job_ids + + def test_get_marketplace_import_jobs_with_filters(self, client, auth_headers, test_marketplace_job): + """Test getting import jobs with filters""" + response = client.get( + f"/api/v1/marketplace/import-jobs?marketplace={test_marketplace_job.marketplace}", + headers=auth_headers + ) + + assert response.status_code == 200 + data = response.json() + assert isinstance(data, list) + assert len(data) >= 1 + + for job in data: + assert test_marketplace_job.marketplace.lower() in job["marketplace"].lower() + + def test_get_marketplace_import_jobs_pagination(self, client, auth_headers): + """Test import jobs pagination""" + response = client.get( + "/api/v1/marketplace/import-jobs?skip=0&limit=5", + headers=auth_headers + ) + + assert response.status_code == 200 + data = response.json() + assert isinstance(data, list) + assert len(data) <= 5 + + def test_get_marketplace_import_stats(self, client, auth_headers, test_marketplace_job): + """Test getting marketplace import statistics""" + response = client.get("/api/v1/marketplace/marketplace-import-stats", headers=auth_headers) + + assert response.status_code == 200 + data = response.json() + assert "total_jobs" in data + assert "pending_jobs" in data + assert "running_jobs" in data + assert "completed_jobs" in data + assert "failed_jobs" in data + 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): + """Test cancelling a marketplace import job""" + # Create a pending job that can be cancelled + from models.database.marketplace import MarketplaceImportJob + import uuid + + unique_id = str(uuid.uuid4())[:8] + job = MarketplaceImportJob( + status="pending", + marketplace="TestMarket", + shop_name=f"Test_Shop_{unique_id}", + user_id=test_user.id, + shop_id=test_shop.id, + source_url="https://test.example.com/import", + imported_count=0, + updated_count=0, + total_processed=0, + error_count=0, + ) + db.add(job) + db.commit() + db.refresh(job) + + response = client.put( + f"/api/v1/marketplace/import-jobs/{job.id}/cancel", + headers=auth_headers + ) + + assert response.status_code == 200 + data = response.json() + assert data["job_id"] == job.id + assert data["status"] == "cancelled" + assert data["completed_at"] is not None + + def test_cancel_marketplace_import_job_not_found(self, client, auth_headers): + """Test cancelling non-existent import job""" + response = client.put( + "/api/v1/marketplace/import-jobs/99999/cancel", + headers=auth_headers + ) + + assert response.status_code == 404 + data = response.json() + assert data["error_code"] == "IMPORT_JOB_NOT_FOUND" + + def test_cancel_marketplace_import_job_cannot_cancel(self, client, auth_headers, test_marketplace_job, db): + """Test cancelling a job that cannot be cancelled""" + # Set job to completed status + test_marketplace_job.status = "completed" + db.commit() + + response = client.put( + f"/api/v1/marketplace/import-jobs/{test_marketplace_job.id}/cancel", + headers=auth_headers + ) + + assert response.status_code == 400 + data = response.json() + 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): + """Test deleting a marketplace import job""" + # Create a completed job that can be deleted + from models.database.marketplace import MarketplaceImportJob + import uuid + + unique_id = str(uuid.uuid4())[:8] + job = MarketplaceImportJob( + status="completed", + marketplace="TestMarket", + shop_name=f"Test_Shop_{unique_id}", + user_id=test_user.id, + shop_id=test_shop.id, + source_url="https://test.example.com/import", + imported_count=0, + updated_count=0, + total_processed=0, + error_count=0, + ) + db.add(job) + db.commit() + db.refresh(job) + + response = client.delete( + f"/api/v1/marketplace/import-jobs/{job.id}", + headers=auth_headers + ) + + assert response.status_code == 200 + data = response.json() + assert "deleted successfully" in data["message"] + + def test_delete_marketplace_import_job_not_found(self, client, auth_headers): + """Test deleting non-existent import job""" + response = client.delete( + "/api/v1/marketplace/import-jobs/99999", + headers=auth_headers + ) + + assert response.status_code == 404 + 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): + """Test deleting a job that cannot be deleted""" + # Create a pending job that cannot be deleted + from models.database.marketplace import MarketplaceImportJob + import uuid + + unique_id = str(uuid.uuid4())[:8] + job = MarketplaceImportJob( + status="pending", + marketplace="TestMarket", + shop_name=f"Test_Shop_{unique_id}", + user_id=test_user.id, + shop_id=test_shop.id, + source_url="https://test.example.com/import", + imported_count=0, + updated_count=0, + total_processed=0, + error_count=0, + ) + db.add(job) + db.commit() + db.refresh(job) + + response = client.delete( + f"/api/v1/marketplace/import-jobs/{job.id}", + headers=auth_headers + ) + + assert response.status_code == 400 + data = response.json() + assert data["error_code"] == "IMPORT_JOB_CANNOT_BE_DELETED" + assert "pending" in data["message"] def test_get_marketplace_without_auth(self, client): """Test that marketplace endpoints require authentication""" response = client.get("/api/v1/marketplace/import-jobs") - assert response.status_code == 401 # No authorization header + assert response.status_code == 401 + data = response.json() + assert data["error_code"] == "INVALID_TOKEN" + + def test_import_without_auth(self, client): + """Test marketplace import without authentication""" + import_data = { + "url": "https://example.com/products.csv", + "marketplace": "TestMarket", + "shop_code": "TEST_SHOP", + } + + response = client.post("/api/v1/marketplace/import-product", json=import_data) + assert response.status_code == 401 + data = response.json() + assert data["error_code"] == "INVALID_TOKEN" + + def test_admin_can_access_all_jobs(self, client, admin_headers, test_marketplace_job): + """Test that admin can access all import jobs""" + response = client.get("/api/v1/marketplace/import-jobs", headers=admin_headers) + + assert response.status_code == 200 + data = response.json() + assert isinstance(data, list) + # Admin should see all jobs, including the test job + job_ids = [job["job_id"] for job in data] + assert test_marketplace_job.id in job_ids + + def test_admin_can_view_any_job_status(self, client, admin_headers, test_marketplace_job): + """Test that admin can view any job status""" + response = client.get( + f"/api/v1/marketplace/import-status/{test_marketplace_job.id}", + headers=admin_headers + ) + + assert response.status_code == 200 + data = response.json() + assert data["job_id"] == test_marketplace_job.id + + def test_admin_can_cancel_any_job(self, client, admin_headers, test_user, test_shop, db): + """Test that admin can cancel any job""" + # Create a pending job owned by different user + from models.database.marketplace import MarketplaceImportJob + import uuid + + unique_id = str(uuid.uuid4())[:8] + job = MarketplaceImportJob( + status="pending", + marketplace="TestMarket", + shop_name=f"Test_Shop_{unique_id}", + user_id=test_user.id, # Different user + shop_id=test_shop.id, + source_url="https://test.example.com/import", + imported_count=0, + updated_count=0, + total_processed=0, + error_count=0, + ) + db.add(job) + db.commit() + db.refresh(job) + + response = client.put( + f"/api/v1/marketplace/import-jobs/{job.id}/cancel", + headers=admin_headers + ) + + assert response.status_code == 200 + data = response.json() + assert data["status"] == "cancelled" + + def test_rate_limiting_applied(self, client, auth_headers, test_shop, 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 + + import_data = { + "url": "https://example.com/products.csv", + "marketplace": "TestMarket", + "shop_code": test_shop.shop_code, + } + + response = client.post( + "/api/v1/marketplace/import-product", headers=auth_headers, json=import_data + ) + + # Should succeed on first request + assert response.status_code == 200 diff --git a/tests/unit/services/test_marketplace_service.py b/tests/unit/services/test_marketplace_service.py index 4e14d78e..24a36e96 100644 --- a/tests/unit/services/test_marketplace_service.py +++ b/tests/unit/services/test_marketplace_service.py @@ -4,6 +4,14 @@ from datetime import datetime import pytest +from app.exceptions.marketplace import ( + ImportJobNotFoundException, + ImportJobNotOwnedException, + ImportJobCannotBeCancelledException, + ImportJobCannotBeDeletedException, +) +from app.exceptions.shop import ShopNotFoundException, UnauthorizedShopAccessException +from app.exceptions.base import ValidationException from app.services.marketplace_service import MarketplaceService from models.schemas.marketplace import MarketplaceImportRequest from models.database.marketplace import MarketplaceImportJob @@ -38,9 +46,14 @@ class TestMarketplaceService: def test_validate_shop_access_shop_not_found(self, db, test_user): """Test shop access validation when shop doesn't exist""" - with pytest.raises(ValueError, match="Shop not found"): + with pytest.raises(ShopNotFoundException) as exc_info: self.service.validate_shop_access(db, "NONEXISTENT", test_user) + exception = exc_info.value + assert exception.error_code == "SHOP_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 ): @@ -49,9 +62,14 @@ class TestMarketplaceService: test_shop.owner_id = other_user.id db.commit() - with pytest.raises(PermissionError, match="Access denied to this shop"): + with pytest.raises(UnauthorizedShopAccessException) as exc_info: self.service.validate_shop_access(db, test_shop.shop_code, test_user) + exception = exc_info.value + assert exception.error_code == "UNAUTHORIZED_SHOP_ACCESS" + assert exception.status_code == 403 + assert test_shop.shop_code in exception.message + def test_create_import_job_success(self, db, test_shop, test_user): """Test successful creation of import job""" # Set the shop owner to the test user @@ -68,11 +86,11 @@ class TestMarketplaceService: result = self.service.create_import_job(db, request, test_user) assert result.marketplace == "Amazon" - # Check the correct field based on your model - assert result.shop_id == test_shop.id # Changed from shop_code to shop_id - assert result.user_id == test_user.id if hasattr(result, "user_id") else True + assert result.shop_id == test_shop.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 def test_create_import_job_invalid_shop(self, db, test_user): """Test import job creation with invalid shop""" @@ -83,9 +101,32 @@ class TestMarketplaceService: batch_size=1000, ) - with pytest.raises(ValueError, match="Shop not found"): + with pytest.raises(ShopNotFoundException) as exc_info: self.service.create_import_job(db, request, test_user) + exception = exc_info.value + assert exception.error_code == "SHOP_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 + db.commit() + + request = MarketplaceImportRequest( + url="https://example.com/products.csv", + marketplace="Amazon", + shop_code=test_shop.shop_code, + batch_size=1000, + ) + + with pytest.raises(UnauthorizedShopAccessException) as exc_info: + self.service.create_import_job(db, request, test_user) + + exception = exc_info.value + assert exception.error_code == "UNAUTHORIZED_SHOP_ACCESS" + def test_get_import_job_by_id_success(self, db, test_marketplace_job, test_user): """Test getting import job by ID for job owner""" result = self.service.get_import_job_by_id( @@ -93,9 +134,7 @@ class TestMarketplaceService: ) assert result.id == test_marketplace_job.id - # Check user_id if the field exists - if hasattr(result, "user_id"): - assert result.user_id == test_user.id + assert result.user_id == test_user.id def test_get_import_job_by_id_admin_access( self, db, test_marketplace_job, test_admin @@ -109,25 +148,33 @@ class TestMarketplaceService: def test_get_import_job_by_id_not_found(self, db, test_user): """Test getting non-existent import job""" - with pytest.raises(ValueError, match="Marketplace import job not found"): + with pytest.raises(ImportJobNotFoundException) as exc_info: self.service.get_import_job_by_id(db, 99999, test_user) + exception = exc_info.value + assert exception.error_code == "IMPORT_JOB_NOT_FOUND" + assert exception.status_code == 404 + assert "99999" in exception.message + def test_get_import_job_by_id_access_denied( self, db, test_marketplace_job, other_user ): """Test access denied when user doesn't own the job""" - with pytest.raises(PermissionError, match="Access denied to this import job"): + with pytest.raises(ImportJobNotOwnedException) as exc_info: self.service.get_import_job_by_id(db, test_marketplace_job.id, other_user) + exception = exc_info.value + assert exception.error_code == "IMPORT_JOB_NOT_OWNED" + assert exception.status_code == 403 + assert str(test_marketplace_job.id) in exception.message + def test_get_import_jobs_user_filter(self, db, test_marketplace_job, test_user): """Test getting import jobs filtered by user""" jobs = self.service.get_import_jobs(db, test_user) assert len(jobs) >= 1 assert any(job.id == test_marketplace_job.id for job in jobs) - # Check user_id if the field exists - if hasattr(test_marketplace_job, "user_id"): - assert test_marketplace_job.user_id == test_user.id + assert test_marketplace_job.user_id == test_user.id def test_get_import_jobs_admin_sees_all(self, db, test_marketplace_job, test_admin): """Test that admin sees all import jobs""" @@ -158,7 +205,7 @@ class TestMarketplaceService: marketplace=f"Marketplace_{unique_id}_{i}", shop_name=f"Test_Shop_{unique_id}_{i}", user_id=test_user.id, - shop_id=test_shop.id, # Use shop_id instead of shop_code + shop_id=test_shop.id, source_url=f"https://test-{i}.example.com/import", imported_count=0, updated_count=0, @@ -172,6 +219,15 @@ class TestMarketplaceService: assert len(jobs) <= 2 # Should be at most 2 + def test_get_import_jobs_database_error(self, db_with_error, test_user): + """Test getting import jobs handles database errors""" + with pytest.raises(ValidationException) as exc_info: + self.service.get_import_jobs(db_with_error, test_user) + + exception = exc_info.value + assert exception.error_code == "VALIDATION_ERROR" + assert "Failed to retrieve import jobs" in exception.message + def test_update_job_status_success(self, db, test_marketplace_job): """Test updating job status""" result = self.service.update_job_status( @@ -188,9 +244,22 @@ class TestMarketplaceService: def test_update_job_status_not_found(self, db): """Test updating non-existent job status""" - with pytest.raises(ValueError, match="Marketplace import job not found"): + with pytest.raises(ImportJobNotFoundException) as exc_info: self.service.update_job_status(db, 99999, "completed") + exception = exc_info.value + assert exception.error_code == "IMPORT_JOB_NOT_FOUND" + assert "99999" in exception.message + + def test_update_job_status_database_error(self, db_with_error): + """Test updating job status handles database errors""" + with pytest.raises(ValidationException) as exc_info: + self.service.update_job_status(db_with_error, 1, "completed") + + exception = exc_info.value + assert exception.error_code == "VALIDATION_ERROR" + assert "Failed to update job status" in exception.message + def test_get_job_stats_user(self, db, test_marketplace_job, test_user): """Test getting job statistics for user""" stats = self.service.get_job_stats(db, test_user) @@ -200,12 +269,23 @@ class TestMarketplaceService: assert "running_jobs" in stats assert "completed_jobs" in stats assert "failed_jobs" in stats + assert isinstance(stats["total_jobs"], int) def test_get_job_stats_admin(self, db, test_marketplace_job, test_admin): """Test getting job statistics for admin""" stats = self.service.get_job_stats(db, test_admin) assert stats["total_jobs"] >= 1 + assert isinstance(stats["total_jobs"], int) + + def test_get_job_stats_database_error(self, db_with_error, test_user): + """Test getting job stats handles database errors""" + with pytest.raises(ValidationException) as exc_info: + self.service.get_job_stats(db_with_error, test_user) + + exception = exc_info.value + assert exception.error_code == "VALIDATION_ERROR" + assert "Failed to retrieve job statistics" in exception.message def test_convert_to_response_model(self, test_marketplace_job): """Test converting database model to response model""" @@ -226,7 +306,7 @@ class TestMarketplaceService: marketplace="Amazon", shop_name=f"TEST_SHOP_{unique_id}", user_id=test_user.id, - shop_id=test_shop.id, # Use shop_id instead of shop_code + shop_id=test_shop.id, source_url="https://test.example.com/import", imported_count=0, updated_count=0, @@ -242,6 +322,22 @@ class TestMarketplaceService: assert result.status == "cancelled" assert result.completed_at is not None + def test_cancel_import_job_not_found(self, db, test_user): + """Test cancelling non-existent import job""" + with pytest.raises(ImportJobNotFoundException) as exc_info: + self.service.cancel_import_job(db, 99999, test_user) + + exception = exc_info.value + assert exception.error_code == "IMPORT_JOB_NOT_FOUND" + + def test_cancel_import_job_access_denied(self, db, test_marketplace_job, other_user): + """Test cancelling import job without access""" + with pytest.raises(ImportJobNotOwnedException) as exc_info: + self.service.cancel_import_job(db, test_marketplace_job.id, other_user) + + exception = exc_info.value + assert exception.error_code == "IMPORT_JOB_NOT_OWNED" + def test_cancel_import_job_invalid_status( self, db, test_marketplace_job, test_user ): @@ -250,11 +346,14 @@ class TestMarketplaceService: test_marketplace_job.status = "completed" db.commit() - with pytest.raises( - ValueError, match="Cannot cancel job with status: completed" - ): + with pytest.raises(ImportJobCannotBeCancelledException) as exc_info: self.service.cancel_import_job(db, test_marketplace_job.id, test_user) + exception = exc_info.value + assert exception.error_code == "IMPORT_JOB_CANNOT_BE_CANCELLED" + assert exception.status_code == 400 + assert "completed" in exception.message + def test_delete_import_job_success(self, db, test_user, test_shop): """Test deleting a completed import job""" unique_id = str(uuid.uuid4())[:8] @@ -265,7 +364,7 @@ class TestMarketplaceService: marketplace="Amazon", shop_name=f"TEST_SHOP_{unique_id}", user_id=test_user.id, - shop_id=test_shop.id, # Use shop_id instead of shop_code + shop_id=test_shop.id, source_url="https://test.example.com/import", imported_count=0, updated_count=0, @@ -289,6 +388,22 @@ class TestMarketplaceService: ) assert deleted_job is None + def test_delete_import_job_not_found(self, db, test_user): + """Test deleting non-existent import job""" + with pytest.raises(ImportJobNotFoundException) as exc_info: + self.service.delete_import_job(db, 99999, test_user) + + exception = exc_info.value + assert exception.error_code == "IMPORT_JOB_NOT_FOUND" + + def test_delete_import_job_access_denied(self, db, test_marketplace_job, other_user): + """Test deleting import job without access""" + with pytest.raises(ImportJobNotOwnedException) as exc_info: + self.service.delete_import_job(db, test_marketplace_job.id, other_user) + + 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): """Test deleting a job that can't be deleted""" unique_id = str(uuid.uuid4())[:8] @@ -299,7 +414,7 @@ class TestMarketplaceService: marketplace="Amazon", shop_name=f"TEST_SHOP_{unique_id}", user_id=test_user.id, - shop_id=test_shop.id, # Use shop_id instead of shop_code + shop_id=test_shop.id, source_url="https://test.example.com/import", imported_count=0, updated_count=0, @@ -310,5 +425,39 @@ class TestMarketplaceService: db.commit() db.refresh(job) - with pytest.raises(ValueError, match="Cannot delete job with status: pending"): + with pytest.raises(ImportJobCannotBeDeletedException) as exc_info: self.service.delete_import_job(db, job.id, test_user) + + exception = exc_info.value + assert exception.error_code == "IMPORT_JOB_CANNOT_BE_DELETED" + assert exception.status_code == 400 + 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 + 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 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 + + def test_create_import_job_database_error(self, db_with_error, test_user): + """Test import job creation handles database errors""" + request = MarketplaceImportRequest( + url="https://example.com/products.csv", + marketplace="Amazon", + shop_code="TEST_SHOP", + batch_size=1000, + ) + + with pytest.raises(ValidationException) as exc_info: + self.service.create_import_job(db_with_error, request, test_user) + + exception = exc_info.value + assert exception.error_code == "VALIDATION_ERROR"