test: 补充知识处理、检索与 Agent 回归用例
覆盖切块、向量检索、工具调用和历史预算;模型相关用例使用桩。
This commit is contained in:
1 parent
362d79a5c2
commit
f4d8019829
123 files changed
+29005
No files matched your search
@@ -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")
|
||||
@@ -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()
|
||||
@@ -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
|
||||
Reference in new issue
Block a user