""" Tests for web/services/research_sources_service.py Tests cover: - ResearchSourcesService.save_research_sources() - ResearchSourcesService.get_research_sources() - ResearchSourcesService.update_research_with_sources() """ from unittest.mock import Mock, patch, MagicMock class TestSaveResearchSources: """Tests for save_research_sources method.""" def test_save_research_sources_empty_list(self): """Test saving empty list returns 0.""" from local_deep_research.web.services.research_sources_service import ( ResearchSourcesService, ) result = ResearchSourcesService.save_research_sources( "test-id", [], username="testuser" ) assert result == 0 def test_save_research_sources_success(self): """Save 2 non-academic web sources → 2 ResearchResource adds. These URLs (``example.com``) don't match any academic engine pattern, so ``detect_engine`` returns None and the Paper / PaperAppearance write path short-circuits. The 1:1 add-count below is correct only for non-academic sources; academic sources go through three add() calls each (ResearchResource + Paper + PaperAppearance). Real academic dedup behavior is covered by tests/database/test_paper_dedup_integration.py. """ from local_deep_research.web.services.research_sources_service import ( ResearchSourcesService, ) sources = [ { "url": "https://example.com/1", "title": "Source 1", "snippet": "Test", }, {"url": "https://example.com/2", "title": "Source 2"}, ] with patch( "local_deep_research.web.services.research_sources_service.get_user_db_session" ) as mock_get_session: mock_session = MagicMock() mock_session.__enter__ = Mock(return_value=mock_session) mock_session.__exit__ = Mock(return_value=False) mock_session.query.return_value.filter_by.return_value.count.return_value = 0 mock_get_session.return_value = mock_session result = ResearchSourcesService.save_research_sources( "test-id", sources, username="testuser" ) assert result == 2 assert mock_session.add.call_count == 2 mock_session.commit.assert_called_once() def test_save_research_sources_skips_existing(self): """Test skipping save when sources already exist.""" from local_deep_research.web.services.research_sources_service import ( ResearchSourcesService, ) sources = [{"url": "https://example.com", "title": "Test"}] with patch( "local_deep_research.web.services.research_sources_service.get_user_db_session" ) as mock_get_session: mock_session = MagicMock() mock_session.__enter__ = Mock(return_value=mock_session) mock_session.__exit__ = Mock(return_value=False) # Return that 5 resources already exist mock_session.query.return_value.filter_by.return_value.count.return_value = 5 mock_get_session.return_value = mock_session result = ResearchSourcesService.save_research_sources( "test-id", sources, username="testuser" ) assert result == 5 mock_session.add.assert_not_called() def test_save_research_sources_skips_no_url(self): """Test skipping sources without URL.""" from local_deep_research.web.services.research_sources_service import ( ResearchSourcesService, ) sources = [ {"title": "No URL source"}, # No url field {"url": "", "title": "Empty URL"}, # Empty url {"url": "https://valid.com", "title": "Valid"}, ] with patch( "local_deep_research.web.services.research_sources_service.get_user_db_session" ) as mock_get_session: mock_session = MagicMock() mock_session.__enter__ = Mock(return_value=mock_session) mock_session.__exit__ = Mock(return_value=False) mock_session.query.return_value.filter_by.return_value.count.return_value = 0 mock_get_session.return_value = mock_session result = ResearchSourcesService.save_research_sources( "test-id", sources, username="testuser" ) # Only valid URL should be saved assert result == 1 def test_save_research_sources_uses_link_fallback(self): """Test using 'link' as fallback for 'url'.""" from local_deep_research.web.services.research_sources_service import ( ResearchSourcesService, ) sources = [{"link": "https://example.com", "title": "Test"}] with patch( "local_deep_research.web.services.research_sources_service.get_user_db_session" ) as mock_get_session: mock_session = MagicMock() mock_session.__enter__ = Mock(return_value=mock_session) mock_session.__exit__ = Mock(return_value=False) mock_session.query.return_value.filter_by.return_value.count.return_value = 0 mock_get_session.return_value = mock_session result = ResearchSourcesService.save_research_sources( "test-id", sources, username="testuser" ) assert result == 1 def test_save_research_sources_truncates_snippet(self): """Test that long snippets are truncated to 1000 chars.""" from local_deep_research.web.services.research_sources_service import ( ResearchSourcesService, ) long_snippet = "x" * 2000 sources = [ { "url": "https://example.com", "title": "Test", "snippet": long_snippet, } ] with patch( "local_deep_research.web.services.research_sources_service.get_user_db_session" ) as mock_get_session: mock_session = MagicMock() mock_session.__enter__ = Mock(return_value=mock_session) mock_session.__exit__ = Mock(return_value=False) mock_session.query.return_value.filter_by.return_value.count.return_value = 0 mock_get_session.return_value = mock_session ResearchSourcesService.save_research_sources( "test-id", sources, username="testuser" ) # Verify the resource was added with truncated preview add_call = mock_session.add.call_args resource = add_call[0][0] assert len(resource.content_preview) == 1000 def test_save_research_sources_handles_individual_errors(self): """Test that individual source errors don't stop the batch.""" from local_deep_research.web.services.research_sources_service import ( ResearchSourcesService, ) sources = [ {"url": "https://good.com", "title": "Good"}, {"url": "https://bad.com", "title": "Bad"}, # Will cause error {"url": "https://good2.com", "title": "Good 2"}, ] with patch( "local_deep_research.web.services.research_sources_service.get_user_db_session" ) as mock_get_session: mock_session = MagicMock() mock_session.__enter__ = Mock(return_value=mock_session) mock_session.__exit__ = Mock(return_value=False) mock_session.query.return_value.filter_by.return_value.count.return_value = 0 mock_get_session.return_value = mock_session # The add method works for good URLs result = ResearchSourcesService.save_research_sources( "test-id", sources, username="testuser" ) assert result == 3 class TestGetResearchSources: """Tests for get_research_sources method.""" def test_get_research_sources_returns_list(self): """Test that method returns a list.""" from local_deep_research.web.services.research_sources_service import ( ResearchSourcesService, ) with patch( "local_deep_research.web.services.research_sources_service.get_user_db_session" ) as mock_get_session: mock_session = MagicMock() mock_session.__enter__ = Mock(return_value=mock_session) mock_session.__exit__ = Mock(return_value=False) mock_session.query.return_value.filter_by.return_value.order_by.return_value.all.return_value = [] mock_get_session.return_value = mock_session result = ResearchSourcesService.get_research_sources( "test-id", username="testuser" ) assert isinstance(result, list) assert len(result) == 0 def test_get_research_sources_formats_correctly(self): """Test that resources are formatted correctly.""" from local_deep_research.web.services.research_sources_service import ( ResearchSourcesService, ) mock_resource = MagicMock() mock_resource.id = 1 mock_resource.url = "https://example.com" mock_resource.title = "Test Title" mock_resource.content_preview = "Preview text" mock_resource.source_type = "web" mock_resource.resource_metadata = {"key": "value"} mock_resource.created_at = "2024-01-01T00:00:00" with patch( "local_deep_research.web.services.research_sources_service.get_user_db_session" ) as mock_get_session: mock_session = MagicMock() mock_session.__enter__ = Mock(return_value=mock_session) mock_session.__exit__ = Mock(return_value=False) mock_session.query.return_value.filter_by.return_value.order_by.return_value.all.return_value = [ mock_resource ] mock_get_session.return_value = mock_session result = ResearchSourcesService.get_research_sources( "test-id", username="testuser" ) assert len(result) == 1 assert result[0]["id"] == 1 assert result[0]["url"] == "https://example.com" assert result[0]["title"] == "Test Title" assert result[0]["snippet"] == "Preview text" assert result[0]["source_type"] == "web" assert result[0]["metadata"] == {"key": "value"} def test_get_research_sources_handles_none_metadata(self): """Test handling resources with None metadata.""" from local_deep_research.web.services.research_sources_service import ( ResearchSourcesService, ) mock_resource = MagicMock() mock_resource.id = 1 mock_resource.url = "https://example.com" mock_resource.title = "Test" mock_resource.content_preview = None mock_resource.source_type = "web" mock_resource.resource_metadata = None mock_resource.created_at = None with patch( "local_deep_research.web.services.research_sources_service.get_user_db_session" ) as mock_get_session: mock_session = MagicMock() mock_session.__enter__ = Mock(return_value=mock_session) mock_session.__exit__ = Mock(return_value=False) mock_session.query.return_value.filter_by.return_value.order_by.return_value.all.return_value = [ mock_resource ] mock_get_session.return_value = mock_session result = ResearchSourcesService.get_research_sources( "test-id", username="testuser" ) assert result[0]["metadata"] == {} class TestUpdateResearchWithSources: """Tests for update_research_with_sources method.""" def test_update_research_with_sources_success(self): """Test successful research update with sources.""" from local_deep_research.web.services.research_sources_service import ( ResearchSourcesService, ) sources = [{"url": "https://example.com", "title": "Test"}] mock_research = MagicMock() mock_research.research_meta = {} with patch.object( ResearchSourcesService, "save_research_sources", return_value=1 ): with patch( "local_deep_research.web.services.research_sources_service.get_user_db_session" ) as mock_get_session: mock_session = MagicMock() mock_session.__enter__ = Mock(return_value=mock_session) mock_session.__exit__ = Mock(return_value=False) mock_session.query.return_value.filter_by.return_value.first.return_value = mock_research mock_get_session.return_value = mock_session result = ResearchSourcesService.update_research_with_sources( "test-id", sources, username="testuser" ) assert result is True assert mock_research.research_meta["sources_count"] == 1 assert mock_research.research_meta["has_sources"] is True def test_update_research_not_found(self): """Test update when research not found.""" from local_deep_research.web.services.research_sources_service import ( ResearchSourcesService, ) with patch.object( ResearchSourcesService, "save_research_sources", return_value=0 ): with patch( "local_deep_research.web.services.research_sources_service.get_user_db_session" ) as mock_get_session: mock_session = MagicMock() mock_session.__enter__ = Mock(return_value=mock_session) mock_session.__exit__ = Mock(return_value=False) mock_session.query.return_value.filter_by.return_value.first.return_value = None mock_get_session.return_value = mock_session result = ResearchSourcesService.update_research_with_sources( "nonexistent-id", [], username="testuser" ) assert result is False def test_update_research_initializes_none_metadata(self): """Test that None metadata is initialized.""" from local_deep_research.web.services.research_sources_service import ( ResearchSourcesService, ) mock_research = MagicMock() mock_research.research_meta = None with patch.object( ResearchSourcesService, "save_research_sources", return_value=5 ): with patch( "local_deep_research.web.services.research_sources_service.get_user_db_session" ) as mock_get_session: mock_session = MagicMock() mock_session.__enter__ = Mock(return_value=mock_session) mock_session.__exit__ = Mock(return_value=False) mock_session.query.return_value.filter_by.return_value.first.return_value = mock_research mock_get_session.return_value = mock_session result = ResearchSourcesService.update_research_with_sources( "test-id", [], username="testuser" ) assert result is True assert mock_research.research_meta == { "sources_count": 5, "has_sources": True, } def test_update_research_handles_exception(self): """Test that exceptions return False.""" from local_deep_research.web.services.research_sources_service import ( ResearchSourcesService, ) with patch.object( ResearchSourcesService, "save_research_sources", side_effect=Exception("DB error"), ): result = ResearchSourcesService.update_research_with_sources( "test-id", [], username="testuser" ) assert result is False class TestMergeIdentifiersJournalColumns: """_merge_identifiers fills container_title only when the existing row has NULL — first-write-wins keeps the row stable as the same Paper gets re-encountered across research sessions. Note: there is no ``journal_quality`` column on Paper; quality is resolved live at render time via ``journals.quality`` (Tier 4) or the bundled reference DB (Tier 1-3). """ def _make_paper_stub(self, **initial): """Minimal stand-in for a Paper ORM row. `_merge_identifiers` only assigns attributes — no SQLAlchemy machinery is needed. Using a plain object with preset fields makes the test assertions straightforward. """ defaults = { "doi": None, "arxiv_id": None, "pmid": None, "journal_id": None, "container_title": None, "paper_metadata": None, } defaults.update(initial) class _Stub: pass stub = _Stub() for k, v in defaults.items(): setattr(stub, k, v) return stub def test_fills_container_title_when_missing(self): from local_deep_research.web.services.research_sources_service import ( _merge_identifiers, ) paper = self._make_paper_stub() _merge_identifiers( paper, {"container_title": "Nature"}, {}, ) assert paper.container_title == "Nature" def test_does_not_overwrite_existing_container_title(self): from local_deep_research.web.services.research_sources_service import ( _merge_identifiers, ) paper = self._make_paper_stub(container_title="Science") _merge_identifiers( paper, {"container_title": "Nature"}, {}, ) assert paper.container_title == "Science" class TestContainerTitleNotInMetadataBlob: """After the B.2 fix, container_title lives only in the indexed Paper column — not duplicated inside the paper_metadata JSON blob. The CSL-JSON export (used for citation rendering) already captures the raw value inside citation_fields["csl_json"] during normalize_citation, so popping the top-level key is safe. """ def test_normalize_citation_still_populates_csl_container_title(self): """The CSL export captures container_title before any write-path pop — citation rendering stays intact even after B.2.""" from local_deep_research.utilities.citation_normalizer import ( normalize_citation, ) source = { "title": "A Study", "link": "https://doi.org/10.1234/test", "journal": "Nature", "source": "openalex", "doi": "10.1234/test", "year": 2024, } fields = normalize_citation(source) assert fields is not None # normalize_citation still emits container_title at the top # level so the write path can pop-and-promote it to the Paper # column. The CSL-JSON snapshot has already captured it too. assert fields.get("container_title") == "Nature" assert fields["csl_json"].get("container-title") == "Nature" def test_write_path_pop_preserves_csl_json(self): """Simulate the write-path pop pattern and confirm csl_json still carries the container-title after container_title is removed from the top-level dict. This is the invariant B.2 relies on. """ from local_deep_research.utilities.citation_normalizer import ( normalize_citation, ) source = { "title": "A Study", "link": "https://doi.org/10.1234/test", "journal": "Nature", "source": "openalex", "doi": "10.1234/test", } fields = normalize_citation(source) assert fields is not None # Mirror the write-path pop behaviour. container_title drops # out of `fields`, but csl_json (which the bibliography # exporter reads) still has it. fields.pop("container_title", None) assert "container_title" not in fields assert fields["csl_json"].get("container-title") == "Nature" class TestResearchSourcesServiceClass: """Tests for ResearchSourcesService class.""" def test_class_has_static_methods(self): """Test that class has required static methods.""" from local_deep_research.web.services.research_sources_service import ( ResearchSourcesService, ) assert hasattr(ResearchSourcesService, "save_research_sources") assert hasattr(ResearchSourcesService, "get_research_sources") assert hasattr(ResearchSourcesService, "update_research_with_sources") assert callable(ResearchSourcesService.save_research_sources) assert callable(ResearchSourcesService.get_research_sources) assert callable(ResearchSourcesService.update_research_with_sources)