# allow: no-sut-import — black-box HTTP test; drives real routes through the Flask test client """ Tests for chat research triggering edge cases. These tests verify the complex research triggering logic in the send_message endpoint. """ import json from unittest.mock import patch, MagicMock class TestResearchTriggering: """Tests for research triggering in send_message endpoint.""" def test_send_message_trigger_research_false_skips_research( self, authenticated_client ): """Test that trigger_research=false skips research process.""" # Create a session first create_response = authenticated_client.post( "/api/chat/sessions", json={"initial_query": "Test query"}, content_type="application/json", ) session_id = json.loads(create_response.data)["session_id"] # Send message with trigger_research=False response = authenticated_client.post( f"/api/chat/sessions/{session_id}/messages", json={"content": "Test message", "trigger_research": False}, content_type="application/json", ) assert response.status_code == 200 data = json.loads(response.data) assert data["success"] is True assert data["research_id"] is None assert data["research_mode"] == "none" def test_send_message_trigger_research_true_returns_research_id( self, authenticated_client ): """Test that trigger_research=true starts research and returns research_id.""" # Create a session first create_response = authenticated_client.post( "/api/chat/sessions", json={"initial_query": "Test query"}, content_type="application/json", ) session_id = json.loads(create_response.data)["session_id"] # Mock the research process to avoid actually running it # Patch at the source module where the function is defined with patch( "local_deep_research.chat.routes.start_research_process" ) as mock_start_research: response = authenticated_client.post( f"/api/chat/sessions/{session_id}/messages", json={ "content": "What is quantum computing?", "trigger_research": True, }, content_type="application/json", ) assert response.status_code == 200 data = json.loads(response.data) assert data["success"] is True assert data["research_id"] is not None assert data["research_mode"] == "quick" # Verify research process was started assert mock_start_research.called def test_send_message_blocks_research_when_password_missing_encrypted( self, authenticated_client ): """Encrypted DB + no DB password -> 401, no research spawned (#4457). Mirrors the guard on /start_research and the follow-up route: a chat-triggered research must not run passwordless, because its background metric writes (token/search) would be silently dropped, leaving the metrics dashboard empty while the research completes. Trigger in production: session-password TTL expiry or a server restart while the session cookie is still valid. """ create_response = authenticated_client.post( "/api/chat/sessions", json={"initial_query": "Test query"}, content_type="application/json", ) session_id = json.loads(create_response.data)["session_id"] # The route delegates the encryption-aware decision to the shared # resolve_user_password helper; simulate "encrypted DB, password # lost" -> (None, session_expired=True). with ( patch( "local_deep_research.chat.routes.resolve_user_password", return_value=(None, True), ), patch( "local_deep_research.chat.routes.start_research_process" ) as mock_start_research, ): response = authenticated_client.post( f"/api/chat/sessions/{session_id}/messages", json={ "content": "What is quantum computing?", "trigger_research": True, }, content_type="application/json", ) assert response.status_code == 401 data = json.loads(response.data) assert data["success"] is False assert "log out" in data["error"].lower() # No worker thread spawned — the run is refused before side effects. mock_start_research.assert_not_called() def test_send_message_unencrypted_db_allows_research_without_password( self, authenticated_client ): """A plaintext (no-SQLCipher) DB must NOT be blocked by the password guard. resolve_user_password returns (None, session_expired=False) for an unencrypted install, so the chat research path must proceed. """ create_response = authenticated_client.post( "/api/chat/sessions", json={"initial_query": "Test query"}, content_type="application/json", ) session_id = json.loads(create_response.data)["session_id"] with ( patch( "local_deep_research.chat.routes.resolve_user_password", return_value=(None, False), ), patch( "local_deep_research.chat.routes.start_research_process" ) as mock_start_research, ): response = authenticated_client.post( f"/api/chat/sessions/{session_id}/messages", json={ "content": "What is quantum computing?", "trigger_research": True, }, content_type="application/json", ) assert response.status_code == 200 assert mock_start_research.called def test_send_message_uses_quick_mode_by_default( self, authenticated_client ): """Test that research always uses 'quick' mode in chat.""" # Create a session create_response = authenticated_client.post( "/api/chat/sessions", json={"initial_query": "Test query"}, content_type="application/json", ) session_id = json.loads(create_response.data)["session_id"] # Mock research process at source module with patch("local_deep_research.chat.routes.start_research_process"): response = authenticated_client.post( f"/api/chat/sessions/{session_id}/messages", json={ "content": "Research question", "trigger_research": True, "research_mode": "detailed", # Try to request detailed }, content_type="application/json", ) # Even when requesting "detailed", chat uses "quick" assert response.status_code == 200 data = json.loads(response.data) assert data["research_mode"] == "quick" def test_send_message_nonexistent_session_returns_404( self, authenticated_client ): """Test that sending message to non-existent session returns 404.""" response = authenticated_client.post( "/api/chat/sessions/non-existent-session-id-12345/messages", json={"content": "Test message", "trigger_research": False}, content_type="application/json", ) assert response.status_code == 404 data = json.loads(response.data) assert data["success"] is False assert "Session not found" in data["error"] def test_send_message_context_includes_history(self, authenticated_client): """Test that research context includes conversation history.""" # Create a session create_response = authenticated_client.post( "/api/chat/sessions", json={"initial_query": "Initial topic"}, content_type="application/json", ) session_id = json.loads(create_response.data)["session_id"] # Send first message without research authenticated_client.post( f"/api/chat/sessions/{session_id}/messages", json={"content": "What is AI?", "trigger_research": False}, content_type="application/json", ) # Send second message with research - verify context is passed # Patch at correct locations - ChatContextManager is imported at module level in routes with patch("local_deep_research.chat.routes.start_research_process"): with patch( "local_deep_research.chat.routes.ChatContextManager" ) as mock_context_manager_class: mock_context_manager = MagicMock() mock_context_manager.build_research_context.return_value = { "session_id": session_id, "is_multi_turn": True, } mock_context_manager_class.return_value = mock_context_manager response = authenticated_client.post( f"/api/chat/sessions/{session_id}/messages", json={ "content": "Tell me more about machine learning", "trigger_research": True, }, content_type="application/json", ) assert response.status_code == 200 # Verify context manager was called with messages assert mock_context_manager_class.called def test_send_message_default_trigger_research_is_true( self, authenticated_client ): """Test that trigger_research defaults to True when not specified.""" # Create a session create_response = authenticated_client.post( "/api/chat/sessions", json={"initial_query": "Test query"}, content_type="application/json", ) session_id = json.loads(create_response.data)["session_id"] # Mock research process at source module with patch( "local_deep_research.chat.routes.start_research_process" ) as mock_start_research: response = authenticated_client.post( f"/api/chat/sessions/{session_id}/messages", json={ "content": "Test message" }, # No trigger_research specified content_type="application/json", ) assert response.status_code == 200 data = json.loads(response.data) # Should trigger research by default assert data["research_id"] is not None assert mock_start_research.called def test_follow_up_uses_contextual_followup_strategy( self, authenticated_client ): """Test that follow-up messages switch to enhanced-contextual-followup strategy.""" create_response = authenticated_client.post( "/api/chat/sessions", json={"initial_query": "Initial topic"}, content_type="application/json", ) session_id = json.loads(create_response.data)["session_id"] # Add first message without research authenticated_client.post( f"/api/chat/sessions/{session_id}/messages", json={"content": "What is AI?", "trigger_research": False}, content_type="application/json", ) # Send follow-up — mock context to return is_multi_turn=True with patch( "local_deep_research.chat.routes.start_research_process" ) as mock_start: with patch( "local_deep_research.chat.routes.ChatContextManager" ) as mock_ctx_cls: mock_ctx = MagicMock() mock_ctx.build_research_context.return_value = { "session_id": session_id, "is_multi_turn": True, } mock_ctx_cls.return_value = mock_ctx authenticated_client.post( f"/api/chat/sessions/{session_id}/messages", json={"content": "Tell me more", "trigger_research": True}, content_type="application/json", ) assert mock_start.called call_kwargs = mock_start.call_args[1] assert call_kwargs["strategy"] == "enhanced-contextual-followup" assert ( call_kwargs["research_context"]["delegate_strategy"] is not None ) def test_first_message_uses_user_strategy(self, authenticated_client): """Test that first message uses the user's configured strategy, not followup.""" create_response = authenticated_client.post( "/api/chat/sessions", json={"initial_query": "First question"}, content_type="application/json", ) session_id = json.loads(create_response.data)["session_id"] with patch( "local_deep_research.chat.routes.start_research_process" ) as mock_start: with patch( "local_deep_research.chat.routes.ChatContextManager" ) as mock_ctx_cls: mock_ctx = MagicMock() mock_ctx.build_research_context.return_value = { "session_id": session_id, "is_multi_turn": False, } mock_ctx_cls.return_value = mock_ctx authenticated_client.post( f"/api/chat/sessions/{session_id}/messages", json={"content": "First query", "trigger_research": True}, content_type="application/json", ) assert mock_start.called call_kwargs = mock_start.call_args[1] assert call_kwargs["strategy"] != "enhanced-contextual-followup"