""" 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.