1062 lines
37 KiB
Python
1062 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, 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 quote
|
|
from datetime import datetime, timezone
|
|
|
|
from app.main import app
|
|
from app.services.shopify_helper_service import ShopifyHelperService
|
|
from app.models.shopify import ShopifyShop
|
|
|
|
|
|
@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
|
|
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
|
|
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. |