Files
wehub-resource-sync 7a0da7932b
OSV-Scanner (Scheduled) / scan-scheduled (push) Failing after 0s
Create Release / test-gate (push) Has been cancelled
Create Release / release-gate (push) Has been cancelled
Create Release / ci-gate (push) Has been cancelled
Create Release / version-check (push) Has been cancelled
Create Release / e2e-test-gate (push) Has been cancelled
Create Release / responsive-test-gate (push) Has been cancelled
Create Release / compat-test-gate (push) Has been cancelled
Create Release / compose-integration-gate (push) Has been cancelled
Create Release / vulture-gate (push) Has been cancelled
Create Release / build (push) Has been cancelled
Create Release / provenance (push) Has been cancelled
Create Release / prerelease-docker (push) Has been cancelled
Create Release / publish-docker (push) Has been cancelled
Create Release / create-release (push) Has been cancelled
Create Release / cleanup-changelog (push) Has been cancelled
Create Release / trigger-pypi (push) Has been cancelled
Create Release / monitor-pypi (push) Has been cancelled
Create Release / Clean up orphan prerelease tags and signatures (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-form] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-metrics] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-workflow] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-core] (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [history-news] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [library] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [link-analytics] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-core] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-lifecycle] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [error-benchmark] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-pages] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) (push) Has been cancelled
Docker Tests (Consolidated) / Accessibility Tests (push) Has been cancelled
Docker Tests (Consolidated) / LLM Unit Tests (push) Has been cancelled
Docker Tests (Consolidated) / LLM Example Tests (push) Has been cancelled
Docker Tests (Consolidated) / Production Image Smoke Test (push) Has been cancelled
Docker Tests (Consolidated) / Infrastructure Tests (push) Has been cancelled
OSSF Scorecard / OSSF Security Scorecard Analysis (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [mobile] (push) Has been cancelled
Backwards Compatibility / Verify Encryption Constants (push) Has been cancelled
Backwards Compatibility / PyPI Version Compatibility (push) Has been cancelled
Backwards Compatibility / Database Migration Tests (push) Has been cancelled
CodeQL Advanced / Analyze (python) (push) Has been cancelled
Docker Tests (Consolidated) / detect-changes (push) Has been cancelled
Docker Tests (Consolidated) / Build Test Image (push) Has been cancelled
Docker Tests (Consolidated) / All Pytest Tests + Coverage (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [accessibility] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [api-crud] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-login] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-pages] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-register] (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:08:55 +08:00

1227 lines
43 KiB
Python

"""
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