Files
dify/api/tests/unit_tests/services/test_annotation_service.py
2026-03-11 16:05:07 +08:00

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")