test: 补充知识处理、检索与 Agent 回归用例

覆盖切块、向量检索、工具调用和历史预算;模型相关用例使用桩。
This commit is contained in:
WangShen007 committed 2026-10-04 20:55:00 +08:00
1 parent 362d79a5c2
commit f4d8019829
123 files changed
+29005

No files matched your search

+16
View File
@@ -0,0 +1,16 @@
"""
Copyright 2024-2026 ChatterMate
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.
"""
@@ -0,0 +1,106 @@
"""
Copyright 2024-2026 ChatterMate
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.
"""
"""
ChatterMate - Guardrailed DB toolkit: the system-prompt table catalogue.
The investigator's first move used to be list_database_tables, spending one of
its max_tool_calls_per_run just to learn which tables exist. The catalogue is
now front-loaded into the toolkit's instructions (#318).
"""
import pytest
from app.services.db_connector_service import DBConnectorConfig
from app.tools.guardrailed_db_toolkit import MAX_CATALOGUE_CHARS, GuardrailedDBTools
def _config(name="prod", tables=None, row_scope=None, engine="postgresql"):
return DBConnectorConfig(
id=None, organization_id=None, name=name, engine=engine,
host="h", port=5432, database="d", username="u", password="p",
allowed_tables=tables if tables is not None else ["public.orders", "public.users"],
row_scope=row_scope or {},
)
def test_catalogue_names_each_connector_its_engine_and_its_tables():
toolkit = GuardrailedDBTools([_config()])
assert toolkit.add_instructions is True
assert "prod [postgresql]" in toolkit.instructions
assert "public.orders" in toolkit.instructions
assert "public.users" in toolkit.instructions
def test_catalogue_carries_the_row_scope_annotation():
"""The agent must not waste a call trying to widen a scoped table."""
toolkit = GuardrailedDBTools(
[_config(row_scope={"public.orders": "customer_email"})]
)
assert "auto-scoped to this ticket's customer by customer_email" in toolkit.instructions
@pytest.mark.asyncio
async def test_the_tool_still_returns_exactly_what_the_prompt_carries():
"""list_database_tables is a registered tool the model already calls; the
sync/async split must not have changed a byte of its output."""
toolkit = GuardrailedDBTools([_config()])
catalogue = toolkit._source_catalogue()
assert await toolkit.list_database_tables() == catalogue
assert catalogue in toolkit.instructions
def test_no_connectors_means_no_instructions():
"""The toolkit is only built when there is at least one config, but an
empty catalogue must never reach the prompt as noise."""
toolkit = GuardrailedDBTools([])
assert toolkit.instructions is None
assert toolkit.add_instructions is False
def test_a_huge_allowlist_is_capped_on_a_line_boundary():
"""A mid-name cut would leave a half-written table the model would try to
query, wasting exactly the call this catalogue is meant to save."""
toolkit = GuardrailedDBTools([
_config(name="a", tables=[f"public.table_{i}" for i in range(400)]),
_config(name="b", tables=["public.orders"]),
])
assert len(toolkit.instructions) < MAX_CATALOGUE_CHARS + 400
assert "list truncated" in toolkit.instructions
# Every surviving line is a whole one, never a severed table name.
for line in toolkit.instructions.splitlines():
assert not line.endswith("public.table_") and not line.endswith("public.tabl")
def test_a_broken_row_scope_costs_the_catalogue_not_the_toolkit():
"""_build_db_tools turns any constructor exception into "no database tools
at all" — so a bad row must degrade to one extra tool call instead."""
broken = _config()
broken.row_scope = "not-a-dict"
toolkit = GuardrailedDBTools([broken])
assert toolkit.instructions is None
assert toolkit.add_instructions is False
# The tools themselves are still registered and usable.
assert {"list_database_tables", "describe_database_table", "query_database"} <= set(
toolkit.functions
)
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,811 @@
"""
Copyright 2024-2026 ChatterMate
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
"""
import pytest
from unittest.mock import patch, MagicMock
from app.tools.knowledge_search_byagent import KnowledgeSearchByAgent
from app.models.knowledge import Knowledge, SourceType
from uuid import uuid4
from agno.knowledge.agent import AgentKnowledge
from agno.vectordb.pgvector import PgVector, SearchType
import os
@pytest.fixture
def mock_db():
"""Create a mock database session"""
db = MagicMock()
return db
@pytest.fixture
def mock_knowledge():
"""Create a mock knowledge entry"""
knowledge_id = uuid4()
org_id = uuid4()
return Knowledge(
id=knowledge_id,
organization_id=org_id,
source="test_document.pdf",
source_type=SourceType.FILE,
table_name="test_table",
schema="test_schema"
)
@pytest.fixture
def mock_vector_db():
"""Create a mock vector database"""
vector_db = MagicMock(spec=PgVector)
vector_db.table_name = "test_table"
vector_db.schema = "test_schema"
vector_db.search_type = SearchType.hybrid
# Mock the search method to avoid OpenAI API key requirement
vector_db.search.return_value = []
return vector_db
@pytest.fixture
def mock_agent_knowledge(mock_vector_db):
"""Create a mock agent knowledge"""
agent_knowledge = MagicMock(spec=AgentKnowledge)
# Mock the search method to return an empty list by default
agent_knowledge.search.return_value = []
# Set the vector_db attribute
agent_knowledge.vector_db = mock_vector_db
return agent_knowledge
@pytest.fixture
def mock_ai_config():
"""Create a mock AI config"""
ai_config = MagicMock()
ai_config.encrypted_api_key = "test-key"
return ai_config
@pytest.fixture
def knowledge_search_tool(mock_db, mock_knowledge, mock_vector_db, mock_agent_knowledge, mock_ai_config):
"""Create a KnowledgeSearchByAgent instance with mocked dependencies"""
agent_id = str(uuid4())
org_id = uuid4()
# Set up environment variables first
os.environ['OPENAI_API_KEY'] = 'test-key'
# Create a real instance but with mocked dependencies
with patch('app.tools.knowledge_search_byagent.SessionLocal') as mock_session_local, \
patch('app.tools.knowledge_search_byagent.PgVector') as mock_pg_vector, \
patch('app.tools.knowledge_search_byagent.AgentKnowledge') as mock_agent_knowledge_class, \
patch('app.tools.knowledge_search_byagent.KnowledgeRepository') as mock_knowledge_repo_class:
# Configure mocks
mock_session_local.return_value.__enter__.return_value = mock_db
mock_pg_vector.return_value = mock_vector_db
mock_agent_knowledge_class.return_value = mock_agent_knowledge
# Create a mock for KnowledgeRepository
mock_knowledge_repo = MagicMock()
mock_knowledge_repo_class.return_value = mock_knowledge_repo
mock_knowledge_repo.get_by_agent.return_value = [mock_knowledge]
# Create the tool instance
tool = KnowledgeSearchByAgent(agent_id=agent_id, org_id=org_id)
# Store the mock objects for later access in tests
tool._mock_knowledge_repo = mock_knowledge_repo
# Set the agent_knowledge directly
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:
# Get knowledge sources linked to this agent
knowledge_sources = tool._mock_knowledge_repo.get_by_agent(tool.agent_id)
if not knowledge_sources:
return "No knowledge sources available for this agent."
# Search with agent_id filter
documents = tool.agent_knowledge.search(
query=query,
num_documents=5,
filters={"agent_id": [str(tool.agent_id)]}
)
search_results = []
for doc in documents:
if doc.content is None:
doc.content = "" # Convert None to empty string
# Find the source type from knowledge sources
source_type = next(
(source.source_type.value.lower() for source in knowledge_sources if source.source == doc.name),
'file' # Default to 'file' for tests
)
search_results.append({
'content': doc.content,
'source_type': source_type,
'name': doc.name or 'Untitled',
'similarity': doc.score if hasattr(doc, 'score') else 0.0
})
if not search_results:
return "No relevant information found in the knowledge base."
# Sort by similarity and format results
search_results.sort(key=lambda x: x['similarity'], reverse=True)
# Format all results for testing
formatted_results = []
for result in search_results:
percent = int(result['similarity'] * 100)
formatted_results.append(
f"[{result['source_type'].upper()} - {result['name']}] {result['content']}\nRelevance: {percent}%"
)
return "\n\n".join(formatted_results)
except Exception as e:
return f"Error searching knowledge base: {str(e)}"
# Replace the search method with our patched version
tool.search_knowledge_base = patched_search_knowledge_base
return tool
def test_search_knowledge_base_success(knowledge_search_tool):
"""Test successful knowledge base search"""
# Setup
query = "test query"
# Create mock document
mock_doc = MagicMock()
mock_doc.name = "test_document.pdf" # Set name as a string
mock_doc.content = "Test content"
mock_doc.score = 0.95
# Configure mocks
knowledge_search_tool.agent_knowledge.search.return_value = [mock_doc]
# Mock the knowledge repository's get_by_agent method
mock_knowledge = MagicMock()
mock_knowledge.source = "test_document.pdf"
mock_knowledge.source_type = SourceType.FILE # Use the actual enum
# Get the knowledge_repo from the fixture
mock_knowledge_repo = knowledge_search_tool._mock_knowledge_repo
mock_knowledge_repo.get_by_agent.return_value = [mock_knowledge]
# Execute
result = knowledge_search_tool.search_knowledge_base(query)
# Assert
assert "[FILE - test_document.pdf] Test content" in result
assert "Relevance: 95%" in result
def test_search_knowledge_base_no_sources(knowledge_search_tool):
"""Test knowledge base search with no sources"""
# Setup
# Mock the knowledge repository's get_by_agent method to return empty list
mock_knowledge_repo = knowledge_search_tool._mock_knowledge_repo
mock_knowledge_repo.get_by_agent.return_value = []
# Execute
result = knowledge_search_tool.search_knowledge_base("test query")
# Assert
assert result == "No knowledge sources available for this agent."
def test_search_knowledge_base_no_results(knowledge_search_tool):
"""Test knowledge base search with no search results"""
# Setup
knowledge_search_tool.agent_knowledge.search.return_value = []
# Mock the knowledge repository's get_by_agent method
mock_knowledge = MagicMock()
mock_knowledge.source = "test_document.pdf"
mock_knowledge.source_type = "FILE"
# Get the knowledge_repo from the fixture
mock_knowledge_repo = knowledge_search_tool._mock_knowledge_repo
mock_knowledge_repo.get_by_agent.return_value = [mock_knowledge]
# Execute
result = knowledge_search_tool.search_knowledge_base("test query")
# Assert
assert result == "No relevant information found in the knowledge base."
def test_search_knowledge_base_multiple_results(knowledge_search_tool):
"""Test knowledge base search with multiple results"""
# Setup
query = "test query"
# Create mock documents with string values for name
mock_docs = []
for i, (name, content, score) in enumerate([
("doc1.pdf", "Content 1", 0.95),
("doc2.pdf", "Content 2", 0.85),
("doc3.pdf", "Content 3", 0.75),
("doc4.pdf", "Content 4", 0.65)
]):
doc = MagicMock()
doc.name = name # Set name as a string
doc.content = content
doc.score = score
mock_docs.append(doc)
knowledge_search_tool.agent_knowledge.search.return_value = mock_docs
# Set up mock knowledge sources to match document names
mock_knowledge_sources = [
Knowledge(
id=uuid4(),
organization_id=uuid4(),
source="doc1.pdf",
source_type=SourceType.FILE,
table_name="test_table",
schema="test_schema"
),
Knowledge(
id=uuid4(),
organization_id=uuid4(),
source="doc2.pdf",
source_type=SourceType.FILE,
table_name="test_table",
schema="test_schema"
),
Knowledge(
id=uuid4(),
organization_id=uuid4(),
source="doc3.pdf",
source_type=SourceType.FILE,
table_name="test_table",
schema="test_schema"
),
Knowledge(
id=uuid4(),
organization_id=uuid4(),
source="doc4.pdf",
source_type=SourceType.FILE,
table_name="test_table",
schema="test_schema"
)
]
# Get the knowledge_repo from the fixture
mock_knowledge_repo = knowledge_search_tool._mock_knowledge_repo
mock_knowledge_repo.get_by_agent.return_value = mock_knowledge_sources
# Execute
result = knowledge_search_tool.search_knowledge_base(query)
# Assert
assert "[FILE - doc1.pdf] Content 1" in result
assert "[FILE - doc2.pdf] Content 2" in result
assert "[FILE - doc3.pdf] Content 3" in result
assert "[FILE - doc4.pdf] Content 4" in result
assert "Relevance: 95%" in result
assert "Relevance: 85%" in result
assert "Relevance: 75%" in result
assert "Relevance: 65%" in result
def test_search_knowledge_base_empty_content(knowledge_search_tool):
"""Test knowledge base search with empty content in results"""
# Setup
# Create mock documents with string values for name
mock_docs = []
for name, content, score in [
("doc1.pdf", "", 0.95), # Empty content
("doc2.pdf", None, 0.85), # None content
("doc3.pdf", "Valid content", 0.75)
]:
doc = MagicMock()
doc.name = name # Set name as a string
doc.content = content
doc.score = score
mock_docs.append(doc)
knowledge_search_tool.agent_knowledge.search.return_value = mock_docs
# Set up mock knowledge sources to match document names
mock_knowledge_sources = [
Knowledge(
id=uuid4(),
organization_id=uuid4(),
source="doc1.pdf",
source_type=SourceType.FILE,
table_name="test_table",
schema="test_schema"
),
Knowledge(
id=uuid4(),
organization_id=uuid4(),
source="doc2.pdf",
source_type=SourceType.FILE,
table_name="test_table",
schema="test_schema"
),
Knowledge(
id=uuid4(),
organization_id=uuid4(),
source="doc3.pdf",
source_type=SourceType.FILE,
table_name="test_table",
schema="test_schema"
)
]
# Get the knowledge_repo from the fixture
mock_knowledge_repo = knowledge_search_tool._mock_knowledge_repo
mock_knowledge_repo.get_by_agent.return_value = mock_knowledge_sources
# Execute
result = knowledge_search_tool.search_knowledge_base("test query")
# Assert
assert "[FILE - doc1.pdf] " in result # Empty content should still be included
assert "[FILE - doc2.pdf] " in result # None content should be included as empty
assert "[FILE - doc3.pdf] Valid content" in result
assert "Relevance: 95%" in result
assert "Relevance: 85%" in result
assert "Relevance: 75%" in result
def test_search_knowledge_base_error(knowledge_search_tool):
"""Test knowledge base search with an error during search"""
# Setup
query = "test query"
# Mock the knowledge repository's get_by_agent method
mock_knowledge = MagicMock()
mock_knowledge.source = "test_document.pdf"
mock_knowledge.source_type = SourceType.FILE # Use the actual enum
# Get the knowledge_repo from the fixture
mock_knowledge_repo = knowledge_search_tool._mock_knowledge_repo
mock_knowledge_repo.get_by_agent.return_value = [mock_knowledge]
# Make the search method raise an exception
knowledge_search_tool.agent_knowledge.search.side_effect = Exception("Search error")
# Execute
result = knowledge_search_tool.search_knowledge_base(query)
# Assert
assert "Error searching knowledge base" in result
# Tests for real implementation
def test_real_search_knowledge_base_with_results():
"""Test real search_knowledge_base implementation with mocked dependencies"""
agent_id = str(uuid4())
org_id = uuid4()
with patch('app.tools.knowledge_search_byagent.SessionLocal') as mock_session_local, \
patch('app.tools.knowledge_search_byagent.KnowledgeRepository') as mock_knowledge_repo_class, \
patch('app.tools.knowledge_search_byagent.PgVector') as mock_pg_vector, \
patch('app.tools.knowledge_search_byagent.AgentKnowledge') as mock_agent_knowledge_class:
# Setup mocks for initialization
mock_db = MagicMock()
mock_session_local.return_value.__enter__.return_value = mock_db
mock_session_local.return_value.__exit__.return_value = None
mock_ai_config = MagicMock()
mock_ai_config.encrypted_api_key = "encrypted_key"
# Create the tool
tool = KnowledgeSearchByAgent(agent_id=agent_id, org_id=org_id)
# Setup mocks for search
mock_knowledge = MagicMock()
mock_knowledge.source = "test.pdf"
mock_knowledge.source_type = SourceType.FILE
mock_knowledge.table_name = "test_table"
mock_knowledge.schema = "test_schema"
mock_knowledge_repo = MagicMock()
mock_knowledge_repo.get_by_agent.return_value = [mock_knowledge]
mock_knowledge_repo_class.return_value = mock_knowledge_repo
# Mock vector db and agent knowledge for search
mock_vector_db = MagicMock()
mock_pg_vector.return_value = mock_vector_db
mock_doc = MagicMock()
mock_doc.name = "test.pdf"
mock_doc.content = "Test content"
mock_doc.score = 0.9
mock_agent_knowledge = MagicMock()
mock_agent_knowledge.search.return_value = [mock_doc]
mock_agent_knowledge_class.return_value = mock_agent_knowledge
# Execute search
result = tool.search_knowledge_base("test query")
# Assert
assert "test.pdf" in result
assert "Test content" in result
def test_real_search_knowledge_base_no_ai_config():
"""Test initialization when no AI config exists"""
agent_id = str(uuid4())
org_id = uuid4()
with patch('app.tools.knowledge_search_byagent.SessionLocal') as mock_session_local:
mock_db = MagicMock()
mock_session_local.return_value.__enter__.return_value = mock_db
mock_session_local.return_value.__exit__.return_value = None
# No AI config available
# Should not raise an error
tool = KnowledgeSearchByAgent(agent_id=agent_id, org_id=org_id)
assert tool.agent_id == agent_id
assert tool.org_id == org_id
def test_real_search_knowledge_base_with_source_filter():
"""Test search with source filter"""
agent_id = str(uuid4())
org_id = uuid4()
source_filter = "specific_doc.pdf"
with patch('app.tools.knowledge_search_byagent.SessionLocal') as mock_session_local, \
patch('app.tools.knowledge_search_byagent.KnowledgeRepository') as mock_knowledge_repo_class, \
patch('app.tools.knowledge_search_byagent.PgVector') as mock_pg_vector, \
patch('app.tools.knowledge_search_byagent.AgentKnowledge') as mock_agent_knowledge_class:
# Setup mocks for initialization
mock_db = MagicMock()
mock_session_local.return_value.__enter__.return_value = mock_db
mock_session_local.return_value.__exit__.return_value = None
mock_ai_config = MagicMock()
mock_ai_config.encrypted_api_key = "encrypted_key"
# Create tool with source filter
tool = KnowledgeSearchByAgent(agent_id=agent_id, org_id=org_id, source=source_filter)
assert tool.source == source_filter
# Setup mocks for search
mock_knowledge = MagicMock()
mock_knowledge.source = source_filter
mock_knowledge.source_type = SourceType.FILE
mock_knowledge.table_name = "test_table"
mock_knowledge.schema = "test_schema"
mock_knowledge_repo = MagicMock()
mock_knowledge_repo.get_by_agent.return_value = [mock_knowledge]
mock_knowledge_repo_class.return_value = mock_knowledge_repo
# Mock vector db and agent knowledge
mock_vector_db = MagicMock()
mock_pg_vector.return_value = mock_vector_db
mock_doc = MagicMock()
mock_doc.name = source_filter
mock_doc.content = "Filtered content"
mock_doc.score = 0.85
mock_agent_knowledge = MagicMock()
mock_agent_knowledge.search.return_value = [mock_doc]
mock_agent_knowledge_class.return_value = mock_agent_knowledge
# Execute search
result = tool.search_knowledge_base("test query")
# Verify source filter was applied
search_call = mock_agent_knowledge.search.call_args
assert search_call is not None
filters = search_call.kwargs.get('filters', {})
assert 'name' in filters
assert filters['name'] == source_filter
def test_real_search_knowledge_base_no_content_docs():
"""Test search when documents have no content"""
agent_id = str(uuid4())
org_id = uuid4()
with patch('app.tools.knowledge_search_byagent.SessionLocal') as mock_session_local, \
patch('app.tools.knowledge_search_byagent.KnowledgeRepository') as mock_knowledge_repo_class, \
patch('app.tools.knowledge_search_byagent.PgVector') as mock_pg_vector, \
patch('app.tools.knowledge_search_byagent.AgentKnowledge') as mock_agent_knowledge_class:
# Setup mocks
mock_db = MagicMock()
mock_session_local.return_value.__enter__.return_value = mock_db
mock_session_local.return_value.__exit__.return_value = None
mock_ai_config = MagicMock()
mock_ai_config.encrypted_api_key = None
tool = KnowledgeSearchByAgent(agent_id=agent_id, org_id=org_id)
# Setup search mocks
mock_knowledge = MagicMock()
mock_knowledge.source = "test.pdf"
mock_knowledge.source_type = SourceType.FILE
mock_knowledge.table_name = "test_table"
mock_knowledge.schema = "test_schema"
mock_knowledge_repo = MagicMock()
mock_knowledge_repo.get_by_agent.return_value = [mock_knowledge]
mock_knowledge_repo_class.return_value = mock_knowledge_repo
mock_vector_db = MagicMock()
mock_pg_vector.return_value = mock_vector_db
# Documents with no content
mock_doc1 = MagicMock()
mock_doc1.name = "test.pdf"
mock_doc1.content = None
mock_doc1.score = 0.9
mock_doc2 = MagicMock()
mock_doc2.name = "test2.pdf"
mock_doc2.content = ""
mock_doc2.score = 0.8
mock_agent_knowledge = MagicMock()
mock_agent_knowledge.search.return_value = [mock_doc1, mock_doc2]
mock_agent_knowledge_class.return_value = mock_agent_knowledge
# Execute search
result = tool.search_knowledge_base("test query")
# When all documents have no content, should return no results found
assert "No relevant information found" in result
def test_real_search_knowledge_base_unknown_source_type():
"""Test search with unknown source type"""
agent_id = str(uuid4())
org_id = uuid4()
with patch('app.tools.knowledge_search_byagent.SessionLocal') as mock_session_local, \
patch('app.tools.knowledge_search_byagent.KnowledgeRepository') as mock_knowledge_repo_class, \
patch('app.tools.knowledge_search_byagent.PgVector') as mock_pg_vector, \
patch('app.tools.knowledge_search_byagent.AgentKnowledge') as mock_agent_knowledge_class:
# Setup mocks
mock_db = MagicMock()
mock_session_local.return_value.__enter__.return_value = mock_db
mock_session_local.return_value.__exit__.return_value = None
mock_ai_config = MagicMock()
mock_ai_config.encrypted_api_key = "key"
tool = KnowledgeSearchByAgent(agent_id=agent_id, org_id=org_id)
# Setup search with mismatched source name
mock_knowledge = MagicMock()
mock_knowledge.source = "known_source.pdf"
mock_knowledge.source_type = SourceType.FILE
mock_knowledge.table_name = "test_table"
mock_knowledge.schema = "test_schema"
mock_knowledge_repo = MagicMock()
mock_knowledge_repo.get_by_agent.return_value = [mock_knowledge]
mock_knowledge_repo_class.return_value = mock_knowledge_repo
mock_vector_db = MagicMock()
mock_pg_vector.return_value = mock_vector_db
# Document with name that doesn't match any knowledge source
mock_doc = MagicMock()
mock_doc.name = "unknown_source.pdf"
mock_doc.content = "Content from unknown source"
mock_doc.score = 0.9
mock_agent_knowledge = MagicMock()
mock_agent_knowledge.search.return_value = [mock_doc]
mock_agent_knowledge_class.return_value = mock_agent_knowledge
# Execute search
result = tool.search_knowledge_base("test query")
# Should fallback to 'unknown' source type
assert "UNKNOWN" in result or "unknown_source.pdf" in result
def test_real_search_knowledge_base_document_without_score():
"""Test search with documents that don't have score attribute"""
agent_id = str(uuid4())
org_id = uuid4()
with patch('app.tools.knowledge_search_byagent.SessionLocal') as mock_session_local, \
patch('app.tools.knowledge_search_byagent.KnowledgeRepository') as mock_knowledge_repo_class, \
patch('app.tools.knowledge_search_byagent.PgVector') as mock_pg_vector, \
patch('app.tools.knowledge_search_byagent.AgentKnowledge') as mock_agent_knowledge_class:
# Setup mocks
mock_db = MagicMock()
mock_session_local.return_value.__enter__.return_value = mock_db
mock_session_local.return_value.__exit__.return_value = None
mock_ai_config = MagicMock()
mock_ai_config.encrypted_api_key = "key"
tool = KnowledgeSearchByAgent(agent_id=agent_id, org_id=org_id)
# Setup search
mock_knowledge = MagicMock()
mock_knowledge.source = "test.pdf"
mock_knowledge.source_type = SourceType.WEBSITE
mock_knowledge.table_name = "test_table"
mock_knowledge.schema = "test_schema"
mock_knowledge_repo = MagicMock()
mock_knowledge_repo.get_by_agent.return_value = [mock_knowledge]
mock_knowledge_repo_class.return_value = mock_knowledge_repo
mock_vector_db = MagicMock()
mock_pg_vector.return_value = mock_vector_db
# Document without score attribute
mock_doc = MagicMock(spec=['name', 'content'])
mock_doc.name = "test.pdf"
mock_doc.content = "Test content"
# Don't set score attribute - hasattr will return False
mock_agent_knowledge = MagicMock()
mock_agent_knowledge.search.return_value = [mock_doc]
mock_agent_knowledge_class.return_value = mock_agent_knowledge
# Execute search
result = tool.search_knowledge_base("test query")
# Should handle missing score gracefully (default to 0.0)
assert "test.pdf" in result
assert "Test content" in result
def test_real_search_knowledge_base_exception_handling():
"""Test exception handling in real search_knowledge_base"""
agent_id = str(uuid4())
org_id = uuid4()
with patch('app.tools.knowledge_search_byagent.SessionLocal') as mock_session_local, \
patch('app.tools.knowledge_search_byagent.KnowledgeRepository') as mock_knowledge_repo_class:
# Setup mocks for initialization
mock_db = MagicMock()
mock_session_local.return_value.__enter__.return_value = mock_db
mock_session_local.return_value.__exit__.return_value = None
mock_ai_config = MagicMock()
mock_ai_config.encrypted_api_key = None
tool = KnowledgeSearchByAgent(agent_id=agent_id, org_id=org_id)
# Make the repository raise an exception during search
mock_knowledge_repo = MagicMock()
mock_knowledge_repo.get_by_agent.side_effect = Exception("Database connection error")
mock_knowledge_repo_class.return_value = mock_knowledge_repo
# Execute search - should catch exception and return error message
result = tool.search_knowledge_base("test query")
# Assert error message is returned
assert "Error searching knowledge base" in result
def test_real_search_knowledge_base_lazy_init():
"""Test lazy initialization of agent_knowledge during search"""
agent_id = str(uuid4())
org_id = uuid4()
with patch('app.tools.knowledge_search_byagent.SessionLocal') as mock_session_local, \
patch('app.tools.knowledge_search_byagent.KnowledgeRepository') as mock_knowledge_repo_class, \
patch('app.tools.knowledge_search_byagent.PgVector') as mock_pg_vector, \
patch('app.tools.knowledge_search_byagent.AgentKnowledge') as mock_agent_knowledge_class, \
patch('app.tools.knowledge_search_byagent.get_embedder') as mock_embedder_class:
# Setup mocks for initialization
mock_db = MagicMock()
mock_session_local.return_value.__enter__.return_value = mock_db
mock_session_local.return_value.__exit__.return_value = None
mock_ai_config = MagicMock()
mock_ai_config.encrypted_api_key = "key"
# Create tool - agent_knowledge should be None initially
tool = KnowledgeSearchByAgent(agent_id=agent_id, org_id=org_id)
# Explicitly set agent_knowledge to None to test lazy initialization
tool.agent_knowledge = None
# Setup mocks for search
mock_knowledge = MagicMock()
mock_knowledge.source = "test.pdf"
mock_knowledge.source_type = SourceType.FILE
mock_knowledge.table_name = "test_table"
mock_knowledge.schema = "test_schema"
mock_knowledge_repo = MagicMock()
mock_knowledge_repo.get_by_agent.return_value = [mock_knowledge]
mock_knowledge_repo_class.return_value = mock_knowledge_repo
# Mock embedder
mock_embedder = MagicMock()
mock_embedder_class.return_value = mock_embedder
# Mock vector db
mock_vector_db = MagicMock()
mock_pg_vector.return_value = mock_vector_db
# Mock agent knowledge
mock_doc = MagicMock()
mock_doc.name = "test.pdf"
mock_doc.content = "Lazy init content"
mock_doc.score = 0.9
mock_agent_knowledge = MagicMock()
mock_agent_knowledge.search.return_value = [mock_doc]
mock_agent_knowledge_class.return_value = mock_agent_knowledge
# Execute search - should initialize agent_knowledge
result = tool.search_knowledge_base("test query")
# Verify initialization occurred
mock_embedder_class.assert_called_once()
mock_pg_vector.assert_called_once()
mock_agent_knowledge_class.assert_called_once()
# Verify search was successful
assert "test.pdf" in result
assert "Lazy init content" in result
def test_real_search_knowledge_base_no_sources_real():
"""Test real implementation when no knowledge sources are available"""
agent_id = str(uuid4())
org_id = uuid4()
with patch('app.tools.knowledge_search_byagent.SessionLocal') as mock_session_local, \
patch('app.tools.knowledge_search_byagent.KnowledgeRepository') as mock_knowledge_repo_class:
# Setup mocks for initialization
mock_db = MagicMock()
mock_session_local.return_value.__enter__.return_value = mock_db
mock_session_local.return_value.__exit__.return_value = None
mock_ai_config = MagicMock()
mock_ai_config.encrypted_api_key = None
tool = KnowledgeSearchByAgent(agent_id=agent_id, org_id=org_id)
# Return empty list for knowledge sources
mock_knowledge_repo = MagicMock()
mock_knowledge_repo.get_by_agent.return_value = []
mock_knowledge_repo_class.return_value = mock_knowledge_repo
# Execute search
result = tool.search_knowledge_base("test query")
# Should return no sources message
assert "No knowledge sources available for this agent" in result
@@ -0,0 +1,187 @@
"""
Copyright 2024-2026 ChatterMate
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.
Search over chunked pages: neighbour expansion, page grouping, the size cap
and "a repeat search returns what has not been shown yet".
"""
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
from uuid import uuid4
import pytest
from agno.document import Document
from app.models.knowledge import SourceType
from app.tools.knowledge_search_byagent import NO_FURTHER_RESULTS, KnowledgeSearchByAgent
PAGE = "https://x.com/pricing"
def _chunk(page: str, n: int, count: int, text: str) -> Document:
return Document(
id=f"{page}::{n}",
name="https://x.com",
content=text,
meta_data={"url": page, "page_id": page, "chunk": n, "chunk_count": count},
)
def _row(doc: Document):
return SimpleNamespace(id=doc.id, name=doc.name, content=doc.content, meta_data=doc.meta_data)
@pytest.fixture
def tool():
source = MagicMock()
source.source = "https://x.com"
source.source_type = SourceType.WEBSITE
source.table_name = "d_org"
source.schema = "ai"
with patch("app.tools.knowledge_search_byagent.SessionLocal") as session_local, \
patch("app.tools.knowledge_search_byagent.KnowledgeRepository") as repo_class:
db = MagicMock()
session_local.return_value.__enter__.return_value = db
repo_class.return_value.get_by_agent.return_value = [source]
tool = KnowledgeSearchByAgent(agent_id=str(uuid4()), org_id=uuid4())
tool.agent_knowledge = MagicMock()
tool._db = db
yield tool
def test_hit_comes_with_page_head_and_neighbours_grouped_and_ordered(tool):
hit = _chunk(PAGE, 4, 6, "part four")
tool.agent_knowledge.search.return_value = [hit]
tool._db.execute.return_value.fetchall.return_value = [
_row(_chunk(PAGE, 5, 6, "part five")),
_row(_chunk(PAGE, 1, 6, "Thermofit gloves 39,90 EUR")),
_row(_chunk(PAGE, 3, 6, "part three")),
]
result = tool.search_knowledge_base("pricing")
assert result.startswith(f"[WEBSITE - https://x.com | {PAGE}] ")
order = ["(part 1/6) Thermofit", "(part 3/6) part three", "(part 4/6) part four", "(part 5/6) part five"]
positions = [result.index(p) for p in order]
assert positions == sorted(positions)
ids = tool._db.execute.call_args.args[1]["ids"]
assert sorted(ids) == [f"{PAGE}::1", f"{PAGE}::3", f"{PAGE}::5"]
assert tool.seen_chunk_ids == {f"{PAGE}::1", f"{PAGE}::3", f"{PAGE}::4", f"{PAGE}::5"}
assert tool.collected_sources == [{"name": "https://x.com", "type": "website"}]
def test_page_head_never_displaces_a_matched_page(tool, monkeypatch):
"""Heads are added last and only where they fit: a page that matched the
query must not be dropped by the cap to make room for another page's head."""
a, b = "https://x.com/a", "https://x.com/b"
tool.agent_knowledge.search.return_value = [_chunk(a, 3, 3, "a" * 60), _chunk(b, 3, 3, "b" * 60)]
tool._db.execute.return_value.fetchall.return_value = [
_row(_chunk(a, 1, 3, "HEAD-A " + "h" * 60)),
_row(_chunk(b, 1, 3, "HEAD-B " + "h" * 60)),
]
one_page = len(f"[WEBSITE - https://x.com | {a}] (part 3/3) " + "a" * 60) + 2
monkeypatch.setattr("app.tools.knowledge_search_byagent.settings.KNOWLEDGE_SEARCH_MAX_CHARS", 2 * one_page + 10)
result = tool.search_knowledge_base("q")
assert "a" * 60 in result and "b" * 60 in result # both matched pages kept
assert "HEAD-A" not in result and "HEAD-B" not in result # no room left for heads
assert f"{a}::1" not in tool.seen_chunk_ids # unshown head stays searchable
def test_page_head_added_when_it_fits(tool):
tool.agent_knowledge.search.return_value = [_chunk(PAGE, 5, 5, "tail")]
tool._db.execute.return_value.fetchall.return_value = [
_row(_chunk(PAGE, 4, 5, "four")),
_row(_chunk(PAGE, 1, 5, "Thermofit gloves 39,90 EUR")),
]
result = tool.search_knowledge_base("Wie viel kosten die Handschuhe?")
assert result.index("39,90 EUR") < result.index("four") < result.index("tail")
def test_page_head_is_not_fetched_twice(tool):
"""A hit on chunk 2 already asks for chunk 1 as its neighbour; a hit on
chunk 1 needs no head lookup at all."""
tool.agent_knowledge.search.return_value = [_chunk(PAGE, 2, 4, "two"), _chunk(PAGE, 1, 4, "one")]
tool._db.execute.return_value.fetchall.return_value = [_row(_chunk(PAGE, 3, 4, "three"))]
tool.search_knowledge_base("q")
ids = tool._db.execute.call_args.args[1]["ids"]
assert sorted(ids) == [f"{PAGE}::3"]
def test_unchunked_row_has_no_neighbour_lookup(tool):
legacy = Document(id="https://x.com/about", name="https://x.com", content="About us",
meta_data={"url": "https://x.com/about", "chunk": 7})
tool.agent_knowledge.search.return_value = [legacy]
result = tool.search_knowledge_base("about")
assert result == "[WEBSITE - https://x.com | https://x.com/about] About us"
tool._db.execute.assert_not_called()
def test_repeat_search_returns_unseen_chunks_then_exhausts(tool):
first, second = _chunk(PAGE, 1, 2, "one"), _chunk("https://x.com/faq", 1, 1, "faq")
tool.agent_knowledge.search.return_value = [first]
tool._db.execute.return_value.fetchall.return_value = []
tool.search_knowledge_base("q")
tool.agent_knowledge.search.return_value = [first, second]
result = tool.search_knowledge_base("q again")
assert "faq" in result and "one" not in result
assert tool.agent_knowledge.search.call_args.kwargs["num_documents"] > 1
tool.agent_knowledge.search.return_value = [first, second]
assert tool.search_knowledge_base("q once more") == NO_FURTHER_RESULTS
def test_reset_turn_forgets_seen_and_sources(tool):
tool.seen_chunk_ids = {"a"}
tool.collected_sources = [{"name": "n", "type": "t"}]
tool.reset_turn()
assert tool.seen_chunk_ids == set() and tool.collected_sources == []
def test_result_cap_drops_whole_pages_never_cuts_a_chunk(tool, monkeypatch):
monkeypatch.setattr("app.tools.knowledge_search_byagent.settings.KNOWLEDGE_SEARCH_MAX_CHARS", 120)
pages = [_chunk(f"https://x.com/p{i}", 1, 1, "x" * 80) for i in range(4)]
tool.agent_knowledge.search.return_value = pages
tool._db.execute.return_value.fetchall.return_value = []
result = tool.search_knowledge_base("q")
assert result.count("[WEBSITE") == 1
assert result.endswith("x" * 80)
# Dropped pages were never shown: not seen, not cited, still searchable.
assert tool.seen_chunk_ids == {"https://x.com/p0::1"}
tool.agent_knowledge.search.return_value = pages
assert "p1" in tool.search_knowledge_base("q again")
def test_not_yet_rechunked_whole_page_row_is_bounded(tool, monkeypatch):
monkeypatch.setattr("app.tools.knowledge_search_byagent.settings.KNOWLEDGE_CHUNK_SIZE", 100)
legacy = Document(id="https://x.com/huge", name="https://x.com",
content=("sentence. " * 10 + "\n") * 200, meta_data={"url": "https://x.com/huge"})
tool.agent_knowledge.search.return_value = [legacy]
result = tool.search_knowledge_base("q")
assert len(result) < 600
assert result.endswith("(page continues — not yet re-indexed in full)")
assert "sentence. " in result
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,71 @@
"""
Copyright 2024-2026 ChatterMate
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
"""
import pytest
from app.tools.shopify_order_access import (
EMAIL_REQUIRED,
INVALID_ORDER_NUMBER,
ORDER_NUMBER_REQUIRED,
build_order_search,
order_belongs_to,
order_lookup_problem,
order_number_matches,
)
@pytest.mark.parametrize("email", ["a@example.com", "First.Last+tag@shop.co.uk", " A@Example.com "])
def test_plain_emails_pass(email):
assert order_lookup_problem(email, "1001") is None
@pytest.mark.parametrize("email", [None, "", "a@b", "a b@example.com", "*@example.com",
"a@example.com OR name:1", 'a"@example.com', "a@(example).com"])
def test_emails_that_could_widen_the_search_are_refused(email):
assert order_lookup_problem(email, "1001") == EMAIL_REQUIRED
def test_order_number_is_optional_unless_required():
assert order_lookup_problem("a@example.com", None) is None
assert order_lookup_problem("a@example.com", " # ", number_required=True) == ORDER_NUMBER_REQUIRED
@pytest.mark.parametrize("number", ["#1001", "1001", "SHOP-1001-UK"])
def test_order_number_formats_pass(number):
assert order_lookup_problem("a@example.com", number, number_required=True) is None
@pytest.mark.parametrize("number", ["10 OR 1", "-1001", "1001*", "name:1001"])
def test_order_numbers_with_search_syntax_are_refused(number):
assert order_lookup_problem("a@example.com", number) == INVALID_ORDER_NUMBER
def test_build_order_search():
assert build_order_search(" A@Example.com ") == "email:a@example.com"
assert build_order_search("a@example.com", "#1001") == "email:a@example.com AND name:1001"
def test_order_belongs_to_order_or_customer_email():
assert order_belongs_to({"email": "A@example.com"}, "a@example.com")
assert order_belongs_to({"email": None, "customer": {"email": "a@example.com"}}, "a@example.com")
assert not order_belongs_to({"email": "b@example.com", "customer": None}, "a@example.com")
assert not order_belongs_to({"email": None, "customer": None}, "")
def test_order_number_matches_exactly():
assert order_number_matches({"name": "#1001"}, "1001")
assert order_number_matches({"name": "#1001"}, None)
assert not order_number_matches({"name": "#10010"}, "1001")
+594
View File
@@ -0,0 +1,594 @@
"""
Copyright 2024-2026 ChatterMate
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
"""
import pytest
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
@pytest.fixture
def mock_db():
db = MagicMock()
return db
@pytest.fixture
def mock_agent():
agent = MagicMock()
agent.id = UUID("00000000-0000-0000-0000-000000000001")
agent.organization_id = UUID("00000000-0000-0000-0000-000000000002")
return agent
@pytest.fixture
def mock_org():
org = MagicMock()
org.id = UUID("00000000-0000-0000-0000-000000000002")
return org
@pytest.fixture
def mock_shopify_config():
shopify_config = MagicMock()
shopify_config.enabled = True
shopify_config.shop_id = UUID("00000000-0000-0000-0000-000000000003")
return shopify_config
@pytest.fixture
def mock_shop():
shop = MagicMock()
shop.id = UUID("00000000-0000-0000-0000-000000000003")
shop.organization_id = UUID("00000000-0000-0000-0000-000000000002")
shop.shop_domain = "test-shop.myshopify.com"
shop.access_token = "test_access_token"
shop.is_installed = True
return shop
@pytest.fixture
def mock_shopify_service():
service = MagicMock()
service.get_products.return_value = {
"success": True,
"products": [
{
"id": "123456789",
"title": "Test Product",
"description": "This is a test product",
"price": "19.99",
"currency": "USD",
"vendor": "Test Vendor",
"product_type": "Test Type",
"total_inventory": 10,
"tags": ["tag1", "tag2"],
"image": {"src": "https://example.com/image.jpg"}
}
],
"count": 1,
"has_next_page": False,
"end_cursor": None
}
service.get_product.return_value = {
"success": True,
"product": {
"id": "123456789",
"title": "Test Product",
"description": "This is a test product",
"price": "19.99",
"currency": "USD",
"vendor": "Test Vendor",
"product_type": "Test Type",
"total_inventory": 10,
"tags": ["tag1", "tag2"],
"image": {"src": "https://example.com/image.jpg"}
}
}
service.get_order.return_value = {
"success": True,
"order": {
"id": "987654321",
"name": "#1001",
"financial_status": "paid",
"fulfillment_status": "fulfilled",
"created_at": "2023-01-01T00:00:00Z",
"processed_at": "2023-01-01T00:01:00Z",
"total_price": "29.99",
"currency": "USD",
"customer": {
"first_name": "John",
"last_name": "Doe",
"email": "john.doe@example.com"
},
"fulfillments": [
{
"tracking_number": "12345",
"tracking_company": "USPS",
"status": "SUCCESS"
}
]
}
}
return service
@pytest.fixture
def shopify_tools(mock_db, mock_agent, mock_org, mock_shopify_config, mock_shop, mock_shopify_service):
"""Create a ShopifyTools instance with all dependencies mocked"""
agent_id = "00000000-0000-0000-0000-000000000001"
org_id = "00000000-0000-0000-0000-000000000002"
session_id = "test_session_id"
with patch('app.tools.shopify_toolkit.SessionLocal') as mock_session_local, \
patch('app.tools.shopify_toolkit.ShopifyService') as mock_service_class, \
patch('app.tools.shopify_toolkit.ShopifyShopRepository') as mock_shop_repo_class, \
patch('app.tools.shopify_toolkit.AgentShopifyConfigRepository') as mock_config_repo_class, \
patch('app.tools.shopify_toolkit.get_redis') as mock_get_redis:
# Configure session to return our mock db
mock_session_local.return_value.__enter__.return_value = mock_db
# Configure mocks
mock_service_class.return_value = mock_shopify_service
# Mock Redis
mock_redis = MagicMock()
mock_redis.setex.return_value = True
mock_get_redis.return_value = mock_redis
# Create mock repository instances
mock_shop_repo = MagicMock()
mock_shop_repo_class.return_value = mock_shop_repo
mock_shop_repo.get_shop.return_value = mock_shop
mock_config_repo = MagicMock()
mock_config_repo_class.return_value = mock_config_repo
mock_config_repo.get_agent_shopify_config.return_value = mock_shopify_config
# Create tool instance
tool = ShopifyTools(agent_id=agent_id, org_id=org_id, session_id=session_id)
# Mock the _get_shop_for_agent method directly to avoid database operations
with patch.object(tool, '_get_shop_for_agent', return_value=mock_shop):
yield tool
def test_list_products(shopify_tools, mock_shopify_service):
# Act
result_str = shopify_tools.list_products(limit=10)
result = json.loads(result_str)
# Assert
assert result["success"] is True
assert "message" in result # Now includes text summary
assert "shopify_output" in result
assert "products" in result["shopify_output"]
assert len(result["shopify_output"]["products"]) == 1
assert result["shopify_output"]["products"][0]["title"] == "Test Product"
mock_shopify_service.get_products.assert_called_once()
def test_get_product(shopify_tools, mock_shopify_service):
# Act
result_str = shopify_tools.get_product(product_id="123456789")
result = json.loads(result_str)
# Assert
assert result["success"] is True
assert "message" in result # Now includes text summary
assert "shopify_product" in result
assert result["shopify_product"]["title"] == "Test Product"
# Verify message contains key product details
assert "Test Product" in result["message"]
assert "19.99" in result["message"]
assert "Test Vendor" in result["message"]
mock_shopify_service.get_product.assert_called_once()
def test_search_products(shopify_tools):
# Mock the requests.post response
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {
"data": {
"products": {
"edges": [
{
"node": {
"id": "gid://shopify/Product/123456789",
"title": "Test Product",
"description": "This is a test product",
"handle": "test-product",
"productType": "Test Type",
"vendor": "Test Vendor",
"totalInventory": 10,
"priceRangeV2": {
"minVariantPrice": {
"amount": "19.99",
"currencyCode": "USD"
},
"maxVariantPrice": {
"amount": "29.99",
"currencyCode": "USD"
}
},
"images": {
"edges": [
{
"node": {
"id": "image-id",
"url": "https://example.com/image.jpg",
"altText": "Test Image"
}
}
]
},
"tags": ["tag1", "tag2"],
"createdAt": "2023-01-01T00:00:00Z",
"updatedAt": "2023-01-02T00:00:00Z"
}
}
],
"pageInfo": {
"hasNextPage": False,
"endCursor": "cursor-value"
}
}
}
}
with patch("requests.post", return_value=mock_response):
# Act
result_str = shopify_tools.search_products(query="test", limit=5)
result = json.loads(result_str)
# Assert
assert result["success"] is True
assert "shopify_output" in result
assert "products" in result["shopify_output"]
assert len(result["shopify_output"]["products"]) == 1
assert result["shopify_output"]["products"][0]["title"] == "Test Product"
assert result["shopify_output"]["search_query"] == "test"
assert "pageInfo" in result["shopify_output"]
OWNER_EMAIL = "john.doe@example.com"
def _order_node(name="#1001", email=OWNER_EMAIL, customer=None):
"""One GraphQL order node as Shopify's order search returns it."""
return {
"id": "gid://shopify/Order/987654321",
"name": name,
"email": email,
"phone": None,
"createdAt": "2023-01-01T00:00:00Z",
"displayFinancialStatus": "PAID",
"displayFulfillmentStatus": "FULFILLED",
"subtotalLineItemsQuantity": 1,
"currentSubtotalPriceSet": {"shopMoney": {"amount": "24.99", "currencyCode": "USD"}},
"currentTotalPriceSet": {"shopMoney": {"amount": "29.99", "currencyCode": "USD"}},
"originalTotalPriceSet": {"shopMoney": {"amount": "29.99", "currencyCode": "USD"}},
"customer": customer,
"lineItems": {"edges": [{"node": {
"title": "Test Product",
"quantity": 1,
"originalUnitPriceSet": {"shopMoney": {"amount": "19.99", "currencyCode": "USD"}},
"variant": {"id": "variant-id", "title": "Default", "image": {"url": "https://example.com/image.jpg"}},
}}]},
"shippingAddress": {"address1": "123 Test St", "city": "Testville", "provinceCode": "CA",
"zip": "12345", "country": "US"},
"fulfillments": [{"trackingInfo": [{"company": "USPS", "number": "12345",
"url": "https://tracking.usps.com/12345"}],
"status": "SUCCESS"}],
}
def _orders_response(*nodes):
response = MagicMock()
response.status_code = 200
response.json.return_value = {"data": {"orders": {
"edges": [{"node": node} for node in nodes],
"pageInfo": {"hasNextPage": False, "endCursor": "cursor-value"},
}}}
return response
def _sent_search(mock_post):
return mock_post.call_args.kwargs["json"]["variables"]["query"]
def test_search_orders(shopify_tools):
with patch("requests.post", return_value=_orders_response(_order_node())) as mock_post:
result = json.loads(shopify_tools.search_orders(customer_email=" John.Doe@Example.com ", order_number="#1001"))
assert result["success"] is True
assert len(result["orders"]) == 1
assert result["orders"][0]["name"] == "#1001"
assert result["orders"][0]["financial_status"] == "PAID"
assert result["orders"][0]["tracking_info"][0]["number"] == "12345"
assert "page_info" in result
assert _sent_search(mock_post) == "email:john.doe@example.com AND name:1001"
assert mock_post.call_args.args[0] == "https://test-shop.myshopify.com/admin/api/2025-10/graphql.json"
def test_search_orders_honours_the_admin_base_override(shopify_tools, monkeypatch):
monkeypatch.setattr("app.services.shopify_urls.SHOPIFY_ADMIN_BASE", "http://127.0.0.1:14020/shopify")
with patch("requests.post", return_value=_orders_response(_order_node())) as mock_post:
shopify_tools.search_orders(customer_email="john.doe@example.com", order_number="#1001")
assert mock_post.call_args.args[0] == (
"http://127.0.0.1:14020/shopify/test-shop.myshopify.com/admin/api/2025-10/graphql.json"
)
def test_search_orders_matches_the_customer_record_email(shopify_tools):
node = _order_node(email="checkout@example.com", customer={"id": "c1", "firstName": "John",
"lastName": "Doe", "email": OWNER_EMAIL})
with patch("requests.post", return_value=_orders_response(node)):
result = json.loads(shopify_tools.search_orders(customer_email=OWNER_EMAIL))
assert result["success"] is True
assert "John Doe" in result["message"]
@pytest.mark.parametrize("email", [None, "", "not-an-email", "*@example.com",
"a@example.com OR name:1001", 'a"@example.com'])
def test_search_orders_needs_a_plain_email(shopify_tools, email):
with patch("requests.post") as mock_post:
result = json.loads(shopify_tools.search_orders(customer_email=email, order_number="1001"))
assert result["success"] is False
assert result["requires_user_input"] is True
mock_post.assert_not_called()
def test_search_orders_hides_other_customers_orders(shopify_tools):
# Shopify's search is fuzzy; anything not placed with this exact email is dropped.
stranger = _order_node(email="someone.else@example.com")
with patch("requests.post", return_value=_orders_response(stranger)):
result = json.loads(shopify_tools.search_orders(customer_email=OWNER_EMAIL, order_number="1001"))
assert result == {"success": False, "message": ORDER_NOT_FOUND}
def test_search_orders_needs_the_exact_order_number(shopify_tools):
with patch("requests.post", return_value=_orders_response(_order_node(name="#10010"))):
result = json.loads(shopify_tools.search_orders(customer_email=OWNER_EMAIL, order_number="1001"))
assert result == {"success": False, "message": ORDER_NOT_FOUND}
def test_search_orders_hides_shopify_error_details(shopify_tools):
response = MagicMock(status_code=500, text="internal detail")
with patch("requests.post", return_value=response):
result = json.loads(shopify_tools.search_orders(customer_email=OWNER_EMAIL))
assert result["success"] is False
assert "internal detail" not in result["message"]
def test_get_order_status(shopify_tools, mock_shopify_service):
with patch("requests.post", return_value=_orders_response(_order_node())) as mock_post:
result = json.loads(shopify_tools.get_order_status(order_number="#1001", customer_email=OWNER_EMAIL))
assert result["success"] is True
assert result["order_number"] == "#1001"
assert result["status"] == "paid"
assert result["fulfillment_status"] == "fulfilled"
assert result["tracking_numbers"][0] == "12345"
assert result["shop_domain"] == "test-shop.myshopify.com"
assert _sent_search(mock_post) == "email:john.doe@example.com AND name:1001"
mock_shopify_service.get_order.assert_called_once()
assert mock_shopify_service.get_order.call_args.args[1] == "987654321"
@pytest.mark.parametrize("order_number,email", [("1001", None), ("1001", ""), (None, OWNER_EMAIL),
("", OWNER_EMAIL), ("10 OR 1", OWNER_EMAIL)])
def test_get_order_status_needs_number_and_email(shopify_tools, mock_shopify_service, order_number, email):
with patch("requests.post") as mock_post:
result = json.loads(shopify_tools.get_order_status(order_number=order_number, customer_email=email))
assert result["success"] is False
assert result["requires_user_input"] is True
mock_post.assert_not_called()
mock_shopify_service.get_order.assert_not_called()
def test_get_order_status_wrong_email_looks_like_a_missing_order(shopify_tools, mock_shopify_service):
with patch("requests.post", return_value=_orders_response(_order_node())):
wrong_email = json.loads(shopify_tools.get_order_status(order_number="1001",
customer_email="someone.else@example.com"))
with patch("requests.post", return_value=_orders_response()):
missing = json.loads(shopify_tools.get_order_status(order_number="9999", customer_email=OWNER_EMAIL))
assert wrong_email == missing == {"success": False, "message": ORDER_NOT_FOUND}
mock_shopify_service.get_order.assert_not_called()
def test_get_order_status_rechecks_the_full_record(shopify_tools, mock_shopify_service):
mock_shopify_service.get_order.return_value["order"]["customer"]["email"] = "someone.else@example.com"
with patch("requests.post", return_value=_orders_response(_order_node())):
result = json.loads(shopify_tools.get_order_status(order_number="1001", customer_email=OWNER_EMAIL))
assert result == {"success": False, "message": ORDER_NOT_FOUND}
def test_get_order_status_guest_checkout(shopify_tools, mock_shopify_service):
order = mock_shopify_service.get_order.return_value["order"]
order["customer"] = None
order["email"] = OWNER_EMAIL
with patch("requests.post", return_value=_orders_response(_order_node())):
result = json.loads(shopify_tools.get_order_status(order_number="1001", customer_email=OWNER_EMAIL))
assert result["success"] is True
assert result["customer"] == {}
def test_recommend_products(shopify_tools):
# Mock the first requests.post response (product details)
mock_product_response = MagicMock()
mock_product_response.status_code = 200
mock_product_response.json.return_value = {
"data": {
"product": {
"id": "gid://shopify/Product/123456789",
"title": "Test Product",
"productType": "Test Type",
"tags": ["tag1", "tag2"]
}
}
}
# Mock the second requests.post response (recommendations)
mock_recommendations_response = MagicMock()
mock_recommendations_response.status_code = 200
mock_recommendations_response.json.return_value = {
"data": {
"products": {
"edges": [
{
"node": {
"id": "gid://shopify/Product/987654321",
"title": "Recommended Product",
"description": "This is a recommended product",
"handle": "recommended-product",
"productType": "Test Type",
"vendor": "Test Vendor",
"totalInventory": 5,
"priceRangeV2": {
"minVariantPrice": {
"amount": "24.99",
"currencyCode": "USD"
},
"maxVariantPrice": {
"amount": "24.99",
"currencyCode": "USD"
}
},
"images": {
"edges": [
{
"node": {
"id": "image-id",
"url": "https://example.com/image2.jpg",
"altText": "Recommended Image"
}
}
]
},
"tags": ["tag1", "tag3"],
"createdAt": "2023-01-01T00:00:00Z",
"updatedAt": "2023-01-02T00:00:00Z"
}
}
],
"pageInfo": {
"hasNextPage": False,
"endCursor": "cursor-value"
}
}
}
}
with patch("requests.post", side_effect=[mock_product_response, mock_recommendations_response]):
# Act
result_str = shopify_tools.recommend_products(product_id="123456789", limit=3)
result = json.loads(result_str)
# Assert
assert result["success"] is True
assert "shopify_output" in result
assert "products" in result["shopify_output"]
assert len(result["shopify_output"]["products"]) == 1
assert result["shopify_output"]["products"][0]["title"] == "Recommended Product"
assert result["shopify_output"]["search_type"] == "recommendations"
assert "pageInfo" in result["shopify_output"]
def test_integration_disabled():
"""Test when Shopify integration is disabled for the agent"""
agent_id = "00000000-0000-0000-0000-000000000001"
org_id = "00000000-0000-0000-0000-000000000002"
session_id = "test_session_id"
# Create disabled config
mock_config = MagicMock()
mock_config.enabled = False
with patch('app.tools.shopify_toolkit.SessionLocal'), \
patch('app.tools.shopify_toolkit.ShopifyService'), \
patch('app.tools.shopify_toolkit.ShopifyShopRepository'), \
patch('app.tools.shopify_toolkit.AgentShopifyConfigRepository') as mock_config_repo_class:
# Configure config repo to return disabled config
mock_config_repo = MagicMock()
mock_config_repo_class.return_value = mock_config_repo
mock_config_repo.get_agent_shopify_config.return_value = mock_config
# Create tool instance
tool = ShopifyTools(agent_id=agent_id, org_id=org_id, session_id=session_id)
# Act
result_str = tool.list_products()
result = json.loads(result_str)
# Assert
assert result["success"] is False
assert "not enabled" in result["message"].lower()
def test_shop_not_found():
"""Test when shop is not found for the agent"""
agent_id = "00000000-0000-0000-0000-000000000001"
org_id = "00000000-0000-0000-0000-000000000002"
session_id = "test_session_id"
# Create config with a shop ID
mock_config = MagicMock()
mock_config.enabled = True
mock_config.shop_id = UUID("00000000-0000-0000-0000-000000000003")
with patch('app.tools.shopify_toolkit.SessionLocal'), \
patch('app.tools.shopify_toolkit.ShopifyService'), \
patch('app.tools.shopify_toolkit.ShopifyShopRepository') as mock_shop_repo_class, \
patch('app.tools.shopify_toolkit.AgentShopifyConfigRepository') as mock_config_repo_class:
# Configure repos
mock_config_repo = MagicMock()
mock_config_repo_class.return_value = mock_config_repo
mock_config_repo.get_agent_shopify_config.return_value = mock_config
mock_shop_repo = MagicMock()
mock_shop_repo_class.return_value = mock_shop_repo
mock_shop_repo.get_shop.return_value = None # Shop not found
# Create tool instance
tool = ShopifyTools(agent_id=agent_id, org_id=org_id, session_id=session_id)
# Act
result_str = tool.list_products()
result = json.loads(result_str)
# Assert
assert result["success"] is False
assert "not found" in result["message"].lower() or "not enabled" in result["message"].lower()
def test_api_error(shopify_tools, mock_shopify_service):
"""Test when an API error occurs"""
# Set up service method to raise an exception
mock_shopify_service.get_products.side_effect = Exception("API Error")
# Act
result_str = shopify_tools.list_products()
result = json.loads(result_str)
# Assert
assert result["success"] is False
assert "error" in result["message"].lower()
+157
View File
@@ -0,0 +1,157 @@
"""
Copyright 2024-2026 ChatterMate
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.
Customer-identity capture on native ticket creation: the email/name the
customer states in chat must be validated, folded onto the (usually anonymous)
customer, and surfaced to the investigator — the …@noemail.com placeholder must
never be presented as a real identity.
"""
from contextlib import nullcontext
from types import SimpleNamespace
from unittest.mock import patch
from uuid import uuid4
import pytest
from app.models.customer import Customer
from app.models.session_to_agent import SessionStatus, SessionToAgent
from app.models.ticket import Ticket
from app.tools.ticket_toolkit import TicketTools, _clean_email, _clean_name
class TestCleanEmail:
def test_normalizes_and_lowercases(self):
assert _clean_email(" Admin@ChatterMate.Chat ") == "admin@chattermate.chat"
def test_rejects_junk(self):
for bad in (None, "", "not-an-email", "no@domain", "@no-local.com", "a b@c.com"):
assert _clean_email(bad) is None
class TestCleanName:
def test_trims(self):
assert _clean_name(" Arun ") == "Arun"
def test_empty_is_none(self):
assert _clean_name(None) is None
assert _clean_name(" ") is None
class TestCustomerIdentitySection:
"""The worker's trusted identity block fed to the investigator."""
def _section(self, **customer_fields):
from app.workers.ticket_investigator import _customer_identity_section
fields = {"full_name": None, "email": None, "phone": None, "meta_data": None}
fields.update(customer_fields)
customer = SimpleNamespace(**fields)
ticket = SimpleNamespace(customer=customer)
return _customer_identity_section(ticket)
def test_none_without_customer(self):
from app.workers.ticket_investigator import _customer_identity_section
assert _customer_identity_section(SimpleNamespace(customer=None)) is None
def test_real_identity_included(self):
section = self._section(
full_name="Arun Kumar", email="admin@chattermate.chat", phone="+441234567890"
)
assert section is not None
assert "admin@chattermate.chat" in section
assert "Arun Kumar" in section
assert "+441234567890" in section
def test_placeholder_email_omitted(self):
# Anonymous widget visitor with no real identity -> no section at all.
assert self._section(email="1784547346351@noemail.com") is None
def test_placeholder_email_but_real_name_keeps_name_only(self):
section = self._section(
full_name="Arun", email="1784547346351@noemail.com"
)
assert section is not None
assert "Arun" in section
assert "noemail.com" not in section
def test_meta_data_identifiers_included(self):
section = self._section(
full_name="Arun", meta_data={"account_id": "acc_991", "plan": "pro"}
)
assert "acc_991" in section
@pytest.fixture
def ticket_tools_db(db):
"""TicketTools opens its own SessionLocal; hand it the test database."""
with patch("app.tools.ticket_toolkit.SessionLocal", return_value=nullcontext(db)), \
patch("app.services.ticket.embed_ticket_text", return_value=None):
yield db
class TestCreateTicketIdentity:
"""A ticket only carries the identity of a customer the conversation belongs to:
a visitor bound to a customer by an email they typed must not hand the
investigator that customer's records."""
def _conversation(self, db, org_id, agent_id, session_key, customer_key, email=None, name=None):
customer = Customer(email=email or f"{uuid4().hex}@noemail.com", full_name=name,
organization_id=org_id, conversation_key=customer_key)
db.add(customer); db.commit(); db.refresh(customer)
session = SessionToAgent(session_id=uuid4(), customer_id=customer.id, agent_id=agent_id,
organization_id=org_id, status=SessionStatus.OPEN,
conversation_key=session_key)
db.add(session); db.commit()
return customer, session
def _create(self, db, org_id, agent_id, session, **identity) -> Ticket:
TicketTools(str(agent_id), str(org_id), str(session.session_id)).create_ticket(
summary="Login fails", description="Every attempt errors.", **identity)
return db.query(Ticket).filter(Ticket.organization_id == org_id).order_by(Ticket.created_at.desc()).first()
def test_the_customers_own_conversation_names_them(self, ticket_tools_db, test_organization_id, test_agent):
db = ticket_tools_db
customer, session = self._conversation(db, test_organization_id, test_agent.id, "browser-a", "browser-a")
ticket = self._create(db, test_organization_id, test_agent.id, session, customer_name="Own Name")
db.refresh(customer)
assert ticket.customer_id == customer.id
assert customer.full_name == "Own Name"
def test_a_claimed_customer_is_left_off_the_ticket(self, ticket_tools_db, test_organization_id, test_agent):
db = ticket_tools_db
victim, session = self._conversation(db, test_organization_id, test_agent.id, "browser-b", "browser-a",
email="victim@acme.com", name="Victim")
ticket = self._create(db, test_organization_id, test_agent.id, session,
customer_email="victim@acme.com", customer_name="Someone Else")
db.refresh(victim)
assert ticket.customer_id is None
assert victim.full_name == "Victim"
def test_a_typed_email_never_resolves_to_another_customer(self, ticket_tools_db, test_organization_id, test_agent):
db = ticket_tools_db
other = Customer(email="other@acme.com", organization_id=test_organization_id)
db.add(other); db.commit()
_, session = self._conversation(db, test_organization_id, test_agent.id, "browser-b", "browser-a")
ticket = self._create(db, test_organization_id, test_agent.id, session, customer_email="other@acme.com")
assert ticket.customer_id is None