# 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.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_vendor): """Test creating credentials for a vendor.""" service = LetzshopCredentialsService(db) credentials = service.create_credentials( vendor_id=test_vendor.id, api_key="test-api-key-12345", auto_sync_enabled=False, sync_interval_minutes=30, ) assert credentials.vendor_id == test_vendor.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_vendor): """Test getting credentials for a vendor.""" service = LetzshopCredentialsService(db) # Create first service.create_credentials( vendor_id=test_vendor.id, api_key="test-api-key", ) # Get credentials = service.get_credentials(test_vendor.id) assert credentials is not None assert credentials.vendor_id == test_vendor.id def test_get_credentials_not_found(self, db, test_vendor): """Test getting non-existent credentials returns None.""" service = LetzshopCredentialsService(db) credentials = service.get_credentials(test_vendor.id) assert credentials is None def test_get_credentials_or_raise(self, db, test_vendor): """Test get_credentials_or_raise raises for non-existent.""" service = LetzshopCredentialsService(db) with pytest.raises(CredentialsNotFoundError): service.get_credentials_or_raise(test_vendor.id) def test_update_credentials(self, db, test_vendor): """Test updating credentials.""" service = LetzshopCredentialsService(db) # Create first service.create_credentials( vendor_id=test_vendor.id, api_key="original-key", auto_sync_enabled=False, ) # Update updated = service.update_credentials( vendor_id=test_vendor.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_vendor): """Test deleting credentials.""" service = LetzshopCredentialsService(db) # Create first service.create_credentials( vendor_id=test_vendor.id, api_key="test-key", ) # Delete result = service.delete_credentials(test_vendor.id) assert result is True # Verify deleted assert service.get_credentials(test_vendor.id) is None def test_delete_credentials_not_found(self, db, test_vendor): """Test deleting non-existent credentials returns False.""" service = LetzshopCredentialsService(db) result = service.delete_credentials(test_vendor.id) assert result is False def test_upsert_credentials_create(self, db, test_vendor): """Test upsert creates when not exists.""" service = LetzshopCredentialsService(db) credentials = service.upsert_credentials( vendor_id=test_vendor.id, api_key="new-key", ) assert credentials.vendor_id == test_vendor.id def test_upsert_credentials_update(self, db, test_vendor): """Test upsert updates when exists.""" service = LetzshopCredentialsService(db) # Create first service.create_credentials( vendor_id=test_vendor.id, api_key="original-key", auto_sync_enabled=False, ) # Upsert with new values credentials = service.upsert_credentials( vendor_id=test_vendor.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_vendor): """Test getting decrypted API key.""" service = LetzshopCredentialsService(db) original_key = "my-secret-api-key" service.create_credentials( vendor_id=test_vendor.id, api_key=original_key, ) decrypted = service.get_decrypted_api_key(test_vendor.id) assert decrypted == original_key def test_get_masked_api_key(self, db, test_vendor): """Test getting masked API key.""" service = LetzshopCredentialsService(db) service.create_credentials( vendor_id=test_vendor.id, api_key="letzshop-api-key-12345", ) masked = service.get_masked_api_key(test_vendor.id) assert masked.startswith("letz") assert "*" in masked def test_is_configured(self, db, test_vendor): """Test is_configured check.""" service = LetzshopCredentialsService(db) assert service.is_configured(test_vendor.id) is False service.create_credentials( vendor_id=test_vendor.id, api_key="test-key", ) assert service.is_configured(test_vendor.id) is True def test_get_status(self, db, test_vendor): """Test getting integration status.""" service = LetzshopCredentialsService(db) # Not configured status = service.get_status(test_vendor.id) assert status["is_configured"] is False assert status["auto_sync_enabled"] is False # Configured service.create_credentials( vendor_id=test_vendor.id, api_key="test-key", auto_sync_enabled=True, ) status = service.get_status(test_vendor.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) # ============================================================================ # Order Service Tests # ============================================================================ @pytest.mark.unit @pytest.mark.letzshop class TestLetzshopOrderService: """Test suite for Letzshop order service.""" def test_create_order_extracts_locale(self, db, test_vendor): """Test that create_order extracts customer locale.""" from app.services.letzshop.order_service import LetzshopOrderService service = LetzshopOrderService(db) shipment_data = { "id": "ship_123", "state": "confirmed", "order": { "id": "order_123", "number": "R123456", "email": "test@example.com", "total": "29.99 EUR", "locale": "fr", "shipAddress": { "firstName": "Jean", "lastName": "Dupont", "country": {"iso": "LU"}, }, "billAddress": { "country": {"iso": "FR"}, }, }, "inventoryUnits": [], } order = service.create_order(test_vendor.id, shipment_data) assert order.customer_locale == "fr" assert order.ship_country_iso == "LU" # Correct attribute name assert order.bill_country_iso == "FR" # Correct attribute name def test_create_order_extracts_ean(self, db, test_vendor): """Test that create_order extracts EAN from tradeId.""" from app.services.letzshop.order_service import LetzshopOrderService service = LetzshopOrderService(db) shipment_data = { "id": "ship_123", "state": "confirmed", "order": { "id": "order_123", "number": "R123456", "email": "test@example.com", "total": 29.99, "shipAddress": {}, }, "inventoryUnits": [ { "id": "unit_1", "state": "confirmed", "variant": { "id": "var_1", "sku": "SKU123", "mpn": "MPN456", "price": 19.99, "tradeId": { "number": "0889698273022", "parser": "gtin13", }, "product": { "name": {"en": "Test Product", "fr": "Produit Test"}, }, }, } ], } order = service.create_order(test_vendor.id, shipment_data) # Check order items (unified model uses items relationship) assert len(order.items) == 1 item = order.items[0] assert item.gtin == "0889698273022" assert item.gtin_type == "gtin13" assert item.product_sku == "SKU123" assert item.product_name == "Test Product" # Price is stored in cents (19.99 EUR = 1999 cents) assert item.unit_price_cents == 1999 def test_import_historical_shipments_deduplication(self, db, test_vendor): """Test that historical import deduplicates existing orders.""" from app.services.letzshop.order_service import LetzshopOrderService service = LetzshopOrderService(db) shipment_data = { "id": "ship_existing", "state": "confirmed", "order": { "id": "order_123", "number": "R123456", "email": "test@example.com", "total": 29.99, "shipAddress": {}, }, "inventoryUnits": [], } # Create first order service.create_order(test_vendor.id, shipment_data) db.commit() # Import same shipment again stats = service.import_historical_shipments( vendor_id=test_vendor.id, shipments=[shipment_data], match_products=False, ) assert stats["total"] == 1 assert stats["imported"] == 0 assert stats["skipped"] == 1 def test_import_historical_shipments_new_orders(self, db, test_vendor): """Test that historical import creates new orders.""" from app.services.letzshop.order_service import LetzshopOrderService service = LetzshopOrderService(db) shipments = [ { "id": f"ship_{i}", "state": "confirmed", "order": { "id": f"order_{i}", "number": f"R{i}", "email": f"customer{i}@example.com", "total": 29.99, "shipAddress": {}, }, "inventoryUnits": [], } for i in range(3) ] stats = service.import_historical_shipments( vendor_id=test_vendor.id, shipments=shipments, match_products=False, ) assert stats["total"] == 3 assert stats["imported"] == 3 assert stats["skipped"] == 0 def test_get_historical_import_summary(self, db, test_vendor): """Test historical import summary statistics.""" from app.services.letzshop.order_service import LetzshopOrderService service = LetzshopOrderService(db) # Create some orders with different locales for i, locale in enumerate(["fr", "fr", "de", "en"]): shipment_data = { "id": f"ship_{i}", "state": "confirmed", "order": { "id": f"order_{i}", "number": f"R{i}", "email": f"customer{i}@example.com", "total": 29.99, "locale": locale, "shipAddress": {"country": {"iso": "LU"}}, }, "inventoryUnits": [], } service.create_order(test_vendor.id, shipment_data) db.commit() summary = service.get_historical_import_summary(test_vendor.id) assert summary["total_orders"] == 4 assert summary["unique_customers"] == 4 assert summary["orders_by_locale"]["fr"] == 2 assert summary["orders_by_locale"]["de"] == 1 assert summary["orders_by_locale"]["en"] == 1