# tests/unit/services/test_store_service.py """Unit tests for StoreService following the application's exception patterns. Note: Product catalog operations (add_product_to_catalog, get_products) have been moved to app.modules.catalog.services. See test_product_service.py for those tests. """ import uuid import pytest from app.exceptions import ValidationException from app.modules.tenancy.exceptions import ( InvalidStoreDataException, UnauthorizedStoreAccessException, StoreAlreadyExistsException, StoreNotFoundException, ) from app.modules.tenancy.services.store_service import StoreService from app.modules.tenancy.models import Merchant from app.modules.tenancy.models import Store from app.modules.tenancy.schemas.store import StoreCreate @pytest.fixture def admin_merchant(db, test_admin): """Create a test merchant for admin.""" unique_id = str(uuid.uuid4())[:8] merchant = Merchant( name=f"Admin Merchant {unique_id}", owner_user_id=test_admin.id, contact_email=f"admin{unique_id}@merchant.com", is_active=True, is_verified=True, ) db.add(merchant) db.commit() db.refresh(merchant) return merchant # Note: other_merchant fixture is defined in tests/fixtures/store_fixtures.py @pytest.mark.unit @pytest.mark.stores class TestStoreService: """Test suite for StoreService following the application's exception patterns.""" def setup_method(self): """Setup method following the same pattern as admin service tests.""" self.service = StoreService() # ==================== create_store Tests ==================== def test_create_store_success(self, db, test_user, test_merchant): """Test successful store creation.""" unique_id = str(uuid.uuid4())[:8] store_data = StoreCreate( merchant_id=test_merchant.id, store_code=f"NEWSTORE_{unique_id}", subdomain=f"newstore{unique_id.lower()}", name=f"New Test Store {unique_id}", description="A new test store", ) store = self.service.create_store(db, store_data, test_user) db.commit() assert store is not None assert store.store_code == f"NEWSTORE_{unique_id}".upper() assert store.merchant_id == test_merchant.id assert store.is_verified is False # Regular user creates unverified store def test_create_store_admin_auto_verify(self, db, test_admin, admin_merchant): """Test admin creates verified store automatically.""" unique_id = str(uuid.uuid4())[:8] store_data = StoreCreate( merchant_id=admin_merchant.id, store_code=f"ADMINSTORE_{unique_id}", subdomain=f"adminstore{unique_id.lower()}", name=f"Admin Test Store {unique_id}", ) store = self.service.create_store(db, store_data, test_admin) db.commit() assert store.is_verified is True # Admin creates verified store def test_create_store_duplicate_code( self, db, test_user, test_merchant, test_store ): """Test store creation fails with duplicate store code.""" store_data = StoreCreate( merchant_id=test_merchant.id, store_code=test_store.store_code, subdomain="duplicatesub", name="Duplicate Name", ) with pytest.raises(StoreAlreadyExistsException) as exc_info: self.service.create_store(db, store_data, test_user) exception = exc_info.value assert exception.status_code == 409 assert exception.error_code == "STORE_ALREADY_EXISTS" assert test_store.store_code.upper() in exception.message def test_create_store_missing_merchant_id(self, db, test_user): """Test store creation fails without merchant_id.""" # StoreCreate requires merchant_id, so this should raise ValidationError # from Pydantic before reaching service with pytest.raises(Exception): # Pydantic ValidationError StoreCreate( store_code="NOMERCHANT", subdomain="nomerchant", name="No Merchant Store", ) def test_create_store_unauthorized_user(self, db, test_user, other_merchant): """Test store creation fails when user doesn't own merchant.""" unique_id = str(uuid.uuid4())[:8] store_data = StoreCreate( merchant_id=other_merchant.id, # Not owned by test_user store_code=f"UNAUTH_{unique_id}", subdomain=f"unauth{unique_id.lower()}", name=f"Unauthorized Store {unique_id}", ) with pytest.raises(UnauthorizedStoreAccessException) as exc_info: self.service.create_store(db, store_data, test_user) exception = exc_info.value assert exception.status_code == 403 assert exception.error_code == "UNAUTHORIZED_STORE_ACCESS" def test_create_store_invalid_merchant_id(self, db, test_user): """Test store creation fails with non-existent merchant.""" unique_id = str(uuid.uuid4())[:8] store_data = StoreCreate( merchant_id=99999, # Non-existent merchant store_code=f"BADMERCHANT_{unique_id}", subdomain=f"badmerchant{unique_id.lower()}", name=f"Bad Merchant Store {unique_id}", ) with pytest.raises(InvalidStoreDataException) as exc_info: self.service.create_store(db, store_data, test_user) exception = exc_info.value assert exception.status_code == 422 assert exception.error_code == "INVALID_STORE_DATA" assert "merchant_id" in exception.details.get("field", "") # ==================== get_stores Tests ==================== def test_get_stores_regular_user( self, db, test_user, test_store, inactive_store ): """Test regular user can only see active verified stores and own stores.""" stores, total = self.service.get_stores(db, test_user, skip=0, limit=100) store_codes = [store.store_code for store in stores] assert test_store.store_code in store_codes # Inactive store should not be visible to regular user assert inactive_store.store_code not in store_codes def test_get_stores_admin_user( self, db, test_admin, test_store, inactive_store, verified_store ): """Test admin user can see all stores with filters.""" stores, total = self.service.get_stores( db, test_admin, active_only=False, verified_only=False ) store_codes = [store.store_code for store in stores] assert test_store.store_code in store_codes assert inactive_store.store_code in store_codes assert verified_store.store_code in store_codes def test_get_stores_pagination(self, db, test_admin): """Test store pagination.""" stores, total = self.service.get_stores( db, test_admin, skip=0, limit=5, active_only=False ) assert len(stores) <= 5 def test_get_stores_database_error(self, db, test_user, monkeypatch): """Test get stores handles database errors gracefully.""" def mock_query(*args): raise Exception("Database query failed") monkeypatch.setattr(db, "query", mock_query) with pytest.raises(ValidationException) as exc_info: self.service.get_stores(db, test_user) exception = exc_info.value assert exception.error_code == "VALIDATION_ERROR" assert "Failed to retrieve stores" in exception.message # ==================== get_store_by_code Tests ==================== def test_get_store_by_code_owner_access(self, db, test_user, test_store): """Test store owner can access their own store.""" store = self.service.get_store_by_code( db, test_store.store_code.lower(), test_user ) assert store is not None assert store.id == test_store.id def test_get_store_by_code_admin_access(self, db, test_admin, test_store): """Test admin can access any store.""" store = self.service.get_store_by_code( db, test_store.store_code.lower(), test_admin ) assert store is not None assert store.id == test_store.id def test_get_store_by_code_not_found(self, db, test_user): """Test store not found raises proper exception.""" with pytest.raises(StoreNotFoundException) as exc_info: self.service.get_store_by_code(db, "NONEXISTENT", test_user) exception = exc_info.value assert exception.status_code == 404 assert exception.error_code == "STORE_NOT_FOUND" def test_get_store_by_code_access_denied(self, db, test_user, inactive_store): """Test regular user cannot access unverified store they don't own.""" with pytest.raises(UnauthorizedStoreAccessException) as exc_info: self.service.get_store_by_code(db, inactive_store.store_code, test_user) exception = exc_info.value assert exception.status_code == 403 assert exception.error_code == "UNAUTHORIZED_STORE_ACCESS" # ==================== get_store_by_id Tests ==================== def test_get_store_by_id_success(self, db, test_store): """Test getting store by ID.""" store = self.service.get_store_by_id(db, test_store.id) assert store is not None assert store.id == test_store.id assert store.store_code == test_store.store_code def test_get_store_by_id_not_found(self, db): """Test getting non-existent store by ID.""" with pytest.raises(StoreNotFoundException) as exc_info: self.service.get_store_by_id(db, 99999) exception = exc_info.value assert exception.status_code == 404 assert exception.error_code == "STORE_NOT_FOUND" # ==================== get_active_store_by_code Tests ==================== def test_get_active_store_by_code_success(self, db, test_store): """Test getting active store by code (public access).""" store = self.service.get_active_store_by_code(db, test_store.store_code) assert store is not None assert store.id == test_store.id assert store.is_active is True def test_get_active_store_by_code_inactive(self, db, inactive_store): """Test getting inactive store fails.""" with pytest.raises(StoreNotFoundException): self.service.get_active_store_by_code(db, inactive_store.store_code) def test_get_active_store_by_code_not_found(self, db): """Test getting non-existent store fails.""" with pytest.raises(StoreNotFoundException): self.service.get_active_store_by_code(db, "NONEXISTENT") # ==================== toggle_verification Tests ==================== def test_toggle_verification_verify(self, db, inactive_store): """Test toggling verification on.""" original_verified = inactive_store.is_verified store, message = self.service.toggle_verification(db, inactive_store.id) db.commit() assert store.is_verified != original_verified assert "verified" in message.lower() def test_toggle_verification_unverify(self, db, verified_store): """Test toggling verification off.""" store, message = self.service.toggle_verification(db, verified_store.id) db.commit() assert store.is_verified is False assert "unverified" in message.lower() def test_toggle_verification_not_found(self, db): """Test toggle verification on non-existent store.""" with pytest.raises(StoreNotFoundException): self.service.toggle_verification(db, 99999) # ==================== toggle_status Tests ==================== def test_toggle_status_deactivate(self, db, test_store): """Test toggling active status off.""" store, message = self.service.toggle_status(db, test_store.id) db.commit() assert store.is_active is False assert "inactive" in message.lower() def test_toggle_status_activate(self, db, inactive_store): """Test toggling active status on.""" store, message = self.service.toggle_status(db, inactive_store.id) db.commit() assert store.is_active is True assert "active" in message.lower() def test_toggle_status_not_found(self, db): """Test toggle status on non-existent store.""" with pytest.raises(StoreNotFoundException): self.service.toggle_status(db, 99999) # ==================== set_verification / set_status Tests ==================== def test_set_verification_to_true(self, db, inactive_store): """Test setting verification to true.""" store, message = self.service.set_verification(db, inactive_store.id, True) db.commit() assert store.is_verified is True def test_set_verification_to_false(self, db, verified_store): """Test setting verification to false.""" store, message = self.service.set_verification(db, verified_store.id, False) db.commit() assert store.is_verified is False def test_set_status_to_active(self, db, inactive_store): """Test setting status to active.""" store, message = self.service.set_status(db, inactive_store.id, True) db.commit() assert store.is_active is True def test_set_status_to_inactive(self, db, test_store): """Test setting status to inactive.""" store, message = self.service.set_status(db, test_store.id, False) db.commit() assert store.is_active is False # NOTE: add_product_to_catalog and get_products tests have been moved to # test_product_service.py since those methods are now in the catalog module. # ==================== Helper Method Tests ==================== def test_store_code_exists(self, db, test_store): """Test _store_code_exists helper method.""" assert self.service._store_code_exists(db, test_store.store_code) is True assert self.service._store_code_exists(db, "NONEXISTENT") is False def test_can_access_store_admin(self, db, test_admin, test_store): """Test admin can always access store.""" # Re-query store to get fresh instance store = db.query(Store).filter(Store.id == test_store.id).first() assert self.service._can_access_store(store, test_admin) is True def test_can_access_store_active_verified(self, db, test_user, verified_store): """Test any user can access active verified store.""" # Re-query store to get fresh instance store = db.query(Store).filter(Store.id == verified_store.id).first() assert self.service._can_access_store(store, test_user) is True @pytest.mark.unit @pytest.mark.stores class TestStoreServiceExceptionDetails: """Additional tests focusing specifically on exception structure and details.""" def setup_method(self): self.service = StoreService() def test_exception_to_dict_structure( self, db, test_user, test_store, test_merchant ): """Test that exceptions can be properly serialized to dict for API responses.""" store_data = StoreCreate( merchant_id=test_merchant.id, store_code=test_store.store_code, subdomain="duplicate", name="Duplicate", ) with pytest.raises(StoreAlreadyExistsException) as exc_info: self.service.create_store(db, store_data, test_user) exception = exc_info.value exception_dict = exception.to_dict() # Verify structure matches expected API response format assert "error_code" in exception_dict assert "message" in exception_dict assert "status_code" in exception_dict assert "details" in exception_dict # Verify values assert exception_dict["error_code"] == "STORE_ALREADY_EXISTS" assert exception_dict["status_code"] == 409 assert isinstance(exception_dict["details"], dict) def test_authorization_exception_user_details(self, db, test_user, inactive_store): """Test authorization exceptions include user context.""" with pytest.raises(UnauthorizedStoreAccessException) as exc_info: self.service.get_store_by_code(db, inactive_store.store_code, test_user) exception = exc_info.value assert exception.details["store_code"] == inactive_store.store_code assert exception.details["user_id"] == test_user.id assert "Unauthorized access" in exception.message def test_not_found_exception_details(self, db, test_user): """Test not found exceptions include identifier details.""" with pytest.raises(StoreNotFoundException) as exc_info: self.service.get_store_by_code(db, "NOTEXIST", test_user) exception = exc_info.value assert exception.status_code == 404 assert exception.error_code == "STORE_NOT_FOUND" @pytest.mark.unit @pytest.mark.stores class TestStoreServiceIdentifier: """Tests for get_store_by_identifier method.""" def setup_method(self): self.service = StoreService() def test_get_store_by_identifier_with_id(self, db, test_store): """Test getting store by numeric ID string.""" store = self.service.get_store_by_identifier(db, str(test_store.id)) assert store is not None assert store.id == test_store.id def test_get_store_by_identifier_with_code(self, db, test_store): """Test getting store by store_code.""" store = self.service.get_store_by_identifier(db, test_store.store_code) assert store is not None assert store.store_code == test_store.store_code def test_get_store_by_identifier_case_insensitive(self, db, test_store): """Test getting store by store_code is case insensitive.""" store = self.service.get_store_by_identifier( db, test_store.store_code.lower() ) assert store is not None assert store.id == test_store.id def test_get_store_by_identifier_not_found(self, db): """Test getting non-existent store.""" with pytest.raises(StoreNotFoundException): self.service.get_store_by_identifier(db, "NONEXISTENT_CODE") @pytest.mark.unit @pytest.mark.stores class TestStoreServicePermissions: """Tests for permission checking methods.""" def setup_method(self): self.service = StoreService() def test_can_update_store_admin(self, db, test_admin, test_store): """Test admin can always update store.""" store = db.query(Store).filter(Store.id == test_store.id).first() assert self.service.can_update_store(store, test_admin) is True def test_can_update_store_owner(self, db, test_user, test_store): """Test owner can update store.""" store = db.query(Store).filter(Store.id == test_store.id).first() assert self.service.can_update_store(store, test_user) is True def test_can_update_store_non_owner(self, db, other_merchant, test_store): """Test non-owner cannot update store.""" from app.modules.tenancy.models import User store = db.query(Store).filter(Store.id == test_store.id).first() other_user = db.query(User).filter(User.id == other_merchant.owner_user_id).first() # Clear any StoreUser relationships assert self.service.can_update_store(store, other_user) is False def test_is_store_owner_true(self, db, test_user, test_store): """Test _is_store_owner returns True for owner.""" store = db.query(Store).filter(Store.id == test_store.id).first() assert self.service._is_store_owner(store, test_user) is True def test_is_store_owner_false(self, db, other_merchant, test_store): """Test _is_store_owner returns False for non-owner.""" from app.modules.tenancy.models import User store = db.query(Store).filter(Store.id == test_store.id).first() other_user = db.query(User).filter(User.id == other_merchant.owner_user_id).first() assert self.service._is_store_owner(store, other_user) is False @pytest.mark.unit @pytest.mark.stores class TestStoreServiceUpdate: """Tests for update methods.""" def setup_method(self): self.service = StoreService() def test_update_store_success(self, db, test_user, test_store): """Test successfully updating store profile.""" from pydantic import BaseModel class StoreUpdate(BaseModel): name: str | None = None description: str | None = None class Config: extra = "forbid" update_data = StoreUpdate( name="Updated Store Name", description="Updated description", ) store = self.service.update_store( db, test_store.id, update_data, test_user ) db.commit() assert store.name == "Updated Store Name" assert store.description == "Updated description" def test_update_store_unauthorized(self, db, other_merchant, test_store): """Test update fails for unauthorized user.""" from pydantic import BaseModel from app.modules.tenancy.exceptions import InsufficientPermissionsException from app.modules.tenancy.models import User class StoreUpdate(BaseModel): name: str | None = None class Config: extra = "forbid" other_user = db.query(User).filter(User.id == other_merchant.owner_user_id).first() update_data = StoreUpdate(name="Unauthorized Update") with pytest.raises(InsufficientPermissionsException): self.service.update_store( db, test_store.id, update_data, other_user ) def test_update_store_not_found(self, db, test_admin): """Test update fails for non-existent store.""" from pydantic import BaseModel class StoreUpdate(BaseModel): name: str | None = None class Config: extra = "forbid" update_data = StoreUpdate(name="Update") with pytest.raises(StoreNotFoundException): self.service.update_store(db, 99999, update_data, test_admin) def test_update_marketplace_settings_success(self, db, test_user, test_store): """Test successfully updating marketplace settings.""" marketplace_config = { "letzshop_csv_url_fr": "https://example.com/fr.csv", "letzshop_csv_url_en": "https://example.com/en.csv", } result = self.service.update_marketplace_settings( db, test_store.id, marketplace_config, test_user ) db.commit() assert result["message"] == "Marketplace settings updated successfully" assert result["letzshop_csv_url_fr"] == "https://example.com/fr.csv" assert result["letzshop_csv_url_en"] == "https://example.com/en.csv" def test_update_marketplace_settings_unauthorized( self, db, other_merchant, test_store ): """Test marketplace settings update fails for unauthorized user.""" from app.modules.tenancy.exceptions import InsufficientPermissionsException from app.modules.tenancy.models import User other_user = db.query(User).filter(User.id == other_merchant.owner_user_id).first() marketplace_config = {"letzshop_csv_url_fr": "https://example.com/fr.csv"} with pytest.raises(InsufficientPermissionsException): self.service.update_marketplace_settings( db, test_store.id, marketplace_config, other_user ) def test_update_marketplace_settings_not_found(self, db, test_admin): """Test marketplace settings update fails for non-existent store.""" marketplace_config = {"letzshop_csv_url_fr": "https://example.com/fr.csv"} with pytest.raises(StoreNotFoundException): self.service.update_marketplace_settings( db, 99999, marketplace_config, test_admin ) @pytest.mark.unit @pytest.mark.stores class TestStoreServiceSingleton: """Test singleton instance.""" def test_singleton_exists(self): """Test store_service singleton exists.""" from app.modules.tenancy.services.store_service import store_service assert store_service is not None assert isinstance(store_service, StoreService)