""" API tests for the Follow-up Research feature. Tests the API endpoints for preparing and starting follow-up research. """ import json import uuid from unittest.mock import MagicMock, patch import pytest from local_deep_research.database.models import ( ResearchHistory, ResearchResource, ) from local_deep_research.followup_research.models import FollowUpRequest from local_deep_research.followup_research.service import ( FollowUpResearchService, ) class TestFollowUpAPI: """Test suite for follow-up research API endpoints.""" @pytest.fixture(autouse=True) def mock_db_manager(self): """Mock the database manager for all tests. Patches all module-level db_manager bindings used by Flask before_request middleware so unpatched middleware doesn't hit the real db_manager singleton (which lacks a "testuser" connection, causing 401s). has_encryption=False avoids the early password check in routes that returns 401 when encrypted DB password is unavailable. """ with ( patch( "local_deep_research.web.auth.decorators.db_manager" ) as mock_db, patch( "local_deep_research.database.encrypted_db.db_manager" ) as mock_enc_db, patch( "local_deep_research.web.auth.database_middleware.db_manager" ) as mock_mw_db, patch( "local_deep_research.web.auth.session_cleanup.db_manager" ) as mock_sc_db, patch( "local_deep_research.web.auth.queue_middleware.db_manager" ) as mock_qm_db, ): for mock in [ mock_db, mock_enc_db, mock_mw_db, mock_sc_db, mock_qm_db, ]: mock.connections = {"testuser": MagicMock()} mock.has_encryption = False mock.is_user_connected.return_value = True yield mock_db @pytest.fixture def app(self): """Create test Flask app.""" from local_deep_research.web.app_factory import create_app app, _ = create_app() # create_app returns (app, socketio) app.config["TESTING"] = True app.config["WTF_CSRF_ENABLED"] = False return app @pytest.fixture def client(self, app): """Create test client.""" return app.test_client() @pytest.fixture def authenticated_client(self, client, app): """Create authenticated test client.""" with client.session_transaction() as sess: sess["username"] = "testuser" sess["authenticated"] = True return client @pytest.fixture def mock_research_data(self): """Mock parent research data.""" research_id = str(uuid.uuid4()) return { "research_id": research_id, "research": ResearchHistory( id=research_id, query="What is quantum computing?", mode="quick_summary", status="completed", created_at="2024-01-01 10:00:00", report_content="Quantum computing uses quantum bits...", ), "resources": [ ResearchResource( research_id=research_id, title="Introduction to Quantum Computing", url="https://example.com/quantum", content_preview="Quantum computing is a revolutionary...", source_type="web", ), ResearchResource( research_id=research_id, title="Quantum Gates Explained", url="https://example.com/gates", content_preview="Quantum gates are the building blocks...", source_type="web", ), ], } def test_prepare_followup_success( self, authenticated_client, mock_research_data ): """Test successful preparation of follow-up research.""" with ( patch( "local_deep_research.followup_research.routes.FollowUpResearchService" ) as MockService, patch( "local_deep_research.settings.manager.SettingsManager" ) as MockSettings, patch( "local_deep_research.database.session_context.get_user_db_session" ) as mock_db_session, ): # Setup settings mock mock_settings_mgr = MockSettings.return_value mock_settings_mgr.get_all_settings.return_value = { "search.search_strategy": {"value": "source-based"}, } # Setup DB session mock mock_db = MagicMock() mock_db_session.return_value.__enter__.return_value = mock_db # Setup service mock mock_service = MockService.return_value mock_service.load_parent_research.return_value = { "query": mock_research_data["research"].query, "resources": [ { "url": r.url, "title": r.title, "content_preview": r.content_preview, "source_type": r.source_type, } for r in mock_research_data["resources"] ], } # Make request response = authenticated_client.post( "/api/followup/prepare", json={ "parent_research_id": mock_research_data["research_id"], "question": "How do quantum gates work?", }, ) # Verify response assert response.status_code == 200 data = json.loads(response.data) assert data["success"] is True assert data["parent_summary"] == "What is quantum computing?" assert data["available_sources"] == 2 assert data["suggested_strategy"] == "source-based" def test_prepare_followup_missing_params(self, authenticated_client): """Test prepare endpoint with missing parameters.""" # Add mocks for settings manager which is always called # audit: PUNCHLIST reviewed 2026-05 — issue resolved by prior PR (recommendation: split into two tests: auth ordering + missing-param). with ( patch( "local_deep_research.settings.manager.SettingsManager" ) as MockSettings, patch( "local_deep_research.database.session_context.get_user_db_session" ) as mock_db_session, ): # Setup settings mock mock_settings_mgr = MockSettings.return_value mock_settings_mgr.get_all_settings.return_value = {} # Setup DB session mock mock_db = MagicMock() mock_db_session.return_value.__enter__.return_value = mock_db response = authenticated_client.post( "/api/followup/prepare", json={"question": "Test question"} ) # Could be 400 (bad request) or 401 (unauthorized) depending on decorator order assert response.status_code in [400, 401] if response.status_code == 400: data = json.loads(response.data) assert data["success"] is False assert "Missing parent_research_id" in data["error"] def test_prepare_followup_not_found(self, authenticated_client): """Test prepare endpoint with non-existent parent research.""" with ( patch( "local_deep_research.followup_research.routes.FollowUpResearchService" ) as MockService, patch( "local_deep_research.settings.manager.SettingsManager" ) as MockSettings, patch( "local_deep_research.database.session_context.get_user_db_session" ) as mock_db_session, ): # Setup settings mock mock_settings_mgr = MockSettings.return_value mock_settings_mgr.get_all_settings.return_value = { "search.search_strategy": {"value": "source-based"}, } # Setup DB session mock mock_db = MagicMock() mock_db_session.return_value.__enter__.return_value = mock_db # Setup service mock mock_service = MockService.return_value mock_service.load_parent_research.return_value = ( None # Return None for not found ) response = authenticated_client.post( "/api/followup/prepare", json={ "parent_research_id": "non-existent-id", "question": "Test question", }, ) # Route returns 404 when load_parent_research yields None; # see followup_research/routes.py. assert response.status_code == 404 data = json.loads(response.data) assert data["success"] is False assert data["error"] == "Parent research not found" def test_start_followup_success( self, authenticated_client, mock_research_data ): """Test successful start of follow-up research.""" with ( patch( "local_deep_research.followup_research.routes.FollowUpResearchService" ) as MockService, patch( "local_deep_research.web.services.research_service.start_research_process" ) as mock_start, patch( "local_deep_research.settings.manager.SettingsManager" ) as MockSettings, patch( "local_deep_research.database.session_context.get_user_db_session" ) as mock_db_session, ): # Setup mocks mock_settings_mgr = MockSettings.return_value mock_settings_mgr.get_all_settings.return_value = { "search.search_strategy": {"value": "source-based"}, "search.iterations": {"value": 1}, "search.questions_per_iteration": {"value": 3}, "llm.provider": {"value": "OLLAMA"}, "llm.model": {"value": "gemma3:12b"}, "search.tool": {"value": "searxng"}, } # Setup DB session mock mock_db = MagicMock() mock_db_session.return_value.__enter__.return_value = mock_db # Setup service mock mock_service = MockService.return_value mock_service.perform_followup.return_value = { "query": "How do quantum gates work?", "strategy": "contextual-followup", "delegate_strategy": "source-based", "max_iterations": 1, "questions_per_iteration": 3, "parent_research_id": mock_research_data["research_id"], "research_context": { "parent_research_id": mock_research_data["research_id"], "past_links": [], "past_findings": "", }, } # Make request response = authenticated_client.post( "/api/followup/start", json={ "parent_research_id": mock_research_data["research_id"], "question": "How do quantum gates work?", "strategy": "source-based", "max_iterations": 1, "questions_per_iteration": 3, }, ) # Verify response assert response.status_code == 200 data = json.loads(response.data) assert data["success"] is True assert "research_id" in data assert data["message"] == "Follow-up research started" # Verify start_research_process was called mock_start.assert_called_once() def test_start_followup_unauthorized(self, client): """Test start endpoint without authentication.""" response = client.post( "/api/followup/start", json={"parent_research_id": "test-id", "question": "Test question"}, ) # Should redirect to login or return 401 assert response.status_code in [302, 401] def test_followup_service_load_parent(self, mock_research_data): """Test FollowUpResearchService.load_parent_research method.""" with ( patch( "local_deep_research.followup_research.service.get_user_db_session" ) as mock_session, patch( "local_deep_research.followup_research.service.ResearchSourcesService" ) as MockSourcesService, ): # Setup mock database session mock_db = MagicMock() mock_session.return_value.__enter__.return_value = mock_db # Setup mock research with research_meta mock_research = mock_research_data["research"] mock_research.research_meta = {"strategy_name": "source-based"} # Setup query results mock_db.query.return_value.filter_by.return_value.first.return_value = mock_research # Setup sources service mock mock_sources_service = MockSourcesService.return_value mock_sources_service.get_research_sources.return_value = [ { "url": r.url, "title": r.title, "content_preview": r.content_preview, "source_type": r.source_type, } for r in mock_research_data["resources"] ] # Test service service = FollowUpResearchService(username="testuser") result = service.load_parent_research( mock_research_data["research_id"] ) # Verify result assert result["research_id"] == mock_research_data["research_id"] assert result["query"] == "What is quantum computing?" assert len(result["resources"]) == 2 assert len(result["all_links_of_system"]) == 2 def test_followup_service_prepare_context(self, mock_research_data): """Test FollowUpResearchService.prepare_research_context method.""" with patch.object( FollowUpResearchService, "load_parent_research" ) as mock_load: # Setup mock mock_load.return_value = { "query": mock_research_data["research"].query, "formatted_findings": "Key findings about quantum computing...", # Mock formatted findings "report_content": mock_research_data["research"].report_content, "resources": [ { "url": r.url, "title": r.title, "content_preview": r.content_preview, "source_type": r.source_type, } for r in mock_research_data["resources"] ], "all_links_of_system": [ { "url": r.url, "title": r.title, "snippet": r.content_preview, } for r in mock_research_data["resources"] ], } # Test service service = FollowUpResearchService(username="testuser") context = service.prepare_research_context( mock_research_data["research_id"] ) # Verify context assert ( context["parent_research_id"] == mock_research_data["research_id"] ) assert len(context["past_links"]) == 2 assert ( context["past_findings"] == "Key findings about quantum computing..." # Expected mock value ) assert context["original_query"] == "What is quantum computing?" def test_followup_request_model(self): """Test FollowUpRequest model.""" request = FollowUpRequest( parent_research_id="test-id", question="How does it work?", strategy="source-based", max_iterations=2, questions_per_iteration=5, ) # Test to_dict data = request.to_dict() assert data["parent_research_id"] == "test-id" assert data["question"] == "How does it work?" assert data["strategy"] == "source-based" assert data["max_iterations"] == 2 assert data["questions_per_iteration"] == 5