mirror of
https://github.com/langgenius/dify.git
synced 2026-03-15 20:07:23 +08:00
1686 lines
69 KiB
Python
1686 lines
69 KiB
Python
"""
|
|
Unit tests for services.annotation_service
|
|
"""
|
|
|
|
from io import BytesIO
|
|
from types import SimpleNamespace
|
|
from typing import Any, cast
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pandas as pd
|
|
import pytest
|
|
from werkzeug.datastructures import FileStorage
|
|
from werkzeug.exceptions import NotFound
|
|
|
|
from models.model import App, AppAnnotationHitHistory, AppAnnotationSetting, Message, MessageAnnotation
|
|
from services.annotation_service import AppAnnotationService
|
|
|
|
|
|
def _make_app(app_id: str = "app-1", tenant_id: str = "tenant-1") -> MagicMock:
|
|
app = MagicMock(spec=App)
|
|
app.id = app_id
|
|
app.tenant_id = tenant_id
|
|
app.status = "normal"
|
|
return app
|
|
|
|
|
|
def _make_user(user_id: str = "user-1") -> MagicMock:
|
|
user = MagicMock()
|
|
user.id = user_id
|
|
return user
|
|
|
|
|
|
def _make_message(message_id: str = "msg-1", app_id: str = "app-1") -> MagicMock:
|
|
message = MagicMock(spec=Message)
|
|
message.id = message_id
|
|
message.app_id = app_id
|
|
message.conversation_id = "conv-1"
|
|
message.query = "default-question"
|
|
message.annotation = None
|
|
return message
|
|
|
|
|
|
def _make_annotation(annotation_id: str = "ann-1") -> MagicMock:
|
|
annotation = MagicMock(spec=MessageAnnotation)
|
|
annotation.id = annotation_id
|
|
annotation.content = ""
|
|
annotation.question = ""
|
|
annotation.question_text = ""
|
|
return annotation
|
|
|
|
|
|
def _make_setting(setting_id: str = "setting-1", with_detail: bool = True) -> MagicMock:
|
|
setting = MagicMock(spec=AppAnnotationSetting)
|
|
setting.id = setting_id
|
|
setting.score_threshold = 0.5
|
|
setting.collection_binding_id = "collection-1"
|
|
if with_detail:
|
|
setting.collection_binding_detail = SimpleNamespace(provider_name="provider-a", model_name="model-a")
|
|
else:
|
|
setting.collection_binding_detail = None
|
|
return setting
|
|
|
|
|
|
def _make_file(content: bytes) -> FileStorage:
|
|
return FileStorage(stream=BytesIO(content))
|
|
|
|
|
|
class TestAppAnnotationServiceUpInsert:
|
|
"""Test suite for up_insert_app_annotation_from_message."""
|
|
|
|
def test_up_insert_app_annotation_from_message_should_raise_not_found_when_app_missing(self) -> None:
|
|
"""Test missing app raises NotFound."""
|
|
# Arrange
|
|
args = {"answer": "hello", "message_id": "msg-1"}
|
|
current_user = _make_user()
|
|
tenant_id = "tenant-1"
|
|
|
|
with (
|
|
patch("services.annotation_service.current_account_with_tenant", return_value=(current_user, tenant_id)),
|
|
patch("services.annotation_service.db") as mock_db,
|
|
):
|
|
app_query = MagicMock()
|
|
app_query.where.return_value = app_query
|
|
app_query.first.return_value = None
|
|
mock_db.session.query.return_value = app_query
|
|
|
|
# Act & Assert
|
|
with pytest.raises(NotFound):
|
|
AppAnnotationService.up_insert_app_annotation_from_message(args, "app-1")
|
|
|
|
def test_up_insert_app_annotation_from_message_should_raise_value_error_when_answer_missing(self) -> None:
|
|
"""Test missing answer and content raises ValueError."""
|
|
# Arrange
|
|
args = {"message_id": "msg-1"}
|
|
current_user = _make_user()
|
|
tenant_id = "tenant-1"
|
|
app = _make_app()
|
|
|
|
with (
|
|
patch("services.annotation_service.current_account_with_tenant", return_value=(current_user, tenant_id)),
|
|
patch("services.annotation_service.db") as mock_db,
|
|
):
|
|
app_query = MagicMock()
|
|
app_query.where.return_value = app_query
|
|
app_query.first.return_value = app
|
|
mock_db.session.query.return_value = app_query
|
|
|
|
# Act & Assert
|
|
with pytest.raises(ValueError):
|
|
AppAnnotationService.up_insert_app_annotation_from_message(args, app.id)
|
|
|
|
def test_up_insert_app_annotation_from_message_should_raise_not_found_when_message_missing(self) -> None:
|
|
"""Test missing message raises NotFound."""
|
|
# Arrange
|
|
args = {"answer": "hello", "message_id": "msg-1"}
|
|
current_user = _make_user()
|
|
tenant_id = "tenant-1"
|
|
app = _make_app()
|
|
|
|
with (
|
|
patch("services.annotation_service.current_account_with_tenant", return_value=(current_user, tenant_id)),
|
|
patch("services.annotation_service.db") as mock_db,
|
|
):
|
|
app_query = MagicMock()
|
|
app_query.where.return_value = app_query
|
|
app_query.first.return_value = app
|
|
|
|
message_query = MagicMock()
|
|
message_query.where.return_value = message_query
|
|
message_query.first.return_value = None
|
|
|
|
mock_db.session.query.side_effect = [app_query, message_query]
|
|
|
|
# Act & Assert
|
|
with pytest.raises(NotFound):
|
|
AppAnnotationService.up_insert_app_annotation_from_message(args, app.id)
|
|
|
|
def test_up_insert_app_annotation_from_message_should_update_existing_annotation_when_found(self) -> None:
|
|
"""Test existing annotation is updated and indexed."""
|
|
# Arrange
|
|
args = {"answer": "updated", "message_id": "msg-1"}
|
|
current_user = _make_user()
|
|
tenant_id = "tenant-1"
|
|
app = _make_app()
|
|
annotation = _make_annotation("ann-1")
|
|
message = _make_message(message_id="msg-1", app_id=app.id)
|
|
message.annotation = annotation
|
|
setting = _make_setting()
|
|
|
|
with (
|
|
patch("services.annotation_service.current_account_with_tenant", return_value=(current_user, tenant_id)),
|
|
patch("services.annotation_service.db") as mock_db,
|
|
patch("services.annotation_service.add_annotation_to_index_task") as mock_task,
|
|
):
|
|
app_query = MagicMock()
|
|
app_query.where.return_value = app_query
|
|
app_query.first.return_value = app
|
|
|
|
message_query = MagicMock()
|
|
message_query.where.return_value = message_query
|
|
message_query.first.return_value = message
|
|
|
|
setting_query = MagicMock()
|
|
setting_query.where.return_value = setting_query
|
|
setting_query.first.return_value = setting
|
|
|
|
mock_db.session.query.side_effect = [app_query, message_query, setting_query]
|
|
|
|
# Act
|
|
result = AppAnnotationService.up_insert_app_annotation_from_message(args, app.id)
|
|
|
|
# Assert
|
|
assert result == annotation
|
|
assert annotation.content == "updated"
|
|
assert annotation.question == message.query
|
|
mock_db.session.add.assert_called_once_with(annotation)
|
|
mock_db.session.commit.assert_called_once()
|
|
mock_task.delay.assert_called_once_with(
|
|
annotation.id,
|
|
message.query,
|
|
tenant_id,
|
|
app.id,
|
|
setting.collection_binding_id,
|
|
)
|
|
|
|
def test_up_insert_app_annotation_from_message_should_create_annotation_when_message_has_no_annotation(
|
|
self,
|
|
) -> None:
|
|
"""Test new annotation is created when message has no annotation."""
|
|
# Arrange
|
|
args = {"answer": "hello", "message_id": "msg-1", "question": "q1"}
|
|
current_user = _make_user()
|
|
tenant_id = "tenant-1"
|
|
app = _make_app()
|
|
message = _make_message(message_id="msg-1", app_id=app.id)
|
|
message.annotation = None
|
|
annotation_instance = _make_annotation("ann-1")
|
|
|
|
with (
|
|
patch("services.annotation_service.current_account_with_tenant", return_value=(current_user, tenant_id)),
|
|
patch("services.annotation_service.db") as mock_db,
|
|
patch("services.annotation_service.MessageAnnotation", return_value=annotation_instance) as mock_cls,
|
|
patch("services.annotation_service.add_annotation_to_index_task") as mock_task,
|
|
):
|
|
app_query = MagicMock()
|
|
app_query.where.return_value = app_query
|
|
app_query.first.return_value = app
|
|
|
|
message_query = MagicMock()
|
|
message_query.where.return_value = message_query
|
|
message_query.first.return_value = message
|
|
|
|
setting_query = MagicMock()
|
|
setting_query.where.return_value = setting_query
|
|
setting_query.first.return_value = None
|
|
|
|
mock_db.session.query.side_effect = [app_query, message_query, setting_query]
|
|
|
|
# Act
|
|
result = AppAnnotationService.up_insert_app_annotation_from_message(args, app.id)
|
|
|
|
# Assert
|
|
assert result == annotation_instance
|
|
mock_cls.assert_called_once_with(
|
|
app_id=app.id,
|
|
conversation_id=message.conversation_id,
|
|
message_id=message.id,
|
|
content="hello",
|
|
question="q1",
|
|
account_id=current_user.id,
|
|
)
|
|
mock_db.session.add.assert_called_once_with(annotation_instance)
|
|
mock_db.session.commit.assert_called_once()
|
|
mock_task.delay.assert_not_called()
|
|
|
|
def test_up_insert_app_annotation_from_message_should_raise_value_error_when_question_missing(self) -> None:
|
|
"""Test missing question without message_id raises ValueError."""
|
|
# Arrange
|
|
args = {"answer": "hello"}
|
|
current_user = _make_user()
|
|
tenant_id = "tenant-1"
|
|
app = _make_app()
|
|
|
|
with (
|
|
patch("services.annotation_service.current_account_with_tenant", return_value=(current_user, tenant_id)),
|
|
patch("services.annotation_service.db") as mock_db,
|
|
):
|
|
app_query = MagicMock()
|
|
app_query.where.return_value = app_query
|
|
app_query.first.return_value = app
|
|
mock_db.session.query.return_value = app_query
|
|
|
|
# Act & Assert
|
|
with pytest.raises(ValueError):
|
|
AppAnnotationService.up_insert_app_annotation_from_message(args, app.id)
|
|
|
|
def test_up_insert_app_annotation_from_message_should_create_annotation_when_message_missing(self) -> None:
|
|
"""Test annotation is created when message_id is not provided."""
|
|
# Arrange
|
|
args = {"answer": "hello", "question": "q1"}
|
|
current_user = _make_user()
|
|
tenant_id = "tenant-1"
|
|
app = _make_app()
|
|
annotation_instance = _make_annotation("ann-1")
|
|
setting = _make_setting()
|
|
|
|
with (
|
|
patch("services.annotation_service.current_account_with_tenant", return_value=(current_user, tenant_id)),
|
|
patch("services.annotation_service.db") as mock_db,
|
|
patch("services.annotation_service.MessageAnnotation", return_value=annotation_instance) as mock_cls,
|
|
patch("services.annotation_service.add_annotation_to_index_task") as mock_task,
|
|
):
|
|
app_query = MagicMock()
|
|
app_query.where.return_value = app_query
|
|
app_query.first.return_value = app
|
|
|
|
setting_query = MagicMock()
|
|
setting_query.where.return_value = setting_query
|
|
setting_query.first.return_value = setting
|
|
|
|
mock_db.session.query.side_effect = [app_query, setting_query]
|
|
|
|
# Act
|
|
result = AppAnnotationService.up_insert_app_annotation_from_message(args, app.id)
|
|
|
|
# Assert
|
|
assert result == annotation_instance
|
|
mock_cls.assert_called_once_with(
|
|
app_id=app.id,
|
|
content="hello",
|
|
question="q1",
|
|
account_id=current_user.id,
|
|
)
|
|
mock_db.session.add.assert_called_once_with(annotation_instance)
|
|
mock_db.session.commit.assert_called_once()
|
|
mock_task.delay.assert_called_once_with(
|
|
annotation_instance.id,
|
|
"q1",
|
|
tenant_id,
|
|
app.id,
|
|
setting.collection_binding_id,
|
|
)
|
|
|
|
|
|
class TestAppAnnotationServiceEnableDisable:
|
|
"""Test suite for enable/disable app annotation."""
|
|
|
|
def test_enable_app_annotation_should_return_processing_when_cache_hit(self) -> None:
|
|
"""Test cache hit returns processing status."""
|
|
# Arrange
|
|
args = {"score_threshold": 0.5, "embedding_provider_name": "p", "embedding_model_name": "m"}
|
|
|
|
with (
|
|
patch("services.annotation_service.redis_client") as mock_redis,
|
|
patch("services.annotation_service.enable_annotation_reply_task") as mock_task,
|
|
):
|
|
mock_redis.get.return_value = "job-1"
|
|
|
|
# Act
|
|
result = AppAnnotationService.enable_app_annotation(args, "app-1")
|
|
|
|
# Assert
|
|
assert result == {"job_id": "job-1", "job_status": "processing"}
|
|
mock_task.delay.assert_not_called()
|
|
|
|
def test_enable_app_annotation_should_enqueue_job_when_cache_miss(self) -> None:
|
|
"""Test cache miss enqueues enable task."""
|
|
# Arrange
|
|
args = {"score_threshold": 0.5, "embedding_provider_name": "p", "embedding_model_name": "m"}
|
|
current_user = _make_user("user-1")
|
|
tenant_id = "tenant-1"
|
|
|
|
with (
|
|
patch("services.annotation_service.redis_client") as mock_redis,
|
|
patch("services.annotation_service.current_account_with_tenant", return_value=(current_user, tenant_id)),
|
|
patch("services.annotation_service.uuid.uuid4", return_value="uuid-1"),
|
|
patch("services.annotation_service.enable_annotation_reply_task") as mock_task,
|
|
):
|
|
mock_redis.get.return_value = None
|
|
|
|
# Act
|
|
result = AppAnnotationService.enable_app_annotation(args, "app-1")
|
|
|
|
# Assert
|
|
assert result == {"job_id": "uuid-1", "job_status": "waiting"}
|
|
mock_redis.setnx.assert_called_once_with("enable_app_annotation_job_uuid-1", "waiting")
|
|
mock_task.delay.assert_called_once_with(
|
|
"uuid-1",
|
|
"app-1",
|
|
current_user.id,
|
|
tenant_id,
|
|
0.5,
|
|
"p",
|
|
"m",
|
|
)
|
|
|
|
def test_disable_app_annotation_should_return_processing_when_cache_hit(self) -> None:
|
|
"""Test disable cache hit returns processing status."""
|
|
# Arrange
|
|
tenant_id = "tenant-1"
|
|
with (
|
|
patch("services.annotation_service.redis_client") as mock_redis,
|
|
patch("services.annotation_service.current_account_with_tenant", return_value=(_make_user(), tenant_id)),
|
|
patch("services.annotation_service.disable_annotation_reply_task") as mock_task,
|
|
):
|
|
mock_redis.get.return_value = "job-2"
|
|
|
|
# Act
|
|
result = AppAnnotationService.disable_app_annotation("app-1")
|
|
|
|
# Assert
|
|
assert result == {"job_id": "job-2", "job_status": "processing"}
|
|
mock_task.delay.assert_not_called()
|
|
|
|
def test_disable_app_annotation_should_enqueue_job_when_cache_miss(self) -> None:
|
|
"""Test disable cache miss enqueues disable task."""
|
|
# Arrange
|
|
tenant_id = "tenant-1"
|
|
|
|
with (
|
|
patch("services.annotation_service.redis_client") as mock_redis,
|
|
patch("services.annotation_service.current_account_with_tenant", return_value=(_make_user(), tenant_id)),
|
|
patch("services.annotation_service.uuid.uuid4", return_value="uuid-2"),
|
|
patch("services.annotation_service.disable_annotation_reply_task") as mock_task,
|
|
):
|
|
mock_redis.get.return_value = None
|
|
|
|
# Act
|
|
result = AppAnnotationService.disable_app_annotation("app-1")
|
|
|
|
# Assert
|
|
assert result == {"job_id": "uuid-2", "job_status": "waiting"}
|
|
mock_redis.setnx.assert_called_once_with("disable_app_annotation_job_uuid-2", "waiting")
|
|
mock_task.delay.assert_called_once_with("uuid-2", "app-1", tenant_id)
|
|
|
|
|
|
class TestAppAnnotationServiceListAndExport:
|
|
"""Test suite for list and export methods."""
|
|
|
|
def test_get_annotation_list_by_app_id_should_raise_not_found_when_app_missing(self) -> None:
|
|
"""Test missing app raises NotFound."""
|
|
# Arrange
|
|
tenant_id = "tenant-1"
|
|
|
|
with (
|
|
patch("services.annotation_service.current_account_with_tenant", return_value=(_make_user(), tenant_id)),
|
|
patch("services.annotation_service.db") as mock_db,
|
|
):
|
|
app_query = MagicMock()
|
|
app_query.where.return_value = app_query
|
|
app_query.first.return_value = None
|
|
mock_db.session.query.return_value = app_query
|
|
|
|
# Act & Assert
|
|
with pytest.raises(NotFound):
|
|
AppAnnotationService.get_annotation_list_by_app_id("app-1", 1, 10, "")
|
|
|
|
def test_get_annotation_list_by_app_id_should_return_items_with_keyword(self) -> None:
|
|
"""Test keyword search returns items and total."""
|
|
# Arrange
|
|
tenant_id = "tenant-1"
|
|
app = _make_app()
|
|
pagination = SimpleNamespace(items=["a1"], total=1)
|
|
|
|
with (
|
|
patch("services.annotation_service.current_account_with_tenant", return_value=(_make_user(), tenant_id)),
|
|
patch("services.annotation_service.db") as mock_db,
|
|
patch("libs.helper.escape_like_pattern", return_value="safe"),
|
|
):
|
|
app_query = MagicMock()
|
|
app_query.where.return_value = app_query
|
|
app_query.first.return_value = app
|
|
mock_db.session.query.return_value = app_query
|
|
mock_db.paginate.return_value = pagination
|
|
|
|
# Act
|
|
items, total = AppAnnotationService.get_annotation_list_by_app_id(app.id, 1, 10, "keyword")
|
|
|
|
# Assert
|
|
assert items == ["a1"]
|
|
assert total == 1
|
|
|
|
def test_get_annotation_list_by_app_id_should_return_items_without_keyword(self) -> None:
|
|
"""Test list query without keyword returns paginated items."""
|
|
# Arrange
|
|
tenant_id = "tenant-1"
|
|
app = _make_app()
|
|
pagination = SimpleNamespace(items=["a1", "a2"], total=2)
|
|
|
|
with (
|
|
patch("services.annotation_service.current_account_with_tenant", return_value=(_make_user(), tenant_id)),
|
|
patch("services.annotation_service.db") as mock_db,
|
|
):
|
|
app_query = MagicMock()
|
|
app_query.where.return_value = app_query
|
|
app_query.first.return_value = app
|
|
mock_db.session.query.return_value = app_query
|
|
mock_db.paginate.return_value = pagination
|
|
|
|
# Act
|
|
items, total = AppAnnotationService.get_annotation_list_by_app_id(app.id, 1, 10, "")
|
|
|
|
# Assert
|
|
assert items == ["a1", "a2"]
|
|
assert total == 2
|
|
|
|
def test_export_annotation_list_by_app_id_should_sanitize_fields(self) -> None:
|
|
"""Test export sanitizes question and content fields."""
|
|
# Arrange
|
|
tenant_id = "tenant-1"
|
|
app = _make_app()
|
|
annotation1 = _make_annotation("ann-1")
|
|
annotation1.question = "=cmd"
|
|
annotation1.content = "+1"
|
|
annotation2 = _make_annotation("ann-2")
|
|
annotation2.question = "@bad"
|
|
annotation2.content = "-2"
|
|
|
|
with (
|
|
patch("services.annotation_service.current_account_with_tenant", return_value=(_make_user(), tenant_id)),
|
|
patch("services.annotation_service.db") as mock_db,
|
|
patch("services.annotation_service.CSVSanitizer.sanitize_value", side_effect=lambda v: f"safe:{v}"),
|
|
):
|
|
app_query = MagicMock()
|
|
app_query.where.return_value = app_query
|
|
app_query.first.return_value = app
|
|
|
|
annotation_query = MagicMock()
|
|
annotation_query.where.return_value = annotation_query
|
|
annotation_query.order_by.return_value = annotation_query
|
|
annotation_query.all.return_value = [annotation1, annotation2]
|
|
|
|
mock_db.session.query.side_effect = [app_query, annotation_query]
|
|
|
|
# Act
|
|
result = AppAnnotationService.export_annotation_list_by_app_id(app.id)
|
|
|
|
# Assert
|
|
assert result == [annotation1, annotation2]
|
|
assert annotation1.question == "safe:=cmd"
|
|
assert annotation1.content == "safe:+1"
|
|
assert annotation2.question == "safe:@bad"
|
|
assert annotation2.content == "safe:-2"
|
|
|
|
def test_export_annotation_list_by_app_id_should_raise_not_found_when_app_missing(self) -> None:
|
|
"""Test export raises NotFound when app is missing."""
|
|
# Arrange
|
|
tenant_id = "tenant-1"
|
|
|
|
with (
|
|
patch("services.annotation_service.current_account_with_tenant", return_value=(_make_user(), tenant_id)),
|
|
patch("services.annotation_service.db") as mock_db,
|
|
):
|
|
app_query = MagicMock()
|
|
app_query.where.return_value = app_query
|
|
app_query.first.return_value = None
|
|
mock_db.session.query.return_value = app_query
|
|
|
|
# Act & Assert
|
|
with pytest.raises(NotFound):
|
|
AppAnnotationService.export_annotation_list_by_app_id("app-1")
|
|
|
|
|
|
class TestAppAnnotationServiceDirectManipulation:
|
|
"""Test suite for direct insert/update/delete methods."""
|
|
|
|
def test_insert_app_annotation_directly_should_raise_not_found_when_app_missing(self) -> None:
|
|
"""Test insert raises NotFound when app is missing."""
|
|
# Arrange
|
|
args = {"answer": "hello", "question": "q1"}
|
|
tenant_id = "tenant-1"
|
|
|
|
with (
|
|
patch("services.annotation_service.current_account_with_tenant", return_value=(_make_user(), tenant_id)),
|
|
patch("services.annotation_service.db") as mock_db,
|
|
):
|
|
app_query = MagicMock()
|
|
app_query.where.return_value = app_query
|
|
app_query.first.return_value = None
|
|
mock_db.session.query.return_value = app_query
|
|
|
|
# Act & Assert
|
|
with pytest.raises(NotFound):
|
|
AppAnnotationService.insert_app_annotation_directly(args, "app-1")
|
|
|
|
def test_insert_app_annotation_directly_should_raise_value_error_when_question_missing(self) -> None:
|
|
"""Test missing question raises ValueError."""
|
|
# Arrange
|
|
args = {"answer": "hello"}
|
|
tenant_id = "tenant-1"
|
|
app = _make_app()
|
|
|
|
with (
|
|
patch("services.annotation_service.current_account_with_tenant", return_value=(_make_user(), tenant_id)),
|
|
patch("services.annotation_service.db") as mock_db,
|
|
):
|
|
app_query = MagicMock()
|
|
app_query.where.return_value = app_query
|
|
app_query.first.return_value = app
|
|
mock_db.session.query.return_value = app_query
|
|
|
|
# Act & Assert
|
|
with pytest.raises(ValueError):
|
|
AppAnnotationService.insert_app_annotation_directly(args, app.id)
|
|
|
|
def test_insert_app_annotation_directly_should_create_annotation_and_index(self) -> None:
|
|
"""Test insert creates annotation and triggers index task."""
|
|
# Arrange
|
|
args = {"answer": "hello", "question": "q1"}
|
|
current_user = _make_user("user-1")
|
|
tenant_id = "tenant-1"
|
|
app = _make_app()
|
|
annotation_instance = _make_annotation("ann-1")
|
|
setting = _make_setting()
|
|
|
|
with (
|
|
patch("services.annotation_service.current_account_with_tenant", return_value=(current_user, tenant_id)),
|
|
patch("services.annotation_service.db") as mock_db,
|
|
patch("services.annotation_service.MessageAnnotation", return_value=annotation_instance) as mock_cls,
|
|
patch("services.annotation_service.add_annotation_to_index_task") as mock_task,
|
|
):
|
|
app_query = MagicMock()
|
|
app_query.where.return_value = app_query
|
|
app_query.first.return_value = app
|
|
|
|
setting_query = MagicMock()
|
|
setting_query.where.return_value = setting_query
|
|
setting_query.first.return_value = setting
|
|
|
|
mock_db.session.query.side_effect = [app_query, setting_query]
|
|
|
|
# Act
|
|
result = AppAnnotationService.insert_app_annotation_directly(args, app.id)
|
|
|
|
# Assert
|
|
assert result == annotation_instance
|
|
mock_cls.assert_called_once_with(
|
|
app_id=app.id,
|
|
content="hello",
|
|
question="q1",
|
|
account_id=current_user.id,
|
|
)
|
|
mock_db.session.add.assert_called_once_with(annotation_instance)
|
|
mock_db.session.commit.assert_called_once()
|
|
mock_task.delay.assert_called_once_with(
|
|
annotation_instance.id,
|
|
"q1",
|
|
tenant_id,
|
|
app.id,
|
|
setting.collection_binding_id,
|
|
)
|
|
|
|
def test_update_app_annotation_directly_should_raise_not_found_when_annotation_missing(self) -> None:
|
|
"""Test missing annotation raises NotFound."""
|
|
# Arrange
|
|
args = {"answer": "hello", "question": "q1"}
|
|
tenant_id = "tenant-1"
|
|
app = _make_app()
|
|
|
|
with (
|
|
patch("services.annotation_service.current_account_with_tenant", return_value=(_make_user(), tenant_id)),
|
|
patch("services.annotation_service.db") as mock_db,
|
|
):
|
|
app_query = MagicMock()
|
|
app_query.where.return_value = app_query
|
|
app_query.first.return_value = app
|
|
|
|
annotation_query = MagicMock()
|
|
annotation_query.where.return_value = annotation_query
|
|
annotation_query.first.return_value = None
|
|
|
|
mock_db.session.query.side_effect = [app_query, annotation_query]
|
|
|
|
# Act & Assert
|
|
with pytest.raises(NotFound):
|
|
AppAnnotationService.update_app_annotation_directly(args, app.id, "ann-1")
|
|
|
|
def test_update_app_annotation_directly_should_raise_not_found_when_app_missing(self) -> None:
|
|
"""Test missing app raises NotFound in update path."""
|
|
# Arrange
|
|
args = {"answer": "hello", "question": "q1"}
|
|
tenant_id = "tenant-1"
|
|
|
|
with (
|
|
patch("services.annotation_service.current_account_with_tenant", return_value=(_make_user(), tenant_id)),
|
|
patch("services.annotation_service.db") as mock_db,
|
|
):
|
|
app_query = MagicMock()
|
|
app_query.where.return_value = app_query
|
|
app_query.first.return_value = None
|
|
mock_db.session.query.return_value = app_query
|
|
|
|
# Act & Assert
|
|
with pytest.raises(NotFound):
|
|
AppAnnotationService.update_app_annotation_directly(args, "app-1", "ann-1")
|
|
|
|
def test_update_app_annotation_directly_should_raise_value_error_when_question_missing(self) -> None:
|
|
"""Test missing question raises ValueError."""
|
|
# Arrange
|
|
args = {"answer": "hello"}
|
|
tenant_id = "tenant-1"
|
|
app = _make_app()
|
|
annotation = _make_annotation("ann-1")
|
|
|
|
with (
|
|
patch("services.annotation_service.current_account_with_tenant", return_value=(_make_user(), tenant_id)),
|
|
patch("services.annotation_service.db") as mock_db,
|
|
):
|
|
app_query = MagicMock()
|
|
app_query.where.return_value = app_query
|
|
app_query.first.return_value = app
|
|
|
|
annotation_query = MagicMock()
|
|
annotation_query.where.return_value = annotation_query
|
|
annotation_query.first.return_value = annotation
|
|
|
|
mock_db.session.query.side_effect = [app_query, annotation_query]
|
|
|
|
# Act & Assert
|
|
with pytest.raises(ValueError):
|
|
AppAnnotationService.update_app_annotation_directly(args, app.id, annotation.id)
|
|
|
|
def test_update_app_annotation_directly_should_update_annotation_and_index(self) -> None:
|
|
"""Test update changes fields and triggers index update."""
|
|
# Arrange
|
|
args = {"answer": "hello", "question": "q1"}
|
|
tenant_id = "tenant-1"
|
|
app = _make_app()
|
|
annotation = _make_annotation("ann-1")
|
|
annotation.question_text = "q1"
|
|
setting = _make_setting()
|
|
|
|
with (
|
|
patch("services.annotation_service.current_account_with_tenant", return_value=(_make_user(), tenant_id)),
|
|
patch("services.annotation_service.db") as mock_db,
|
|
patch("services.annotation_service.update_annotation_to_index_task") as mock_task,
|
|
):
|
|
app_query = MagicMock()
|
|
app_query.where.return_value = app_query
|
|
app_query.first.return_value = app
|
|
|
|
annotation_query = MagicMock()
|
|
annotation_query.where.return_value = annotation_query
|
|
annotation_query.first.return_value = annotation
|
|
|
|
setting_query = MagicMock()
|
|
setting_query.where.return_value = setting_query
|
|
setting_query.first.return_value = setting
|
|
|
|
mock_db.session.query.side_effect = [app_query, annotation_query, setting_query]
|
|
|
|
# Act
|
|
result = AppAnnotationService.update_app_annotation_directly(args, app.id, annotation.id)
|
|
|
|
# Assert
|
|
assert result == annotation
|
|
assert annotation.content == "hello"
|
|
assert annotation.question == "q1"
|
|
mock_db.session.commit.assert_called_once()
|
|
mock_task.delay.assert_called_once_with(
|
|
annotation.id,
|
|
annotation.question_text,
|
|
tenant_id,
|
|
app.id,
|
|
setting.collection_binding_id,
|
|
)
|
|
|
|
def test_delete_app_annotation_should_delete_annotation_and_histories(self) -> None:
|
|
"""Test delete removes annotation and hit histories."""
|
|
# Arrange
|
|
tenant_id = "tenant-1"
|
|
app = _make_app()
|
|
annotation = _make_annotation("ann-1")
|
|
history1 = MagicMock(spec=AppAnnotationHitHistory)
|
|
history2 = MagicMock(spec=AppAnnotationHitHistory)
|
|
setting = _make_setting()
|
|
|
|
with (
|
|
patch("services.annotation_service.current_account_with_tenant", return_value=(_make_user(), tenant_id)),
|
|
patch("services.annotation_service.db") as mock_db,
|
|
patch("services.annotation_service.delete_annotation_index_task") as mock_task,
|
|
):
|
|
app_query = MagicMock()
|
|
app_query.where.return_value = app_query
|
|
app_query.first.return_value = app
|
|
|
|
annotation_query = MagicMock()
|
|
annotation_query.where.return_value = annotation_query
|
|
annotation_query.first.return_value = annotation
|
|
|
|
setting_query = MagicMock()
|
|
setting_query.where.return_value = setting_query
|
|
setting_query.first.return_value = setting
|
|
|
|
scalars_result = MagicMock()
|
|
scalars_result.all.return_value = [history1, history2]
|
|
|
|
mock_db.session.query.side_effect = [app_query, annotation_query, setting_query]
|
|
mock_db.session.scalars.return_value = scalars_result
|
|
|
|
# Act
|
|
AppAnnotationService.delete_app_annotation(app.id, annotation.id)
|
|
|
|
# Assert
|
|
mock_db.session.delete.assert_any_call(annotation)
|
|
mock_db.session.delete.assert_any_call(history1)
|
|
mock_db.session.delete.assert_any_call(history2)
|
|
mock_db.session.commit.assert_called_once()
|
|
mock_task.delay.assert_called_once_with(
|
|
annotation.id,
|
|
app.id,
|
|
tenant_id,
|
|
setting.collection_binding_id,
|
|
)
|
|
|
|
def test_delete_app_annotation_should_raise_not_found_when_app_missing(self) -> None:
|
|
"""Test delete raises NotFound when app is missing."""
|
|
# Arrange
|
|
tenant_id = "tenant-1"
|
|
|
|
with (
|
|
patch("services.annotation_service.current_account_with_tenant", return_value=(_make_user(), tenant_id)),
|
|
patch("services.annotation_service.db") as mock_db,
|
|
):
|
|
app_query = MagicMock()
|
|
app_query.where.return_value = app_query
|
|
app_query.first.return_value = None
|
|
mock_db.session.query.return_value = app_query
|
|
|
|
# Act & Assert
|
|
with pytest.raises(NotFound):
|
|
AppAnnotationService.delete_app_annotation("app-1", "ann-1")
|
|
|
|
def test_delete_app_annotation_should_raise_not_found_when_annotation_missing(self) -> None:
|
|
"""Test delete raises NotFound when annotation is missing."""
|
|
# Arrange
|
|
tenant_id = "tenant-1"
|
|
app = _make_app()
|
|
|
|
with (
|
|
patch("services.annotation_service.current_account_with_tenant", return_value=(_make_user(), tenant_id)),
|
|
patch("services.annotation_service.db") as mock_db,
|
|
):
|
|
app_query = MagicMock()
|
|
app_query.where.return_value = app_query
|
|
app_query.first.return_value = app
|
|
|
|
annotation_query = MagicMock()
|
|
annotation_query.where.return_value = annotation_query
|
|
annotation_query.first.return_value = None
|
|
|
|
mock_db.session.query.side_effect = [app_query, annotation_query]
|
|
|
|
# Act & Assert
|
|
with pytest.raises(NotFound):
|
|
AppAnnotationService.delete_app_annotation(app.id, "ann-1")
|
|
|
|
def test_delete_app_annotations_in_batch_should_return_zero_when_none_found(self) -> None:
|
|
"""Test batch delete returns zero when no annotations found."""
|
|
# Arrange
|
|
tenant_id = "tenant-1"
|
|
app = _make_app()
|
|
|
|
with (
|
|
patch("services.annotation_service.current_account_with_tenant", return_value=(_make_user(), tenant_id)),
|
|
patch("services.annotation_service.db") as mock_db,
|
|
):
|
|
app_query = MagicMock()
|
|
app_query.where.return_value = app_query
|
|
app_query.first.return_value = app
|
|
|
|
annotations_query = MagicMock()
|
|
annotations_query.outerjoin.return_value = annotations_query
|
|
annotations_query.where.return_value = annotations_query
|
|
annotations_query.all.return_value = []
|
|
|
|
mock_db.session.query.side_effect = [app_query, annotations_query]
|
|
|
|
# Act
|
|
result = AppAnnotationService.delete_app_annotations_in_batch(app.id, ["ann-1"])
|
|
|
|
# Assert
|
|
assert result == {"deleted_count": 0}
|
|
|
|
def test_delete_app_annotations_in_batch_should_raise_not_found_when_app_missing(self) -> None:
|
|
"""Test batch delete raises NotFound when app is missing."""
|
|
# Arrange
|
|
tenant_id = "tenant-1"
|
|
|
|
with (
|
|
patch("services.annotation_service.current_account_with_tenant", return_value=(_make_user(), tenant_id)),
|
|
patch("services.annotation_service.db") as mock_db,
|
|
):
|
|
app_query = MagicMock()
|
|
app_query.where.return_value = app_query
|
|
app_query.first.return_value = None
|
|
mock_db.session.query.return_value = app_query
|
|
|
|
# Act & Assert
|
|
with pytest.raises(NotFound):
|
|
AppAnnotationService.delete_app_annotations_in_batch("app-1", ["ann-1"])
|
|
|
|
def test_delete_app_annotations_in_batch_should_delete_annotations_and_histories(self) -> None:
|
|
"""Test batch delete removes annotations and triggers index deletion."""
|
|
# Arrange
|
|
tenant_id = "tenant-1"
|
|
app = _make_app()
|
|
annotation1 = _make_annotation("ann-1")
|
|
annotation2 = _make_annotation("ann-2")
|
|
setting = _make_setting()
|
|
|
|
with (
|
|
patch("services.annotation_service.current_account_with_tenant", return_value=(_make_user(), tenant_id)),
|
|
patch("services.annotation_service.db") as mock_db,
|
|
patch("services.annotation_service.delete_annotation_index_task") as mock_task,
|
|
):
|
|
app_query = MagicMock()
|
|
app_query.where.return_value = app_query
|
|
app_query.first.return_value = app
|
|
|
|
annotations_query = MagicMock()
|
|
annotations_query.outerjoin.return_value = annotations_query
|
|
annotations_query.where.return_value = annotations_query
|
|
annotations_query.all.return_value = [(annotation1, setting), (annotation2, None)]
|
|
|
|
hit_history_query = MagicMock()
|
|
hit_history_query.where.return_value = hit_history_query
|
|
hit_history_query.delete.return_value = None
|
|
|
|
delete_query = MagicMock()
|
|
delete_query.where.return_value = delete_query
|
|
delete_query.delete.return_value = 2
|
|
|
|
mock_db.session.query.side_effect = [app_query, annotations_query, hit_history_query, delete_query]
|
|
|
|
# Act
|
|
result = AppAnnotationService.delete_app_annotations_in_batch(app.id, ["ann-1", "ann-2"])
|
|
|
|
# Assert
|
|
assert result == {"deleted_count": 2}
|
|
mock_task.delay.assert_called_once_with(annotation1.id, app.id, tenant_id, setting.collection_binding_id)
|
|
mock_db.session.commit.assert_called_once()
|
|
|
|
|
|
class TestAppAnnotationServiceBatchImport:
|
|
"""Test suite for batch import."""
|
|
|
|
def test_batch_import_app_annotations_should_raise_not_found_when_app_missing(self) -> None:
|
|
"""Test missing app raises NotFound."""
|
|
# Arrange
|
|
file = _make_file(b"question,answer\nq,a\n")
|
|
tenant_id = "tenant-1"
|
|
|
|
with (
|
|
patch("services.annotation_service.current_account_with_tenant", return_value=(_make_user(), tenant_id)),
|
|
patch("services.annotation_service.db") as mock_db,
|
|
):
|
|
app_query = MagicMock()
|
|
app_query.where.return_value = app_query
|
|
app_query.first.return_value = None
|
|
mock_db.session.query.return_value = app_query
|
|
|
|
# Act & Assert
|
|
with pytest.raises(NotFound):
|
|
AppAnnotationService.batch_import_app_annotations("app-1", file)
|
|
|
|
def test_batch_import_app_annotations_should_return_error_when_columns_invalid(self) -> None:
|
|
"""Test invalid column count returns error message."""
|
|
# Arrange
|
|
file = _make_file(b"question\nq\n")
|
|
tenant_id = "tenant-1"
|
|
app = _make_app()
|
|
df = pd.DataFrame({"q": ["only"]})
|
|
|
|
with (
|
|
patch("services.annotation_service.current_account_with_tenant", return_value=(_make_user(), tenant_id)),
|
|
patch("services.annotation_service.db") as mock_db,
|
|
patch("services.annotation_service.pd.read_csv", return_value=df),
|
|
patch(
|
|
"configs.dify_config",
|
|
new=SimpleNamespace(ANNOTATION_IMPORT_MAX_RECORDS=5, ANNOTATION_IMPORT_MIN_RECORDS=1),
|
|
),
|
|
):
|
|
app_query = MagicMock()
|
|
app_query.where.return_value = app_query
|
|
app_query.first.return_value = app
|
|
mock_db.session.query.return_value = app_query
|
|
|
|
# Act
|
|
result = AppAnnotationService.batch_import_app_annotations(app.id, file)
|
|
|
|
# Assert
|
|
error_msg = cast(str, result["error_msg"])
|
|
assert "Invalid CSV format" in error_msg
|
|
|
|
def test_batch_import_app_annotations_should_return_error_when_file_empty(self) -> None:
|
|
"""Test empty file returns validation error before CSV parsing."""
|
|
# Arrange
|
|
file = _make_file(b"")
|
|
tenant_id = "tenant-1"
|
|
app = _make_app()
|
|
|
|
with (
|
|
patch("services.annotation_service.current_account_with_tenant", return_value=(_make_user(), tenant_id)),
|
|
patch("services.annotation_service.db") as mock_db,
|
|
patch(
|
|
"configs.dify_config",
|
|
new=SimpleNamespace(ANNOTATION_IMPORT_MAX_RECORDS=5, ANNOTATION_IMPORT_MIN_RECORDS=1),
|
|
),
|
|
):
|
|
app_query = MagicMock()
|
|
app_query.where.return_value = app_query
|
|
app_query.first.return_value = app
|
|
mock_db.session.query.return_value = app_query
|
|
|
|
# Act
|
|
result = AppAnnotationService.batch_import_app_annotations(app.id, file)
|
|
|
|
# Assert
|
|
error_msg = cast(str, result["error_msg"])
|
|
assert "empty or invalid" in error_msg
|
|
|
|
def test_batch_import_app_annotations_should_return_error_when_min_records_not_met(self) -> None:
|
|
"""Test min records validation returns error message."""
|
|
# Arrange
|
|
file = _make_file(b"question,answer\nq,a\n")
|
|
tenant_id = "tenant-1"
|
|
app = _make_app()
|
|
df = pd.DataFrame({"q": ["q1"], "a": ["a1"]})
|
|
features = SimpleNamespace(billing=SimpleNamespace(enabled=False), annotation_quota_limit=None)
|
|
|
|
with (
|
|
patch("services.annotation_service.current_account_with_tenant", return_value=(_make_user(), tenant_id)),
|
|
patch("services.annotation_service.db") as mock_db,
|
|
patch("services.annotation_service.pd.read_csv", return_value=df),
|
|
patch("services.annotation_service.FeatureService.get_features", return_value=features),
|
|
patch(
|
|
"configs.dify_config",
|
|
new=SimpleNamespace(ANNOTATION_IMPORT_MAX_RECORDS=5, ANNOTATION_IMPORT_MIN_RECORDS=2),
|
|
),
|
|
):
|
|
app_query = MagicMock()
|
|
app_query.where.return_value = app_query
|
|
app_query.first.return_value = app
|
|
mock_db.session.query.return_value = app_query
|
|
|
|
# Act
|
|
result = AppAnnotationService.batch_import_app_annotations(app.id, file)
|
|
|
|
# Assert
|
|
error_msg = cast(str, result["error_msg"])
|
|
assert "at least" in error_msg
|
|
|
|
def test_batch_import_app_annotations_should_return_error_when_row_limit_exceeded(self) -> None:
|
|
"""Test row count over max limit returns explicit error."""
|
|
# Arrange
|
|
file = _make_file(b"question,answer\nq1,a1\nq2,a2\n")
|
|
tenant_id = "tenant-1"
|
|
app = _make_app()
|
|
df = pd.DataFrame({"q": ["q1", "q2"], "a": ["a1", "a2"]})
|
|
|
|
with (
|
|
patch("services.annotation_service.current_account_with_tenant", return_value=(_make_user(), tenant_id)),
|
|
patch("services.annotation_service.db") as mock_db,
|
|
patch("services.annotation_service.pd.read_csv", return_value=df),
|
|
patch(
|
|
"configs.dify_config",
|
|
new=SimpleNamespace(ANNOTATION_IMPORT_MAX_RECORDS=1, ANNOTATION_IMPORT_MIN_RECORDS=1),
|
|
),
|
|
):
|
|
app_query = MagicMock()
|
|
app_query.where.return_value = app_query
|
|
app_query.first.return_value = app
|
|
mock_db.session.query.return_value = app_query
|
|
|
|
# Act
|
|
result = AppAnnotationService.batch_import_app_annotations(app.id, file)
|
|
|
|
# Assert
|
|
error_msg = cast(str, result["error_msg"])
|
|
assert "too many records" in error_msg
|
|
|
|
def test_batch_import_app_annotations_should_skip_malformed_rows_and_fail_min_records(self) -> None:
|
|
"""Test malformed row extraction is skipped and can fail min record validation."""
|
|
# Arrange
|
|
file = _make_file(b"question,answer\nq,a\n")
|
|
tenant_id = "tenant-1"
|
|
app = _make_app()
|
|
malformed_row = MagicMock()
|
|
malformed_row.iloc.__getitem__.side_effect = IndexError()
|
|
df = MagicMock()
|
|
df.columns = ["q", "a"]
|
|
df.iterrows.return_value = [(0, malformed_row)]
|
|
|
|
with (
|
|
patch("services.annotation_service.current_account_with_tenant", return_value=(_make_user(), tenant_id)),
|
|
patch("services.annotation_service.db") as mock_db,
|
|
patch("services.annotation_service.pd.read_csv", return_value=df),
|
|
patch(
|
|
"configs.dify_config",
|
|
new=SimpleNamespace(ANNOTATION_IMPORT_MAX_RECORDS=5, ANNOTATION_IMPORT_MIN_RECORDS=1),
|
|
),
|
|
):
|
|
app_query = MagicMock()
|
|
app_query.where.return_value = app_query
|
|
app_query.first.return_value = app
|
|
mock_db.session.query.return_value = app_query
|
|
|
|
# Act
|
|
result = AppAnnotationService.batch_import_app_annotations(app.id, file)
|
|
|
|
# Assert
|
|
error_msg = cast(str, result["error_msg"])
|
|
assert "at least" in error_msg
|
|
|
|
def test_batch_import_app_annotations_should_skip_nan_rows_and_fail_min_records(self) -> None:
|
|
"""Test NaN rows are skipped by validation and reported via min record check."""
|
|
# Arrange
|
|
file = _make_file(b"question,answer\nnan,nan\n")
|
|
tenant_id = "tenant-1"
|
|
app = _make_app()
|
|
df = pd.DataFrame({"q": ["nan"], "a": ["nan"]})
|
|
|
|
with (
|
|
patch("services.annotation_service.current_account_with_tenant", return_value=(_make_user(), tenant_id)),
|
|
patch("services.annotation_service.db") as mock_db,
|
|
patch("services.annotation_service.pd.read_csv", return_value=df),
|
|
patch(
|
|
"configs.dify_config",
|
|
new=SimpleNamespace(ANNOTATION_IMPORT_MAX_RECORDS=5, ANNOTATION_IMPORT_MIN_RECORDS=1),
|
|
),
|
|
):
|
|
app_query = MagicMock()
|
|
app_query.where.return_value = app_query
|
|
app_query.first.return_value = app
|
|
mock_db.session.query.return_value = app_query
|
|
|
|
# Act
|
|
result = AppAnnotationService.batch_import_app_annotations(app.id, file)
|
|
|
|
# Assert
|
|
error_msg = cast(str, result["error_msg"])
|
|
assert "at least" in error_msg
|
|
|
|
def test_batch_import_app_annotations_should_return_error_when_question_too_long(self) -> None:
|
|
"""Test oversized question is rejected with row context."""
|
|
# Arrange
|
|
file = _make_file(b"question,answer\nq,a\n")
|
|
tenant_id = "tenant-1"
|
|
app = _make_app()
|
|
df = pd.DataFrame({"q": ["q" * 2001], "a": ["a"]})
|
|
|
|
with (
|
|
patch("services.annotation_service.current_account_with_tenant", return_value=(_make_user(), tenant_id)),
|
|
patch("services.annotation_service.db") as mock_db,
|
|
patch("services.annotation_service.pd.read_csv", return_value=df),
|
|
patch(
|
|
"configs.dify_config",
|
|
new=SimpleNamespace(ANNOTATION_IMPORT_MAX_RECORDS=5, ANNOTATION_IMPORT_MIN_RECORDS=1),
|
|
),
|
|
):
|
|
app_query = MagicMock()
|
|
app_query.where.return_value = app_query
|
|
app_query.first.return_value = app
|
|
mock_db.session.query.return_value = app_query
|
|
|
|
# Act
|
|
result = AppAnnotationService.batch_import_app_annotations(app.id, file)
|
|
|
|
# Assert
|
|
error_msg = cast(str, result["error_msg"])
|
|
assert "Question at row" in error_msg
|
|
|
|
def test_batch_import_app_annotations_should_return_error_when_answer_too_long(self) -> None:
|
|
"""Test oversized answer is rejected with row context."""
|
|
# Arrange
|
|
file = _make_file(b"question,answer\nq,a\n")
|
|
tenant_id = "tenant-1"
|
|
app = _make_app()
|
|
df = pd.DataFrame({"q": ["q"], "a": ["a" * 10001]})
|
|
|
|
with (
|
|
patch("services.annotation_service.current_account_with_tenant", return_value=(_make_user(), tenant_id)),
|
|
patch("services.annotation_service.db") as mock_db,
|
|
patch("services.annotation_service.pd.read_csv", return_value=df),
|
|
patch(
|
|
"configs.dify_config",
|
|
new=SimpleNamespace(ANNOTATION_IMPORT_MAX_RECORDS=5, ANNOTATION_IMPORT_MIN_RECORDS=1),
|
|
),
|
|
):
|
|
app_query = MagicMock()
|
|
app_query.where.return_value = app_query
|
|
app_query.first.return_value = app
|
|
mock_db.session.query.return_value = app_query
|
|
|
|
# Act
|
|
result = AppAnnotationService.batch_import_app_annotations(app.id, file)
|
|
|
|
# Assert
|
|
error_msg = cast(str, result["error_msg"])
|
|
assert "Answer at row" in error_msg
|
|
|
|
def test_batch_import_app_annotations_should_return_error_when_quota_exceeded(self) -> None:
|
|
"""Test quota validation returns error message."""
|
|
# Arrange
|
|
file = _make_file(b"question,answer\nq,a\n")
|
|
tenant_id = "tenant-1"
|
|
app = _make_app()
|
|
df = pd.DataFrame({"q": ["q1"], "a": ["a1"]})
|
|
features = SimpleNamespace(
|
|
billing=SimpleNamespace(enabled=True),
|
|
annotation_quota_limit=SimpleNamespace(limit=1, size=1),
|
|
)
|
|
|
|
with (
|
|
patch("services.annotation_service.current_account_with_tenant", return_value=(_make_user(), tenant_id)),
|
|
patch("services.annotation_service.db") as mock_db,
|
|
patch("services.annotation_service.pd.read_csv", return_value=df),
|
|
patch("services.annotation_service.FeatureService.get_features", return_value=features),
|
|
patch(
|
|
"configs.dify_config",
|
|
new=SimpleNamespace(ANNOTATION_IMPORT_MAX_RECORDS=5, ANNOTATION_IMPORT_MIN_RECORDS=1),
|
|
),
|
|
):
|
|
app_query = MagicMock()
|
|
app_query.where.return_value = app_query
|
|
app_query.first.return_value = app
|
|
mock_db.session.query.return_value = app_query
|
|
|
|
# Act
|
|
result = AppAnnotationService.batch_import_app_annotations(app.id, file)
|
|
|
|
# Assert
|
|
error_msg = cast(str, result["error_msg"])
|
|
assert "exceeds the limit" in error_msg
|
|
|
|
def test_batch_import_app_annotations_should_enqueue_job_when_valid(self) -> None:
|
|
"""Test successful batch import enqueues job and returns status."""
|
|
# Arrange
|
|
file = _make_file(b"question,answer\nq,a\n")
|
|
tenant_id = "tenant-1"
|
|
current_user = _make_user("user-1")
|
|
app = _make_app()
|
|
df = pd.DataFrame({"q": ["q1"], "a": ["a1"]})
|
|
features = SimpleNamespace(billing=SimpleNamespace(enabled=False), annotation_quota_limit=None)
|
|
|
|
with (
|
|
patch("services.annotation_service.current_account_with_tenant", return_value=(current_user, tenant_id)),
|
|
patch("services.annotation_service.db") as mock_db,
|
|
patch("services.annotation_service.pd.read_csv", return_value=df),
|
|
patch("services.annotation_service.FeatureService.get_features", return_value=features),
|
|
patch("services.annotation_service.batch_import_annotations_task") as mock_task,
|
|
patch("services.annotation_service.redis_client") as mock_redis,
|
|
patch("services.annotation_service.uuid.uuid4", return_value="uuid-3"),
|
|
patch("services.annotation_service.naive_utc_now", return_value=SimpleNamespace(timestamp=lambda: 1)),
|
|
patch(
|
|
"configs.dify_config",
|
|
new=SimpleNamespace(ANNOTATION_IMPORT_MAX_RECORDS=5, ANNOTATION_IMPORT_MIN_RECORDS=1),
|
|
),
|
|
):
|
|
app_query = MagicMock()
|
|
app_query.where.return_value = app_query
|
|
app_query.first.return_value = app
|
|
mock_db.session.query.return_value = app_query
|
|
|
|
# Act
|
|
result = AppAnnotationService.batch_import_app_annotations(app.id, file)
|
|
|
|
# Assert
|
|
assert result == {"job_id": "uuid-3", "job_status": "waiting", "record_count": 1}
|
|
mock_redis.zadd.assert_called_once()
|
|
mock_redis.expire.assert_called_once()
|
|
mock_redis.setnx.assert_called_once_with("app_annotation_batch_import_uuid-3", "waiting")
|
|
mock_task.delay.assert_called_once()
|
|
|
|
def test_batch_import_app_annotations_should_cleanup_active_job_on_unexpected_exception(self) -> None:
|
|
"""Test unexpected runtime errors trigger cleanup and return wrapped error."""
|
|
# Arrange
|
|
file = _make_file(b"question,answer\nq,a\n")
|
|
tenant_id = "tenant-1"
|
|
current_user = _make_user("user-1")
|
|
app = _make_app()
|
|
df = pd.DataFrame({"q": ["q1"], "a": ["a1"]})
|
|
features = SimpleNamespace(billing=SimpleNamespace(enabled=False), annotation_quota_limit=None)
|
|
|
|
with (
|
|
patch("services.annotation_service.current_account_with_tenant", return_value=(current_user, tenant_id)),
|
|
patch("services.annotation_service.db") as mock_db,
|
|
patch("services.annotation_service.pd.read_csv", return_value=df),
|
|
patch("services.annotation_service.FeatureService.get_features", return_value=features),
|
|
patch("services.annotation_service.redis_client") as mock_redis,
|
|
patch("services.annotation_service.uuid.uuid4", return_value="uuid-4"),
|
|
patch("services.annotation_service.naive_utc_now", return_value=SimpleNamespace(timestamp=lambda: 1)),
|
|
patch("services.annotation_service.logger") as mock_logger,
|
|
patch(
|
|
"configs.dify_config",
|
|
new=SimpleNamespace(ANNOTATION_IMPORT_MAX_RECORDS=5, ANNOTATION_IMPORT_MIN_RECORDS=1),
|
|
),
|
|
):
|
|
app_query = MagicMock()
|
|
app_query.where.return_value = app_query
|
|
app_query.first.return_value = app
|
|
mock_db.session.query.return_value = app_query
|
|
mock_redis.zadd.side_effect = RuntimeError("boom")
|
|
mock_redis.zrem.side_effect = RuntimeError("cleanup-failed")
|
|
|
|
# Act
|
|
result = AppAnnotationService.batch_import_app_annotations(app.id, file)
|
|
|
|
# Assert
|
|
assert result["error_msg"] == "An error occurred while processing the file: boom"
|
|
mock_redis.zrem.assert_called_once_with(f"annotation_import_active:{tenant_id}", "uuid-4")
|
|
mock_logger.debug.assert_called_once()
|
|
|
|
|
|
class TestAppAnnotationServiceHitHistoryAndSettings:
|
|
"""Test suite for hit history and settings methods."""
|
|
|
|
def test_get_annotation_hit_histories_should_raise_not_found_when_app_missing(self) -> None:
|
|
"""Test missing app raises NotFound."""
|
|
# Arrange
|
|
tenant_id = "tenant-1"
|
|
|
|
with (
|
|
patch("services.annotation_service.current_account_with_tenant", return_value=(_make_user(), tenant_id)),
|
|
patch("services.annotation_service.db") as mock_db,
|
|
):
|
|
app_query = MagicMock()
|
|
app_query.where.return_value = app_query
|
|
app_query.first.return_value = None
|
|
mock_db.session.query.return_value = app_query
|
|
|
|
# Act & Assert
|
|
with pytest.raises(NotFound):
|
|
AppAnnotationService.get_annotation_hit_histories("app-1", "ann-1", 1, 10)
|
|
|
|
def test_get_annotation_hit_histories_should_return_items_and_total(self) -> None:
|
|
"""Test hit histories pagination returns items and total."""
|
|
# Arrange
|
|
tenant_id = "tenant-1"
|
|
app = _make_app()
|
|
annotation = _make_annotation("ann-1")
|
|
pagination = SimpleNamespace(items=["h1"], total=2)
|
|
|
|
with (
|
|
patch("services.annotation_service.current_account_with_tenant", return_value=(_make_user(), tenant_id)),
|
|
patch("services.annotation_service.db") as mock_db,
|
|
):
|
|
app_query = MagicMock()
|
|
app_query.where.return_value = app_query
|
|
app_query.first.return_value = app
|
|
|
|
annotation_query = MagicMock()
|
|
annotation_query.where.return_value = annotation_query
|
|
annotation_query.first.return_value = annotation
|
|
|
|
mock_db.session.query.side_effect = [app_query, annotation_query]
|
|
mock_db.paginate.return_value = pagination
|
|
|
|
# Act
|
|
items, total = AppAnnotationService.get_annotation_hit_histories(app.id, annotation.id, 1, 10)
|
|
|
|
# Assert
|
|
assert items == ["h1"]
|
|
assert total == 2
|
|
|
|
def test_get_annotation_hit_histories_should_raise_not_found_when_annotation_missing(self) -> None:
|
|
"""Test missing annotation raises NotFound."""
|
|
# Arrange
|
|
tenant_id = "tenant-1"
|
|
app = _make_app()
|
|
|
|
with (
|
|
patch("services.annotation_service.current_account_with_tenant", return_value=(_make_user(), tenant_id)),
|
|
patch("services.annotation_service.db") as mock_db,
|
|
):
|
|
app_query = MagicMock()
|
|
app_query.where.return_value = app_query
|
|
app_query.first.return_value = app
|
|
|
|
annotation_query = MagicMock()
|
|
annotation_query.where.return_value = annotation_query
|
|
annotation_query.first.return_value = None
|
|
|
|
mock_db.session.query.side_effect = [app_query, annotation_query]
|
|
|
|
# Act & Assert
|
|
with pytest.raises(NotFound):
|
|
AppAnnotationService.get_annotation_hit_histories(app.id, "ann-1", 1, 10)
|
|
|
|
def test_get_annotation_by_id_should_return_none_when_missing(self) -> None:
|
|
"""Test get_annotation_by_id returns None when not found."""
|
|
# Arrange
|
|
with patch("services.annotation_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
|
|
result = AppAnnotationService.get_annotation_by_id("ann-1")
|
|
|
|
# Assert
|
|
assert result is None
|
|
|
|
def test_get_annotation_by_id_should_return_annotation_when_exists(self) -> None:
|
|
"""Test get_annotation_by_id returns annotation when found."""
|
|
# Arrange
|
|
annotation = _make_annotation("ann-1")
|
|
with patch("services.annotation_service.db") as mock_db:
|
|
query = MagicMock()
|
|
query.where.return_value = query
|
|
query.first.return_value = annotation
|
|
mock_db.session.query.return_value = query
|
|
|
|
# Act
|
|
result = AppAnnotationService.get_annotation_by_id("ann-1")
|
|
|
|
# Assert
|
|
assert result == annotation
|
|
|
|
def test_add_annotation_history_should_update_hit_count_and_store_history(self) -> None:
|
|
"""Test add_annotation_history updates hit count and creates history."""
|
|
# Arrange
|
|
with (
|
|
patch("services.annotation_service.db") as mock_db,
|
|
patch("services.annotation_service.AppAnnotationHitHistory") as mock_history_cls,
|
|
):
|
|
query = MagicMock()
|
|
query.where.return_value = query
|
|
mock_db.session.query.return_value = query
|
|
|
|
# Act
|
|
AppAnnotationService.add_annotation_history(
|
|
annotation_id="ann-1",
|
|
app_id="app-1",
|
|
annotation_question="q",
|
|
annotation_content="a",
|
|
query="q",
|
|
user_id="user-1",
|
|
message_id="msg-1",
|
|
from_source="chat",
|
|
score=0.8,
|
|
)
|
|
|
|
# Assert
|
|
query.update.assert_called_once()
|
|
mock_history_cls.assert_called_once()
|
|
mock_db.session.add.assert_called_once()
|
|
mock_db.session.commit.assert_called_once()
|
|
|
|
def test_get_app_annotation_setting_by_app_id_should_return_embedding_model_when_detail_exists(self) -> None:
|
|
"""Test setting detail returns embedding model info."""
|
|
# Arrange
|
|
tenant_id = "tenant-1"
|
|
app = _make_app()
|
|
setting = _make_setting(with_detail=True)
|
|
|
|
with (
|
|
patch("services.annotation_service.current_account_with_tenant", return_value=(_make_user(), tenant_id)),
|
|
patch("services.annotation_service.db") as mock_db,
|
|
):
|
|
app_query = MagicMock()
|
|
app_query.where.return_value = app_query
|
|
app_query.first.return_value = app
|
|
|
|
setting_query = MagicMock()
|
|
setting_query.where.return_value = setting_query
|
|
setting_query.first.return_value = setting
|
|
|
|
mock_db.session.query.side_effect = [app_query, setting_query]
|
|
|
|
# Act
|
|
result = AppAnnotationService.get_app_annotation_setting_by_app_id(app.id)
|
|
|
|
# Assert
|
|
assert result["enabled"] is True
|
|
embedding_model = cast(dict[str, Any], result["embedding_model"])
|
|
assert embedding_model["embedding_provider_name"] == "provider-a"
|
|
assert embedding_model["embedding_model_name"] == "model-a"
|
|
|
|
def test_get_app_annotation_setting_by_app_id_should_raise_not_found_when_app_missing(self) -> None:
|
|
"""Test missing app raises NotFound."""
|
|
# Arrange
|
|
tenant_id = "tenant-1"
|
|
|
|
with (
|
|
patch("services.annotation_service.current_account_with_tenant", return_value=(_make_user(), tenant_id)),
|
|
patch("services.annotation_service.db") as mock_db,
|
|
):
|
|
app_query = MagicMock()
|
|
app_query.where.return_value = app_query
|
|
app_query.first.return_value = None
|
|
mock_db.session.query.return_value = app_query
|
|
|
|
# Act & Assert
|
|
with pytest.raises(NotFound):
|
|
AppAnnotationService.get_app_annotation_setting_by_app_id("app-1")
|
|
|
|
def test_get_app_annotation_setting_by_app_id_should_return_empty_embedding_model_when_no_detail(self) -> None:
|
|
"""Test setting without detail returns empty embedding model."""
|
|
# Arrange
|
|
tenant_id = "tenant-1"
|
|
app = _make_app()
|
|
setting = _make_setting(with_detail=False)
|
|
|
|
with (
|
|
patch("services.annotation_service.current_account_with_tenant", return_value=(_make_user(), tenant_id)),
|
|
patch("services.annotation_service.db") as mock_db,
|
|
):
|
|
app_query = MagicMock()
|
|
app_query.where.return_value = app_query
|
|
app_query.first.return_value = app
|
|
|
|
setting_query = MagicMock()
|
|
setting_query.where.return_value = setting_query
|
|
setting_query.first.return_value = setting
|
|
|
|
mock_db.session.query.side_effect = [app_query, setting_query]
|
|
|
|
# Act
|
|
result = AppAnnotationService.get_app_annotation_setting_by_app_id(app.id)
|
|
|
|
# Assert
|
|
assert result["enabled"] is True
|
|
assert result["embedding_model"] == {}
|
|
|
|
def test_get_app_annotation_setting_by_app_id_should_return_disabled_when_setting_missing(self) -> None:
|
|
"""Test missing setting returns disabled payload."""
|
|
# Arrange
|
|
tenant_id = "tenant-1"
|
|
app = _make_app()
|
|
|
|
with (
|
|
patch("services.annotation_service.current_account_with_tenant", return_value=(_make_user(), tenant_id)),
|
|
patch("services.annotation_service.db") as mock_db,
|
|
):
|
|
app_query = MagicMock()
|
|
app_query.where.return_value = app_query
|
|
app_query.first.return_value = app
|
|
|
|
setting_query = MagicMock()
|
|
setting_query.where.return_value = setting_query
|
|
setting_query.first.return_value = None
|
|
|
|
mock_db.session.query.side_effect = [app_query, setting_query]
|
|
|
|
# Act
|
|
result = AppAnnotationService.get_app_annotation_setting_by_app_id(app.id)
|
|
|
|
# Assert
|
|
assert result == {"enabled": False}
|
|
|
|
def test_update_app_annotation_setting_should_update_and_return_detail(self) -> None:
|
|
"""Test update_app_annotation_setting updates fields and returns detail."""
|
|
# Arrange
|
|
tenant_id = "tenant-1"
|
|
current_user = _make_user("user-1")
|
|
app = _make_app()
|
|
setting = _make_setting(with_detail=True)
|
|
args = {"score_threshold": 0.8}
|
|
|
|
with (
|
|
patch("services.annotation_service.current_account_with_tenant", return_value=(current_user, tenant_id)),
|
|
patch("services.annotation_service.db") as mock_db,
|
|
patch("services.annotation_service.naive_utc_now", return_value="now"),
|
|
):
|
|
app_query = MagicMock()
|
|
app_query.where.return_value = app_query
|
|
app_query.first.return_value = app
|
|
|
|
setting_query = MagicMock()
|
|
setting_query.where.return_value = setting_query
|
|
setting_query.first.return_value = setting
|
|
|
|
mock_db.session.query.side_effect = [app_query, setting_query]
|
|
|
|
# Act
|
|
result = AppAnnotationService.update_app_annotation_setting(app.id, setting.id, args)
|
|
|
|
# Assert
|
|
assert result["enabled"] is True
|
|
assert result["score_threshold"] == 0.8
|
|
embedding_model = cast(dict[str, Any], result["embedding_model"])
|
|
assert embedding_model["embedding_provider_name"] == "provider-a"
|
|
mock_db.session.add.assert_called_once_with(setting)
|
|
mock_db.session.commit.assert_called_once()
|
|
|
|
def test_update_app_annotation_setting_should_return_empty_embedding_model_when_detail_missing(self) -> None:
|
|
"""Test update returns empty embedding_model when collection detail is absent."""
|
|
# Arrange
|
|
tenant_id = "tenant-1"
|
|
current_user = _make_user("user-1")
|
|
app = _make_app()
|
|
setting = _make_setting(with_detail=False)
|
|
args = {"score_threshold": 0.7}
|
|
|
|
with (
|
|
patch("services.annotation_service.current_account_with_tenant", return_value=(current_user, tenant_id)),
|
|
patch("services.annotation_service.db") as mock_db,
|
|
patch("services.annotation_service.naive_utc_now", return_value="now"),
|
|
):
|
|
app_query = MagicMock()
|
|
app_query.where.return_value = app_query
|
|
app_query.first.return_value = app
|
|
|
|
setting_query = MagicMock()
|
|
setting_query.where.return_value = setting_query
|
|
setting_query.first.return_value = setting
|
|
|
|
mock_db.session.query.side_effect = [app_query, setting_query]
|
|
|
|
# Act
|
|
result = AppAnnotationService.update_app_annotation_setting(app.id, setting.id, args)
|
|
|
|
# Assert
|
|
assert result["enabled"] is True
|
|
assert result["score_threshold"] == 0.7
|
|
assert result["embedding_model"] == {}
|
|
|
|
def test_update_app_annotation_setting_should_raise_not_found_when_app_missing(self) -> None:
|
|
"""Test update raises NotFound when app is missing."""
|
|
# Arrange
|
|
tenant_id = "tenant-1"
|
|
|
|
with (
|
|
patch("services.annotation_service.current_account_with_tenant", return_value=(_make_user(), tenant_id)),
|
|
patch("services.annotation_service.db") as mock_db,
|
|
):
|
|
app_query = MagicMock()
|
|
app_query.where.return_value = app_query
|
|
app_query.first.return_value = None
|
|
mock_db.session.query.return_value = app_query
|
|
|
|
# Act & Assert
|
|
with pytest.raises(NotFound):
|
|
AppAnnotationService.update_app_annotation_setting("app-1", "setting-1", {"score_threshold": 0.5})
|
|
|
|
def test_update_app_annotation_setting_should_raise_not_found_when_setting_missing(self) -> None:
|
|
"""Test update raises NotFound when setting is missing."""
|
|
# Arrange
|
|
tenant_id = "tenant-1"
|
|
app = _make_app()
|
|
|
|
with (
|
|
patch("services.annotation_service.current_account_with_tenant", return_value=(_make_user(), tenant_id)),
|
|
patch("services.annotation_service.db") as mock_db,
|
|
):
|
|
app_query = MagicMock()
|
|
app_query.where.return_value = app_query
|
|
app_query.first.return_value = app
|
|
|
|
setting_query = MagicMock()
|
|
setting_query.where.return_value = setting_query
|
|
setting_query.first.return_value = None
|
|
|
|
mock_db.session.query.side_effect = [app_query, setting_query]
|
|
|
|
# Act & Assert
|
|
with pytest.raises(NotFound):
|
|
AppAnnotationService.update_app_annotation_setting(app.id, "setting-1", {"score_threshold": 0.5})
|
|
|
|
|
|
class TestAppAnnotationServiceClearAll:
|
|
"""Test suite for clear_all_annotations."""
|
|
|
|
def test_clear_all_annotations_should_delete_annotations_and_histories(self) -> None:
|
|
"""Test clear_all_annotations deletes all data and triggers index removal."""
|
|
# Arrange
|
|
tenant_id = "tenant-1"
|
|
app = _make_app()
|
|
setting = _make_setting()
|
|
annotation1 = _make_annotation("ann-1")
|
|
annotation2 = _make_annotation("ann-2")
|
|
history = MagicMock(spec=AppAnnotationHitHistory)
|
|
|
|
def query_side_effect(*args: object, **kwargs: object) -> MagicMock:
|
|
query = MagicMock()
|
|
query.where.return_value = query
|
|
if App in args:
|
|
query.first.return_value = app
|
|
elif AppAnnotationSetting in args:
|
|
query.first.return_value = setting
|
|
elif MessageAnnotation in args:
|
|
query.yield_per.return_value = [annotation1, annotation2]
|
|
elif AppAnnotationHitHistory in args:
|
|
query.yield_per.return_value = [history]
|
|
return query
|
|
|
|
with (
|
|
patch("services.annotation_service.current_account_with_tenant", return_value=(_make_user(), tenant_id)),
|
|
patch("services.annotation_service.db") as mock_db,
|
|
patch("services.annotation_service.delete_annotation_index_task") as mock_task,
|
|
):
|
|
mock_db.session.query.side_effect = query_side_effect
|
|
|
|
# Act
|
|
result = AppAnnotationService.clear_all_annotations(app.id)
|
|
|
|
# Assert
|
|
assert result == {"result": "success"}
|
|
mock_db.session.delete.assert_any_call(annotation1)
|
|
mock_db.session.delete.assert_any_call(annotation2)
|
|
mock_db.session.delete.assert_any_call(history)
|
|
mock_task.delay.assert_any_call(annotation1.id, app.id, tenant_id, setting.collection_binding_id)
|
|
mock_task.delay.assert_any_call(annotation2.id, app.id, tenant_id, setting.collection_binding_id)
|
|
mock_db.session.commit.assert_called_once()
|
|
|
|
def test_clear_all_annotations_should_raise_not_found_when_app_missing(self) -> None:
|
|
"""Test missing app raises NotFound."""
|
|
# Arrange
|
|
tenant_id = "tenant-1"
|
|
|
|
with (
|
|
patch("services.annotation_service.current_account_with_tenant", return_value=(_make_user(), tenant_id)),
|
|
patch("services.annotation_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(NotFound):
|
|
AppAnnotationService.clear_all_annotations("app-1")
|