"""Unit tests for ChatService.""" import uuid from unittest.mock import MagicMock, patch import pytest from freezegun import freeze_time from tests.chat.conftest import setup_query_mock_with_session class TestChatServiceSessionCreation: """Tests for ChatService session creation.""" def test_create_session_returns_uuid(self, mock_user_db_session): """Test that create_session returns a valid UUID.""" from src.local_deep_research.chat.service import ChatService mock_user_db_session.add = MagicMock() mock_user_db_session.commit = MagicMock() service = ChatService(username="testuser") session_id = service.create_session() # Verify it's a valid UUID format assert session_id is not None try: uuid.UUID(session_id) except ValueError: pytest.fail("create_session did not return a valid UUID") def test_create_session_with_initial_query(self, mock_user_db_session): """Test creating a session with an initial query.""" from src.local_deep_research.chat.service import ChatService mock_user_db_session.add = MagicMock() mock_user_db_session.commit = MagicMock() service = ChatService(username="testuser") initial_query = "What is quantum computing?" session_id = service.create_session(initial_query=initial_query) assert session_id is not None # Verify that session was added mock_user_db_session.add.assert_called() def test_create_session_with_custom_title(self, mock_user_db_session): """Test creating a session with a custom title.""" from src.local_deep_research.chat.service import ChatService mock_user_db_session.add = MagicMock() mock_user_db_session.commit = MagicMock() service = ChatService(username="testuser") custom_title = "My Research Session" session_id = service.create_session(title=custom_title) assert session_id is not None # The session should be added to the database mock_user_db_session.add.assert_called() def test_create_session_generates_title_from_query( self, mock_user_db_session, long_query_text ): """Test that title is generated from query and truncated to 100 chars + '...'.""" from src.local_deep_research.chat.service import ChatService mock_user_db_session.add = MagicMock() mock_user_db_session.commit = MagicMock() service = ChatService(username="testuser") session_id = service.create_session(initial_query=long_query_text) assert session_id is not None # The session was added (title truncation happens in service) mock_user_db_session.add.assert_called() # Get the added session and verify title length. # ChatSession always has a `title` column — drop the hasattr guard # so a regression that stops populating it surfaces here instead # of silently passing. added_session = mock_user_db_session.add.call_args[0][0] assert len(long_query_text) > 100, ( "test fixture must exceed the 100-char truncation threshold" ) assert len(added_session.title) <= 103 # 100 chars + "..." @freeze_time("2024-01-15 14:30:00") def test_create_session_generates_title_without_query( self, mock_user_db_session ): """Test that title is generated in format 'Chat YYYY-MM-DD HH:MM' when no query.""" from src.local_deep_research.chat.service import ChatService mock_user_db_session.add = MagicMock() mock_user_db_session.commit = MagicMock() service = ChatService(username="testuser") session_id = service.create_session() assert session_id is not None mock_user_db_session.add.assert_called() # Get the added session and verify title format added_session = mock_user_db_session.add.call_args[0][0] expected_title = "Chat 2024-01-15 14:30" assert added_session.title == expected_title def test_create_session_initializes_accumulated_context( self, mock_user_db_session ): """Test that accumulated_context is initialized with proper structure.""" from src.local_deep_research.chat.service import ChatService mock_user_db_session.add = MagicMock() mock_user_db_session.commit = MagicMock() service = ChatService(username="testuser") session_id = service.create_session() assert session_id is not None # Verify the session was added with accumulated_context added_session = mock_user_db_session.add.call_args[0][0] # Actual implementation initializes with this structure expected_context = { "key_entities": [], "topics": [], "summary": "", } assert added_session.accumulated_context == expected_context class TestChatServiceAddMessage: """Tests for ChatService add_message method.""" def test_add_message_returns_uuid( self, mock_user_db_session, mock_chat_session ): """Test that add_message returns a valid UUID.""" from src.local_deep_research.chat.service import ChatService # Set up query mock to return session (handles with_for_update) setup_query_mock_with_session(mock_user_db_session, mock_chat_session) mock_user_db_session.add = MagicMock() mock_user_db_session.commit = MagicMock() service = ChatService(username="testuser") message_id = service.add_message( session_id=mock_chat_session.id, role="user", content="Test message", message_type="query", ) assert message_id is not None try: uuid.UUID(message_id) except ValueError: pytest.fail("add_message did not return a valid UUID") def test_add_message_increments_sequence_number( self, mock_user_db_session, mock_chat_session ): """Test that sequence number increments with each message.""" from src.local_deep_research.chat.service import ChatService mock_chat_session.message_count = 5 setup_query_mock_with_session(mock_user_db_session, mock_chat_session) mock_user_db_session.add = MagicMock() mock_user_db_session.commit = MagicMock() service = ChatService(username="testuser") message_id = service.add_message( session_id=mock_chat_session.id, role="user", content="Test message", message_type="query", ) assert message_id is not None # Verify the added message has correct sequence number added_message = mock_user_db_session.add.call_args[0][0] assert added_message.sequence_number == 6 def test_add_message_updates_session_message_count( self, mock_user_db_session, mock_chat_session ): """Test that session message_count is updated.""" from src.local_deep_research.chat.service import ChatService mock_chat_session.message_count = 5 setup_query_mock_with_session(mock_user_db_session, mock_chat_session) mock_user_db_session.add = MagicMock() mock_user_db_session.commit = MagicMock() service = ChatService(username="testuser") service.add_message( session_id=mock_chat_session.id, role="user", content="Test message", message_type="query", ) # Session message_count should be incremented assert mock_chat_session.message_count == 6 def test_add_message_raises_for_invalid_session(self, mock_user_db_session): """Test that add_message raises ValueError for non-existent session.""" from src.local_deep_research.chat.service import ChatService # Set up query mock to return None (session not found) setup_query_mock_with_session(mock_user_db_session, None) service = ChatService(username="testuser") with pytest.raises(ValueError) as exc_info: service.add_message( session_id="nonexistent-session", role="user", content="Test message", message_type="query", ) assert "not found" in str(exc_info.value) class TestChatServiceContextAccumulation: """Tests for ChatService context accumulation methods.""" def test_update_accumulated_context_adds_entities( self, mock_user_db_session, mock_chat_session ): """Test that new entities are added to accumulated context.""" from src.local_deep_research.chat.service import ChatService mock_chat_session.accumulated_context = { "key_entities": ["entity1"], "topics": [], "summary": "", } setup_query_mock_with_session(mock_user_db_session, mock_chat_session) mock_user_db_session.commit = MagicMock() service = ChatService(username="testuser") # Use the actual method signature with individual params result = service.update_accumulated_context( session_id=mock_chat_session.id, new_entities=["entity2", "entity3"], new_topics=["topic1"], summary_addition="New summary", ) assert result is True # Verify entities were added assert ( "entity1" in mock_chat_session.accumulated_context["key_entities"] ) assert ( "entity2" in mock_chat_session.accumulated_context["key_entities"] ) assert ( "entity3" in mock_chat_session.accumulated_context["key_entities"] ) def test_update_accumulated_context_limits_entities_to_50( self, mock_user_db_session, mock_chat_session ): """Test that entities are limited to 50.""" from src.local_deep_research.chat.service import ChatService mock_chat_session.accumulated_context = { "key_entities": [], "topics": [], "summary": "", } setup_query_mock_with_session(mock_user_db_session, mock_chat_session) mock_user_db_session.commit = MagicMock() service = ChatService(username="testuser") # Add 60 entities many_entities = [f"entity_{i}" for i in range(60)] service.update_accumulated_context( session_id=mock_chat_session.id, new_entities=many_entities, ) # Entities should be capped at 50 assert len(mock_chat_session.accumulated_context["key_entities"]) <= 50 def test_update_accumulated_context_limits_topics_to_20( self, mock_user_db_session, mock_chat_session ): """Test that topics are limited to 20.""" from src.local_deep_research.chat.service import ChatService mock_chat_session.accumulated_context = { "key_entities": [], "topics": [], "summary": "", } setup_query_mock_with_session(mock_user_db_session, mock_chat_session) mock_user_db_session.commit = MagicMock() service = ChatService(username="testuser") # Add 25 topics many_topics = [f"topic_{i}" for i in range(25)] service.update_accumulated_context( session_id=mock_chat_session.id, new_topics=many_topics, ) # Topics should be capped at 20 assert len(mock_chat_session.accumulated_context["topics"]) <= 20 def test_update_accumulated_context_truncates_summary_to_8000_chars( self, mock_user_db_session, mock_chat_session ): """Test that summary is truncated to 8000 characters.""" from src.local_deep_research.chat.service import ChatService mock_chat_session.accumulated_context = { "key_entities": [], "topics": [], "summary": "", } setup_query_mock_with_session(mock_user_db_session, mock_chat_session) mock_user_db_session.commit = MagicMock() service = ChatService(username="testuser") # Add a 9000 character summary long_summary = "A" * 9000 service.update_accumulated_context( session_id=mock_chat_session.id, summary_addition=long_summary, ) # Summary should be capped at 8000 characters assert len(mock_chat_session.accumulated_context["summary"]) <= 8000 class TestChatServiceGetSession: """Tests for ChatService get_session method.""" def test_get_session_returns_session_dict( self, mock_user_db_session, mock_chat_session ): """Test that get_session returns the correct session as a dict.""" from src.local_deep_research.chat.service import ChatService query_mock = MagicMock() query_mock.filter_by.return_value.first.return_value = mock_chat_session mock_user_db_session.query.return_value = query_mock service = ChatService(username="testuser") result = service.get_session(mock_chat_session.id) assert result is not None # get_session returns a dict, not an object assert isinstance(result, dict) assert result["id"] == mock_chat_session.id def test_get_session_raises_not_found_for_nonexistent( self, mock_user_db_session ): """Test that get_session raises ChatSessionNotFound for missing rows.""" import pytest from src.local_deep_research.chat.service import ( ChatService, ChatSessionNotFound, ) query_mock = MagicMock() query_mock.filter_by.return_value.first.return_value = None mock_user_db_session.query.return_value = query_mock service = ChatService(username="testuser") with pytest.raises(ChatSessionNotFound): service.get_session("nonexistent-id") class TestChatServiceGetSessionMessages: """Tests for ChatService get_session_messages method.""" def _setup_query_side_effect( self, mock_user_db_session, mock_messages, mock_steps=() ): """get_session_messages does TWO queries (ChatMessage + ChatProgressStep). Helper sets up the side_effect to dispatch.""" from src.local_deep_research.database.models import ( ChatMessage, ChatProgressStep, ) def query_side_effect(model): q = MagicMock() # Service applies SQL-level LIMIT now (was Python slice). Chain # is: query → filter_by → order_by → limit → all if model is ChatMessage: ( q.filter_by.return_value.order_by.return_value.limit.return_value.all.return_value ) = list(mock_messages) elif model is ChatProgressStep: ( q.filter_by.return_value.order_by.return_value.limit.return_value.all.return_value ) = list(mock_steps) return q mock_user_db_session.query.side_effect = query_side_effect def test_get_session_messages_returns_ordered_messages( self, mock_user_db_session, sample_messages ): """Test that get_session_messages returns messages in order.""" from src.local_deep_research.chat.service import ChatService mock_messages = [] for msg in sample_messages: mock_msg = MagicMock() for key, value in msg.items(): setattr(mock_msg, key, value) mock_messages.append(mock_msg) self._setup_query_side_effect(mock_user_db_session, mock_messages) service = ChatService(username="testuser") result = service.get_session_messages("session-123") assert len(result) == len(sample_messages) def test_get_session_messages_returns_empty_for_no_messages( self, mock_user_db_session ): """Test that get_session_messages returns empty list when no messages.""" from src.local_deep_research.chat.service import ChatService self._setup_query_side_effect(mock_user_db_session, [], []) service = ChatService(username="testuser") result = service.get_session_messages("session-123") assert result == [] def test_get_session_messages_with_pagination( self, mock_user_db_session, sample_messages ): """Test that get_session_messages respects limit and offset.""" from src.local_deep_research.chat.service import ChatService mock_messages = [] for msg in sample_messages[:2]: mock_msg = MagicMock() for key, value in msg.items(): setattr(mock_msg, key, value) mock_messages.append(mock_msg) self._setup_query_side_effect(mock_user_db_session, mock_messages) service = ChatService(username="testuser") result = service.get_session_messages("session-123", limit=2, offset=0) assert len(result) == 2 def test_get_session_messages_merges_step_rows(self, mock_user_db_session): """get_session_messages MUST merge ChatProgressStep rows from chat_progress_steps into the response with message_type='step', so chat.js's existing filter and the in-flight reload UX continue to work. Regression: chat.js fetchFormattedMessage filters ``m.message_type !== 'step'`` to skip per-iteration progress milestones. The merged stream must surface step entries with that exact discriminator value. """ from src.local_deep_research.chat.service import ChatService from src.local_deep_research.database.models import ( ChatMessage, ChatProgressStep, ) from datetime import datetime msgs = [ MagicMock( id="resp-1", session_id="session-123", role="assistant", message_type="response", content="Photosynthesis is the process by which plants...", research_id="r-1", sequence_number=1, created_at=datetime(2024, 1, 1, 12, 0, 5), ), ] steps = [ MagicMock( id="step-uuid-1", session_id="session-123", research_id="r-1", phase="search", content="Starting research process", sequence_number=1, created_at=datetime(2024, 1, 1, 12, 0, 1), ), MagicMock( id="step-uuid-2", session_id="session-123", research_id="r-1", phase="search", content="Tool: web_search", sequence_number=2, created_at=datetime(2024, 1, 1, 12, 0, 2), ), ] def query_side_effect(model): q = MagicMock() # Chain: filter_by → order_by → limit → all (service applies # SQL-level LIMIT now). if model is ChatMessage: ( q.filter_by.return_value.order_by.return_value.limit.return_value.all.return_value ) = msgs elif model is ChatProgressStep: ( q.filter_by.return_value.order_by.return_value.limit.return_value.all.return_value ) = steps return q mock_user_db_session.query.side_effect = query_side_effect service = ChatService(username="testuser") result = service.get_session_messages("session-123") # Merged + sorted by created_at: 2 steps then the response. assert len(result) == 3 types = [r["message_type"] for r in result] assert types == ["step", "step", "response"] # Step IDs are prefixed to avoid collision with chat_messages ids. assert result[0]["id"].startswith("step-") # JS filter mirror: find the first assistant + has-content + non-step. formatted = next( ( m for m in result if m["role"] == "assistant" and m["research_id"] == "r-1" and m["content"] and m["message_type"] != "step" ), None, ) assert formatted is not None assert formatted["id"] == "resp-1" assert "Photosynthesis" in formatted["content"] class TestChatServiceTitleGeneration: """Tests for the split between sync fallback title and async LLM title.""" def test_create_session_does_not_invoke_llm(self, mock_user_db_session): """create_session must not block on the LLM even when settings ask for it. Regression guard: the deferred-title refactor moved LLM title generation out of the POST /api/chat/sessions path so the HTTP response isn't blocked on a model round-trip. """ from src.local_deep_research.chat.service import ChatService mock_user_db_session.add = MagicMock() mock_user_db_session.commit = MagicMock() settings_snapshot = {"chat.llm_title_generation": {"value": True}} with patch( "src.local_deep_research.config.llm_config.get_llm" ) as mock_get_llm: service = ChatService(username="testuser") service.create_session( initial_query="Does quantum entanglement violate locality?", settings_snapshot=settings_snapshot, ) mock_get_llm.assert_not_called() def test_regenerate_title_with_llm_updates_session( self, mock_user_db_session, mock_chat_session ): """regenerate_title_with_llm writes the LLM-returned title to the session.""" from src.local_deep_research.chat.service import ChatService # Title must equal the non-LLM fallback so the idempotency check # at the top of regenerate_title_with_llm doesn't short-circuit. query = "Does quantum entanglement violate locality?" mock_chat_session.title = query # fallback for short queries == query setup_query_mock_with_session(mock_user_db_session, mock_chat_session) mock_user_db_session.commit = MagicMock() fake_llm = MagicMock() fake_llm.invoke.return_value = MagicMock( content=" Quantum Locality Question " ) settings_snapshot = {"chat.llm_title_generation": {"value": True}} with ( patch( "src.local_deep_research.config.llm_config.get_llm", return_value=fake_llm, ), patch( "src.local_deep_research.config.thread_settings.get_setting_from_snapshot", return_value=True, ), ): service = ChatService(username="testuser") new_title = service.regenerate_title_with_llm( mock_chat_session.id, query=query, settings_snapshot=settings_snapshot, ) assert new_title == "Quantum Locality Question" # The session object passed to the DB got the new title assert mock_chat_session.title == "Quantum Locality Question" def test_regenerate_title_strips_newlines_from_llm_output( self, mock_user_db_session, mock_chat_session ): """Regression guard: LLM-generated titles must have CR/LF flattened. The LLM-generated title is interpolated into loguru f-strings (the "title already set" log line) and into document.title / chatTitle.textContent on the client. An LLM that returns a title containing a newline would otherwise forge what looks like a second log entry in aggregators (log injection). The stored title must have all CR/LF flattened to spaces. """ from src.local_deep_research.chat.service import ChatService query = "Does quantum entanglement violate locality?" mock_chat_session.title = query # equals fallback → not skipped setup_query_mock_with_session(mock_user_db_session, mock_chat_session) mock_user_db_session.commit = MagicMock() # Hostile/sloppy LLM output: embedded LF and CRLF that, unstripped, # would split the audit log line into three. fake_llm = MagicMock() fake_llm.invoke.return_value = MagicMock( content="Quantum Locality\nINFO faked log line\r\ntrailing" ) settings_snapshot = {"chat.llm_title_generation": {"value": True}} with ( patch( "src.local_deep_research.config.llm_config.get_llm", return_value=fake_llm, ), patch( "src.local_deep_research.config.thread_settings.get_setting_from_snapshot", return_value=True, ), ): service = ChatService(username="testuser") new_title = service.regenerate_title_with_llm( mock_chat_session.id, query=query, settings_snapshot=settings_snapshot, ) assert new_title is not None assert "\n" not in new_title, "newline must be stripped from title" assert "\r" not in new_title, ( "carriage return must be stripped from title" ) # The visible content survives, just flattened to spaces. assert new_title.startswith("Quantum Locality") # And the same flattened value is what landed on the session row. assert mock_chat_session.title == new_title def test_regenerate_title_skipped_when_user_has_edited_title( self, mock_user_db_session, mock_chat_session ): """If the title no longer matches the non-LLM fallback (because the user manually edited it, or a sibling tab's LLM-gen already ran), skip the LLM call to avoid burning credits to overwrite their work.""" from src.local_deep_research.chat.service import ChatService query = "Does quantum entanglement violate locality?" mock_chat_session.title = "My Custom Title" # user-edited setup_query_mock_with_session(mock_user_db_session, mock_chat_session) fake_llm = MagicMock() fake_llm.invoke.return_value = MagicMock(content="LLM Title") with ( patch( "src.local_deep_research.config.llm_config.get_llm", return_value=fake_llm, ), patch( "src.local_deep_research.config.thread_settings.get_setting_from_snapshot", return_value=True, ), ): service = ChatService(username="testuser") new_title = service.regenerate_title_with_llm( mock_chat_session.id, query=query, settings_snapshot={ "chat.llm_title_generation": {"value": True} }, ) assert new_title is None fake_llm.invoke.assert_not_called() # User's custom title is preserved assert mock_chat_session.title == "My Custom Title" def test_regenerate_title_with_llm_returns_none_for_empty_query( self, mock_user_db_session ): """Empty query → None (no LLM call, no DB write).""" from src.local_deep_research.chat.service import ChatService service = ChatService(username="testuser") assert ( service.regenerate_title_with_llm( "sess-1", query="", settings_snapshot={} ) is None ) assert ( service.regenerate_title_with_llm( "sess-1", query=None, settings_snapshot={} ) is None ) class TestChatMessageEnumValidation: """Real-SQLite (not mocked) coverage for ChatRole/ChatMessageType validation. The manual `valid_roles` / `valid_message_types` set checks were replaced by `ChatRole(value)` / `ChatMessageType(value)` coercion that re-raises as ValueError, preserving the HTTP 400 route-layer contract. """ def _setup_real_db(self, tmp_path): """File-backed SQLite with chat tables + a seeded session.""" import uuid as _uuid from datetime import datetime as _dt, UTC as _utc from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker from src.local_deep_research.database.models import ( Base, ChatSession, ) db_path = tmp_path / "enum_validation.db" engine = create_engine(f"sqlite:///{db_path}") Base.metadata.create_all(engine) Session = sessionmaker(bind=engine) session_id = str(_uuid.uuid4()) with Session() as db: db.add( ChatSession( id=session_id, title="enum-test", status="active", accumulated_context={ "key_entities": [], "topics": [], "summary": "", }, created_at=_dt.now(_utc), message_count=0, ) ) db.commit() return engine, Session, session_id def test_add_message_rejects_invalid_role(self, tmp_path): """Unknown role → ValueError (which routes map to HTTP 400).""" from contextlib import contextmanager from src.local_deep_research.chat.service import ChatService engine, Session, session_id = self._setup_real_db(tmp_path) @contextmanager def real_get_user_db_session(username, password=None): with Session() as db: yield db service = ChatService(username="testuser") with patch( "src.local_deep_research.chat.service.get_user_db_session", real_get_user_db_session, ): with pytest.raises(ValueError, match="Invalid role"): service.add_message( session_id=session_id, role="usr", # typo — not in ChatRole content="hi", message_type="query", ) engine.dispose() def test_add_message_rejects_invalid_message_type(self, tmp_path): """Unknown message_type → ValueError.""" from contextlib import contextmanager from src.local_deep_research.chat.service import ChatService engine, Session, session_id = self._setup_real_db(tmp_path) @contextmanager def real_get_user_db_session(username, password=None): with Session() as db: yield db service = ChatService(username="testuser") with patch( "src.local_deep_research.chat.service.get_user_db_session", real_get_user_db_session, ): with pytest.raises(ValueError, match="Invalid message_type"): service.add_message( session_id=session_id, role="user", content="hi", message_type="queryz", # typo ) engine.dispose() def test_add_message_accepts_string_values(self, tmp_path): """Valid string values are coerced to enum members transparently.""" from contextlib import contextmanager from src.local_deep_research.chat.service import ChatService from src.local_deep_research.database.models import ( ChatMessage, ChatMessageType, ChatRole, ) engine, Session, session_id = self._setup_real_db(tmp_path) @contextmanager def real_get_user_db_session(username, password=None): with Session() as db: yield db service = ChatService(username="testuser") with patch( "src.local_deep_research.chat.service.get_user_db_session", real_get_user_db_session, ): service.add_message( session_id=session_id, role="user", content="hi", message_type="query", ) with Session() as db: msg = db.query(ChatMessage).filter_by(session_id=session_id).one() assert msg.role is ChatRole.USER assert msg.message_type is ChatMessageType.QUERY # `(str, enum.Enum)` members compare equal to their string value. assert msg.role == "user" assert msg.message_type == "query" engine.dispose() class TestChatServiceGetInProgressResearchId: """Tests for ``ChatService.get_in_progress_research_id``. The client (chat.js ``loadSession``) reads this value to restore the "thinking" indicator on reload. The method queries ResearchHistory via the partial-unique index ``ux_research_history_chat_session_in_progress`` (migration 0010) so at most one row can match. """ def test_returns_none_when_no_in_progress_research( self, mock_user_db_session ): from src.local_deep_research.chat.service import ChatService mock_user_db_session.query.return_value.filter.return_value.first.return_value = None service = ChatService(username="testuser") assert service.get_in_progress_research_id("session-123") is None def test_returns_id_when_in_progress_research_exists( self, mock_user_db_session ): from src.local_deep_research.chat.service import ChatService mock_user_db_session.query.return_value.filter.return_value.first.return_value = ( "research-abc-123", ) service = ChatService(username="testuser") assert ( service.get_in_progress_research_id("session-123") == "research-abc-123" ) def test_db_error_propagates(self, mock_user_db_session): """DB exceptions must propagate so the route handler can return a 500 the client surfaces as an error banner. The previous return-None behaviour was indistinguishable from "no research running" — the UI silently re-enabled the send button and the user's double-submit hit the unique-index guard. The strict behaviour matches every other ChatService query method.""" import pytest from sqlalchemy.exc import OperationalError from src.local_deep_research.chat.service import ChatService mock_user_db_session.query.side_effect = OperationalError( "stmt", {}, Exception("boom") ) service = ChatService(username="testuser") with pytest.raises(OperationalError): service.get_in_progress_research_id("session-123") def test_query_filters_by_in_progress_status_and_session_id( self, mock_user_db_session ): """Locks the query shape against accidental regressions — the partial-unique-index lookup depends on the predicate matching the index's WHERE clause exactly.""" from src.local_deep_research.chat.service import ChatService from src.local_deep_research.constants import ResearchStatus from src.local_deep_research.database.models import ResearchHistory query_mock = MagicMock() query_mock.filter.return_value.first.return_value = None mock_user_db_session.query.return_value = query_mock service = ChatService(username="testuser") service.get_in_progress_research_id("session-xyz") # Selected ResearchHistory.id (not the whole row) mock_user_db_session.query.assert_called_once_with(ResearchHistory.id) # Filter call received both clauses; we don't assert SQL text # equality (SQLAlchemy ClauseElements compare by identity), only # that two clauses were passed and IN_PROGRESS is referenced. filter_args = query_mock.filter.call_args[0] assert len(filter_args) == 2 # Verify the actual bound values rather than just that the enum exists # (the previous `is not None` assertion was a tautology). Each clause is # a BinaryExpression whose .right is a BindParameter carrying the # compared value; this catches a regression that swaps IN_PROGRESS for # another status or drops the session-id predicate. bind_values = [ getattr(getattr(c, "right", None), "value", None) for c in filter_args ] assert ResearchStatus.IN_PROGRESS in bind_values assert "session-xyz" in bind_values