""" Tests for LibraryService. """ from unittest.mock import Mock, patch, MagicMock class TestLibraryServiceUrlDetection: """Tests for URL detection methods.""" def test_is_arxiv_url_with_arxiv_domain(self): """Detects arxiv.org URLs correctly.""" from local_deep_research.research_library.services.library_service import ( LibraryService, ) with patch.object( LibraryService, "__init__", lambda self, username: None ): service = LibraryService.__new__(LibraryService) service.username = "test_user" # Valid arXiv URLs assert ( service._is_arxiv_url("https://arxiv.org/abs/2301.00001") is True ) assert ( service._is_arxiv_url("https://arxiv.org/pdf/2301.00001.pdf") is True ) assert ( service._is_arxiv_url("http://arxiv.org/abs/1234.5678") is True ) assert ( service._is_arxiv_url("https://export.arxiv.org/abs/2301.00001") is True ) def test_is_arxiv_url_with_non_arxiv_domain(self): """Rejects non-arXiv URLs.""" from local_deep_research.research_library.services.library_service import ( LibraryService, ) with patch.object( LibraryService, "__init__", lambda self, username: None ): service = LibraryService.__new__(LibraryService) service.username = "test_user" assert service._is_arxiv_url("https://google.com") is False assert ( service._is_arxiv_url("https://pubmed.ncbi.nlm.nih.gov/12345") is False ) assert ( service._is_arxiv_url("https://example.com/arxiv.org") is False ) def test_is_arxiv_url_with_invalid_url(self): """Handles invalid URLs gracefully.""" from local_deep_research.research_library.services.library_service import ( LibraryService, ) with patch.object( LibraryService, "__init__", lambda self, username: None ): service = LibraryService.__new__(LibraryService) service.username = "test_user" assert service._is_arxiv_url("not a url") is False assert service._is_arxiv_url("") is False def test_is_pubmed_url_with_pubmed_domain(self): """Detects PubMed URLs correctly.""" from local_deep_research.research_library.services.library_service import ( LibraryService, ) with patch.object( LibraryService, "__init__", lambda self, username: None ): service = LibraryService.__new__(LibraryService) service.username = "test_user" # Valid PubMed URLs assert ( service._is_pubmed_url( "https://pubmed.ncbi.nlm.nih.gov/12345678" ) is True ) assert ( service._is_pubmed_url( "https://ncbi.nlm.nih.gov/pmc/articles/PMC1234567" ) is True ) def test_is_pubmed_url_with_non_pubmed_domain(self): """Rejects non-PubMed URLs.""" from local_deep_research.research_library.services.library_service import ( LibraryService, ) with patch.object( LibraryService, "__init__", lambda self, username: None ): service = LibraryService.__new__(LibraryService) service.username = "test_user" assert ( service._is_pubmed_url("https://arxiv.org/abs/2301.00001") is False ) assert service._is_pubmed_url("https://google.com") is False class TestLibraryServiceDomainExtraction: """Tests for domain extraction.""" def test_extract_domain_from_url(self): """Extracts domain from URL correctly.""" from local_deep_research.research_library.services.library_service import ( LibraryService, ) with patch.object( LibraryService, "__init__", lambda self, username: None ): service = LibraryService.__new__(LibraryService) service.username = "test_user" assert ( service._extract_domain("https://arxiv.org/abs/2301.00001") == "arxiv.org" ) assert ( service._extract_domain("https://pubmed.ncbi.nlm.nih.gov/12345") == "pubmed.ncbi.nlm.nih.gov" ) assert ( service._extract_domain("https://example.com/path") == "example.com" ) def test_extract_domain_with_invalid_url(self): """Handles invalid URLs gracefully.""" from local_deep_research.research_library.services.library_service import ( LibraryService, ) with patch.object( LibraryService, "__init__", lambda self, username: None ): service = LibraryService.__new__(LibraryService) service.username = "test_user" assert service._extract_domain("not a url") == "" assert service._extract_domain("") == "" class TestLibraryServiceUrlHash: """Tests for URL hashing.""" def test_get_url_hash_normalizes_url(self): """URL hashing normalizes URLs before hashing.""" from local_deep_research.research_library.services.library_service import ( LibraryService, ) with patch.object( LibraryService, "__init__", lambda self, username: None ): service = LibraryService.__new__(LibraryService) service.username = "test_user" # Same URL with different protocols should produce same hash hash1 = service._get_url_hash("https://arxiv.org/abs/2301.00001") hash2 = service._get_url_hash("http://arxiv.org/abs/2301.00001") assert hash1 == hash2 def test_get_url_hash_removes_www(self): """URL hashing removes www prefix.""" from local_deep_research.research_library.services.library_service import ( LibraryService, ) with patch.object( LibraryService, "__init__", lambda self, username: None ): service = LibraryService.__new__(LibraryService) service.username = "test_user" hash1 = service._get_url_hash("https://www.example.com/page") hash2 = service._get_url_hash("https://example.com/page") assert hash1 == hash2 def test_get_url_hash_removes_trailing_slash(self): """URL hashing removes trailing slashes.""" from local_deep_research.research_library.services.library_service import ( LibraryService, ) with patch.object( LibraryService, "__init__", lambda self, username: None ): service = LibraryService.__new__(LibraryService) service.username = "test_user" hash1 = service._get_url_hash("https://example.com/page/") hash2 = service._get_url_hash("https://example.com/page") assert hash1 == hash2 class TestLibraryServiceToggleFavorite: """Tests for toggling document favorites.""" def test_toggle_favorite_document_found(self, library_session, mocker): """Toggles favorite status when document exists.""" from local_deep_research.research_library.services.library_service import ( LibraryService, ) # Create a mock document mock_doc = Mock() mock_doc.favorite = False # Mock the session context mock_session_context = mocker.patch( "local_deep_research.research_library.services.library_service.get_user_db_session" ) mock_session = MagicMock() mock_session.__enter__ = Mock(return_value=mock_session) mock_session.__exit__ = Mock(return_value=False) mock_session.query.return_value.get.return_value = mock_doc mock_session_context.return_value = mock_session with patch.object( LibraryService, "__init__", lambda self, username: None ): service = LibraryService.__new__(LibraryService) service.username = "test_user" result = service.toggle_favorite("doc-123") # Should toggle to True assert mock_doc.favorite is True assert result is True def test_toggle_favorite_document_not_found(self, mocker): """Returns False when document doesn't exist.""" from local_deep_research.research_library.services.library_service import ( LibraryService, ) # Mock the session context mock_session_context = mocker.patch( "local_deep_research.research_library.services.library_service.get_user_db_session" ) mock_session = MagicMock() mock_session.__enter__ = Mock(return_value=mock_session) mock_session.__exit__ = Mock(return_value=False) mock_session.query.return_value.get.return_value = None mock_session_context.return_value = mock_session with patch.object( LibraryService, "__init__", lambda self, username: None ): service = LibraryService.__new__(LibraryService) service.username = "test_user" result = service.toggle_favorite("nonexistent-doc") assert result is False class TestLibraryServiceDeleteDocument: """Tests for document deletion.""" def test_delete_document_not_found(self, mocker): """Returns False when document doesn't exist.""" from local_deep_research.research_library.services.library_service import ( LibraryService, ) # Mock the session context mock_session_context = mocker.patch( "local_deep_research.research_library.services.library_service.get_user_db_session" ) mock_session = MagicMock() mock_session.__enter__ = Mock(return_value=mock_session) mock_session.__exit__ = Mock(return_value=False) mock_session.query.return_value.get.return_value = None mock_session_context.return_value = mock_session with patch.object( LibraryService, "__init__", lambda self, username: None ): service = LibraryService.__new__(LibraryService) service.username = "test_user" result = service.delete_document("nonexistent-doc") assert result is False class TestLibraryServiceGetUniqueDomains: """Tests for getting unique domains.""" def test_get_unique_domains_returns_list(self, mocker): """Returns a sorted list of unique netlocs from all document URLs.""" from local_deep_research.research_library.services.library_service import ( LibraryService, ) # Mock the session context with sample data mock_session_context = mocker.patch( "local_deep_research.research_library.services.library_service.get_user_db_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.return_value.yield_per.return_value = [ ("https://arxiv.org/abs/1234",), ("https://nature.com/paper1",), ] mock_session_context.return_value = mock_session with patch.object( LibraryService, "__init__", lambda self, username: None ): service = LibraryService.__new__(LibraryService) service.username = "test_user" result = service.get_unique_domains() assert isinstance(result, list) assert "arxiv.org" in result assert "nature.com" in result class TestLibraryServiceGetAllCollections: """Tests for getting all collections.""" def test_get_all_collections_returns_list(self, mocker): """Returns a list of collections with document counts.""" from local_deep_research.research_library.services.library_service import ( LibraryService, ) # Create mock collection mock_collection = Mock() mock_collection.id = "coll-123" mock_collection.name = "Test Collection" mock_collection.description = "A test collection" mock_collection.is_default = False # Mock the session context mock_session_context = mocker.patch( "local_deep_research.research_library.services.library_service.get_user_db_session" ) mock_session = MagicMock() mock_session.__enter__ = Mock(return_value=mock_session) mock_session.__exit__ = Mock(return_value=False) # Mock query chain mock_query = Mock() mock_query.outerjoin.return_value = mock_query mock_query.group_by.return_value = mock_query mock_query.order_by.return_value = mock_query mock_query.all.return_value = [(mock_collection, 5, 3)] mock_session.query.return_value = mock_query mock_session_context.return_value = mock_session with patch.object( LibraryService, "__init__", lambda self, username: None ): service = LibraryService.__new__(LibraryService) service.username = "test_user" result = service.get_all_collections() assert isinstance(result, list) assert len(result) == 1 assert result[0]["id"] == "coll-123" assert result[0]["name"] == "Test Collection" assert result[0]["document_count"] == 5 class TestLibraryServiceGetDocumentById: """Tests for getting document by ID.""" def test_get_document_by_id_not_found(self, mocker): """Returns None when document not found.""" from local_deep_research.research_library.services.library_service import ( LibraryService, ) # Mock the session context mock_session_context = mocker.patch( "local_deep_research.research_library.services.library_service.get_user_db_session" ) mock_session = MagicMock() mock_session.__enter__ = Mock(return_value=mock_session) mock_session.__exit__ = Mock(return_value=False) # Mock query to return None mock_query = Mock() mock_query.outerjoin.return_value = mock_query mock_query.filter.return_value = mock_query mock_query.first.return_value = None mock_session.query.return_value = mock_query mock_session_context.return_value = mock_session with patch.object( LibraryService, "__init__", lambda self, username: None ): service = LibraryService.__new__(LibraryService) service.username = "test_user" result = service.get_document_by_id("nonexistent-doc") assert result is None class TestLibraryServiceGetLibraryStats: """Tests for get_library_stats method.""" def test_get_library_stats_returns_dict(self, mocker): """Returns dictionary with library statistics.""" from local_deep_research.research_library.services.library_service import ( LibraryService, ) mock_session_context = mocker.patch( "local_deep_research.research_library.services.library_service.get_user_db_session" ) mock_session = MagicMock() mock_session.__enter__ = Mock(return_value=mock_session) mock_session.__exit__ = Mock(return_value=False) # Mock query counts mock_session.query.return_value.count.return_value = 10 mock_session.query.return_value.filter.return_value.count.return_value = 5 mock_session_context.return_value = mock_session with patch.object( LibraryService, "__init__", lambda self, username: None ): service = LibraryService.__new__(LibraryService) service.username = "test_user" result = service.get_library_stats() assert isinstance(result, dict) class TestLibraryServiceGetDocuments: """Tests for get_documents method.""" def test_get_documents_returns_list(self, mocker): """Returns list of documents.""" from contextlib import contextmanager from local_deep_research.research_library.services.library_service import ( LibraryService, ) # Create a proper mock session mock_session = MagicMock() # Mock query chain - need to support chained calls mock_query = MagicMock() mock_query.outerjoin.return_value = mock_query mock_query.filter.return_value = mock_query mock_query.order_by.return_value = mock_query mock_query.offset.return_value = mock_query mock_query.limit.return_value = mock_query mock_query.all.return_value = [] mock_query.count.return_value = 0 mock_session.query.return_value = mock_query # Create a context manager that yields our mock session @contextmanager def mock_get_session(username, password=None): yield mock_session # Patch at the module level where it's imported mocker.patch( "local_deep_research.research_library.services.library_service.get_user_db_session", side_effect=mock_get_session, ) # Mock get_default_library_id since get_documents() calls it first # It's imported inside the function, so patch at the source module mocker.patch( "local_deep_research.database.library_init.get_default_library_id", return_value="default-library-id", ) with patch.object( LibraryService, "__init__", lambda self, username: None ): service = LibraryService.__new__(LibraryService) service.username = "test_user" result = service.get_documents() # get_documents() returns List[Dict] directly, not {"documents": [...]} assert isinstance(result, list) class TestLibraryServiceApplyDomainFilter: """Tests for _apply_domain_filter method.""" def test_apply_domain_filter_custom_domain(self, mocker): """Applies custom domain filter correctly (generic branch).""" from local_deep_research.research_library.services.library_service import ( LibraryService, ) mock_query = Mock() mock_query.filter.return_value = mock_query # Create a proper mock model class with the required attribute mock_model = Mock() mock_model.original_url = Mock() mock_model.original_url.ilike = Mock(return_value="filter_condition") with patch.object( LibraryService, "__init__", lambda self, username: None ): service = LibraryService.__new__(LibraryService) service.username = "test_user" service._apply_domain_filter(mock_query, mock_model, "example.com") # Should have called filter assert mock_query.filter.called class TestLibraryServiceApplySearchFilter: """Tests for _apply_search_filter method.""" def test_apply_search_filter_query(self, mocker): """Applies search query filter correctly.""" from local_deep_research.research_library.services.library_service import ( LibraryService, ) mock_query = Mock() mock_query.filter.return_value = mock_query # Create a proper mock model class with required attributes # Use Mock() for return values since SQLAlchemy's or_() will receive them mock_model = Mock() mock_model.title = Mock() mock_model.title.ilike = Mock( return_value=Mock() ) # Return Mock, not string mock_model.authors = Mock() mock_model.authors.ilike = Mock(return_value=Mock()) mock_model.doi = Mock() mock_model.doi.ilike = Mock(return_value=Mock()) # Mock the or_ function to avoid SQLAlchemy validation mocker.patch( "local_deep_research.research_library.services.library_service.or_", return_value=Mock(), ) # Also mock ResearchResource.title.ilike since _apply_search_filter uses it mock_resource = Mock() mock_resource.title = Mock() mock_resource.title.ilike = Mock(return_value=Mock()) mocker.patch( "local_deep_research.research_library.services.library_service.ResearchResource", mock_resource, ) with patch.object( LibraryService, "__init__", lambda self, username: None ): service = LibraryService.__new__(LibraryService) service.username = "test_user" service._apply_search_filter(mock_query, mock_model, "test search") assert mock_query.filter.called class TestLibraryServiceApplySearchFilterEscaping: """Integration tests: LIKE wildcards in user input must be literal. Uses a real in-memory SQLite session to verify that ``%`` and ``_`` in user-supplied search/domain strings are treated as literals, not as SQL LIKE wildcards. Regression coverage for the security fix in PR #3094. """ def test_search_percent_treated_as_literal( self, library_session, mock_source_type ): """Searching for '100%' must not match '100 pages'.""" import hashlib import uuid from local_deep_research.database.models.library import ( Document, DocumentStatus, ) from local_deep_research.database.models.research import ( ResearchResource, ) from local_deep_research.research_library.services.library_service import ( LibraryService, ) target = Document( id=str(uuid.uuid4()), source_type_id=mock_source_type.id, document_hash=hashlib.sha256(b"target").hexdigest(), file_size=0, file_type="pdf", title="Discount: 100% off", status=DocumentStatus.COMPLETED, ) decoy = Document( id=str(uuid.uuid4()), source_type_id=mock_source_type.id, document_hash=hashlib.sha256(b"decoy").hexdigest(), file_size=0, file_type="pdf", title="100 pages of notes", status=DocumentStatus.COMPLETED, ) library_session.add_all([target, decoy]) library_session.commit() with patch.object( LibraryService, "__init__", lambda self, username: None ): service = LibraryService.__new__(LibraryService) service.username = "test_user" query = library_session.query(Document).outerjoin( ResearchResource, (Document.resource_id == ResearchResource.id) | (ResearchResource.document_id == Document.id), ) results = service._apply_search_filter( query, Document, "100%" ).all() titles = [r.title for r in results] assert "Discount: 100% off" in titles assert "100 pages of notes" not in titles def test_search_underscore_treated_as_literal( self, library_session, mock_source_type ): """Searching for 'a_b' must not match 'aXb'.""" import hashlib import uuid from local_deep_research.database.models.library import ( Document, DocumentStatus, ) from local_deep_research.database.models.research import ( ResearchResource, ) from local_deep_research.research_library.services.library_service import ( LibraryService, ) target = Document( id=str(uuid.uuid4()), source_type_id=mock_source_type.id, document_hash=hashlib.sha256(b"target").hexdigest(), file_size=0, file_type="pdf", title="The a_b variable", status=DocumentStatus.COMPLETED, ) decoy = Document( id=str(uuid.uuid4()), source_type_id=mock_source_type.id, document_hash=hashlib.sha256(b"decoy").hexdigest(), file_size=0, file_type="pdf", title="aXb variable", status=DocumentStatus.COMPLETED, ) library_session.add_all([target, decoy]) library_session.commit() with patch.object( LibraryService, "__init__", lambda self, username: None ): service = LibraryService.__new__(LibraryService) service.username = "test_user" query = library_session.query(Document).outerjoin( ResearchResource, (Document.resource_id == ResearchResource.id) | (ResearchResource.document_id == Document.id), ) results = service._apply_search_filter(query, Document, "a_b").all() titles = [r.title for r in results] assert "The a_b variable" in titles assert "aXb variable" not in titles def test_domain_percent_treated_as_literal( self, library_session, mock_source_type ): """Domain filter for '100%.com' must not match '100X.com'.""" import hashlib import uuid from local_deep_research.database.models.library import ( Document, DocumentStatus, ) from local_deep_research.research_library.services.library_service import ( LibraryService, ) target = Document( id=str(uuid.uuid4()), source_type_id=mock_source_type.id, document_hash=hashlib.sha256(b"target").hexdigest(), file_size=0, file_type="pdf", original_url="https://100%.com/paper", status=DocumentStatus.COMPLETED, ) decoy = Document( id=str(uuid.uuid4()), source_type_id=mock_source_type.id, document_hash=hashlib.sha256(b"decoy").hexdigest(), file_size=0, file_type="pdf", original_url="https://100pages.com/article", status=DocumentStatus.COMPLETED, ) library_session.add_all([target, decoy]) library_session.commit() with patch.object( LibraryService, "__init__", lambda self, username: None ): service = LibraryService.__new__(LibraryService) service.username = "test_user" query = library_session.query(Document) results = service._apply_domain_filter( query, Document, "100%.com" ).all() urls = [r.original_url for r in results] assert "https://100%.com/paper" in urls assert "https://100pages.com/article" not in urls class TestLibraryServiceGetResearchListWithStats: """Tests for get_research_list_with_stats method.""" def test_get_research_list_with_stats_returns_list(self, mocker): """Returns list of research with stats.""" from local_deep_research.research_library.services.library_service import ( LibraryService, ) mock_session_context = mocker.patch( "local_deep_research.research_library.services.library_service.get_user_db_session" ) mock_session = MagicMock() mock_session.__enter__ = Mock(return_value=mock_session) mock_session.__exit__ = Mock(return_value=False) # Mock query mock_query = Mock() mock_query.outerjoin.return_value = mock_query mock_query.group_by.return_value = mock_query mock_query.order_by.return_value = mock_query mock_query.all.return_value = [] mock_session.query.return_value = mock_query mock_session_context.return_value = mock_session with patch.object( LibraryService, "__init__", lambda self, username: None ): service = LibraryService.__new__(LibraryService) service.username = "test_user" result = service.get_research_list_with_stats() assert isinstance(result, list) class TestGetResearchListRatingsPreload: """Tests for batch-preloaded ratings in get_research_list_with_stats (N+1 fix).""" def test_ratings_fetched_in_single_batch_query(self, mocker): """Ratings should be preloaded via .in_() query, not per-research.""" from local_deep_research.research_library.services.library_service import ( LibraryService, ) mock_session_context = mocker.patch( "local_deep_research.research_library.services.library_service.get_user_db_session" ) mock_session = MagicMock() mock_session.__enter__ = Mock(return_value=mock_session) mock_session.__exit__ = Mock(return_value=False) # Create mock research results (tuples of research, total, downloaded, downloadable) mock_research1 = Mock() mock_research1.id = 1 mock_research2 = Mock() mock_research2.id = 2 # Set up main query to return two research results mock_main_query = Mock() mock_main_query.outerjoin.return_value = mock_main_query mock_main_query.group_by.return_value = mock_main_query mock_main_query.order_by.return_value = mock_main_query mock_main_query.all.return_value = [ (mock_research1, 5, 3, 5), (mock_research2, 10, 8, 10), ] # Set up ratings query (preloaded batch) mock_rating = Mock() mock_rating.research_id = 1 mock_rating.rating = 4 mock_rating.notes = "Good" mock_ratings_query = Mock() mock_ratings_query.filter.return_value = mock_ratings_query mock_ratings_query.all.return_value = [mock_rating] # Set up domain query chain (used inside loop for domain breakdown) mock_domain_query = Mock() mock_domain_query.filter.return_value = mock_domain_query mock_domain_query.group_by.return_value = mock_domain_query mock_domain_query.limit.return_value = mock_domain_query mock_domain_query.all.return_value = [] call_count = {"n": 0} def query_side_effect(*args): call_count["n"] += 1 if call_count["n"] == 1: return mock_main_query # Main research query if call_count["n"] == 2: return mock_ratings_query # Batch ratings query return mock_domain_query # Domain breakdown queries mock_session.query.side_effect = query_side_effect mock_session_context.return_value = mock_session with patch.object( LibraryService, "__init__", lambda self, username: None ): service = LibraryService.__new__(LibraryService) service.username = "test_user" result = service.get_research_list_with_stats() assert isinstance(result, list) assert len(result) == 2 # The ratings query (call #2) should use .filter() with .in_(), # not be called per-research inside the loop mock_ratings_query.filter.assert_called_once() class TestLibraryServiceOpenFileLocation: """Tests for open_file_location method.""" def test_open_file_location_document_not_found(self, mocker): """Returns False when document not found.""" from local_deep_research.research_library.services.library_service import ( LibraryService, ) mock_session_context = mocker.patch( "local_deep_research.research_library.services.library_service.get_user_db_session" ) mock_session = MagicMock() mock_session.__enter__ = Mock(return_value=mock_session) mock_session.__exit__ = Mock(return_value=False) mock_session.query.return_value.get.return_value = None mock_session_context.return_value = mock_session with patch.object( LibraryService, "__init__", lambda self, username: None ): service = LibraryService.__new__(LibraryService) service.username = "test_user" result = service.open_file_location("nonexistent-doc") assert result is False class TestLibraryServiceSyncLibrary: """Tests for sync_library_with_filesystem method.""" def test_sync_library_returns_dict(self, mocker): """Returns dictionary with sync results.""" from local_deep_research.research_library.services.library_service import ( LibraryService, ) mock_session_context = mocker.patch( "local_deep_research.research_library.services.library_service.get_user_db_session" ) mock_session = MagicMock() mock_session.__enter__ = Mock(return_value=mock_session) mock_session.__exit__ = Mock(return_value=False) mock_session.query.return_value.all.return_value = [] mock_session_context.return_value = mock_session # Mock path operations mocker.patch("pathlib.Path.exists", return_value=True) mocker.patch("pathlib.Path.glob", return_value=[]) with patch.object( LibraryService, "__init__", lambda self, username: None ): service = LibraryService.__new__(LibraryService) service.username = "test_user" result = service.sync_library_with_filesystem() assert isinstance(result, dict) class TestLibraryServiceMarkForRedownload: """Tests for mark_for_redownload method.""" def test_mark_for_redownload_returns_count(self, mocker): """Returns count of marked documents.""" from contextlib import contextmanager from local_deep_research.research_library.services.library_service import ( LibraryService, ) # Create mock document with real string values mock_doc = Mock() mock_doc.original_url = ( "https://example.com/doc.pdf" # Real string for _get_url_hash ) mock_doc.status = "completed" mock_doc.id = "doc-123" # Create mock tracker mock_tracker = Mock() mock_tracker.is_downloaded = True mock_tracker.file_path = "/path/to/file.pdf" # Create mock session mock_session = MagicMock() # Mock the query().get() chain for Document lookup mock_doc_query = MagicMock() mock_doc_query.get.return_value = mock_doc # Mock the filter_by().first() chain for DownloadTracker lookup mock_tracker_query = MagicMock() mock_tracker_filter = MagicMock() mock_tracker_filter.first.return_value = mock_tracker mock_tracker_query.filter_by.return_value = mock_tracker_filter # Configure query() to return different mocks based on model def query_side_effect(model): # Check model name since we can't import the actual models easily model_name = getattr(model, "__name__", str(model)) if "Document" in str(model_name) or "Document" in str(model): return mock_doc_query if "DownloadTracker" in str(model_name) or "Tracker" in str(model): return mock_tracker_query return MagicMock() mock_session.query.side_effect = query_side_effect # Create a context manager that yields our mock session @contextmanager def mock_get_session(username, password=None): yield mock_session mocker.patch( "local_deep_research.research_library.services.library_service.get_user_db_session", side_effect=mock_get_session, ) with patch.object( LibraryService, "__init__", lambda self, username: None ): service = LibraryService.__new__(LibraryService) service.username = "test_user" result = service.mark_for_redownload(["doc-123"]) assert isinstance(result, int) assert result == 1 # One document was marked class TestLibraryServiceHasBlobInDb: """Tests for _has_blob_in_db method.""" def test_has_blob_in_db_true(self, mocker): """Returns True when blob exists.""" from local_deep_research.research_library.services.library_service import ( LibraryService, ) mock_session = MagicMock() mock_session.query.return_value.filter_by.return_value.first.return_value = Mock() with patch.object( LibraryService, "__init__", lambda self, username: None ): service = LibraryService.__new__(LibraryService) service.username = "test_user" result = service._has_blob_in_db(mock_session, "doc-123") assert result is True def test_has_blob_in_db_false(self, mocker): """Returns False when blob does not exist.""" from local_deep_research.research_library.services.library_service import ( LibraryService, ) mock_session = MagicMock() mock_session.query.return_value.filter_by.return_value.first.return_value = None with patch.object( LibraryService, "__init__", lambda self, username: None ): service = LibraryService.__new__(LibraryService) service.username = "test_user" result = service._has_blob_in_db(mock_session, "doc-123") assert result is False class TestLibraryServiceGetStoragePath: """Tests for _get_storage_path method.""" def test_get_storage_path_returns_string(self, mocker): """Returns string path.""" from local_deep_research.research_library.services.library_service import ( LibraryService, ) # Mock the settings manager at the correct import location mock_settings_manager = Mock() mock_settings_manager.get_setting.return_value = "/test/storage/path" mocker.patch( "local_deep_research.utilities.db_utils.get_settings_manager", return_value=mock_settings_manager, ) with patch.object( LibraryService, "__init__", lambda self, username: None ): service = LibraryService.__new__(LibraryService) service.username = "test_user" result = service._get_storage_path() assert isinstance(result, str) # ============== Integration tests against a real session ============== # These use library_session (in-memory SQLite) to exercise behavior that # mocks can't reproduce — specifically, a real Document with # original_url=None, which mocks would silently coerce into a string. class TestSyncLibraryUploadsIntegration: """Regression for #3869: uploads must not crash sync nor be deleted by it.""" def test_sync_skips_uploads_and_does_not_delete_them( self, library_session, mock_source_type, mock_upload_source_type, mocker, ): """Upload (original_url=None) survives sync; download with no tracker is processed.""" import hashlib import uuid from contextlib import contextmanager from local_deep_research.database.models.library import ( Document, DocumentStatus, ) from local_deep_research.research_library.services.library_service import ( LibraryService, ) upload_id = str(uuid.uuid4()) upload = Document( id=upload_id, source_type_id=mock_upload_source_type.id, document_hash=hashlib.sha256(b"upload").hexdigest(), original_url=None, file_size=1024, file_type="pdf", title="User Upload", status=DocumentStatus.COMPLETED, ) download_id = str(uuid.uuid4()) download = Document( id=download_id, source_type_id=mock_source_type.id, document_hash=hashlib.sha256(b"download").hexdigest(), original_url="https://example.com/paper.pdf", file_size=2048, file_type="pdf", title="Research Download", status=DocumentStatus.COMPLETED, ) library_session.add_all([upload, download]) library_session.commit() @contextmanager def _session_cm(*_args, **_kwargs): yield library_session mocker.patch( "local_deep_research.research_library.services.library_service.get_user_db_session", side_effect=_session_cm, ) with patch.object( LibraryService, "__init__", lambda self, username: None ): service = LibraryService.__new__(LibraryService) service.username = "test_user" stats = service.sync_library_with_filesystem() # Upload was excluded from the sync (regression guard for both the # TypeError crash and the silent cascade-delete in the else branch). assert library_session.query(Document).get(upload_id) is not None assert stats["total_documents"] == 1 # only the download is counted # The download has no DownloadTracker, so it must still travel through # the destructive `else` branch and be cascade-deleted. Asserting this # explicitly proves the deletion path stays functional for legitimate # downloads — without it, a regression that broke the else branch # would still pass the upload-survival check above. assert library_session.query(Document).get(download_id) is None assert stats["files_missing"] == 1 class TestMarkForRedownloadUploadsIntegration: """Regression for #3869: mark_for_redownload must not crash on uploads.""" def test_mark_for_redownload_skips_upload_documents( self, library_session, mock_upload_source_type, mocker, caplog, ): """Calling mark_for_redownload with an upload's id is a no-op, not a crash.""" import hashlib import uuid from contextlib import contextmanager from local_deep_research.database.models.library import ( Document, DocumentStatus, ) from local_deep_research.research_library.services.library_service import ( LibraryService, ) upload_id = str(uuid.uuid4()) upload = Document( id=upload_id, source_type_id=mock_upload_source_type.id, document_hash=hashlib.sha256(b"upload-mfr").hexdigest(), original_url=None, file_size=512, file_type="pdf", title="User Upload", status=DocumentStatus.COMPLETED, ) library_session.add(upload) library_session.commit() @contextmanager def _session_cm(*_args, **_kwargs): yield library_session mocker.patch( "local_deep_research.research_library.services.library_service.get_user_db_session", side_effect=_session_cm, ) with patch.object( LibraryService, "__init__", lambda self, username: None ): service = LibraryService.__new__(LibraryService) service.username = "test_user" count = service.mark_for_redownload([upload_id]) assert count == 0 # Upload's status must be unchanged. refreshed = library_session.query(Document).get(upload_id) assert refreshed.status == DocumentStatus.COMPLETED