feat: add Letzshop bidirectional order integration

Add complete Letzshop marketplace integration with:
- GraphQL client for order import and fulfillment operations
- Encrypted credential storage per vendor (Fernet encryption)
- Admin and vendor API endpoints for credentials management
- Order import, confirmation, rejection, and tracking
- Fulfillment queue and sync logging
- Comprehensive documentation and test coverage

New files:
- app/services/letzshop/ - GraphQL client and services
- app/utils/encryption.py - Fernet encryption utility
- models/database/letzshop.py - Database models
- models/schema/letzshop.py - Pydantic schemas
- app/api/v1/admin/letzshop.py - Admin API endpoints
- app/api/v1/vendor/letzshop.py - Vendor API endpoints
- docs/guides/letzshop-order-integration.md - Documentation

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2025-12-13 12:19:54 +01:00
parent 837b1f93f4
commit 448f01f82b
20 changed files with 5251 additions and 0 deletions

View File

@@ -0,0 +1,361 @@
# tests/integration/api/v1/admin/test_letzshop.py
"""
Integration tests for admin Letzshop API endpoints.
Tests cover:
1. Vendor Letzshop status overview
2. Credentials management for vendors
3. Connection testing
4. Order management for vendors
"""
import pytest
from unittest.mock import patch, MagicMock
@pytest.mark.integration
@pytest.mark.api
@pytest.mark.admin
@pytest.mark.letzshop
class TestAdminLetzshopVendorsAPI:
"""Test admin Letzshop vendor overview endpoints."""
def test_list_vendors_letzshop_status(
self, client, admin_headers, test_vendor
):
"""Test listing vendors with Letzshop status."""
response = client.get(
"/api/v1/admin/letzshop/vendors", headers=admin_headers
)
assert response.status_code == 200
data = response.json()
assert "vendors" in data
assert "total" in data
# Find our test vendor
vendor_found = False
for v in data["vendors"]:
if v["vendor_id"] == test_vendor.id:
vendor_found = True
assert v["is_configured"] is False # Not configured yet
break
# Vendor may not be found if inactive, that's ok
def test_list_vendors_configured_only(
self, client, db, admin_headers, test_vendor
):
"""Test listing only configured vendors."""
from models.database.letzshop import VendorLetzshopCredentials
from app.utils.encryption import encrypt_value
# Configure credentials for test vendor
credentials = VendorLetzshopCredentials(
vendor_id=test_vendor.id,
api_key_encrypted=encrypt_value("test-key"),
api_endpoint="https://letzshop.lu/graphql",
)
db.add(credentials)
db.commit()
response = client.get(
"/api/v1/admin/letzshop/vendors?configured_only=true",
headers=admin_headers,
)
assert response.status_code == 200
data = response.json()
# All returned vendors should be configured
for v in data["vendors"]:
assert v["is_configured"] is True
@pytest.mark.integration
@pytest.mark.api
@pytest.mark.admin
@pytest.mark.letzshop
class TestAdminLetzshopCredentialsAPI:
"""Test admin Letzshop credentials management endpoints."""
def test_get_vendor_credentials_not_configured(
self, client, admin_headers, test_vendor
):
"""Test getting credentials when not configured returns 404."""
response = client.get(
f"/api/v1/admin/letzshop/vendors/{test_vendor.id}/credentials",
headers=admin_headers,
)
assert response.status_code == 404
def test_create_vendor_credentials(
self, client, admin_headers, test_vendor
):
"""Test creating credentials for a vendor."""
response = client.post(
f"/api/v1/admin/letzshop/vendors/{test_vendor.id}/credentials",
headers=admin_headers,
json={
"api_key": "admin-set-api-key-12345",
"auto_sync_enabled": True,
"sync_interval_minutes": 60,
},
)
assert response.status_code == 200
data = response.json()
assert data["vendor_id"] == test_vendor.id
assert "****" in data["api_key_masked"]
assert data["auto_sync_enabled"] is True
assert data["sync_interval_minutes"] == 60
def test_get_vendor_credentials_after_create(
self, client, db, admin_headers, test_vendor
):
"""Test getting credentials after creation."""
# Create first
client.post(
f"/api/v1/admin/letzshop/vendors/{test_vendor.id}/credentials",
headers=admin_headers,
json={"api_key": "test-key"},
)
# Get
response = client.get(
f"/api/v1/admin/letzshop/vendors/{test_vendor.id}/credentials",
headers=admin_headers,
)
assert response.status_code == 200
data = response.json()
assert data["vendor_id"] == test_vendor.id
def test_update_vendor_credentials(
self, client, admin_headers, test_vendor
):
"""Test partial update of vendor credentials."""
# Create first
client.post(
f"/api/v1/admin/letzshop/vendors/{test_vendor.id}/credentials",
headers=admin_headers,
json={"api_key": "original-key", "auto_sync_enabled": False},
)
# Update
response = client.patch(
f"/api/v1/admin/letzshop/vendors/{test_vendor.id}/credentials",
headers=admin_headers,
json={"auto_sync_enabled": True},
)
assert response.status_code == 200
data = response.json()
assert data["auto_sync_enabled"] is True
def test_delete_vendor_credentials(
self, client, admin_headers, test_vendor
):
"""Test deleting vendor credentials."""
# Create first
client.post(
f"/api/v1/admin/letzshop/vendors/{test_vendor.id}/credentials",
headers=admin_headers,
json={"api_key": "test-key"},
)
# Delete
response = client.delete(
f"/api/v1/admin/letzshop/vendors/{test_vendor.id}/credentials",
headers=admin_headers,
)
assert response.status_code == 200
data = response.json()
assert data["success"] is True
def test_vendor_not_found(self, client, admin_headers):
"""Test operations on non-existent vendor return 404."""
response = client.get(
"/api/v1/admin/letzshop/vendors/99999/credentials",
headers=admin_headers,
)
assert response.status_code == 404
@pytest.mark.integration
@pytest.mark.api
@pytest.mark.admin
@pytest.mark.letzshop
class TestAdminLetzshopConnectionAPI:
"""Test admin Letzshop connection testing endpoints."""
@patch("app.services.letzshop.client.requests.Session.post")
def test_test_vendor_connection(
self, mock_post, client, admin_headers, test_vendor
):
"""Test connection for a specific vendor."""
# Mock response
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {"data": {"__typename": "Query"}}
mock_post.return_value = mock_response
# Create credentials
client.post(
f"/api/v1/admin/letzshop/vendors/{test_vendor.id}/credentials",
headers=admin_headers,
json={"api_key": "test-key"},
)
# Test connection
response = client.post(
f"/api/v1/admin/letzshop/vendors/{test_vendor.id}/test",
headers=admin_headers,
)
assert response.status_code == 200
data = response.json()
assert data["success"] is True
@patch("app.services.letzshop.client.requests.Session.post")
def test_test_api_key_directly(
self, mock_post, client, admin_headers
):
"""Test any API key without associating with vendor."""
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {"data": {"__typename": "Query"}}
mock_post.return_value = mock_response
response = client.post(
"/api/v1/admin/letzshop/test",
headers=admin_headers,
json={"api_key": "test-api-key-to-validate"},
)
assert response.status_code == 200
data = response.json()
assert data["success"] is True
@pytest.mark.integration
@pytest.mark.api
@pytest.mark.admin
@pytest.mark.letzshop
class TestAdminLetzshopOrdersAPI:
"""Test admin Letzshop order management endpoints."""
def test_list_vendor_orders_empty(
self, client, admin_headers, test_vendor
):
"""Test listing vendor orders when none exist."""
response = client.get(
f"/api/v1/admin/letzshop/vendors/{test_vendor.id}/orders",
headers=admin_headers,
)
assert response.status_code == 200
data = response.json()
assert data["orders"] == []
assert data["total"] == 0
def test_list_vendor_orders_with_data(
self, client, db, admin_headers, test_vendor
):
"""Test listing vendor orders with data."""
from models.database.letzshop import LetzshopOrder
# Create test orders
order = LetzshopOrder(
vendor_id=test_vendor.id,
letzshop_order_id="admin_order_1",
letzshop_state="unconfirmed",
customer_email="admin-test@example.com",
total_amount="150.00",
sync_status="pending",
)
db.add(order)
db.commit()
response = client.get(
f"/api/v1/admin/letzshop/vendors/{test_vendor.id}/orders",
headers=admin_headers,
)
assert response.status_code == 200
data = response.json()
assert data["total"] == 1
assert data["orders"][0]["customer_email"] == "admin-test@example.com"
@patch("app.services.letzshop.client.requests.Session.post")
def test_trigger_vendor_sync(
self, mock_post, client, admin_headers, test_vendor
):
"""Test triggering sync for a vendor."""
# Mock response
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {
"data": {
"shipments": {
"nodes": [
{
"id": "gid://letzshop/Shipment/789",
"state": "unconfirmed",
"order": {
"id": "gid://letzshop/Order/111",
"number": "LS-ADMIN-001",
"email": "sync@example.com",
"totalPrice": {"amount": "200.00", "currency": "EUR"},
},
"inventoryUnits": {"nodes": []},
}
]
}
}
}
mock_post.return_value = mock_response
# Create credentials
client.post(
f"/api/v1/admin/letzshop/vendors/{test_vendor.id}/credentials",
headers=admin_headers,
json={"api_key": "admin-sync-key"},
)
# Trigger sync
response = client.post(
f"/api/v1/admin/letzshop/vendors/{test_vendor.id}/sync",
headers=admin_headers,
json={"operation": "order_import"},
)
assert response.status_code == 200
data = response.json()
assert data["success"] is True
assert data["orders_imported"] >= 0
@pytest.mark.integration
@pytest.mark.api
@pytest.mark.admin
@pytest.mark.letzshop
class TestAdminLetzshopAccessControl:
"""Test admin access control for Letzshop endpoints."""
def test_non_admin_cannot_access(
self, client, auth_headers, test_vendor
):
"""Test that non-admin users cannot access admin endpoints."""
response = client.get(
"/api/v1/admin/letzshop/vendors",
headers=auth_headers,
)
assert response.status_code == 403
def test_unauthenticated_cannot_access(self, client):
"""Test that unauthenticated requests are rejected."""
response = client.get("/api/v1/admin/letzshop/vendors")
assert response.status_code == 401

