Merge branch 'main' into optional-plugin-invoke

This commit is contained in:
99 2026-03-24 21:59:56 +08:00 committed by GitHub
commit 5f15d46ded
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 70 additions and 1188 deletions

View File

@ -141,3 +141,73 @@ class TestArchivedWorkflowRunDeletion:
db_session_with_containers.expunge_all()
deleted_run = db_session_with_containers.get(WorkflowRun, run_id)
assert deleted_run is None
def test_delete_run_dry_run(self, db_session_with_containers):
"""Dry run should return success without actually deleting."""
tenant_id = str(uuid4())
run = self._create_workflow_run(
db_session_with_containers,
tenant_id=tenant_id,
created_at=datetime.now(UTC),
)
run_id = run.id
deleter = ArchivedWorkflowRunDeletion(dry_run=True)
result = deleter._delete_run(run)
assert result.success is True
assert result.run_id == run_id
# Run should still exist because it's a dry run
db_session_with_containers.expire_all()
assert db_session_with_containers.get(WorkflowRun, run_id) is not None
def test_delete_run_exception_returns_error(self, db_session_with_containers):
"""Exception during deletion should return failure result."""
from unittest.mock import MagicMock, patch
tenant_id = str(uuid4())
run = self._create_workflow_run(
db_session_with_containers,
tenant_id=tenant_id,
created_at=datetime.now(UTC),
)
deleter = ArchivedWorkflowRunDeletion(dry_run=False)
with patch.object(deleter, "_get_workflow_run_repo") as mock_get_repo:
mock_repo = MagicMock()
mock_get_repo.return_value = mock_repo
mock_repo.delete_runs_with_related.side_effect = Exception("Database error")
result = deleter._delete_run(run)
assert result.success is False
assert result.error == "Database error"
def test_delete_by_run_id_success(self, db_session_with_containers):
"""Successfully delete an archived workflow run by ID."""
tenant_id = str(uuid4())
base_time = datetime.now(UTC)
run = self._create_workflow_run(
db_session_with_containers,
tenant_id=tenant_id,
created_at=base_time,
)
self._create_archive_log(db_session_with_containers, run=run)
run_id = run.id
deleter = ArchivedWorkflowRunDeletion()
result = deleter.delete_by_run_id(run_id)
assert result.success is True
db_session_with_containers.expunge_all()
assert db_session_with_containers.get(WorkflowRun, run_id) is None
def test_get_workflow_run_repo_caches_instance(self, db_session_with_containers):
"""_get_workflow_run_repo should return a cached repo on subsequent calls."""
deleter = ArchivedWorkflowRunDeletion()
repo1 = deleter._get_workflow_run_repo()
repo2 = deleter._get_workflow_run_repo()
assert repo1 is repo2
assert deleter.workflow_run_repo is repo1

View File

