Marketplace tests update

This commit is contained in:
2025-09-24 22:28:44 +02:00
parent f9879126c8
commit cea88a46c5
16 changed files with 613 additions and 73 deletions

View File

@@ -113,7 +113,7 @@ def delete_product(
return {"message": "Product and associated stock deleted successfully"} return {"message": "Product and associated stock deleted successfully"}
@router.get("/export-csv") @router.get("/product/export-csv")
async def export_csv( async def export_csv(
marketplace: Optional[str] = Query(None, description="Filter by marketplace"), marketplace: Optional[str] = Query(None, description="Filter by marketplace"),
shop_name: Optional[str] = Query(None, description="Filter by shop name"), shop_name: Optional[str] = Query(None, description="Filter by shop name"),

View File

@@ -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( return JSONResponse(
status_code=422, status_code=422,
content={ content={
@@ -92,7 +110,7 @@ def setup_exception_handlers(app):
"message": "Request validation failed", "message": "Request validation failed",
"status_code": 422, "status_code": 422,
"details": { "details": {
"validation_errors": exc.errors() "validation_errors": clean_errors # Use cleaned errors
} }
} }
) )

View File

@@ -9,7 +9,7 @@ This module provides classes and functions for:
""" """
import logging import logging
from datetime import datetime from datetime import datetime, timezone
from typing import List, Optional, Tuple from typing import List, Optional, Tuple
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -81,7 +81,7 @@ class AdminService:
try: try:
original_status = user.is_active original_status = user.is_active
user.is_active = not 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.commit()
db.refresh(user) db.refresh(user)
@@ -146,11 +146,11 @@ class AdminService:
try: try:
original_status = shop.is_verified original_status = shop.is_verified
shop.is_verified = not 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 # Add verification timestamp if implementing audit trail
if shop.is_verified: if shop.is_verified:
shop.verified_at = datetime.utcnow() shop.verified_at = datetime.now(timezone.utc)
db.commit() db.commit()
db.refresh(shop) db.refresh(shop)
@@ -190,7 +190,7 @@ class AdminService:
try: try:
original_status = shop.is_active original_status = shop.is_active
shop.is_active = not 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.commit()
db.refresh(shop) db.refresh(shop)

View File

@@ -9,7 +9,7 @@ This module provides classes and functions for:
""" """
import logging import logging
from datetime import datetime from datetime import datetime, timezone
from typing import List, Optional from typing import List, Optional
from sqlalchemy import func from sqlalchemy import func
@@ -106,7 +106,7 @@ class MarketplaceService:
shop_id=shop.id, # Foreign key to shops table shop_id=shop.id, # Foreign key to shops table
shop_name=shop.shop_name, # Use shop.shop_name (the display name) shop_name=shop.shop_name, # Use shop.shop_name (the display name)
user_id=user.id, user_id=user.id,
created_at=datetime.utcnow(), created_at=datetime.now(timezone.utc),
) )
db.add(import_job) db.add(import_job)
@@ -360,7 +360,7 @@ class MarketplaceService:
raise ImportJobCannotBeCancelledException(job_id, job.status) raise ImportJobCannotBeCancelledException(job_id, job.status)
job.status = "cancelled" job.status = "cancelled"
job.completed_at = datetime.utcnow() job.completed_at = datetime.now(timezone.utc)
db.commit() db.commit()
db.refresh(job) db.refresh(job)

View File

@@ -10,7 +10,7 @@ This module provides classes and functions for:
""" """
import logging import logging
from datetime import datetime from datetime import datetime, timezone
from typing import Generator, List, Optional, Tuple from typing import Generator, List, Optional, Tuple
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
@@ -250,7 +250,7 @@ class ProductService:
for key, value in update_data.items(): for key, value in update_data.items():
setattr(product, key, value) setattr(product, key, value)
product.updated_at = datetime.utcnow() product.updated_at = datetime.now(timezone.utc)
db.commit() db.commit()
db.refresh(product) db.refresh(product)

View File

@@ -10,7 +10,7 @@ This module provides classes and functions for:
""" """
import logging import logging
from datetime import datetime from datetime import datetime, timezone
from typing import List, Optional from typing import List, Optional
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -68,7 +68,7 @@ class StockService:
# Update existing stock (SET to exact quantity) # Update existing stock (SET to exact quantity)
old_quantity = existing_stock.quantity old_quantity = existing_stock.quantity
existing_stock.quantity = stock_data.quantity existing_stock.quantity = stock_data.quantity
existing_stock.updated_at = datetime.utcnow() existing_stock.updated_at = datetime.now(timezone.utc)
db.commit() db.commit()
db.refresh(existing_stock) db.refresh(existing_stock)
@@ -128,7 +128,7 @@ class StockService:
# Add to existing stock # Add to existing stock
old_quantity = existing_stock.quantity old_quantity = existing_stock.quantity
existing_stock.quantity += stock_data.quantity existing_stock.quantity += stock_data.quantity
existing_stock.updated_at = datetime.utcnow() existing_stock.updated_at = datetime.now(timezone.utc)
db.commit() db.commit()
db.refresh(existing_stock) db.refresh(existing_stock)
@@ -207,7 +207,7 @@ class StockService:
raise NegativeStockException(normalized_gtin, location, new_quantity) raise NegativeStockException(normalized_gtin, location, new_quantity)
existing_stock.quantity = new_quantity existing_stock.quantity = new_quantity
existing_stock.updated_at = datetime.utcnow() existing_stock.updated_at = datetime.now(timezone.utc)
db.commit() db.commit()
db.refresh(existing_stock) db.refresh(existing_stock)
@@ -381,7 +381,7 @@ class StockService:
self._validate_quantity(stock_update.quantity, allow_zero=True) self._validate_quantity(stock_update.quantity, allow_zero=True)
stock_entry.quantity = stock_update.quantity stock_entry.quantity = stock_update.quantity
stock_entry.updated_at = datetime.utcnow() stock_entry.updated_at = datetime.now(timezone.utc)
db.commit() db.commit()
db.refresh(stock_entry) db.refresh(stock_entry)

View File

@@ -8,7 +8,7 @@ This module provides classes and functions for:
""" """
import logging import logging
from datetime import datetime from datetime import datetime, timezone
from app.core.database import SessionLocal from app.core.database import SessionLocal
from models.database.marketplace import MarketplaceImportJob from models.database.marketplace import MarketplaceImportJob
@@ -37,7 +37,7 @@ async def process_marketplace_import(
return return
job.status = "processing" job.status = "processing"
job.started_at = datetime.utcnow() job.started_at = datetime.now(timezone.utc)
db.commit() db.commit()
logger.info(f"Processing import: Job {job_id}, Marketplace: {marketplace}") logger.info(f"Processing import: Job {job_id}, Marketplace: {marketplace}")
@@ -49,7 +49,7 @@ async def process_marketplace_import(
# Update job with results # Update job with results
job.status = "completed" job.status = "completed"
job.completed_at = datetime.utcnow() job.completed_at = datetime.now(timezone.utc)
job.imported_count = result["imported"] job.imported_count = result["imported"]
job.updated_count = result["updated"] job.updated_count = result["updated"]
job.error_count = result.get("errors", 0) job.error_count = result.get("errors", 0)
@@ -68,7 +68,7 @@ async def process_marketplace_import(
try: try:
job.status = "failed" job.status = "failed"
job.error_message = str(e) job.error_message = str(e)
job.completed_at = datetime.utcnow() job.completed_at = datetime.now(timezone.utc)
db.commit() db.commit()
except Exception as commit_error: except Exception as commit_error:
logger.error(f"Failed to update job status: {commit_error}") logger.error(f"Failed to update job status: {commit_error}")

View File

@@ -8,7 +8,7 @@ This module provides classes and functions for:
""" """
import logging import logging
from datetime import datetime from datetime import datetime, timezone
from io import StringIO from io import StringIO
from typing import Any, Dict from typing import Any, Dict
@@ -290,7 +290,7 @@ class CSVProcessor:
existing_product, key existing_product, key
): ):
setattr(existing_product, key, value) setattr(existing_product, key, value)
existing_product.updated_at = datetime.utcnow() existing_product.updated_at = datetime.now(timezone.utc)
updated += 1 updated += 1
logger.debug( logger.debug(
f"Updated product {product_data['product_id']} for " f"Updated product {product_data['product_id']} for "

View File

@@ -1,5 +1,5 @@
import logging import logging
from datetime import datetime from datetime import datetime, timezone
from fastapi import Depends, FastAPI, HTTPException from fastapi import Depends, FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
@@ -56,7 +56,7 @@ def health_check(db: Session = Depends(get_db)):
db.execute(text("SELECT 1")) db.execute(text("SELECT 1"))
return { return {
"status": "healthy", "status": "healthy",
"timestamp": datetime.utcnow(), "timestamp": datetime.now(timezone.utc),
"message": f"{settings.project_name} v{settings.version}", "message": f"{settings.project_name} v{settings.version}",
"docs": { "docs": {
"swagger": "/docs", "swagger": "/docs",

View File

@@ -9,7 +9,7 @@ This module provides classes and functions for:
import logging import logging
import os import os
from datetime import datetime, timedelta from datetime import datetime, timedelta, timezone
from typing import Any, Dict, Optional from typing import Any, Dict, Optional
from fastapi import HTTPException from fastapi import HTTPException
@@ -71,7 +71,7 @@ class AuthManager:
def create_access_token(self, user: User) -> Dict[str, Any]: def create_access_token(self, user: User) -> Dict[str, Any]:
"""Create JWT access token for user.""" """Create JWT access token for user."""
expires_delta = timedelta(minutes=self.token_expire_minutes) expires_delta = timedelta(minutes=self.token_expire_minutes)
expire = datetime.utcnow() + expires_delta expire = datetime.now(timezone.utc) + expires_delta
payload = { payload = {
"sub": str(user.id), "sub": str(user.id),
@@ -79,7 +79,7 @@ class AuthManager:
"email": user.email, "email": user.email,
"role": user.role, "role": user.role,
"exp": expire, "exp": expire,
"iat": datetime.utcnow(), "iat": datetime.now(timezone.utc),
} }
token = jwt.encode(payload, self.secret_key, algorithm=self.algorithm) token = jwt.encode(payload, self.secret_key, algorithm=self.algorithm)
@@ -100,7 +100,7 @@ class AuthManager:
if exp is None: if exp is None:
raise InvalidTokenException("Token missing expiration") raise InvalidTokenException("Token missing expiration")
if datetime.utcnow() > datetime.fromtimestamp(exp): if datetime.now(timezone.utc) > datetime.fromtimestamp(exp):
raise TokenExpiredException() raise TokenExpiredException()
# Extract user data # Extract user data

View File

@@ -9,7 +9,7 @@ This module provides classes and functions for:
import logging import logging
from collections import defaultdict, deque from collections import defaultdict, deque
from datetime import datetime, timedelta from datetime import datetime, timedelta, timezone
from typing import Dict from typing import Dict
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -23,7 +23,7 @@ class RateLimiter:
# Dictionary to store request timestamps for each client # Dictionary to store request timestamps for each client
self.clients: Dict[str, deque] = defaultdict(lambda: deque()) self.clients: Dict[str, deque] = defaultdict(lambda: deque())
self.cleanup_interval = 3600 # Clean up old entries every hour 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( def allow_request(
self, client_id: str, max_requests: int, window_seconds: int self, client_id: str, max_requests: int, window_seconds: int
@@ -33,7 +33,7 @@ class RateLimiter:
Uses sliding window algorithm Uses sliding window algorithm
""" """
now = datetime.utcnow() now = datetime.now(timezone.utc)
window_start = now - timedelta(seconds=window_seconds) window_start = now - timedelta(seconds=window_seconds)
# Clean up old entries periodically # Clean up old entries periodically
@@ -60,7 +60,7 @@ class RateLimiter:
def _cleanup_old_entries(self): def _cleanup_old_entries(self):
"""Clean up old entries to prevent memory leaks.""" """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 = [] clients_to_remove = []
for client_id, requests in self.clients.items(): for client_id, requests in self.clients.items():
@@ -84,7 +84,7 @@ class RateLimiter:
"""Get statistics for a specific client.""" """Get statistics for a specific client."""
client_requests = self.clients.get(client_id, deque()) client_requests = self.clients.get(client_id, deque())
now = datetime.utcnow() now = datetime.now(timezone.utc)
hour_ago = now - timedelta(hours=1) hour_ago = now - timedelta(hours=1)
day_ago = now - timedelta(days=1) day_ago = now - timedelta(days=1)

View File

@@ -10,10 +10,10 @@ from models.database.stock import Stock
@pytest.fixture @pytest.fixture
def test_shop(db, test_user): def test_shop(db, test_user):
"""Create a test shop with unique shop code""" """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 = Shop(
shop_code=f"TESTSHOP_{unique_id}", shop_code=f"TESTSHOP_{unique_id}", # Will be all uppercase
shop_name=f"Test Shop {unique_id}", shop_name=f"Test Shop {unique_id.lower()}", # Keep display name readable
owner_id=test_user.id, owner_id=test_user.id,
is_active=True, is_active=True,
is_verified=True, is_verified=True,

View File

@@ -1,7 +1,8 @@
# tests/integration/api/v1/test_auth_endpoints.py # tests/integration/api/v1/test_auth_endpoints.py
import pytest import pytest
from jose import jwt from jose import jwt
from datetime import datetime, timedelta from datetime import datetime, timedelta, timezone
@pytest.mark.integration @pytest.mark.integration
@pytest.mark.api @pytest.mark.api
@@ -195,8 +196,8 @@ class TestAuthenticationAPI:
"username": test_user.username, "username": test_user.username,
"email": test_user.email, "email": test_user.email,
"role": test_user.role, "role": test_user.role,
"exp": datetime.utcnow() - timedelta(hours=1), "exp": datetime.now(timezone.utc) - timedelta(hours=1),
"iat": datetime.utcnow() - timedelta(hours=2), "iat": datetime.now(timezone.utc) - timedelta(hours=2),
} }
expired_token = jwt.encode( expired_token = jwt.encode(

View File

@@ -13,7 +13,7 @@ from models.database.product import Product
class TestExportFunctionality: class TestExportFunctionality:
def test_csv_export_basic(self, client, auth_headers, test_product): def test_csv_export_basic(self, client, auth_headers, test_product):
"""Test basic CSV export functionality""" """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.status_code == 200
assert response.headers["content-type"] == "text/csv; charset=utf-8" assert response.headers["content-type"] == "text/csv; charset=utf-8"
@@ -40,7 +40,7 @@ class TestExportFunctionality:
db.commit() db.commit()
response = client.get( 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 assert response.status_code == 200
@@ -66,7 +66,7 @@ class TestExportFunctionality:
import time import time
start_time = time.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() end_time = time.time()
assert response.status_code == 200 assert response.status_code == 200

View File

@@ -4,9 +4,14 @@ from unittest.mock import AsyncMock, patch
import pytest import pytest
@pytest.mark.integration
@pytest.mark.api
@pytest.mark.marketplace
class TestMarketplaceAPI: 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""" """Test marketplace import endpoint - just test job creation"""
# Ensure user owns the shop
test_shop.owner_id = test_user.id
import_data = { import_data = {
"url": "https://example.com/products.csv", "url": "https://example.com/products.csv",
@@ -23,8 +28,8 @@ class TestMarketplaceAPI:
assert data["status"] == "pending" assert data["status"] == "pending"
assert data["marketplace"] == "TestMarket" assert data["marketplace"] == "TestMarket"
assert "job_id" in data assert "job_id" in data
assert data["shop_code"] == test_shop.shop_code
# Don't test the background task here - test it separately assert data["shop_id"] == test_shop.id
def test_import_from_marketplace_invalid_shop(self, client, auth_headers): def test_import_from_marketplace_invalid_shop(self, client, auth_headers):
"""Test marketplace import with invalid shop""" """Test marketplace import with invalid shop"""
@@ -39,16 +44,383 @@ class TestMarketplaceAPI:
) )
assert response.status_code == 404 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""" """Test getting marketplace import jobs"""
response = client.get("/api/v1/marketplace/import-jobs", headers=auth_headers) response = client.get("/api/v1/marketplace/import-jobs", headers=auth_headers)
assert response.status_code == 200 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): def test_get_marketplace_without_auth(self, client):
"""Test that marketplace endpoints require authentication""" """Test that marketplace endpoints require authentication"""
response = client.get("/api/v1/marketplace/import-jobs") 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

View File

@@ -4,6 +4,14 @@ from datetime import datetime
import pytest 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 app.services.marketplace_service import MarketplaceService
from models.schemas.marketplace import MarketplaceImportRequest from models.schemas.marketplace import MarketplaceImportRequest
from models.database.marketplace import MarketplaceImportJob from models.database.marketplace import MarketplaceImportJob
@@ -38,9 +46,14 @@ class TestMarketplaceService:
def test_validate_shop_access_shop_not_found(self, db, test_user): def test_validate_shop_access_shop_not_found(self, db, test_user):
"""Test shop access validation when shop doesn't exist""" """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) 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( def test_validate_shop_access_permission_denied(
self, db, test_shop, test_user, other_user self, db, test_shop, test_user, other_user
): ):
@@ -49,9 +62,14 @@ class TestMarketplaceService:
test_shop.owner_id = other_user.id test_shop.owner_id = other_user.id
db.commit() 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) 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): def test_create_import_job_success(self, db, test_shop, test_user):
"""Test successful creation of import job""" """Test successful creation of import job"""
# Set the shop owner to the test user # Set the shop owner to the test user
@@ -68,11 +86,11 @@ class TestMarketplaceService:
result = self.service.create_import_job(db, request, test_user) result = self.service.create_import_job(db, request, test_user)
assert result.marketplace == "Amazon" assert result.marketplace == "Amazon"
# Check the correct field based on your model assert result.shop_id == test_shop.id
assert result.shop_id == test_shop.id # Changed from shop_code to shop_id assert result.user_id == test_user.id
assert result.user_id == test_user.id if hasattr(result, "user_id") else True
assert result.status == "pending" assert result.status == "pending"
assert result.source_url == "https://example.com/products.csv" assert result.source_url == "https://example.com/products.csv"
assert result.shop_name == test_shop.shop_name
def test_create_import_job_invalid_shop(self, db, test_user): def test_create_import_job_invalid_shop(self, db, test_user):
"""Test import job creation with invalid shop""" """Test import job creation with invalid shop"""
@@ -83,9 +101,32 @@ class TestMarketplaceService:
batch_size=1000, 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) 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): def test_get_import_job_by_id_success(self, db, test_marketplace_job, test_user):
"""Test getting import job by ID for job owner""" """Test getting import job by ID for job owner"""
result = self.service.get_import_job_by_id( result = self.service.get_import_job_by_id(
@@ -93,9 +134,7 @@ class TestMarketplaceService:
) )
assert result.id == test_marketplace_job.id assert result.id == test_marketplace_job.id
# Check user_id if the field exists assert result.user_id == test_user.id
if hasattr(result, "user_id"):
assert result.user_id == test_user.id
def test_get_import_job_by_id_admin_access( def test_get_import_job_by_id_admin_access(
self, db, test_marketplace_job, test_admin 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): def test_get_import_job_by_id_not_found(self, db, test_user):
"""Test getting non-existent import job""" """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) 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( def test_get_import_job_by_id_access_denied(
self, db, test_marketplace_job, other_user self, db, test_marketplace_job, other_user
): ):
"""Test access denied when user doesn't own the job""" """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) 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): def test_get_import_jobs_user_filter(self, db, test_marketplace_job, test_user):
"""Test getting import jobs filtered by user""" """Test getting import jobs filtered by user"""
jobs = self.service.get_import_jobs(db, test_user) jobs = self.service.get_import_jobs(db, test_user)
assert len(jobs) >= 1 assert len(jobs) >= 1
assert any(job.id == test_marketplace_job.id for job in jobs) assert any(job.id == test_marketplace_job.id for job in jobs)
# Check user_id if the field exists assert test_marketplace_job.user_id == test_user.id
if hasattr(test_marketplace_job, "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): def test_get_import_jobs_admin_sees_all(self, db, test_marketplace_job, test_admin):
"""Test that admin sees all import jobs""" """Test that admin sees all import jobs"""
@@ -158,7 +205,7 @@ class TestMarketplaceService:
marketplace=f"Marketplace_{unique_id}_{i}", marketplace=f"Marketplace_{unique_id}_{i}",
shop_name=f"Test_Shop_{unique_id}_{i}", shop_name=f"Test_Shop_{unique_id}_{i}",
user_id=test_user.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=f"https://test-{i}.example.com/import", source_url=f"https://test-{i}.example.com/import",
imported_count=0, imported_count=0,
updated_count=0, updated_count=0,
@@ -172,6 +219,15 @@ class TestMarketplaceService:
assert len(jobs) <= 2 # Should be at most 2 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): def test_update_job_status_success(self, db, test_marketplace_job):
"""Test updating job status""" """Test updating job status"""
result = self.service.update_job_status( result = self.service.update_job_status(
@@ -188,9 +244,22 @@ class TestMarketplaceService:
def test_update_job_status_not_found(self, db): def test_update_job_status_not_found(self, db):
"""Test updating non-existent job status""" """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") 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): def test_get_job_stats_user(self, db, test_marketplace_job, test_user):
"""Test getting job statistics for user""" """Test getting job statistics for user"""
stats = self.service.get_job_stats(db, test_user) stats = self.service.get_job_stats(db, test_user)
@@ -200,12 +269,23 @@ class TestMarketplaceService:
assert "running_jobs" in stats assert "running_jobs" in stats
assert "completed_jobs" in stats assert "completed_jobs" in stats
assert "failed_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): def test_get_job_stats_admin(self, db, test_marketplace_job, test_admin):
"""Test getting job statistics for admin""" """Test getting job statistics for admin"""
stats = self.service.get_job_stats(db, test_admin) stats = self.service.get_job_stats(db, test_admin)
assert stats["total_jobs"] >= 1 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): def test_convert_to_response_model(self, test_marketplace_job):
"""Test converting database model to response model""" """Test converting database model to response model"""
@@ -226,7 +306,7 @@ class TestMarketplaceService:
marketplace="Amazon", marketplace="Amazon",
shop_name=f"TEST_SHOP_{unique_id}", shop_name=f"TEST_SHOP_{unique_id}",
user_id=test_user.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", source_url="https://test.example.com/import",
imported_count=0, imported_count=0,
updated_count=0, updated_count=0,
@@ -242,6 +322,22 @@ class TestMarketplaceService:
assert result.status == "cancelled" assert result.status == "cancelled"
assert result.completed_at is not None 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( def test_cancel_import_job_invalid_status(
self, db, test_marketplace_job, test_user self, db, test_marketplace_job, test_user
): ):
@@ -250,11 +346,14 @@ class TestMarketplaceService:
test_marketplace_job.status = "completed" test_marketplace_job.status = "completed"
db.commit() db.commit()
with pytest.raises( with pytest.raises(ImportJobCannotBeCancelledException) as exc_info:
ValueError, match="Cannot cancel job with status: completed"
):
self.service.cancel_import_job(db, test_marketplace_job.id, test_user) 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): def test_delete_import_job_success(self, db, test_user, test_shop):
"""Test deleting a completed import job""" """Test deleting a completed import job"""
unique_id = str(uuid.uuid4())[:8] unique_id = str(uuid.uuid4())[:8]
@@ -265,7 +364,7 @@ class TestMarketplaceService:
marketplace="Amazon", marketplace="Amazon",
shop_name=f"TEST_SHOP_{unique_id}", shop_name=f"TEST_SHOP_{unique_id}",
user_id=test_user.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", source_url="https://test.example.com/import",
imported_count=0, imported_count=0,
updated_count=0, updated_count=0,
@@ -289,6 +388,22 @@ class TestMarketplaceService:
) )
assert deleted_job is None 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): def test_delete_import_job_invalid_status(self, db, test_user, test_shop):
"""Test deleting a job that can't be deleted""" """Test deleting a job that can't be deleted"""
unique_id = str(uuid.uuid4())[:8] unique_id = str(uuid.uuid4())[:8]
@@ -299,7 +414,7 @@ class TestMarketplaceService:
marketplace="Amazon", marketplace="Amazon",
shop_name=f"TEST_SHOP_{unique_id}", shop_name=f"TEST_SHOP_{unique_id}",
user_id=test_user.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", source_url="https://test.example.com/import",
imported_count=0, imported_count=0,
updated_count=0, updated_count=0,
@@ -310,5 +425,39 @@ class TestMarketplaceService:
db.commit() db.commit()
db.refresh(job) 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) 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"