fix: improve maintenance and synchronize live conversations
This commit is contained in:
1 parent
bb541bd212
commit
ef86a5d3af
297 files changed
+2983
-1274
No files matched your search
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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""):
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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, \
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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")
|
||||
@@ -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
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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():
|
||||
|
||||
Reference in new issue
Block a user