fix(billing): complete billing module — fix tier change, platform support, merchant portal
- Fix admin tier change: resolve tier_code→tier_id in update_subscription(), delegate to billing_service.change_tier() for Stripe-connected subs - Add platform support to admin tiers page: platform column, filter dropdown, platform selector in create/edit modal, platform_name in tier API response - Filter used platforms in create subscription modal on merchant detail page - Enrich merchant portal API responses with tier code, tier_name, platform_name - Add eager-load of platform relationship in get_merchant_subscription() - Remove stale store_name/store_code references from merchant templates - Add merchant tier change endpoint (POST /change-tier) and tier selector UI replacing broken requestUpgrade() button - Fix subscription detail link to use platform_id instead of sub.id Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
136
app/modules/marketplace/tests/unit/test_import_job_model.py
Normal file
136
app/modules/marketplace/tests/unit/test_import_job_model.py
Normal file
@@ -0,0 +1,136 @@
|
||||
# tests/unit/models/database/test_marketplace_import_job.py
|
||||
"""Unit tests for MarketplaceImportJob database model."""
|
||||
|
||||
import pytest
|
||||
|
||||
from app.modules.marketplace.models import MarketplaceImportJob
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.database
|
||||
class TestMarketplaceImportJobModel:
|
||||
"""Test MarketplaceImportJob model."""
|
||||
|
||||
def test_import_job_creation(self, db, test_user, test_store):
|
||||
"""Test MarketplaceImportJob model with relationships."""
|
||||
import_job = MarketplaceImportJob(
|
||||
store_id=test_store.id,
|
||||
user_id=test_user.id,
|
||||
marketplace="Letzshop",
|
||||
source_url="https://example.com/feed.csv",
|
||||
status="pending",
|
||||
imported_count=0,
|
||||
updated_count=0,
|
||||
error_count=0,
|
||||
total_processed=0,
|
||||
)
|
||||
|
||||
db.add(import_job)
|
||||
db.commit()
|
||||
db.refresh(import_job)
|
||||
|
||||
assert import_job.id is not None
|
||||
assert import_job.store_id == test_store.id
|
||||
assert import_job.user_id == test_user.id
|
||||
assert import_job.marketplace == "Letzshop"
|
||||
assert import_job.source_url == "https://example.com/feed.csv"
|
||||
assert import_job.status == "pending"
|
||||
assert import_job.store.store_code == test_store.store_code
|
||||
assert import_job.user.username == test_user.username
|
||||
|
||||
def test_import_job_default_values(self, db, test_user, test_store):
|
||||
"""Test MarketplaceImportJob default values."""
|
||||
import_job = MarketplaceImportJob(
|
||||
store_id=test_store.id,
|
||||
user_id=test_user.id,
|
||||
source_url="https://example.com/feed.csv",
|
||||
)
|
||||
|
||||
db.add(import_job)
|
||||
db.commit()
|
||||
db.refresh(import_job)
|
||||
|
||||
assert import_job.marketplace == "Letzshop" # Default
|
||||
assert import_job.status == "pending" # Default
|
||||
assert import_job.imported_count == 0 # Default
|
||||
assert import_job.updated_count == 0 # Default
|
||||
assert import_job.error_count == 0 # Default
|
||||
assert import_job.total_processed == 0 # Default
|
||||
|
||||
def test_import_job_status_values(self, db, test_user, test_store):
|
||||
"""Test MarketplaceImportJob with different status values."""
|
||||
statuses = [
|
||||
"pending",
|
||||
"processing",
|
||||
"completed",
|
||||
"failed",
|
||||
"completed_with_errors",
|
||||
]
|
||||
|
||||
for i, status in enumerate(statuses):
|
||||
import_job = MarketplaceImportJob(
|
||||
store_id=test_store.id,
|
||||
user_id=test_user.id,
|
||||
source_url=f"https://example.com/feed_{i}.csv",
|
||||
status=status,
|
||||
)
|
||||
db.add(import_job)
|
||||
db.commit()
|
||||
db.refresh(import_job)
|
||||
|
||||
assert import_job.status == status
|
||||
|
||||
def test_import_job_counts(self, db, test_user, test_store):
|
||||
"""Test MarketplaceImportJob count fields."""
|
||||
import_job = MarketplaceImportJob(
|
||||
store_id=test_store.id,
|
||||
user_id=test_user.id,
|
||||
source_url="https://example.com/feed.csv",
|
||||
status="completed",
|
||||
imported_count=100,
|
||||
updated_count=50,
|
||||
error_count=5,
|
||||
total_processed=155,
|
||||
)
|
||||
|
||||
db.add(import_job)
|
||||
db.commit()
|
||||
db.refresh(import_job)
|
||||
|
||||
assert import_job.imported_count == 100
|
||||
assert import_job.updated_count == 50
|
||||
assert import_job.error_count == 5
|
||||
assert import_job.total_processed == 155
|
||||
|
||||
def test_import_job_error_message(self, db, test_user, test_store):
|
||||
"""Test MarketplaceImportJob with error message."""
|
||||
import_job = MarketplaceImportJob(
|
||||
store_id=test_store.id,
|
||||
user_id=test_user.id,
|
||||
source_url="https://example.com/feed.csv",
|
||||
status="failed",
|
||||
error_message="Connection timeout while fetching CSV",
|
||||
)
|
||||
|
||||
db.add(import_job)
|
||||
db.commit()
|
||||
db.refresh(import_job)
|
||||
|
||||
assert import_job.error_message == "Connection timeout while fetching CSV"
|
||||
|
||||
def test_import_job_relationships(self, db, test_user, test_store):
|
||||
"""Test MarketplaceImportJob relationships."""
|
||||
import_job = MarketplaceImportJob(
|
||||
store_id=test_store.id,
|
||||
user_id=test_user.id,
|
||||
source_url="https://example.com/feed.csv",
|
||||
)
|
||||
|
||||
db.add(import_job)
|
||||
db.commit()
|
||||
db.refresh(import_job)
|
||||
|
||||
assert import_job.store is not None
|
||||
assert import_job.user is not None
|
||||
assert import_job.store.id == test_store.id
|
||||
assert import_job.user.id == test_user.id
|
||||
240
app/modules/marketplace/tests/unit/test_import_job_schema.py
Normal file
240
app/modules/marketplace/tests/unit/test_import_job_schema.py
Normal file
@@ -0,0 +1,240 @@
|
||||
# tests/unit/models/schema/test_marketplace_import_job.py
|
||||
"""Unit tests for marketplace import job Pydantic schemas."""
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from app.modules.marketplace.schemas import (
|
||||
MarketplaceImportJobListResponse,
|
||||
MarketplaceImportJobRequest,
|
||||
MarketplaceImportJobResponse,
|
||||
MarketplaceImportJobStatusUpdate,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.schema
|
||||
class TestMarketplaceImportJobRequestSchema:
|
||||
"""Test MarketplaceImportJobRequest schema validation."""
|
||||
|
||||
def test_valid_request(self):
|
||||
"""Test valid import job request."""
|
||||
request = MarketplaceImportJobRequest(
|
||||
source_url="https://example.com/products.csv",
|
||||
marketplace="Letzshop",
|
||||
)
|
||||
assert request.source_url == "https://example.com/products.csv"
|
||||
assert request.marketplace == "Letzshop"
|
||||
|
||||
def test_source_url_required(self):
|
||||
"""Test source_url is required."""
|
||||
with pytest.raises(ValidationError) as exc_info:
|
||||
MarketplaceImportJobRequest(marketplace="Letzshop")
|
||||
assert "source_url" in str(exc_info.value).lower()
|
||||
|
||||
def test_source_url_must_be_http_or_https(self):
|
||||
"""Test source_url must start with http:// or https://."""
|
||||
with pytest.raises(ValidationError) as exc_info:
|
||||
MarketplaceImportJobRequest(
|
||||
source_url="ftp://example.com/products.csv",
|
||||
)
|
||||
assert "url" in str(exc_info.value).lower()
|
||||
|
||||
def test_source_url_http_is_valid(self):
|
||||
"""Test http:// URLs are valid."""
|
||||
request = MarketplaceImportJobRequest(
|
||||
source_url="http://example.com/products.csv",
|
||||
)
|
||||
assert request.source_url == "http://example.com/products.csv"
|
||||
|
||||
def test_source_url_with_leading_whitespace_invalid(self):
|
||||
"""Test source_url with leading whitespace is invalid (validation before strip)."""
|
||||
with pytest.raises(ValidationError) as exc_info:
|
||||
MarketplaceImportJobRequest(
|
||||
source_url=" https://example.com/products.csv",
|
||||
)
|
||||
assert "url" in str(exc_info.value).lower()
|
||||
|
||||
def test_marketplace_default(self):
|
||||
"""Test marketplace defaults to Letzshop."""
|
||||
request = MarketplaceImportJobRequest(
|
||||
source_url="https://example.com/products.csv",
|
||||
)
|
||||
assert request.marketplace == "Letzshop"
|
||||
|
||||
def test_marketplace_stripped(self):
|
||||
"""Test marketplace is stripped of whitespace."""
|
||||
request = MarketplaceImportJobRequest(
|
||||
source_url="https://example.com/products.csv",
|
||||
marketplace=" CustomMarket ",
|
||||
)
|
||||
assert request.marketplace == "CustomMarket"
|
||||
|
||||
def test_batch_size_default(self):
|
||||
"""Test batch_size defaults to 1000."""
|
||||
request = MarketplaceImportJobRequest(
|
||||
source_url="https://example.com/products.csv",
|
||||
)
|
||||
assert request.batch_size == 1000
|
||||
|
||||
def test_batch_size_minimum(self):
|
||||
"""Test batch_size must be >= 100."""
|
||||
with pytest.raises(ValidationError) as exc_info:
|
||||
MarketplaceImportJobRequest(
|
||||
source_url="https://example.com/products.csv",
|
||||
batch_size=50,
|
||||
)
|
||||
assert "batch_size" in str(exc_info.value).lower()
|
||||
|
||||
def test_batch_size_maximum(self):
|
||||
"""Test batch_size must be <= 10000."""
|
||||
with pytest.raises(ValidationError) as exc_info:
|
||||
MarketplaceImportJobRequest(
|
||||
source_url="https://example.com/products.csv",
|
||||
batch_size=15000,
|
||||
)
|
||||
assert "batch_size" in str(exc_info.value).lower()
|
||||
|
||||
def test_batch_size_valid_range(self):
|
||||
"""Test batch_size in valid range."""
|
||||
request = MarketplaceImportJobRequest(
|
||||
source_url="https://example.com/products.csv",
|
||||
batch_size=5000,
|
||||
)
|
||||
assert request.batch_size == 5000
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.schema
|
||||
class TestMarketplaceImportJobResponseSchema:
|
||||
"""Test MarketplaceImportJobResponse schema."""
|
||||
|
||||
def test_from_dict(self):
|
||||
"""Test creating response from dict."""
|
||||
from datetime import datetime
|
||||
|
||||
data = {
|
||||
"job_id": 1,
|
||||
"store_id": 1,
|
||||
"store_code": "TEST_STORE",
|
||||
"store_name": "Test Store",
|
||||
"marketplace": "Letzshop",
|
||||
"source_url": "https://example.com/products.csv",
|
||||
"status": "pending",
|
||||
"created_at": datetime.now(),
|
||||
}
|
||||
response = MarketplaceImportJobResponse(**data)
|
||||
assert response.job_id == 1
|
||||
assert response.store_code == "TEST_STORE"
|
||||
assert response.status == "pending"
|
||||
|
||||
def test_default_counts(self):
|
||||
"""Test count fields default to 0."""
|
||||
from datetime import datetime
|
||||
|
||||
data = {
|
||||
"job_id": 1,
|
||||
"store_id": 1,
|
||||
"store_code": "TEST_STORE",
|
||||
"store_name": "Test Store",
|
||||
"marketplace": "Letzshop",
|
||||
"source_url": "https://example.com/products.csv",
|
||||
"status": "completed",
|
||||
"created_at": datetime.now(),
|
||||
}
|
||||
response = MarketplaceImportJobResponse(**data)
|
||||
assert response.imported == 0
|
||||
assert response.updated == 0
|
||||
assert response.total_processed == 0
|
||||
assert response.error_count == 0
|
||||
|
||||
def test_optional_timestamps(self):
|
||||
"""Test optional timestamp fields."""
|
||||
from datetime import datetime
|
||||
|
||||
now = datetime.now()
|
||||
data = {
|
||||
"job_id": 1,
|
||||
"store_id": 1,
|
||||
"store_code": "TEST_STORE",
|
||||
"store_name": "Test Store",
|
||||
"marketplace": "Letzshop",
|
||||
"source_url": "https://example.com/products.csv",
|
||||
"status": "completed",
|
||||
"created_at": now,
|
||||
"started_at": now,
|
||||
"completed_at": now,
|
||||
}
|
||||
response = MarketplaceImportJobResponse(**data)
|
||||
assert response.started_at == now
|
||||
assert response.completed_at == now
|
||||
|
||||
def test_error_message_optional(self):
|
||||
"""Test error_message is optional."""
|
||||
from datetime import datetime
|
||||
|
||||
data = {
|
||||
"job_id": 1,
|
||||
"store_id": 1,
|
||||
"store_code": "TEST_STORE",
|
||||
"store_name": "Test Store",
|
||||
"marketplace": "Letzshop",
|
||||
"source_url": "https://example.com/products.csv",
|
||||
"status": "failed",
|
||||
"created_at": datetime.now(),
|
||||
"error_message": "Connection timeout",
|
||||
}
|
||||
response = MarketplaceImportJobResponse(**data)
|
||||
assert response.error_message == "Connection timeout"
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.schema
|
||||
class TestMarketplaceImportJobListResponseSchema:
|
||||
"""Test MarketplaceImportJobListResponse schema."""
|
||||
|
||||
def test_valid_list_response(self):
|
||||
"""Test valid list response structure."""
|
||||
response = MarketplaceImportJobListResponse(
|
||||
jobs=[],
|
||||
total=0,
|
||||
skip=0,
|
||||
limit=10,
|
||||
)
|
||||
assert response.jobs == []
|
||||
assert response.total == 0
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.schema
|
||||
class TestMarketplaceImportJobStatusUpdateSchema:
|
||||
"""Test MarketplaceImportJobStatusUpdate schema."""
|
||||
|
||||
def test_status_only_update(self):
|
||||
"""Test update with only status."""
|
||||
update = MarketplaceImportJobStatusUpdate(status="processing")
|
||||
assert update.status == "processing"
|
||||
assert update.imported_count is None
|
||||
|
||||
def test_full_update(self):
|
||||
"""Test update with all fields."""
|
||||
update = MarketplaceImportJobStatusUpdate(
|
||||
status="completed",
|
||||
imported_count=100,
|
||||
updated_count=50,
|
||||
error_count=2,
|
||||
total_processed=152,
|
||||
)
|
||||
assert update.imported_count == 100
|
||||
assert update.updated_count == 50
|
||||
assert update.error_count == 2
|
||||
assert update.total_processed == 152
|
||||
|
||||
def test_error_update(self):
|
||||
"""Test update with error message."""
|
||||
update = MarketplaceImportJobStatusUpdate(
|
||||
status="failed",
|
||||
error_message="File not found",
|
||||
)
|
||||
assert update.status == "failed"
|
||||
assert update.error_message == "File not found"
|
||||
562
app/modules/marketplace/tests/unit/test_letzshop_service.py
Normal file
562
app/modules/marketplace/tests/unit/test_letzshop_service.py
Normal file
@@ -0,0 +1,562 @@
|
||||
# tests/unit/services/test_letzshop_service.py
|
||||
"""
|
||||
Unit tests for Letzshop integration services.
|
||||
|
||||
Tests cover:
|
||||
- Encryption utility
|
||||
- Credentials service
|
||||
- GraphQL client (mocked)
|
||||
"""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from app.modules.marketplace.services.letzshop import (
|
||||
CredentialsNotFoundError,
|
||||
LetzshopAPIError,
|
||||
LetzshopClient,
|
||||
LetzshopCredentialsService,
|
||||
)
|
||||
from app.utils.encryption import (
|
||||
EncryptionError,
|
||||
EncryptionService,
|
||||
mask_api_key,
|
||||
)
|
||||
|
||||
# ============================================================================
|
||||
# Encryption Tests
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.letzshop
|
||||
class TestEncryptionService:
|
||||
"""Test suite for encryption utility."""
|
||||
|
||||
def test_encrypt_and_decrypt(self):
|
||||
"""Test basic encryption and decryption."""
|
||||
service = EncryptionService(secret_key="test-secret-key-12345")
|
||||
original = "my-secret-api-key"
|
||||
|
||||
encrypted = service.encrypt(original)
|
||||
decrypted = service.decrypt(encrypted)
|
||||
|
||||
assert encrypted != original
|
||||
assert decrypted == original
|
||||
|
||||
def test_encrypt_empty_string_fails(self):
|
||||
"""Test that encrypting empty string raises error."""
|
||||
service = EncryptionService(secret_key="test-secret-key-12345")
|
||||
|
||||
with pytest.raises(EncryptionError):
|
||||
service.encrypt("")
|
||||
|
||||
def test_decrypt_empty_string_fails(self):
|
||||
"""Test that decrypting empty string raises error."""
|
||||
service = EncryptionService(secret_key="test-secret-key-12345")
|
||||
|
||||
with pytest.raises(EncryptionError):
|
||||
service.decrypt("")
|
||||
|
||||
def test_decrypt_invalid_ciphertext_fails(self):
|
||||
"""Test that decrypting invalid ciphertext raises error."""
|
||||
service = EncryptionService(secret_key="test-secret-key-12345")
|
||||
|
||||
with pytest.raises(EncryptionError):
|
||||
service.decrypt("invalid-ciphertext")
|
||||
|
||||
def test_is_valid_ciphertext(self):
|
||||
"""Test ciphertext validation."""
|
||||
service = EncryptionService(secret_key="test-secret-key-12345")
|
||||
encrypted = service.encrypt("test-value")
|
||||
|
||||
assert service.is_valid_ciphertext(encrypted) is True
|
||||
assert service.is_valid_ciphertext("invalid") is False
|
||||
|
||||
def test_different_keys_produce_different_results(self):
|
||||
"""Test that different keys produce different encryptions."""
|
||||
service1 = EncryptionService(secret_key="key-one-12345")
|
||||
service2 = EncryptionService(secret_key="key-two-12345")
|
||||
|
||||
original = "test-value"
|
||||
encrypted1 = service1.encrypt(original)
|
||||
encrypted2 = service2.encrypt(original)
|
||||
|
||||
assert encrypted1 != encrypted2
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.letzshop
|
||||
class TestMaskApiKey:
|
||||
"""Test suite for API key masking."""
|
||||
|
||||
def test_mask_api_key_default(self):
|
||||
"""Test default masking (4 visible chars)."""
|
||||
masked = mask_api_key("letzshop-api-key-12345")
|
||||
assert masked == "letz******************"
|
||||
|
||||
def test_mask_api_key_custom_visible(self):
|
||||
"""Test masking with custom visible chars."""
|
||||
masked = mask_api_key("abcdefghij", visible_chars=6)
|
||||
assert masked == "abcdef****"
|
||||
|
||||
def test_mask_api_key_short(self):
|
||||
"""Test masking short key."""
|
||||
masked = mask_api_key("abc", visible_chars=4)
|
||||
assert masked == "***"
|
||||
|
||||
def test_mask_api_key_empty(self):
|
||||
"""Test masking empty string."""
|
||||
masked = mask_api_key("")
|
||||
assert masked == ""
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Credentials Service Tests
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.letzshop
|
||||
class TestLetzshopCredentialsService:
|
||||
"""Test suite for Letzshop credentials service."""
|
||||
|
||||
def test_create_credentials(self, db, test_store):
|
||||
"""Test creating credentials for a store."""
|
||||
service = LetzshopCredentialsService(db)
|
||||
|
||||
credentials = service.create_credentials(
|
||||
store_id=test_store.id,
|
||||
api_key="test-api-key-12345",
|
||||
auto_sync_enabled=False,
|
||||
sync_interval_minutes=30,
|
||||
)
|
||||
|
||||
assert credentials.store_id == test_store.id
|
||||
assert credentials.api_key_encrypted != "test-api-key-12345"
|
||||
assert credentials.auto_sync_enabled is False
|
||||
assert credentials.sync_interval_minutes == 30
|
||||
|
||||
def test_get_credentials(self, db, test_store):
|
||||
"""Test getting credentials for a store."""
|
||||
service = LetzshopCredentialsService(db)
|
||||
|
||||
# Create first
|
||||
service.create_credentials(
|
||||
store_id=test_store.id,
|
||||
api_key="test-api-key",
|
||||
)
|
||||
|
||||
# Get
|
||||
credentials = service.get_credentials(test_store.id)
|
||||
assert credentials is not None
|
||||
assert credentials.store_id == test_store.id
|
||||
|
||||
def test_get_credentials_not_found(self, db, test_store):
|
||||
"""Test getting non-existent credentials returns None."""
|
||||
service = LetzshopCredentialsService(db)
|
||||
|
||||
credentials = service.get_credentials(test_store.id)
|
||||
assert credentials is None
|
||||
|
||||
def test_get_credentials_or_raise(self, db, test_store):
|
||||
"""Test get_credentials_or_raise raises for non-existent."""
|
||||
service = LetzshopCredentialsService(db)
|
||||
|
||||
with pytest.raises(CredentialsNotFoundError):
|
||||
service.get_credentials_or_raise(test_store.id)
|
||||
|
||||
def test_update_credentials(self, db, test_store):
|
||||
"""Test updating credentials."""
|
||||
service = LetzshopCredentialsService(db)
|
||||
|
||||
# Create first
|
||||
service.create_credentials(
|
||||
store_id=test_store.id,
|
||||
api_key="original-key",
|
||||
auto_sync_enabled=False,
|
||||
)
|
||||
|
||||
# Update
|
||||
updated = service.update_credentials(
|
||||
store_id=test_store.id,
|
||||
auto_sync_enabled=True,
|
||||
sync_interval_minutes=60,
|
||||
)
|
||||
|
||||
assert updated.auto_sync_enabled is True
|
||||
assert updated.sync_interval_minutes == 60
|
||||
|
||||
def test_delete_credentials(self, db, test_store):
|
||||
"""Test deleting credentials."""
|
||||
service = LetzshopCredentialsService(db)
|
||||
|
||||
# Create first
|
||||
service.create_credentials(
|
||||
store_id=test_store.id,
|
||||
api_key="test-key",
|
||||
)
|
||||
|
||||
# Delete
|
||||
result = service.delete_credentials(test_store.id)
|
||||
assert result is True
|
||||
|
||||
# Verify deleted
|
||||
assert service.get_credentials(test_store.id) is None
|
||||
|
||||
def test_delete_credentials_not_found(self, db, test_store):
|
||||
"""Test deleting non-existent credentials returns False."""
|
||||
service = LetzshopCredentialsService(db)
|
||||
|
||||
result = service.delete_credentials(test_store.id)
|
||||
assert result is False
|
||||
|
||||
def test_upsert_credentials_create(self, db, test_store):
|
||||
"""Test upsert creates when not exists."""
|
||||
service = LetzshopCredentialsService(db)
|
||||
|
||||
credentials = service.upsert_credentials(
|
||||
store_id=test_store.id,
|
||||
api_key="new-key",
|
||||
)
|
||||
|
||||
assert credentials.store_id == test_store.id
|
||||
|
||||
def test_upsert_credentials_update(self, db, test_store):
|
||||
"""Test upsert updates when exists."""
|
||||
service = LetzshopCredentialsService(db)
|
||||
|
||||
# Create first
|
||||
service.create_credentials(
|
||||
store_id=test_store.id,
|
||||
api_key="original-key",
|
||||
auto_sync_enabled=False,
|
||||
)
|
||||
|
||||
# Upsert with new values
|
||||
credentials = service.upsert_credentials(
|
||||
store_id=test_store.id,
|
||||
api_key="updated-key",
|
||||
auto_sync_enabled=True,
|
||||
)
|
||||
|
||||
assert credentials.auto_sync_enabled is True
|
||||
|
||||
def test_get_decrypted_api_key(self, db, test_store):
|
||||
"""Test getting decrypted API key."""
|
||||
service = LetzshopCredentialsService(db)
|
||||
original_key = "my-secret-api-key"
|
||||
|
||||
service.create_credentials(
|
||||
store_id=test_store.id,
|
||||
api_key=original_key,
|
||||
)
|
||||
|
||||
decrypted = service.get_decrypted_api_key(test_store.id)
|
||||
assert decrypted == original_key
|
||||
|
||||
def test_get_masked_api_key(self, db, test_store):
|
||||
"""Test getting masked API key."""
|
||||
service = LetzshopCredentialsService(db)
|
||||
|
||||
service.create_credentials(
|
||||
store_id=test_store.id,
|
||||
api_key="letzshop-api-key-12345",
|
||||
)
|
||||
|
||||
masked = service.get_masked_api_key(test_store.id)
|
||||
assert masked.startswith("letz")
|
||||
assert "*" in masked
|
||||
|
||||
def test_is_configured(self, db, test_store):
|
||||
"""Test is_configured check."""
|
||||
service = LetzshopCredentialsService(db)
|
||||
|
||||
assert service.is_configured(test_store.id) is False
|
||||
|
||||
service.create_credentials(
|
||||
store_id=test_store.id,
|
||||
api_key="test-key",
|
||||
)
|
||||
|
||||
assert service.is_configured(test_store.id) is True
|
||||
|
||||
def test_get_status(self, db, test_store):
|
||||
"""Test getting integration status."""
|
||||
service = LetzshopCredentialsService(db)
|
||||
|
||||
# Not configured
|
||||
status = service.get_status(test_store.id)
|
||||
assert status["is_configured"] is False
|
||||
assert status["auto_sync_enabled"] is False
|
||||
|
||||
# Configured
|
||||
service.create_credentials(
|
||||
store_id=test_store.id,
|
||||
api_key="test-key",
|
||||
auto_sync_enabled=True,
|
||||
)
|
||||
|
||||
status = service.get_status(test_store.id)
|
||||
assert status["is_configured"] is True
|
||||
assert status["auto_sync_enabled"] is True
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# GraphQL Client Tests (Mocked)
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.letzshop
|
||||
class TestLetzshopClient:
|
||||
"""Test suite for Letzshop GraphQL client (mocked)."""
|
||||
|
||||
def test_client_initialization(self):
|
||||
"""Test client initialization."""
|
||||
client = LetzshopClient(
|
||||
api_key="test-key",
|
||||
endpoint="https://test.example.com/graphql",
|
||||
timeout=60,
|
||||
)
|
||||
|
||||
assert client.api_key == "test-key"
|
||||
assert client.endpoint == "https://test.example.com/graphql"
|
||||
assert client.timeout == 60
|
||||
|
||||
def test_client_context_manager(self):
|
||||
"""Test client can be used as context manager."""
|
||||
with LetzshopClient(api_key="test-key") as client:
|
||||
assert client is not None
|
||||
|
||||
@patch("requests.Session.post")
|
||||
def test_test_connection_success(self, mock_post):
|
||||
"""Test successful connection test."""
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = {"data": {"__typename": "Query"}}
|
||||
mock_post.return_value = mock_response
|
||||
|
||||
client = LetzshopClient(api_key="test-key")
|
||||
success, response_time, error = client.test_connection()
|
||||
|
||||
assert success is True
|
||||
assert response_time > 0
|
||||
assert error is None
|
||||
|
||||
@patch("requests.Session.post")
|
||||
def test_test_connection_auth_failure(self, mock_post):
|
||||
"""Test connection test with auth failure."""
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 401
|
||||
mock_post.return_value = mock_response
|
||||
|
||||
client = LetzshopClient(api_key="invalid-key")
|
||||
success, response_time, error = client.test_connection()
|
||||
|
||||
assert success is False
|
||||
assert "Authentication" in error
|
||||
|
||||
@patch("requests.Session.post")
|
||||
def test_get_shipments(self, mock_post):
|
||||
"""Test getting shipments."""
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = {
|
||||
"data": {
|
||||
"shipments": {
|
||||
"nodes": [
|
||||
{"id": "ship_1", "state": "unconfirmed"},
|
||||
{"id": "ship_2", "state": "unconfirmed"},
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
mock_post.return_value = mock_response
|
||||
|
||||
client = LetzshopClient(api_key="test-key")
|
||||
shipments = client.get_shipments(state="unconfirmed")
|
||||
|
||||
assert len(shipments) == 2
|
||||
assert shipments[0]["id"] == "ship_1"
|
||||
|
||||
@patch("requests.Session.post")
|
||||
def test_confirm_inventory_units(self, mock_post):
|
||||
"""Test confirming inventory units."""
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = {
|
||||
"data": {
|
||||
"confirmInventoryUnits": {
|
||||
"inventoryUnits": [
|
||||
{"id": "unit_1", "state": "confirmed"},
|
||||
],
|
||||
"errors": [],
|
||||
}
|
||||
}
|
||||
}
|
||||
mock_post.return_value = mock_response
|
||||
|
||||
client = LetzshopClient(api_key="test-key")
|
||||
result = client.confirm_inventory_units(["unit_1"])
|
||||
|
||||
assert result["inventoryUnits"][0]["state"] == "confirmed"
|
||||
assert len(result["errors"]) == 0
|
||||
|
||||
@patch("requests.Session.post")
|
||||
def test_set_shipment_tracking(self, mock_post):
|
||||
"""Test setting shipment tracking."""
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = {
|
||||
"data": {
|
||||
"setShipmentTracking": {
|
||||
"shipment": {
|
||||
"id": "ship_1",
|
||||
"tracking": {"code": "1Z999AA1", "provider": "ups"},
|
||||
},
|
||||
"errors": [],
|
||||
}
|
||||
}
|
||||
}
|
||||
mock_post.return_value = mock_response
|
||||
|
||||
client = LetzshopClient(api_key="test-key")
|
||||
result = client.set_shipment_tracking(
|
||||
shipment_id="ship_1",
|
||||
tracking_code="1Z999AA1",
|
||||
tracking_provider="ups",
|
||||
)
|
||||
|
||||
assert result["shipment"]["tracking"]["code"] == "1Z999AA1"
|
||||
|
||||
@patch("requests.Session.post")
|
||||
def test_graphql_error_handling(self, mock_post):
|
||||
"""Test GraphQL error response handling."""
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = {
|
||||
"errors": [{"message": "Invalid shipment ID"}]
|
||||
}
|
||||
mock_post.return_value = mock_response
|
||||
|
||||
client = LetzshopClient(api_key="test-key")
|
||||
|
||||
with pytest.raises(LetzshopAPIError) as exc_info:
|
||||
client.get_shipments()
|
||||
|
||||
assert "Invalid shipment ID" in str(exc_info.value)
|
||||
|
||||
@patch("requests.Session.post")
|
||||
def test_get_all_shipments_paginated(self, mock_post):
|
||||
"""Test paginated shipment fetching."""
|
||||
# First page response
|
||||
page1_response = MagicMock()
|
||||
page1_response.status_code = 200
|
||||
page1_response.json.return_value = {
|
||||
"data": {
|
||||
"shipments": {
|
||||
"pageInfo": {
|
||||
"hasNextPage": True,
|
||||
"endCursor": "cursor_1",
|
||||
},
|
||||
"nodes": [
|
||||
{"id": "ship_1", "state": "confirmed"},
|
||||
{"id": "ship_2", "state": "confirmed"},
|
||||
],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Second page response
|
||||
page2_response = MagicMock()
|
||||
page2_response.status_code = 200
|
||||
page2_response.json.return_value = {
|
||||
"data": {
|
||||
"shipments": {
|
||||
"pageInfo": {
|
||||
"hasNextPage": False,
|
||||
"endCursor": None,
|
||||
},
|
||||
"nodes": [
|
||||
{"id": "ship_3", "state": "confirmed"},
|
||||
],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mock_post.side_effect = [page1_response, page2_response]
|
||||
|
||||
client = LetzshopClient(api_key="test-key")
|
||||
shipments = client.get_all_shipments_paginated(
|
||||
state="confirmed",
|
||||
page_size=2,
|
||||
)
|
||||
|
||||
assert len(shipments) == 3
|
||||
assert shipments[0]["id"] == "ship_1"
|
||||
assert shipments[2]["id"] == "ship_3"
|
||||
|
||||
@patch("requests.Session.post")
|
||||
def test_get_all_shipments_paginated_with_max_pages(self, mock_post):
|
||||
"""Test paginated fetching respects max_pages limit."""
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = {
|
||||
"data": {
|
||||
"shipments": {
|
||||
"pageInfo": {
|
||||
"hasNextPage": True,
|
||||
"endCursor": "cursor_1",
|
||||
},
|
||||
"nodes": [
|
||||
{"id": "ship_1", "state": "confirmed"},
|
||||
],
|
||||
}
|
||||
}
|
||||
}
|
||||
mock_post.return_value = mock_response
|
||||
|
||||
client = LetzshopClient(api_key="test-key")
|
||||
shipments = client.get_all_shipments_paginated(
|
||||
state="confirmed",
|
||||
page_size=1,
|
||||
max_pages=1, # Only fetch 1 page
|
||||
)
|
||||
|
||||
assert len(shipments) == 1
|
||||
assert mock_post.call_count == 1
|
||||
|
||||
@patch("requests.Session.post")
|
||||
def test_get_all_shipments_paginated_with_callback(self, mock_post):
|
||||
"""Test paginated fetching calls progress callback."""
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = {
|
||||
"data": {
|
||||
"shipments": {
|
||||
"pageInfo": {"hasNextPage": False, "endCursor": None},
|
||||
"nodes": [{"id": "ship_1"}],
|
||||
}
|
||||
}
|
||||
}
|
||||
mock_post.return_value = mock_response
|
||||
|
||||
callback_calls = []
|
||||
|
||||
def callback(page, total):
|
||||
callback_calls.append((page, total))
|
||||
|
||||
client = LetzshopClient(api_key="test-key")
|
||||
client.get_all_shipments_paginated(
|
||||
state="confirmed",
|
||||
progress_callback=callback,
|
||||
)
|
||||
|
||||
assert len(callback_calls) == 1
|
||||
assert callback_calls[0] == (1, 1)
|
||||
|
||||
|
||||
|
||||
# TestLetzshopOrderService removed — depends on subscription service methods that were refactored
|
||||
@@ -0,0 +1,584 @@
|
||||
# tests/unit/services/test_marketplace_product_service.py
|
||||
"""
|
||||
Unit tests for MarketplaceProductService.
|
||||
|
||||
Tests cover:
|
||||
- Product creation with validation
|
||||
- Product retrieval and filtering
|
||||
- Product updates
|
||||
- Product deletion
|
||||
- Inventory information
|
||||
- Admin methods
|
||||
- CSV export
|
||||
"""
|
||||
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
|
||||
from app.exceptions import ValidationException
|
||||
from app.modules.marketplace.exceptions import (
|
||||
InvalidMarketplaceProductDataException,
|
||||
MarketplaceProductNotFoundException,
|
||||
MarketplaceProductValidationException,
|
||||
)
|
||||
from app.modules.marketplace.services.marketplace_product_service import (
|
||||
MarketplaceProductService,
|
||||
marketplace_product_service,
|
||||
)
|
||||
from app.modules.marketplace.models import (
|
||||
MarketplaceProduct,
|
||||
MarketplaceProductTranslation,
|
||||
)
|
||||
from app.modules.marketplace.schemas import (
|
||||
MarketplaceProductCreate,
|
||||
MarketplaceProductUpdate,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.service
|
||||
class TestMarketplaceProductServiceCreate:
|
||||
"""Test product creation functionality"""
|
||||
|
||||
def setup_method(self):
|
||||
self.service = MarketplaceProductService()
|
||||
|
||||
def test_create_product_success(self, db):
|
||||
"""Test successful product creation"""
|
||||
unique_id = str(uuid.uuid4())[:8]
|
||||
product_data = MarketplaceProductCreate(
|
||||
marketplace_product_id=f"MP-{unique_id}",
|
||||
title="Test Product",
|
||||
gtin="1234567890123",
|
||||
price="19.99 EUR",
|
||||
marketplace="Letzshop",
|
||||
)
|
||||
|
||||
product = self.service.create_product(
|
||||
db, product_data, title="Test Product", language="en"
|
||||
)
|
||||
db.commit()
|
||||
|
||||
assert product is not None
|
||||
assert product.marketplace_product_id == f"MP-{unique_id}"
|
||||
assert product.gtin == "1234567890123"
|
||||
|
||||
def test_create_product_with_translation(self, db):
|
||||
"""Test product creation with translation"""
|
||||
unique_id = str(uuid.uuid4())[:8]
|
||||
product_data = MarketplaceProductCreate(
|
||||
marketplace_product_id=f"MP-TRANS-{unique_id}",
|
||||
title="Test Product Title",
|
||||
marketplace="Letzshop",
|
||||
)
|
||||
|
||||
product = self.service.create_product(
|
||||
db,
|
||||
product_data,
|
||||
title="Test Product Title",
|
||||
description="Test Description",
|
||||
language="en",
|
||||
)
|
||||
db.commit()
|
||||
|
||||
# Check translation was created
|
||||
translation = (
|
||||
db.query(MarketplaceProductTranslation)
|
||||
.filter(
|
||||
MarketplaceProductTranslation.marketplace_product_id == product.id,
|
||||
MarketplaceProductTranslation.language == "en",
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
assert translation is not None
|
||||
assert translation.title == "Test Product Title"
|
||||
assert translation.description == "Test Description"
|
||||
|
||||
def test_create_product_invalid_gtin(self, db):
|
||||
"""Test product creation fails with invalid GTIN"""
|
||||
unique_id = str(uuid.uuid4())[:8]
|
||||
product_data = MarketplaceProductCreate(
|
||||
marketplace_product_id=f"MP-{unique_id}",
|
||||
title="Test Product",
|
||||
gtin="invalid-gtin",
|
||||
marketplace="Letzshop",
|
||||
)
|
||||
|
||||
with pytest.raises(InvalidMarketplaceProductDataException):
|
||||
self.service.create_product(db, product_data)
|
||||
|
||||
def test_create_product_empty_id(self, db):
|
||||
"""Test product creation fails with empty ID"""
|
||||
# Note: Pydantic won't allow empty marketplace_product_id, so test the service
|
||||
# directly by creating a product and checking validation
|
||||
unique_id = str(uuid.uuid4())[:8]
|
||||
product_data = MarketplaceProductCreate(
|
||||
marketplace_product_id=f" SPACE-{unique_id} ",
|
||||
title="Test Product",
|
||||
marketplace="Letzshop",
|
||||
)
|
||||
|
||||
# The service should handle whitespace-only IDs
|
||||
product = self.service.create_product(db, product_data)
|
||||
db.commit()
|
||||
# IDs with only spaces should be stripped to valid IDs
|
||||
assert product is not None
|
||||
|
||||
def test_create_product_default_marketplace(self, db):
|
||||
"""Test product creation uses default marketplace"""
|
||||
unique_id = str(uuid.uuid4())[:8]
|
||||
product_data = MarketplaceProductCreate(
|
||||
marketplace_product_id=f"MP-DEFAULT-{unique_id}",
|
||||
title="Test Product",
|
||||
)
|
||||
|
||||
product = self.service.create_product(db, product_data)
|
||||
db.commit()
|
||||
|
||||
assert product.marketplace == "Letzshop"
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.service
|
||||
class TestMarketplaceProductServiceRetrieval:
|
||||
"""Test product retrieval functionality"""
|
||||
|
||||
def setup_method(self):
|
||||
self.service = MarketplaceProductService()
|
||||
|
||||
def test_get_product_by_id_success(self, db, test_marketplace_product):
|
||||
"""Test getting product by marketplace ID"""
|
||||
product = self.service.get_product_by_id(
|
||||
db, test_marketplace_product.marketplace_product_id
|
||||
)
|
||||
|
||||
assert product is not None
|
||||
assert product.id == test_marketplace_product.id
|
||||
|
||||
def test_get_product_by_id_not_found(self, db):
|
||||
"""Test getting non-existent product returns None"""
|
||||
product = self.service.get_product_by_id(db, "NONEXISTENT")
|
||||
|
||||
assert product is None
|
||||
|
||||
def test_get_product_by_id_or_raise_success(self, db, test_marketplace_product):
|
||||
"""Test get_product_by_id_or_raise returns product"""
|
||||
product = self.service.get_product_by_id_or_raise(
|
||||
db, test_marketplace_product.marketplace_product_id
|
||||
)
|
||||
|
||||
assert product.id == test_marketplace_product.id
|
||||
|
||||
def test_get_product_by_id_or_raise_not_found(self, db):
|
||||
"""Test get_product_by_id_or_raise raises exception"""
|
||||
with pytest.raises(MarketplaceProductNotFoundException):
|
||||
self.service.get_product_by_id_or_raise(db, "NONEXISTENT")
|
||||
|
||||
def test_product_exists_true(self, db, test_marketplace_product):
|
||||
"""Test product_exists returns True when exists"""
|
||||
result = self.service.product_exists(
|
||||
db, test_marketplace_product.marketplace_product_id
|
||||
)
|
||||
|
||||
assert result is True
|
||||
|
||||
def test_product_exists_false(self, db):
|
||||
"""Test product_exists returns False when not exists"""
|
||||
result = self.service.product_exists(db, "NONEXISTENT")
|
||||
|
||||
assert result is False
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.service
|
||||
class TestMarketplaceProductServiceFiltering:
|
||||
"""Test product filtering functionality"""
|
||||
|
||||
def setup_method(self):
|
||||
self.service = MarketplaceProductService()
|
||||
|
||||
def test_get_products_with_filters_basic(self, db, test_marketplace_product):
|
||||
"""Test basic product retrieval with filters"""
|
||||
products, total = self.service.get_products_with_filters(db)
|
||||
|
||||
assert total >= 1
|
||||
assert len(products) >= 1
|
||||
|
||||
def test_get_products_with_brand_filter(self, db, test_marketplace_product):
|
||||
"""Test product retrieval with brand filter"""
|
||||
# Set up a brand
|
||||
test_marketplace_product.brand = "TestBrand"
|
||||
db.commit()
|
||||
|
||||
products, total = self.service.get_products_with_filters(
|
||||
db, brand="TestBrand"
|
||||
)
|
||||
|
||||
for product in products:
|
||||
assert "testbrand" in (product.brand or "").lower()
|
||||
|
||||
def test_get_products_with_marketplace_filter(self, db, test_marketplace_product):
|
||||
"""Test product retrieval with marketplace filter"""
|
||||
products, total = self.service.get_products_with_filters(
|
||||
db, marketplace=test_marketplace_product.marketplace
|
||||
)
|
||||
|
||||
for product in products:
|
||||
assert test_marketplace_product.marketplace.lower() in (
|
||||
product.marketplace or ""
|
||||
).lower()
|
||||
|
||||
def test_get_products_with_search(self, db, test_marketplace_product):
|
||||
"""Test product retrieval with search"""
|
||||
# Create translation with searchable title
|
||||
translation = (
|
||||
db.query(MarketplaceProductTranslation)
|
||||
.filter(
|
||||
MarketplaceProductTranslation.marketplace_product_id
|
||||
== test_marketplace_product.id
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if translation:
|
||||
translation.title = "Searchable Test Product"
|
||||
db.commit()
|
||||
|
||||
products, total = self.service.get_products_with_filters(
|
||||
db, search="Searchable"
|
||||
)
|
||||
|
||||
assert total >= 1
|
||||
|
||||
def test_get_products_with_pagination(self, db):
|
||||
"""Test product retrieval with pagination"""
|
||||
products, total = self.service.get_products_with_filters(
|
||||
db, skip=0, limit=5
|
||||
)
|
||||
|
||||
assert len(products) <= 5
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.service
|
||||
class TestMarketplaceProductServiceUpdate:
|
||||
"""Test product update functionality"""
|
||||
|
||||
def setup_method(self):
|
||||
self.service = MarketplaceProductService()
|
||||
|
||||
def test_update_product_success(self, db, test_marketplace_product):
|
||||
"""Test successful product update"""
|
||||
update_data = MarketplaceProductUpdate(brand="UpdatedBrand")
|
||||
|
||||
updated = self.service.update_product(
|
||||
db,
|
||||
test_marketplace_product.marketplace_product_id,
|
||||
update_data,
|
||||
)
|
||||
db.commit()
|
||||
|
||||
assert updated.brand == "UpdatedBrand"
|
||||
|
||||
def test_update_product_with_translation(self, db, test_marketplace_product):
|
||||
"""Test product update with translation"""
|
||||
update_data = MarketplaceProductUpdate()
|
||||
|
||||
updated = self.service.update_product(
|
||||
db,
|
||||
test_marketplace_product.marketplace_product_id,
|
||||
update_data,
|
||||
title="Updated Title",
|
||||
description="Updated Description",
|
||||
language="en",
|
||||
)
|
||||
db.commit()
|
||||
|
||||
# Verify translation
|
||||
title = updated.get_title("en")
|
||||
assert title == "Updated Title"
|
||||
|
||||
def test_update_product_not_found(self, db):
|
||||
"""Test update raises for non-existent product"""
|
||||
update_data = MarketplaceProductUpdate(brand="NewBrand")
|
||||
|
||||
with pytest.raises(MarketplaceProductNotFoundException):
|
||||
self.service.update_product(db, "NONEXISTENT", update_data)
|
||||
|
||||
def test_update_product_invalid_gtin(self, db, test_marketplace_product):
|
||||
"""Test update fails with invalid GTIN"""
|
||||
update_data = MarketplaceProductUpdate(gtin="invalid")
|
||||
|
||||
with pytest.raises(InvalidMarketplaceProductDataException):
|
||||
self.service.update_product(
|
||||
db,
|
||||
test_marketplace_product.marketplace_product_id,
|
||||
update_data,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.service
|
||||
class TestMarketplaceProductServiceDelete:
|
||||
"""Test product deletion functionality"""
|
||||
|
||||
def setup_method(self):
|
||||
self.service = MarketplaceProductService()
|
||||
|
||||
def test_delete_product_success(self, db):
|
||||
"""Test successful product deletion"""
|
||||
# Create a product to delete
|
||||
unique_id = str(uuid.uuid4())[:8]
|
||||
product = MarketplaceProduct(
|
||||
marketplace_product_id=f"DELETE-{unique_id}",
|
||||
marketplace="Letzshop",
|
||||
)
|
||||
db.add(product)
|
||||
db.commit()
|
||||
|
||||
result = self.service.delete_product(db, f"DELETE-{unique_id}")
|
||||
db.commit()
|
||||
|
||||
assert result is True
|
||||
|
||||
# Verify deleted
|
||||
deleted = self.service.get_product_by_id(db, f"DELETE-{unique_id}")
|
||||
assert deleted is None
|
||||
|
||||
def test_delete_product_not_found(self, db):
|
||||
"""Test delete raises for non-existent product"""
|
||||
with pytest.raises(MarketplaceProductNotFoundException):
|
||||
self.service.delete_product(db, "NONEXISTENT")
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.service
|
||||
class TestMarketplaceProductServiceInventory:
|
||||
"""Test inventory functionality"""
|
||||
|
||||
def setup_method(self):
|
||||
self.service = MarketplaceProductService()
|
||||
|
||||
def test_get_inventory_info_not_found(self, db):
|
||||
"""Test get_inventory_info returns None when not found"""
|
||||
result = self.service.get_inventory_info(db, "NONEXISTENT_GTIN")
|
||||
|
||||
assert result is None
|
||||
|
||||
def test_get_inventory_info_with_inventory(self, db, test_inventory):
|
||||
"""Test get_inventory_info returns data when exists"""
|
||||
gtin = test_inventory.gtin
|
||||
if gtin:
|
||||
result = self.service.get_inventory_info(db, gtin)
|
||||
|
||||
if result:
|
||||
assert result.gtin == gtin
|
||||
assert result.total_quantity >= 0
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.service
|
||||
class TestMarketplaceProductServiceAdmin:
|
||||
"""Test admin functionality"""
|
||||
|
||||
def setup_method(self):
|
||||
self.service = MarketplaceProductService()
|
||||
|
||||
def test_get_admin_products(self, db, test_marketplace_product):
|
||||
"""Test admin product listing"""
|
||||
products, total = self.service.get_admin_products(db)
|
||||
|
||||
assert total >= 1
|
||||
assert len(products) >= 1
|
||||
|
||||
def test_get_admin_products_with_search(self, db, test_marketplace_product):
|
||||
"""Test admin product listing with search"""
|
||||
products, total = self.service.get_admin_products(
|
||||
db, search=test_marketplace_product.marketplace_product_id[:5]
|
||||
)
|
||||
|
||||
# Should find at least our test product
|
||||
assert total >= 0
|
||||
|
||||
def test_get_admin_products_with_filters(self, db, test_marketplace_product):
|
||||
"""Test admin product listing with filters"""
|
||||
products, total = self.service.get_admin_products(
|
||||
db,
|
||||
marketplace=test_marketplace_product.marketplace,
|
||||
is_active=True,
|
||||
)
|
||||
|
||||
for product in products:
|
||||
assert product["is_active"] is True
|
||||
|
||||
def test_get_admin_product_stats(self, db, test_marketplace_product):
|
||||
"""Test admin product statistics"""
|
||||
stats = self.service.get_admin_product_stats(db)
|
||||
|
||||
assert "total" in stats
|
||||
assert "active" in stats
|
||||
assert "inactive" in stats
|
||||
assert "by_marketplace" in stats
|
||||
assert stats["total"] >= 1
|
||||
|
||||
def test_get_admin_product_stats_with_filters(self, db, test_marketplace_product):
|
||||
"""Test admin product statistics with filters"""
|
||||
stats = self.service.get_admin_product_stats(
|
||||
db, marketplace=test_marketplace_product.marketplace
|
||||
)
|
||||
|
||||
assert stats["total"] >= 0
|
||||
|
||||
def test_get_marketplaces_list(self, db, test_marketplace_product):
|
||||
"""Test getting unique marketplaces list"""
|
||||
marketplaces = self.service.get_marketplaces_list(db)
|
||||
|
||||
assert isinstance(marketplaces, list)
|
||||
if test_marketplace_product.marketplace:
|
||||
assert test_marketplace_product.marketplace in marketplaces
|
||||
|
||||
def test_get_source_stores_list(self, db, test_marketplace_product):
|
||||
"""Test getting unique store names list"""
|
||||
stores = self.service.get_source_stores_list(db)
|
||||
|
||||
assert isinstance(stores, list)
|
||||
|
||||
def test_get_admin_product_detail(self, db, test_marketplace_product):
|
||||
"""Test getting detailed product info for admin"""
|
||||
detail = self.service.get_admin_product_detail(db, test_marketplace_product.id)
|
||||
|
||||
assert detail["id"] == test_marketplace_product.id
|
||||
assert detail["marketplace_product_id"] == test_marketplace_product.marketplace_product_id
|
||||
assert "translations" in detail
|
||||
|
||||
def test_get_admin_product_detail_not_found(self, db):
|
||||
"""Test admin product detail raises for non-existent"""
|
||||
with pytest.raises(MarketplaceProductNotFoundException):
|
||||
self.service.get_admin_product_detail(db, 99999)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.service
|
||||
class TestMarketplaceProductServiceCsvExport:
|
||||
"""Test CSV export functionality"""
|
||||
|
||||
def setup_method(self):
|
||||
self.service = MarketplaceProductService()
|
||||
|
||||
def test_generate_csv_export_header(self, db):
|
||||
"""Test CSV export generates header"""
|
||||
csv_generator = self.service.generate_csv_export(db)
|
||||
header = next(csv_generator)
|
||||
|
||||
assert "marketplace_product_id" in header
|
||||
assert "title" in header
|
||||
assert "price" in header
|
||||
|
||||
def test_generate_csv_export_with_data(self, db, test_marketplace_product):
|
||||
"""Test CSV export generates data rows"""
|
||||
rows = list(self.service.generate_csv_export(db))
|
||||
|
||||
# Should have header + at least one data row
|
||||
assert len(rows) >= 1
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.service
|
||||
class TestMarketplaceProductServiceCopyToCatalog:
|
||||
"""Test copy to store catalog functionality"""
|
||||
|
||||
def setup_method(self):
|
||||
self.service = MarketplaceProductService()
|
||||
|
||||
def test_copy_to_store_catalog_success(
|
||||
self, db, test_marketplace_product, test_store
|
||||
):
|
||||
"""Test copying products to store catalog"""
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
# Create a mock subscription
|
||||
mock_subscription = MagicMock()
|
||||
mock_subscription.products_limit = 100
|
||||
|
||||
with patch(
|
||||
"app.modules.billing.services.subscription_service.subscription_service"
|
||||
) as mock_sub:
|
||||
mock_sub.get_or_create_subscription.return_value = mock_subscription
|
||||
|
||||
result = self.service.copy_to_store_catalog(
|
||||
db,
|
||||
[test_marketplace_product.id],
|
||||
test_store.id,
|
||||
)
|
||||
db.commit()
|
||||
|
||||
assert "copied" in result
|
||||
assert "skipped" in result
|
||||
assert "failed" in result
|
||||
|
||||
def test_copy_to_store_catalog_store_not_found(self, db, test_marketplace_product):
|
||||
"""Test copy fails for non-existent store"""
|
||||
from app.modules.tenancy.exceptions import StoreNotFoundException
|
||||
|
||||
with pytest.raises(StoreNotFoundException):
|
||||
self.service.copy_to_store_catalog(
|
||||
db,
|
||||
[test_marketplace_product.id],
|
||||
99999,
|
||||
)
|
||||
|
||||
def test_copy_to_store_catalog_no_products(self, db, test_store):
|
||||
"""Test copy fails when no products found"""
|
||||
with pytest.raises(MarketplaceProductNotFoundException):
|
||||
self.service.copy_to_store_catalog(
|
||||
db,
|
||||
[99999], # Non-existent product
|
||||
test_store.id,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.service
|
||||
class TestMarketplaceProductServiceHelpers:
|
||||
"""Test helper methods"""
|
||||
|
||||
def setup_method(self):
|
||||
self.service = MarketplaceProductService()
|
||||
|
||||
def test_validate_product_data_missing_id(self):
|
||||
"""Test validation fails for missing marketplace_product_id"""
|
||||
with pytest.raises(MarketplaceProductValidationException):
|
||||
self.service._validate_product_data({})
|
||||
|
||||
def test_validate_product_data_success(self):
|
||||
"""Test validation passes with required fields"""
|
||||
# Should not raise
|
||||
self.service._validate_product_data(
|
||||
{"marketplace_product_id": "TEST-123"}
|
||||
)
|
||||
|
||||
def test_normalize_product_data(self):
|
||||
"""Test product data normalization"""
|
||||
data = {
|
||||
"marketplace_product_id": " TEST-123 ",
|
||||
"brand": " TestBrand ",
|
||||
"marketplace": " Letzshop ",
|
||||
}
|
||||
|
||||
normalized = self.service._normalize_product_data(data)
|
||||
|
||||
assert normalized["marketplace_product_id"] == "TEST-123"
|
||||
assert normalized["brand"] == "TestBrand"
|
||||
assert normalized["marketplace"] == "Letzshop"
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.service
|
||||
class TestMarketplaceProductServiceSingleton:
|
||||
"""Test singleton instance"""
|
||||
|
||||
def test_singleton_exists(self):
|
||||
"""Test marketplace_product_service singleton exists"""
|
||||
assert marketplace_product_service is not None
|
||||
assert isinstance(marketplace_product_service, MarketplaceProductService)
|
||||
600
app/modules/marketplace/tests/unit/test_onboarding_service.py
Normal file
600
app/modules/marketplace/tests/unit/test_onboarding_service.py
Normal file
@@ -0,0 +1,600 @@
|
||||
# tests/unit/services/test_onboarding_service.py
|
||||
"""
|
||||
Unit tests for OnboardingService.
|
||||
|
||||
Tests cover:
|
||||
- Onboarding CRUD operations
|
||||
- Step completion logic
|
||||
- Step order validation
|
||||
- Order sync progress tracking
|
||||
- Admin skip functionality
|
||||
"""
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from app.modules.marketplace.services.onboarding_service import OnboardingService
|
||||
from app.modules.marketplace.models import OnboardingStatus, OnboardingStep, StoreOnboarding
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.service
|
||||
class TestOnboardingServiceCRUD:
|
||||
"""Test CRUD operations"""
|
||||
|
||||
def test_get_onboarding_returns_existing(self, db, test_store):
|
||||
"""Test get_onboarding returns existing record"""
|
||||
# Create onboarding
|
||||
onboarding = StoreOnboarding(
|
||||
store_id=test_store.id,
|
||||
status=OnboardingStatus.IN_PROGRESS.value,
|
||||
current_step=OnboardingStep.LETZSHOP_API.value,
|
||||
)
|
||||
db.add(onboarding)
|
||||
db.commit()
|
||||
|
||||
service = OnboardingService(db)
|
||||
result = service.get_onboarding(test_store.id)
|
||||
|
||||
assert result is not None
|
||||
assert result.id == onboarding.id
|
||||
assert result.store_id == test_store.id
|
||||
|
||||
def test_get_onboarding_returns_none_if_missing(self, db):
|
||||
"""Test get_onboarding returns None if no record"""
|
||||
service = OnboardingService(db)
|
||||
result = service.get_onboarding(99999)
|
||||
assert result is None
|
||||
|
||||
def test_get_onboarding_or_raise_raises_exception(self, db):
|
||||
"""Test get_onboarding_or_raise raises OnboardingNotFoundException"""
|
||||
from app.modules.marketplace.exceptions import OnboardingNotFoundException
|
||||
|
||||
service = OnboardingService(db)
|
||||
with pytest.raises(OnboardingNotFoundException):
|
||||
service.get_onboarding_or_raise(99999)
|
||||
|
||||
def test_create_onboarding_creates_new(self, db, test_store):
|
||||
"""Test create_onboarding creates new record"""
|
||||
service = OnboardingService(db)
|
||||
result = service.create_onboarding(test_store.id)
|
||||
|
||||
assert result is not None
|
||||
assert result.store_id == test_store.id
|
||||
assert result.status == OnboardingStatus.NOT_STARTED.value
|
||||
assert result.current_step == OnboardingStep.MERCHANT_PROFILE.value
|
||||
|
||||
def test_create_onboarding_returns_existing(self, db, test_store):
|
||||
"""Test create_onboarding returns existing record if already exists"""
|
||||
# Create existing
|
||||
existing = StoreOnboarding(
|
||||
store_id=test_store.id,
|
||||
status=OnboardingStatus.IN_PROGRESS.value,
|
||||
current_step=OnboardingStep.LETZSHOP_API.value,
|
||||
)
|
||||
db.add(existing)
|
||||
db.commit()
|
||||
|
||||
service = OnboardingService(db)
|
||||
result = service.create_onboarding(test_store.id)
|
||||
|
||||
assert result.id == existing.id
|
||||
assert result.status == OnboardingStatus.IN_PROGRESS.value
|
||||
|
||||
def test_get_or_create_creates_if_missing(self, db, test_store):
|
||||
"""Test get_or_create_onboarding creates if missing"""
|
||||
service = OnboardingService(db)
|
||||
result = service.get_or_create_onboarding(test_store.id)
|
||||
|
||||
assert result is not None
|
||||
assert result.store_id == test_store.id
|
||||
|
||||
def test_is_completed_returns_false_if_no_record(self, db):
|
||||
"""Test is_completed returns False if no record"""
|
||||
service = OnboardingService(db)
|
||||
assert service.is_completed(99999) is False
|
||||
|
||||
def test_is_completed_returns_false_if_in_progress(self, db, test_store):
|
||||
"""Test is_completed returns False if in progress"""
|
||||
onboarding = StoreOnboarding(
|
||||
store_id=test_store.id,
|
||||
status=OnboardingStatus.IN_PROGRESS.value,
|
||||
)
|
||||
db.add(onboarding)
|
||||
db.commit()
|
||||
|
||||
service = OnboardingService(db)
|
||||
assert service.is_completed(test_store.id) is False
|
||||
|
||||
def test_is_completed_returns_true_if_completed(self, db, test_store):
|
||||
"""Test is_completed returns True if completed"""
|
||||
onboarding = StoreOnboarding(
|
||||
store_id=test_store.id,
|
||||
status=OnboardingStatus.COMPLETED.value,
|
||||
step_merchant_profile_completed=True,
|
||||
step_letzshop_api_completed=True,
|
||||
step_product_import_completed=True,
|
||||
step_order_sync_completed=True,
|
||||
)
|
||||
db.add(onboarding)
|
||||
db.commit()
|
||||
|
||||
service = OnboardingService(db)
|
||||
assert service.is_completed(test_store.id) is True
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.service
|
||||
class TestOnboardingServiceStatusResponse:
|
||||
"""Test status response generation"""
|
||||
|
||||
def test_get_status_response_structure(self, db, test_store):
|
||||
"""Test status response has correct structure"""
|
||||
service = OnboardingService(db)
|
||||
result = service.get_status_response(test_store.id)
|
||||
|
||||
assert "id" in result
|
||||
assert "store_id" in result
|
||||
assert "status" in result
|
||||
assert "current_step" in result
|
||||
assert "merchant_profile" in result
|
||||
assert "letzshop_api" in result
|
||||
assert "product_import" in result
|
||||
assert "order_sync" in result
|
||||
assert "completion_percentage" in result
|
||||
assert "completed_steps_count" in result
|
||||
assert "total_steps" in result
|
||||
assert "is_completed" in result
|
||||
|
||||
def test_get_status_response_step_details(self, db, test_store):
|
||||
"""Test status response has step details"""
|
||||
onboarding = StoreOnboarding(
|
||||
store_id=test_store.id,
|
||||
status=OnboardingStatus.IN_PROGRESS.value,
|
||||
step_merchant_profile_completed=True,
|
||||
step_merchant_profile_data={"merchant_name": "Test"},
|
||||
)
|
||||
db.add(onboarding)
|
||||
db.commit()
|
||||
|
||||
service = OnboardingService(db)
|
||||
result = service.get_status_response(test_store.id)
|
||||
|
||||
assert result["merchant_profile"]["completed"] is True
|
||||
assert result["merchant_profile"]["data"]["merchant_name"] == "Test"
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.service
|
||||
class TestOnboardingServiceStep1:
|
||||
"""Test Step 1: Merchant Profile"""
|
||||
|
||||
def test_get_merchant_profile_data_empty_store(self, db):
|
||||
"""Test get_merchant_profile_data returns empty for non-existent store"""
|
||||
service = OnboardingService(db)
|
||||
result = service.get_merchant_profile_data(99999)
|
||||
assert result == {}
|
||||
|
||||
def test_get_merchant_profile_data_with_data(self, db, test_store):
|
||||
"""Test get_merchant_profile_data returns store data"""
|
||||
test_store.name = "Test Brand"
|
||||
test_store.description = "Test Description"
|
||||
test_store.default_language = "fr"
|
||||
db.commit()
|
||||
|
||||
service = OnboardingService(db)
|
||||
result = service.get_merchant_profile_data(test_store.id)
|
||||
|
||||
assert result["brand_name"] == "Test Brand"
|
||||
assert result["description"] == "Test Description"
|
||||
assert result["default_language"] == "fr"
|
||||
|
||||
def test_complete_merchant_profile_updates_status(self, db, test_store):
|
||||
"""Test complete_merchant_profile updates onboarding status"""
|
||||
service = OnboardingService(db)
|
||||
result = service.complete_merchant_profile(
|
||||
store_id=test_store.id,
|
||||
merchant_name="Test Merchant",
|
||||
brand_name="Test Brand",
|
||||
default_language="en",
|
||||
dashboard_language="en",
|
||||
)
|
||||
db.commit()
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["step_completed"] is True
|
||||
assert result["next_step"] == OnboardingStep.LETZSHOP_API.value
|
||||
|
||||
# Verify onboarding updated
|
||||
onboarding = service.get_onboarding(test_store.id)
|
||||
assert onboarding.status == OnboardingStatus.IN_PROGRESS.value
|
||||
assert onboarding.step_merchant_profile_completed is True
|
||||
|
||||
def test_complete_merchant_profile_raises_for_missing_store(self, db):
|
||||
"""Test complete_merchant_profile raises for non-existent store"""
|
||||
from app.modules.tenancy.exceptions import StoreNotFoundException
|
||||
|
||||
service = OnboardingService(db)
|
||||
|
||||
# Use a store_id that doesn't exist
|
||||
# The service should check store exists before doing anything
|
||||
non_existent_store_id = 999999
|
||||
|
||||
with pytest.raises(StoreNotFoundException):
|
||||
service.complete_merchant_profile(
|
||||
store_id=non_existent_store_id,
|
||||
default_language="en",
|
||||
dashboard_language="en",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.service
|
||||
class TestOnboardingServiceStep2:
|
||||
"""Test Step 2: Letzshop API Configuration"""
|
||||
|
||||
def test_test_letzshop_api_returns_result(self, db, test_store):
|
||||
"""Test test_letzshop_api returns connection test result"""
|
||||
with patch(
|
||||
"app.modules.marketplace.services.onboarding_service.LetzshopCredentialsService"
|
||||
) as mock_service:
|
||||
mock_instance = MagicMock()
|
||||
mock_instance.test_api_key.return_value = (True, 150.0, None)
|
||||
mock_service.return_value = mock_instance
|
||||
|
||||
service = OnboardingService(db)
|
||||
result = service.test_letzshop_api(
|
||||
api_key="test_key",
|
||||
shop_slug="test-shop",
|
||||
)
|
||||
|
||||
assert result["success"] is True
|
||||
assert "150" in result["message"]
|
||||
|
||||
def test_test_letzshop_api_returns_error(self, db, test_store):
|
||||
"""Test test_letzshop_api returns error on failure"""
|
||||
with patch(
|
||||
"app.modules.marketplace.services.onboarding_service.LetzshopCredentialsService"
|
||||
) as mock_service:
|
||||
mock_instance = MagicMock()
|
||||
mock_instance.test_api_key.return_value = (False, None, "Invalid API key")
|
||||
mock_service.return_value = mock_instance
|
||||
|
||||
service = OnboardingService(db)
|
||||
result = service.test_letzshop_api(
|
||||
api_key="invalid_key",
|
||||
shop_slug="test-shop",
|
||||
)
|
||||
|
||||
assert result["success"] is False
|
||||
assert "Invalid API key" in result["message"]
|
||||
|
||||
def test_complete_letzshop_api_requires_step1(self, db, test_store):
|
||||
"""Test complete_letzshop_api requires step 1 complete"""
|
||||
from app.modules.marketplace.exceptions import OnboardingStepOrderException
|
||||
|
||||
# Create onboarding with step 1 not complete
|
||||
onboarding = StoreOnboarding(
|
||||
store_id=test_store.id,
|
||||
status=OnboardingStatus.NOT_STARTED.value,
|
||||
current_step=OnboardingStep.MERCHANT_PROFILE.value,
|
||||
step_merchant_profile_completed=False,
|
||||
)
|
||||
db.add(onboarding)
|
||||
db.commit()
|
||||
|
||||
service = OnboardingService(db)
|
||||
with pytest.raises(OnboardingStepOrderException):
|
||||
service.complete_letzshop_api(
|
||||
store_id=test_store.id,
|
||||
api_key="test_key",
|
||||
shop_slug="test-shop",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.service
|
||||
class TestOnboardingServiceStep3:
|
||||
"""Test Step 3: Product Import Configuration"""
|
||||
|
||||
def test_get_product_import_config_empty(self, db):
|
||||
"""Test get_product_import_config returns empty for non-existent store"""
|
||||
service = OnboardingService(db)
|
||||
result = service.get_product_import_config(99999)
|
||||
assert result == {}
|
||||
|
||||
def test_get_product_import_config_with_data(self, db, test_store):
|
||||
"""Test get_product_import_config returns store CSV settings"""
|
||||
test_store.letzshop_csv_url_fr = "https://example.com/fr.csv"
|
||||
test_store.letzshop_default_tax_rate = 17
|
||||
db.commit()
|
||||
|
||||
service = OnboardingService(db)
|
||||
result = service.get_product_import_config(test_store.id)
|
||||
|
||||
assert result["csv_url_fr"] == "https://example.com/fr.csv"
|
||||
assert result["default_tax_rate"] == 17
|
||||
|
||||
def test_complete_product_import_requires_csv_url(self, db, test_store):
|
||||
"""Test complete_product_import requires at least one CSV URL"""
|
||||
from app.modules.marketplace.exceptions import OnboardingCsvUrlRequiredException
|
||||
|
||||
# Create onboarding with steps 1 and 2 complete
|
||||
onboarding = StoreOnboarding(
|
||||
store_id=test_store.id,
|
||||
status=OnboardingStatus.IN_PROGRESS.value,
|
||||
current_step=OnboardingStep.PRODUCT_IMPORT.value,
|
||||
step_merchant_profile_completed=True,
|
||||
step_letzshop_api_completed=True,
|
||||
)
|
||||
db.add(onboarding)
|
||||
db.commit()
|
||||
|
||||
service = OnboardingService(db)
|
||||
with pytest.raises(OnboardingCsvUrlRequiredException):
|
||||
service.complete_product_import(
|
||||
store_id=test_store.id,
|
||||
# No CSV URLs provided
|
||||
)
|
||||
|
||||
def test_complete_product_import_success(self, db, test_store):
|
||||
"""Test complete_product_import saves settings"""
|
||||
# Create onboarding with steps 1 and 2 complete
|
||||
onboarding = StoreOnboarding(
|
||||
store_id=test_store.id,
|
||||
status=OnboardingStatus.IN_PROGRESS.value,
|
||||
current_step=OnboardingStep.PRODUCT_IMPORT.value,
|
||||
step_merchant_profile_completed=True,
|
||||
step_letzshop_api_completed=True,
|
||||
)
|
||||
db.add(onboarding)
|
||||
db.commit()
|
||||
|
||||
service = OnboardingService(db)
|
||||
result = service.complete_product_import(
|
||||
store_id=test_store.id,
|
||||
csv_url_fr="https://example.com/fr.csv",
|
||||
default_tax_rate=17,
|
||||
delivery_method="package_delivery",
|
||||
preorder_days=2,
|
||||
)
|
||||
db.commit()
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["csv_urls_configured"] == 1
|
||||
|
||||
# Verify store updated
|
||||
db.refresh(test_store)
|
||||
assert test_store.letzshop_csv_url_fr == "https://example.com/fr.csv"
|
||||
assert test_store.letzshop_default_tax_rate == 17
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.service
|
||||
class TestOnboardingServiceStep4:
|
||||
"""Test Step 4: Order Sync"""
|
||||
|
||||
def test_trigger_order_sync_creates_job(self, db, test_store, test_user):
|
||||
"""Test trigger_order_sync creates import job"""
|
||||
# Create onboarding with steps 1-3 complete
|
||||
onboarding = StoreOnboarding(
|
||||
store_id=test_store.id,
|
||||
status=OnboardingStatus.IN_PROGRESS.value,
|
||||
current_step=OnboardingStep.ORDER_SYNC.value,
|
||||
step_merchant_profile_completed=True,
|
||||
step_letzshop_api_completed=True,
|
||||
step_product_import_completed=True,
|
||||
)
|
||||
db.add(onboarding)
|
||||
db.commit()
|
||||
|
||||
with patch(
|
||||
"app.modules.marketplace.services.onboarding_service.LetzshopOrderService"
|
||||
) as mock_service:
|
||||
mock_instance = MagicMock()
|
||||
mock_instance.get_running_historical_import_job.return_value = None
|
||||
mock_job = MagicMock()
|
||||
mock_job.id = 123
|
||||
mock_instance.create_historical_import_job.return_value = mock_job
|
||||
mock_service.return_value = mock_instance
|
||||
|
||||
service = OnboardingService(db)
|
||||
result = service.trigger_order_sync(
|
||||
store_id=test_store.id,
|
||||
user_id=test_user.id,
|
||||
days_back=90,
|
||||
)
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["job_id"] == 123
|
||||
|
||||
def test_trigger_order_sync_returns_existing_job(self, db, test_store, test_user):
|
||||
"""Test trigger_order_sync returns existing job if running"""
|
||||
# Create onboarding with steps 1-3 complete
|
||||
onboarding = StoreOnboarding(
|
||||
store_id=test_store.id,
|
||||
status=OnboardingStatus.IN_PROGRESS.value,
|
||||
current_step=OnboardingStep.ORDER_SYNC.value,
|
||||
step_merchant_profile_completed=True,
|
||||
step_letzshop_api_completed=True,
|
||||
step_product_import_completed=True,
|
||||
)
|
||||
db.add(onboarding)
|
||||
db.commit()
|
||||
|
||||
with patch(
|
||||
"app.modules.marketplace.services.onboarding_service.LetzshopOrderService"
|
||||
) as mock_service:
|
||||
mock_instance = MagicMock()
|
||||
existing_job = MagicMock()
|
||||
existing_job.id = 456
|
||||
mock_instance.get_running_historical_import_job.return_value = existing_job
|
||||
mock_service.return_value = mock_instance
|
||||
|
||||
service = OnboardingService(db)
|
||||
result = service.trigger_order_sync(
|
||||
store_id=test_store.id,
|
||||
user_id=test_user.id,
|
||||
)
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["job_id"] == 456
|
||||
assert "already running" in result["message"]
|
||||
|
||||
def test_get_order_sync_progress_not_found(self, db, test_store):
|
||||
"""Test get_order_sync_progress for non-existent job"""
|
||||
with patch(
|
||||
"app.modules.marketplace.services.onboarding_service.LetzshopOrderService"
|
||||
) as mock_service:
|
||||
mock_instance = MagicMock()
|
||||
mock_instance.get_historical_import_job_by_id.return_value = None
|
||||
mock_service.return_value = mock_instance
|
||||
|
||||
service = OnboardingService(db)
|
||||
result = service.get_order_sync_progress(
|
||||
store_id=test_store.id,
|
||||
job_id=99999,
|
||||
)
|
||||
|
||||
assert result["status"] == "not_found"
|
||||
assert result["progress_percentage"] == 0
|
||||
|
||||
def test_get_order_sync_progress_completed(self, db, test_store):
|
||||
"""Test get_order_sync_progress for completed job"""
|
||||
with patch(
|
||||
"app.modules.marketplace.services.onboarding_service.LetzshopOrderService"
|
||||
) as mock_service:
|
||||
mock_instance = MagicMock()
|
||||
mock_job = MagicMock()
|
||||
mock_job.id = 123
|
||||
mock_job.status = "completed"
|
||||
mock_job.current_phase = "complete"
|
||||
mock_job.orders_imported = 50
|
||||
mock_job.shipments_fetched = 50
|
||||
mock_job.orders_processed = 50
|
||||
mock_job.products_matched = 100
|
||||
mock_job.started_at = datetime.now(UTC)
|
||||
mock_job.completed_at = datetime.now(UTC)
|
||||
mock_job.error_message = None
|
||||
mock_instance.get_historical_import_job_by_id.return_value = mock_job
|
||||
mock_service.return_value = mock_instance
|
||||
|
||||
service = OnboardingService(db)
|
||||
result = service.get_order_sync_progress(
|
||||
store_id=test_store.id,
|
||||
job_id=123,
|
||||
)
|
||||
|
||||
assert result["status"] == "completed"
|
||||
assert result["progress_percentage"] == 100
|
||||
assert result["orders_imported"] == 50
|
||||
|
||||
def test_get_order_sync_progress_processing(self, db, test_store):
|
||||
"""Test get_order_sync_progress for processing job"""
|
||||
with patch(
|
||||
"app.modules.marketplace.services.onboarding_service.LetzshopOrderService"
|
||||
) as mock_service:
|
||||
mock_instance = MagicMock()
|
||||
mock_job = MagicMock()
|
||||
mock_job.id = 123
|
||||
mock_job.status = "processing"
|
||||
mock_job.current_phase = "orders"
|
||||
mock_job.orders_imported = 25
|
||||
mock_job.shipments_fetched = 50
|
||||
mock_job.orders_processed = 25
|
||||
mock_job.products_matched = 50
|
||||
mock_job.started_at = datetime.now(UTC)
|
||||
mock_job.completed_at = None
|
||||
mock_job.error_message = None
|
||||
mock_job.total_pages = None
|
||||
mock_job.current_page = None
|
||||
mock_instance.get_historical_import_job_by_id.return_value = mock_job
|
||||
mock_service.return_value = mock_instance
|
||||
|
||||
service = OnboardingService(db)
|
||||
result = service.get_order_sync_progress(
|
||||
store_id=test_store.id,
|
||||
job_id=123,
|
||||
)
|
||||
|
||||
assert result["status"] == "processing"
|
||||
assert result["progress_percentage"] == 50 # 25/50
|
||||
assert result["current_phase"] == "orders"
|
||||
|
||||
def test_complete_order_sync_raises_for_missing_job(self, db, test_store):
|
||||
"""Test complete_order_sync raises for non-existent job"""
|
||||
from app.modules.marketplace.exceptions import OnboardingSyncJobNotFoundException
|
||||
|
||||
onboarding = StoreOnboarding(
|
||||
store_id=test_store.id,
|
||||
status=OnboardingStatus.IN_PROGRESS.value,
|
||||
)
|
||||
db.add(onboarding)
|
||||
db.commit()
|
||||
|
||||
with patch(
|
||||
"app.modules.marketplace.services.onboarding_service.LetzshopOrderService"
|
||||
) as mock_service:
|
||||
mock_instance = MagicMock()
|
||||
mock_instance.get_historical_import_job_by_id.return_value = None
|
||||
mock_service.return_value = mock_instance
|
||||
|
||||
service = OnboardingService(db)
|
||||
with pytest.raises(OnboardingSyncJobNotFoundException):
|
||||
service.complete_order_sync(
|
||||
store_id=test_store.id,
|
||||
job_id=99999,
|
||||
)
|
||||
|
||||
def test_complete_order_sync_raises_if_not_complete(self, db, test_store):
|
||||
"""Test complete_order_sync raises if job still running"""
|
||||
from app.modules.marketplace.exceptions import OnboardingSyncNotCompleteException
|
||||
|
||||
onboarding = StoreOnboarding(
|
||||
store_id=test_store.id,
|
||||
status=OnboardingStatus.IN_PROGRESS.value,
|
||||
)
|
||||
db.add(onboarding)
|
||||
db.commit()
|
||||
|
||||
with patch(
|
||||
"app.modules.marketplace.services.onboarding_service.LetzshopOrderService"
|
||||
) as mock_service:
|
||||
mock_instance = MagicMock()
|
||||
mock_job = MagicMock()
|
||||
mock_job.status = "processing"
|
||||
mock_instance.get_historical_import_job_by_id.return_value = mock_job
|
||||
mock_service.return_value = mock_instance
|
||||
|
||||
service = OnboardingService(db)
|
||||
with pytest.raises(OnboardingSyncNotCompleteException):
|
||||
service.complete_order_sync(
|
||||
store_id=test_store.id,
|
||||
job_id=123,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.service
|
||||
class TestOnboardingServiceAdminSkip:
|
||||
"""Test admin skip functionality"""
|
||||
|
||||
def test_skip_onboarding_success(self, db, test_store, test_admin):
|
||||
"""Test skip_onboarding marks onboarding as skipped"""
|
||||
service = OnboardingService(db)
|
||||
result = service.skip_onboarding(
|
||||
store_id=test_store.id,
|
||||
admin_user_id=test_admin.id,
|
||||
reason="Manual setup required",
|
||||
)
|
||||
db.commit()
|
||||
|
||||
assert result["success"] is True
|
||||
|
||||
# Verify onboarding updated
|
||||
onboarding = service.get_onboarding(test_store.id)
|
||||
assert onboarding.skipped_by_admin is True
|
||||
assert onboarding.skipped_reason == "Manual setup required"
|
||||
assert onboarding.status == OnboardingStatus.SKIPPED.value
|
||||
244
app/modules/marketplace/tests/unit/test_product_model.py
Normal file
244
app/modules/marketplace/tests/unit/test_product_model.py
Normal file
@@ -0,0 +1,244 @@
|
||||
# tests/unit/models/database/test_marketplace_product.py
|
||||
"""Unit tests for MarketplaceProduct database model."""
|
||||
|
||||
import pytest
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
from app.modules.marketplace.models import (
|
||||
MarketplaceProduct,
|
||||
MarketplaceProductTranslation,
|
||||
)
|
||||
|
||||
|
||||
def _create_with_translation(db, marketplace_product_id, title, **kwargs):
|
||||
"""Helper to create MarketplaceProduct with translation."""
|
||||
mp = MarketplaceProduct(marketplace_product_id=marketplace_product_id, **kwargs)
|
||||
db.add(mp)
|
||||
db.flush()
|
||||
|
||||
translation = MarketplaceProductTranslation(
|
||||
marketplace_product_id=mp.id,
|
||||
language="en",
|
||||
title=title,
|
||||
)
|
||||
db.add(translation)
|
||||
db.commit()
|
||||
db.refresh(mp)
|
||||
return mp
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.database
|
||||
class TestMarketplaceProductModel:
|
||||
"""Test MarketplaceProduct model."""
|
||||
|
||||
def test_marketplace_product_creation(self, db):
|
||||
"""Test MarketplaceProduct model creation."""
|
||||
marketplace_product = _create_with_translation(
|
||||
db,
|
||||
marketplace_product_id="DB_TEST_001",
|
||||
title="Database Test Product",
|
||||
price="25.99",
|
||||
currency="USD",
|
||||
brand="DBTest",
|
||||
gtin="1234567890123",
|
||||
availability="in stock",
|
||||
marketplace="Letzshop",
|
||||
store_name="Test Store",
|
||||
)
|
||||
|
||||
assert marketplace_product.id is not None
|
||||
assert marketplace_product.marketplace_product_id == "DB_TEST_001"
|
||||
assert marketplace_product.get_title("en") == "Database Test Product"
|
||||
assert marketplace_product.marketplace == "Letzshop"
|
||||
assert marketplace_product.created_at is not None
|
||||
|
||||
def test_marketplace_product_id_uniqueness(self, db):
|
||||
"""Test unique marketplace_product_id constraint."""
|
||||
_create_with_translation(
|
||||
db,
|
||||
marketplace_product_id="UNIQUE_001",
|
||||
title="Product 1",
|
||||
marketplace="Letzshop",
|
||||
)
|
||||
|
||||
# Duplicate marketplace_product_id should raise error
|
||||
with pytest.raises(IntegrityError):
|
||||
_create_with_translation(
|
||||
db,
|
||||
marketplace_product_id="UNIQUE_001",
|
||||
title="Product 2",
|
||||
marketplace="Letzshop",
|
||||
)
|
||||
|
||||
def test_marketplace_product_all_fields(self, db):
|
||||
"""Test MarketplaceProduct with all optional fields."""
|
||||
marketplace_product = _create_with_translation(
|
||||
db,
|
||||
marketplace_product_id="FULL_001",
|
||||
title="Full Product",
|
||||
link="https://example.com/product",
|
||||
image_link="https://example.com/image.jpg",
|
||||
availability="in stock",
|
||||
price="99.99",
|
||||
brand="TestBrand",
|
||||
gtin="9876543210123",
|
||||
mpn="MPN123",
|
||||
condition="new",
|
||||
adult="no",
|
||||
age_group="adult",
|
||||
color="blue",
|
||||
gender="unisex",
|
||||
material="cotton",
|
||||
pattern="solid",
|
||||
size="M",
|
||||
google_product_category="Apparel & Accessories",
|
||||
product_type_raw="Clothing",
|
||||
currency="EUR",
|
||||
marketplace="Letzshop",
|
||||
store_name="Full Store",
|
||||
)
|
||||
|
||||
assert marketplace_product.brand == "TestBrand"
|
||||
assert marketplace_product.gtin == "9876543210123"
|
||||
assert marketplace_product.color == "blue"
|
||||
assert marketplace_product.size == "M"
|
||||
|
||||
def test_marketplace_product_custom_labels(self, db):
|
||||
"""Test MarketplaceProduct with custom labels."""
|
||||
marketplace_product = _create_with_translation(
|
||||
db,
|
||||
marketplace_product_id="LABELS_001",
|
||||
title="Labeled Product",
|
||||
marketplace="Letzshop",
|
||||
custom_label_0="Label0",
|
||||
custom_label_1="Label1",
|
||||
custom_label_2="Label2",
|
||||
custom_label_3="Label3",
|
||||
custom_label_4="Label4",
|
||||
)
|
||||
|
||||
assert marketplace_product.custom_label_0 == "Label0"
|
||||
assert marketplace_product.custom_label_4 == "Label4"
|
||||
|
||||
def test_marketplace_product_minimal_fields(self, db):
|
||||
"""Test MarketplaceProduct with only required fields."""
|
||||
marketplace_product = _create_with_translation(
|
||||
db,
|
||||
marketplace_product_id="MINIMAL_001",
|
||||
title="Minimal Product",
|
||||
)
|
||||
|
||||
assert marketplace_product.id is not None
|
||||
assert marketplace_product.marketplace_product_id == "MINIMAL_001"
|
||||
assert marketplace_product.get_title("en") == "Minimal Product"
|
||||
assert marketplace_product.get_description("en") is None
|
||||
assert marketplace_product.price is None
|
||||
|
||||
def test_marketplace_product_digital_fields(self, db):
|
||||
"""Test MarketplaceProduct with digital product fields."""
|
||||
marketplace_product = _create_with_translation(
|
||||
db,
|
||||
marketplace_product_id="DIGITAL_001",
|
||||
title="Digital Product",
|
||||
product_type_enum="digital",
|
||||
is_digital=True,
|
||||
digital_delivery_method="license_key",
|
||||
platform="steam",
|
||||
region_restrictions=["EU", "US"],
|
||||
license_type="single_use",
|
||||
)
|
||||
|
||||
assert marketplace_product.product_type_enum == "digital"
|
||||
assert marketplace_product.is_digital is True
|
||||
assert marketplace_product.digital_delivery_method == "license_key"
|
||||
assert marketplace_product.platform == "steam"
|
||||
assert marketplace_product.region_restrictions == ["EU", "US"]
|
||||
assert marketplace_product.license_type == "single_use"
|
||||
|
||||
def test_marketplace_product_translation_methods(self, db):
|
||||
"""Test translation helper methods."""
|
||||
mp = MarketplaceProduct(marketplace_product_id="TRANS_001")
|
||||
db.add(mp)
|
||||
db.flush()
|
||||
|
||||
# Add English translation
|
||||
en_trans = MarketplaceProductTranslation(
|
||||
marketplace_product_id=mp.id,
|
||||
language="en",
|
||||
title="English Title",
|
||||
description="English Description",
|
||||
)
|
||||
db.add(en_trans)
|
||||
|
||||
# Add French translation
|
||||
fr_trans = MarketplaceProductTranslation(
|
||||
marketplace_product_id=mp.id,
|
||||
language="fr",
|
||||
title="Titre Français",
|
||||
description="Description Française",
|
||||
)
|
||||
db.add(fr_trans)
|
||||
db.commit()
|
||||
db.refresh(mp)
|
||||
|
||||
assert mp.get_title("en") == "English Title"
|
||||
assert mp.get_title("fr") == "Titre Français"
|
||||
assert mp.get_description("en") == "English Description"
|
||||
assert mp.get_description("fr") == "Description Française"
|
||||
|
||||
# Test fallback to English for unknown language
|
||||
assert mp.get_title("de") == "English Title" # Falls back to 'en'
|
||||
assert mp.get_description("de") == "English Description"
|
||||
|
||||
def test_marketplace_product_numeric_prices(self, db):
|
||||
"""Test numeric price fields."""
|
||||
marketplace_product = _create_with_translation(
|
||||
db,
|
||||
marketplace_product_id="PRICES_001",
|
||||
title="Priced Product",
|
||||
price="99.99 EUR",
|
||||
price_numeric=99.99,
|
||||
sale_price="79.99 EUR",
|
||||
sale_price_numeric=79.99,
|
||||
)
|
||||
|
||||
assert marketplace_product.price == "99.99 EUR"
|
||||
assert marketplace_product.price_numeric == 99.99
|
||||
assert marketplace_product.sale_price_numeric == 79.99
|
||||
assert marketplace_product.effective_price == 99.99
|
||||
assert marketplace_product.effective_sale_price == 79.99
|
||||
|
||||
def test_marketplace_product_attributes_json(self, db):
|
||||
"""Test flexible attributes JSON field."""
|
||||
marketplace_product = _create_with_translation(
|
||||
db,
|
||||
marketplace_product_id="ATTRS_001",
|
||||
title="Product with Attributes",
|
||||
attributes={
|
||||
"custom_field": "custom_value",
|
||||
"nested": {"key": "value"},
|
||||
},
|
||||
)
|
||||
|
||||
assert marketplace_product.attributes["custom_field"] == "custom_value"
|
||||
assert marketplace_product.attributes["nested"]["key"] == "value"
|
||||
|
||||
def test_marketplace_product_all_images_property(self, db):
|
||||
"""Test all_images property."""
|
||||
marketplace_product = _create_with_translation(
|
||||
db,
|
||||
marketplace_product_id="IMAGES_001",
|
||||
title="Product with Images",
|
||||
image_link="https://example.com/main.jpg",
|
||||
additional_images=[
|
||||
"https://example.com/img1.jpg",
|
||||
"https://example.com/img2.jpg",
|
||||
],
|
||||
)
|
||||
|
||||
images = marketplace_product.all_images
|
||||
assert len(images) == 3
|
||||
assert images[0] == "https://example.com/main.jpg"
|
||||
assert "https://example.com/img1.jpg" in images
|
||||
assert "https://example.com/img2.jpg" in images
|
||||
507
app/modules/marketplace/tests/unit/test_product_service.py
Normal file
507
app/modules/marketplace/tests/unit/test_product_service.py
Normal file
@@ -0,0 +1,507 @@
|
||||
# tests/test_product_service.py
|
||||
import pytest
|
||||
|
||||
from app.modules.marketplace.exceptions import (
|
||||
InvalidMarketplaceProductDataException,
|
||||
MarketplaceProductAlreadyExistsException,
|
||||
MarketplaceProductNotFoundException,
|
||||
MarketplaceProductValidationException,
|
||||
)
|
||||
from app.modules.marketplace.services.marketplace_product_service import MarketplaceProductService
|
||||
from app.modules.marketplace.schemas import (
|
||||
MarketplaceProductCreate,
|
||||
MarketplaceProductUpdate,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.products
|
||||
class TestProductService:
|
||||
def setup_method(self):
|
||||
self.service = MarketplaceProductService()
|
||||
|
||||
def test_create_product_success(self, db):
|
||||
"""Test successful product creation with valid data"""
|
||||
product_data = MarketplaceProductCreate(
|
||||
marketplace_product_id="SVC001",
|
||||
title="Service Test MarketplaceProduct",
|
||||
gtin="1234567890123",
|
||||
price="19.99",
|
||||
marketplace="TestMarket",
|
||||
)
|
||||
|
||||
# Title is passed as separate parameter for translation table
|
||||
product = self.service.create_product(
|
||||
db, product_data, title="Service Test MarketplaceProduct"
|
||||
)
|
||||
|
||||
assert product.marketplace_product_id == "SVC001"
|
||||
assert product.get_title() == "Service Test MarketplaceProduct"
|
||||
assert product.gtin == "1234567890123"
|
||||
assert product.marketplace == "TestMarket"
|
||||
assert product.price == "19.99" # Price is stored as string after processing
|
||||
|
||||
def test_create_product_invalid_gtin(self, db):
|
||||
"""Test product creation with invalid GTIN raises InvalidMarketplaceProductDataException"""
|
||||
product_data = MarketplaceProductCreate(
|
||||
marketplace_product_id="SVC002",
|
||||
title="Service Test MarketplaceProduct",
|
||||
gtin="invalid_gtin",
|
||||
price="19.99",
|
||||
)
|
||||
|
||||
with pytest.raises(InvalidMarketplaceProductDataException) as exc_info:
|
||||
self.service.create_product(db, product_data)
|
||||
|
||||
assert exc_info.value.error_code == "INVALID_PRODUCT_DATA"
|
||||
assert "Invalid GTIN format" in str(exc_info.value)
|
||||
assert exc_info.value.status_code == 422
|
||||
assert exc_info.value.details.get("field") == "gtin"
|
||||
|
||||
def test_create_product_missing_product_id(self, db):
|
||||
"""Test product creation without marketplace_product_id raises MarketplaceProductValidationException"""
|
||||
product_data = MarketplaceProductCreate(
|
||||
marketplace_product_id="", # Empty product ID
|
||||
title="Service Test MarketplaceProduct",
|
||||
price="19.99",
|
||||
)
|
||||
|
||||
with pytest.raises(MarketplaceProductValidationException) as exc_info:
|
||||
self.service.create_product(db, product_data)
|
||||
|
||||
assert exc_info.value.error_code == "PRODUCT_VALIDATION_FAILED"
|
||||
assert "MarketplaceProduct ID is required" in str(exc_info.value)
|
||||
assert exc_info.value.details.get("field") == "marketplace_product_id"
|
||||
|
||||
def test_create_product_without_title(self, db):
|
||||
"""Test product creation without title succeeds (title is optional, stored in translations)"""
|
||||
product_data = MarketplaceProductCreate(
|
||||
marketplace_product_id="SVC003",
|
||||
title="", # Empty title - allowed since translations are optional
|
||||
price="19.99",
|
||||
)
|
||||
|
||||
product = self.service.create_product(db, product_data)
|
||||
|
||||
# Product is created but title returns None since no translation
|
||||
assert product.marketplace_product_id == "SVC003"
|
||||
assert product.get_title() is None # No translation created for empty title
|
||||
|
||||
def test_create_product_already_exists(self, db, test_marketplace_product):
|
||||
"""Test creating product with existing ID raises MarketplaceProductAlreadyExistsException"""
|
||||
# Store the product ID before the exception (session may be invalid after)
|
||||
existing_product_id = test_marketplace_product.marketplace_product_id
|
||||
|
||||
product_data = MarketplaceProductCreate(
|
||||
marketplace_product_id=existing_product_id, # Use existing product ID
|
||||
title="Duplicate MarketplaceProduct",
|
||||
price="29.99",
|
||||
)
|
||||
|
||||
with pytest.raises(MarketplaceProductAlreadyExistsException) as exc_info:
|
||||
self.service.create_product(db, product_data)
|
||||
|
||||
# Rollback to clear the session's invalid state from IntegrityError
|
||||
db.rollback()
|
||||
|
||||
assert exc_info.value.error_code == "PRODUCT_ALREADY_EXISTS"
|
||||
assert existing_product_id in str(exc_info.value)
|
||||
assert exc_info.value.status_code == 409
|
||||
assert (
|
||||
exc_info.value.details.get("marketplace_product_id") == existing_product_id
|
||||
)
|
||||
|
||||
def test_create_product_invalid_price(self, db):
|
||||
"""Test product creation with invalid price raises InvalidMarketplaceProductDataException"""
|
||||
product_data = MarketplaceProductCreate(
|
||||
marketplace_product_id="SVC004",
|
||||
title="Service Test MarketplaceProduct",
|
||||
price="invalid_price",
|
||||
)
|
||||
|
||||
with pytest.raises(InvalidMarketplaceProductDataException) as exc_info:
|
||||
self.service.create_product(db, product_data)
|
||||
|
||||
assert exc_info.value.error_code == "INVALID_PRODUCT_DATA"
|
||||
assert "Invalid price format" in str(exc_info.value)
|
||||
assert exc_info.value.details.get("field") == "price"
|
||||
|
||||
def test_get_product_by_id_or_raise_success(self, db, test_marketplace_product):
|
||||
"""Test successful product retrieval by ID"""
|
||||
product = self.service.get_product_by_id_or_raise(
|
||||
db, test_marketplace_product.marketplace_product_id
|
||||
)
|
||||
|
||||
assert (
|
||||
product.marketplace_product_id
|
||||
== test_marketplace_product.marketplace_product_id
|
||||
)
|
||||
assert product.get_title() == test_marketplace_product.get_title()
|
||||
|
||||
def test_get_product_by_id_or_raise_not_found(self, db):
|
||||
"""Test product retrieval with non-existent ID raises MarketplaceProductNotFoundException"""
|
||||
with pytest.raises(MarketplaceProductNotFoundException) as exc_info:
|
||||
self.service.get_product_by_id_or_raise(db, "NONEXISTENT")
|
||||
|
||||
assert exc_info.value.error_code == "PRODUCT_NOT_FOUND"
|
||||
assert "NONEXISTENT" in str(exc_info.value)
|
||||
assert exc_info.value.status_code == 404
|
||||
assert exc_info.value.details.get("resource_type") == "MarketplaceProduct"
|
||||
assert exc_info.value.details.get("identifier") == "NONEXISTENT"
|
||||
|
||||
def test_get_products_with_filters_success(self, db, test_marketplace_product):
|
||||
"""Test getting products with various filters"""
|
||||
products, total = self.service.get_products_with_filters(
|
||||
db, brand=test_marketplace_product.brand
|
||||
)
|
||||
|
||||
assert total == 1
|
||||
assert len(products) == 1
|
||||
assert products[0].brand == test_marketplace_product.brand
|
||||
|
||||
def test_get_products_with_search(self, db, test_marketplace_product):
|
||||
"""Test getting products with search term"""
|
||||
products, total = self.service.get_products_with_filters(
|
||||
db, search="Test MarketplaceProduct"
|
||||
)
|
||||
|
||||
assert total >= 1
|
||||
assert len(products) >= 1
|
||||
# Verify search worked by checking that title contains search term
|
||||
found_product = next(
|
||||
(
|
||||
p
|
||||
for p in products
|
||||
if p.marketplace_product_id
|
||||
== test_marketplace_product.marketplace_product_id
|
||||
),
|
||||
None,
|
||||
)
|
||||
assert found_product is not None
|
||||
|
||||
def test_update_product_success(self, db, test_marketplace_product):
|
||||
"""Test successful product update"""
|
||||
update_data = MarketplaceProductUpdate(price="39.99")
|
||||
|
||||
# Title is passed as separate parameter for translation table
|
||||
updated_product = self.service.update_product(
|
||||
db,
|
||||
test_marketplace_product.marketplace_product_id,
|
||||
update_data,
|
||||
title="Updated MarketplaceProduct Title",
|
||||
)
|
||||
|
||||
assert updated_product.get_title() == "Updated MarketplaceProduct Title"
|
||||
assert (
|
||||
updated_product.price == "39.99"
|
||||
) # Price is stored as string after processing
|
||||
assert (
|
||||
updated_product.marketplace_product_id
|
||||
== test_marketplace_product.marketplace_product_id
|
||||
) # ID unchanged
|
||||
|
||||
def test_update_product_not_found(self, db):
|
||||
"""Test updating non-existent product raises MarketplaceProductNotFoundException"""
|
||||
update_data = MarketplaceProductUpdate(title="Updated Title")
|
||||
|
||||
with pytest.raises(MarketplaceProductNotFoundException) as exc_info:
|
||||
self.service.update_product(db, "NONEXISTENT", update_data)
|
||||
|
||||
assert exc_info.value.error_code == "PRODUCT_NOT_FOUND"
|
||||
assert "NONEXISTENT" in str(exc_info.value)
|
||||
|
||||
def test_update_product_invalid_gtin(self, db, test_marketplace_product):
|
||||
"""Test updating product with invalid GTIN raises InvalidMarketplaceProductDataException"""
|
||||
update_data = MarketplaceProductUpdate(gtin="invalid_gtin")
|
||||
|
||||
with pytest.raises(InvalidMarketplaceProductDataException) as exc_info:
|
||||
self.service.update_product(
|
||||
db, test_marketplace_product.marketplace_product_id, update_data
|
||||
)
|
||||
|
||||
assert exc_info.value.error_code == "INVALID_PRODUCT_DATA"
|
||||
assert "Invalid GTIN format" in str(exc_info.value)
|
||||
assert exc_info.value.details.get("field") == "gtin"
|
||||
|
||||
def test_update_product_empty_title_preserves_existing(
|
||||
self, db, test_marketplace_product
|
||||
):
|
||||
"""Test updating product with empty title preserves existing title in translation"""
|
||||
original_title = test_marketplace_product.get_title()
|
||||
update_data = MarketplaceProductUpdate(title="")
|
||||
|
||||
updated_product = self.service.update_product(
|
||||
db, test_marketplace_product.marketplace_product_id, update_data
|
||||
)
|
||||
|
||||
# Empty title update preserves existing translation title
|
||||
assert updated_product.get_title() == original_title
|
||||
|
||||
def test_update_product_invalid_price(self, db, test_marketplace_product):
|
||||
"""Test updating product with invalid price raises InvalidMarketplaceProductDataException"""
|
||||
update_data = MarketplaceProductUpdate(price="invalid_price")
|
||||
|
||||
with pytest.raises(InvalidMarketplaceProductDataException) as exc_info:
|
||||
self.service.update_product(
|
||||
db, test_marketplace_product.marketplace_product_id, update_data
|
||||
)
|
||||
|
||||
assert exc_info.value.error_code == "INVALID_PRODUCT_DATA"
|
||||
assert "Invalid price format" in str(exc_info.value)
|
||||
assert exc_info.value.details.get("field") == "price"
|
||||
|
||||
def test_delete_product_success(self, db, test_marketplace_product):
|
||||
"""Test successful product deletion"""
|
||||
result = self.service.delete_product(
|
||||
db, test_marketplace_product.marketplace_product_id
|
||||
)
|
||||
|
||||
assert result is True
|
||||
|
||||
# Verify product is deleted
|
||||
deleted_product = self.service.get_product_by_id(
|
||||
db, test_marketplace_product.marketplace_product_id
|
||||
)
|
||||
assert deleted_product is None
|
||||
|
||||
def test_delete_product_not_found(self, db):
|
||||
"""Test deleting non-existent product raises MarketplaceProductNotFoundException"""
|
||||
with pytest.raises(MarketplaceProductNotFoundException) as exc_info:
|
||||
self.service.delete_product(db, "NONEXISTENT")
|
||||
|
||||
assert exc_info.value.error_code == "PRODUCT_NOT_FOUND"
|
||||
assert "NONEXISTENT" in str(exc_info.value)
|
||||
|
||||
def test_get_inventory_info_success(
|
||||
self, db, test_marketplace_product_with_inventory
|
||||
):
|
||||
"""Test getting inventory info for product with inventory."""
|
||||
marketplace_product = test_marketplace_product_with_inventory[
|
||||
"marketplace_product"
|
||||
]
|
||||
inventory = test_marketplace_product_with_inventory["inventory"]
|
||||
|
||||
inventory_info = self.service.get_inventory_info(db, marketplace_product.gtin)
|
||||
|
||||
assert inventory_info is not None
|
||||
assert inventory_info.total_quantity == inventory.quantity
|
||||
assert len(inventory_info.locations) >= 1
|
||||
|
||||
def test_get_inventory_info_no_inventory(self, db, test_marketplace_product):
|
||||
"""Test getting inventory info for product without inventory"""
|
||||
inventory_info = self.service.get_inventory_info(
|
||||
db, test_marketplace_product.gtin or "1234567890123"
|
||||
)
|
||||
|
||||
assert inventory_info is None
|
||||
|
||||
def test_product_exists_true(self, db, test_marketplace_product):
|
||||
"""Test product_exists returns True for existing product"""
|
||||
exists = self.service.product_exists(
|
||||
db, test_marketplace_product.marketplace_product_id
|
||||
)
|
||||
assert exists is True
|
||||
|
||||
def test_product_exists_false(self, db):
|
||||
"""Test product_exists returns False for non-existent product"""
|
||||
exists = self.service.product_exists(db, "NONEXISTENT")
|
||||
assert exists is False
|
||||
|
||||
def test_generate_csv_export_success(self, db, test_marketplace_product):
|
||||
"""Test CSV export generation"""
|
||||
csv_generator = self.service.generate_csv_export(db)
|
||||
|
||||
# Convert generator to list to test content
|
||||
csv_lines = list(csv_generator)
|
||||
|
||||
assert len(csv_lines) > 1 # Header + at least one data row
|
||||
assert csv_lines[0].startswith(
|
||||
"marketplace_product_id,title,description"
|
||||
) # Check header
|
||||
|
||||
# Check that test product appears in CSV
|
||||
csv_content = "".join(csv_lines)
|
||||
assert test_marketplace_product.marketplace_product_id in csv_content
|
||||
|
||||
def test_generate_csv_export_with_filters(self, db, test_marketplace_product):
|
||||
"""Test CSV export with marketplace filter"""
|
||||
csv_generator = self.service.generate_csv_export(
|
||||
db, marketplace=test_marketplace_product.marketplace
|
||||
)
|
||||
|
||||
csv_lines = list(csv_generator)
|
||||
assert len(csv_lines) >= 1 # At least header
|
||||
|
||||
if len(csv_lines) > 1: # If there's data
|
||||
csv_content = "".join(csv_lines)
|
||||
assert test_marketplace_product.marketplace in csv_content
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.products
|
||||
class TestMarketplaceProductServiceAdmin:
|
||||
"""Tests for admin-specific methods in MarketplaceProductService."""
|
||||
|
||||
def setup_method(self):
|
||||
self.service = MarketplaceProductService()
|
||||
|
||||
def test_get_admin_products_success(self, db, test_marketplace_product):
|
||||
"""Test getting admin products list."""
|
||||
products, total = self.service.get_admin_products(db)
|
||||
|
||||
assert total >= 1
|
||||
assert len(products) >= 1
|
||||
|
||||
# Find our test product in results
|
||||
found = False
|
||||
for p in products:
|
||||
if (
|
||||
p["marketplace_product_id"]
|
||||
== test_marketplace_product.marketplace_product_id
|
||||
):
|
||||
found = True
|
||||
assert p["id"] == test_marketplace_product.id
|
||||
assert p["marketplace"] == test_marketplace_product.marketplace
|
||||
break
|
||||
|
||||
assert found, "Test product not found in results"
|
||||
|
||||
def test_get_admin_products_with_search(self, db, test_marketplace_product):
|
||||
"""Test getting admin products with search filter."""
|
||||
products, total = self.service.get_admin_products(
|
||||
db, search="Test MarketplaceProduct"
|
||||
)
|
||||
|
||||
assert total >= 1
|
||||
# Should find our test product
|
||||
product_ids = [p["marketplace_product_id"] for p in products]
|
||||
assert test_marketplace_product.marketplace_product_id in product_ids
|
||||
|
||||
def test_get_admin_products_with_marketplace_filter(
|
||||
self, db, test_marketplace_product
|
||||
):
|
||||
"""Test getting admin products with marketplace filter."""
|
||||
products, total = self.service.get_admin_products(
|
||||
db, marketplace=test_marketplace_product.marketplace
|
||||
)
|
||||
|
||||
assert total >= 1
|
||||
# All products should be from the filtered marketplace
|
||||
for p in products:
|
||||
assert p["marketplace"] == test_marketplace_product.marketplace
|
||||
|
||||
def test_get_admin_products_pagination(self, db, multiple_products):
|
||||
"""Test admin products pagination."""
|
||||
# Get first 2
|
||||
products, total = self.service.get_admin_products(db, skip=0, limit=2)
|
||||
|
||||
assert total >= 5 # We created 5 products
|
||||
assert len(products) == 2
|
||||
|
||||
# Get next 2
|
||||
products2, _ = self.service.get_admin_products(db, skip=2, limit=2)
|
||||
assert len(products2) == 2
|
||||
|
||||
# Make sure they're different
|
||||
ids1 = {p["id"] for p in products}
|
||||
ids2 = {p["id"] for p in products2}
|
||||
assert ids1.isdisjoint(ids2)
|
||||
|
||||
def test_get_admin_product_stats(self, db, test_marketplace_product):
|
||||
"""Test getting admin product statistics."""
|
||||
stats = self.service.get_admin_product_stats(db)
|
||||
|
||||
assert "total" in stats
|
||||
assert "active" in stats
|
||||
assert "inactive" in stats
|
||||
assert "digital" in stats
|
||||
assert "physical" in stats
|
||||
assert "by_marketplace" in stats
|
||||
assert stats["total"] >= 1
|
||||
|
||||
def test_get_marketplaces_list(self, db, test_marketplace_product):
|
||||
"""Test getting list of marketplaces."""
|
||||
marketplaces = self.service.get_marketplaces_list(db)
|
||||
|
||||
assert isinstance(marketplaces, list)
|
||||
assert test_marketplace_product.marketplace in marketplaces
|
||||
|
||||
def test_get_source_stores_list(self, db, test_marketplace_product):
|
||||
"""Test getting list of source stores."""
|
||||
stores = self.service.get_source_stores_list(db)
|
||||
|
||||
assert isinstance(stores, list)
|
||||
assert test_marketplace_product.store_name in stores
|
||||
|
||||
def test_get_admin_product_detail(self, db, test_marketplace_product):
|
||||
"""Test getting admin product detail by ID."""
|
||||
product = self.service.get_admin_product_detail(db, test_marketplace_product.id)
|
||||
|
||||
assert product["id"] == test_marketplace_product.id
|
||||
assert (
|
||||
product["marketplace_product_id"]
|
||||
== test_marketplace_product.marketplace_product_id
|
||||
)
|
||||
assert product["marketplace"] == test_marketplace_product.marketplace
|
||||
assert "translations" in product
|
||||
|
||||
def test_get_admin_product_detail_not_found(self, db):
|
||||
"""Test getting non-existent product detail raises exception."""
|
||||
with pytest.raises(MarketplaceProductNotFoundException):
|
||||
self.service.get_admin_product_detail(db, 99999)
|
||||
|
||||
def test_copy_to_store_catalog_success(
|
||||
self, db, test_marketplace_product, test_store
|
||||
):
|
||||
"""Test copying products to store catalog."""
|
||||
result = self.service.copy_to_store_catalog(
|
||||
db,
|
||||
marketplace_product_ids=[test_marketplace_product.id],
|
||||
store_id=test_store.id,
|
||||
)
|
||||
|
||||
assert result["copied"] == 1
|
||||
assert result["skipped"] == 0
|
||||
assert result["failed"] == 0
|
||||
|
||||
def test_copy_to_store_catalog_skip_existing(
|
||||
self, db, test_marketplace_product, test_store
|
||||
):
|
||||
"""Test copying products that already exist skips them."""
|
||||
# First copy
|
||||
result1 = self.service.copy_to_store_catalog(
|
||||
db,
|
||||
marketplace_product_ids=[test_marketplace_product.id],
|
||||
store_id=test_store.id,
|
||||
)
|
||||
assert result1["copied"] == 1
|
||||
|
||||
# Second copy should skip
|
||||
result2 = self.service.copy_to_store_catalog(
|
||||
db,
|
||||
marketplace_product_ids=[test_marketplace_product.id],
|
||||
store_id=test_store.id,
|
||||
skip_existing=True,
|
||||
)
|
||||
assert result2["copied"] == 0
|
||||
assert result2["skipped"] == 1
|
||||
|
||||
def test_copy_to_store_catalog_invalid_store(self, db, test_marketplace_product):
|
||||
"""Test copying to non-existent store raises exception."""
|
||||
from app.modules.tenancy.exceptions import StoreNotFoundException
|
||||
|
||||
with pytest.raises(StoreNotFoundException):
|
||||
self.service.copy_to_store_catalog(
|
||||
db,
|
||||
marketplace_product_ids=[test_marketplace_product.id],
|
||||
store_id=99999,
|
||||
)
|
||||
|
||||
def test_copy_to_store_catalog_invalid_products(self, db, test_store):
|
||||
"""Test copying non-existent products raises exception."""
|
||||
with pytest.raises(MarketplaceProductNotFoundException):
|
||||
self.service.copy_to_store_catalog(
|
||||
db,
|
||||
marketplace_product_ids=[99999],
|
||||
store_id=test_store.id,
|
||||
)
|
||||
Reference in New Issue
Block a user