View File

@@ -0,0 +1,499 @@
# tests/integration/api/v1/vendor/test_letzshop.py
"""
Integration tests for vendor Letzshop API endpoints.
Tests cover:
1. Credentials management (CRUD)
2. Connection testing
3. Order listing
"""
import pytest
from unittest.mock import patch, MagicMock
@pytest.mark.integration
@pytest.mark.api
@pytest.mark.vendor
@pytest.mark.letzshop
class TestVendorLetzshopCredentialsAPI:
"""Test vendor Letzshop credentials endpoints."""
def test_get_status_not_configured(
self, client, vendor_user_headers, test_vendor_with_vendor_user
):
"""Test getting status when not configured."""
response = client.get(
"/api/v1/vendor/letzshop/status", headers=vendor_user_headers
)
assert response.status_code == 200
data = response.json()
assert data["is_configured"] is False
assert data["auto_sync_enabled"] is False
def test_save_credentials(
self, client, vendor_user_headers, test_vendor_with_vendor_user
):
"""Test saving Letzshop credentials."""
response = client.post(
"/api/v1/vendor/letzshop/credentials",
headers=vendor_user_headers,
json={
"api_key": "test-letzshop-api-key-12345",
"auto_sync_enabled": False,
"sync_interval_minutes": 30,
},
)
assert response.status_code == 200
data = response.json()
assert data["vendor_id"] == test_vendor_with_vendor_user.id
assert "****" in data["api_key_masked"]
assert data["auto_sync_enabled"] is False
assert data["sync_interval_minutes"] == 30
def test_get_credentials_after_save(
self, client, db, vendor_user_headers, test_vendor_with_vendor_user
):
"""Test getting credentials after saving."""
# Save first
client.post(
"/api/v1/vendor/letzshop/credentials",
headers=vendor_user_headers,
json={"api_key": "my-test-key"},
)
# Get
response = client.get(
"/api/v1/vendor/letzshop/credentials", headers=vendor_user_headers
)
assert response.status_code == 200
data = response.json()
assert data["vendor_id"] == test_vendor_with_vendor_user.id
assert "api_key_masked" in data
def test_get_credentials_not_configured(
self, client, vendor_user_headers, test_vendor_with_vendor_user
):
"""Test getting credentials when not configured returns 404."""
response = client.get(
"/api/v1/vendor/letzshop/credentials", headers=vendor_user_headers
)
assert response.status_code == 404
def test_update_credentials(
self, client, vendor_user_headers, test_vendor_with_vendor_user
):
"""Test partial update of credentials."""
# Save first
client.post(
"/api/v1/vendor/letzshop/credentials",
headers=vendor_user_headers,
json={"api_key": "original-key", "auto_sync_enabled": False},
)
# Update
response = client.patch(
"/api/v1/vendor/letzshop/credentials",
headers=vendor_user_headers,
json={"auto_sync_enabled": True, "sync_interval_minutes": 60},
)
assert response.status_code == 200
data = response.json()
assert data["auto_sync_enabled"] is True
assert data["sync_interval_minutes"] == 60
def test_delete_credentials(
self, client, vendor_user_headers, test_vendor_with_vendor_user
):
"""Test deleting credentials."""
# Save first
client.post(
"/api/v1/vendor/letzshop/credentials",
headers=vendor_user_headers,
json={"api_key": "test-key"},
)
# Delete
response = client.delete(
"/api/v1/vendor/letzshop/credentials", headers=vendor_user_headers
)
assert response.status_code == 200
data = response.json()
assert data["success"] is True
# Verify deleted
response = client.get(
"/api/v1/vendor/letzshop/credentials", headers=vendor_user_headers
)
assert response.status_code == 404
def test_delete_credentials_not_configured(
self, client, vendor_user_headers, test_vendor_with_vendor_user
):
"""Test deleting when not configured returns 404."""
response = client.delete(
"/api/v1/vendor/letzshop/credentials", headers=vendor_user_headers
)
assert response.status_code == 404
@pytest.mark.integration
@pytest.mark.api
@pytest.mark.vendor
@pytest.mark.letzshop
class TestVendorLetzshopConnectionAPI:
"""Test vendor Letzshop connection testing endpoints."""
def test_test_connection_not_configured(
self, client, vendor_user_headers, test_vendor_with_vendor_user
):
"""Test connection when not configured."""
response = client.post(
"/api/v1/vendor/letzshop/test", headers=vendor_user_headers
)
assert response.status_code == 200
data = response.json()
assert data["success"] is False
assert "not configured" in data["error_details"]
@patch("app.services.letzshop.client.requests.Session.post")
def test_test_connection_success(
self, mock_post, client, vendor_user_headers, test_vendor_with_vendor_user
):
"""Test successful connection test."""
# Mock successful response
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {"data": {"__typename": "Query"}}
mock_post.return_value = mock_response
# Save credentials first
client.post(
"/api/v1/vendor/letzshop/credentials",
headers=vendor_user_headers,
json={"api_key": "valid-test-key"},
)
# Test connection
response = client.post(
"/api/v1/vendor/letzshop/test", headers=vendor_user_headers
)
assert response.status_code == 200
data = response.json()
assert data["success"] is True
assert data["response_time_ms"] is not None
@patch("app.services.letzshop.client.requests.Session.post")
def test_test_api_key_without_saving(
self, mock_post, client, vendor_user_headers, test_vendor_with_vendor_user
):
"""Test API key without saving it."""
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {"data": {"__typename": "Query"}}
mock_post.return_value = mock_response
response = client.post(
"/api/v1/vendor/letzshop/test-key",
headers=vendor_user_headers,
json={"api_key": "test-key-to-validate"},
)
assert response.status_code == 200
data = response.json()
assert data["success"] is True
@pytest.mark.integration
@pytest.mark.api
@pytest.mark.vendor
@pytest.mark.letzshop
class TestVendorLetzshopOrdersAPI:
"""Test vendor Letzshop order endpoints."""
def test_list_orders_empty(
self, client, vendor_user_headers, test_vendor_with_vendor_user
):
"""Test listing orders when none exist."""
response = client.get(
"/api/v1/vendor/letzshop/orders", headers=vendor_user_headers
)
assert response.status_code == 200
data = response.json()
assert data["orders"] == []
assert data["total"] == 0
def test_list_orders_with_filters(
self, client, db, vendor_user_headers, test_vendor_with_vendor_user
):
"""Test listing orders with status filter."""
from models.database.letzshop import LetzshopOrder
# Create test orders
order1 = LetzshopOrder(
vendor_id=test_vendor_with_vendor_user.id,
letzshop_order_id="order_1",
letzshop_state="unconfirmed",
sync_status="pending",
)
order2 = LetzshopOrder(
vendor_id=test_vendor_with_vendor_user.id,
letzshop_order_id="order_2",
letzshop_state="confirmed",
sync_status="confirmed",
)
db.add_all([order1, order2])
db.commit()
# List pending only
response = client.get(
"/api/v1/vendor/letzshop/orders?sync_status=pending",
headers=vendor_user_headers,
)
assert response.status_code == 200
data = response.json()
assert data["total"] == 1
assert data["orders"][0]["sync_status"] == "pending"
def test_get_order_detail(
self, client, db, vendor_user_headers, test_vendor_with_vendor_user
):
"""Test getting order detail."""
from models.database.letzshop import LetzshopOrder
order = LetzshopOrder(
vendor_id=test_vendor_with_vendor_user.id,
letzshop_order_id="order_detail_test",
letzshop_shipment_id="shipment_1",
letzshop_state="unconfirmed",
customer_email="test@example.com",
total_amount="99.99",
sync_status="pending",
raw_order_data={"test": "data"},
)
db.add(order)
db.commit()
response = client.get(
f"/api/v1/vendor/letzshop/orders/{order.id}",
headers=vendor_user_headers,
)
assert response.status_code == 200
data = response.json()
assert data["letzshop_order_id"] == "order_detail_test"
assert data["customer_email"] == "test@example.com"
assert data["raw_order_data"] == {"test": "data"}
def test_get_order_not_found(
self, client, vendor_user_headers, test_vendor_with_vendor_user
):
"""Test getting non-existent order returns 404."""
response = client.get(
"/api/v1/vendor/letzshop/orders/99999",
headers=vendor_user_headers,
)
assert response.status_code == 404
def test_import_orders_not_configured(
self, client, vendor_user_headers, test_vendor_with_vendor_user
):
"""Test importing orders when not configured."""
response = client.post(
"/api/v1/vendor/letzshop/orders/import",
headers=vendor_user_headers,
json={},
)
assert response.status_code == 422 # Validation error
@patch("app.services.letzshop.client.requests.Session.post")
def test_import_orders_success(
self,
mock_post,
client,
db,
vendor_user_headers,
test_vendor_with_vendor_user,
):
"""Test successful order import."""
# Mock Letzshop API response
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {
"data": {
"shipments": {
"nodes": [
{
"id": "gid://letzshop/Shipment/123",
"state": "unconfirmed",
"order": {
"id": "gid://letzshop/Order/456",
"number": "LS-2025-001",
"email": "customer@example.com",
"totalPrice": {"amount": "99.99", "currency": "EUR"},
},
"inventoryUnits": {
"nodes": [
{"id": "unit_1", "state": "unconfirmed"},
]
},
}
]
}
}
}
mock_post.return_value = mock_response
# Save credentials first
client.post(
"/api/v1/vendor/letzshop/credentials",
headers=vendor_user_headers,
json={"api_key": "test-key"},
)
# Import orders
response = client.post(
"/api/v1/vendor/letzshop/orders/import",
headers=vendor_user_headers,
json={"operation": "order_import"},
)
assert response.status_code == 200
data = response.json()
assert data["success"] is True
assert data["orders_imported"] == 1
@pytest.mark.integration
@pytest.mark.api
@pytest.mark.vendor
@pytest.mark.letzshop
class TestVendorLetzshopFulfillmentAPI:
"""Test vendor Letzshop fulfillment endpoints."""
@patch("app.services.letzshop.client.requests.Session.post")
def test_confirm_order(
self,
mock_post,
client,
db,
vendor_user_headers,
test_vendor_with_vendor_user,
):
"""Test confirming an order."""
from models.database.letzshop import LetzshopOrder
# Create test order
order = LetzshopOrder(
vendor_id=test_vendor_with_vendor_user.id,
letzshop_order_id="order_confirm",
letzshop_shipment_id="shipment_1",
letzshop_state="unconfirmed",
sync_status="pending",
inventory_units=[{"id": "unit_1", "state": "unconfirmed"}],
)
db.add(order)
db.commit()
# Save credentials
client.post(
"/api/v1/vendor/letzshop/credentials",
headers=vendor_user_headers,
json={"api_key": "test-key"},
)
# Mock confirm response
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
# Confirm order
response = client.post(
f"/api/v1/vendor/letzshop/orders/{order.id}/confirm",
headers=vendor_user_headers,
)
assert response.status_code == 200
data = response.json()
assert data["success"] is True
@patch("app.services.letzshop.client.requests.Session.post")
def test_set_tracking(
self,
mock_post,
client,
db,
vendor_user_headers,
test_vendor_with_vendor_user,
):
"""Test setting tracking information."""
from models.database.letzshop import LetzshopOrder
order = LetzshopOrder(
vendor_id=test_vendor_with_vendor_user.id,
letzshop_order_id="order_tracking",
letzshop_shipment_id="shipment_track",
letzshop_state="confirmed",
sync_status="confirmed",
)
db.add(order)
db.commit()
# Save credentials
client.post(
"/api/v1/vendor/letzshop/credentials",
headers=vendor_user_headers,
json={"api_key": "test-key"},
)
# Mock tracking response
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {
"data": {
"setShipmentTracking": {
"shipment": {
"id": "shipment_track",
"tracking": {"code": "1Z999AA1", "provider": "ups"},
},
"errors": [],
}
}
}
mock_post.return_value = mock_response
# Set tracking
response = client.post(
f"/api/v1/vendor/letzshop/orders/{order.id}/tracking",
headers=vendor_user_headers,
json={
"tracking_number": "1Z999AA1",
"tracking_carrier": "ups",
},
)
assert response.status_code == 200
data = response.json()
assert data["success"] is True
assert data["tracking_number"] == "1Z999AA1"

View File

@@ -0,0 +1,460 @@
# tests/unit/services/test_letzshop_service.py
"""
Unit tests for Letzshop integration services.
Tests cover:
- Encryption utility
- Credentials service
- GraphQL client (mocked)
"""
import pytest
from unittest.mock import MagicMock, patch
from app.services.letzshop import (
LetzshopClient,
LetzshopClientError,
LetzshopAuthError,
LetzshopAPIError,
LetzshopCredentialsService,
CredentialsNotFoundError,
)
from app.utils.encryption import (
EncryptionService,
EncryptionError,
encrypt_value,
decrypt_value,
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)