fix: improve maintenance and synchronize live conversations

This commit is contained in:
WangShen007 committed 2026-10-29 09:46:21 +08:00
1 parent bb541bd212
commit ef86a5d3af
297 files changed
+2983 -1274

No files matched your search

+2 -9
View File
@@ -16,7 +16,6 @@ limitations under the License.
import asyncio
import pytest
from fastapi.testclient import TestClient
from app.core.config import settings
from app.models.user import User
from app.models.role import Role
@@ -27,9 +26,7 @@ from app.models.customer import Customer
from app.models.agent import Agent, AgentType
from app.models.schemas.jira import AgentWithJiraConfig
from app.agents.chat_agent import ChatAgent, ChatResponse, TransferReasonType
from app.repositories.ai_config import AIConfigRepository
from uuid import uuid4
from datetime import datetime, timezone
from unittest.mock import patch, MagicMock
from agno.storage.base import Storage
from fastapi import HTTPException
@@ -182,11 +179,7 @@ def test_session(db, test_organization_id, test_customer, test_agent) -> Session
@pytest.fixture
def mock_db_session(db):
"""Mock database session for ChatAgent"""
def get_mock_db():
yield db
with patch('app.agents.chat_agent.get_db', get_mock_db), \
patch('app.tools.knowledge_search_byagent.SessionLocal') as mock_knowledge_session_local, \
with patch('app.tools.knowledge_search_byagent.SessionLocal') as mock_knowledge_session_local, \
patch('app.agents.chat_agent.SessionLocal') as mock_chat_agent_session_local:
# Setup SessionLocal to return our test db when used as context manager
mock_knowledge_session_local.return_value.__enter__.return_value = db
@@ -452,4 +445,4 @@ async def test_chat_agent_run_timeout(test_organization_id, test_agent, test_use
assert isinstance(response, ChatResponse)
assert "error" in response.message.lower()
assert not response.transfer_to_human
assert not response.transfer_to_human
@@ -31,7 +31,6 @@ from app.agents.guardrail_policy import (
)
from app.models.agent import Agent, AgentType
from app.models.chat_history import ChatHistory
from app.models.schemas.chat import ChatResponse
from app.models.schemas.jira import AgentWithJiraConfig
from app.utils.guardrail_runtime import BLOCK_REPLY, LEAK_REPLY
+3 -8
View File
@@ -17,9 +17,7 @@ limitations under the License.
import asyncio
import pytest
import pytz
from datetime import datetime, timedelta
from unittest.mock import patch, MagicMock, AsyncMock
import sys
from app.agents.transfer_agent import (
FOLLOW_UP_TIMEOUT_MESSAGE,
TRANSFER_TIMEOUT_MESSAGE,
@@ -32,8 +30,6 @@ from app.models.agent import Agent, AgentType
from app.models.organization import Organization
from app.models.user import User, UserGroup
from uuid import uuid4
import app.utils.agno_utils
from fastapi import HTTPException
# Mock for agno.agent.Agent
class MockPhiAgent:
@@ -132,7 +128,6 @@ async def test_transfer_response_agent_initialization():
# Create a spy to track calls to create_model
create_model_calls = []
original_create_model = app.utils.agno_utils.create_model
def spy_create_model(*args, **kwargs):
create_model_calls.append((args, kwargs))
@@ -533,7 +528,7 @@ async def test_timezone_handling(test_agent):
mock_pytz.UTC = pytz.UTC
mock_pytz.UnknownTimeZoneError = pytz.exceptions.UnknownTimeZoneError
response = await get_agent_availability_response(
await get_agent_availability_response(
agent=test_agent,
customer_id="customer123",
chat_history=[],
@@ -552,7 +547,7 @@ async def test_timezone_handling(test_agent):
mock_pytz.timezone.side_effect = pytz.exceptions.UnknownTimeZoneError("Invalid/Timezone")
test_agent.organization.timezone = "Invalid/Timezone"
response = await get_agent_availability_response(
await get_agent_availability_response(
agent=test_agent,
customer_id="customer123",
chat_history=[],
@@ -564,4 +559,4 @@ async def test_timezone_handling(test_agent):
)
# Verify that timezone was called with the invalid timezone
mock_pytz.timezone.assert_called_with("Invalid/Timezone")
mock_pytz.timezone.assert_called_with("Invalid/Timezone")
@@ -65,15 +65,14 @@ def chat_agent(mock_agent_data, mock_db):
mock_storage = Mock()
mock_storage.get_session_state.return_value = {"status": "active"}
with patch('app.agents.chat_agent.get_db') as mock_get_db, \
with patch('app.agents.chat_agent.SessionLocal') as mock_session_local, \
patch('app.agents.chat_agent.JiraRepository') as mock_jira_repo, \
patch('app.tools.knowledge_search_byagent.KnowledgeSearchByAgent', return_value=mock_knowledge_tool), \
patch('app.agents.chat_agent.EncryptedPostgresAgentStorage', return_value=mock_storage), \
patch('app.agents.chat_agent.settings.DATABASE_URL', "mock://test"), \
patch.dict('os.environ', mock_env, clear=True):
# Mock database session
mock_get_db.return_value = iter([mock_db])
mock_session_local.return_value.__enter__.return_value = mock_db
# Mock JiraRepository methods
mock_jira_repo.return_value.get_agent_with_jira_config.return_value = mock_jira_agent_data
@@ -241,4 +240,4 @@ class TestChatAgentEndChat:
# Verify agent's default setting was used
assert result.request_rating is True
assert "Would you please take a moment to rate your experience?" in result.message
assert "Would you please take a moment to rate your experience?" in result.message
@@ -66,7 +66,7 @@ def build_agent(known_email: str = None, known_name: str = None, trusted: bool =
lead_repo = MagicMock()
lead_repo.return_value.get_by_agent.return_value = lead_config
with patch('app.agents.chat_agent.get_db') as mock_get_db, \
with patch('app.agents.chat_agent.SessionLocal') as mock_session_local, \
patch('app.agents.chat_agent.JiraRepository') as mock_jira_repo, \
patch('app.repositories.customer.CustomerRepository', customer_repo), \
patch('app.repositories.lead_capture.LeadCaptureConfigRepository', lead_repo), \
@@ -76,7 +76,7 @@ def build_agent(known_email: str = None, known_name: str = None, trusted: bool =
patch('app.agents.chat_agent.EncryptedPostgresAgentStorage', return_value=storage), \
patch('app.agents.chat_agent.settings.DATABASE_URL', "mock://test"), \
patch.dict('os.environ', {}, clear=True):
mock_get_db.return_value = iter([MagicMock(spec=Session)])
mock_session_local.return_value.__enter__.return_value = MagicMock(spec=Session)
mock_jira_repo.return_value.get_agent_with_jira_config.return_value = agent_data
return ChatAgent(
@@ -17,7 +17,6 @@ limitations under the License.
from unittest.mock import MagicMock, Mock, patch
from uuid import uuid4
import pytest
from sqlalchemy.orm import Session
from app.agents.chat_agent import ChatAgent
@@ -36,13 +35,13 @@ def build_agent(transfer_to_human: bool) -> ChatAgent:
storage = Mock()
storage.get_session_state.return_value = {"status": "active"}
with patch('app.agents.chat_agent.get_db') as mock_get_db, \
with patch('app.agents.chat_agent.SessionLocal') as mock_session_local, \
patch('app.agents.chat_agent.JiraRepository') as mock_jira_repo, \
patch('app.tools.knowledge_search_byagent.KnowledgeSearchByAgent', return_value=Mock()), \
patch('app.agents.chat_agent.EncryptedPostgresAgentStorage', return_value=storage), \
patch('app.agents.chat_agent.settings.DATABASE_URL', "mock://test"), \
patch.dict('os.environ', {}, clear=True):
mock_get_db.return_value = iter([MagicMock(spec=Session)])
mock_session_local.return_value.__enter__.return_value = MagicMock(spec=Session)
mock_jira_repo.return_value.get_agent_with_jira_config.return_value = agent_data
return ChatAgent(
@@ -26,7 +26,6 @@ from app.agents.guardrail_policy import (
OPERATOR_CLOSE,
OPERATOR_OPEN,
POLICY_HEADER,
REFUSAL_MARKER,
GuardrailContext,
apply_guardrail_policy,
build_policy_block,
+2 -2
View File
@@ -119,13 +119,13 @@ def _build_agent(choice_limits) -> ChatAgent:
storage = Mock()
storage.get_session_state.return_value = {"status": "active"}
with patch('app.agents.chat_agent.get_db') as mock_get_db, \
with patch('app.agents.chat_agent.SessionLocal') as mock_session_local, \
patch('app.agents.chat_agent.JiraRepository') as mock_jira_repo, \
patch('app.tools.knowledge_search_byagent.KnowledgeSearchByAgent', return_value=Mock()), \
patch('app.agents.chat_agent.EncryptedPostgresAgentStorage', return_value=storage), \
patch('app.agents.chat_agent.settings.DATABASE_URL', "mock://test"), \
patch.dict('os.environ', {}, clear=True):
mock_get_db.return_value = iter([MagicMock(spec=Session)])
mock_session_local.return_value.__enter__.return_value = MagicMock(spec=Session)
mock_jira_repo.return_value.get_agent_with_jira_config.return_value = agent_data
return ChatAgent(api_key="test-key", model_name="gpt-4", model_type="openai",
org_id=str(uuid4()), agent_id=str(uuid4()),
-1
View File
@@ -14,7 +14,6 @@ See the License for the specific language governing permissions and
limitations under the License.
"""
import os
import sys
from pathlib import Path
+3 -10
View File
@@ -22,10 +22,7 @@ from app.models.user import User, UserGroup
from app.models.agent import Agent, AgentCustomization, AgentType
from app.models.role import Role
from app.models.permission import Permission, role_permissions
from uuid import UUID, uuid4
from datetime import datetime
import os
from typing import Dict, Generator
from uuid import uuid4
from app.api import agent as agent_router
from app.core.auth import get_current_user, require_permissions, get_unified_auth
from app.models.schemas.agent_customization import CustomizationCreate
@@ -705,7 +702,7 @@ def test_customization_response_signs_photo_url_without_persisting_it(
instance, which SQLAlchemy then flushed, so stored URLs carried a signature
that eventually expired.
"""
from unittest.mock import AsyncMock, patch, MagicMock
from unittest.mock import patch, MagicMock
stored_url = "https://bucket.s3.us-east-1.amazonaws.com/agents/photo.png"
signed_url = f"{stored_url}?AWSAccessKeyId=A&Signature=S&Expires=1"
@@ -1261,7 +1258,7 @@ def test_generate_instructions_ai_error(
"existing_instructions": []
}
from unittest.mock import patch, AsyncMock, MagicMock
from unittest.mock import patch, MagicMock
# Create valid AI config mock
mock_config = MagicMock()
@@ -1289,10 +1286,6 @@ def test_generate_instructions_rate_limiting(
"""Test rate limiting on instruction generation endpoint"""
from unittest.mock import patch
prompt_data = {
"prompt": "Create helpful instructions",
"existing_instructions": []
}
# Mock rate limiting decorator to raise exception
with patch('app.utils.rate_limit.limit_instruction_generation') as mock_limit:
-1
View File
@@ -21,7 +21,6 @@ from app.models.user import User
from app.models.agent import Agent, AgentType
from app.models.role import Role
from app.models.permission import Permission, role_permissions
from app.models.organization import Organization
from app.models.workflow import Workflow, WorkflowStatus
from uuid import uuid4
from app.api import agent as agent_router
+2 -4
View File
@@ -16,18 +16,16 @@ limitations under the License.
import pytest
import os
from unittest.mock import patch, MagicMock
from unittest.mock import patch
from fastapi.testclient import TestClient
from fastapi import FastAPI
from app.models.user import User
from app.models.ai_config import AIConfig, AIModelType
from app.models.ai_config import AIConfig
from app.models.role import Role
from app.models.permission import Permission, role_permissions
from uuid import uuid4
from app.api import ai_setup as ai_setup_router
from app.core.auth import get_current_user, require_permissions
from app.models.schemas.ai_config import AIConfigCreate, AIConfigUpdate
from pydantic import SecretStr
from app.database import get_db
# Create a test FastAPI app
+2 -5
View File
@@ -16,10 +16,7 @@ limitations under the License.
import pytest
from fastapi.testclient import TestClient
from sqlalchemy.orm import Session
from unittest.mock import patch, MagicMock
from datetime import datetime, timedelta
import sqlite3
import json
from app.database import Base, get_db
@@ -29,14 +26,14 @@ from app.models.session_to_agent import SessionToAgent, SessionStatus
from app.models.rating import Rating
from app.models.permission import Permission, role_permissions
from app.models.role import Role
from uuid import UUID, uuid4
from uuid import uuid4
from app.api import analytics as analytics_router
from app.core.auth import get_current_user, require_permissions
from app.main import app
from app.core.config import settings
from app.repositories.session_to_agent import SessionToAgentRepository
from app.repositories.rating import RatingRepository
from tests.conftest import engine, TestingSessionLocal, create_tables, test_organization
from tests.conftest import engine, TestingSessionLocal, create_tables
import logging
# Set up logging
-1
View File
@@ -504,7 +504,6 @@ def test_get_chat_detail_with_shopify_output(
test_customer
):
"""Test getting chat detail with Shopify output in attributes"""
import json
# Create message with shopify_output in attributes
shopify_data = {
+1 -1
View File
@@ -29,7 +29,7 @@ from sqlalchemy.orm import Session
from uuid import uuid4
from app.api import users as users_router
from app.core.auth import get_current_user, require_permissions
from app.core.auth import get_current_user
from app.core.config import settings
from app.core.security import get_password_hash
from app.database import Base, get_db
-1
View File
@@ -33,7 +33,6 @@ from app.core.auth import get_current_user
from app.core.config import settings
from app.crm.base import OAuthTokens
from app.crm.hubspot import HubSpotAdapter
from app.crm.pipedrive import PipedriveAdapter
from app.database import get_db
from app.models.crm import CrmConnectionStatus, CrmSyncJobStatus
from app.models.lead_capture import LeadCaptureResponse
+1 -3
View File
@@ -15,11 +15,9 @@ limitations under the License.
"""
import pytest
import os
from unittest.mock import patch, Mock
from fastapi.testclient import TestClient
from fastapi import FastAPI, status
from sqlalchemy.orm import Session
from fastapi import FastAPI
from app.api.file_upload import router, get_current_user_or_widget
from app.models.user import User
+2 -2
View File
@@ -26,9 +26,9 @@ from fastapi.testclient import TestClient
import app.main # noqa: F401 — ensures routers are registered on the FastAPI app
from app.core.application import app
from app.core.auth import get_current_user, require_permissions
from app.core.auth import get_current_user
from app.database import get_db
from app.models.faq import FAQ, FAQStatus
from app.models.faq import FAQ
from app.repositories.faq import FAQRepository
BASE = "/api/v1/help-center"
+2 -2
View File
@@ -16,7 +16,7 @@ limitations under the License.
import pytest
from fastapi.testclient import TestClient
from unittest.mock import patch, MagicMock, AsyncMock
from unittest.mock import patch, AsyncMock
from app.database import get_db
from fastapi import FastAPI
from app.models.user import User
@@ -25,7 +25,7 @@ from app.models.role import Role
from app.models.permission import Permission, role_permissions
from app.models.jira import JiraToken, AgentJiraConfig
from app.models.agent import Agent, AgentType
from uuid import UUID, uuid4
from uuid import uuid4
from datetime import datetime, timedelta
from app.api import jira as jira_router
import app.core.oauth_state as oauth_state
+2 -2
View File
@@ -31,7 +31,7 @@ from app.api import knowledge as knowledge_router
from app.core.auth import get_current_user, require_permissions
from app.main import app
from app.core.config import settings
from tests.conftest import engine, TestingSessionLocal, create_tables
from tests.conftest import engine, TestingSessionLocal
from datetime import datetime, timezone
import io
import os
@@ -924,7 +924,7 @@ def test_add_subpage_with_enterprise_limits_mocked():
with pytest.raises(HTTPException) as exc_info:
# Call the endpoint function directly
import asyncio
result = asyncio.run(add_subpage(
asyncio.run(add_subpage(
knowledge_id=1,
subpage_name="test",
content="test content",
+2 -3
View File
@@ -25,7 +25,7 @@ from fastapi import FastAPI
from app.api import mcp_tool as mcp_api
from app.core.config import settings
from app.core.auth import get_current_user, require_permissions
from app.core.auth import get_current_user
from app.database import get_db
from app.models.user import User
from app.models.role import Role
@@ -34,7 +34,6 @@ from app.models.agent import Agent, AgentType
from app.models.mcp_tool import MCPTransportType
from app.repositories.mcp_tool import MCPToolRepository
from app.models.schemas.mcp_tool import (
MCPToolCreate, MCPToolUpdate, MCPToolToAgentCreate,
MAX_USAGE_GUIDANCE_CHARS, SECRET_MASK,
)
from app.models.ticket_settings import OrganizationTicketSettings
@@ -108,7 +107,7 @@ def test_create_and_get_mcp_tool(client: TestClient, db):
# Disable enterprise gating for tests
mcp_api.HAS_ENTERPRISE = False
# Fetch the user created in fixture
user = db.query(User).first()
db.query(User).first()
# Create an MCP tool
payload = {
-2
View File
@@ -29,7 +29,6 @@ from app.models.permission import (
DEFAULT_AGENT_ROLE_PERMISSIONS,
)
from uuid import UUID, uuid4
from datetime import datetime, timezone
from app.api import organizations as organizations_router
from app.core.auth import get_current_user, require_permissions
from tests.conftest import engine, TestingSessionLocal, create_tables, Base
@@ -185,7 +184,6 @@ def test_create_organization(client, db, monkeypatch):
# Mock the agent repository to avoid SQLite issues with JSON
from app.repositories.agent import AgentRepository
original_create_agent = AgentRepository.create_agent
def mock_create_agent(self, **kwargs):
# Skip agent creation in tests
+1 -1
View File
@@ -24,7 +24,7 @@ import app.main # noqa: F401 — ensures routers are registered
import app.api.people as people_api
from app.core.application import app
from app.core.auth import get_current_user
from app.crm.base import CrmPushResult, OAuthTokens
from app.crm.base import CrmPushResult
from app.crm.hubspot import HubSpotAdapter
from app.database import get_db
from app.models.customer import Customer
+2 -2
View File
@@ -22,12 +22,12 @@ from fastapi import FastAPI
from app.models.user import User
from app.models.role import Role
from app.models.permission import Permission, role_permissions
from uuid import UUID, uuid4
from uuid import uuid4
from app.api import roles as roles_router
from app.core.auth import get_current_user
from app.main import app
from app.core.config import settings
from tests.conftest import engine, TestingSessionLocal, create_tables
from tests.conftest import engine, TestingSessionLocal
# Create a test FastAPI app
app = FastAPI()
+2 -2
View File
@@ -25,9 +25,9 @@ from app.models.session_to_agent import SessionToAgent, SessionStatus
from app.models.chat_history import ChatHistory
from app.models.agent import Agent, AgentType
from app.models.customer import Customer
from uuid import UUID, uuid4
from uuid import uuid4
from app.api import session_to_agent as session_to_agent_router
from app.core.auth import get_current_user, require_permissions
from app.core.auth import get_current_user
from typing import Generator
from datetime import datetime
from app.models.schemas.chat import ChatDetailResponse, CustomerInfo, AgentInfo, Message
@@ -15,16 +15,11 @@ limitations under the License.
"""
import pytest
from unittest.mock import patch
from app.models.shopify.agent_shopify_config import AgentShopifyConfig
from app.models.shopify.shopify_shop import ShopifyShop
import uuid
from fastapi.testclient import TestClient
from app.core.application import app
from app.database import get_db
from app.core.auth import get_current_user, require_permissions, get_current_organization
from app.services.shopify_session import require_shopify_session
from sqlalchemy.orm import Session
from fastapi import HTTPException
+4 -12
View File
@@ -15,7 +15,7 @@ limitations under the License.
"""
import pytest
from unittest.mock import MagicMock, patch, ANY, AsyncMock
from unittest.mock import MagicMock, patch, AsyncMock
from fastapi import HTTPException
from fastapi.testclient import TestClient
from sqlalchemy.orm import Session
@@ -24,20 +24,12 @@ import json
import hmac
import hashlib
import base64
from urllib.parse import urlencode, quote
from urllib.parse import 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
@@ -334,7 +326,7 @@ async def test_delete_shop(mock_shop_repo, mock_config_repo, mock_db, mock_organ
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)
str(mock_organization.id)
mock_shop_repo_instance = MagicMock()
mock_shop_repo.return_value = mock_shop_repo_instance
@@ -362,7 +354,7 @@ async def test_check_connection_connected(mock_shop_repo, mock_db, mock_organiza
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)
str(mock_organization.id)
mock_shop_repo_instance = MagicMock()
mock_shop_repo.return_value = mock_shop_repo_instance
+1 -6
View File
@@ -17,17 +17,12 @@ limitations under the License.
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from unittest.mock import MagicMock, patch, AsyncMock
from unittest.mock import MagicMock, patch
from uuid import uuid4
from datetime import datetime, timezone
import jwt
from app.api import token as token_router
from app.api.token import (
validate_api_key,
GenerateTokenRequest,
RevokeTokenRequest,
)
from app.database import get_db
from app.core.security import CONVERSATION_SECRET_KEY, ALGORITHM
+2 -2
View File
@@ -24,11 +24,11 @@ from app.models.role import Role
from app.models.permission import Permission
from uuid import UUID, uuid4
from app.api import user_groups as user_groups_router
from app.core.auth import get_current_user, require_permissions
from app.core.auth import get_current_user
from app.main import app
from app.core.config import settings
from app.core.security import get_password_hash
from tests.conftest import engine, TestingSessionLocal, create_tables, test_organization
from tests.conftest import engine, TestingSessionLocal, create_tables
# Create a test FastAPI app
app = FastAPI()
+3 -7
View File
@@ -17,25 +17,21 @@ limitations under the License.
import pytest
from fastapi.testclient import TestClient
from sqlalchemy.orm import Session
from unittest.mock import patch, MagicMock
from app.database import Base, get_db
from fastapi import FastAPI, HTTPException
from app.models.user import User, UserGroup
from app.models.user import User
from app.models.fcm_token import FCMToken
from app.models.organization import Organization
from app.models.role import Role
from app.models.permission import Permission, role_permissions
from uuid import UUID, uuid4
from uuid import uuid4
from app.api import users as users_router
from app.core.auth import get_current_user, require_permissions
from app.main import app
from app.core.config import settings
from app.core.security import get_password_hash, verify_password
from datetime import datetime, timedelta
import json
from urllib.parse import unquote
from tests.conftest import engine, TestingSessionLocal, create_tables, test_organization
from tests.conftest import engine, TestingSessionLocal, create_tables
# The disposable-address gate is enterprise-only; a community checkout has no
# blocklist and accepts everything.
+1 -3
View File
@@ -17,13 +17,11 @@ limitations under the License.
import pytest
from unittest.mock import patch
from fastapi.testclient import TestClient
from sqlalchemy.orm import Session
from app.database import Base, get_db
from fastapi import FastAPI
from app.models.widget import Widget
from app.models.user import User
from app.models.agent import Agent, AgentType
from app.models.agent import AgentCustomization
from app.models.agent import Agent
from uuid import UUID, uuid4
from app.api import widget as widget_router
from app.core.auth import get_current_user
+33 -6
View File
@@ -27,13 +27,10 @@ from app.repositories.widget import WidgetRepository
from app.models.schemas.widget import WidgetCreate
from app.core.security import encrypt_api_key
from uuid import UUID, uuid4
from tests.conftest import engine, TestingSessionLocal, create_tables, Base
from app.models.session_to_agent import SessionStatus, SessionToAgent
from app.repositories.chat import ChatRepository
from app.models.workflow import Workflow
from app.models.workflow_node import WorkflowNode, NodeType
from app.models.workflow_connection import WorkflowConnection
from app.models.rating import Rating
import datetime
from socketio.exceptions import ConnectionRefusedError as SocketRefused
@@ -563,7 +560,7 @@ async def test_agent_message(db, test_widget, test_customer, test_user, mock_sio
# Create a test session
from app.repositories.session_to_agent import SessionToAgentRepository
session_repo = SessionToAgentRepository(db)
session = session_repo.create_session(
session_repo.create_session(
session_id=session_id,
agent_id=test_widget.agent_id,
customer_id=test_customer.id,
@@ -706,6 +703,36 @@ class TestUtilityFunctions:
assert validate_form_field(number_config, "101") == "Age must not exceed 100"
assert validate_form_field(number_config, "invalid") == "Age must be a valid number"
assert validate_form_field(number_config, "25") is None
# The client shares these accepted values: trim text/email input, reject
# parseFloat-only suffixes, and preserve numeric zero bounds and values.
assert validate_form_field(text_config, " A ") == "Name must be at least 2 characters"
assert validate_form_field(text_config, " AB ") is None
assert validate_form_field(
{"name": "name", "label": "Name", "type": "text", "minLength": 2}, "🙂"
) == "Name must be at least 2 characters"
assert validate_form_field(
{"name": "name", "label": "Name", "type": "text", "maxLength": 1}, "🙂"
) is None
assert validate_form_field(number_config, "12abc") == "Age must be a valid number"
assert validate_form_field(
{"name": "amount", "label": "Amount", "type": "number", "maxLength": 0}, "1"
) == "Amount must not exceed 0"
assert validate_form_field(
{"name": "amount", "label": "Amount", "type": "number", "maxLength": 0}, 0
) is None
assert validate_form_field(
{"name": "amount", "label": "Amount", "type": "number"}, "3."
) is None
assert validate_form_field(
{"name": "amount", "label": "Amount", "type": "number"}, "1.e3"
) is None
assert validate_form_field(
{
"name": "amount", "label": "Amount", "type": "number",
"minLength": None, "maxLength": None,
}, "-1"
) is None
def test_validate_form_data(self):
"""Test validate_form_data function"""
@@ -1563,7 +1590,7 @@ class TestComplexScenarios:
session_id = uuid4()
from app.repositories.session_to_agent import SessionToAgentRepository
session_repo = SessionToAgentRepository(db)
session = session_repo.create_session(
session_repo.create_session(
session_id=session_id,
agent_id=test_widget.agent_id,
customer_id=test_customer.id,
@@ -1621,7 +1648,7 @@ class TestComplexScenarios:
session_id = uuid4()
from app.repositories.session_to_agent import SessionToAgentRepository
session_repo = SessionToAgentRepository(db)
session = session_repo.create_session(
session_repo.create_session(
session_id=session_id,
agent_id=test_widget.agent_id,
customer_id=test_customer.id,
@@ -20,7 +20,6 @@ from app.models.widget import Widget
from app.models.agent import Agent, AgentType
from app.models.ai_config import AIConfig, AIModelType
from app.models.customer import Customer
from app.models.user import User
from app.models.workflow import Workflow, WorkflowStatus
from app.models.session_to_agent import SessionToAgent, SessionStatus
@@ -30,7 +29,7 @@ from app.repositories.session_to_agent import SessionToAgentRepository
from app.repositories.chat import ChatRepository
from app.models.schemas.widget import WidgetCreate
from app.core.security import encrypt_api_key
from uuid import UUID, uuid4
from uuid import uuid4
from datetime import datetime, timezone
+274 -2
View File
@@ -131,7 +131,7 @@ class TestWidgetChatWorkflow:
"""Test that WorkflowExecutionService can be mocked properly"""
from app.services.workflow_execution import WorkflowExecutionService
with patch.object(WorkflowExecutionService, '__init__', return_value=None) as mock_init, \
with patch.object(WorkflowExecutionService, '__init__', return_value=None), \
patch.object(WorkflowExecutionService, 'execute_workflow', new_callable=AsyncMock) as mock_execute:
mock_execute.return_value = {
@@ -252,4 +252,276 @@ class TestOneMessageWorkflowIsSentOnce:
await widget_chat.handle_widget_chat("test_sid", {"message": "hi"})
assert _chat_responses(sio) == ["Hello from the workflow"]
assert _stored_bot_messages(db, session_id) == ["Hello from the workflow"]
assert _stored_bot_messages(db, session_id) == ["Hello from the workflow"]
# Synthetic values are deliberately recognizable in a rendered log message.
_SYNTHETIC_KEY = "sk-f01-synthetic-key-do-not-log"
_SYNTHETIC_NOTE = "f01-private-form-answer"
_SYNTHETIC_EMAIL = "f01-private@example.test"
_SYNTHETIC_STATE = "f01-private-previous-state"
def _rendered_log(caplog):
import logging
formatter = logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s")
return "\n".join(formatter.format(record) for record in caplog.records)
@pytest.fixture
def form_logging_session(db, test_workflow, test_agent, test_customer, test_ai_config, monkeypatch):
"""A real SQLite session: FORM -> CONDITION -> GUARDRAILS -> MESSAGE -> LLM."""
from app.api import widget_chat
from app.core.security import encrypt_api_key
from app.models.session_to_agent import SessionToAgent, SessionStatus
from app.models.workflow_connection import WorkflowConnection
from app.services import socket_rate_limit
form = WorkflowNode(id=uuid4(), workflow_id=test_workflow.id, node_type=NodeType.FORM,
name="Contact Form", config={"form_fields": [
{"name": "email", "type": "email", "label": "Email"},
{"name": "note", "type": "text", "label": "Note"},
]})
condition = WorkflowNode(id=uuid4(), workflow_id=test_workflow.id, node_type=NodeType.CONDITION,
name="Condition", config={"condition_expression": "{{contact_form_note}} > 1"})
guard = WorkflowNode(id=uuid4(), workflow_id=test_workflow.id, node_type=NodeType.GUARDRAILS,
name="Guard", config={"enabled_guardrails": ["pii"], "pii_action": "redact",
"text_source": "contact_form_note"})
message = WorkflowNode(id=uuid4(), workflow_id=test_workflow.id, node_type=NodeType.MESSAGE,
name="Message", config={"message_text": "Noted {{contact_form_note}}"})
model = WorkflowNode(id=uuid4(), workflow_id=test_workflow.id, node_type=NodeType.LLM,
name="Model", config={"system_prompt": "Use {{contact_form_email}}",
"exit_condition": "continuous_execution"})
db.add_all([form, condition, guard, message, model])
for source, target, label in [(form, condition, None), (condition, guard, "false"),
(guard, message, "pass"), (message, model, None)]:
db.add(WorkflowConnection(id=uuid4(), workflow_id=test_workflow.id,
source_node_id=source.id, target_node_id=target.id, label=label))
test_customer.email = "anonymous@noemail.com"
test_ai_config.encrypted_api_key = encrypt_api_key(_SYNTHETIC_KEY)
test_ai_config.settings = {"base_url": "https://model.example.test/v1"}
active = SessionToAgent(session_id=uuid4(), organization_id=test_agent.organization_id,
customer_id=test_customer.id, agent_id=test_agent.id,
workflow_id=test_workflow.id, current_node_id=form.id,
workflow_state={"form_state": "waiting", "variables": {
"earlier": {"private": _SYNTHETIC_STATE}}},
workflow_history=[], status=SessionStatus.OPEN)
db.add(active)
db.commit()
sio = MagicMock()
sio.emit = AsyncMock()
sio.get_session = AsyncMock(return_value={
"widget_id": str(uuid4()), "org_id": str(test_agent.organization_id),
"agent_id": str(test_agent.id), "customer_id": str(test_customer.id),
"session_id": str(active.session_id), "ai_config": test_ai_config,
"conversation_scope": VERIFIED_SCOPE, "source": "widget",
})
monkeypatch.setattr(widget_chat, "sio", sio)
monkeypatch.setattr(socket_rate_limit, "sio", sio)
monkeypatch.setattr(widget_chat, "get_db", lambda: iter([db]))
return active, form, model, sio
@pytest.mark.asyncio
@pytest.mark.parametrize("duplicate_email", [False, True])
async def test_form_submission_keeps_secrets_out_of_rendered_logs(
db, test_organization, test_customer, test_ai_config, form_logging_session, duplicate_email,
caplog, monkeypatch,
):
import logging
from app.api import widget_chat
from app.models.customer import Customer
from app.models.schemas.chat import ChatResponse
from app.services import workflow_execution
active, form, model, sio = form_logging_session
if duplicate_email:
db.add(Customer(id=uuid4(), organization_id=test_organization.id,
email=_SYNTHETIC_EMAIL, full_name="Other"))
db.commit()
agent = MagicMock()
agent._get_llm_response_only = AsyncMock(return_value=ChatResponse(
message=f"Reply {_SYNTHETIC_NOTE}", transfer_to_human=False, end_chat=False,
request_rating=False, create_ticket=False,
))
agent.safe_cleanup_mcp_tools = AsyncMock()
create_agent = AsyncMock(return_value=agent)
monkeypatch.setattr(workflow_execution.ChatAgent, "create_async", create_agent)
session_id = str(active.session_id)
caplog.set_level(logging.DEBUG)
caplog.clear()
await widget_chat.handle_form_submission("sid-f01", {"form_data": {
"email": _SYNTHETIC_EMAIL, "note": _SYNTHETIC_NOTE,
}})
assert _chat_responses(sio) == [f"Noted {_SYNTHETIC_NOTE}", f"Reply {_SYNTHETIC_NOTE}"]
assert sorted(_stored_bot_messages(db, session_id)) == sorted(_chat_responses(sio))
assert any(c.args[0] == "form_submitted" and c.args[1]["success"] for c in sio.emit.call_args_list)
db.refresh(active)
db.refresh(test_customer)
assert test_customer.email == ("anonymous@noemail.com" if duplicate_email else _SYNTHETIC_EMAIL)
assert active.current_node_id == model.id
assert active.workflow_state["variables"][str(form.id)] == {
"contact_form_email": _SYNTHETIC_EMAIL, "contact_form_note": _SYNTHETIC_NOTE,
}
assert active.workflow_history[-1]["data"] == {"email": _SYNTHETIC_EMAIL, "note": _SYNTHETIC_NOTE}
kwargs = create_agent.await_args.kwargs
assert kwargs["api_key"] == _SYNTHETIC_KEY
assert kwargs["model_name"] == test_ai_config.model_name
assert kwargs["model_type"] == test_ai_config.model_type
assert kwargs["custom_system_prompt"] == f"Use {_SYNTHETIC_EMAIL}"
assert kwargs["session_id"] == session_id
assert kwargs["base_url"] == "https://model.example.test/v1"
assert kwargs["org_id"] == str(test_organization.id)
assert kwargs["customer_id"] == str(test_customer.id)
assert kwargs["agent_id"] == str(active.agent_id)
assert kwargs["source"] == "widget"
assert _SYNTHETIC_NOTE in agent._get_llm_response_only.await_args.kwargs["message"]
agent.safe_cleanup_mcp_tools.assert_awaited_once()
logs = _rendered_log(caplog)
for secret in (_SYNTHETIC_KEY, _SYNTHETIC_NOTE, _SYNTHETIC_EMAIL, _SYNTHETIC_STATE):
assert secret not in logs
assert session_id in logs and str(form.id) in logs and str(model.id) in logs
assert "form" in logs.lower()
@pytest.mark.asyncio
@pytest.mark.parametrize("case", ["missing", "unexpected", "exception"])
async def test_form_submission_failure_logs_identifiers_not_sensitive_inputs(case, caplog):
import logging
from app.services.workflow_execution import WorkflowExecutionService
service = WorkflowExecutionService(Mock())
node_id = uuid4()
session_id = "f01-failure-session"
workflow_id = uuid4()
error = f"database failed while handling {_SYNTHETIC_NOTE} and {_SYNTHETIC_KEY}"
service.session_repo.get_session = Mock(
side_effect=RuntimeError(error) if case == "exception" else None,
return_value=Mock(workflow_state={"form_state": "not-waiting", "private": _SYNTHETIC_STATE},
current_node_id=node_id) if case == "unexpected" else None,
)
service.execute_workflow = AsyncMock()
caplog.set_level(logging.DEBUG)
caplog.clear()
result = await service.submit_form(session_id, {"note": _SYNTHETIC_NOTE}, workflow_id,
api_key=_SYNTHETIC_KEY)
expected = {"missing": "Session not found", "unexpected": "No form submission expected",
"exception": error}[case]
assert result.success is False and result.error == expected
service.execute_workflow.assert_not_awaited()
logs = _rendered_log(caplog)
for secret in (_SYNTHETIC_KEY, _SYNTHETIC_NOTE, _SYNTHETIC_STATE):
assert secret not in logs
assert session_id in logs and str(workflow_id) in logs
assert any(record.levelno >= logging.WARNING for record in caplog.records)
if case == "unexpected":
assert str(node_id) in logs
if case == "exception":
assert "RuntimeError" in logs
def test_landing_page_interpolation_does_not_log_submitted_values(caplog):
import logging
from app.services.workflow_execution import WorkflowExecutionService
service = WorkflowExecutionService(Mock())
node = Mock(spec=WorkflowNode)
node.id = uuid4()
node.config = {"landing_page_heading": "Hello", "landing_page_content": "Saved {{note}}"}
state = {"variables": {"form": {"note": _SYNTHETIC_NOTE}}}
caplog.set_level(logging.DEBUG)
caplog.clear()
result = service._execute_landing_page_node(node, state)
assert result.landing_page_data == {"heading": "Hello", "content": f"Saved {_SYNTHETIC_NOTE}"}
logs = _rendered_log(caplog)
assert _SYNTHETIC_NOTE not in logs
assert str(node.id) in logs
@pytest.mark.parametrize("operation", ["update", "history"])
def test_form_state_persistence_failure_logs_no_database_parameters(
db, form_logging_session, caplog, monkeypatch, operation,
):
import logging
from app.repositories.session_to_agent import SessionToAgentRepository
active, form, _, _ = form_logging_session
session_id = str(active.session_id)
repository = SessionToAgentRepository(db)
monkeypatch.setattr(db, "commit", Mock(side_effect=RuntimeError(f"database error: {_SYNTHETIC_NOTE}")))
caplog.set_level(logging.DEBUG)
caplog.clear()
if operation == "update":
result = repository.update_workflow_state(session_id, form.id, {"secret": _SYNTHETIC_NOTE})
else:
result = repository.add_workflow_history_entry(session_id, form.id, "form_submission", {"note": _SYNTHETIC_NOTE})
assert result is False
logs = _rendered_log(caplog)
assert _SYNTHETIC_NOTE not in logs
assert session_id in logs and "RuntimeError" in logs
@pytest.mark.asyncio
async def test_model_failure_does_not_log_form_data_or_api_key(
db, form_logging_session, caplog, monkeypatch,
):
import logging
from app.api import widget_chat
from app.services import workflow_execution
active, form, model, sio = form_logging_session
error = f"model refused {_SYNTHETIC_NOTE} with {_SYNTHETIC_KEY}"
monkeypatch.setattr(workflow_execution.ChatAgent, "create_async", AsyncMock(side_effect=RuntimeError(error)))
session_id = str(active.session_id)
caplog.set_level(logging.DEBUG)
caplog.clear()
await widget_chat.handle_form_submission("sid-f01", {"form_data": {
"email": _SYNTHETIC_EMAIL, "note": _SYNTHETIC_NOTE,
}})
errors = [c.args[1] for c in sio.emit.call_args_list if c.args[0] == "error"]
assert errors == [{"error": error, "type": "form_error"}]
assert not any(c.args[0] == "form_submitted" for c in sio.emit.call_args_list)
logs = _rendered_log(caplog)
for secret in (_SYNTHETIC_KEY, _SYNTHETIC_NOTE, _SYNTHETIC_EMAIL, _SYNTHETIC_STATE):
assert secret not in logs
assert session_id in logs and str(form.id) in logs and str(model.id) in logs
assert "RuntimeError" in logs
def test_form_email_lookup_database_failure_does_not_log_parameters(caplog):
import logging
from app.repositories.customer import CustomerRepository
db = Mock()
db.query.side_effect = RuntimeError(f"database lookup failed: {_SYNTHETIC_EMAIL}")
caplog.set_level(logging.DEBUG)
caplog.clear()
assert CustomerRepository(db).get_customer_by_email(_SYNTHETIC_EMAIL, uuid4()) is None
logs = _rendered_log(caplog)
assert _SYNTHETIC_EMAIL not in logs and "RuntimeError" in logs
@pytest.mark.asyncio
@pytest.mark.parametrize("error_type", [ValueError, RuntimeError])
async def test_widget_form_exception_logs_type_without_exception_contents(
form_logging_session, caplog, monkeypatch, error_type,
):
import logging
from app.api import widget_chat
_, _, _, sio = form_logging_session
detail = f"failed with {_SYNTHETIC_KEY} and {_SYNTHETIC_NOTE}"
monkeypatch.setattr(widget_chat, "_own_session", Mock(side_effect=error_type(detail)))
caplog.set_level(logging.DEBUG)
caplog.clear()
await widget_chat.handle_form_submission("sid-f01", {"form_data": {"note": _SYNTHETIC_NOTE}})
emitted = [call.args[1] for call in sio.emit.call_args_list if call.args[0] == "error"]
assert emitted == [{"error": detail if error_type is ValueError else "Failed to submit form",
"type": "form_error"}]
logs = _rendered_log(caplog)
assert _SYNTHETIC_KEY not in logs and _SYNTHETIC_NOTE not in logs
assert "sid-f01" in logs and error_type.__name__ in logs
-4
View File
@@ -16,8 +16,6 @@ limitations under the License.
import pytest
import uuid
from unittest.mock import MagicMock, patch
from fastapi import HTTPException
from fastapi.testclient import TestClient
from sqlalchemy.orm import Session
@@ -28,8 +26,6 @@ from app.models.agent import Agent
from app.models.organization import Organization
from app.models.role import Role
from app.models.permission import Permission
from app.models.schemas.workflow import WorkflowCreate, WorkflowUpdate
from app.services.workflow import WorkflowService
from app.core.config import settings
from app.core.auth import get_current_user, require_permissions
from app.database import get_db
@@ -19,13 +19,9 @@ from fastapi.testclient import TestClient
from fastapi import FastAPI
from uuid import uuid4
from app.models import (
Agent,
User,
Organization,
Workflow,
WorkflowNode
)
from app.models.schemas.workflow import WorkflowNodeCreate, WorkflowNodeUpdate
from app.models.workflow import WorkflowStatus
from app.models.workflow_node import NodeType
from app.api import workflow_node as workflow_node_router
+1 -1
View File
@@ -207,7 +207,7 @@ class TestSlackProfileAndTyping:
@pytest.mark.asyncio
async def test_typing_placeholder_then_edit(self, adapter, monkeypatch):
from unittest.mock import AsyncMock, MagicMock
from unittest.mock import MagicMock
import app.channels.slack as slk
acc = MagicMock(); acc.id = "acc1"
conv = MagicMock(external_conversation_id="D7")
@@ -24,7 +24,6 @@ import pytest
from app.channels.sms import get_provider, list_providers
from app.channels.sms.base import SmsWebhookRequest
from app.channels.sms.twilio import TwilioProvider
def make_req(params=None, json_body=None, headers=None, url="https://x/api/v1/webhooks/sms/p/abc", raw=b""):
+2 -7
View File
@@ -35,14 +35,14 @@ import pytest
import uuid
import asyncio
import sys
from sqlalchemy import create_engine, event, DDL, String
from sqlalchemy import create_engine, event
from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import StaticPool
from sqlalchemy.dialects import postgresql
from sqlalchemy.ext.compiler import compiles
from app.database import Base
from typing import Generator
from sqlalchemy.schema import CreateTable, Table
from sqlalchemy.schema import Table
from app.models.organization import Organization
from uuid import UUID, uuid4
@@ -71,14 +71,9 @@ from app.repositories.widget import WidgetRepository
from app.models.schemas.widget import WidgetCreate
from app.core.security import encrypt_api_key, get_password_hash, create_access_token, create_refresh_token, create_conversation_token
from unittest.mock import MagicMock
from fastapi.testclient import TestClient
from app.main import app
from app.channels import app_hooks
from app.models.shopify.shopify_shop import ShopifyShop
from app.models.shopify.agent_shopify_config import AgentShopifyConfig
import jwt
from datetime import datetime, timedelta
from app.core.config import settings
from app.models.role import Role
from app.models.permission import Permission
+1 -1
View File
@@ -16,7 +16,7 @@ limitations under the License.
import pytest
from unittest.mock import patch, MagicMock, AsyncMock
from fastapi import HTTPException, Request, Depends
from fastapi import HTTPException, Request
from sqlalchemy.orm import Session
from uuid import uuid4
+6 -7
View File
@@ -15,7 +15,7 @@ limitations under the License.
"""
import pytest
from unittest.mock import patch, MagicMock, AsyncMock
from unittest.mock import patch, MagicMock
from app.core.auth_utils import (
refresh_access_token,
authenticate_socket,
@@ -26,8 +26,7 @@ from app.models.user import User
from app.models.widget import Widget
from uuid import uuid4
import http.cookies
from datetime import datetime, timezone
from app.core.security import verify_token, verify_conversation_token, create_access_token, create_refresh_token, create_conversation_token
from app.core.security import verify_token, create_access_token, create_refresh_token
from app.models.auth_session import AuthSession
from app.services.auth_sessions import issue_tokens, revoke_session
@@ -297,13 +296,13 @@ async def test_authenticate_socket_conversation_token_missing_widget(mock_db):
mock_db.query.return_value.filter.return_value.first.return_value = None
with patch('app.core.auth_utils.verify_conversation_token') as mock_verify, \
patch('app.core.auth_utils.get_db') as mock_get_db:
patch('app.core.auth_utils.SessionLocal') as mock_session_local:
mock_verify.return_value = {
"widget_id": widget_id,
"sub": customer_id,
"type": "conversation"
}
mock_get_db.return_value.__next__.return_value = mock_db
mock_session_local.return_value.__enter__.return_value = mock_db
# Execute
result = await authenticate_socket_conversation_token(sid, auth)
@@ -325,13 +324,13 @@ async def test_authenticate_socket_conversation_token_wrong_type(mock_db, mock_w
mock_db.query.return_value.filter.return_value.first.return_value = mock_widget
with patch('app.core.auth_utils.verify_conversation_token') as mock_verify, \
patch('app.core.auth_utils.get_db') as mock_get_db:
patch('app.core.auth_utils.SessionLocal') as mock_session_local:
mock_verify.return_value = {
"widget_id": str(mock_widget.id),
"sub": customer_id,
"type": "access" # Wrong token type
}
mock_get_db.return_value.__next__.return_value = mock_db
mock_session_local.return_value.__enter__.return_value = mock_db
# Execute
result = await authenticate_socket_conversation_token(sid, auth)
+5 -18
View File
@@ -18,10 +18,9 @@ import pytest
from unittest.mock import patch, MagicMock, AsyncMock
from app.knowledge.knowledge_base import KnowledgeManager
from app.models.knowledge import Knowledge, SourceType
from app.models.knowledge_to_agent import KnowledgeToAgent
from uuid import UUID, uuid4
from uuid import uuid4
import os
from agno.vectordb.pgvector import PgVector, SearchType
from agno.vectordb.pgvector import PgVector
@pytest.fixture
def mock_db():
@@ -29,13 +28,6 @@ def mock_db():
db = MagicMock()
return db
@pytest.fixture
def mock_ai_config():
"""Create a mock AI config"""
config = MagicMock()
config.encrypted_api_key = "encrypted_key"
return config
@pytest.fixture
def mock_knowledge():
"""Create a mock knowledge entry"""
@@ -59,20 +51,15 @@ def mock_vector_db():
return vector_db
@pytest.fixture
def knowledge_manager(mock_db, mock_ai_config, mock_vector_db):
def knowledge_manager(mock_db, mock_vector_db):
"""Create a KnowledgeManager instance with mocked dependencies"""
org_id = uuid4()
agent_id = uuid4()
with patch('app.knowledge.knowledge_base.SessionLocal') as mock_session_local, \
patch('app.knowledge.knowledge_base.AIConfigRepository') as mock_ai_config_repo, \
patch('app.knowledge.knowledge_base.OptimizedPgVector') as mock_pg_vector, \
with patch('app.knowledge.knowledge_base.OptimizedPgVector') as mock_pg_vector, \
patch('app.knowledge.knowledge_base.KnowledgeRepository') as mock_knowledge_repo, \
patch('app.knowledge.knowledge_base.KnowledgeToAgentRepository') as mock_link_repo:
# Configure basic mocks
mock_session_local.return_value.__enter__.return_value = mock_db
mock_ai_config_repo.return_value.get_active_config.return_value = mock_ai_config
mock_pg_vector.return_value = mock_vector_db
# Configure repository mocks
@@ -174,7 +161,7 @@ async def test_add_pdf_files_success(knowledge_manager, mock_knowledge):
files = ["/path/to/test.pdf"]
with patch('app.knowledge.knowledge_base.EnhancedPDFKnowledgeBase') as mock_pdf_kb, \
patch('app.knowledge.knowledge_base.PDFImageReader') as mock_pdf_reader, \
patch('app.knowledge.knowledge_base.PDFImageReader'), \
patch('os.path.exists') as mock_exists, \
patch('os.path.getsize') as mock_getsize, \
patch('os.path.basename') as mock_basename, \
-1
View File
@@ -14,7 +14,6 @@ See the License for the specific language governing permissions and
limitations under the License.
"""
import pytest
from unittest.mock import patch, MagicMock
@patch('app.core.redis.redis')
+2 -3
View File
@@ -17,9 +17,8 @@ limitations under the License.
import os
import pytest
from unittest.mock import patch, MagicMock, AsyncMock
from fastapi import UploadFile, HTTPException
from io import BytesIO
from unittest.mock import patch, MagicMock
from fastapi import HTTPException
from app.core.s3 import (
S3_MAX_PRESIGN_SECONDS,
delete_file_from_s3,
+1 -1
View File
@@ -18,7 +18,7 @@ import pytest
from unittest.mock import patch, MagicMock
import socketio
from app.core.socketio import configure_socketio, sio, socket_app
from app.core.socketio import configure_socketio, sio
def test_configure_socketio_with_cors():
@@ -1,4 +1,3 @@
from pathlib import Path
from fastapi import FastAPI
from fastapi.testclient import TestClient
@@ -14,7 +14,6 @@ See the License for the specific language governing permissions and
limitations under the License.
"""
import pytest
from unittest.mock import patch, MagicMock
from app.knowledge.content_summarizer import ContentSummarizer, get_content_summarizer
@@ -360,7 +359,7 @@ class TestChatAgentURLInstructions:
mock_agent_class.return_value = mock_agent_instance
# Create chat agent
chat_agent = ChatAgent(
ChatAgent(
api_key="test_key",
model_name="gpt-4",
model_type="OPENAI",
@@ -14,11 +14,13 @@ See the License for the specific language governing permissions and
limitations under the License.
"""
import asyncio
import os
import socket
import threading
import pytest
from unittest.mock import MagicMock, patch, AsyncMock
from bs4 import BeautifulSoup
import asyncio
from app.knowledge.crawl4ai_fallback import (
Crawl4AIFallback,
@@ -36,6 +38,34 @@ _SKIP_LIVE = os.getenv("CI") is not None and os.getenv("RUN_LIVE_CRAWL_TESTS") !
_LIVE_REASON = "live crawl4ai network/browser test (set RUN_LIVE_CRAWL_TESTS=1 to run; skipped in CI)"
@pytest.fixture(autouse=True)
def offline_crawler_dns(monkeypatch):
"""Keep offline cases synthetic without changing the live-test opt-in."""
if not _SKIP_LIVE:
return
def unresolved_host(*args, **kwargs):
raise socket.gaierror("synthetic test host")
monkeypatch.setattr(socket, "getaddrinfo", unresolved_host)
@pytest.mark.parametrize("skip_live", [True, False])
def test_offline_dns_fixture_respects_existing_live_mode(monkeypatch, skip_live):
monkeypatch.setitem(globals(), "_SKIP_LIVE", skip_live)
resolver = MagicMock(return_value=[])
monkeypatch.setattr(socket, "getaddrinfo", resolver)
offline_crawler_dns.__wrapped__(monkeypatch)
if skip_live:
with pytest.raises(socket.gaierror, match="synthetic test host"):
socket.getaddrinfo("crawl.test", 443)
resolver.assert_not_called()
else:
assert socket.getaddrinfo is resolver
class TestCrawl4AIFallback:
"""Test suite for Crawl4AIFallback class"""
@@ -66,7 +96,7 @@ class TestCrawl4AIFallback:
async def test_async_fetch_success(self, mock_crawler_class):
"""Test successful async fetch without screenshot"""
# Arrange
url = "https://example.com"
url = "https://crawl.test"
mock_result = MagicMock()
mock_result.success = True
mock_result.html = "<html><body>Test content</body></html>"
@@ -98,7 +128,7 @@ class TestCrawl4AIFallback:
async def test_async_fetch_failure(self, mock_crawler_class):
"""Test failed async fetch"""
# Arrange
url = "https://example.com"
url = "https://crawl.test"
mock_result = MagicMock()
mock_result.success = False
mock_result.error_message = "Connection timeout"
@@ -127,7 +157,7 @@ class TestCrawl4AIFallback:
async def test_async_fetch_with_screenshot(self, mock_crawler_class, mock_playwright):
"""Test async fetch with screenshot enabled"""
# Arrange
url = "https://example.com"
url = "https://crawl.test"
# Mock Playwright screenshot
mock_page = AsyncMock()
@@ -181,7 +211,7 @@ class TestCrawl4AIFallback:
async def test_async_fetch_exception(self, mock_crawler_class):
"""Test async fetch with exception"""
# Arrange
url = "https://example.com"
url = "https://crawl.test"
mock_crawler_class.side_effect = Exception("Network error")
fallback = Crawl4AIFallback()
@@ -289,106 +319,62 @@ class TestCrawl4AIFallback:
@patch('app.knowledge.crawl4ai_fallback.CRAWL4AI_AVAILABLE', True)
@patch('asyncio.get_event_loop')
@patch('asyncio.run')
def test_run_async_safely_no_running_loop(self, mock_asyncio_run, mock_get_loop):
def test_run_async_safely_no_running_loop(self, mock_get_loop):
"""Test _run_async_safely when no event loop is running"""
# Arrange
mock_loop = MagicMock()
mock_loop.is_running.return_value = False
mock_get_loop.return_value = mock_loop
mock_asyncio_run.return_value = ("<html>Test</html>", "# Test", None)
fallback = Crawl4AIFallback()
fallback._is_available = True
# Act
html, markdown, screenshot = fallback._run_async_safely("https://example.com", False)
# Assert
assert html == "<html>Test</html>"
assert markdown == "# Test"
assert screenshot is None
mock_asyncio_run.assert_called_once()
expected = ("<html>Test</html>", "# Test", None)
with patch.object(fallback, '_async_fetch', new=AsyncMock(return_value=expected)) as fetch:
result = fallback._run_async_safely("https://crawl.test", False)
assert result == expected
fetch.assert_awaited_once_with("https://crawl.test", False)
@pytest.mark.asyncio
@patch('app.knowledge.crawl4ai_fallback.CRAWL4AI_AVAILABLE', True)
async def test_run_async_safely_with_running_loop(self):
"""A running caller loop uses another thread and loop, then joins it."""
fallback = Crawl4AIFallback()
fallback._is_available = True
caller_loop = asyncio.get_running_loop()
caller_thread = threading.get_ident()
expected = ("<html>Test</html>", "# Test", None)
async def fetch_in_worker(url, take_screenshot):
assert threading.get_ident() != caller_thread
assert asyncio.get_running_loop() is not caller_loop
return expected
with patch.object(fallback, '_async_fetch', new=AsyncMock(side_effect=fetch_in_worker)) as fetch:
html, markdown, screenshot = fallback._run_async_safely("https://crawl.test", False)
assert (html, markdown, screenshot) == expected
fetch.assert_awaited_once_with("https://crawl.test", False)
@patch('app.knowledge.crawl4ai_fallback.CRAWL4AI_AVAILABLE', True)
@patch('asyncio.get_event_loop')
@patch('threading.Thread')
def test_run_async_safely_with_running_loop(self, mock_thread_class, mock_get_loop):
"""Test _run_async_safely when event loop is already running"""
# Arrange
mock_loop = MagicMock()
mock_loop.is_running.return_value = True
mock_get_loop.return_value = mock_loop
# Mock thread execution
mock_thread = MagicMock()
mock_thread.is_alive.return_value = False
mock_thread_class.return_value = mock_thread
fallback = Crawl4AIFallback()
fallback._is_available = True
# Mock the async fetch to set result
def mock_thread_target(*args, **kwargs):
# Simulate successful execution
pass
with patch.object(fallback, '_async_fetch', return_value=("<html>Test</html>", "# Test", None)):
# Act
html, markdown, screenshot = fallback._run_async_safely("https://example.com", False)
# Assert
mock_thread_class.assert_called_once()
mock_thread.start.assert_called_once()
mock_thread.join.assert_called_once()
@patch('app.knowledge.crawl4ai_fallback.CRAWL4AI_AVAILABLE', True)
@patch('asyncio.get_event_loop')
@patch('asyncio.run')
def test_run_async_safely_exception(self, mock_asyncio_run, mock_get_loop):
def test_run_async_safely_exception(self, mock_get_loop):
"""Test _run_async_safely handles exceptions"""
# Arrange
mock_loop = MagicMock()
mock_loop.is_running.return_value = False
mock_get_loop.return_value = mock_loop
# Simulate exception in asyncio.run
mock_asyncio_run.side_effect = RuntimeError("Async execution failed")
fallback = Crawl4AIFallback()
fallback._is_available = True
# Act
html, markdown, screenshot = fallback._run_async_safely("https://example.com", False)
# Assert - should handle exception gracefully and return None
with patch.object(fallback, '_async_fetch', new=AsyncMock(side_effect=RuntimeError("Async execution failed"))):
html, markdown, screenshot = fallback._run_async_safely("https://crawl.test", False)
assert html is None
assert markdown is None
assert screenshot is None
@patch('app.knowledge.crawl4ai_fallback.CRAWL4AI_AVAILABLE', True)
def test_run_async_safely_threaded_execution(self):
"""Cover the real new-thread/new-loop path when an event loop is already running.
Mocks _async_fetch so no real network/browser is touched, but lets the actual
threading + new event loop machinery run (the path the live test used to cover).
"""
fallback = Crawl4AIFallback()
fallback._is_available = True
expected = ("<html><body>Threaded</body></html>", "# Threaded content", None)
fake_loop = MagicMock()
fake_loop.is_running.return_value = True
with patch('asyncio.get_event_loop', return_value=fake_loop), \
patch.object(fallback, '_async_fetch', new=AsyncMock(return_value=expected)):
html, markdown, screenshot = fallback._run_async_safely("https://example.com", False)
assert (html, markdown, screenshot) == expected
@patch('app.knowledge.crawl4ai_fallback.CRAWL4AI_AVAILABLE', True)
def test_run_async_safely_threaded_exception(self):
"""An exception inside the worker thread is re-raised and handled gracefully."""
@@ -15,7 +15,6 @@ limitations under the License.
"""
import pytest
import os
from unittest.mock import MagicMock, patch, call, ANY
from agno.document.base import Document
from app.core.config import settings
@@ -14,10 +14,9 @@ See the License for the specific language governing permissions and
limitations under the License.
"""
import os
import socket
import unittest
from unittest.mock import patch, MagicMock, Mock
from unittest.mock import patch
from bs4 import BeautifulSoup
import httpx
@@ -138,7 +138,7 @@ class TestOptimizedPgVector:
# Mock the document's embed method
with patch.object(Document, 'embed') as mock_embed:
# Act
with patch('sqlalchemy.dialects.postgresql.insert') as mock_insert:
with patch('sqlalchemy.dialects.postgresql.insert'):
db.insert([pre_embedded_doc, unembedded_doc])
# Assert
+2 -2
View File
@@ -15,7 +15,7 @@ limitations under the License.
"""
import pytest
from app.models.agent import Agent, AgentType
from app.models.agent import AgentType
from app.repositories.agent import AgentRepository
import json
@@ -90,7 +90,7 @@ def test_get_org_agents(agent_repo, test_organization_id):
org_id=test_organization_id,
is_active=True
)
agent2 = agent_repo.create_agent(
agent_repo.create_agent(
name="Agent 2",
agent_type=AgentType.CUSTOMER_SUPPORT,
instructions=["Instruction"],
+1 -2
View File
@@ -15,10 +15,9 @@ limitations under the License.
"""
import pytest
from app.models.ai_config import AIConfig, AIModelType
from app.models.ai_config import AIModelType
from app.repositories.ai_config import AIConfigRepository
from app.core.security import decrypt_api_key
from uuid import UUID
from app.models.organization import Organization
@pytest.fixture
+1 -1
View File
@@ -23,7 +23,7 @@ from app.models.customer import Customer
from app.models.agent import Agent, AgentType
from app.models.session_to_agent import SessionToAgent, SessionStatus
from app.models.user import User, UserGroup
from uuid import uuid4, UUID
from uuid import uuid4
from app.channels.interactive import from_options
from app.channels.sent_choices import SENT_CHOICES_ATTRIBUTE, encode_sent_choices
from app.models.schemas.chat import ChatDetailResponse
+1 -1
View File
@@ -195,7 +195,7 @@ class TestCrmSyncJobRepository:
def test_skip_pending_for_connection(
self, job_repo, test_organization, test_lead_response):
job = job_repo.enqueue(test_organization.id, test_lead_response.id, "hubspot")
job_repo.enqueue(test_organization.id, test_lead_response.id, "hubspot")
count = job_repo.skip_pending_for_connection(
test_organization.id, "hubspot", "app uninstalled")
assert count == 1
+1 -3
View File
@@ -15,9 +15,8 @@ limitations under the License.
"""
import pytest
from app.models.customer import Customer
from app.repositories.customer import CustomerRepository
from uuid import UUID, uuid4
from uuid import uuid4
@pytest.fixture
def customer_repo(db):
@@ -446,7 +445,6 @@ class TestPhoneIdentityHazards:
# Simulate losing the race: our lookup saw nothing, the insert collides.
calls = {"n": 0}
real_create = customer_repo.create_customer
def racing_create(*args, **kwargs):
calls["n"] += 1
@@ -15,10 +15,8 @@ limitations under the License.
"""
import pytest
from datetime import datetime
from uuid import uuid4
from app.repositories.file_attachment import FileAttachmentRepository
from app.models.file_attachment import FileAttachment
from app.models.chat_history import ChatHistory
from app.models.customer import Customer
from app.models.user import User, UserGroup
+1 -1
View File
@@ -17,7 +17,7 @@ limitations under the License.
import pytest
from app.repositories.group import GroupRepository
from app.models.user import UserGroup, User
from uuid import uuid4, UUID
from uuid import uuid4
@pytest.fixture
def group_repo(db):
@@ -18,7 +18,6 @@ import pytest
from app.repositories.knowledge_queue import KnowledgeQueueRepository
from app.models.knowledge_queue import KnowledgeQueue, QueueStatus
from app.models.user import User
from uuid import uuid4
@pytest.fixture
def queue_repo(db):
@@ -19,7 +19,6 @@ from app.repositories.knowledge import KnowledgeRepository
from app.models.knowledge import Knowledge, SourceType
from app.models.knowledge_to_agent import KnowledgeToAgent
from app.models.agent import Agent, AgentType
from uuid import uuid4
@pytest.fixture
def knowledge_repo(db):
@@ -15,7 +15,6 @@ limitations under the License.
"""
import pytest
from app.models.organization import Organization
from app.repositories.organization import OrganizationRepository
@pytest.fixture
+1 -1
View File
@@ -190,7 +190,7 @@ def test_phone_identifies_and_is_searchable(repo, db, test_organization_id):
def test_update_person_edits_and_guards(repo, db, test_organization_id):
person = _customer(db, test_organization_id)
other = _customer(db, test_organization_id, phone="+15550001111")
_customer(db, test_organization_id, phone="+15550001111")
# Set + correct (overwrite is allowed here — it's the explicit human path)
updated, error = repo.update_person(test_organization_id, person.id,
-1
View File
@@ -20,7 +20,6 @@ from app.models.role import Role
from app.models.permission import Permission
from app.models.user import User
from app.models.organization import Organization
from uuid import uuid4, UUID
@pytest.fixture
def test_organization(db):
@@ -182,13 +182,13 @@ def test_close_session(session_repo, test_session):
def test_get_agent_sessions(session_repo, test_agent, test_organization_id, test_customer):
"""Test retrieving all sessions for an agent"""
# Create multiple sessions
session1 = session_repo.create_session(
session_repo.create_session(
session_id=uuid4(),
agent_id=test_agent.id,
customer_id=test_customer.id,
organization_id=test_organization_id
)
session2 = session_repo.create_session(
session_repo.create_session(
session_id=uuid4(),
agent_id=test_agent.id,
customer_id=test_customer.id,
@@ -239,7 +239,7 @@ def test_get_open_sessions(session_repo, test_session):
def test_get_customer_sessions(session_repo, test_session, test_customer):
"""Test retrieving all sessions for a customer"""
# Create another session for the same customer
session2 = session_repo.create_session(
session_repo.create_session(
session_id=uuid4(),
agent_id=test_session.agent_id,
customer_id=test_customer.id,
+2 -3
View File
@@ -17,7 +17,6 @@ limitations under the License.
import pytest
from app.models.user import User
from app.repositories.user import UserRepository
from uuid import UUID, uuid4
from app.models.organization import Organization
@pytest.fixture
@@ -81,13 +80,13 @@ def test_get_users_by_organization(user_repo, test_organization_id):
"""Test retrieving all users for an organization"""
# Create multiple users
hashed_password = User.get_password_hash("testpassword")
user1 = user_repo.create_user(
user_repo.create_user(
email="user1@example.com",
hashed_password=hashed_password,
full_name="User One",
organization_id=test_organization_id
)
user2 = user_repo.create_user(
user_repo.create_user(
email="user2@example.com",
hashed_password=hashed_password,
full_name="User Two",
+1 -2
View File
@@ -18,12 +18,11 @@ import pytest
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from app.database import Base
from app.models.widget import Widget
from app.models.schemas.widget import WidgetCreate
from app.repositories.widget import WidgetRepository
from app.models.agent import Agent, AgentType
from app.models.organization import Organization
from uuid import UUID, uuid4
from uuid import uuid4
# Test database URL
SQLALCHEMY_DATABASE_URL = "sqlite:///:memory:"
@@ -18,7 +18,6 @@ import pytest
from uuid import uuid4
from unittest.mock import patch
from app.repositories.chat import ChatRepository
from app.models.chat_history import ChatHistory
from app.models.session_to_agent import SessionToAgent, SessionStatus
@@ -16,7 +16,7 @@ limitations under the License.
import json
from datetime import datetime, timedelta, timezone
from unittest.mock import MagicMock, patch
from unittest.mock import patch
import pytest
@@ -26,7 +26,6 @@ from app.models.role import Role
from app.models.permission import Permission, role_permissions
from app.models.agent import Agent, AgentType
from app.models.jira import AgentJiraConfig, JiraToken
from app.models.organization import Organization
from app.models.user import UserGroup
from app.core.security import get_password_hash
from app.repositories.jira import JiraRepository
@@ -461,7 +460,7 @@ class TestJiraRepositoryPerformance:
mock_query.side_effect = query_side_effect
result = jira_repo.get_agent_with_jira_config(str(test_agent.id))
jira_repo.get_agent_with_jira_config(str(test_agent.id))
# Verify that query was called (optimization check)
assert mock_query.called
@@ -15,10 +15,9 @@ limitations under the License.
"""
import pytest
import json
from datetime import datetime, timedelta
from uuid import UUID, uuid4
from unittest.mock import patch, MagicMock
from unittest.mock import patch
from sqlalchemy.orm import Session
from sqlalchemy.exc import SQLAlchemyError
@@ -385,13 +384,13 @@ class TestSessionQueries:
def test_get_agent_sessions(self, session_repo, test_agent, test_organization_id, test_customer):
"""Test retrieving all sessions for an agent"""
# Create multiple sessions
session1 = session_repo.create_session(
session_repo.create_session(
session_id=uuid4(),
agent_id=test_agent.id,
customer_id=test_customer.id,
organization_id=test_organization_id
)
session2 = session_repo.create_session(
session_repo.create_session(
session_id=uuid4(),
agent_id=test_agent.id,
customer_id=test_customer.id,
@@ -462,7 +461,7 @@ class TestSessionQueries:
def test_get_customer_sessions(self, session_repo, test_session, test_customer):
"""Test retrieving all sessions for a customer"""
# Create another session for the same customer
session2 = session_repo.create_session(
session_repo.create_session(
session_id=uuid4(),
agent_id=test_session.agent_id,
customer_id=test_customer.id,
@@ -494,7 +493,7 @@ class TestSessionQueries:
def test_get_agent_customer_sessions(self, session_repo, test_session, test_agent, test_customer):
"""Test retrieving sessions between specific agent and customer"""
# Create another session for the same agent-customer pair
session2 = session_repo.create_session(
session_repo.create_session(
session_id=uuid4(),
agent_id=test_agent.id,
customer_id=test_customer.id,
@@ -15,7 +15,7 @@ limitations under the License.
"""
import pytest
from unittest.mock import MagicMock, patch, ANY
from unittest.mock import MagicMock, patch
from sqlalchemy.orm import Session
from app.repositories.shopify_shop_repository import ShopifyShopRepository
from app.models.shopify import ShopifyShop
@@ -67,7 +67,7 @@ def test_get_shop(repository, mock_db, mock_shop, sample_shop_data):
shop_id = sample_shop_data["id"]
query_mock = MagicMock()
filter_mock = MagicMock()
first_mock = MagicMock(return_value=mock_shop)
MagicMock(return_value=mock_shop)
mock_db.query.return_value = query_mock
query_mock.filter.return_value = filter_mock
@@ -90,7 +90,7 @@ def test_get_shop_by_domain(repository, mock_db, mock_shop, sample_shop_data):
shop_domain = sample_shop_data["shop_domain"]
query_mock = MagicMock()
filter_mock = MagicMock()
first_mock = MagicMock(return_value=mock_shop)
MagicMock(return_value=mock_shop)
mock_db.query.return_value = query_mock
query_mock.filter.return_value = filter_mock
@@ -113,7 +113,7 @@ def test_get_shop_not_found(repository, mock_db):
shop_id = str(uuid.uuid4())
query_mock = MagicMock()
filter_mock = MagicMock()
first_mock = MagicMock(return_value=None)
MagicMock(return_value=None)
mock_db.query.return_value = query_mock
query_mock.filter.return_value = filter_mock
@@ -140,7 +140,7 @@ def test_get_shops(repository, mock_db):
query_mock = MagicMock()
offset_mock = MagicMock()
limit_mock = MagicMock()
all_mock = MagicMock(return_value=shops)
MagicMock(return_value=shops)
mock_db.query.return_value = query_mock
query_mock.offset.return_value = offset_mock
@@ -176,7 +176,7 @@ def test_get_shops_by_organization(repository, mock_db):
filter_mock = MagicMock()
offset_mock = MagicMock()
limit_mock = MagicMock()
all_mock = MagicMock(return_value=shops)
MagicMock(return_value=shops)
mock_db.query.return_value = query_mock
query_mock.filter.return_value = filter_mock
@@ -16,7 +16,7 @@ limitations under the License.
import pytest
from uuid import uuid4
from unittest.mock import Mock, MagicMock, patch, PropertyMock
from unittest.mock import Mock, MagicMock, patch
from datetime import datetime
from app.repositories.widget_app import WidgetAppRepository
@@ -533,7 +533,6 @@ class TestAPIKeyRegeneration:
self, mock_hash, mock_generate, widget_app_repo, mock_db, mock_widget_app, org_id
):
"""Test API key regeneration."""
old_hash = mock_widget_app.api_key_hash
mock_generate.return_value = "wak_new_key_12345678901234567890123456789012345"
mock_hash.return_value = "$2b$12$newhash"
@@ -627,7 +626,7 @@ class TestOrganizationIsolation:
def test_cannot_access_other_org_app(self, widget_app_repo, mock_db):
"""Test that one org cannot access another org's app."""
org1 = uuid4()
uuid4()
org2 = uuid4()
mock_query = MagicMock()
+1 -1
View File
@@ -28,7 +28,7 @@ from app.models.crm import (
from app.models.customer import Customer
from app.models.lead_capture import LeadCaptureConfig, LeadCaptureResponse
from app.repositories.crm import (
CrmConnectionRepository, CrmCustomerSyncRepository, CrmSyncJobRepository,
CrmConnectionRepository, CrmCustomerSyncRepository,
)
from app.services.crm_sync import (
CrmManualSyncError, enqueue_crm_sync, process_job, sync_customer_to_crm,
+1 -2
View File
@@ -16,9 +16,8 @@ limitations under the License.
import pytest
from firebase_admin import credentials
from unittest.mock import patch, MagicMock, call, create_autospec
from unittest.mock import patch, MagicMock, call
from app.services.firebase import initialize_firebase
import os
@pytest.fixture(autouse=True)
def mock_logger():
@@ -20,7 +20,7 @@ from uuid import uuid4
from app.models.customer import Customer, LeadStage
from app.models.session_to_agent import SessionStatus, SessionToAgent
from app.services.conversation_token import VERIFIED_SCOPE
from app.models.lead_capture import LeadCaptureConfig, LeadCaptureResponse
from app.models.lead_capture import LeadCaptureConfig
from app.services.lead_capture import (
has_captured_lead, record_lead_capture, _valid_email,
)
@@ -19,8 +19,6 @@ import redis
import asyncio
from unittest.mock import patch, MagicMock, AsyncMock
from app.services.socket_rate_limit import socket_rate_limit
from app.core.config import settings
from app.core.socketio import sio
# Test constants
TEST_SID = "test_session_id"
@@ -31,7 +31,7 @@ from app.channels.app_hooks import (
from app.channels.base import InboundMessage
from app.models.channels import ChannelConversation
from app.models.chat_history import ChatHistory
from app.models.session_to_agent import SessionToAgent, SessionStatus
from app.models.session_to_agent import SessionToAgent
from app.repositories.channels import AgentChannelConfigRepository, ChannelAccountRepository
from app.repositories.chat import ChatRepository
from app.services import agent_rooms, app_first_routing, channel_chat
@@ -16,7 +16,6 @@ limitations under the License.
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch
from uuid import uuid4
import pytest
@@ -28,7 +27,6 @@ from app.models.agent import AgentType
from app.models.session_to_agent import SessionToAgent, SessionStatus
from app.repositories.channels import (
ChannelAccountRepository,
ChannelConversationRepository,
AgentChannelConfigRepository,
)
from app.repositories.agent import AgentRepository
@@ -252,7 +252,7 @@ async def test_receipt_reaches_the_handling_agents_inbox(db, account, test_organ
organization_id=test_organization.id, channel="whatsapp")
session.user_id = test_user.id
db.commit()
message = ChatRepository(db).create_message({
ChatRepository(db).create_message({
"message": "Your session is at 5pm", "message_type": "agent",
"session_id": str(session.session_id), "organization_id": str(test_organization.id),
"external_message_id": "wamid.1",
@@ -0,0 +1,207 @@
import asyncio
from contextlib import nullcontext
from types import SimpleNamespace
from unittest.mock import AsyncMock
from uuid import uuid4
import pytest
from app.agents import chat_agent as chat_agent_module
from app.api import widget_chat
from app.models.chat_history import ChatHistory
from app.models.permission import Permission
from app.models.session_to_agent import SessionStatus, SessionToAgent
from app.services import agent_rooms
@pytest.fixture
def conversation(db, test_agent, test_customer, test_organization):
chat = SessionToAgent(
session_id=uuid4(), agent_id=test_agent.id, customer_id=test_customer.id,
organization_id=test_organization.id, status=SessionStatus.OPEN,
)
db.add(chat)
db.commit()
return chat
def grant(db, role, name):
permission = Permission(name=name)
db.add(permission)
role.permissions = [*role.permissions, permission]
db.commit()
def socket_for(user):
return {'user_id': str(user.id), 'organization_id': str(user.organization_id)}
@pytest.mark.asyncio
async def test_watch_room_enforces_org_scope_and_current_permissions(db, test_user, test_role, conversation):
room = f'chat_watch_{conversation.session_id}'
socket = socket_for(test_user)
assert not await agent_rooms.may_watch_chat(db, socket, room)
grant(db, test_role, 'view_unassigned_chats')
assert await agent_rooms.may_watch_chat(db, socket, room)
assert not await agent_rooms.may_watch_chat(db, {**socket, 'organization_id': str(uuid4())}, room)
assert not await agent_rooms.may_watch_chat(db, socket, 'chat_watch_invalid')
# A viewer of the AI queue loses access after another person takes it.
conversation.user_id = uuid4()
db.commit()
assert not await agent_rooms.may_watch_chat(db, socket, room)
test_user.is_active = False
db.commit()
assert not await agent_rooms.may_watch_chat(db, socket, room)
@pytest.mark.asyncio
async def test_assigned_reader_can_watch_own_but_not_another_users_chat(db, test_user, test_role, conversation):
grant(db, test_role, 'view_assigned_chats')
room = f'chat_watch_{conversation.session_id}'
conversation.user_id = test_user.id
db.commit()
assert await agent_rooms.may_watch_chat(db, socket_for(test_user), room)
conversation.user_id = uuid4()
db.commit()
assert not await agent_rooms.may_watch_chat(db, socket_for(test_user), room)
@pytest.mark.asyncio
async def test_socket_join_uses_the_same_scope_check(db, test_user, test_role, conversation, monkeypatch):
grant(db, test_role, 'view_unassigned_chats')
sio = SimpleNamespace(
get_session=AsyncMock(return_value=socket_for(test_user)),
enter_room=AsyncMock(), emit=AsyncMock(),
)
monkeypatch.setattr(widget_chat, 'sio', sio)
monkeypatch.setattr(widget_chat, 'get_db', lambda: iter([db]))
room = f'chat_watch_{conversation.session_id}'
await widget_chat.handle_join_room('sid', {'session_id': room})
sio.enter_room.assert_awaited_once_with('sid', room, namespace='/agent')
conversation.user_id = uuid4()
db.commit()
await widget_chat.handle_join_room('sid', {'session_id': room})
assert sio.enter_room.await_count == 1
assert sio.emit.await_args.args[0] == 'error'
def test_organization_room_is_limited_to_active_chat_viewers(db, test_user, test_role):
room = f'org_chat_updates_{test_user.organization_id}'
assert not agent_rooms.may_join_chat_updates(db, socket_for(test_user), room)
grant(db, test_role, 'view_assigned_chats')
assert agent_rooms.may_join_chat_updates(db, socket_for(test_user), room)
assert not agent_rooms.may_join_chat_updates(db, socket_for(test_user), f'org_chat_updates_{uuid4()}')
@pytest.mark.asyncio
async def test_notification_contains_no_message_or_session_data_and_broker_failure_does_not_break_chat(monkeypatch):
emit = AsyncMock(side_effect=[RuntimeError('broker unavailable'), None])
monkeypatch.setattr(agent_rooms.sio, 'emit', emit)
monkeypatch.setattr(agent_rooms, '_INBOX_COALESCE_SECONDS', 0.001)
await agent_rooms.notify_conversation_changed('org-1', 'session-1')
await agent_rooms._pending_inbox_updates['org-1'].task
assert emit.await_count == 2
assert emit.await_args_list[0].args == ('chat_changed', {})
assert emit.await_args_list[0].kwargs == {'room': 'chat_watch_session-1', 'namespace': '/agent'}
assert emit.await_args_list[1].args == ('inbox_changed', {})
assert emit.await_args_list[1].kwargs == {'room': 'org_chat_updates_org-1', 'namespace': '/agent'}
@pytest.mark.asyncio
async def test_org_broadcast_coalesces_a_burst_and_keeps_changes_during_publish(monkeypatch):
started, release = asyncio.Event(), asyncio.Event()
calls = []
async def emit(event, data, **kwargs):
calls.append(event)
if event == 'inbox_changed' and calls.count('inbox_changed') == 1:
started.set()
await release.wait()
monkeypatch.setattr(agent_rooms.sio, 'emit', emit)
monkeypatch.setattr(agent_rooms, '_INBOX_COALESCE_SECONDS', 0.001)
await asyncio.gather(*(agent_rooms.notify_conversation_changed('org-1', 'session-1') for _ in range(1000)))
await started.wait()
assert calls.count('inbox_changed') == 1
await agent_rooms.notify_conversation_changed('org-1', 'session-1')
task = agent_rooms._pending_inbox_updates['org-1'].task
release.set()
await task
assert calls.count('chat_changed') == 1001
assert calls.count('inbox_changed') == 2
assert 'org-1' not in agent_rooms._pending_inbox_updates
@pytest.mark.asyncio
async def test_customer_and_bot_are_notified_after_each_commit_before_any_model_call(
db, test_agent, test_customer, test_organization, conversation, monkeypatch
):
agent = object.__new__(chat_agent_module.ChatAgent)
agent.agent = SimpleNamespace(session_id=None)
agent._guardrail_ctx = None
agent._guardrail_surface = None
verdict = SimpleNamespace(block=True, reply='synthetic blocked reply', as_attributes=lambda: {})
monkeypatch.setattr(chat_agent_module, 'check_inbound', lambda *args, **kwargs: verdict)
monkeypatch.setattr(chat_agent_module, 'SessionLocal', lambda: nullcontext(db))
seen = []
async def on_change(org_id, session_id):
seen.append([row.message_type for row in db.query(ChatHistory).order_by(ChatHistory.id).all()])
monkeypatch.setattr(agent_rooms, 'notify_conversation_changed', on_change)
response = await agent.get_response(
message='synthetic visitor', session_id=str(conversation.session_id),
org_id=str(test_organization.id), agent_id=str(test_agent.id), customer_id=str(test_customer.id),
)
assert response.message == 'synthetic blocked reply'
assert seen == [['user'], ['user', 'bot']]
@pytest.mark.asyncio
async def test_customer_is_visible_while_normal_ai_response_is_still_running(
db, test_agent, test_customer, test_organization, conversation, monkeypatch
):
agent = object.__new__(chat_agent_module.ChatAgent)
agent.agent = SimpleNamespace(session_id=None)
agent._guardrail_ctx = None
agent._guardrail_surface = None
agent.knowledge_tool = None
agent.agent_data = None
agent.transfer_to_human = False
agent._use_groq_json_tool = False
agent._groq_json_capture = {}
agent._response_model = chat_agent_module.ChatResponse
verdict = SimpleNamespace(block=False, as_attributes=lambda: {})
monkeypatch.setattr(chat_agent_module, 'check_inbound', lambda *args, **kwargs: verdict)
monkeypatch.setattr(chat_agent_module, 'check_output', lambda message, **kwargs: (message, None))
monkeypatch.setattr(chat_agent_module, 'SessionLocal', lambda: nullcontext(db))
response = chat_agent_module.ChatResponse(
message='synthetic answer', transfer_to_human=False, end_chat=False,
request_rating=False, create_ticket=False,
)
monkeypatch.setattr(chat_agent_module, 'parse_response_content', lambda *args: response)
started, release = asyncio.Event(), asyncio.Event()
async def run_model(*args):
started.set()
await release.wait()
return object()
agent._arun = run_model
seen = []
async def on_change(org_id, session_id):
seen.append([row.message_type for row in db.query(ChatHistory).order_by(ChatHistory.id).all()])
monkeypatch.setattr(agent_rooms, 'notify_conversation_changed', on_change)
task = asyncio.create_task(agent.get_response(
message='synthetic question', session_id=str(conversation.session_id),
org_id=str(test_organization.id), agent_id=str(test_agent.id), customer_id=str(test_customer.id),
))
await started.wait()
assert seen == [['user']]
release.set()
result = await task
assert result.message == 'synthetic answer'
assert seen == [['user'], ['user', 'bot']]
@@ -17,8 +17,7 @@ Article-mode import tests: link discovery bounds, HTML→Markdown conversion
(link absolutization, image placeholder/re-host, data: skip), and the
end-to-end job with mocked fetches.
"""
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch
from unittest.mock import MagicMock, patch
from urllib.parse import urlparse
import pytest
@@ -16,9 +16,7 @@ limitations under the License.
import pytest
import base64
import os
import tempfile
from unittest.mock import Mock, patch, AsyncMock, MagicMock
from unittest.mock import Mock, patch, MagicMock
from uuid import uuid4
from app.services.file_upload_service import (
@@ -344,7 +342,7 @@ class TestFileUploadServiceUpload:
async def test_upload_file_local_write_failure(self, sample_file_data):
"""Test handling of local file write failure"""
with patch('app.services.file_upload_service.settings') as mock_settings, \
patch('os.makedirs') as mock_makedirs, \
patch('os.makedirs'), \
patch('builtins.open', create=True) as mock_open:
mock_settings.S3_FILE_STORAGE = False
+1 -5
View File
@@ -14,15 +14,11 @@ See the License for the specific language governing permissions and
limitations under the License.
"""
import pytest
from unittest.mock import patch, MagicMock
from unittest.mock import patch
from app.services.sentiment import (
analyze_sentiment,
compute_session_sentiment,
_analyze_with_keywords,
_analyze_with_textblob,
POSITIVE_KEYWORDS,
NEGATIVE_KEYWORDS,
)
@@ -15,12 +15,11 @@ limitations under the License.
"""
import pytest
import json
from unittest.mock import patch, MagicMock, ANY
from unittest.mock import patch, MagicMock
from app.services.shopify import ShopifyService
from app.models.shopify import ShopifyShop
from sqlalchemy.orm import Session
from uuid import UUID, uuid4
from uuid import UUID
@pytest.fixture
def mock_db():
@@ -18,7 +18,7 @@ import pytest
import jwt
import time
import uuid
from unittest.mock import MagicMock, patch, AsyncMock
from unittest.mock import MagicMock, patch
from fastapi import HTTPException, Request
from sqlalchemy.orm import Session
+1 -1
View File
@@ -23,7 +23,7 @@ import pytest
from app.models.chat_history import ChatHistory
from app.models.rating import Rating
from app.models.session_to_agent import SessionStatus, SessionToAgent
from app.models.ticket import Ticket, TicketPriority, TicketSource, TicketStatus
from app.models.ticket import Ticket, TicketPriority, TicketSource
from app.models.ticket_activity import TicketActivityType, TicketActorType
from app.repositories.rating import RatingRepository
from app.services.ticket import TicketService
@@ -22,9 +22,8 @@ from datetime import datetime, timedelta, timezone
from types import SimpleNamespace
from unittest.mock import patch
import pytest
from app.models.investigation import InvestigationRun, InvestigationRunType, InvestigationTrigger
from app.models.investigation import InvestigationRun, InvestigationRunType
from app.models.ticket import Ticket, TicketPriority, TicketSource, TicketStatus
from app.models.ticket_activity import TicketActivity
from app.repositories.ticket import InvestigationRepository
+2 -3
View File
@@ -24,8 +24,8 @@ from uuid import uuid4
import pytest
from app.api.ticket_webhooks import _parse_alert
from app.models.investigation import ProposalStatus, TicketProposal
from app.models.ticket import Ticket, TicketPriority, TicketSource, TicketStatus
from app.models.investigation import ProposalStatus
from app.models.ticket import TicketSource, TicketStatus
from app.models.ticket_activity import TicketActivity, TicketActivityType
from app.services.ticket import TicketService
from app.services.ticket_jira import should_escalate
@@ -214,7 +214,6 @@ class TestRunChaining:
enqueueing the next one in the same session must work — the guard
has to flush before checking for active runs."""
from app.models.investigation import (
InvestigationRun,
InvestigationRunStatus,
InvestigationRunType,
)
+30 -1
View File
@@ -15,6 +15,7 @@ limitations under the License.
"""
from unittest.mock import patch
from datetime import datetime, timezone
from uuid import uuid4
import pytest
@@ -28,7 +29,7 @@ from app.models.ticket import (
TicketSource,
TicketStatus,
)
from app.models.ticket_activity import TicketActivity, TicketActivityType, TicketActorType
from app.models.ticket_activity import TicketActivityType, TicketActorType
from app.services.ticket import NATIVE_INTEGRATION_TYPE, TicketService, render_customer_template
@@ -315,6 +316,34 @@ class TestCustomerEmailPath:
row = db.query(ChatHistory).filter_by(session_id=test_session.session_id).one()
assert deliver.await_args.kwargs["message_id"] == row.id
assert row.attributes["delivery_status"] == "window_expired"
assert ticket.first_response_at is None
assert TicketActivityType.CUSTOMER_NOTIFIED.value not in [
activity.activity_type
for activity in service.activity_repo.list_for_ticket(ticket.id)
]
@pytest.mark.asyncio
async def test_refused_channel_reply_preserves_an_existing_first_response(
self, service, db, test_organization, test_session
):
test_session.channel = "whatsapp"
db.commit()
ticket = make_ticket(service, db, test_organization, session_id=test_session.session_id)
prior_response = datetime(2026, 10, 1, tzinfo=timezone.utc)
ticket.first_response_at = prior_response
db.commit()
db.refresh(ticket)
prior_response = ticket.first_response_at
refused = DeliveryResult(ok=False, reason="window_expired", can_template=True)
with patch("app.services.ticket.deliver_to_customer", return_value=refused):
await service.send_customer_message(ticket, "Your refund is on its way")
assert ticket.first_response_at == prior_response
assert TicketActivityType.CUSTOMER_NOTIFIED.value not in [
activity.activity_type
for activity in service.activity_repo.list_for_ticket(ticket.id)
]
@pytest.mark.asyncio
async def test_a_send_that_raises_is_marked_undelivered(
@@ -19,7 +19,6 @@ from unittest.mock import Mock, patch, AsyncMock, MagicMock
from uuid import uuid4
from app.services.workflow_execution import WorkflowExecutionService
from app.models.workflow_node import WorkflowNode, NodeType, ExitCondition
from app.agents.chat_agent import ChatResponse
from app.models.workflow import Workflow, WorkflowStatus
+3 -3
View File
@@ -16,7 +16,7 @@ limitations under the License.
import pytest
from unittest.mock import Mock, MagicMock, patch
from uuid import uuid4, UUID
from uuid import uuid4
from datetime import datetime
from app.services.workflow_node import WorkflowNodeService, sanitize_utf8_text
@@ -587,7 +587,7 @@ class TestWorkflowNodeService:
}
]
result = workflow_node_service.replace_workflow_nodes_and_connections(
workflow_node_service.replace_workflow_nodes_and_connections(
test_workflow.id, nodes_data, [], test_organization_id
)
@@ -635,7 +635,7 @@ class TestWorkflowNodeService:
"description": "Updated\udcac description\udd16"
}
result = workflow_node_service.update_single_node(
workflow_node_service.update_single_node(
test_workflow_node.workflow_id,
test_workflow_node.id,
update_data,
@@ -16,9 +16,9 @@ limitations under the License.
import pytest
import uuid
from unittest.mock import MagicMock, patch
from unittest.mock import MagicMock
from app.services.workflow import WorkflowService
from app.models.workflow import Workflow, WorkflowStatus
from app.models.workflow import WorkflowStatus
from app.models.schemas.workflow import WorkflowCreate
@@ -0,0 +1,153 @@
"""
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 ast
import builtins
from contextlib import redirect_stdout
from dataclasses import dataclass
from io import StringIO
from pathlib import Path
from types import ModuleType
from typing import Literal
import pytest
BACKEND_ROOT = Path(__file__).resolve().parents[1]
@dataclass(frozen=True)
class EnterpriseProbe:
source: str
module: str
symbol: str
occurrence: int = 0
PROBES = (
EnterpriseProbe(
"app/api/agent.py",
"app.enterprise.repositories.subscription",
"SubscriptionRepository",
occurrence=0,
),
EnterpriseProbe(
"app/api/agent.py",
"app.enterprise.repositories.subscription",
"SubscriptionRepository",
occurrence=1,
),
EnterpriseProbe(
"app/knowledge/knowledge_base.py",
"app.enterprise.repositories.subscription",
"SubscriptionRepository",
),
EnterpriseProbe("app/main.py", "app.enterprise.models", "OTP"),
)
def _probe_node(probe: EnterpriseProbe) -> ast.Try:
source_path = BACKEND_ROOT / probe.source
tree = ast.parse(source_path.read_text(encoding="utf-8"), filename=str(source_path))
matches = []
for node in ast.walk(tree):
if not isinstance(node, ast.Try):
continue
imports_symbol = any(
isinstance(statement, ast.ImportFrom)
and statement.module == probe.module
and any(imported.name == probe.symbol for imported in statement.names)
for statement in node.body
)
if imports_symbol:
matches.append(node)
matches.sort(key=lambda node: node.lineno)
assert len(matches) > probe.occurrence, (
f"{probe.source} no longer contains the requested optional import probe"
)
return matches[probe.occurrence]
def _execute_probe(
probe: EnterpriseProbe,
outcome: Literal["missing_module", "missing_symbol", "available", "internal_attribute_error"],
) -> tuple[dict[str, object], str]:
probe_node = _probe_node(probe)
source_path = BACKEND_ROOT / probe.source
snippet = ast.Module(body=[probe_node], type_ignores=[])
code = compile(snippet, str(source_path), "exec")
available_target = type("SyntheticEnterpriseTarget", (), {})
def fake_import(
name: str,
globals: dict[str, object] | None = None,
locals: dict[str, object] | None = None,
fromlist: tuple[str, ...] = (),
level: int = 0,
) -> object:
del globals, locals, fromlist, level
if name.startswith("app.enterprise"):
if outcome == "missing_module":
raise ModuleNotFoundError(name)
if outcome == "internal_attribute_error":
raise AttributeError("synthetic enterprise import-time failure")
module = ModuleType(name)
if outcome == "available":
setattr(module, probe.symbol, available_target)
return module
return builtins.__import__(name)
namespace: dict[str, object] = {
"__builtins__": {**vars(builtins), "__import__": fake_import},
"__name__": "enterprise_probe_contract_test",
}
output = StringIO()
with redirect_stdout(output):
exec(code, namespace)
return namespace, output.getvalue()
@pytest.mark.parametrize("probe", PROBES, ids=lambda probe: f"{probe.source}:{probe.occurrence}")
@pytest.mark.parametrize("outcome", ["missing_module", "missing_symbol"])
def test_optional_probe_falls_back_when_module_or_symbol_is_missing(
probe: EnterpriseProbe,
outcome: Literal["missing_module", "missing_symbol"],
) -> None:
namespace, output = _execute_probe(probe, outcome)
if "HAS_ENTERPRISE" in namespace:
assert namespace["HAS_ENTERPRISE"] is False
else:
assert output == "Enterprise models not available\n"
@pytest.mark.parametrize("probe", PROBES, ids=lambda probe: f"{probe.source}:{probe.occurrence}")
def test_optional_probe_accepts_a_valid_enterprise_symbol(probe: EnterpriseProbe) -> None:
namespace, output = _execute_probe(probe, "available")
assert namespace[probe.symbol].__name__ == "SyntheticEnterpriseTarget"
if "HAS_ENTERPRISE" in namespace:
assert namespace["HAS_ENTERPRISE"] is True
else:
assert output == ""
@pytest.mark.parametrize("probe", PROBES, ids=lambda probe: f"{probe.source}:{probe.occurrence}")
def test_import_time_attribute_error_is_not_mistaken_for_absent_enterprise(
probe: EnterpriseProbe,
) -> None:
with pytest.raises(AttributeError, match="synthetic enterprise import-time failure"):
_execute_probe(probe, "internal_attribute_error")
+14 -26
View File
@@ -16,14 +16,8 @@ limitations under the License.
import pytest
import json
import os
import requests
from unittest.mock import patch, MagicMock, AsyncMock
from unittest.mock import patch, MagicMock
from app.tools.jira_toolkit import JiraTools
from app.models.jira import AgentJiraConfig, JiraToken
from app.models.organization import Organization
from app.models.agent import Agent
from app.models.session_to_agent import SessionToAgent
from uuid import UUID
from datetime import timedelta
@@ -77,7 +71,9 @@ def mock_session_repo():
@pytest.fixture
def mock_jira_service():
service = AsyncMock()
# JiraTools calls validate_token synchronously; an AsyncMock would hand it
# a coroutine object and silently treat that as a truthy validation result.
service = MagicMock()
service.create_issue.return_value = {
"key": "TEST-123",
"status": {"name": "Open"},
@@ -194,7 +190,7 @@ def test_check_existing_ticket_none(mock_db, mock_jira_service):
# Arrange
with patch("app.tools.jira_toolkit.JiraService", return_value=mock_jira_service):
with patch("app.tools.jira_toolkit.SessionToAgentRepository", return_value=MagicMock()) as mock_session_repo_class:
with patch("asyncio.run", side_effect=lambda x: x) as mock_asyncio_run:
with patch("asyncio.run", side_effect=lambda x: x):
mock_session_repo = mock_session_repo_class.return_value
mock_session_repo.get_session.return_value = None
@@ -205,9 +201,6 @@ def test_check_existing_ticket_none(mock_db, mock_jira_service):
)
jira_tools.db = mock_db
# Mock the _check_existing_ticket method
jira_tools._check_existing_ticket = AsyncMock(return_value={"exists": False, "message": "No ticket found for this session"})
# Act
result_str = jira_tools.check_existing_ticket()
@@ -222,7 +215,7 @@ def test_check_existing_ticket_exists(mock_db, mock_jira_service):
# Arrange
with patch("app.tools.jira_toolkit.JiraService", return_value=mock_jira_service):
with patch("app.tools.jira_toolkit.SessionToAgentRepository", return_value=MagicMock()) as mock_session_repo_class:
with patch("asyncio.run", side_effect=lambda x: x) as mock_asyncio_run:
with patch("asyncio.run", side_effect=lambda x: x):
mock_session_repo = mock_session_repo_class.return_value
mock_session = MagicMock()
mock_session.ticket_id = "TEST-123"
@@ -330,7 +323,7 @@ def test_create_jira_ticket_disabled_integration(mock_db, mock_jira_service):
# Arrange
with patch("app.tools.jira_toolkit.JiraService", return_value=mock_jira_service):
with patch("app.tools.jira_toolkit.SessionToAgentRepository", return_value=MagicMock()) as mock_session_repo_class:
with patch("asyncio.run", side_effect=lambda x: x) as mock_asyncio_run:
with patch("asyncio.run", side_effect=lambda x: x):
with patch("app.tools.jira_toolkit.SessionLocal") as mock_session_local:
mock_session_repo = mock_session_repo_class.return_value
mock_session_repo.get_session.return_value = None
@@ -393,7 +386,7 @@ def test_create_jira_ticket_no_token(mock_db, mock_jira_service):
# Arrange
with patch("app.tools.jira_toolkit.JiraService", return_value=mock_jira_service):
with patch("app.tools.jira_toolkit.SessionToAgentRepository", return_value=MagicMock()) as mock_session_repo_class:
with patch("asyncio.run", side_effect=lambda x: x) as mock_asyncio_run:
with patch("asyncio.run", side_effect=lambda x: x):
with patch("app.tools.jira_toolkit.SessionLocal") as mock_session_local:
mock_session_repo = mock_session_repo_class.return_value
mock_session_repo.get_session.return_value = None
@@ -457,7 +450,7 @@ def test_get_ticket_status_api_error(mock_db, mock_jira_service):
# Arrange
with patch("app.tools.jira_toolkit.JiraService", return_value=mock_jira_service):
with patch("app.tools.jira_toolkit.SessionToAgentRepository", return_value=MagicMock()) as mock_session_repo_class:
with patch("asyncio.run", side_effect=lambda x: x) as mock_asyncio_run:
with patch("asyncio.run", side_effect=lambda x: x):
with patch("app.tools.jira_toolkit.SessionLocal") as mock_session_local:
mock_session_repo = mock_session_repo_class.return_value
mock_session = MagicMock()
@@ -620,7 +613,8 @@ async def test_create_jira_ticket_token_refresh_failure(mock_db, mock_jira_servi
with patch("app.tools.jira_toolkit.JiraService", return_value=mock_jira_service):
with patch("app.tools.jira_toolkit.SessionToAgentRepository", return_value=MagicMock()) as mock_session_repo_class:
with patch("requests.post") as mock_post:
with patch("os.getenv") as mock_getenv:
with patch("app.tools.jira_toolkit.settings.JIRA_CLIENT_ID", "test_client_id"), \
patch("app.tools.jira_toolkit.settings.JIRA_CLIENT_SECRET", "test_client_secret"):
with patch("datetime.datetime") as mock_datetime:
with patch("datetime.timedelta", timedelta):
with patch("app.tools.jira_toolkit.SessionLocal") as mock_session_local:
@@ -628,12 +622,6 @@ async def test_create_jira_ticket_token_refresh_failure(mock_db, mock_jira_servi
mock_now = MagicMock()
mock_datetime.utcnow.return_value = mock_now
# Setup environment variables
mock_getenv.side_effect = lambda x: {
"JIRA_CLIENT_ID": "test_client_id",
"JIRA_CLIENT_SECRET": "test_client_secret"
}.get(x)
# Setup SessionLocal to return our test db when used as context manager
mock_session_local.return_value.__enter__.return_value = mock_db
mock_session_local.return_value.__exit__.return_value = None
@@ -696,7 +684,7 @@ async def test_create_jira_ticket_token_refresh_failure(mock_db, mock_jira_servi
# Assert
assert result["success"] is False
assert "Failed to create Jira ticket: Invalid refresh token" in result["message"]
assert "Failed to refresh Jira token: Invalid refresh token" in result["message"]
assert mock_post.call_count == 1 # Only the failed token refresh attempt
assert not mock_db.commit.called # Token should not be updated in database
@@ -1295,7 +1283,7 @@ async def test_create_jira_ticket_update_description_error(mock_db, mock_jira_se
with patch("asyncio.run", side_effect=lambda x: x):
with patch("datetime.datetime") as mock_datetime:
with patch("datetime.timedelta", timedelta):
with patch("requests.post") as mock_post:
with patch("requests.post"):
with patch("requests.get") as mock_get:
with patch("requests.put") as mock_put:
with patch("app.tools.jira_toolkit.SessionLocal") as mock_session_local:
@@ -1401,4 +1389,4 @@ async def test_create_jira_ticket_update_description_error(mock_db, mock_jira_se
# Assert
assert result["success"] is False
assert "Error updating Jira ticket" in result["message"]
assert mock_put.call_count == 0 # No update should be attempted
assert mock_put.call_count == 0 # No update should be attempted
@@ -108,7 +108,6 @@ def knowledge_search_tool(mock_db, mock_knowledge, mock_vector_db, mock_agent_kn
tool.agent_knowledge = mock_agent_knowledge
# Create a patched version of search_knowledge_base that doesn't depend on database
original_search = tool.search_knowledge_base
def patched_search_knowledge_base(query):
try:
@@ -512,7 +511,7 @@ def test_real_search_knowledge_base_with_source_filter():
mock_agent_knowledge_class.return_value = mock_agent_knowledge
# Execute search
result = tool.search_knowledge_base("test query")
tool.search_knowledge_base("test query")
# Verify source filter was applied
search_call = mock_agent_knowledge.search.call_args
+1 -5
View File
@@ -19,11 +19,7 @@ import json
from unittest.mock import patch, MagicMock
from app.tools.shopify_toolkit import ShopifyTools
from app.tools.shopify_order_access import ORDER_NOT_FOUND
from app.models.shopify import AgentShopifyConfig, ShopifyShop
from app.models.organization import Organization
from app.models.agent import Agent
from uuid import UUID, uuid4
import requests
from uuid import UUID
@pytest.fixture
def mock_db():
+14 -8
View File
@@ -15,12 +15,9 @@ limitations under the License.
"""
import pytest
import sys
from unittest.mock import patch, MagicMock, AsyncMock
from agno.agent import Agent
from app.utils import agno_utils
from fastapi import HTTPException
import importlib
class TestCreateModel:
@@ -179,9 +176,17 @@ class TestCreateModel:
def test_create_model_google(self):
"""Test creating Google Gemini model"""
with patch("agno.models.google.Gemini") as mock_gemini:
mock_model = MagicMock()
mock_gemini.return_value = mock_model
import sys
from types import ModuleType
import agno.models
google_module = ModuleType("agno.models.google")
google_module.Gemini = MagicMock()
mock_model = MagicMock()
google_module.Gemini.return_value = mock_model
with patch.dict(sys.modules, {"agno.models.google": google_module}), \
patch.object(agno.models, "google", google_module, create=True):
result = agno_utils.create_model(
model_type="GOOGLE",
@@ -189,12 +194,13 @@ class TestCreateModel:
model_name="gemini-pro"
)
mock_gemini.assert_called_once_with(
google_module.Gemini.assert_called_once_with(
api_key="test-api-key",
id="gemini-pro",
max_output_tokens=1000 # Gemini's agno model uses max_output_tokens
)
assert result == mock_model
assert result == mock_model
def test_create_model_googlevertex(self):
"""Test creating Google Vertex AI model (should trigger import error)"""
@@ -573,4 +579,4 @@ class TestEdgeCases:
result = await agno_utils.test_model_api_key("", "", "")
mock_create_model.assert_called_once_with("", "", "", base_url=None)
assert result is True
assert result is True
@@ -18,7 +18,7 @@ from unittest.mock import patch
import pytest
from app.agents.guardrail_policy import CANARY_STRINGS, looks_like_scope_refusal
from app.agents.guardrail_policy import CANARY_STRINGS
from app.utils.guardrail_runtime import (
BLOCK_REPLY,
LEAK_REPLY,
@@ -14,7 +14,6 @@ See the License for the specific language governing permissions and
limitations under the License.
"""
import pytest
from unittest.mock import MagicMock
from app.utils.response_parser import (
GENERIC_ERROR_MESSAGE,
@@ -21,7 +21,6 @@ from app.models.knowledge_queue import QueueStatus
from app.models.notification import NotificationType
from uuid import uuid4
import pytest_asyncio
from datetime import datetime
@pytest.fixture
def mock_db():