Files
replypier/backend/tests/services/test_shopify_service.py
T

704 lines
25 KiB
Python
Raw Normal View History

"""
Copyright 2024-2026 ReplyPier
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
"""
import pytest
from unittest.mock import patch, MagicMock
from app.services.shopify import ShopifyService
from app.models.shopify import ShopifyShop
from sqlalchemy.orm import Session
from uuid import UUID
@pytest.fixture
def mock_db():
"""Create a mock database session"""
db = MagicMock(spec=Session)
return db
@pytest.fixture
def mock_shop():
"""Create a mock Shopify shop"""
shop = MagicMock(spec=ShopifyShop)
shop.id = UUID("00000000-0000-0000-0000-000000000003")
shop.organization_id = UUID("00000000-0000-0000-0000-000000000002")
shop.shop_domain = "test-shop.myshopify.com"
shop.access_token = "test_access_token"
shop.is_installed = True
return shop
@pytest.fixture
def shopify_service(mock_db):
"""Create a ShopifyService instance with mocked dependencies"""
with patch('app.services.shopify.ShopifyShopRepository') as mock_shop_repo_class, \
patch('app.services.shopify.AgentShopifyConfigRepository') as mock_config_repo_class:
# Create and configure mock repositories
mock_shop_repo = MagicMock()
mock_shop_repo_class.return_value = mock_shop_repo
mock_config_repo = MagicMock()
mock_config_repo_class.return_value = mock_config_repo
# Create service instance
service = ShopifyService(mock_db)
# Test access to mocks
service._mock_shop_repo = mock_shop_repo
service._mock_config_repo = mock_config_repo
return service
@pytest.fixture
def mock_graphql_success_response():
"""Create a mock successful GraphQL response"""
return {
"data": {
"products": {
"edges": [
{
"node": {
"id": "gid://shopify/Product/123456789",
"title": "Test Product",
"description": "This is a test product",
"handle": "test-product",
"productType": "Test Type",
"vendor": "Test Vendor",
"totalInventory": 10,
"priceRangeV2": {
"minVariantPrice": {
"amount": "19.99",
"currencyCode": "USD"
},
"maxVariantPrice": {
"amount": "29.99",
"currencyCode": "USD"
}
},
"images": {
"edges": [
{
"node": {
"id": "image-id",
"url": "https://example.com/image.jpg",
"altText": "Test Image"
}
}
]
},
"tags": ["tag1", "tag2"],
"createdAt": "2023-01-01T00:00:00Z",
"updatedAt": "2023-01-02T00:00:00Z"
}
}
],
"pageInfo": {
"hasNextPage": False,
"endCursor": "cursor-value"
}
}
}
}
@pytest.fixture
def mock_rest_success_response():
"""Create a mock successful REST API response"""
return {
"product": {
"id": 123456789,
"title": "Test Product",
"description": "This is a test product",
"handle": "test-product",
"product_type": "Test Type",
"vendor": "Test Vendor",
"price": "19.99",
"image": {"src": "https://example.com/image.jpg"},
"tags": "tag1, tag2",
"created_at": "2023-01-01T00:00:00Z",
"updated_at": "2023-01-02T00:00:00Z"
}
}
def test_execute_graphql_success(shopify_service, mock_shop, mock_graphql_success_response):
"""Test successful GraphQL query execution"""
# Arrange
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = mock_graphql_success_response
with patch('requests.post', return_value=mock_response):
# Act
result = shopify_service._execute_graphql(
shop=mock_shop,
query="query { products { edges { node { id title } } } }",
variables={"limit": 10}
)
# Assert
assert result["success"] is True
assert "data" in result
assert "products" in result["data"]
def test_execute_graphql_shop_not_connected(shopify_service):
"""Test GraphQL query with unconnected shop"""
# Arrange
mock_shop = MagicMock(spec=ShopifyShop)
mock_shop.is_installed = False
# Act
result = shopify_service._execute_graphql(
shop=mock_shop,
query="query { products { edges { node { id title } } } }"
)
# Assert
assert result["success"] is False
assert "not connected" in result["message"].lower()
def test_execute_graphql_api_error(shopify_service, mock_shop):
"""Test GraphQL query with API error"""
# Arrange
mock_response = MagicMock()
mock_response.status_code = 400
mock_response.text = "Bad Request"
with patch('requests.post', return_value=mock_response):
# Act
result = shopify_service._execute_graphql(
shop=mock_shop,
query="query { products { edges { node { id title } } } }"
)
# Assert
assert result["success"] is False
assert "Failed to execute GraphQL query" in result["message"]
def test_execute_graphql_with_errors(shopify_service, mock_shop):
"""Test GraphQL query that returns errors"""
# Arrange
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {
"errors": [
{"message": "Field 'product' doesn't exist"}
]
}
with patch('requests.post', return_value=mock_response):
# Act
result = shopify_service._execute_graphql(
shop=mock_shop,
query="query { product { id } }"
)
# Assert
assert result["success"] is False
assert "GraphQL errors" in result["message"] or "Field 'product' doesn't exist" in result["message"]
def test_get_products_success(shopify_service, mock_shop, mock_graphql_success_response):
"""Test successful product retrieval"""
# Arrange
with patch.object(shopify_service, '_execute_graphql', return_value={
"success": True,
"data": mock_graphql_success_response["data"]
}):
# Act
result = shopify_service.get_products(mock_shop, 10)
# Assert
assert result["success"] is True
assert "products" in result
assert len(result["products"]) == 1
assert result["products"][0]["title"] == "Test Product"
def test_get_products_failure(shopify_service, mock_shop):
"""Test product retrieval failure"""
# Arrange
error_response = {
"success": False,
"message": "API Error"
}
with patch.object(shopify_service, '_execute_graphql', return_value=error_response):
# Act
result = shopify_service.get_products(mock_shop)
# Assert
assert result["success"] is False
assert result["message"] == "API Error"
def test_get_product_success(shopify_service, mock_shop):
"""Test successful single product retrieval"""
# Arrange
product_data = {
"product": {
"id": "gid://shopify/Product/123456789",
"title": "Test Product",
"description": "Product description",
"descriptionHtml": "<p>Product description</p>",
"handle": "test-product",
"productType": "Test Type",
"vendor": "Test Vendor",
"totalInventory": 10,
"priceRangeV2": {
"minVariantPrice": {
"amount": "19.99",
"currencyCode": "USD"
},
"maxVariantPrice": {
"amount": "19.99",
"currencyCode": "USD"
}
},
"images": {
"edges": [
{
"node": {
"id": "gid://shopify/ProductImage/111",
"url": "https://example.com/image.jpg",
"altText": "Image",
"width": 800,
"height": 600
}
}
]
},
"variants": {
"edges": [
{
"node": {
"id": "gid://shopify/ProductVariant/222",
"title": "Default",
"price": "19.99",
"compareAtPrice": None,
"inventoryQuantity": 10,
"sku": "SKU123",
"barcode": "BARCODE123"
}
}
]
},
"tags": ["tag1", "tag2"],
"collections": {
"edges": [
{
"node": {
"id": "gid://shopify/Collection/333",
"title": "Featured"
}
}
]
},
"createdAt": "2023-01-01T00:00:00Z",
"updatedAt": "2023-01-02T00:00:00Z"
}
}
with patch.object(shopify_service, '_execute_graphql', return_value={
"success": True,
"data": product_data
}):
# Act
result = shopify_service.get_product(mock_shop, "123456789")
# Assert
assert result["success"] is True
assert "product" in result
assert result["product"]["title"] == "Test Product"
assert "images" in result["product"]
assert "variants" in result["product"]
assert "collections" in result["product"]
def test_get_product_not_found(shopify_service, mock_shop):
"""Test product retrieval when product doesn't exist"""
# Arrange
with patch.object(shopify_service, '_execute_graphql', return_value={
"success": True,
"data": {}
}):
# Act
result = shopify_service.get_product(mock_shop, "999999")
# Assert
assert result["success"] is False
assert "not found" in result["message"].lower()
def test_create_product_success(shopify_service, mock_shop, mock_rest_success_response):
"""Test successful product creation"""
# Arrange
product_data = {
"title": "New Product",
"body_html": "Product description",
"vendor": "Test Vendor",
"product_type": "Test Type",
"tags": ["tag1", "tag2"]
}
mock_response = MagicMock()
mock_response.status_code = 201
mock_response.json.return_value = mock_rest_success_response
with patch('requests.post', return_value=mock_response):
# Act
result = shopify_service.create_product(mock_shop, product_data)
# Assert
assert result["success"] is True
assert "product" in result
assert "created successfully" in result["message"].lower()
def test_create_product_failure(shopify_service, mock_shop):
"""Test product creation failure"""
# Arrange
product_data = {"title": "New Product"}
mock_response = MagicMock()
mock_response.status_code = 422
mock_response.text = "Validation error"
with patch('requests.post', return_value=mock_response):
# Act
result = shopify_service.create_product(mock_shop, product_data)
# Assert
assert result["success"] is False
assert "Failed to create product" in result["message"]
def test_update_product_success(shopify_service, mock_shop, mock_rest_success_response):
"""Test successful product update"""
# Arrange
product_id = "123456789"
product_data = {"title": "Updated Product"}
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = mock_rest_success_response
with patch('requests.put', return_value=mock_response):
# Act
result = shopify_service.update_product(mock_shop, product_id, product_data)
# Assert
assert result["success"] is True
assert "product" in result
assert "updated successfully" in result["message"].lower()
def test_delete_product_success(shopify_service, mock_shop):
"""Test successful product deletion"""
# Arrange
product_id = "123456789"
mock_response = MagicMock()
mock_response.status_code = 200
with patch('requests.delete', return_value=mock_response):
# Act
result = shopify_service.delete_product(mock_shop, product_id)
# Assert
assert result["success"] is True
assert "deleted successfully" in result["message"].lower()
def test_search_products_success(shopify_service, mock_shop, mock_graphql_success_response):
"""Test successful product search"""
# Arrange
with patch.object(shopify_service, '_execute_graphql', return_value={
"success": True,
"data": mock_graphql_success_response["data"]
}):
# Act
result = shopify_service.search_products(mock_shop, "test", 10)
# Assert
assert result["success"] is True
assert "products" in result
assert len(result["products"]) == 1
assert result["products"][0]["title"] == "Test Product"
def test_search_orders_success(shopify_service, mock_shop):
"""Test successful order search"""
# Arrange
mock_order_response = {
"data": {
"orders": {
"edges": [
{
"node": {
"id": "gid://shopify/Order/987654321",
"name": "#1001",
"email": "customer@example.com",
"createdAt": "2023-01-01T00:00:00Z",
"displayFinancialStatus": "PAID",
"displayFulfillmentStatus": "FULFILLED",
"subtotalLineItemsQuantity": 2,
"currentSubtotalPriceSet": {
"shopMoney": {
"amount": "24.99",
"currencyCode": "USD"
}
},
"currentTotalPriceSet": {
"shopMoney": {
"amount": "29.99",
"currencyCode": "USD"
}
},
"originalTotalPriceSet": {
"shopMoney": {
"amount": "29.99",
"currencyCode": "USD"
}
},
"customer": {
"id": "customer-id",
"firstName": "John",
"lastName": "Doe",
"email": "john.doe@example.com"
},
"lineItems": {"edges": []},
"shippingAddress": {},
"fulfillments": []
}
}
],
"pageInfo": {
"hasNextPage": False,
"endCursor": "cursor-value"
}
}
}
}
with patch.object(shopify_service, '_execute_graphql', return_value={
"success": True,
"data": mock_order_response["data"]
}):
# Act
result = shopify_service.search_orders(mock_shop, {"query": "test"}, 10)
# Assert
assert result["success"] is True
assert "orders" in result
assert len(result["orders"]) == 1
assert result["orders"][0]["name"] == "#1001"
def test_get_order_success(shopify_service, mock_shop):
"""Test successful order retrieval"""
# Arrange
mock_order_data = {
"order": {
"id": "gid://shopify/Order/987654321",
"name": "#1001",
"email": "customer@example.com",
"processedAt": "2023-01-01T00:01:00Z",
"createdAt": "2023-01-01T00:00:00Z",
"updatedAt": "2023-01-01T00:02:00Z",
"cancelledAt": None,
"cancelReason": None,
"displayFinancialStatus": "PAID",
"displayFulfillmentStatus": "FULFILLED",
"customerJourneySummary": {
"customerOrderIndex": 1,
"daysToConversion": 2
},
"customer": {
"id": "gid://shopify/Customer/123",
"firstName": "John",
"lastName": "Doe",
"email": "john.doe@example.com",
"phone": "+1234567890"
},
"currentTotalPrice": {
"amount": "29.99",
"currencyCode": "USD"
},
"originalTotalPrice": {
"amount": "29.99",
"currencyCode": "USD"
},
"shippingAddress": {
"address1": "123 Test St",
"address2": "",
"city": "Test City",
"country": "US",
"zip": "12345",
"phone": "+1234567890",
"name": "John Doe"
},
"fulfillments": [
{
"status": "SUCCESS",
"trackingInfo": [
{
"number": "12345",
"url": "https://tracking.example.com/12345"
}
]
}
],
"lineItems": {
"edges": [
{
"node": {
"id": "gid://shopify/LineItem/111",
"name": "Test Product",
"quantity": 1,
"originalTotalPrice": {
"amount": "19.99",
"currencyCode": "USD"
},
"variant": {
"id": "gid://shopify/ProductVariant/222",
"title": "Default",
"sku": "SKU123"
}
}
}
]
}
}
}
with patch.object(shopify_service, '_execute_graphql', return_value={
"success": True,
"data": mock_order_data
}):
# Act
result = shopify_service.get_order(mock_shop, "987654321")
# Assert
assert result["success"] is True
assert "order" in result
assert result["order"]["name"] == "#1001"
assert "line_items" in result["order"]
assert "fulfillments" in result["order"]
assert "customer" in result["order"]
assert "shipping_address" in result["order"]
def test_make_rest_request_success(shopify_service, mock_shop):
"""Test successful REST API request"""
# Arrange
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {"products": [{"id": 123, "title": "Test Product"}]}
with patch('requests.request', return_value=mock_response):
# Act
result = shopify_service._make_rest_request(
shop=mock_shop,
method="GET",
endpoint="products.json",
payload=None
)
# Assert
assert "success" in result and result["success"] is True
assert "products" in result["data"]
def test_make_rest_request_rate_limit(shopify_service, mock_shop):
"""Test REST API request with rate limit response"""
# Arrange
mock_response = MagicMock()
mock_response.status_code = 429
mock_response.headers = {"Retry-After": "5"}
with patch('requests.request', return_value=mock_response):
# Act
result = shopify_service._make_rest_request(
shop=mock_shop,
method="GET",
endpoint="products.json"
)
# Assert
assert result["success"] is False
assert "Rate limit exceeded" in result["message"]
assert result["status_code"] == 429
@pytest.mark.asyncio
async def test_execute_graphql_async_success(shopify_service, mock_shop, mock_graphql_success_response):
"""Test successful async GraphQL query execution"""
# Arrange
mock_response = MagicMock()
mock_response.status = 200
mock_response.json = MagicMock(return_value=mock_graphql_success_response)
mock_session = MagicMock()
mock_session.__aenter__.return_value = mock_session
mock_session.post.return_value.__aenter__.return_value = mock_response
mock_client_session = MagicMock()
mock_client_session.__aenter__.return_value = mock_session
with patch('aiohttp.ClientSession', return_value=mock_client_session):
# Act
with patch.object(shopify_service, '_execute_graphql_async', return_value={
"success": True,
"data": mock_graphql_success_response["data"]
}):
result = await shopify_service._execute_graphql_async(
shop=mock_shop,
query="query { products { edges { node { id title } } } }",
variables={"limit": 10}
)
# Assert
assert result["success"] is True
@pytest.fixture
def reload_shopify_urls(monkeypatch):
"""Re-import shopify_urls under a given SHOPIFY_ADMIN_BASE, then restore it."""
import importlib
import app.services.shopify_urls as shopify_urls
def _reload(admin_base=None):
monkeypatch.delenv("SHOPIFY_ADMIN_BASE", raising=False)
if admin_base is not None:
monkeypatch.setenv("SHOPIFY_ADMIN_BASE", admin_base)
return importlib.reload(shopify_urls)
yield _reload
monkeypatch.delenv("SHOPIFY_ADMIN_BASE", raising=False)
importlib.reload(shopify_urls)
def test_shopify_urls_default_to_the_shop_host(reload_shopify_urls):
urls = reload_shopify_urls()
assert urls.shopify_admin_url("test-shop.myshopify.com", "graphql.json") == (
"https://test-shop.myshopify.com/admin/api/2025-10/graphql.json"
)
assert urls.shopify_oauth_token_url("test-shop.myshopify.com") == (
"https://test-shop.myshopify.com/admin/oauth/access_token"
)
def test_shopify_admin_base_puts_the_shop_under_the_override(reload_shopify_urls):
urls = reload_shopify_urls("http://127.0.0.1:14020/shopify/")
assert urls.shopify_admin_url("test-shop.myshopify.com", "products/1.json") == (
"http://127.0.0.1:14020/shopify/test-shop.myshopify.com/admin/api/2025-10/products/1.json"
)
assert urls.shopify_oauth_token_url("test-shop.myshopify.com") == (
"http://127.0.0.1:14020/shopify/test-shop.myshopify.com/admin/oauth/access_token"
)
def test_execute_graphql_posts_to_the_admin_url(mock_db, mock_shop, monkeypatch):
monkeypatch.setattr("app.services.shopify_urls.SHOPIFY_ADMIN_BASE", "http://fake/shopify")
response = MagicMock(status_code=200)
response.json.return_value = {"data": {}}
with patch("requests.post", return_value=response) as mock_post:
ShopifyService(mock_db)._execute_graphql(mock_shop, "{ shop { name } }")
assert mock_post.call_args.args[0] == (
"http://fake/shopify/test-shop.myshopify.com/admin/api/2025-10/graphql.json"
)