@ -1,216 +0,0 @@
from datetime import datetime
from unittest.mock import MagicMock, patch
import pytest
from sqlalchemy.orm import Session
from models.workflow import WorkflowRun
from services.retention.workflow_run.delete_archived_workflow_run import ArchivedWorkflowRunDeletion, DeleteResult
class TestArchivedWorkflowRunDeletion:
@pytest.fixture
def mock_db(self):
with patch("services.retention.workflow_run.delete_archived_workflow_run.db") as mock_db:
mock_db.engine = MagicMock()
yield mock_db
@pytest.fixture
def mock_sessionmaker(self):
with patch("services.retention.workflow_run.delete_archived_workflow_run.sessionmaker") as mock_sm:
mock_session = MagicMock(spec=Session)
mock_sm.return_value.return_value.__enter__.return_value = mock_session
yield mock_sm, mock_session
@pytest.fixture
def mock_workflow_run_repo(self):
with patch(
"services.retention.workflow_run.delete_archived_workflow_run.APIWorkflowRunRepository"
) as mock_repo_cls:
mock_repo = MagicMock()
yield mock_repo
def test_delete_by_run_id_success(self, mock_db, mock_sessionmaker):
mock_sm, mock_session = mock_sessionmaker
run_id = "run-123"
tenant_id = "tenant-456"
mock_run = MagicMock(spec=WorkflowRun)
mock_run.id = run_id
mock_run.tenant_id = tenant_id
mock_session.get.return_value = mock_run
deletion = ArchivedWorkflowRunDeletion()
with patch.object(deletion, "_get_workflow_run_repo") as mock_get_repo:
mock_repo = MagicMock()
mock_get_repo.return_value = mock_repo
mock_repo.get_archived_run_ids.return_value = [run_id]
with patch.object(deletion, "_delete_run") as mock_delete_run:
expected_result = DeleteResult(run_id=run_id, tenant_id=tenant_id, success=True)
mock_delete_run.return_value = expected_result
result = deletion.delete_by_run_id(run_id)
assert result == expected_result
mock_session.get.assert_called_once_with(WorkflowRun, run_id)
mock_repo.get_archived_run_ids.assert_called_once()
mock_delete_run.assert_called_once_with(mock_run)
def test_delete_by_run_id_not_found(self, mock_db, mock_sessionmaker):
mock_sm, mock_session = mock_sessionmaker
run_id = "run-123"
mock_session.get.return_value = None
deletion = ArchivedWorkflowRunDeletion()
with patch.object(deletion, "_get_workflow_run_repo"):
result = deletion.delete_by_run_id(run_id)
assert result.success is False
assert "not found" in result.error
assert result.run_id == run_id
def test_delete_by_run_id_not_archived(self, mock_db, mock_sessionmaker):
mock_sm, mock_session = mock_sessionmaker
run_id = "run-123"
mock_run = MagicMock(spec=WorkflowRun)
mock_run.id = run_id
mock_session.get.return_value = mock_run
deletion = ArchivedWorkflowRunDeletion()
with patch.object(deletion, "_get_workflow_run_repo") as mock_get_repo:
mock_repo = MagicMock()
mock_get_repo.return_value = mock_repo
mock_repo.get_archived_run_ids.return_value = []
result = deletion.delete_by_run_id(run_id)
assert result.success is False
assert "is not archived" in result.error
def test_delete_batch(self, mock_db, mock_sessionmaker):
mock_sm, mock_session = mock_sessionmaker
deletion = ArchivedWorkflowRunDeletion()
mock_run1 = MagicMock(spec=WorkflowRun)
mock_run1.id = "run-1"
mock_run2 = MagicMock(spec=WorkflowRun)
mock_run2.id = "run-2"
with patch.object(deletion, "_get_workflow_run_repo") as mock_get_repo:
mock_repo = MagicMock()
mock_get_repo.return_value = mock_repo
mock_repo.get_archived_runs_by_time_range.return_value = [mock_run1, mock_run2]
with patch.object(deletion, "_delete_run") as mock_delete_run:
mock_delete_run.side_effect = [
DeleteResult(run_id="run-1", tenant_id="t1", success=True),
DeleteResult(run_id="run-2", tenant_id="t1", success=True),
]
results = deletion.delete_batch(tenant_ids=["t1"], start_date=datetime.now(), end_date=datetime.now())
assert len(results) == 2
assert results[0].run_id == "run-1"
assert results[1].run_id == "run-2"
assert mock_delete_run.call_count == 2
def test_delete_run_dry_run(self):
deletion = ArchivedWorkflowRunDeletion(dry_run=True)
mock_run = MagicMock(spec=WorkflowRun)
mock_run.id = "run-123"
mock_run.tenant_id = "tenant-456"
result = deletion._delete_run(mock_run)
assert result.success is True
assert result.run_id == "run-123"
def test_delete_run_success(self):
deletion = ArchivedWorkflowRunDeletion(dry_run=False)
mock_run = MagicMock(spec=WorkflowRun)
mock_run.id = "run-123"
mock_run.tenant_id = "tenant-456"
with patch.object(deletion, "_get_workflow_run_repo") as mock_get_repo:
mock_repo = MagicMock()
mock_get_repo.return_value = mock_repo
mock_repo.delete_runs_with_related.return_value = {"workflow_runs": 1}
result = deletion._delete_run(mock_run)
assert result.success is True
assert result.deleted_counts == {"workflow_runs": 1}
def test_delete_run_exception(self):
deletion = ArchivedWorkflowRunDeletion(dry_run=False)
mock_run = MagicMock(spec=WorkflowRun)
mock_run.id = "run-123"
with patch.object(deletion, "_get_workflow_run_repo") as mock_get_repo:
mock_repo = MagicMock()
mock_get_repo.return_value = mock_repo
mock_repo.delete_runs_with_related.side_effect = Exception("Database error")
result = deletion._delete_run(mock_run)
assert result.success is False
assert result.error == "Database error"
def test_delete_trigger_logs(self):
mock_session = MagicMock(spec=Session)
run_ids = ["run-1", "run-2"]
with patch(
"services.retention.workflow_run.delete_archived_workflow_run.SQLAlchemyWorkflowTriggerLogRepository"
) as mock_repo_cls:
mock_repo = MagicMock()
mock_repo_cls.return_value = mock_repo
mock_repo.delete_by_run_ids.return_value = 5
count = ArchivedWorkflowRunDeletion._delete_trigger_logs(mock_session, run_ids)
assert count == 5
mock_repo_cls.assert_called_once_with(mock_session)
mock_repo.delete_by_run_ids.assert_called_once_with(run_ids)
def test_delete_node_executions(self):
mock_session = MagicMock(spec=Session)
mock_run = MagicMock(spec=WorkflowRun)
mock_run.id = "run-1"
runs = [mock_run]
with patch(
"repositories.factory.DifyAPIRepositoryFactory.create_api_workflow_node_execution_repository"
) as mock_create_repo:
mock_repo = MagicMock()
mock_create_repo.return_value = mock_repo
mock_repo.delete_by_runs.return_value = (1, 2)
with patch("services.retention.workflow_run.delete_archived_workflow_run.sessionmaker") as mock_sm:
result = ArchivedWorkflowRunDeletion._delete_node_executions(mock_session, runs)
assert result == (1, 2)
mock_create_repo.assert_called_once()
mock_repo.delete_by_runs.assert_called_once_with(mock_session, ["run-1"])
def test_get_workflow_run_repo(self, mock_db):
deletion = ArchivedWorkflowRunDeletion()
with patch(
"repositories.factory.DifyAPIRepositoryFactory.create_api_workflow_run_repository"
) as mock_create_repo:
mock_repo = MagicMock()
mock_create_repo.return_value = mock_repo
# First call
repo1 = deletion._get_workflow_run_repo()
assert repo1 == mock_repo
assert deletion.workflow_run_repo == mock_repo
# Second call (should return cached)
repo2 = deletion._get_workflow_run_repo()
assert repo2 == mock_repo
mock_create_repo.assert_called_once()

View File

