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
830 lines
28 KiB
Python
830 lines
28 KiB
Python
"""
|
|
Coverage tests for LibraryService — targeting the 29 missing statements.
|
|
|
|
Focuses narrowly on uncovered branches:
|
|
- _get_safe_absolute_path: empty/sentinel paths, None abs_path return
|
|
- get_document_by_id: downloaded_at branches (processed_at with/without isoformat,
|
|
uploaded_at fallback, None), research_created_at (str vs datetime vs None),
|
|
original_url from resource fallback, no-resource domain fallback
|
|
- get_documents: downloaded_at with processed_at, uploaded_at fallback,
|
|
_sort_date with uploaded_at, collection_id provided (skips get_default_library_id)
|
|
- open_file_location: library_root is None, ValueError from validate_safe_path
|
|
"""
|
|
|
|
from contextlib import contextmanager
|
|
from unittest.mock import Mock, MagicMock, patch
|
|
|
|
MODULE = "local_deep_research.research_library.services.library_service"
|
|
|
|
|
|
# ============== Helper ==============
|
|
|
|
|
|
def _make_service():
|
|
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"
|
|
return service
|
|
|
|
|
|
def _mock_session_cm(mocker, mock_session):
|
|
"""Patch get_user_db_session as a proper context manager."""
|
|
|
|
@contextmanager
|
|
def _cm(username, password=None):
|
|
yield mock_session
|
|
|
|
mocker.patch(
|
|
f"{MODULE}.get_user_db_session",
|
|
side_effect=_cm,
|
|
)
|
|
|
|
|
|
def _setup_get_document_by_id(
|
|
mocker,
|
|
mock_session,
|
|
doc_overrides=None,
|
|
research_overrides=None,
|
|
resource=None,
|
|
):
|
|
"""
|
|
Configure mock_session for get_document_by_id.
|
|
Returns (service, mock_doc, mock_resource, mock_research).
|
|
"""
|
|
service = _make_service()
|
|
|
|
mock_doc = Mock()
|
|
mock_doc.id = "doc-123"
|
|
mock_doc.resource_id = "res-1"
|
|
mock_doc.research_id = "research-1"
|
|
mock_doc.title = "Test Paper"
|
|
mock_doc.original_url = "https://arxiv.org/abs/2301.00001"
|
|
mock_doc.file_path = "pdfs/test.pdf"
|
|
mock_doc.filename = "test.pdf"
|
|
mock_doc.file_size = 1024
|
|
mock_doc.file_type = "pdf"
|
|
mock_doc.mime_type = "application/pdf"
|
|
mock_doc.text_content = None
|
|
mock_doc.status = "completed"
|
|
mock_doc.processed_at = None
|
|
mock_doc.favorite = False
|
|
mock_doc.tags = []
|
|
mock_doc.storage_mode = "filesystem"
|
|
|
|
if doc_overrides:
|
|
for k, v in doc_overrides.items():
|
|
setattr(mock_doc, k, v)
|
|
|
|
if resource is None:
|
|
mock_resource = Mock()
|
|
mock_resource.title = "Resource Title"
|
|
mock_resource.url = "https://arxiv.org/abs/2301.00001"
|
|
else:
|
|
mock_resource = resource
|
|
|
|
mock_research = Mock()
|
|
mock_research.query = "quantum computing"
|
|
mock_research.created_at = "2024-01-01T00:00:00"
|
|
if research_overrides:
|
|
for k, v in research_overrides.items():
|
|
setattr(mock_research, k, v)
|
|
|
|
mock_main_query = MagicMock()
|
|
mock_main_query.outerjoin.return_value = mock_main_query
|
|
mock_main_query.filter.return_value = mock_main_query
|
|
mock_main_query.first.return_value = (
|
|
mock_doc,
|
|
mock_resource,
|
|
mock_research,
|
|
)
|
|
|
|
mock_dc = Mock()
|
|
mock_dc.indexed = False
|
|
mock_dc.chunk_count = 0
|
|
mock_coll = Mock()
|
|
mock_coll.id = "coll-1"
|
|
mock_coll.name = "Library"
|
|
|
|
mock_coll_query = MagicMock()
|
|
mock_coll_query.join.return_value = mock_coll_query
|
|
mock_coll_query.filter.return_value = mock_coll_query
|
|
mock_coll_query.all.return_value = [(mock_dc, mock_coll)]
|
|
|
|
call_count = {"n": 0}
|
|
|
|
def query_side_effect(*args):
|
|
call_count["n"] += 1
|
|
return mock_main_query if call_count["n"] == 1 else mock_coll_query
|
|
|
|
mock_session.query.side_effect = query_side_effect
|
|
_mock_session_cm(mocker, mock_session)
|
|
|
|
mocker.patch(
|
|
f"{MODULE}.get_absolute_path_from_settings",
|
|
return_value=None,
|
|
)
|
|
|
|
return service, mock_doc, mock_resource, mock_research
|
|
|
|
|
|
# ============== _get_safe_absolute_path ==============
|
|
|
|
|
|
class TestGetSafeAbsolutePath:
|
|
"""Tests for the _get_safe_absolute_path helper."""
|
|
|
|
def test_empty_file_path_returns_none(self):
|
|
"""Empty string returns None without calling get_absolute_path_from_settings."""
|
|
service = _make_service()
|
|
result = service._get_safe_absolute_path("")
|
|
assert result is None
|
|
|
|
def test_sentinel_path_returns_none(self):
|
|
"""A FILE_PATH_SENTINELS value returns None."""
|
|
from local_deep_research.constants import FILE_PATH_METADATA_ONLY
|
|
|
|
service = _make_service()
|
|
result = service._get_safe_absolute_path(FILE_PATH_METADATA_ONLY)
|
|
assert result is None
|
|
|
|
def test_valid_path_with_none_abs_path_returns_none(self, mocker):
|
|
"""When get_absolute_path_from_settings returns None, result is None."""
|
|
service = _make_service()
|
|
mocker.patch(
|
|
f"{MODULE}.get_absolute_path_from_settings",
|
|
return_value=None,
|
|
)
|
|
result = service._get_safe_absolute_path("some/relative/path.pdf")
|
|
assert result is None
|
|
|
|
def test_valid_path_with_abs_path_returns_string(self, mocker):
|
|
"""When get_absolute_path_from_settings returns a path, result is str."""
|
|
service = _make_service()
|
|
from pathlib import Path
|
|
|
|
fake_path = Path("/library/some/relative/path.pdf")
|
|
mocker.patch(
|
|
f"{MODULE}.get_absolute_path_from_settings",
|
|
return_value=fake_path,
|
|
)
|
|
result = service._get_safe_absolute_path("some/relative/path.pdf")
|
|
assert result == str(fake_path)
|
|
assert isinstance(result, str)
|
|
|
|
|
|
# ============== get_document_by_id downloaded_at branches ==============
|
|
|
|
|
|
class TestGetDocumentByIdDownloadedAt:
|
|
"""Uncovered branches in the downloaded_at ternary expression."""
|
|
|
|
def test_processed_at_without_isoformat_uses_str(self, mocker):
|
|
"""processed_at exists but has no isoformat → str(processed_at) used."""
|
|
mock_session = MagicMock()
|
|
# Use a plain string as processed_at — strings have no .isoformat(), so
|
|
# the ternary falls to str(processed_at).
|
|
fake_processed_at = "2024-01-15"
|
|
service, mock_doc, _, _ = _setup_get_document_by_id(
|
|
mocker,
|
|
mock_session,
|
|
doc_overrides={"processed_at": fake_processed_at},
|
|
)
|
|
|
|
result = service.get_document_by_id("doc-123")
|
|
|
|
assert result is not None
|
|
# str("2024-01-15") == "2024-01-15"
|
|
assert result["downloaded_at"] == str(fake_processed_at)
|
|
|
|
def test_no_processed_at_with_uploaded_at_uses_isoformat(self, mocker):
|
|
"""processed_at is None, uploaded_at has isoformat → uploaded_at.isoformat() used."""
|
|
mock_session = MagicMock()
|
|
mock_uploaded_at = Mock()
|
|
mock_uploaded_at.isoformat.return_value = "2024-02-20T10:00:00"
|
|
|
|
service, mock_doc, _, _ = _setup_get_document_by_id(
|
|
mocker,
|
|
mock_session,
|
|
doc_overrides={
|
|
"processed_at": None,
|
|
"uploaded_at": mock_uploaded_at,
|
|
},
|
|
)
|
|
|
|
result = service.get_document_by_id("doc-123")
|
|
|
|
assert result is not None
|
|
assert result["downloaded_at"] == "2024-02-20T10:00:00"
|
|
|
|
def test_no_processed_at_no_uploaded_at_returns_none(self, mocker):
|
|
"""processed_at is None and no uploaded_at → downloaded_at is None."""
|
|
mock_session = MagicMock()
|
|
|
|
# Use a real Mock but delete uploaded_at attribute
|
|
class DocWithoutUploadedAt:
|
|
pass
|
|
|
|
service, mock_doc, _, _ = _setup_get_document_by_id(
|
|
mocker,
|
|
mock_session,
|
|
doc_overrides={"processed_at": None},
|
|
)
|
|
# Ensure hasattr(doc, 'uploaded_at') returns False
|
|
del mock_doc.uploaded_at
|
|
|
|
result = service.get_document_by_id("doc-123")
|
|
|
|
assert result is not None
|
|
assert result["downloaded_at"] is None
|
|
|
|
|
|
# ============== get_document_by_id research_created_at branches ==============
|
|
|
|
|
|
class TestGetDocumentByIdResearchCreatedAt:
|
|
"""Uncovered branches for research_created_at ternary."""
|
|
|
|
def test_research_created_at_as_datetime_uses_isoformat(self, mocker):
|
|
"""research.created_at is a datetime object → .isoformat() called."""
|
|
mock_session = MagicMock()
|
|
mock_created_at = Mock()
|
|
mock_created_at.isoformat.return_value = "2024-03-01T12:00:00"
|
|
# isinstance(research.created_at, str) == False → goes to elif branch
|
|
|
|
service, _, _, mock_research = _setup_get_document_by_id(
|
|
mocker,
|
|
mock_session,
|
|
research_overrides={"created_at": mock_created_at},
|
|
)
|
|
# Make isinstance check return False (created_at is not str)
|
|
mock_research.created_at = mock_created_at
|
|
|
|
result = service.get_document_by_id("doc-123")
|
|
|
|
assert result is not None
|
|
assert result["research_created_at"] == "2024-03-01T12:00:00"
|
|
|
|
def test_no_research_created_at_returns_none(self, mocker):
|
|
"""research is None → research_created_at is None."""
|
|
service = _make_service()
|
|
mock_session = MagicMock()
|
|
|
|
mock_doc = Mock()
|
|
mock_doc.id = "doc-123"
|
|
mock_doc.resource_id = None
|
|
mock_doc.research_id = None
|
|
mock_doc.title = "Upload"
|
|
mock_doc.original_url = None
|
|
mock_doc.file_path = None
|
|
mock_doc.filename = "upload.pdf"
|
|
mock_doc.file_size = 512
|
|
mock_doc.file_type = "pdf"
|
|
mock_doc.mime_type = "application/pdf"
|
|
mock_doc.text_content = None
|
|
mock_doc.status = "completed"
|
|
mock_doc.processed_at = None
|
|
mock_doc.favorite = False
|
|
mock_doc.tags = []
|
|
mock_doc.storage_mode = "filesystem"
|
|
|
|
mock_main_query = MagicMock()
|
|
mock_main_query.outerjoin.return_value = mock_main_query
|
|
mock_main_query.filter.return_value = mock_main_query
|
|
# research is None (user upload)
|
|
mock_main_query.first.return_value = (mock_doc, None, None)
|
|
|
|
mock_coll_query = MagicMock()
|
|
mock_coll_query.join.return_value = mock_coll_query
|
|
mock_coll_query.filter.return_value = mock_coll_query
|
|
mock_coll_query.all.return_value = []
|
|
|
|
call_count = {"n": 0}
|
|
|
|
def query_side_effect(*args):
|
|
call_count["n"] += 1
|
|
return mock_main_query if call_count["n"] == 1 else mock_coll_query
|
|
|
|
mock_session.query.side_effect = query_side_effect
|
|
_mock_session_cm(mocker, mock_session)
|
|
|
|
mocker.patch(
|
|
f"{MODULE}.get_absolute_path_from_settings", return_value=None
|
|
)
|
|
|
|
result = service.get_document_by_id("doc-123")
|
|
|
|
assert result is not None
|
|
assert result["research_created_at"] is None
|
|
assert result["research_title"] == "User Upload"
|
|
assert result["domain"] == "User Upload"
|
|
|
|
|
|
# ============== get_document_by_id original_url fallback ==============
|
|
|
|
|
|
class TestGetDocumentByIdOriginalUrlFallback:
|
|
"""original_url falls back to resource.url when doc.original_url is None."""
|
|
|
|
def test_doc_original_url_none_uses_resource_url(self, mocker):
|
|
"""doc.original_url=None → original_url comes from resource.url."""
|
|
mock_session = MagicMock()
|
|
service, mock_doc, mock_resource, _ = _setup_get_document_by_id(
|
|
mocker,
|
|
mock_session,
|
|
doc_overrides={"original_url": None},
|
|
)
|
|
mock_resource.url = "https://example.com/paper.pdf"
|
|
|
|
result = service.get_document_by_id("doc-123")
|
|
|
|
assert result is not None
|
|
assert result["original_url"] == "https://example.com/paper.pdf"
|
|
|
|
|
|
# ============== open_file_location uncovered branches ==============
|
|
|
|
|
|
class TestOpenFileLocationMissingBranches:
|
|
"""Branches in open_file_location not covered by existing tests."""
|
|
|
|
def test_library_root_none_returns_false(self, mocker):
|
|
"""get_absolute_path_from_settings returns None for library root → False."""
|
|
service = _make_service()
|
|
mock_session = MagicMock()
|
|
|
|
mock_doc = Mock()
|
|
mock_doc.original_url = "https://example.com/doc.pdf"
|
|
|
|
mock_tracker = Mock()
|
|
mock_tracker.file_path = "pdfs/doc.pdf"
|
|
|
|
mock_doc_query = MagicMock()
|
|
mock_doc_query.get.return_value = mock_doc
|
|
mock_tracker_query = MagicMock()
|
|
mock_tracker_query.filter_by.return_value.first.return_value = (
|
|
mock_tracker
|
|
)
|
|
|
|
def query_router(model):
|
|
name = getattr(model, "__name__", str(model))
|
|
if "DownloadTracker" in str(name) or "Tracker" in str(model):
|
|
return mock_tracker_query
|
|
return mock_doc_query
|
|
|
|
mock_session.query.side_effect = query_router
|
|
_mock_session_cm(mocker, mock_session)
|
|
|
|
# library_root is None when get_absolute_path_from_settings("") returns None
|
|
mocker.patch(
|
|
f"{MODULE}.get_absolute_path_from_settings",
|
|
return_value=None,
|
|
)
|
|
|
|
result = service.open_file_location("doc-123")
|
|
|
|
assert result is False
|
|
|
|
def test_path_validation_raises_value_error_returns_false(self, mocker):
|
|
"""PathValidator.validate_safe_path raises ValueError → returns False."""
|
|
service = _make_service()
|
|
mock_session = MagicMock()
|
|
|
|
mock_doc = Mock()
|
|
mock_doc.original_url = "https://example.com/doc.pdf"
|
|
|
|
mock_tracker = Mock()
|
|
mock_tracker.file_path = "../../etc/passwd" # traversal attempt
|
|
|
|
mock_doc_query = MagicMock()
|
|
mock_doc_query.get.return_value = mock_doc
|
|
mock_tracker_query = MagicMock()
|
|
mock_tracker_query.filter_by.return_value.first.return_value = (
|
|
mock_tracker
|
|
)
|
|
|
|
def query_router(model):
|
|
name = getattr(model, "__name__", str(model))
|
|
if "DownloadTracker" in str(name) or "Tracker" in str(model):
|
|
return mock_tracker_query
|
|
return mock_doc_query
|
|
|
|
mock_session.query.side_effect = query_router
|
|
_mock_session_cm(mocker, mock_session)
|
|
|
|
mocker.patch(
|
|
f"{MODULE}.get_absolute_path_from_settings",
|
|
return_value=MagicMock(), # non-None library root
|
|
)
|
|
mocker.patch(
|
|
f"{MODULE}.PathValidator.validate_safe_path",
|
|
side_effect=ValueError("Path traversal detected"),
|
|
)
|
|
|
|
result = service.open_file_location("doc-123")
|
|
|
|
assert result is False
|
|
|
|
|
|
# ============== get_documents with explicit collection_id ==============
|
|
|
|
|
|
class TestGetDocumentsWithCollectionId:
|
|
"""get_documents when collection_id is explicitly provided (skips get_default_library_id)."""
|
|
|
|
def test_explicit_collection_id_skips_default_lookup(self, mocker):
|
|
"""When collection_id is provided, get_default_library_id is NOT called."""
|
|
service = _make_service()
|
|
mock_session = MagicMock()
|
|
|
|
mock_query = MagicMock()
|
|
mock_query.join.return_value = mock_query
|
|
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.subquery.return_value = MagicMock()
|
|
mock_query.all.return_value = []
|
|
mock_session.query.return_value = mock_query
|
|
|
|
_mock_session_cm(mocker, mock_session)
|
|
|
|
mock_get_default = mocker.patch(
|
|
"local_deep_research.database.library_init.get_default_library_id",
|
|
return_value="default-id",
|
|
)
|
|
|
|
result = service.get_documents(collection_id="explicit-coll-id")
|
|
|
|
assert isinstance(result, list)
|
|
mock_get_default.assert_not_called()
|
|
|
|
|
|
# ============== get_documents downloaded_at / _sort_date with uploaded_at ==============
|
|
|
|
|
|
class TestGetDocumentsDateBranches:
|
|
"""Test downloaded_at and _sort_date branches in get_documents result processing."""
|
|
|
|
def _make_mock_doc_result(self, processed_at=None, uploaded_at=None):
|
|
"""Create a (doc, res_by_fk, res_by_doc, research, doc_collection) tuple."""
|
|
mock_doc = Mock()
|
|
mock_doc.id = "doc-1"
|
|
mock_doc.resource_id = "res-1"
|
|
mock_doc.research_id = "research-1"
|
|
mock_doc.title = "Test"
|
|
mock_doc.authors = "Author"
|
|
mock_doc.published_date = None
|
|
mock_doc.doi = None
|
|
mock_doc.arxiv_id = None
|
|
mock_doc.pmid = None
|
|
mock_doc.file_path = "pdfs/test.pdf"
|
|
mock_doc.filename = "test.pdf"
|
|
mock_doc.file_size = 1024
|
|
mock_doc.file_type = "pdf"
|
|
mock_doc.original_url = "https://arxiv.org/abs/1234.5678"
|
|
mock_doc.status = "completed"
|
|
mock_doc.processed_at = processed_at
|
|
mock_doc.favorite = False
|
|
mock_doc.tags = []
|
|
mock_doc.text_content = None
|
|
mock_doc.storage_mode = "filesystem"
|
|
|
|
if uploaded_at is not None:
|
|
mock_doc.uploaded_at = uploaded_at
|
|
|
|
mock_resource = Mock()
|
|
mock_resource.title = "Resource"
|
|
|
|
mock_research = Mock()
|
|
mock_research.title = "Research"
|
|
mock_research.query = "test query"
|
|
mock_research.mode = "deep"
|
|
mock_research.created_at = "2024-01-01"
|
|
|
|
mock_doc_collection = Mock()
|
|
mock_doc_collection.indexed = False
|
|
mock_doc_collection.chunk_count = 0
|
|
|
|
return (
|
|
mock_doc,
|
|
mock_resource,
|
|
None,
|
|
mock_research,
|
|
mock_doc_collection,
|
|
)
|
|
|
|
def test_downloaded_at_uses_processed_at_isoformat(self, mocker):
|
|
"""processed_at with isoformat → downloaded_at = processed_at.isoformat()."""
|
|
service = _make_service()
|
|
mock_session = MagicMock()
|
|
|
|
mock_processed_at = Mock()
|
|
mock_processed_at.isoformat.return_value = "2024-05-01T09:00:00"
|
|
|
|
result_row = self._make_mock_doc_result(processed_at=mock_processed_at)
|
|
|
|
mock_query = MagicMock()
|
|
mock_query.join.return_value = mock_query
|
|
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.subquery.return_value = MagicMock()
|
|
mock_query.all.return_value = [result_row]
|
|
mock_session.query.return_value = mock_query
|
|
|
|
_mock_session_cm(mocker, mock_session)
|
|
|
|
mocker.patch(
|
|
"local_deep_research.database.library_init.get_default_library_id",
|
|
return_value="default-id",
|
|
)
|
|
mocker.patch(
|
|
f"{MODULE}.get_absolute_path_from_settings",
|
|
return_value=None,
|
|
)
|
|
|
|
results = service.get_documents()
|
|
|
|
assert len(results) == 1
|
|
assert results[0]["downloaded_at"] == "2024-05-01T09:00:00"
|
|
|
|
def test_downloaded_at_falls_back_to_uploaded_at(self, mocker):
|
|
"""No processed_at, has uploaded_at → downloaded_at = uploaded_at.isoformat()."""
|
|
service = _make_service()
|
|
mock_session = MagicMock()
|
|
|
|
mock_uploaded_at = Mock()
|
|
mock_uploaded_at.isoformat.return_value = "2024-06-15T14:30:00"
|
|
|
|
result_row = self._make_mock_doc_result(
|
|
processed_at=None, uploaded_at=mock_uploaded_at
|
|
)
|
|
|
|
mock_query = MagicMock()
|
|
mock_query.join.return_value = mock_query
|
|
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.subquery.return_value = MagicMock()
|
|
mock_query.all.return_value = [result_row]
|
|
mock_session.query.return_value = mock_query
|
|
|
|
_mock_session_cm(mocker, mock_session)
|
|
|
|
mocker.patch(
|
|
"local_deep_research.database.library_init.get_default_library_id",
|
|
return_value="default-id",
|
|
)
|
|
mocker.patch(
|
|
f"{MODULE}.get_absolute_path_from_settings",
|
|
return_value=None,
|
|
)
|
|
|
|
results = service.get_documents()
|
|
|
|
assert len(results) == 1
|
|
assert results[0]["downloaded_at"] == "2024-06-15T14:30:00"
|
|
|
|
|
|
# ============== get_documents resource coalesce, dedup, blob, search ==============
|
|
|
|
|
|
class TestGetDocumentsResourceCoalesce:
|
|
"""Test resource coalescing (res_by_fk vs res_by_doc) and dedup in get_documents."""
|
|
|
|
def _setup_query(self, mocker, mock_session, result_rows):
|
|
"""Wire up the mock query chain and session for get_documents."""
|
|
mock_query = MagicMock()
|
|
mock_query.join.return_value = mock_query
|
|
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.distinct.return_value = mock_query
|
|
mock_query.subquery.return_value = MagicMock()
|
|
mock_query.all.return_value = result_rows
|
|
mock_session.query.return_value = mock_query
|
|
|
|
_mock_session_cm(mocker, mock_session)
|
|
|
|
mocker.patch(
|
|
"local_deep_research.database.library_init.get_default_library_id",
|
|
return_value="default-id",
|
|
)
|
|
mocker.patch(
|
|
f"{MODULE}.get_absolute_path_from_settings",
|
|
return_value=None,
|
|
)
|
|
|
|
def _make_doc(self, doc_id="doc-1", storage_mode="filesystem"):
|
|
mock_doc = Mock()
|
|
mock_doc.id = doc_id
|
|
mock_doc.resource_id = None
|
|
mock_doc.research_id = "research-1"
|
|
mock_doc.title = "Test"
|
|
mock_doc.authors = "Author"
|
|
mock_doc.published_date = None
|
|
mock_doc.doi = None
|
|
mock_doc.arxiv_id = None
|
|
mock_doc.pmid = None
|
|
mock_doc.file_path = "pdfs/test.pdf"
|
|
mock_doc.filename = "test.pdf"
|
|
mock_doc.file_size = 1024
|
|
mock_doc.file_type = "pdf"
|
|
mock_doc.original_url = "https://example.com/paper.pdf"
|
|
mock_doc.status = "completed"
|
|
mock_doc.processed_at = Mock()
|
|
mock_doc.processed_at.isoformat.return_value = "2024-01-01T00:00:00"
|
|
mock_doc.favorite = False
|
|
mock_doc.tags = []
|
|
mock_doc.text_content = None
|
|
mock_doc.storage_mode = storage_mode
|
|
return mock_doc
|
|
|
|
def _make_research(self):
|
|
mock_research = Mock()
|
|
mock_research.title = "Research"
|
|
mock_research.query = "test query"
|
|
mock_research.mode = "deep"
|
|
mock_research.created_at = "2024-01-01"
|
|
return mock_research
|
|
|
|
def _make_doc_collection(self):
|
|
mock_dc = Mock()
|
|
mock_dc.indexed = False
|
|
mock_dc.chunk_count = 0
|
|
return mock_dc
|
|
|
|
def test_resource_coalesce_falls_back_to_res_by_doc(self, mocker):
|
|
"""When res_by_fk is None, res_by_doc is used for document_title."""
|
|
service = _make_service()
|
|
mock_session = MagicMock()
|
|
|
|
mock_resource = Mock()
|
|
mock_resource.title = "From document_id FK"
|
|
|
|
doc = self._make_doc()
|
|
doc.title = None # Force fallback to resource.title
|
|
|
|
# (doc, res_by_fk=None, res_by_doc=resource, research, doc_collection)
|
|
row = (
|
|
doc,
|
|
None,
|
|
mock_resource,
|
|
self._make_research(),
|
|
self._make_doc_collection(),
|
|
)
|
|
self._setup_query(mocker, mock_session, [row])
|
|
|
|
results = service.get_documents()
|
|
|
|
assert len(results) == 1
|
|
assert results[0]["document_title"] == "From document_id FK"
|
|
|
|
def test_resource_coalesce_both_none(self, mocker):
|
|
"""When both res_by_fk and res_by_doc are None, falls back to filename."""
|
|
service = _make_service()
|
|
mock_session = MagicMock()
|
|
|
|
doc = self._make_doc()
|
|
doc.title = None # Force fallback to filename
|
|
|
|
# (doc, res_by_fk=None, res_by_doc=None, research, doc_collection)
|
|
row = (
|
|
doc,
|
|
None,
|
|
None,
|
|
self._make_research(),
|
|
self._make_doc_collection(),
|
|
)
|
|
self._setup_query(mocker, mock_session, [row])
|
|
|
|
results = service.get_documents()
|
|
|
|
assert len(results) == 1
|
|
assert results[0]["document_title"] == "test.pdf"
|
|
|
|
def test_dedup_skips_fan_out_duplicate_rows(self, mocker):
|
|
"""When ResourceByDoc fan-out produces duplicate doc rows, dedup keeps first."""
|
|
service = _make_service()
|
|
mock_session = MagicMock()
|
|
|
|
doc = self._make_doc(doc_id="doc-dup")
|
|
res_a = Mock()
|
|
res_a.title = "Resource A"
|
|
res_b = Mock()
|
|
res_b.title = "Resource B"
|
|
research = self._make_research()
|
|
dc = self._make_doc_collection()
|
|
|
|
# Two rows for same doc — fan-out from ResourceByDoc
|
|
row1 = (doc, None, res_a, research, dc)
|
|
row2 = (doc, None, res_b, research, dc)
|
|
self._setup_query(mocker, mock_session, [row1, row2])
|
|
|
|
results = service.get_documents()
|
|
|
|
# Only one result despite two rows — dedup works
|
|
assert len(results) == 1
|
|
assert results[0]["id"] == "doc-dup"
|
|
|
|
def test_batch_blob_check_for_database_storage(self, mocker):
|
|
"""Documents with storage_mode=database use batch blob check for has_pdf."""
|
|
service = _make_service()
|
|
mock_session = MagicMock()
|
|
|
|
doc = self._make_doc(doc_id="doc-blob", storage_mode="database")
|
|
doc.file_path = None # No filesystem path
|
|
doc.filename = "paper.pdf"
|
|
|
|
row = (
|
|
doc,
|
|
None,
|
|
None,
|
|
self._make_research(),
|
|
self._make_doc_collection(),
|
|
)
|
|
|
|
mock_query = MagicMock()
|
|
mock_query.join.return_value = mock_query
|
|
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.subquery.return_value = MagicMock()
|
|
|
|
# First .all() → main query results, second .all() → blob query results
|
|
mock_query.all.side_effect = [
|
|
[row],
|
|
[("doc-blob",)], # blob exists for this doc
|
|
]
|
|
mock_session.query.return_value = mock_query
|
|
|
|
_mock_session_cm(mocker, mock_session)
|
|
mocker.patch(
|
|
"local_deep_research.database.library_init.get_default_library_id",
|
|
return_value="default-id",
|
|
)
|
|
mocker.patch(
|
|
f"{MODULE}.get_absolute_path_from_settings",
|
|
return_value=None,
|
|
)
|
|
|
|
results = service.get_documents()
|
|
|
|
assert len(results) == 1
|
|
assert results[0]["has_pdf"] is True
|
|
|
|
def test_search_query_exercises_distinct_path(self, mocker):
|
|
"""When search_query is provided, the subquery uses outerjoin + distinct."""
|
|
service = _make_service()
|
|
mock_session = MagicMock()
|
|
|
|
doc = self._make_doc()
|
|
row = (
|
|
doc,
|
|
None,
|
|
None,
|
|
self._make_research(),
|
|
self._make_doc_collection(),
|
|
)
|
|
|
|
mock_query = MagicMock()
|
|
mock_query.join.return_value = mock_query
|
|
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.distinct.return_value = mock_query
|
|
mock_query.subquery.return_value = MagicMock()
|
|
mock_query.all.return_value = [row]
|
|
mock_session.query.return_value = mock_query
|
|
|
|
_mock_session_cm(mocker, mock_session)
|
|
mocker.patch(
|
|
"local_deep_research.database.library_init.get_default_library_id",
|
|
return_value="default-id",
|
|
)
|
|
mocker.patch(
|
|
f"{MODULE}.get_absolute_path_from_settings",
|
|
return_value=None,
|
|
)
|
|
|
|
results = service.get_documents(search_query="test")
|
|
|
|
assert len(results) == 1
|
|
# Verify distinct was called (search_query path)
|
|
mock_query.distinct.assert_called()
|