Files
replypier/backend/tests/api/test_shopify_api.py
T
WangShen007 164f5f9615 test: 补充权限、消息与会话服务回归用例
会话读取、接管、重分配与 Socket 订阅统一校验角色、组织与可见性。
2026-10-08 22:35:00 +08:00

1070 lines
37 KiB
Python

"""
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 MagicMock, patch, ANY, AsyncMock
from fastapi import HTTPException
from fastapi.testclient import TestClient
from sqlalchemy.orm import Session
import uuid
import json
import hmac
import hashlib
import base64
from urllib.parse import urlencode, quote
from datetime import datetime, timezone
from app.models.schemas.shopify.agent_shopify_config import AgentShopifyConfig
from app.models.shopify.agent_shopify_config import AgentShopifyConfig
from app.main import app
from app.api.shopify import router
from app.services.shopify_helper_service import ShopifyHelperService
from app.repositories.shopify_shop_repository import ShopifyShopRepository
from app.repositories.agent_shopify_config_repository import AgentShopifyConfigRepository
from app.models.shopify import ShopifyShop
from app.models.schemas.shopify import ShopifyShopCreate, ShopifyShopUpdate
from app.models.schemas.shopify import AgentShopifyConfigBase, AgentShopifyConfigCreate, AgentShopifyConfigUpdate
from app.services.shopify import ShopifyService
@pytest.fixture
def mock_db():
"""Create a mock database session"""
db = MagicMock(spec=Session)
return db
@pytest.fixture
def sample_shop_data():
"""Sample shop data for testing"""
return {
"id": str(uuid.uuid4()),
"shop_domain": "test-shop.myshopify.com",
"access_token": "test_access_token",
"scope": "read_products,write_products",
"is_installed": True,
"organization_id": str(uuid.uuid4()),
"created_at": datetime.now(timezone.utc),
"updated_at": datetime.now(timezone.utc)
}
@pytest.fixture
def mock_shop(sample_shop_data):
"""Create a mock ShopifyShop instance"""
shop = MagicMock(spec=ShopifyShop)
for key, value in sample_shop_data.items():
setattr(shop, key, value)
return shop
@pytest.fixture
def mock_organization():
"""Create a mock Organization instance"""
org = MagicMock()
org.id = uuid.uuid4()
return org
@pytest.fixture
def mock_user():
"""Create a mock User instance"""
user = MagicMock()
user.id = uuid.uuid4()
return user
@pytest.fixture
def api_client():
"""Create a TestClient for the FastAPI app"""
return TestClient(app)
def test_verify_shopify_webhook():
"""Test the verify_shopify_webhook function"""
# Arrange
request_body = b'{"test":"data"}'
secret = "test_secret"
with patch('app.services.shopify_helper_service.settings') as mock_settings:
mock_settings.SHOPIFY_API_SECRET = secret
# Create a valid HMAC
digest = hmac.new(
secret.encode('utf-8'),
request_body,
hashlib.sha256
).digest()
valid_hmac = base64.b64encode(digest).decode('utf-8')
# Act & Assert - Valid HMAC
headers = {"X-Shopify-Hmac-Sha256": valid_hmac}
assert ShopifyHelperService.verify_shopify_webhook(headers, request_body) is True
# Act & Assert - Invalid HMAC
headers = {"X-Shopify-Hmac-Sha256": "invalid_hmac"}
assert ShopifyHelperService.verify_shopify_webhook(headers, request_body) is False
# Act & Assert - Missing HMAC
headers = {}
assert ShopifyHelperService.verify_shopify_webhook(headers, request_body) is False
def test_validate_shop_request():
"""Test the validate_shop_request function"""
# Arrange
shop = "test-shop.myshopify.com"
secret = "test_secret"
# Create a valid request with HMAC
query_params = {
"shop": shop,
"timestamp": "1234567890",
"code": "test_code"
}
# Sort and encode parameters for HMAC calculation
sorted_params = "&".join([f"{key}={quote(value)}" for key, value in sorted(query_params.items())])
# Generate HMAC
digest = hmac.new(
secret.encode('utf-8'),
sorted_params.encode('utf-8'),
hashlib.sha256
).hexdigest()
# Create request object
mock_request = MagicMock()
# Add hmac to query params
query_params_with_hmac = query_params.copy()
query_params_with_hmac["hmac"] = digest
mock_request.query_params = query_params_with_hmac
with patch('app.services.shopify_helper_service.settings') as mock_settings:
mock_settings.SHOPIFY_API_SECRET = secret
# Act & Assert - Valid shop and HMAC
assert ShopifyHelperService.validate_shop_request(mock_request, shop, digest) is True
# Act & Assert - Invalid shop domain
assert ShopifyHelperService.validate_shop_request(mock_request, "not-shopify.com", digest) is False
# Act & Assert - Invalid HMAC
assert ShopifyHelperService.validate_shop_request(mock_request, shop, "invalid_hmac") is False
# Note: Tests for exchange_session_token and link_shop_to_org endpoints
# have been temporarily removed due to complex mocking requirements.
# These endpoints are tested through integration tests and manual testing.
@pytest.mark.asyncio
@patch('app.api.shopify.ShopifyShopRepository')
async def test_get_shops(mock_shop_repo, mock_db, mock_organization, mock_user):
"""Test the get_shops endpoint"""
# Arrange
mock_shop_repo_instance = MagicMock()
mock_shop_repo.return_value = mock_shop_repo_instance
# Create some mock shops
shop1 = MagicMock(spec=ShopifyShop)
shop1.id = str(uuid.uuid4())
shop1.shop_domain = "shop1.myshopify.com"
shop2 = MagicMock(spec=ShopifyShop)
shop2.id = str(uuid.uuid4())
shop2.shop_domain = "shop2.myshopify.com"
mock_shop_repo_instance.get_shops_by_organization.return_value = [shop1, shop2]
# Act
from app.api.shopify import get_shops
response = await get_shops(mock_db, 0, 100, mock_organization, mock_user)
# Assert
mock_shop_repo_instance.get_shops_by_organization.assert_called_once()
assert len(response) == 2
assert response[0] == shop1
assert response[1] == shop2
@pytest.mark.asyncio
@patch('app.api.shopify.ShopifyShopRepository')
async def test_get_shop(mock_shop_repo, mock_db, mock_organization, mock_user, sample_shop_data):
"""Test the get_shop endpoint"""
# Arrange
shop_id = sample_shop_data["id"]
org_id = str(mock_organization.id)
mock_shop_repo_instance = MagicMock()
mock_shop_repo.return_value = mock_shop_repo_instance
# Create a mock shop with the same organization ID
mock_shop = MagicMock(spec=ShopifyShop)
for key, value in sample_shop_data.items():
setattr(mock_shop, key, value)
mock_shop.organization_id = org_id
mock_shop_repo_instance.get_shop.return_value = mock_shop
# Act
from app.api.shopify import get_shop
response = await get_shop(shop_id, mock_db, mock_organization, mock_user)
# Assert
mock_shop_repo_instance.get_shop.assert_called_once_with(shop_id)
assert response == mock_shop
@pytest.mark.asyncio
@patch('app.api.shopify.ShopifyShopRepository')
async def test_get_shop_not_found(mock_shop_repo, mock_db, mock_organization, mock_user):
"""Test the get_shop endpoint when shop is not found"""
# Arrange
shop_id = str(uuid.uuid4())
mock_shop_repo_instance = MagicMock()
mock_shop_repo.return_value = mock_shop_repo_instance
mock_shop_repo_instance.get_shop.return_value = None # Shop not found
# Act & Assert
from app.api.shopify import get_shop
with pytest.raises(HTTPException) as exc_info:
await get_shop(shop_id, mock_db, mock_organization, mock_user)
assert exc_info.value.status_code == 404
assert "not found" in str(exc_info.value.detail).lower()
@pytest.mark.asyncio
@patch('app.api.shopify.ShopifyShopRepository')
async def test_get_shop_wrong_organization(mock_shop_repo, mock_db, mock_organization, mock_user, sample_shop_data):
"""Test the get_shop endpoint when shop belongs to a different organization"""
# Arrange
shop_id = sample_shop_data["id"]
mock_shop_repo_instance = MagicMock()
mock_shop_repo.return_value = mock_shop_repo_instance
# Create a mock shop with a different organization ID
mock_shop = MagicMock(spec=ShopifyShop)
for key, value in sample_shop_data.items():
setattr(mock_shop, key, value)
mock_shop.organization_id = str(uuid.uuid4()) # Different organization ID
mock_shop_repo_instance.get_shop.return_value = mock_shop
# Act & Assert
from app.api.shopify import get_shop
with pytest.raises(HTTPException) as exc_info:
await get_shop(shop_id, mock_db, mock_organization, mock_user)
assert exc_info.value.status_code == 403
assert "does not belong to your organization" in str(exc_info.value.detail).lower()
@pytest.mark.asyncio
@patch('app.api.shopify.AgentShopifyConfigRepository')
@patch('app.api.shopify.ShopifyShopRepository')
async def test_delete_shop(mock_shop_repo, mock_config_repo, mock_db, mock_organization, mock_user, sample_shop_data):
"""Test the delete_shop endpoint"""
# Arrange
shop_id = sample_shop_data["id"]
org_id = str(mock_organization.id)
mock_shop_repo_instance = MagicMock()
mock_shop_repo.return_value = mock_shop_repo_instance
mock_config_repo_instance = MagicMock()
mock_config_repo.return_value = mock_config_repo_instance
# Create a mock shop with the same organization ID
mock_shop = MagicMock(spec=ShopifyShop)
for key, value in sample_shop_data.items():
setattr(mock_shop, key, value)
mock_shop.organization_id = org_id
mock_shop_repo_instance.get_shop.return_value = mock_shop
mock_shop_repo_instance.delete_shop.return_value = True
# Mock some agent configs
agent_config1 = MagicMock()
agent_config1.agent_id = str(uuid.uuid4())
agent_config2 = MagicMock()
agent_config2.agent_id = str(uuid.uuid4())
mock_config_repo_instance.get_configs_by_shop.return_value = [agent_config1, agent_config2]
with patch('app.api.shopify.requests.post') as mock_post:
mock_post.return_value.status_code = 200
mock_post.return_value.text = "ok"
# Act
from app.api.shopify import delete_shop
response = await delete_shop(shop_id, mock_db, mock_organization, mock_user)
# Assert
mock_shop_repo_instance.get_shop.assert_called_once_with(shop_id)
mock_config_repo_instance.get_configs_by_shop.assert_called_once_with(shop_id)
# Should update each agent config to disable Shopify
assert mock_config_repo_instance.update_agent_shopify_config.call_count == 2
# Should delete the shop
mock_shop_repo_instance.delete_shop.assert_called_once_with(shop_id)
assert response["status"] == "success"
assert "disconnected" in response["message"].lower()
@pytest.mark.asyncio
@patch('app.api.shopify.ShopifyShopRepository')
async def test_check_connection_connected(mock_shop_repo, mock_db, mock_organization, mock_user):
"""Test the check_connection endpoint when connected"""
# Arrange
org_id = str(mock_organization.id)
mock_shop_repo_instance = MagicMock()
mock_shop_repo.return_value = mock_shop_repo_instance
# Create a mock shop that is installed
mock_shop = MagicMock(spec=ShopifyShop)
mock_shop.id = str(uuid.uuid4())
mock_shop.shop_domain = "test-shop.myshopify.com"
mock_shop.is_installed = True
mock_shop_repo_instance.get_shops_by_organization.return_value = [mock_shop]
# Act
from app.api.shopify import check_connection
response = await check_connection(mock_db, mock_organization, mock_user)
# Assert
mock_shop_repo_instance.get_shops_by_organization.assert_called_once()
assert response["connected"] is True
assert response["shop_domain"] == mock_shop.shop_domain
@pytest.mark.asyncio
@patch('app.api.shopify.ShopifyShopRepository')
async def test_check_connection_not_connected(mock_shop_repo, mock_db, mock_organization, mock_user):
"""Test the check_connection endpoint when not connected"""
# Arrange
org_id = str(mock_organization.id)
mock_shop_repo_instance = MagicMock()
mock_shop_repo.return_value = mock_shop_repo_instance
# No shops returned
mock_shop_repo_instance.get_shops_by_organization.return_value = []
# Act
from app.api.shopify import check_connection
response = await check_connection(mock_db, mock_organization, mock_user)
# Assert
mock_shop_repo_instance.get_shops_by_organization.assert_called_once()
assert response["connected"] is False
assert "shop_domain" not in response
@pytest.mark.asyncio
@patch('app.services.shopify_session.ShopifySessionService.get_session_token_from_request')
@patch('app.api.shopify.ShopifyShopRepository')
@patch('app.api.shopify.requests.post')
@patch('jwt.decode')
async def test_exchange_session_token_success(mock_jwt_decode, mock_requests_post, mock_shop_repo, mock_get_token, mock_db):
"""Test successful session token exchange"""
# Arrange
shop_domain = "test-shop.myshopify.com"
session_token = "mock_session_token"
access_token = "mock_access_token"
mock_get_token.return_value = session_token
mock_jwt_decode.return_value = {"dest": f"https://{shop_domain}"}
# Mock Shopify API response
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {"access_token": access_token}
mock_requests_post.return_value = mock_response
# Mock repository
mock_shop_repo_instance = MagicMock()
mock_shop_repo.return_value = mock_shop_repo_instance
mock_shop_repo_instance.get_shop_by_domain.return_value = None
mock_created_shop = MagicMock()
mock_created_shop.id = str(uuid.uuid4())
mock_created_shop.shop_domain = shop_domain
mock_created_shop.organization_id = None
mock_shop_repo_instance.create_shop.return_value = mock_created_shop
mock_request = MagicMock()
# Act
from app.api.shopify import exchange_session_token
response = await exchange_session_token(mock_request, mock_db)
# Assert
assert response["shop_id"] == str(mock_created_shop.id)
assert response["shop_domain"] == shop_domain
assert response["organization_id"] is None
assert response["is_installed"] is True
@pytest.mark.asyncio
@patch('app.services.shopify_session.ShopifySessionService.get_session_token_from_request')
async def test_exchange_session_token_no_token(mock_get_token, mock_db):
"""Test session token exchange with missing token"""
# Arrange
mock_get_token.return_value = None
mock_request = MagicMock()
# Act & Assert
from app.api.shopify import exchange_session_token
with pytest.raises(HTTPException) as exc_info:
await exchange_session_token(mock_request, mock_db)
assert exc_info.value.status_code == 401
assert "Session token required" in str(exc_info.value.detail)
@pytest.mark.asyncio
@patch('app.services.shopify_session.ShopifySessionService.get_session_token_from_request')
@patch('jwt.decode')
async def test_exchange_session_token_invalid_token(mock_jwt_decode, mock_get_token, mock_db):
"""Test session token exchange with invalid token format"""
# Arrange
mock_get_token.return_value = "invalid_token"
mock_jwt_decode.return_value = {} # Missing 'dest' field
mock_request = MagicMock()
# Act & Assert
from app.api.shopify import exchange_session_token
with pytest.raises(HTTPException) as exc_info:
await exchange_session_token(mock_request, mock_db)
assert exc_info.value.status_code == 400
assert "Invalid session token: bad shop domain" in str(exc_info.value.detail)
@pytest.mark.asyncio
@patch('app.services.shopify_session.ShopifySessionService.get_session_token_from_request')
@patch('app.api.shopify.requests.post')
@patch('jwt.decode')
async def test_exchange_session_token_rejects_non_shopify_dest(
mock_jwt_decode, mock_requests_post, mock_get_token, mock_db
):
"""A token whose `dest` points at a host the caller controls must be rejected
before we POST client_secret anywhere - otherwise the exchange leaks the
Shopify API secret to that host."""
mock_get_token.return_value = "forged_token"
mock_jwt_decode.return_value = {"dest": "https://attacker.example"}
mock_request = MagicMock()
from app.api.shopify import exchange_session_token
with pytest.raises(HTTPException) as exc_info:
await exchange_session_token(mock_request, mock_db)
assert exc_info.value.status_code == 400
assert "bad shop domain" in str(exc_info.value.detail)
mock_requests_post.assert_not_called()
@pytest.mark.asyncio
@patch('app.services.shopify_session.ShopifySessionService.get_session_token_from_request')
@patch('app.api.shopify.requests.post')
@patch('jwt.decode')
async def test_exchange_session_token_shopify_api_error(mock_jwt_decode, mock_requests_post, mock_get_token, mock_db):
"""Test session token exchange when Shopify API returns error"""
# Arrange
shop_domain = "test-shop.myshopify.com"
mock_get_token.return_value = "valid_token"
mock_jwt_decode.return_value = {"dest": f"https://{shop_domain}"}
# Mock Shopify API error response
mock_response = MagicMock()
mock_response.status_code = 400
mock_response.text = "Invalid request"
mock_requests_post.return_value = mock_response
mock_request = MagicMock()
# Act & Assert
from app.api.shopify import exchange_session_token
with pytest.raises(HTTPException) as exc_info:
await exchange_session_token(mock_request, mock_db)
assert exc_info.value.status_code == 400
@pytest.mark.asyncio
@patch('app.api.shopify.get_current_user')
@patch('app.api.shopify.check_permissions')
@patch('app.api.shopify.ShopifyShopRepository')
async def test_link_shop_to_org_success(mock_shop_repo, mock_check_permissions, mock_get_user, mock_db):
"""Test successful shop to organization linking"""
# Arrange
shop_id = str(uuid.uuid4())
org_id = str(uuid.uuid4())
mock_user = MagicMock()
mock_user.id = str(uuid.uuid4())
mock_user.organization_id = org_id
mock_get_user.return_value = mock_user
mock_check_permissions.return_value = True
mock_shop_repo_instance = MagicMock()
mock_shop_repo.return_value = mock_shop_repo_instance
mock_shop = MagicMock()
mock_shop.id = shop_id
# Unclaimed: installed but never linked to an organization yet.
mock_shop.organization_id = None
mock_shop_repo_instance.get_shop.return_value = mock_shop
mock_request = MagicMock()
mock_request.json = AsyncMock(return_value={"shop_id": shop_id})
# Act
from app.api.shopify import link_shop_to_org
response = await link_shop_to_org(mock_request, mock_db, mock_user)
# Assert
assert response["success"] is True
assert response["shop_id"] == shop_id
assert response["organization_id"] == org_id
@pytest.mark.asyncio
@patch('app.api.shopify.get_current_user')
@patch('app.api.shopify.check_permissions')
@patch('app.api.shopify.ShopifyShopRepository')
async def test_link_shop_already_owned_by_another_org(
mock_shop_repo, mock_check_permissions, mock_get_user, mock_db
):
"""A shop another organization already installed can't be re-pointed"""
shop_id = str(uuid.uuid4())
mock_user = MagicMock()
mock_user.id = str(uuid.uuid4())
mock_user.organization_id = str(uuid.uuid4())
mock_get_user.return_value = mock_user
mock_check_permissions.return_value = True
mock_shop_repo_instance = MagicMock()
mock_shop_repo.return_value = mock_shop_repo_instance
mock_shop = MagicMock()
mock_shop.id = shop_id
mock_shop.organization_id = str(uuid.uuid4()) # someone else's
mock_shop_repo_instance.get_shop.return_value = mock_shop
mock_request = MagicMock()
mock_request.json = AsyncMock(return_value={"shop_id": shop_id})
from app.api.shopify import link_shop_to_org
with pytest.raises(HTTPException) as exc_info:
await link_shop_to_org(mock_request, mock_db, mock_user)
assert exc_info.value.status_code == 404
assert not mock_shop_repo_instance.update_shop.called
@pytest.mark.asyncio
@patch('app.api.shopify.get_current_user')
@patch('app.api.shopify.check_permissions')
@patch('app.api.shopify.ShopifyShopRepository')
async def test_link_shop_already_owned_by_same_org_is_allowed(
mock_shop_repo, mock_check_permissions, mock_get_user, mock_db
):
"""Re-linking your own shop stays idempotent"""
shop_id = str(uuid.uuid4())
org_id = str(uuid.uuid4())
mock_user = MagicMock()
mock_user.id = str(uuid.uuid4())
mock_user.organization_id = org_id
mock_get_user.return_value = mock_user
mock_check_permissions.return_value = True
mock_shop_repo_instance = MagicMock()
mock_shop_repo.return_value = mock_shop_repo_instance
mock_shop = MagicMock()
mock_shop.id = shop_id
mock_shop.organization_id = org_id
mock_shop_repo_instance.get_shop.return_value = mock_shop
mock_request = MagicMock()
mock_request.json = AsyncMock(return_value={"shop_id": shop_id})
from app.api.shopify import link_shop_to_org
response = await link_shop_to_org(mock_request, mock_db, mock_user)
assert response["success"] is True
assert response["organization_id"] == org_id
@pytest.mark.asyncio
@patch('app.api.shopify.get_current_user')
async def test_link_shop_to_org_missing_shop_id(mock_get_user, mock_db):
"""Test shop linking with missing shop_id"""
# Arrange
mock_user = MagicMock()
mock_get_user.return_value = mock_user
mock_request = MagicMock()
mock_request.json = AsyncMock(return_value={}) # Missing shop_id
# Act & Assert
from app.api.shopify import link_shop_to_org
with pytest.raises(HTTPException) as exc_info:
await link_shop_to_org(mock_request, mock_db, mock_user)
assert exc_info.value.status_code == 400
assert "shop_id is required" in str(exc_info.value.detail)
@pytest.mark.asyncio
@patch('app.api.shopify.get_current_user')
@patch('app.api.shopify.check_permissions')
async def test_link_shop_to_org_insufficient_permissions(mock_check_permissions, mock_get_user, mock_db):
"""Test shop linking with insufficient permissions"""
# Arrange
mock_user = MagicMock()
mock_get_user.return_value = mock_user
mock_check_permissions.return_value = False # No permissions
mock_request = MagicMock()
mock_request.json = AsyncMock(return_value={"shop_id": str(uuid.uuid4())})
# Act & Assert
from app.api.shopify import link_shop_to_org
with pytest.raises(HTTPException) as exc_info:
await link_shop_to_org(mock_request, mock_db, mock_user)
assert exc_info.value.status_code == 403
assert "Insufficient permissions" in str(exc_info.value.detail)
@pytest.mark.asyncio
@patch('app.api.shopify.get_current_user')
@patch('app.api.shopify.check_permissions')
@patch('app.api.shopify.ShopifyShopRepository')
async def test_link_shop_to_org_shop_not_found(mock_shop_repo, mock_check_permissions, mock_get_user, mock_db):
"""Test shop linking when shop doesn't exist"""
# Arrange
shop_id = str(uuid.uuid4())
mock_user = MagicMock()
mock_user.organization_id = str(uuid.uuid4())
mock_get_user.return_value = mock_user
mock_check_permissions.return_value = True
mock_shop_repo_instance = MagicMock()
mock_shop_repo.return_value = mock_shop_repo_instance
mock_shop_repo_instance.get_shop.return_value = None # Shop not found
mock_request = MagicMock()
mock_request.json = AsyncMock(return_value={"shop_id": shop_id})
# Act & Assert
from app.api.shopify import link_shop_to_org
with pytest.raises(HTTPException) as exc_info:
await link_shop_to_org(mock_request, mock_db, mock_user)
assert exc_info.value.status_code == 404
assert "Shop not found" in str(exc_info.value.detail)
@pytest.mark.asyncio
@patch('app.api.shopify.ShopifyShopRepository')
async def test_get_status_connected(mock_shop_repo, mock_db, mock_organization, mock_user):
"""Test status endpoint when Shopify is connected"""
# Arrange
mock_shop_repo_instance = MagicMock()
mock_shop_repo.return_value = mock_shop_repo_instance
mock_shop = MagicMock()
mock_shop.shop_domain = "test-shop.myshopify.com"
mock_shop.is_installed = True
mock_shop_repo_instance.get_shops_by_organization.return_value = [mock_shop]
# Act
from app.api.shopify import check_connection
response = await check_connection(mock_db, mock_organization, mock_user)
# Assert
assert response["connected"] is True
assert response["shop_domain"] == mock_shop.shop_domain
@pytest.mark.asyncio
@patch('app.api.shopify.ShopifyShopRepository')
async def test_get_status_not_connected(mock_shop_repo, mock_db, mock_organization, mock_user):
"""Test status endpoint when Shopify is not connected"""
# Arrange
mock_shop_repo_instance = MagicMock()
mock_shop_repo.return_value = mock_shop_repo_instance
mock_shop_repo_instance.get_shops_by_organization.return_value = []
# Act
from app.api.shopify import check_connection
response = await check_connection(mock_db, mock_organization, mock_user)
# Assert
assert response["connected"] is False
@pytest.mark.asyncio
@patch('app.api.shopify.require_shopify_session')
@patch('app.api.shopify.AgentShopifyConfigRepository')
@patch('app.api.shopify.WidgetRepository')
async def test_get_shop_config_status_success(mock_widget_repo, mock_config_repo, mock_require_session, mock_db):
"""Test shop config status endpoint"""
# Arrange
shop_id = str(uuid.uuid4())
widget_id = str(uuid.uuid4())
mock_db_shop = MagicMock()
mock_db_shop.id = shop_id
mock_db_shop.shop_domain = 'test-shop.myshopify.com'
mock_shopify_session = {
'shop_id': shop_id,
'shop_domain': 'test-shop.myshopify.com',
'db_shop': mock_db_shop
}
mock_require_session.return_value = mock_shopify_session
# Mock agent config repository
mock_config_repo_instance = MagicMock()
mock_config_repo.return_value = mock_config_repo_instance
mock_agent_config = MagicMock()
mock_agent_config.agent_id = str(uuid.uuid4())
mock_config_repo_instance.get_configs_by_shop.return_value = [mock_agent_config]
# Mock widget repository
mock_widget_repo_instance = MagicMock()
mock_widget_repo.return_value = mock_widget_repo_instance
mock_widget = MagicMock()
mock_widget.id = widget_id
mock_widget_repo_instance.get_widgets_by_agent.return_value = [mock_widget]
# Act
from app.api.shopify import get_shop_config_status
response = await get_shop_config_status(mock_shopify_session, mock_db)
# Assert
assert response["shop_id"] == shop_id
assert response["widget_id"] == str(widget_id)
@pytest.mark.asyncio
@patch('app.api.shopify.require_shopify_session')
@patch('app.api.shopify.AgentShopifyConfigRepository')
@patch('app.repositories.agent.AgentRepository')
async def test_get_connected_agents_success(mock_agent_repo, mock_config_repo, mock_require_session, mock_db):
"""Test get connected agents endpoint"""
# Arrange
shop_id = str(uuid.uuid4())
agent_id = str(uuid.uuid4())
mock_require_session.return_value = {
'shop_id': str(shop_id),
'shop_domain': 'test-shop.myshopify.com'
}
mock_config_repo_instance = MagicMock()
mock_config_repo.return_value = mock_config_repo_instance
mock_config = MagicMock()
mock_config.agent_id = agent_id
mock_config_repo_instance.get_configs_by_shop.return_value = [mock_config]
mock_agent_repo_instance = MagicMock()
mock_agent_repo.return_value = mock_agent_repo_instance
mock_agent = MagicMock()
mock_agent.id = agent_id
mock_agent.name = "Test Agent"
mock_agent.display_name = "Test Agent Display"
mock_agent.description = "Test Description"
mock_agent.is_active = True
mock_agent.organization_id = str(uuid.uuid4())
mock_agent_repo_instance.get_agent.return_value = mock_agent
# Act
from app.api.shopify import get_connected_agents
response = await get_connected_agents(str(shop_id), mock_require_session.return_value, mock_db)
# Assert
assert len(response) == 1
assert response[0]["id"] == str(agent_id)
assert response[0]["name"] == "Test Agent"
@pytest.mark.asyncio
@patch('app.api.shopify.require_shopify_session')
@patch('app.api.shopify.AgentShopifyConfigRepository')
async def test_get_connected_agents_empty(mock_config_repo, mock_require_session, mock_db):
"""Test get connected agents with no agents"""
# Arrange
shop_id = str(uuid.uuid4())
mock_require_session.return_value = {
'shop_id': str(shop_id),
'shop_domain': 'test-shop.myshopify.com'
}
mock_config_repo_instance = MagicMock()
mock_config_repo.return_value = mock_config_repo_instance
mock_config_repo_instance.get_configs_by_shop.return_value = []
# Act
from app.api.shopify import get_connected_agents
response = await get_connected_agents(str(shop_id), mock_require_session.return_value, mock_db)
# Assert
assert response == []
@pytest.mark.asyncio
@patch('app.api.shopify.ShopifyHelperService.verify_shopify_webhook')
@patch('app.api.shopify.ShopifyShopRepository')
async def test_shopify_app_uninstalled_webhook_success(mock_shop_repo, mock_verify_webhook, mock_db):
"""Test app uninstalled webhook"""
# Arrange
mock_verify_webhook.return_value = True
mock_shop_repo_instance = MagicMock()
mock_shop_repo.return_value = mock_shop_repo_instance
mock_shop = MagicMock()
mock_shop.id = str(uuid.uuid4())
mock_shop_repo_instance.get_shop_by_domain.return_value = mock_shop
mock_request = MagicMock()
mock_request.headers = {"X-Shopify-Shop-Domain": "test-shop.myshopify.com"}
webhook_data = {"domain": "test-shop.myshopify.com"}
# Mock request body
mock_request.body = AsyncMock(return_value=json.dumps(webhook_data).encode())
# Act
from app.api.shopify import shopify_app_uninstalled_webhook
response = await shopify_app_uninstalled_webhook(mock_request, mock_db)
# Assert
assert response["success"] is True
assert "App uninstalled successfully" in response["message"]
@pytest.mark.asyncio
@patch('app.api.shopify.ShopifyHelperService.verify_shopify_webhook')
async def test_shopify_app_uninstalled_webhook_invalid_hmac(mock_verify_webhook, mock_db):
"""Test app uninstalled webhook with invalid HMAC"""
# Arrange
mock_verify_webhook.return_value = False
mock_request = MagicMock()
webhook_data = {"domain": "test-shop.myshopify.com"}
# Mock request body
mock_request.body = AsyncMock(return_value=json.dumps(webhook_data).encode())
# Act & Assert
from app.api.shopify import shopify_app_uninstalled_webhook
with pytest.raises(HTTPException) as exc_info:
await shopify_app_uninstalled_webhook(mock_request, mock_db)
assert exc_info.value.status_code == 401
assert "Invalid webhook signature" in str(exc_info.value.detail)
@pytest.mark.asyncio
@patch('app.api.shopify.ShopifyHelperService.verify_shopify_webhook')
async def test_shopify_customers_data_request_webhook_success(mock_verify_webhook, mock_db):
"""Test customers data request webhook"""
# Arrange
mock_verify_webhook.return_value = True
mock_request = MagicMock()
webhook_data = {
"shop_id": 12345,
"shop_domain": "test-shop.myshopify.com",
"customer": {"id": 67890}
}
# Mock request body
mock_request.body = AsyncMock(return_value=json.dumps(webhook_data).encode())
# Act
from app.api.shopify import shopify_customers_data_request_webhook
response = await shopify_customers_data_request_webhook(mock_request, mock_db)
# Assert
assert response["success"] is True
assert "Customer data request received and logged" in response["message"]
@pytest.mark.asyncio
@patch('app.api.shopify.ShopifyHelperService.verify_shopify_webhook')
async def test_shopify_customers_redact_webhook_success(mock_verify_webhook, mock_db):
"""Test customers redact webhook"""
# Arrange
mock_verify_webhook.return_value = True
mock_request = MagicMock()
webhook_data = {
"shop_id": 12345,
"shop_domain": "test-shop.myshopify.com",
"customer": {"id": 67890}
}
# Mock request body
mock_request.body = AsyncMock(return_value=json.dumps(webhook_data).encode())
# Act
from app.api.shopify import shopify_customers_redact_webhook
response = await shopify_customers_redact_webhook(mock_request, mock_db)
# Assert
assert response["success"] is True
assert "Customer data redaction request received and logged" in response["message"]
@pytest.mark.asyncio
@patch('app.api.shopify.ShopifyHelperService.verify_shopify_webhook')
async def test_shopify_shop_redact_webhook_success(mock_verify_webhook, mock_db):
"""Test shop redact webhook"""
# Arrange
mock_verify_webhook.return_value = True
mock_request = MagicMock()
webhook_data = {
"shop_id": 12345,
"shop_domain": "test-shop.myshopify.com"
}
# Mock request body
mock_request.body = AsyncMock(return_value=json.dumps(webhook_data).encode())
# Act
from app.api.shopify import shopify_shop_redact_webhook
response = await shopify_shop_redact_webhook(mock_request, mock_db)
# Assert
assert response["success"] is True
assert "Shop data redacted successfully" in response["message"]
@pytest.mark.asyncio
@patch('app.api.shopify.ShopifyHelperService.verify_shopify_webhook')
@patch('app.api.shopify.ShopifyShopRepository')
async def test_shopify_app_uninstalled_webhook_shop_not_found(mock_shop_repo, mock_verify_webhook, mock_db):
"""Test app uninstalled webhook when shop is not found"""
# Arrange
mock_verify_webhook.return_value = True
mock_shop_repo_instance = MagicMock()
mock_shop_repo.return_value = mock_shop_repo_instance
mock_shop_repo_instance.get_shop_by_domain.return_value = None # Shop not found
mock_request = MagicMock()
mock_request.headers = {"X-Shopify-Shop-Domain": "nonexistent-shop.myshopify.com"}
webhook_data = {"domain": "nonexistent-shop.myshopify.com"}
# Mock request body
mock_request.body = AsyncMock(return_value=json.dumps(webhook_data).encode())
# Act
from app.api.shopify import shopify_app_uninstalled_webhook
response = await shopify_app_uninstalled_webhook(mock_request, mock_db)
# Assert
assert response["success"] is True
assert "Shop not found" in response["message"]
@pytest.mark.asyncio
@patch('app.api.shopify.require_shopify_session')
async def test_get_connected_agents_shop_id_mismatch(mock_require_session, mock_db):
"""Test get connected agents with shop ID mismatch"""
# Arrange
requested_shop_id = str(uuid.uuid4())
session_shop_id = str(uuid.uuid4()) # Different shop ID
mock_require_session.return_value = {
'shop_id': session_shop_id,
'shop_domain': 'test-shop.myshopify.com'
}
# Act & Assert
from app.api.shopify import get_connected_agents
with pytest.raises(HTTPException) as exc_info:
await get_connected_agents(requested_shop_id, mock_require_session.return_value, mock_db)
assert exc_info.value.status_code == 403
assert "Shop ID mismatch" in str(exc_info.value.detail)
@pytest.mark.asyncio
@patch('app.api.shopify.require_shopify_session')
@patch('app.api.shopify.AgentShopifyConfigRepository')
@patch('app.api.shopify.WidgetRepository')
async def test_get_shop_config_status_no_widget(mock_widget_repo, mock_config_repo, mock_require_session, mock_db):
"""Test shop config status when no widget exists"""
# Arrange
shop_id = str(uuid.uuid4())
mock_db_shop = MagicMock()
mock_db_shop.id = shop_id
mock_db_shop.shop_domain = 'test-shop.myshopify.com'
mock_shopify_session = {
'shop_id': shop_id,
'shop_domain': 'test-shop.myshopify.com',
'db_shop': mock_db_shop
}
mock_require_session.return_value = mock_shopify_session
# Mock agent config repository - no configs
mock_config_repo_instance = MagicMock()
mock_config_repo.return_value = mock_config_repo_instance
mock_config_repo_instance.get_configs_by_shop.return_value = [] # No agent configs
# Mock widget repository (won't be called since no configs)
mock_widget_repo_instance = MagicMock()
mock_widget_repo.return_value = mock_widget_repo_instance
# Act
from app.api.shopify import get_shop_config_status
response = await get_shop_config_status(mock_shopify_session, mock_db)
# Assert
assert response["shop_id"] == shop_id
assert response["widget_id"] is None
# Note: Tests for agent shopify config endpoints (GET/POST /agent-config/{agent_id})
# have been temporarily removed due to complex hybrid authentication requirements.
# These endpoints are tested through integration tests and manual testing.