@ -1,346 +0,0 @@
"""
Unit tests for services.agent_service
"""
from collections.abc import Callable
from datetime import datetime
from unittest.mock import MagicMock, patch
import pytest
import pytz
from core.plugin.impl.exc import PluginDaemonClientSideError
from models import Account
from models.model import App, Conversation, EndUser, Message, MessageAgentThought
from services.agent_service import AgentService
def _make_current_user_account(timezone: str = "UTC") -> Account:
account = Account(name="Test User", email="test@example.com")
account.timezone = timezone
return account
def _make_app_model(app_model_config: MagicMock | None) -> MagicMock:
app_model = MagicMock(spec=App)
app_model.id = "app-123"
app_model.tenant_id = "tenant-123"
app_model.app_model_config = app_model_config
return app_model
def _make_conversation(from_end_user_id: str | None, from_account_id: str | None) -> MagicMock:
conversation = MagicMock(spec=Conversation)
conversation.id = "conv-123"
conversation.app_id = "app-123"
conversation.from_end_user_id = from_end_user_id
conversation.from_account_id = from_account_id
return conversation
def _make_message(agent_thoughts: list[MessageAgentThought]) -> MagicMock:
message = MagicMock(spec=Message)
message.id = "msg-123"
message.conversation_id = "conv-123"
message.created_at = datetime(2024, 1, 1, tzinfo=pytz.UTC)
message.provider_response_latency = 1.23
message.answer_tokens = 4
message.message_tokens = 6
message.agent_thoughts = agent_thoughts
message.message_files = ["file-a.txt"]
return message
def _make_agent_thought() -> MagicMock:
agent_thought = MagicMock(spec=MessageAgentThought)
agent_thought.tokens = 3
agent_thought.tool_input = "raw-input"
agent_thought.observation = "raw-output"
agent_thought.thought = "thinking"
agent_thought.created_at = datetime(2024, 1, 1, tzinfo=pytz.UTC)
agent_thought.files = []
agent_thought.tools = ["tool_a", "dataset_tool"]
agent_thought.tool_labels = {"tool_a": "Tool A"}
agent_thought.tool_meta = {
"tool_a": {
"tool_config": {
"tool_provider_type": "custom",
"tool_provider": "provider-1",
},
"tool_parameters": {"param": "value"},
"time_cost": 2.5,
},
"dataset_tool": {
"tool_config": {
"tool_provider_type": "dataset-retrieval",
"tool_provider": "dataset-provider",
}
},
}
agent_thought.tool_inputs_dict = {"tool_a": {"q": "hello"}, "dataset_tool": {"k": "v"}}
agent_thought.tool_outputs_dict = {"tool_a": {"result": "ok"}}
return agent_thought
def _build_query_side_effect(
conversation: Conversation | None,
message: Message | None,
executor: EndUser | Account | None,
) -> Callable[..., MagicMock]:
def _query_side_effect(*args: object, **kwargs: object) -> MagicMock:
query = MagicMock()
query.where.return_value = query
if any(arg is Conversation for arg in args):
query.first.return_value = conversation
elif any(arg is Message for arg in args):
query.first.return_value = message
elif any(arg is EndUser for arg in args) or any(arg is Account for arg in args):
query.first.return_value = executor
return query
return _query_side_effect
class TestAgentServiceGetAgentLogs:
"""Test suite for AgentService.get_agent_logs."""
def test_get_agent_logs_should_raise_when_conversation_missing(self) -> None:
"""Test missing conversation raises ValueError."""
# Arrange
app_model = _make_app_model(MagicMock())
with patch("services.agent_service.db") as mock_db:
query = MagicMock()
query.where.return_value = query
query.first.return_value = None
mock_db.session.query.return_value = query
# Act & Assert
with pytest.raises(ValueError):
AgentService.get_agent_logs(app_model, "missing-conv", "msg-1")
def test_get_agent_logs_should_raise_when_message_missing(self) -> None:
"""Test missing message raises ValueError."""
# Arrange
app_model = _make_app_model(MagicMock())
conversation = _make_conversation(from_end_user_id="end-user-1", from_account_id=None)
with patch("services.agent_service.db") as mock_db:
conversation_query = MagicMock()
conversation_query.where.return_value = conversation_query
conversation_query.first.return_value = conversation
message_query = MagicMock()
message_query.where.return_value = message_query
message_query.first.return_value = None
mock_db.session.query.side_effect = [conversation_query, message_query]
# Act & Assert
with pytest.raises(ValueError):
AgentService.get_agent_logs(app_model, conversation.id, "missing-msg")
def test_get_agent_logs_should_raise_when_app_model_config_missing(self) -> None:
"""Test missing app model config raises ValueError."""
# Arrange
app_model = _make_app_model(None)
conversation = _make_conversation(from_end_user_id="end-user-1", from_account_id=None)
message = _make_message([])
current_user = _make_current_user_account()
with patch("services.agent_service.db") as mock_db, patch("services.agent_service.current_user", current_user):
mock_db.session.query.side_effect = _build_query_side_effect(conversation, message, MagicMock())
# Act & Assert
with pytest.raises(ValueError):
AgentService.get_agent_logs(app_model, conversation.id, message.id)
def test_get_agent_logs_should_raise_when_agent_config_missing(self) -> None:
"""Test missing agent config raises ValueError."""
# Arrange
app_model_config = MagicMock()
app_model_config.agent_mode_dict = {"strategy": "react"}
app_model_config.to_dict.return_value = {"tools": []}
app_model = _make_app_model(app_model_config)
conversation = _make_conversation(from_end_user_id="end-user-1", from_account_id=None)
message = _make_message([])
current_user = _make_current_user_account()
with (
patch("services.agent_service.db") as mock_db,
patch("services.agent_service.AgentConfigManager.convert", return_value=None),
patch("services.agent_service.current_user", current_user),
):
mock_db.session.query.side_effect = _build_query_side_effect(conversation, message, MagicMock())
# Act & Assert
with pytest.raises(ValueError):
AgentService.get_agent_logs(app_model, conversation.id, message.id)
def test_get_agent_logs_should_return_logs_for_end_user_executor(self) -> None:
"""Test agent logs returned for end-user executor with tool icons."""
# Arrange
agent_thought = _make_agent_thought()
message = _make_message([agent_thought])
conversation = _make_conversation(from_end_user_id="end-user-1", from_account_id=None)
executor = MagicMock(spec=EndUser)
executor.name = "End User"
app_model_config = MagicMock()
app_model_config.agent_mode_dict = {"strategy": "react"}
app_model_config.to_dict.return_value = {"tools": []}
app_model = _make_app_model(app_model_config)
current_user = _make_current_user_account()
agent_tool = MagicMock()
agent_tool.tool_name = "tool_a"
agent_tool.provider_type = "custom"
agent_tool.provider_id = "provider-2"
agent_config = MagicMock()
agent_config.tools = [agent_tool]
with (
patch("services.agent_service.db") as mock_db,
patch("services.agent_service.AgentConfigManager.convert", return_value=agent_config) as mock_convert,
patch("services.agent_service.ToolManager.get_tool_icon") as mock_get_icon,
patch("services.agent_service.current_user", current_user),
):
mock_db.session.query.side_effect = _build_query_side_effect(conversation, message, executor)
mock_get_icon.side_effect = [None, "icon-a"]
# Act
result = AgentService.get_agent_logs(app_model, conversation.id, message.id)
# Assert
assert result["meta"]["status"] == "success"
assert result["meta"]["executor"] == "End User"
assert result["meta"]["total_tokens"] == 10
assert result["meta"]["agent_mode"] == "react"
assert result["meta"]["iterations"] == 1
assert result["files"] == ["file-a.txt"]
assert len(result["iterations"]) == 1
tool_calls = result["iterations"][0]["tool_calls"]
assert tool_calls[0]["tool_name"] == "tool_a"
assert tool_calls[0]["tool_icon"] == "icon-a"
assert tool_calls[1]["tool_name"] == "dataset_tool"
assert tool_calls[1]["tool_icon"] == ""
mock_convert.assert_called_once()
def test_get_agent_logs_should_return_account_executor_when_no_end_user(self) -> None:
"""Test agent logs fall back to account executor when end user is missing."""
# Arrange
agent_thought = _make_agent_thought()
message = _make_message([agent_thought])
conversation = _make_conversation(from_end_user_id=None, from_account_id="account-1")
executor = MagicMock(spec=Account)
executor.name = "Account User"
app_model_config = MagicMock()
app_model_config.agent_mode_dict = {"strategy": "react"}
app_model_config.to_dict.return_value = {"tools": []}
app_model = _make_app_model(app_model_config)
current_user = _make_current_user_account()
agent_config = MagicMock()
agent_config.tools = []
with (
patch("services.agent_service.db") as mock_db,
patch("services.agent_service.AgentConfigManager.convert", return_value=agent_config),
patch("services.agent_service.ToolManager.get_tool_icon", return_value=""),
patch("services.agent_service.current_user", current_user),
):
mock_db.session.query.side_effect = _build_query_side_effect(conversation, message, executor)
# Act
result = AgentService.get_agent_logs(app_model, conversation.id, message.id)
# Assert
assert result["meta"]["executor"] == "Account User"
def test_get_agent_logs_should_use_defaults_when_executor_and_tool_data_missing(self) -> None:
"""Test unknown executor and missing tool details fall back to defaults."""
# Arrange
agent_thought = _make_agent_thought()
agent_thought.tool_labels = {}
agent_thought.tool_inputs_dict = {}
agent_thought.tool_outputs_dict = None
agent_thought.tool_meta = {"tool_a": {"error": "failed"}}
agent_thought.tools = ["tool_a"]
message = _make_message([agent_thought])
conversation = _make_conversation(from_end_user_id="end-user-1", from_account_id=None)
app_model_config = MagicMock()
app_model_config.agent_mode_dict = {}
app_model_config.to_dict.return_value = {"tools": []}
app_model = _make_app_model(app_model_config)
current_user = _make_current_user_account()
agent_config = MagicMock()
agent_config.tools = []
with (
patch("services.agent_service.db") as mock_db,
patch("services.agent_service.AgentConfigManager.convert", return_value=agent_config),
patch("services.agent_service.ToolManager.get_tool_icon", return_value=None),
patch("services.agent_service.current_user", current_user),
):
mock_db.session.query.side_effect = _build_query_side_effect(conversation, message, None)
# Act
result = AgentService.get_agent_logs(app_model, conversation.id, message.id)
# Assert
assert result["meta"]["executor"] == "Unknown"
assert result["meta"]["agent_mode"] == "react"
tool_call = result["iterations"][0]["tool_calls"][0]
assert tool_call["status"] == "error"
assert tool_call["error"] == "failed"
assert tool_call["tool_label"] == "tool_a"
assert tool_call["tool_input"] == {}
assert tool_call["tool_output"] == {}
assert tool_call["time_cost"] == 0
assert tool_call["tool_parameters"] == {}
assert tool_call["tool_icon"] is None
class TestAgentServiceProviders:
"""Test suite for AgentService provider methods."""
def test_list_agent_providers_should_delegate_to_plugin_client(self) -> None:
"""Test list_agent_providers delegates to PluginAgentClient."""
# Arrange
tenant_id = "tenant-1"
expected = [{"name": "provider"}]
with patch("services.agent_service.PluginAgentClient") as mock_client:
mock_client.return_value.fetch_agent_strategy_providers.return_value = expected
# Act
result = AgentService.list_agent_providers("user-1", tenant_id)
# Assert
assert result == expected
mock_client.return_value.fetch_agent_strategy_providers.assert_called_once_with(tenant_id)
def test_get_agent_provider_should_return_provider_when_successful(self) -> None:
"""Test get_agent_provider returns provider when successful."""
# Arrange
tenant_id = "tenant-1"
provider_name = "provider-a"
expected = {"name": provider_name}
with patch("services.agent_service.PluginAgentClient") as mock_client:
mock_client.return_value.fetch_agent_strategy_provider.return_value = expected
# Act
result = AgentService.get_agent_provider("user-1", tenant_id, provider_name)
# Assert
assert result == expected
mock_client.return_value.fetch_agent_strategy_provider.assert_called_once_with(tenant_id, provider_name)
def test_get_agent_provider_should_raise_value_error_on_plugin_error(self) -> None:
"""Test get_agent_provider wraps PluginDaemonClientSideError into ValueError."""
# Arrange
tenant_id = "tenant-1"
provider_name = "provider-a"
with patch("services.agent_service.PluginAgentClient") as mock_client:
mock_client.return_value.fetch_agent_strategy_provider.side_effect = PluginDaemonClientSideError(
"plugin error"
)
# Act & Assert
with pytest.raises(ValueError):
AgentService.get_agent_provider("user-1", tenant_id, provider_name)

