From b81737cfb334f9abbca65e8ccc9316814b59d6c8 Mon Sep 17 00:00:00 2001 From: Asuka Minato Date: Mon, 27 Jul 2026 11:01:47 +0900 Subject: [PATCH] test: use sqlite3 session in test_message_service (#38698) --- .../services/test_message_service.py | 1616 ++++++----------- 1 file changed, 605 insertions(+), 1011 deletions(-) diff --git a/api/tests/unit_tests/services/test_message_service.py b/api/tests/unit_tests/services/test_message_service.py index dfdaf7b40e2..afc49c8865c 100644 --- a/api/tests/unit_tests/services/test_message_service.py +++ b/api/tests/unit_tests/services/test_message_service.py @@ -1,13 +1,35 @@ import json +from collections.abc import Iterator from datetime import datetime -from unittest.mock import MagicMock, patch +from decimal import Decimal +from unittest.mock import MagicMock import pytest +from sqlalchemy.engine import Engine +from sqlalchemy.orm import Session, scoped_session +import models.model as model_module +import services.message_service as service_module +from core.app.entities.app_invoke_entities import InvokeFrom from graphon.model_runtime.entities.model_entities import ModelType -from libs.infinite_scroll_pagination import InfiniteScrollPagination -from models.enums import FeedbackFromSource, FeedbackRating -from models.model import App, AppMode, EndUser, Message +from models.account import Account, AccountStatus +from models.enums import ( + ConversationFromSource, + EndUserType, + FeedbackFromSource, + FeedbackRating, +) +from models.model import ( + App, + AppAnnotationSetting, + AppMode, + AppModelConfig, + Conversation, + EndUser, + Message, + MessageFeedback, +) +from repositories.sqlalchemy_execution_extra_content_repository import SQLAlchemyExecutionExtraContentRepository from services.errors.message import ( FirstMessageNotExistsError, LastMessageNotExistsError, @@ -16,1247 +38,819 @@ from services.errors.message import ( ) from services.message_service import MessageService, attach_message_extra_contents +SQLITE_MODELS = (Conversation, Message, MessageFeedback, AppModelConfig, AppAnnotationSetting) +pytestmark = [ + pytest.mark.usefixtures("sqlite_session"), + pytest.mark.parametrize("sqlite_session", [SQLITE_MODELS], indirect=True), +] -class TestMessageServiceFactory: - """Factory class for creating test data and mock objects for message service tests.""" + +class _DatabaseBinding: + """Expose the SQLite engine and shared session through the production DB interface.""" + + engine: Engine + session: scoped_session[Session] + + def __init__(self, engine: Engine, session: scoped_session[Session]) -> None: + self.engine = engine + self.session = session + + +class MessageServiceTestDataFactory: + """Create real service inputs and persistent message-domain rows.""" @staticmethod - def create_app_mock( + def create_app( app_id: str = "app-123", - mode: str = AppMode.ADVANCED_CHAT.value, - name: str = "Test App", - ) -> MagicMock: - """Create a mock App object.""" - app = MagicMock(spec=App) - app.id = app_id - app.mode = mode - app.name = name - return app + mode: AppMode = AppMode.ADVANCED_CHAT, + tenant_id: str = "tenant-123", + ) -> App: + return App( + id=app_id, + tenant_id=tenant_id, + name="Test App", + description="", + mode=mode, + enable_site=True, + enable_api=True, + max_active_requests=0, + ) @staticmethod - def create_end_user_mock( - user_id: str = "user-456", - session_id: str = "session-789", - ) -> MagicMock: - """Create a mock EndUser object.""" - user = MagicMock(spec=EndUser) - user.id = user_id - user.session_id = session_id - return user + def create_end_user(user_id: str = "user-456") -> EndUser: + return EndUser( + id=user_id, + tenant_id="tenant-123", + app_id="app-123", + type=EndUserType.SERVICE_API, + session_id="session-789", + ) @staticmethod - def create_conversation_mock( + def create_account(user_id: str = "account-123") -> Account: + account = Account(name="Admin", email="admin@example.com", status=AccountStatus.ACTIVE) + account.id = user_id + return account + + @staticmethod + def create_conversation( conversation_id: str = "conv-001", app_id: str = "app-123", - ) -> MagicMock: - """Create a mock Conversation object.""" - conversation = MagicMock() - conversation.id = conversation_id - conversation.app_id = app_id + *, + app_model_config_id: str | None = None, + override_model_configs: str | None = None, + ) -> Conversation: + conversation = Conversation( + id=conversation_id, + app_id=app_id, + app_model_config_id=app_model_config_id, + override_model_configs=override_model_configs, + mode=AppMode.CHAT, + name="Test conversation", + status="normal", + from_source=ConversationFromSource.API, + from_end_user_id="user-456", + ) + conversation._inputs = {} return conversation @staticmethod - def create_message_mock( + def create_message( message_id: str = "msg-001", conversation_id: str = "conv-001", - query: str = "What is AI?", - answer: str = "AI stands for Artificial Intelligence.", + app_id: str = "app-123", + *, created_at: datetime | None = None, - ) -> MagicMock: - """Create a mock Message object.""" - message = MagicMock(spec=Message) - message.id = message_id - message.conversation_id = conversation_id - message.query = query - message.answer = answer - message.created_at = created_at or datetime.now() - message.user_feedback_with_session.return_value = None - message.admin_feedback_with_session.return_value = None + from_source: ConversationFromSource = ConversationFromSource.API, + from_end_user_id: str | None = "user-456", + from_account_id: str | None = None, + ) -> Message: + message = Message( + id=message_id, + app_id=app_id, + conversation_id=conversation_id, + query="What is AI?", + message={"role": "user", "content": "What is AI?"}, + answer="AI stands for Artificial Intelligence.", + message_unit_price=Decimal("0.0001"), + answer_unit_price=Decimal("0.0002"), + currency="USD", + from_source=from_source, + from_end_user_id=from_end_user_id, + from_account_id=from_account_id, + ) + message._inputs = {} + timestamp = created_at or datetime.now() + message.created_at = timestamp + message.updated_at = timestamp return message + @staticmethod + def create_feedback( + feedback_id: str, + message: Message, + *, + source: FeedbackFromSource, + rating: FeedbackRating = FeedbackRating.LIKE, + ) -> MessageFeedback: + feedback = MessageFeedback( + app_id=message.app_id, + conversation_id=message.conversation_id, + message_id=message.id, + rating=rating, + from_source=source, + from_end_user_id="user-456" if source == FeedbackFromSource.USER else None, + from_account_id="account-123" if source == FeedbackFromSource.ADMIN else None, + ) + feedback.id = feedback_id + return feedback + + +@pytest.fixture +def factory() -> MessageServiceTestDataFactory: + return MessageServiceTestDataFactory() + + +@pytest.fixture(autouse=True) +def database_boundaries( + monkeypatch: pytest.MonkeyPatch, + sqlite_engine: Engine, + sqlite_session: Session, +) -> Iterator[None]: + """Bind global model properties and service-owned factories to the shared SQLite session.""" + sessions = scoped_session(lambda: sqlite_session) + database = _DatabaseBinding(engine=sqlite_engine, session=sessions) + monkeypatch.setattr(service_module, "db", database) + monkeypatch.setattr(model_module, "db", database) + try: + yield + finally: + sessions.remove() + + +@pytest.fixture +def empty_extra_content_repository(monkeypatch: pytest.MonkeyPatch) -> MagicMock: + repository = MagicMock() + repository.get_by_message_ids.side_effect = lambda message_ids: [[] for _ in message_ids] + monkeypatch.setattr(service_module, "_create_execution_extra_content_repository", lambda: repository) + return repository + + +def _persist(session: Session, *records: object) -> None: + session.add_all(records) + session.commit() + + +def _patch_conversation(monkeypatch: pytest.MonkeyPatch, conversation: Conversation) -> MagicMock: + get_conversation = MagicMock(return_value=conversation) + monkeypatch.setattr(service_module.ConversationService, "get_conversation", get_conversation) + return get_conversation + class TestMessageServicePaginationByFirstId: - """ - Unit tests for MessageService.pagination_by_first_id method. + """Verify cursor pagination using persisted message timestamps and IDs.""" - This test suite covers: - - Basic pagination with and without first_id - - Order handling (asc/desc) - - Edge cases (no user, no conversation, invalid first_id) - - Has_more flag logic - """ - - @pytest.fixture - def factory(self): - """Provide test data factory.""" - return TestMessageServiceFactory() - - # Test 01: No user provided - def test_pagination_by_first_id_no_user(self, factory: TestMessageServiceFactory): - """Test pagination returns empty result when no user is provided.""" - # Arrange - app = factory.create_app_mock() - - # Act + @pytest.mark.parametrize(("user", "conversation_id"), [(None, "conv-001"), ("end_user", "")]) + def test_early_return( + self, + user: str | None, + conversation_id: str, + factory: MessageServiceTestDataFactory, + sqlite_session: Session, + ) -> None: result = MessageService.pagination_by_first_id( - app_model=app, - user=None, - conversation_id="conv-001", + app_model=factory.create_app(), + user=factory.create_end_user() if user else None, + conversation_id=conversation_id, first_id=None, limit=10, - session=MagicMock(), + session=sqlite_session, ) - # Assert - assert isinstance(result, InfiniteScrollPagination) assert result.data == [] assert result.limit == 10 assert result.has_more is False - # Test 02: No conversation_id provided - def test_pagination_by_first_id_no_conversation(self, factory: TestMessageServiceFactory): - """Test pagination returns empty result when no conversation_id is provided.""" - # Arrange - app = factory.create_app_mock() - user = factory.create_end_user_mock() - - # Act - result = MessageService.pagination_by_first_id( - app_model=app, - user=user, - conversation_id="", - first_id=None, - limit=10, - session=MagicMock(), - ) - - # Assert - assert isinstance(result, InfiniteScrollPagination) - assert result.data == [] - assert result.limit == 10 - assert result.has_more is False - - # Test 03: Basic pagination without first_id (desc order) - @patch("services.message_service._create_execution_extra_content_repository") - @patch("services.message_service.db") - @patch("services.message_service.ConversationService") - def test_pagination_by_first_id_without_first_id_desc( - self, mock_conversation_service, mock_db, mock_create_repo, factory: TestMessageServiceFactory - ): - """Test basic pagination without first_id in descending order.""" - # Arrange - app = factory.create_app_mock() - user = factory.create_end_user_mock() - conversation = factory.create_conversation_mock() - - mock_conversation_service.get_conversation.return_value = conversation - - # Create 5 messages + @pytest.mark.parametrize( + ("order", "expected_ids"), + [ + ("desc", ["msg-004", "msg-003", "msg-002", "msg-001", "msg-000"]), + ("asc", ["msg-000", "msg-001", "msg-002", "msg-003", "msg-004"]), + ], + ) + def test_orders_persisted_messages( + self, + order: str, + expected_ids: list[str], + monkeypatch: pytest.MonkeyPatch, + factory: MessageServiceTestDataFactory, + sqlite_session: Session, + empty_extra_content_repository: MagicMock, + ) -> None: + conversation = factory.create_conversation() messages = [ - factory.create_message_mock( - message_id=f"msg-{i:03d}", - created_at=datetime(2024, 1, 1, 12, i), - ) - for i in range(5) + factory.create_message(f"msg-{index:03d}", created_at=datetime(2024, 1, 1, 12, index)) for index in range(5) ] + _persist(sqlite_session, conversation, *messages) + _patch_conversation(monkeypatch, conversation) - mock_db.session.scalars.return_value.all.return_value = messages - - # Act result = MessageService.pagination_by_first_id( - app_model=app, - user=user, - conversation_id="conv-001", + app_model=factory.create_app(), + user=factory.create_end_user(), + conversation_id=conversation.id, first_id=None, limit=10, - order="desc", - session=mock_db.session, + order=order, + session=sqlite_session, ) - # Assert - assert len(result.data) == 5 + assert [message.id for message in result.data] == expected_ids assert result.has_more is False - assert result.limit == 10 - # Messages should remain in desc order (not reversed) - assert result.data[0].id == "msg-000" - # Test 04: Basic pagination without first_id (asc order) - @patch("services.message_service._create_execution_extra_content_repository") - @patch("services.message_service.db") - @patch("services.message_service.ConversationService") - def test_pagination_by_first_id_without_first_id_asc( - self, mock_conversation_service, mock_db, mock_create_repo, factory: TestMessageServiceFactory - ): - """Test basic pagination without first_id in ascending order.""" - # Arrange - app = factory.create_app_mock() - user = factory.create_end_user_mock() - conversation = factory.create_conversation_mock() - - mock_conversation_service.get_conversation.return_value = conversation - - # Create 5 messages (returned in desc order from DB) + def test_first_id_excludes_cursor_and_newer_messages( + self, + monkeypatch: pytest.MonkeyPatch, + factory: MessageServiceTestDataFactory, + sqlite_session: Session, + empty_extra_content_repository: MagicMock, + ) -> None: + conversation = factory.create_conversation() messages = [ - factory.create_message_mock( - message_id=f"msg-{i:03d}", - created_at=datetime(2024, 1, 1, 12, 4 - i), # Descending timestamps - ) - for i in range(5) + factory.create_message(f"msg-{index:03d}", created_at=datetime(2024, 1, 1, 12, index)) for index in range(7) ] + _persist(sqlite_session, conversation, *messages) + _patch_conversation(monkeypatch, conversation) - mock_db.session.scalars.return_value.all.return_value = messages - - # Act result = MessageService.pagination_by_first_id( - app_model=app, - user=user, - conversation_id="conv-001", - first_id=None, - limit=10, - order="asc", - session=mock_db.session, - ) - - # Assert - assert len(result.data) == 5 - assert result.has_more is False - # Messages should be reversed to asc order - assert result.data[0].id == "msg-004" - assert result.data[4].id == "msg-000" - - # Test 05: Pagination with first_id - @patch("services.message_service._create_execution_extra_content_repository") - @patch("services.message_service.db") - @patch("services.message_service.ConversationService") - def test_pagination_by_first_id_with_first_id( - self, mock_conversation_service, mock_db, mock_create_repo, factory: TestMessageServiceFactory - ): - """Test pagination with first_id to get messages before a specific message.""" - # Arrange - app = factory.create_app_mock() - user = factory.create_end_user_mock() - conversation = factory.create_conversation_mock() - - mock_conversation_service.get_conversation.return_value = conversation - - first_message = factory.create_message_mock( - message_id="msg-005", - created_at=datetime(2024, 1, 1, 12, 5), - ) - - # Messages before first_message - history_messages = [ - factory.create_message_mock( - message_id=f"msg-{i:03d}", - created_at=datetime(2024, 1, 1, 12, i), - ) - for i in range(5) - ] - - mock_db.session.scalar.return_value = first_message - mock_db.session.scalars.return_value.all.return_value = history_messages - - # Act - result = MessageService.pagination_by_first_id( - app_model=app, - user=user, - conversation_id="conv-001", + app_model=factory.create_app(), + user=factory.create_end_user(), + conversation_id=conversation.id, first_id="msg-005", limit=10, order="desc", - session=mock_db.session, + session=sqlite_session, ) - # Assert - assert len(result.data) == 5 - assert result.has_more is False + assert [message.id for message in result.data] == [f"msg-{index:03d}" for index in range(4, -1, -1)] - # Test 06: First message not found - @patch("services.message_service.db") - @patch("services.message_service.ConversationService") - def test_pagination_by_first_id_first_message_not_exists( - self, mock_conversation_service, mock_db, factory: TestMessageServiceFactory - ): - """Test error handling when first_id doesn't exist.""" - # Arrange - app = factory.create_app_mock() - user = factory.create_end_user_mock() - conversation = factory.create_conversation_mock() + def test_missing_first_id_raises( + self, + monkeypatch: pytest.MonkeyPatch, + factory: MessageServiceTestDataFactory, + sqlite_session: Session, + ) -> None: + conversation = factory.create_conversation() + _persist(sqlite_session, conversation) + _patch_conversation(monkeypatch, conversation) - mock_conversation_service.get_conversation.return_value = conversation - - mock_db.session.scalar.return_value = None # Message not found - - # Act & Assert with pytest.raises(FirstMessageNotExistsError): MessageService.pagination_by_first_id( - app_model=app, - user=user, - conversation_id="conv-001", - first_id="nonexistent-msg", + app_model=factory.create_app(), + user=factory.create_end_user(), + conversation_id=conversation.id, + first_id="missing", limit=10, - session=mock_db.session, + session=sqlite_session, ) - # Test 07: Has_more flag when results exceed limit - @patch("services.message_service._create_execution_extra_content_repository") - @patch("services.message_service.db") - @patch("services.message_service.ConversationService") - def test_pagination_by_first_id_has_more_true( - self, mock_conversation_service, mock_db, mock_create_repo, factory: TestMessageServiceFactory - ): - """Test has_more flag is True when results exceed limit.""" - # Arrange - app = factory.create_app_mock() - user = factory.create_end_user_mock() - conversation = factory.create_conversation_mock() - - mock_conversation_service.get_conversation.return_value = conversation - - # Create limit+1 messages (11 messages for limit=10) + def test_has_more_trims_oldest_extra_row( + self, + monkeypatch: pytest.MonkeyPatch, + factory: MessageServiceTestDataFactory, + sqlite_session: Session, + empty_extra_content_repository: MagicMock, + ) -> None: + conversation = factory.create_conversation() messages = [ - factory.create_message_mock( - message_id=f"msg-{i:03d}", - created_at=datetime(2024, 1, 1, 12, i), - ) - for i in range(11) + factory.create_message(f"msg-{index:03d}", created_at=datetime(2024, 1, 1, 12, index)) + for index in range(11) ] + _persist(sqlite_session, conversation, *messages) + _patch_conversation(monkeypatch, conversation) - mock_db.session.scalars.return_value.all.return_value = messages - - # Act result = MessageService.pagination_by_first_id( - app_model=app, - user=user, - conversation_id="conv-001", + app_model=factory.create_app(), + user=factory.create_end_user(), + conversation_id=conversation.id, first_id=None, limit=10, - session=mock_db.session, + order="desc", + session=sqlite_session, ) - # Assert - assert len(result.data) == 10 # Last message trimmed + assert len(result.data) == 10 assert result.has_more is True - assert result.limit == 10 + assert result.data[-1].id == "msg-001" - # Test 08: Empty conversation - @patch("services.message_service.db") - @patch("services.message_service.ConversationService") - def test_pagination_by_first_id_empty_conversation( - self, mock_conversation_service, mock_db, factory: TestMessageServiceFactory - ): - """Test pagination with conversation that has no messages.""" - # Arrange - app = factory.create_app_mock() - user = factory.create_end_user_mock() - conversation = factory.create_conversation_mock() + def test_empty_conversation( + self, + monkeypatch: pytest.MonkeyPatch, + factory: MessageServiceTestDataFactory, + sqlite_session: Session, + empty_extra_content_repository: MagicMock, + ) -> None: + conversation = factory.create_conversation() + _persist(sqlite_session, conversation) + _patch_conversation(monkeypatch, conversation) - mock_conversation_service.get_conversation.return_value = conversation - - mock_db.session.scalars.return_value.all.return_value = [] - - # Act result = MessageService.pagination_by_first_id( - app_model=app, - user=user, - conversation_id="conv-001", + app_model=factory.create_app(), + user=factory.create_end_user(), + conversation_id=conversation.id, first_id=None, limit=10, - session=mock_db.session, + session=sqlite_session, ) - # Assert - assert len(result.data) == 0 + assert result.data == [] assert result.has_more is False - assert result.limit == 10 class TestMessageServicePaginationByLastId: - """ - Unit tests for MessageService.pagination_by_last_id method. + """Verify reverse cursor, conversation, and include-ID filtering.""" - This test suite covers: - - Basic pagination with and without last_id - - Conversation filtering - - Include_ids filtering - - Edge cases (no user, invalid last_id) - """ - - @pytest.fixture - def factory(self): - """Provide test data factory.""" - return TestMessageServiceFactory() - - # Test 09: No user provided - def test_pagination_by_last_id_no_user(self, factory: TestMessageServiceFactory): - """Test pagination returns empty result when no user is provided.""" - # Arrange - app = factory.create_app_mock() - - # Act + def test_no_user(self, factory: MessageServiceTestDataFactory, sqlite_session: Session) -> None: result = MessageService.pagination_by_last_id( - app_model=app, - user=None, - last_id=None, - limit=10, - session=MagicMock(), + app_model=factory.create_app(), user=None, last_id=None, limit=10, session=sqlite_session ) - - # Assert - assert isinstance(result, InfiniteScrollPagination) assert result.data == [] assert result.limit == 10 assert result.has_more is False - # Test 10: Basic pagination without last_id - @patch("services.message_service.db") - def test_pagination_by_last_id_without_last_id(self, mock_db, factory: TestMessageServiceFactory): - """Test basic pagination without last_id.""" - # Arrange - app = factory.create_app_mock() - user = factory.create_end_user_mock() - + def test_without_last_id(self, factory: MessageServiceTestDataFactory, sqlite_session: Session) -> None: messages = [ - factory.create_message_mock( - message_id=f"msg-{i:03d}", - created_at=datetime(2024, 1, 1, 12, i), - ) - for i in range(5) + factory.create_message(f"msg-{index:03d}", created_at=datetime(2024, 1, 1, 12, index)) for index in range(5) ] + _persist(sqlite_session, *messages) - mock_db.session.scalars.return_value.all.return_value = messages - - # Act result = MessageService.pagination_by_last_id( - app_model=app, - user=user, + app_model=factory.create_app(), + user=factory.create_end_user(), last_id=None, limit=10, - session=mock_db.session, + session=sqlite_session, ) - # Assert - assert len(result.data) == 5 + assert [message.id for message in result.data] == [f"msg-{index:03d}" for index in range(4, -1, -1)] assert result.has_more is False - assert result.limit == 10 - # Test 11: Pagination with last_id - @patch("services.message_service.db") - def test_pagination_by_last_id_with_last_id(self, mock_db, factory: TestMessageServiceFactory): - """Test pagination with last_id to get messages after a specific message.""" - # Arrange - app = factory.create_app_mock() - user = factory.create_end_user_mock() - - last_message = factory.create_message_mock( - message_id="msg-005", - created_at=datetime(2024, 1, 1, 12, 5), - ) - - # Messages after last_message - new_messages = [ - factory.create_message_mock( - message_id=f"msg-{i:03d}", - created_at=datetime(2024, 1, 1, 12, i), - ) - for i in range(6, 10) + def test_last_id_returns_older_rows(self, factory: MessageServiceTestDataFactory, sqlite_session: Session) -> None: + messages = [ + factory.create_message(f"msg-{index:03d}", created_at=datetime(2024, 1, 1, 12, index)) for index in range(7) ] + _persist(sqlite_session, *messages) - mock_db.session.scalar.return_value = last_message - mock_db.session.scalars.return_value.all.return_value = new_messages - - # Act result = MessageService.pagination_by_last_id( - app_model=app, - user=user, + app_model=factory.create_app(), + user=factory.create_end_user(), last_id="msg-005", limit=10, - session=mock_db.session, + session=sqlite_session, ) - # Assert - assert len(result.data) == 4 - assert result.has_more is False + assert [message.id for message in result.data] == [f"msg-{index:03d}" for index in range(4, -1, -1)] - # Test 12: Last message not found - @patch("services.message_service.db") - def test_pagination_by_last_id_last_message_not_exists(self, mock_db, factory: TestMessageServiceFactory): - """Test error handling when last_id doesn't exist.""" - # Arrange - app = factory.create_app_mock() - user = factory.create_end_user_mock() - - mock_db.session.scalar.return_value = None # Message not found - - # Act & Assert + def test_missing_last_id_raises(self, factory: MessageServiceTestDataFactory, sqlite_session: Session) -> None: with pytest.raises(LastMessageNotExistsError): MessageService.pagination_by_last_id( - app_model=app, - user=user, - last_id="nonexistent-msg", + app_model=factory.create_app(), + user=factory.create_end_user(), + last_id="missing", limit=10, - session=mock_db.session, + session=sqlite_session, ) - # Test 13: Pagination with conversation_id filter - @patch("services.message_service.ConversationService") - @patch("services.message_service.db") - def test_pagination_by_last_id_with_conversation_filter( - self, mock_db, mock_conversation_service, factory: TestMessageServiceFactory - ): - """Test pagination filtered by conversation_id.""" - # Arrange - app = factory.create_app_mock() - user = factory.create_end_user_mock() - conversation = factory.create_conversation_mock(conversation_id="conv-001") + def test_conversation_filter( + self, + monkeypatch: pytest.MonkeyPatch, + factory: MessageServiceTestDataFactory, + sqlite_session: Session, + ) -> None: + conversation = factory.create_conversation() + other_conversation = factory.create_conversation("conv-002") + matching = factory.create_message("matching", conversation_id=conversation.id) + excluded = factory.create_message("excluded", conversation_id=other_conversation.id) + _persist(sqlite_session, conversation, other_conversation, matching, excluded) + get_conversation = _patch_conversation(monkeypatch, conversation) - mock_conversation_service.get_conversation.return_value = conversation - - messages = [ - factory.create_message_mock( - message_id=f"msg-{i:03d}", - conversation_id="conv-001", - created_at=datetime(2024, 1, 1, 12, i), - ) - for i in range(5) - ] - - mock_db.session.scalars.return_value.all.return_value = messages - - # Act result = MessageService.pagination_by_last_id( - app_model=app, - user=user, + app_model=factory.create_app(), + user=factory.create_end_user(), last_id=None, limit=10, - conversation_id="conv-001", - session=mock_db.session, + conversation_id=conversation.id, + session=sqlite_session, ) - # Assert - assert len(result.data) == 5 - assert result.has_more is False - mock_conversation_service.get_conversation.assert_called_once() + assert [message.id for message in result.data] == [matching.id] + get_conversation.assert_called_once() - # Test 14: Pagination with include_ids filter - @patch("services.message_service.db") - def test_pagination_by_last_id_with_include_ids(self, mock_db, factory: TestMessageServiceFactory): - """Test pagination filtered by include_ids.""" - # Arrange - app = factory.create_app_mock() - user = factory.create_end_user_mock() - - # Only messages with IDs in include_ids should be returned + def test_include_ids_filter(self, factory: MessageServiceTestDataFactory, sqlite_session: Session) -> None: messages = [ - factory.create_message_mock(message_id="msg-001"), - factory.create_message_mock(message_id="msg-003"), + factory.create_message(f"msg-{index:03d}", created_at=datetime(2024, 1, 1, 12, index)) for index in range(4) ] + _persist(sqlite_session, *messages) - mock_db.session.scalars.return_value.all.return_value = messages - - # Act result = MessageService.pagination_by_last_id( - app_model=app, - user=user, + app_model=factory.create_app(), + user=factory.create_end_user(), last_id=None, limit=10, include_ids=["msg-001", "msg-003"], - session=mock_db.session, + session=sqlite_session, ) - # Assert - assert len(result.data) == 2 - assert result.data[0].id == "msg-001" - assert result.data[1].id == "msg-003" + assert [message.id for message in result.data] == ["msg-003", "msg-001"] - # Test 15: Has_more flag when results exceed limit - @patch("services.message_service.db") - def test_pagination_by_last_id_has_more_true(self, mock_db, factory: TestMessageServiceFactory): - """Test has_more flag is True when results exceed limit.""" - # Arrange - app = factory.create_app_mock() - user = factory.create_end_user_mock() - - # Create limit+1 messages (11 messages for limit=10) + def test_has_more(self, factory: MessageServiceTestDataFactory, sqlite_session: Session) -> None: messages = [ - factory.create_message_mock( - message_id=f"msg-{i:03d}", - created_at=datetime(2024, 1, 1, 12, i), - ) - for i in range(11) + factory.create_message(f"msg-{index:03d}", created_at=datetime(2024, 1, 1, 12, index)) + for index in range(11) ] + _persist(sqlite_session, *messages) - mock_db.session.scalars.return_value.all.return_value = messages - - # Act result = MessageService.pagination_by_last_id( - app_model=app, - user=user, + app_model=factory.create_app(), + user=factory.create_end_user(), last_id=None, limit=10, - session=mock_db.session, + session=sqlite_session, ) - # Assert - assert len(result.data) == 10 # Last message trimmed + assert len(result.data) == 10 assert result.has_more is True - assert result.limit == 10 class TestMessageServiceUtilities: - """Unit tests for MessageService module-level utility functions.""" - - @pytest.fixture - def factory(self): - """Provide test data factory.""" - return TestMessageServiceFactory() - - # Test 16: attach_message_extra_contents with empty list - def test_attach_message_extra_contents_empty(self): - """Test attach_message_extra_contents with empty list does nothing.""" - # Act & Assert (should not raise error) + def test_attach_message_extra_contents_empty(self) -> None: attach_message_extra_contents([]) - # Test 17: attach_message_extra_contents with messages - @patch("services.message_service._create_execution_extra_content_repository") - def test_attach_message_extra_contents_with_messages(self, mock_create_repo, factory: TestMessageServiceFactory): - """Test attach_message_extra_contents correctly attaches content.""" - # Arrange - messages = [factory.create_message_mock(message_id="msg-1"), factory.create_message_mock(message_id="msg-2")] + def test_attach_message_extra_contents( + self, + monkeypatch: pytest.MonkeyPatch, + factory: MessageServiceTestDataFactory, + ) -> None: + messages = [factory.create_message("msg-1"), factory.create_message("msg-2")] + content_one = MagicMock() + content_one.model_dump.return_value = {"key": "value1"} + content_two = MagicMock() + content_two.model_dump.return_value = {"key": "value2"} + repository = MagicMock() + repository.get_by_message_ids.return_value = [[content_one], [content_two]] + monkeypatch.setattr(service_module, "_create_execution_extra_content_repository", lambda: repository) - mock_repo = MagicMock() - mock_create_repo.return_value = mock_repo - - # Mock extra content models - mock_content1 = MagicMock() - mock_content1.model_dump.return_value = {"key": "value1"} - mock_content2 = MagicMock() - mock_content2.model_dump.return_value = {"key": "value2"} - - mock_repo.get_by_message_ids.return_value = [[mock_content1], [mock_content2]] - - # Act attach_message_extra_contents(messages) - # Assert - mock_repo.get_by_message_ids.assert_called_once_with(["msg-1", "msg-2"]) - messages[0].set_extra_contents.assert_called_once_with([{"key": "value1"}]) - messages[1].set_extra_contents.assert_called_once_with([{"key": "value2"}]) + assert messages[0].extra_contents == [{"key": "value1"}] + assert messages[1].extra_contents == [{"key": "value2"}] - # Test 18: attach_message_extra_contents with index out of bounds - @patch("services.message_service._create_execution_extra_content_repository") - def test_attach_message_extra_contents_index_out_of_bounds( - self, mock_create_repo, factory: TestMessageServiceFactory - ): - """Test attach_message_extra_contents handles missing content lists.""" - # Arrange - messages = [factory.create_message_mock(message_id="msg-1")] + def test_attach_message_extra_contents_missing_list( + self, + monkeypatch: pytest.MonkeyPatch, + factory: MessageServiceTestDataFactory, + ) -> None: + message = factory.create_message("msg-1") + repository = MagicMock() + repository.get_by_message_ids.return_value = [] + monkeypatch.setattr(service_module, "_create_execution_extra_content_repository", lambda: repository) - mock_repo = MagicMock() - mock_create_repo.return_value = mock_repo - mock_repo.get_by_message_ids.return_value = [] # Empty returned list + attach_message_extra_contents([message]) - # Act - attach_message_extra_contents(messages) + assert message.extra_contents == [] - # Assert - messages[0].set_extra_contents.assert_called_once_with([]) + def test_create_execution_extra_content_repository_uses_sqlite_factory(self, sqlite_engine: Engine) -> None: + repository = service_module._create_execution_extra_content_repository() - # Test 19: _create_execution_extra_content_repository - @patch("services.message_service.db") - @patch("services.message_service.sessionmaker") - @patch("services.message_service.SQLAlchemyExecutionExtraContentRepository") - def test_create_execution_extra_content_repository(self, mock_repo_class, mock_sessionmaker, mock_db): - """Test _create_execution_extra_content_repository creates expected repository.""" - from services.message_service import _create_execution_extra_content_repository - - # Act - _create_execution_extra_content_repository() - - # Assert - mock_sessionmaker.assert_called_once() - mock_repo_class.assert_called_once() + assert isinstance(repository, SQLAlchemyExecutionExtraContentRepository) + assert repository._session_maker.kw["bind"] is sqlite_engine + with repository._session_maker() as session: + assert isinstance(session, Session) class TestMessageServiceGetMessage: - """Unit tests for MessageService.get_message method.""" + @pytest.mark.parametrize("actor", ["end_user", "account"]) + def test_identity_scoped_success( + self, + actor: str, + factory: MessageServiceTestDataFactory, + sqlite_session: Session, + ) -> None: + if actor == "end_user": + user: Account | EndUser = factory.create_end_user("end-user-123") + message = factory.create_message( + "msg-123", from_end_user_id=user.id, from_account_id=None, from_source=ConversationFromSource.API + ) + else: + user = factory.create_account("account-123") + message = factory.create_message( + "msg-123", + from_end_user_id=None, + from_account_id=user.id, + from_source=ConversationFromSource.CONSOLE, + ) + distractor = factory.create_message("wrong-app", app_id="app-456") + _persist(sqlite_session, message, distractor) - @pytest.fixture - def factory(self): - """Provide test data factory.""" - return TestMessageServiceFactory() + result = MessageService.get_message( + app_model=factory.create_app(), user=user, message_id=message.id, session=sqlite_session + ) - # Test 20: get_message success for EndUser - @patch("services.message_service.db") - def test_get_message_end_user_success(self, mock_db, factory: TestMessageServiceFactory): - """Test get_message returns message for EndUser.""" - # Arrange - app = factory.create_app_mock() - user = factory.create_end_user_mock(user_id="end-user-123") - message = factory.create_message_mock() + assert result.id == message.id - mock_db.session.scalar.return_value = message - - # Act, - result = MessageService.get_message(app_model=app, user=user, message_id="msg-123", session=mock_db.session) - - # Assert - assert result == message - - # Test 21: get_message success for Account (Admin) - @patch("services.message_service.db") - def test_get_message_account_success(self, mock_db, factory: TestMessageServiceFactory): - """Test get_message returns message for Account.""" - # Arrange - from models import Account - - app = factory.create_app_mock() - user = MagicMock(spec=Account) - user.id = "account-123" - message = factory.create_message_mock() - - mock_db.session.scalar.return_value = message - - # Act, - result = MessageService.get_message(app_model=app, user=user, message_id="msg-123", session=mock_db.session) - - # Assert - assert result == message - - # Test 22: get_message not found - @patch("services.message_service.db") - def test_get_message_not_found(self, mock_db, factory: TestMessageServiceFactory): - """Test get_message raises MessageNotExistsError when not found.""" - # Arrange - app = factory.create_app_mock() - user = factory.create_end_user_mock() - - mock_db.session.scalar.return_value = None - - # Act & Assert + def test_not_found(self, factory: MessageServiceTestDataFactory, sqlite_session: Session) -> None: with pytest.raises(MessageNotExistsError): - MessageService.get_message(app_model=app, user=user, message_id="msg-123", session=mock_db.session) + MessageService.get_message( + app_model=factory.create_app(), + user=factory.create_end_user(), + message_id="missing", + session=sqlite_session, + ) class TestMessageServiceFeedback: - """Unit tests for MessageService feedback-related methods.""" + def test_create_new_end_user_feedback( + self, + factory: MessageServiceTestDataFactory, + sqlite_session: Session, + sqlite_engine: Engine, + ) -> None: + user = factory.create_end_user() + message = factory.create_message("msg-123") + _persist(sqlite_session, message) - @pytest.fixture - def factory(self): - """Provide test data factory.""" - return TestMessageServiceFactory() - - # Test 23: create_feedback - new feedback for EndUser - @patch("services.message_service.db") - @patch.object(MessageService, "get_message") - def test_create_feedback_new_end_user(self, mock_get_message, mock_db, factory: TestMessageServiceFactory): - """Test creating new feedback for an end user.""" - # Arrange - app = factory.create_app_mock() - user = factory.create_end_user_mock() - message = factory.create_message_mock() - message.user_feedback = None - message.user_feedback_with_session.return_value = None - mock_get_message.return_value = message - - # Act - result = MessageService.create_feedback( - app_model=app, - message_id="msg-123", + feedback = MessageService.create_feedback( + app_model=factory.create_app(), + message_id=message.id, user=user, rating=FeedbackRating.LIKE, content="Good answer", - session=mock_db.session, + session=sqlite_session, ) - # Assert - assert result.rating == FeedbackRating.LIKE - assert result.content == "Good answer" - assert result.from_source == FeedbackFromSource.USER - mock_db.session.add.assert_called_once() - mock_db.session.commit.assert_called_once() + with Session(sqlite_engine) as verification_session: + persisted = verification_session.get(MessageFeedback, feedback.id) + assert persisted is not None + assert persisted.rating == FeedbackRating.LIKE + assert persisted.content == "Good answer" + assert persisted.from_source == FeedbackFromSource.USER - # Test 24: create_feedback - update feedback for Account - @patch("services.message_service.db") - @patch.object(MessageService, "get_message") - def test_create_feedback_update_account(self, mock_get_message, mock_db, factory: TestMessageServiceFactory): - """Test updating existing feedback for an account.""" - # Arrange - from models import Account, MessageFeedback + def test_update_account_feedback( + self, + factory: MessageServiceTestDataFactory, + sqlite_session: Session, + sqlite_engine: Engine, + ) -> None: + user = factory.create_account() + message = factory.create_message( + "msg-123", + from_source=ConversationFromSource.CONSOLE, + from_end_user_id=None, + from_account_id=user.id, + ) + feedback = factory.create_feedback("feedback-1", message, source=FeedbackFromSource.ADMIN) + _persist(sqlite_session, message, feedback) - app = factory.create_app_mock() - user = MagicMock(spec=Account) - user.id = "account-123" - message = factory.create_message_mock() - feedback = MagicMock(spec=MessageFeedback) - message.admin_feedback = feedback - message.admin_feedback_with_session.return_value = feedback - mock_get_message.return_value = message - - # Act result = MessageService.create_feedback( - app_model=app, - message_id="msg-123", + app_model=factory.create_app(), + message_id=message.id, user=user, rating=FeedbackRating.DISLIKE, content="Bad answer", - session=mock_db.session, + session=sqlite_session, ) - # Assert - assert result == feedback - assert feedback.rating == FeedbackRating.DISLIKE - assert feedback.content == "Bad answer" - mock_db.session.commit.assert_called_once() + assert result.id == feedback.id + with Session(sqlite_engine) as verification_session: + persisted = verification_session.get(MessageFeedback, feedback.id) + assert persisted is not None + assert persisted.rating == FeedbackRating.DISLIKE + assert persisted.content == "Bad answer" - # Test 25: create_feedback - delete feedback (rating is None) - @patch("services.message_service.db") - @patch.object(MessageService, "get_message") - def test_create_feedback_delete(self, mock_get_message, mock_db, factory: TestMessageServiceFactory): - """Test deleting feedback by passing rating=None.""" - # Arrange - app = factory.create_app_mock() - user = factory.create_end_user_mock() - message = factory.create_message_mock() - feedback = MagicMock() - message.user_feedback = feedback - message.user_feedback_with_session.return_value = feedback - mock_get_message.return_value = message + def test_delete_feedback( + self, + factory: MessageServiceTestDataFactory, + sqlite_session: Session, + sqlite_engine: Engine, + ) -> None: + user = factory.create_end_user() + message = factory.create_message("msg-123") + feedback = factory.create_feedback("feedback-1", message, source=FeedbackFromSource.USER) + _persist(sqlite_session, message, feedback) - # Act - result = MessageService.create_feedback( - app_model=app, - message_id="msg-123", + MessageService.create_feedback( + app_model=factory.create_app(), + message_id=message.id, user=user, rating=None, content=None, - session=mock_db.session, + session=sqlite_session, ) - # Assert - assert result == feedback - mock_db.session.delete.assert_called_once_with(feedback) - mock_db.session.commit.assert_called_once() + with Session(sqlite_engine) as verification_session: + assert verification_session.get(MessageFeedback, feedback.id) is None - # Test 26: get_all_messages_feedbacks - @patch("services.message_service.db") - def test_get_all_messages_feedbacks(self, mock_db, factory: TestMessageServiceFactory): - """Test get_all_messages_feedbacks returns list of dicts.""" - # Arrange - app = factory.create_app_mock() - feedback = MagicMock() - feedback.to_dict.return_value = {"id": "fb-1"} + def test_get_all_feedbacks_is_app_scoped_and_paginated( + self, + factory: MessageServiceTestDataFactory, + sqlite_session: Session, + ) -> None: + message = factory.create_message("msg-123") + newest = factory.create_feedback("feedback-new", message, source=FeedbackFromSource.USER) + oldest = factory.create_feedback("feedback-old", message, source=FeedbackFromSource.USER) + other_message = factory.create_message("other-msg", app_id="app-456") + other_app = factory.create_feedback("feedback-other", other_message, source=FeedbackFromSource.USER) + newest.created_at = datetime(2024, 1, 2) + oldest.created_at = datetime(2024, 1, 1) + other_app.created_at = datetime(2024, 1, 3) + _persist(sqlite_session, newest, oldest, other_app) - mock_db.session.scalars.return_value.all.return_value = [feedback] + result = MessageService.get_all_messages_feedbacks( + app_model=factory.create_app(), page=1, limit=1, session=sqlite_session + ) - # Act, - result = MessageService.get_all_messages_feedbacks(app_model=app, page=1, limit=10, session=mock_db.session) - - # Assert - assert result == [{"id": "fb-1"}] + assert [record["id"] for record in result] == [newest.id] class TestMessageServiceSuggestedQuestions: - """Unit tests for MessageService.get_suggested_questions_after_answer method.""" + @staticmethod + def _chat_boundaries( + monkeypatch: pytest.MonkeyPatch, + conversation: Conversation, + ) -> tuple[MagicMock, MagicMock, MagicMock]: + message = MagicMock() + message.conversation_id = conversation.id + monkeypatch.setattr(service_module.MessageService, "get_message", MagicMock(return_value=message)) + monkeypatch.setattr( + service_module.ConversationService, "get_conversation", MagicMock(return_value=conversation) + ) + model_manager = MagicMock() + monkeypatch.setattr(service_module.ModelManager, "for_tenant", MagicMock(return_value=model_manager)) + memory = MagicMock() + memory.return_value.get_history_prompt_text.return_value = "histories" + monkeypatch.setattr(service_module, "TokenBufferMemory", memory) + llm_generator = MagicMock() + llm_generator.generate_suggested_questions_after_answer.return_value = ["Q1?"] + monkeypatch.setattr(service_module, "LLMGenerator", llm_generator) + monkeypatch.setattr(service_module, "TraceQueueManager", MagicMock()) + return model_manager, memory, llm_generator - @pytest.fixture - def factory(self): - """Provide test data factory.""" - return TestMessageServiceFactory() - - # Test 27: get_suggested_questions_after_answer - user is None - def test_get_suggested_questions_user_none(self, factory: TestMessageServiceFactory): - app = factory.create_app_mock() + def test_user_none(self, factory: MessageServiceTestDataFactory, sqlite_session: Session) -> None: with pytest.raises(ValueError, match="user cannot be None"): MessageService.get_suggested_questions_after_answer( - app_model=app, + app_model=factory.create_app(), user=None, message_id="msg-123", - invoke_from=MagicMock(), - session=MagicMock(), + invoke_from=InvokeFrom.WEB_APP, + session=sqlite_session, ) - # Test 28: get_suggested_questions_after_answer - Advanced Chat success - @patch("services.message_service.ModelManager.for_tenant") - @patch("services.message_service.WorkflowService") - @patch("services.message_service.AdvancedChatAppConfigManager") - @patch("services.message_service.TokenBufferMemory") - @patch("services.message_service.LLMGenerator") - @patch("services.message_service.TraceQueueManager") - @patch.object(MessageService, "get_message") - @patch("services.message_service.ConversationService") - def test_get_suggested_questions_advanced_chat_success( + def test_advanced_chat_success( self, - mock_conversation_service, - mock_get_message, - mock_trace_manager, - mock_llm_gen, - mock_memory, - mock_config_manager, - mock_workflow_service, - mock_model_manager, - factory: TestMessageServiceFactory, - ): - """Test successful suggested questions generation in Advanced Chat mode.""" - from core.app.entities.app_invoke_entities import InvokeFrom - - # Arrange - app = factory.create_app_mock(mode=AppMode.ADVANCED_CHAT.value) - user = factory.create_end_user_mock() - message = factory.create_message_mock() - mock_get_message.return_value = message - + monkeypatch: pytest.MonkeyPatch, + factory: MessageServiceTestDataFactory, + sqlite_session: Session, + ) -> None: + conversation = factory.create_conversation() + _, _, llm_generator = self._chat_boundaries(monkeypatch, conversation) workflow = MagicMock() - mock_workflow_service.return_value.get_published_workflow.return_value = workflow + workflow.features_dict = {"suggested_questions_after_answer": {"enabled": True}} + workflow_service = MagicMock() + workflow_service.return_value.get_published_workflow.return_value = workflow + monkeypatch.setattr(service_module, "WorkflowService", workflow_service) + app_config_manager = MagicMock() + app_config_manager.get_app_config.return_value.additional_features.suggested_questions_after_answer = True + monkeypatch.setattr(service_module, "AdvancedChatAppConfigManager", app_config_manager) - app_config = MagicMock() - app_config.additional_features.suggested_questions_after_answer = True - mock_config_manager.get_app_config.return_value = app_config - - mock_llm_gen.generate_suggested_questions_after_answer.return_value = ["Q1?"] - - # Act result = MessageService.get_suggested_questions_after_answer( - app_model=app, - user=user, + app_model=factory.create_app(mode=AppMode.ADVANCED_CHAT), + user=factory.create_end_user(), message_id="msg-123", invoke_from=InvokeFrom.WEB_APP, - session=MagicMock(), + session=sqlite_session, ) - # Assert assert result == ["Q1?"] - mock_workflow_service.return_value.get_published_workflow.assert_called_once() - mock_llm_gen.generate_suggested_questions_after_answer.assert_called_once() + llm_generator.generate_suggested_questions_after_answer.assert_called_once() - # Test 29: get_suggested_questions_after_answer - Chat app success (no override) - @patch("services.message_service.db") - @patch("services.message_service.ModelManager.for_tenant") - @patch("services.message_service.TokenBufferMemory") - @patch("services.message_service.LLMGenerator") - @patch("services.message_service.TraceQueueManager") - @patch.object(MessageService, "get_message") - @patch("services.message_service.ConversationService") - def test_get_suggested_questions_chat_app_success( + @pytest.mark.parametrize( + ("config", "expected_prompt", "expected_model"), + [ + ({"enabled": True}, None, None), + ( + { + "enabled": True, + "prompt": "custom prompt", + "model": { + "provider": "openai", + "name": "gpt-4o-mini", + "completion_params": {"max_tokens": 2048, "temperature": 0.1}, + }, + }, + "custom prompt", + { + "provider": "openai", + "name": "gpt-4o-mini", + "completion_params": {"max_tokens": 2048, "temperature": 0.1}, + }, + ), + ( + {"enabled": True, "model": {"provider": "openai", "name": "invalid-model"}}, + None, + {"provider": "openai", "name": "invalid-model"}, + ), + ], + ) + def test_chat_app_uses_persisted_model_config( self, - mock_conversation_service: MagicMock, - mock_get_message: MagicMock, - mock_trace_manager: MagicMock, - mock_llm_gen: MagicMock, - mock_memory: MagicMock, - mock_model_manager: MagicMock, - mock_db: MagicMock, - factory: TestMessageServiceFactory, - ): - """Test successful suggested questions generation in basic Chat mode.""" - # Arrange - app = factory.create_app_mock(mode=AppMode.CHAT) - user = factory.create_end_user_mock() - message = factory.create_message_mock() - mock_get_message.return_value = message - - conversation = MagicMock() - conversation.override_model_configs = None - mock_conversation_service.get_conversation.return_value = conversation - - app_model_config = MagicMock() - app_model_config.suggested_questions_after_answer_dict = {"enabled": True} - app_model_config.model_dict = {"provider": "openai", "name": "gpt-4"} - - mock_db.session.scalar.return_value = app_model_config - - mock_llm_gen.generate_suggested_questions_after_answer.return_value = ["Q1?"] - - # Act - result = MessageService.get_suggested_questions_after_answer( - app_model=app, - user=user, - message_id="msg-123", - invoke_from=MagicMock(), - session=mock_db.session, + config: dict[str, object], + expected_prompt: str | None, + expected_model: dict[str, object] | None, + monkeypatch: pytest.MonkeyPatch, + factory: MessageServiceTestDataFactory, + sqlite_session: Session, + ) -> None: + app_model_config = AppModelConfig( + app_id="app-123", + suggested_questions_after_answer=json.dumps(config), ) - - # Assert - assert result == ["Q1?"] - mock_llm_gen.generate_suggested_questions_after_answer.assert_called_once() - - @patch("services.message_service.db") - @patch("services.message_service.ModelManager.for_tenant") - @patch("services.message_service.TokenBufferMemory") - @patch("services.message_service.LLMGenerator") - @patch("services.message_service.TraceQueueManager") - @patch.object(MessageService, "get_message") - @patch("services.message_service.ConversationService") - def test_get_suggested_questions_chat_app_uses_frontend_model_and_prompt( - self, - mock_conversation_service: MagicMock, - mock_get_message: MagicMock, - mock_trace_manager: MagicMock, - mock_llm_gen: MagicMock, - mock_memory: MagicMock, - mock_model_manager: MagicMock, - mock_db: MagicMock, - factory: TestMessageServiceFactory, - ): - """Test suggested question generation uses frontend configured model and prompt.""" - from core.app.entities.app_invoke_entities import InvokeFrom - - app = factory.create_app_mock(mode=AppMode.CHAT) - app.tenant_id = "tenant-123" - user = factory.create_end_user_mock() - message = factory.create_message_mock() - mock_get_message.return_value = message - - conversation = MagicMock() - conversation.override_model_configs = None - mock_conversation_service.get_conversation.return_value = conversation - - app_model_config = MagicMock() - app_model_config.suggested_questions_after_answer_dict = { - "enabled": True, - "prompt": "custom prompt", - "model": { - "provider": "openai", - "name": "gpt-4o-mini", - "completion_params": {"max_tokens": 2048, "temperature": 0.1}, - }, - } - mock_db.session.scalar.return_value = app_model_config - - mock_memory.return_value.get_history_prompt_text.return_value = "histories" - mock_llm_gen.generate_suggested_questions_after_answer.return_value = ["Q1?"] + app_model_config.id = "config-1" + conversation = factory.create_conversation(app_model_config_id=app_model_config.id) + _persist(sqlite_session, app_model_config) + model_manager, memory, llm_generator = self._chat_boundaries(monkeypatch, conversation) result = MessageService.get_suggested_questions_after_answer( - app_model=app, - user=user, + app_model=factory.create_app(mode=AppMode.CHAT), + user=factory.create_end_user(), message_id="msg-123", invoke_from=InvokeFrom.WEB_APP, - session=mock_db.session, + session=sqlite_session, ) assert result == ["Q1?"] - mock_model_manager.return_value.get_default_model_instance.assert_called_once_with( - tenant_id="tenant-123", - model_type=ModelType.LLM, + model_manager.get_default_model_instance.assert_called_once_with( + tenant_id="tenant-123", model_type=ModelType.LLM ) - mock_memory.assert_called_once_with( + memory.assert_called_once_with( conversation=conversation, - model_instance=mock_model_manager.return_value.get_default_model_instance.return_value, + model_instance=model_manager.get_default_model_instance.return_value, ) - mock_llm_gen.generate_suggested_questions_after_answer.assert_called_once_with( + llm_generator.generate_suggested_questions_after_answer.assert_called_once_with( tenant_id="tenant-123", histories="histories", - instruction_prompt="custom prompt", - model_config={ - "provider": "openai", - "name": "gpt-4o-mini", - "completion_params": {"max_tokens": 2048, "temperature": 0.1}, - }, + instruction_prompt=expected_prompt, + model_config=expected_model, ) - @patch("services.message_service.db") - @patch("services.message_service.ModelManager.for_tenant") - @patch("services.message_service.TokenBufferMemory") - @patch("services.message_service.LLMGenerator") - @patch("services.message_service.TraceQueueManager") - @patch.object(MessageService, "get_message") - @patch("services.message_service.ConversationService") - def test_get_suggested_questions_chat_app_invalid_frontend_model_fallback_to_default( + def test_chat_app_uses_compatible_override_model_config( self, - mock_conversation_service: MagicMock, - mock_get_message: MagicMock, - mock_trace_manager: MagicMock, - mock_llm_gen: MagicMock, - mock_memory: MagicMock, - mock_model_manager: MagicMock, - mock_db: MagicMock, - factory: TestMessageServiceFactory, - ): - """Test invalid frontend configured model falls back to tenant default model.""" - app = factory.create_app_mock(mode=AppMode.CHAT) - app.tenant_id = "tenant-123" - user = factory.create_end_user_mock() - message = factory.create_message_mock() - mock_get_message.return_value = message - - conversation = MagicMock() - conversation.override_model_configs = None - mock_conversation_service.get_conversation.return_value = conversation - - app_model_config = MagicMock() - app_model_config.suggested_questions_after_answer_dict = { - "enabled": True, - "model": {"provider": "openai", "name": "invalid-model"}, - } - mock_db.session.scalar.return_value = app_model_config - - mock_model_manager.return_value.get_model_instance.side_effect = ValueError("invalid model") - mock_memory.return_value.get_history_prompt_text.return_value = "histories" - mock_llm_gen.generate_suggested_questions_after_answer.return_value = ["Q1?"] - - result = MessageService.get_suggested_questions_after_answer( - app_model=app, - user=user, - message_id="msg-123", - invoke_from=MagicMock(), - session=mock_db.session, - ) - - assert result == ["Q1?"] - mock_model_manager.return_value.get_default_model_instance.assert_called_once_with( - tenant_id="tenant-123", - model_type=ModelType.LLM, - ) - mock_model_manager.return_value.get_model_instance.assert_not_called() - - @patch("services.message_service.db") - @patch("services.message_service.ModelManager.for_tenant") - @patch("services.message_service.TokenBufferMemory") - @patch("services.message_service.LLMGenerator") - @patch("services.message_service.TraceQueueManager") - @patch.object(MessageService, "get_message") - @patch("services.message_service.ConversationService") - def test_get_suggested_questions_chat_app_uses_compatible_override_model_config( - self, - mock_conversation_service: MagicMock, - mock_get_message: MagicMock, - mock_trace_manager: MagicMock, - mock_llm_gen: MagicMock, - mock_memory: MagicMock, - mock_model_manager: MagicMock, - mock_db: MagicMock, - factory: TestMessageServiceFactory, - ): - """Test legacy override configs are normalized before suggested questions reads them.""" - app = factory.create_app_mock(mode=AppMode.CHAT) - app.tenant_id = "tenant-123" - user = factory.create_end_user_mock() - message = factory.create_message_mock() - mock_get_message.return_value = message - - conversation = MagicMock() - conversation.override_model_configs = json.dumps( - { - "speech_to_text": {"enabled": False}, - "text_to_speech": {"enabled": False}, - "retriever_resource": {"enabled": False}, - "model": {"provider": "openai", "name": "gpt-4o-mini", "mode": "chat"}, - "user_input_form": [], - "dataset_query_variable": "", - "pre_prompt": "", - "agent_mode": { - "enabled": False, - "max_iteration": 5, - "strategy": "function_call", - "tools": [], - }, - "prompt_type": "simple", - "chat_prompt_config": {}, - "completion_prompt_config": {}, - "dataset_configs": {"retrieval_model": "single", "datasets": {"datasets": []}}, - "file_upload": { - "image": { - "detail": "high", - "enabled": False, - "number_limits": 3, - "transfer_methods": ["remote_url", "local_file"], - } - }, - "suggested_questions_after_answer": { - "enabled": True, - "prompt": "legacy prompt", - }, - } - ) - conversation.model_config = { - "opening_statement": None, - "suggested_questions": [], - "suggested_questions_after_answer": { - "enabled": True, - "prompt": "legacy prompt", - }, - "speech_to_text": {"enabled": False}, - "text_to_speech": {"enabled": False}, - "retriever_resource": {"enabled": False}, - "annotation_reply": {"enabled": False}, - "more_like_this": {"enabled": False}, - "sensitive_word_avoidance": {"enabled": False, "type": "", "config": {}}, - "external_data_tools": [], + monkeypatch: pytest.MonkeyPatch, + factory: MessageServiceTestDataFactory, + sqlite_session: Session, + ) -> None: + override = { "model": {"provider": "openai", "name": "gpt-4o-mini", "mode": "chat"}, - "user_input_form": [], - "dataset_query_variable": "", - "pre_prompt": "", - "agent_mode": {"enabled": False, "strategy": "function_call", "tools": [], "prompt": None}, - "prompt_type": "simple", - "chat_prompt_config": {}, - "completion_prompt_config": {}, - "dataset_configs": {"retrieval_model": "single", "datasets": {"datasets": []}}, - "file_upload": { - "image": { - "detail": "high", - "enabled": False, - "number_limits": 3, - "transfer_methods": ["remote_url", "local_file"], - } - }, - "model_id": None, - "provider": None, + "suggested_questions_after_answer": {"enabled": True, "prompt": "legacy prompt"}, } - conversation.model_config_with_session.return_value = conversation.model_config - mock_conversation_service.get_conversation.return_value = conversation - - mock_memory.return_value.get_history_prompt_text.return_value = "histories" - mock_llm_gen.generate_suggested_questions_after_answer.return_value = ["Q1?"] + conversation = factory.create_conversation(override_model_configs=json.dumps(override)) + _, _, llm_generator = self._chat_boundaries(monkeypatch, conversation) result = MessageService.get_suggested_questions_after_answer( - app_model=app, - user=user, + app_model=factory.create_app(mode=AppMode.CHAT), + user=factory.create_end_user(), message_id="msg-123", - invoke_from=MagicMock(), - session=mock_db.session, + invoke_from=InvokeFrom.WEB_APP, + session=sqlite_session, ) assert result == ["Q1?"] - mock_db.session.scalar.assert_not_called() - mock_llm_gen.generate_suggested_questions_after_answer.assert_called_once_with( + llm_generator.generate_suggested_questions_after_answer.assert_called_once_with( tenant_id="tenant-123", histories="histories", instruction_prompt="legacy prompt", model_config=None, ) - # Test 30: get_suggested_questions_after_answer - Disabled Error - @patch("services.message_service.WorkflowService") - @patch("services.message_service.AdvancedChatAppConfigManager") - @patch.object(MessageService, "get_message") - @patch("services.message_service.ConversationService") - def test_get_suggested_questions_disabled_error( + def test_disabled_error( self, - mock_conversation_service, - mock_get_message, - mock_config_manager, - mock_workflow_service, - factory: TestMessageServiceFactory, - ): - """Test SuggestedQuestionsAfterAnswerDisabledError is raised when feature is disabled.""" - # Arrange - app = factory.create_app_mock(mode=AppMode.ADVANCED_CHAT.value) - user = factory.create_end_user_mock() - mock_get_message.return_value = factory.create_message_mock() - + monkeypatch: pytest.MonkeyPatch, + factory: MessageServiceTestDataFactory, + sqlite_session: Session, + ) -> None: + conversation = factory.create_conversation() + self._chat_boundaries(monkeypatch, conversation) workflow = MagicMock() - mock_workflow_service.return_value.get_published_workflow.return_value = workflow + workflow_service = MagicMock() + workflow_service.return_value.get_published_workflow.return_value = workflow + monkeypatch.setattr(service_module, "WorkflowService", workflow_service) + app_config_manager = MagicMock() + app_config_manager.get_app_config.return_value.additional_features.suggested_questions_after_answer = False + monkeypatch.setattr(service_module, "AdvancedChatAppConfigManager", app_config_manager) - app_config = MagicMock() - app_config.additional_features.suggested_questions_after_answer = False - mock_config_manager.get_app_config.return_value = app_config - - # Act & Assert with pytest.raises(SuggestedQuestionsAfterAnswerDisabledError): MessageService.get_suggested_questions_after_answer( - app_model=app, - user=user, + app_model=factory.create_app(mode=AppMode.ADVANCED_CHAT), + user=factory.create_end_user(), message_id="msg-123", - invoke_from=MagicMock(), - session=MagicMock(), + invoke_from=InvokeFrom.WEB_APP, + session=sqlite_session, )