Files
replypier/backend/tests/tools/test_shopify_toolkit.py
T
WangShen007 f4d8019829 test: 补充知识处理、检索与 Agent 回归用例
覆盖切块、向量检索、工具调用和历史预算;模型相关用例使用桩。
2026-10-04 20:55:00 +08:00

594 lines
24 KiB
Python

"""
Copyright 2024-2026 ChatterMate
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
import json
from unittest.mock import patch, MagicMock
from app.tools.shopify_toolkit import ShopifyTools
from app.tools.shopify_order_access import ORDER_NOT_FOUND
from app.models.shopify import AgentShopifyConfig, ShopifyShop
from app.models.organization import Organization
from app.models.agent import Agent
from uuid import UUID, uuid4
import requests
@pytest.fixture
def mock_db():
db = MagicMock()
return db
@pytest.fixture
def mock_agent():
agent = MagicMock()
agent.id = UUID("00000000-0000-0000-0000-000000000001")
agent.organization_id = UUID("00000000-0000-0000-0000-000000000002")
return agent
@pytest.fixture
def mock_org():
org = MagicMock()
org.id = UUID("00000000-0000-0000-0000-000000000002")
return org
@pytest.fixture
def mock_shopify_config():
shopify_config = MagicMock()
shopify_config.enabled = True
shopify_config.shop_id = UUID("00000000-0000-0000-0000-000000000003")
return shopify_config
@pytest.fixture
def mock_shop():
shop = MagicMock()
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 mock_shopify_service():
service = MagicMock()
service.get_products.return_value = {
"success": True,
"products": [
{
"id": "123456789",
"title": "Test Product",
"description": "This is a test product",
"price": "19.99",
"currency": "USD",
"vendor": "Test Vendor",
"product_type": "Test Type",
"total_inventory": 10,
"tags": ["tag1", "tag2"],
"image": {"src": "https://example.com/image.jpg"}
}
],
"count": 1,
"has_next_page": False,
"end_cursor": None
}
service.get_product.return_value = {
"success": True,
"product": {
"id": "123456789",
"title": "Test Product",
"description": "This is a test product",
"price": "19.99",
"currency": "USD",
"vendor": "Test Vendor",
"product_type": "Test Type",
"total_inventory": 10,
"tags": ["tag1", "tag2"],
"image": {"src": "https://example.com/image.jpg"}
}
}
service.get_order.return_value = {
"success": True,
"order": {
"id": "987654321",
"name": "#1001",
"financial_status": "paid",
"fulfillment_status": "fulfilled",
"created_at": "2023-01-01T00:00:00Z",
"processed_at": "2023-01-01T00:01:00Z",
"total_price": "29.99",
"currency": "USD",
"customer": {
"first_name": "John",
"last_name": "Doe",
"email": "john.doe@example.com"
},
"fulfillments": [
{
"tracking_number": "12345",
"tracking_company": "USPS",
"status": "SUCCESS"
}
]
}
}
return service
@pytest.fixture
def shopify_tools(mock_db, mock_agent, mock_org, mock_shopify_config, mock_shop, mock_shopify_service):
"""Create a ShopifyTools instance with all dependencies mocked"""
agent_id = "00000000-0000-0000-0000-000000000001"
org_id = "00000000-0000-0000-0000-000000000002"
session_id = "test_session_id"
with patch('app.tools.shopify_toolkit.SessionLocal') as mock_session_local, \
patch('app.tools.shopify_toolkit.ShopifyService') as mock_service_class, \
patch('app.tools.shopify_toolkit.ShopifyShopRepository') as mock_shop_repo_class, \
patch('app.tools.shopify_toolkit.AgentShopifyConfigRepository') as mock_config_repo_class, \
patch('app.tools.shopify_toolkit.get_redis') as mock_get_redis:
# Configure session to return our mock db
mock_session_local.return_value.__enter__.return_value = mock_db
# Configure mocks
mock_service_class.return_value = mock_shopify_service
# Mock Redis
mock_redis = MagicMock()
mock_redis.setex.return_value = True
mock_get_redis.return_value = mock_redis
# Create mock repository instances
mock_shop_repo = MagicMock()
mock_shop_repo_class.return_value = mock_shop_repo
mock_shop_repo.get_shop.return_value = mock_shop
mock_config_repo = MagicMock()
mock_config_repo_class.return_value = mock_config_repo
mock_config_repo.get_agent_shopify_config.return_value = mock_shopify_config
# Create tool instance
tool = ShopifyTools(agent_id=agent_id, org_id=org_id, session_id=session_id)
# Mock the _get_shop_for_agent method directly to avoid database operations
with patch.object(tool, '_get_shop_for_agent', return_value=mock_shop):
yield tool
def test_list_products(shopify_tools, mock_shopify_service):
# Act
result_str = shopify_tools.list_products(limit=10)
result = json.loads(result_str)
# Assert
assert result["success"] is True
assert "message" in result # Now includes text summary
assert "shopify_output" in result
assert "products" in result["shopify_output"]
assert len(result["shopify_output"]["products"]) == 1
assert result["shopify_output"]["products"][0]["title"] == "Test Product"
mock_shopify_service.get_products.assert_called_once()
def test_get_product(shopify_tools, mock_shopify_service):
# Act
result_str = shopify_tools.get_product(product_id="123456789")
result = json.loads(result_str)
# Assert
assert result["success"] is True
assert "message" in result # Now includes text summary
assert "shopify_product" in result
assert result["shopify_product"]["title"] == "Test Product"
# Verify message contains key product details
assert "Test Product" in result["message"]
assert "19.99" in result["message"]
assert "Test Vendor" in result["message"]
mock_shopify_service.get_product.assert_called_once()
def test_search_products(shopify_tools):
# Mock the requests.post response
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {
"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"
}
}
}
}
with patch("requests.post", return_value=mock_response):
# Act
result_str = shopify_tools.search_products(query="test", limit=5)
result = json.loads(result_str)
# Assert
assert result["success"] is True
assert "shopify_output" in result
assert "products" in result["shopify_output"]
assert len(result["shopify_output"]["products"]) == 1
assert result["shopify_output"]["products"][0]["title"] == "Test Product"
assert result["shopify_output"]["search_query"] == "test"
assert "pageInfo" in result["shopify_output"]
OWNER_EMAIL = "john.doe@example.com"
def _order_node(name="#1001", email=OWNER_EMAIL, customer=None):
"""One GraphQL order node as Shopify's order search returns it."""
return {
"id": "gid://shopify/Order/987654321",
"name": name,
"email": email,
"phone": None,
"createdAt": "2023-01-01T00:00:00Z",
"displayFinancialStatus": "PAID",
"displayFulfillmentStatus": "FULFILLED",
"subtotalLineItemsQuantity": 1,
"currentSubtotalPriceSet": {"shopMoney": {"amount": "24.99", "currencyCode": "USD"}},
"currentTotalPriceSet": {"shopMoney": {"amount": "29.99", "currencyCode": "USD"}},
"originalTotalPriceSet": {"shopMoney": {"amount": "29.99", "currencyCode": "USD"}},
"customer": customer,
"lineItems": {"edges": [{"node": {
"title": "Test Product",
"quantity": 1,
"originalUnitPriceSet": {"shopMoney": {"amount": "19.99", "currencyCode": "USD"}},
"variant": {"id": "variant-id", "title": "Default", "image": {"url": "https://example.com/image.jpg"}},
}}]},
"shippingAddress": {"address1": "123 Test St", "city": "Testville", "provinceCode": "CA",
"zip": "12345", "country": "US"},
"fulfillments": [{"trackingInfo": [{"company": "USPS", "number": "12345",
"url": "https://tracking.usps.com/12345"}],
"status": "SUCCESS"}],
}
def _orders_response(*nodes):
response = MagicMock()
response.status_code = 200
response.json.return_value = {"data": {"orders": {
"edges": [{"node": node} for node in nodes],
"pageInfo": {"hasNextPage": False, "endCursor": "cursor-value"},
}}}
return response
def _sent_search(mock_post):
return mock_post.call_args.kwargs["json"]["variables"]["query"]
def test_search_orders(shopify_tools):
with patch("requests.post", return_value=_orders_response(_order_node())) as mock_post:
result = json.loads(shopify_tools.search_orders(customer_email=" John.Doe@Example.com ", order_number="#1001"))
assert result["success"] is True
assert len(result["orders"]) == 1
assert result["orders"][0]["name"] == "#1001"
assert result["orders"][0]["financial_status"] == "PAID"
assert result["orders"][0]["tracking_info"][0]["number"] == "12345"
assert "page_info" in result
assert _sent_search(mock_post) == "email:john.doe@example.com AND name:1001"
assert mock_post.call_args.args[0] == "https://test-shop.myshopify.com/admin/api/2025-10/graphql.json"
def test_search_orders_honours_the_admin_base_override(shopify_tools, monkeypatch):
monkeypatch.setattr("app.services.shopify_urls.SHOPIFY_ADMIN_BASE", "http://127.0.0.1:14020/shopify")
with patch("requests.post", return_value=_orders_response(_order_node())) as mock_post:
shopify_tools.search_orders(customer_email="john.doe@example.com", order_number="#1001")
assert mock_post.call_args.args[0] == (
"http://127.0.0.1:14020/shopify/test-shop.myshopify.com/admin/api/2025-10/graphql.json"
)
def test_search_orders_matches_the_customer_record_email(shopify_tools):
node = _order_node(email="checkout@example.com", customer={"id": "c1", "firstName": "John",
"lastName": "Doe", "email": OWNER_EMAIL})
with patch("requests.post", return_value=_orders_response(node)):
result = json.loads(shopify_tools.search_orders(customer_email=OWNER_EMAIL))
assert result["success"] is True
assert "John Doe" in result["message"]
@pytest.mark.parametrize("email", [None, "", "not-an-email", "*@example.com",
"a@example.com OR name:1001", 'a"@example.com'])
def test_search_orders_needs_a_plain_email(shopify_tools, email):
with patch("requests.post") as mock_post:
result = json.loads(shopify_tools.search_orders(customer_email=email, order_number="1001"))
assert result["success"] is False
assert result["requires_user_input"] is True
mock_post.assert_not_called()
def test_search_orders_hides_other_customers_orders(shopify_tools):
# Shopify's search is fuzzy; anything not placed with this exact email is dropped.
stranger = _order_node(email="someone.else@example.com")
with patch("requests.post", return_value=_orders_response(stranger)):
result = json.loads(shopify_tools.search_orders(customer_email=OWNER_EMAIL, order_number="1001"))
assert result == {"success": False, "message": ORDER_NOT_FOUND}
def test_search_orders_needs_the_exact_order_number(shopify_tools):
with patch("requests.post", return_value=_orders_response(_order_node(name="#10010"))):
result = json.loads(shopify_tools.search_orders(customer_email=OWNER_EMAIL, order_number="1001"))
assert result == {"success": False, "message": ORDER_NOT_FOUND}
def test_search_orders_hides_shopify_error_details(shopify_tools):
response = MagicMock(status_code=500, text="internal detail")
with patch("requests.post", return_value=response):
result = json.loads(shopify_tools.search_orders(customer_email=OWNER_EMAIL))
assert result["success"] is False
assert "internal detail" not in result["message"]
def test_get_order_status(shopify_tools, mock_shopify_service):
with patch("requests.post", return_value=_orders_response(_order_node())) as mock_post:
result = json.loads(shopify_tools.get_order_status(order_number="#1001", customer_email=OWNER_EMAIL))
assert result["success"] is True
assert result["order_number"] == "#1001"
assert result["status"] == "paid"
assert result["fulfillment_status"] == "fulfilled"
assert result["tracking_numbers"][0] == "12345"
assert result["shop_domain"] == "test-shop.myshopify.com"
assert _sent_search(mock_post) == "email:john.doe@example.com AND name:1001"
mock_shopify_service.get_order.assert_called_once()
assert mock_shopify_service.get_order.call_args.args[1] == "987654321"
@pytest.mark.parametrize("order_number,email", [("1001", None), ("1001", ""), (None, OWNER_EMAIL),
("", OWNER_EMAIL), ("10 OR 1", OWNER_EMAIL)])
def test_get_order_status_needs_number_and_email(shopify_tools, mock_shopify_service, order_number, email):
with patch("requests.post") as mock_post:
result = json.loads(shopify_tools.get_order_status(order_number=order_number, customer_email=email))
assert result["success"] is False
assert result["requires_user_input"] is True
mock_post.assert_not_called()
mock_shopify_service.get_order.assert_not_called()
def test_get_order_status_wrong_email_looks_like_a_missing_order(shopify_tools, mock_shopify_service):
with patch("requests.post", return_value=_orders_response(_order_node())):
wrong_email = json.loads(shopify_tools.get_order_status(order_number="1001",
customer_email="someone.else@example.com"))
with patch("requests.post", return_value=_orders_response()):
missing = json.loads(shopify_tools.get_order_status(order_number="9999", customer_email=OWNER_EMAIL))
assert wrong_email == missing == {"success": False, "message": ORDER_NOT_FOUND}
mock_shopify_service.get_order.assert_not_called()
def test_get_order_status_rechecks_the_full_record(shopify_tools, mock_shopify_service):
mock_shopify_service.get_order.return_value["order"]["customer"]["email"] = "someone.else@example.com"
with patch("requests.post", return_value=_orders_response(_order_node())):
result = json.loads(shopify_tools.get_order_status(order_number="1001", customer_email=OWNER_EMAIL))
assert result == {"success": False, "message": ORDER_NOT_FOUND}
def test_get_order_status_guest_checkout(shopify_tools, mock_shopify_service):
order = mock_shopify_service.get_order.return_value["order"]
order["customer"] = None
order["email"] = OWNER_EMAIL
with patch("requests.post", return_value=_orders_response(_order_node())):
result = json.loads(shopify_tools.get_order_status(order_number="1001", customer_email=OWNER_EMAIL))
assert result["success"] is True
assert result["customer"] == {}
def test_recommend_products(shopify_tools):
# Mock the first requests.post response (product details)
mock_product_response = MagicMock()
mock_product_response.status_code = 200
mock_product_response.json.return_value = {
"data": {
"product": {
"id": "gid://shopify/Product/123456789",
"title": "Test Product",
"productType": "Test Type",
"tags": ["tag1", "tag2"]
}
}
}
# Mock the second requests.post response (recommendations)
mock_recommendations_response = MagicMock()
mock_recommendations_response.status_code = 200
mock_recommendations_response.json.return_value = {
"data": {
"products": {
"edges": [
{
"node": {
"id": "gid://shopify/Product/987654321",
"title": "Recommended Product",
"description": "This is a recommended product",
"handle": "recommended-product",
"productType": "Test Type",
"vendor": "Test Vendor",
"totalInventory": 5,
"priceRangeV2": {
"minVariantPrice": {
"amount": "24.99",
"currencyCode": "USD"
},
"maxVariantPrice": {
"amount": "24.99",
"currencyCode": "USD"
}
},
"images": {
"edges": [
{
"node": {
"id": "image-id",
"url": "https://example.com/image2.jpg",
"altText": "Recommended Image"
}
}
]
},
"tags": ["tag1", "tag3"],
"createdAt": "2023-01-01T00:00:00Z",
"updatedAt": "2023-01-02T00:00:00Z"
}
}
],
"pageInfo": {
"hasNextPage": False,
"endCursor": "cursor-value"
}
}
}
}
with patch("requests.post", side_effect=[mock_product_response, mock_recommendations_response]):
# Act
result_str = shopify_tools.recommend_products(product_id="123456789", limit=3)
result = json.loads(result_str)
# Assert
assert result["success"] is True
assert "shopify_output" in result
assert "products" in result["shopify_output"]
assert len(result["shopify_output"]["products"]) == 1
assert result["shopify_output"]["products"][0]["title"] == "Recommended Product"
assert result["shopify_output"]["search_type"] == "recommendations"
assert "pageInfo" in result["shopify_output"]
def test_integration_disabled():
"""Test when Shopify integration is disabled for the agent"""
agent_id = "00000000-0000-0000-0000-000000000001"
org_id = "00000000-0000-0000-0000-000000000002"
session_id = "test_session_id"
# Create disabled config
mock_config = MagicMock()
mock_config.enabled = False
with patch('app.tools.shopify_toolkit.SessionLocal'), \
patch('app.tools.shopify_toolkit.ShopifyService'), \
patch('app.tools.shopify_toolkit.ShopifyShopRepository'), \
patch('app.tools.shopify_toolkit.AgentShopifyConfigRepository') as mock_config_repo_class:
# Configure config repo to return disabled config
mock_config_repo = MagicMock()
mock_config_repo_class.return_value = mock_config_repo
mock_config_repo.get_agent_shopify_config.return_value = mock_config
# Create tool instance
tool = ShopifyTools(agent_id=agent_id, org_id=org_id, session_id=session_id)
# Act
result_str = tool.list_products()
result = json.loads(result_str)
# Assert
assert result["success"] is False
assert "not enabled" in result["message"].lower()
def test_shop_not_found():
"""Test when shop is not found for the agent"""
agent_id = "00000000-0000-0000-0000-000000000001"
org_id = "00000000-0000-0000-0000-000000000002"
session_id = "test_session_id"
# Create config with a shop ID
mock_config = MagicMock()
mock_config.enabled = True
mock_config.shop_id = UUID("00000000-0000-0000-0000-000000000003")
with patch('app.tools.shopify_toolkit.SessionLocal'), \
patch('app.tools.shopify_toolkit.ShopifyService'), \
patch('app.tools.shopify_toolkit.ShopifyShopRepository') as mock_shop_repo_class, \
patch('app.tools.shopify_toolkit.AgentShopifyConfigRepository') as mock_config_repo_class:
# Configure repos
mock_config_repo = MagicMock()
mock_config_repo_class.return_value = mock_config_repo
mock_config_repo.get_agent_shopify_config.return_value = mock_config
mock_shop_repo = MagicMock()
mock_shop_repo_class.return_value = mock_shop_repo
mock_shop_repo.get_shop.return_value = None # Shop not found
# Create tool instance
tool = ShopifyTools(agent_id=agent_id, org_id=org_id, session_id=session_id)
# Act
result_str = tool.list_products()
result = json.loads(result_str)
# Assert
assert result["success"] is False
assert "not found" in result["message"].lower() or "not enabled" in result["message"].lower()
def test_api_error(shopify_tools, mock_shopify_service):
"""Test when an API error occurs"""
# Set up service method to raise an exception
mock_shopify_service.get_products.side_effect = Exception("API Error")
# Act
result_str = shopify_tools.list_products()
result = json.loads(result_str)
# Assert
assert result["success"] is False
assert "error" in result["message"].lower()