View File

@ -1,626 +0,0 @@
import csv
import io
import json
from datetime import datetime
from unittest.mock import MagicMock, patch
import pytest
from services.feedback_service import FeedbackService
class TestFeedbackServiceFactory:
"""Factory class for creating test data and mock objects for feedback service tests."""
@staticmethod
def create_feedback_mock(
feedback_id: str = "feedback-123",
app_id: str = "app-456",
conversation_id: str = "conv-789",
message_id: str = "msg-001",
rating: str = "like",
content: str | None = "Great response!",
from_source: str = "user",
from_account_id: str | None = None,
from_end_user_id: str | None = "end-user-001",
created_at: datetime | None = None,
) -> MagicMock:
"""Create a mock MessageFeedback object."""
feedback = MagicMock()
feedback.id = feedback_id
feedback.app_id = app_id
feedback.conversation_id = conversation_id
feedback.message_id = message_id
feedback.rating = rating
feedback.content = content
feedback.from_source = from_source
feedback.from_account_id = from_account_id
feedback.from_end_user_id = from_end_user_id
feedback.created_at = created_at or datetime.now()
return feedback
@staticmethod
def create_message_mock(
message_id: str = "msg-001",
query: str = "What is AI?",
answer: str = "AI stands for Artificial Intelligence.",
inputs: dict | None = None,
created_at: datetime | None = None,
):
"""Create a mock Message object."""
# Create a simple object with instance attributes
# Using a class with __init__ ensures attributes are instance attributes
class Message:
def __init__(self):
self.id = message_id
self.query = query
self.answer = answer
self.inputs = inputs
self.created_at = created_at or datetime.now()
return Message()
@staticmethod
def create_conversation_mock(
conversation_id: str = "conv-789",
name: str | None = "Test Conversation",
) -> MagicMock:
"""Create a mock Conversation object."""
conversation = MagicMock()
conversation.id = conversation_id
conversation.name = name
return conversation
@staticmethod
def create_app_mock(
app_id: str = "app-456",
name: str = "Test App",
) -> MagicMock:
"""Create a mock App object."""
app = MagicMock()
app.id = app_id
app.name = name
return app
@staticmethod
def create_account_mock(
account_id: str = "account-123",
name: str = "Test Admin",
) -> MagicMock:
"""Create a mock Account object."""
account = MagicMock()
account.id = account_id
account.name = name
return account
class TestFeedbackService:
"""
Comprehensive unit tests for FeedbackService.
This test suite covers:
- CSV and JSON export formats
- All filter combinations
- Edge cases and error handling
- Response validation
"""
@pytest.fixture
def factory(self):
"""Provide test data factory."""
return TestFeedbackServiceFactory()
@pytest.fixture
def sample_feedback_data(self, factory):
"""Create sample feedback data for testing."""
feedback = factory.create_feedback_mock(
rating="like",
content="Excellent answer!",
from_source="user",
)
message = factory.create_message_mock(
query="What is Python?",
answer="Python is a programming language.",
)
conversation = factory.create_conversation_mock(name="Python Discussion")
app = factory.create_app_mock(name="AI Assistant")
account = factory.create_account_mock(name="Admin User")
return [(feedback, message, conversation, app, account)]
# Test 01: CSV Export - Basic Functionality
@patch("services.feedback_service.db")
def test_export_feedbacks_csv_basic(self, mock_db, factory, sample_feedback_data):
"""Test basic CSV export with single feedback record."""
# Arrange
mock_query = MagicMock()
# Configure the mock to return itself for all chaining methods
mock_query.join.return_value = mock_query
mock_query.outerjoin.return_value = mock_query
mock_query.where.return_value = mock_query
mock_query.filter.return_value = mock_query
mock_query.order_by.return_value = mock_query
mock_query.all.return_value = sample_feedback_data
# Set up the session.query to return our mock
mock_db.session.query.return_value = mock_query
# Act
response = FeedbackService.export_feedbacks(app_id="app-456", format_type="csv")
# Assert
assert response.mimetype == "text/csv"
assert "charset=utf-8-sig" in response.content_type
assert "attachment" in response.headers["Content-Disposition"]
assert "dify_feedback_export_app-456" in response.headers["Content-Disposition"]
# Verify CSV content
csv_content = response.get_data(as_text=True)
reader = csv.DictReader(io.StringIO(csv_content))
rows = list(reader)
assert len(rows) == 1
assert rows[0]["feedback_rating"] == "👍"
assert rows[0]["feedback_rating_raw"] == "like"
assert rows[0]["feedback_comment"] == "Excellent answer!"
assert rows[0]["user_query"] == "What is Python?"
assert rows[0]["ai_response"] == "Python is a programming language."
# Test 02: JSON Export - Basic Functionality
@patch("services.feedback_service.db")
def test_export_feedbacks_json_basic(self, mock_db, factory, sample_feedback_data):
"""Test basic JSON export with metadata structure."""
# Arrange
mock_query = MagicMock()
# Configure the mock to return itself for all chaining methods
mock_query.join.return_value = mock_query
mock_query.outerjoin.return_value = mock_query
mock_query.where.return_value = mock_query
mock_query.filter.return_value = mock_query
mock_query.order_by.return_value = mock_query
mock_query.all.return_value = sample_feedback_data
# Set up the session.query to return our mock
mock_db.session.query.return_value = mock_query
# Act
response = FeedbackService.export_feedbacks(app_id="app-456", format_type="json")
# Assert
assert response.mimetype == "application/json"
assert "charset=utf-8" in response.content_type
assert "attachment" in response.headers["Content-Disposition"]
# Verify JSON structure
json_content = json.loads(response.get_data(as_text=True))
assert "export_info" in json_content
assert "feedback_data" in json_content
assert json_content["export_info"]["app_id"] == "app-456"
assert json_content["export_info"]["total_records"] == 1
assert len(json_content["feedback_data"]) == 1
# Test 03: Filter by from_source
@patch("services.feedback_service.db")
def test_export_feedbacks_filter_from_source(self, mock_db, factory):
"""Test filtering by feedback source (user/admin)."""
# Arrange
mock_query = MagicMock()
mock_db.session.query.return_value = mock_query
mock_query.join.return_value = mock_query
mock_query.outerjoin.return_value = mock_query
mock_query.where.return_value = mock_query
mock_query.filter.return_value = mock_query
mock_query.order_by.return_value = mock_query
mock_query.all.return_value = []
# Act
FeedbackService.export_feedbacks(app_id="app-456", from_source="admin")
# Assert
mock_query.filter.assert_called()
# Test 04: Filter by rating
@patch("services.feedback_service.db")
def test_export_feedbacks_filter_rating(self, mock_db, factory):
"""Test filtering by rating (like/dislike)."""
# Arrange
mock_query = MagicMock()
mock_db.session.query.return_value = mock_query
mock_query.join.return_value = mock_query
mock_query.outerjoin.return_value = mock_query
mock_query.where.return_value = mock_query
mock_query.filter.return_value = mock_query
mock_query.order_by.return_value = mock_query
mock_query.all.return_value = []
# Act
FeedbackService.export_feedbacks(app_id="app-456", rating="dislike")
# Assert
mock_query.filter.assert_called()
# Test 05: Filter by has_comment (True)
@patch("services.feedback_service.db")
def test_export_feedbacks_filter_has_comment_true(self, mock_db, factory):
"""Test filtering for feedback with comments."""
# Arrange
mock_query = MagicMock()
mock_db.session.query.return_value = mock_query
mock_query.join.return_value = mock_query
mock_query.outerjoin.return_value = mock_query
mock_query.where.return_value = mock_query
mock_query.filter.return_value = mock_query
mock_query.order_by.return_value = mock_query
mock_query.all.return_value = []
# Act
FeedbackService.export_feedbacks(app_id="app-456", has_comment=True)
# Assert
mock_query.filter.assert_called()
# Test 06: Filter by has_comment (False)
@patch("services.feedback_service.db")
def test_export_feedbacks_filter_has_comment_false(self, mock_db, factory):
"""Test filtering for feedback without comments."""
# Arrange
mock_query = MagicMock()
mock_db.session.query.return_value = mock_query
mock_query.join.return_value = mock_query
mock_query.outerjoin.return_value = mock_query
mock_query.where.return_value = mock_query
mock_query.filter.return_value = mock_query
mock_query.order_by.return_value = mock_query
mock_query.all.return_value = []
# Act
FeedbackService.export_feedbacks(app_id="app-456", has_comment=False)
# Assert
mock_query.filter.assert_called()
# Test 07: Filter by date range
@patch("services.feedback_service.db")
def test_export_feedbacks_filter_date_range(self, mock_db, factory):
"""Test filtering by start and end dates."""
# Arrange
mock_query = MagicMock()
mock_db.session.query.return_value = mock_query
mock_query.join.return_value = mock_query
mock_query.outerjoin.return_value = mock_query
mock_query.where.return_value = mock_query
mock_query.filter.return_value = mock_query
mock_query.order_by.return_value = mock_query
mock_query.all.return_value = []
# Act
FeedbackService.export_feedbacks(
app_id="app-456",
start_date="2024-01-01",
end_date="2024-12-31",
)
# Assert
assert mock_query.filter.call_count >= 2 # Called for both start and end dates
# Test 08: Invalid date format - start_date
@patch("services.feedback_service.db")
def test_export_feedbacks_invalid_start_date(self, mock_db):
"""Test error handling for invalid start_date format."""
# Arrange
mock_query = MagicMock()
mock_db.session.query.return_value = mock_query
mock_query.join.return_value = mock_query
mock_query.outerjoin.return_value = mock_query
mock_query.where.return_value = mock_query
# Act & Assert
with pytest.raises(ValueError, match="Invalid start_date format"):
FeedbackService.export_feedbacks(app_id="app-456", start_date="invalid-date")
# Test 09: Invalid date format - end_date
@patch("services.feedback_service.db")
def test_export_feedbacks_invalid_end_date(self, mock_db):
"""Test error handling for invalid end_date format."""
# Arrange
mock_query = MagicMock()
mock_db.session.query.return_value = mock_query
mock_query.join.return_value = mock_query
mock_query.outerjoin.return_value = mock_query
mock_query.where.return_value = mock_query
# Act & Assert
with pytest.raises(ValueError, match="Invalid end_date format"):
FeedbackService.export_feedbacks(app_id="app-456", end_date="2024-13-45")
# Test 10: Unsupported format
def test_export_feedbacks_unsupported_format(self):
"""Test error handling for unsupported export format."""
# Act & Assert
with pytest.raises(ValueError, match="Unsupported format"):
FeedbackService.export_feedbacks(app_id="app-456", format_type="xml")
# Test 11: Empty result set - CSV
@patch("services.feedback_service.db")
def test_export_feedbacks_empty_results_csv(self, mock_db):
"""Test CSV export with no feedback records."""
# Arrange
mock_query = MagicMock()
mock_db.session.query.return_value = mock_query
mock_query.join.return_value = mock_query
mock_query.outerjoin.return_value = mock_query
mock_query.where.return_value = mock_query
mock_query.filter.return_value = mock_query
mock_query.order_by.return_value = mock_query
mock_query.all.return_value = []
# Act
response = FeedbackService.export_feedbacks(app_id="app-456", format_type="csv")
# Assert
csv_content = response.get_data(as_text=True)
reader = csv.DictReader(io.StringIO(csv_content))
rows = list(reader)
assert len(rows) == 0
# But headers should still be present
assert reader.fieldnames is not None
# Test 12: Empty result set - JSON
@patch("services.feedback_service.db")
def test_export_feedbacks_empty_results_json(self, mock_db):
"""Test JSON export with no feedback records."""
# Arrange
mock_query = MagicMock()
mock_db.session.query.return_value = mock_query
mock_query.join.return_value = mock_query
mock_query.outerjoin.return_value = mock_query
mock_query.where.return_value = mock_query
mock_query.filter.return_value = mock_query
mock_query.order_by.return_value = mock_query
mock_query.all.return_value = []
# Act
response = FeedbackService.export_feedbacks(app_id="app-456", format_type="json")
# Assert
json_content = json.loads(response.get_data(as_text=True))
assert json_content["export_info"]["total_records"] == 0
assert len(json_content["feedback_data"]) == 0
# Test 13: Long response truncation
@patch("services.feedback_service.db")
def test_export_feedbacks_long_response_truncation(self, mock_db, factory):
"""Test that long AI responses are truncated to 500 characters."""
# Arrange
long_answer = "A" * 600 # 600 characters
feedback = factory.create_feedback_mock()
message = factory.create_message_mock(answer=long_answer)
conversation = factory.create_conversation_mock()
app = factory.create_app_mock()
account = factory.create_account_mock()
mock_query = MagicMock()
mock_db.session.query.return_value = mock_query
mock_query.join.return_value = mock_query
mock_query.outerjoin.return_value = mock_query
mock_query.where.return_value = mock_query
mock_query.filter.return_value = mock_query
mock_query.order_by.return_value = mock_query
mock_query.all.return_value = [(feedback, message, conversation, app, account)]
# Act
response = FeedbackService.export_feedbacks(app_id="app-456", format_type="json")
# Assert
json_content = json.loads(response.get_data(as_text=True))
ai_response = json_content["feedback_data"][0]["ai_response"]
assert len(ai_response) == 503 # 500 + "..."
assert ai_response.endswith("...")
# Test 14: Null account (end user feedback)
@patch("services.feedback_service.db")
def test_export_feedbacks_null_account(self, mock_db, factory):
"""Test handling of feedback from end users (no account)."""
# Arrange
feedback = factory.create_feedback_mock(from_account_id=None)
message = factory.create_message_mock()
conversation = factory.create_conversation_mock()
app = factory.create_app_mock()
account = None # No account for end user
mock_query = MagicMock()
mock_db.session.query.return_value = mock_query
mock_query.join.return_value = mock_query
mock_query.outerjoin.return_value = mock_query
mock_query.where.return_value = mock_query
mock_query.filter.return_value = mock_query
mock_query.order_by.return_value = mock_query
mock_query.all.return_value = [(feedback, message, conversation, app, account)]
# Act
response = FeedbackService.export_feedbacks(app_id="app-456", format_type="json")
# Assert
json_content = json.loads(response.get_data(as_text=True))
assert json_content["feedback_data"][0]["from_account_name"] == ""
# Test 15: Null conversation name
@patch("services.feedback_service.db")
def test_export_feedbacks_null_conversation_name(self, mock_db, factory):
"""Test handling of conversations without names."""
# Arrange
feedback = factory.create_feedback_mock()
message = factory.create_message_mock()
conversation = factory.create_conversation_mock(name=None)
app = factory.create_app_mock()
account = factory.create_account_mock()
mock_query = MagicMock()
mock_db.session.query.return_value = mock_query
mock_query.join.return_value = mock_query
mock_query.outerjoin.return_value = mock_query
mock_query.where.return_value = mock_query
mock_query.filter.return_value = mock_query
mock_query.order_by.return_value = mock_query
mock_query.all.return_value = [(feedback, message, conversation, app, account)]
# Act
response = FeedbackService.export_feedbacks(app_id="app-456", format_type="json")
# Assert
json_content = json.loads(response.get_data(as_text=True))
assert json_content["feedback_data"][0]["conversation_name"] == ""
# Test 16: Dislike rating emoji
@patch("services.feedback_service.db")
def test_export_feedbacks_dislike_rating(self, mock_db, factory):
"""Test that dislike rating shows thumbs down emoji."""
# Arrange
feedback = factory.create_feedback_mock(rating="dislike")
message = factory.create_message_mock()
conversation = factory.create_conversation_mock()
app = factory.create_app_mock()
account = factory.create_account_mock()
mock_query = MagicMock()
mock_db.session.query.return_value = mock_query
mock_query.join.return_value = mock_query
mock_query.outerjoin.return_value = mock_query
mock_query.where.return_value = mock_query
mock_query.filter.return_value = mock_query
mock_query.order_by.return_value = mock_query
mock_query.all.return_value = [(feedback, message, conversation, app, account)]
# Act
response = FeedbackService.export_feedbacks(app_id="app-456", format_type="json")
# Assert
json_content = json.loads(response.get_data(as_text=True))
assert json_content["feedback_data"][0]["feedback_rating"] == "👎"
assert json_content["feedback_data"][0]["feedback_rating_raw"] == "dislike"
# Test 17: Combined filters
@patch("services.feedback_service.db")
def test_export_feedbacks_combined_filters(self, mock_db, factory):
"""Test applying multiple filters simultaneously."""
# Arrange
mock_query = MagicMock()
mock_db.session.query.return_value = mock_query
mock_query.join.return_value = mock_query
mock_query.outerjoin.return_value = mock_query
mock_query.where.return_value = mock_query
mock_query.filter.return_value = mock_query
mock_query.order_by.return_value = mock_query
mock_query.all.return_value = []
# Act
FeedbackService.export_feedbacks(
app_id="app-456",
from_source="admin",
rating="like",
has_comment=True,
start_date="2024-01-01",
end_date="2024-12-31",
)
# Assert
# Should have called filter multiple times for each condition
assert mock_query.filter.call_count >= 4
# Test 18: Message query fallback to inputs
@patch("services.feedback_service.db")
def test_export_feedbacks_message_query_from_inputs(self, mock_db, factory):
"""Test fallback to inputs.query when message.query is None."""
# Arrange
feedback = factory.create_feedback_mock()
message = factory.create_message_mock(query=None, inputs={"query": "Query from inputs"})
conversation = factory.create_conversation_mock()
app = factory.create_app_mock()
account = factory.create_account_mock()
mock_query = MagicMock()
mock_db.session.query.return_value = mock_query
mock_query.join.return_value = mock_query
mock_query.outerjoin.return_value = mock_query
mock_query.where.return_value = mock_query
mock_query.filter.return_value = mock_query
mock_query.order_by.return_value = mock_query
mock_query.all.return_value = [(feedback, message, conversation, app, account)]
# Act
response = FeedbackService.export_feedbacks(app_id="app-456", format_type="json")
# Assert
json_content = json.loads(response.get_data(as_text=True))
assert json_content["feedback_data"][0]["user_query"] == "Query from inputs"
# Test 19: Empty feedback content
@patch("services.feedback_service.db")
def test_export_feedbacks_empty_feedback_content(self, mock_db, factory):
"""Test handling of feedback with empty/null content."""
# Arrange
feedback = factory.create_feedback_mock(content=None)
message = factory.create_message_mock()
conversation = factory.create_conversation_mock()
app = factory.create_app_mock()
account = factory.create_account_mock()
mock_query = MagicMock()
mock_db.session.query.return_value = mock_query
mock_query.join.return_value = mock_query
mock_query.outerjoin.return_value = mock_query
mock_query.where.return_value = mock_query
mock_query.filter.return_value = mock_query
mock_query.order_by.return_value = mock_query
mock_query.all.return_value = [(feedback, message, conversation, app, account)]
# Act
response = FeedbackService.export_feedbacks(app_id="app-456", format_type="json")
# Assert
json_content = json.loads(response.get_data(as_text=True))
assert json_content["feedback_data"][0]["feedback_comment"] == ""
assert json_content["feedback_data"][0]["has_comment"] == "No"
# Test 20: CSV headers validation
@patch("services.feedback_service.db")
def test_export_feedbacks_csv_headers(self, mock_db, factory, sample_feedback_data):
"""Test that CSV contains all expected headers."""
# Arrange
mock_query = MagicMock()
mock_db.session.query.return_value = mock_query
mock_query.join.return_value = mock_query
mock_query.outerjoin.return_value = mock_query
mock_query.where.return_value = mock_query
mock_query.filter.return_value = mock_query
mock_query.order_by.return_value = mock_query
mock_query.all.return_value = sample_feedback_data
expected_headers = [
"feedback_id",
"app_name",
"app_id",
"conversation_id",
"conversation_name",
"message_id",
"user_query",
"ai_response",
"feedback_rating",
"feedback_rating_raw",
"feedback_comment",
"feedback_source",
"feedback_date",
"message_date",
"from_account_name",
"from_end_user_id",
"has_comment",
]
# Act
response = FeedbackService.export_feedbacks(app_id="app-456", format_type="csv")
# Assert
csv_content = response.get_data(as_text=True)
reader = csv.DictReader(io.StringIO(csv_content))
assert list(reader.fieldnames) == expected_headers