s
This commit is contained in:
parent
5499904cef
commit
1c01b29be7
31 changed files with 1725 additions and 53 deletions
|
|
@ -52,7 +52,9 @@ async def test_crossref_discovers_doi_from_best_title_match(monkeypatch: pytest.
|
|||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_crossref_rejects_large_year_mismatch(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
async def test_crossref_relaxed_fallback_allows_large_year_mismatch_for_strong_title_match(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
async def _fake_fetch_items(**_kwargs):
|
||||
return [
|
||||
{
|
||||
|
|
@ -72,4 +74,30 @@ async def test_crossref_rejects_large_year_mismatch(monkeypatch: pytest.MonkeyPa
|
|||
max_rows=10,
|
||||
email=None,
|
||||
)
|
||||
assert doi is None
|
||||
assert doi == "10.1000/wrong-year"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_crossref_relaxed_fallback_allows_author_mismatch_for_strong_title_match(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
async def _fake_fetch_items(**_kwargs):
|
||||
return [
|
||||
{
|
||||
"DOI": "10.1000/author-fallback",
|
||||
"title": ["Induction of Pluripotent Stem Cells from Adult Human Fibroblasts"],
|
||||
"issued": {"date-parts": [[2007]]},
|
||||
"author": [{"family": "SomeoneElse"}],
|
||||
}
|
||||
]
|
||||
|
||||
monkeypatch.setattr(crossref_app, "_fetch_items", _fake_fetch_items)
|
||||
doi = await crossref_app.discover_doi_for_publication(
|
||||
item=_item(
|
||||
title="Induction of Pluripotent Stem Cells from Adult Human Fibroblasts",
|
||||
year=2007,
|
||||
),
|
||||
max_rows=10,
|
||||
email=None,
|
||||
)
|
||||
assert doi == "10.1000/author-fallback"
|
||||
|
|
|
|||
38
tests/unit/test_publication_identifiers.py
Normal file
38
tests/unit/test_publication_identifiers.py
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from app.services.domains.publication_identifiers import application as identifier_service
|
||||
|
||||
|
||||
def test_derive_display_identifier_prefers_doi_over_arxiv() -> None:
|
||||
display = identifier_service.derive_display_identifier_from_values(
|
||||
doi="10.1000/example",
|
||||
pub_url="https://arxiv.org/abs/1504.08025",
|
||||
pdf_url=None,
|
||||
)
|
||||
assert display is not None
|
||||
assert display.kind == "doi"
|
||||
assert display.value == "10.1000/example"
|
||||
assert display.url == "https://doi.org/10.1000/example"
|
||||
|
||||
|
||||
def test_derive_display_identifier_uses_arxiv_when_doi_missing() -> None:
|
||||
display = identifier_service.derive_display_identifier_from_values(
|
||||
doi=None,
|
||||
pub_url="https://arxiv.org/pdf/1504.08025v2",
|
||||
pdf_url=None,
|
||||
)
|
||||
assert display is not None
|
||||
assert display.kind == "arxiv"
|
||||
assert display.value == "1504.08025v2"
|
||||
assert display.label == "arXiv: 1504.08025v2"
|
||||
|
||||
|
||||
def test_derive_display_identifier_uses_pmcid_when_present() -> None:
|
||||
display = identifier_service.derive_display_identifier_from_values(
|
||||
doi=None,
|
||||
pub_url=None,
|
||||
pdf_url="https://pmc.ncbi.nlm.nih.gov/articles/PMC2175868/pdf/file.pdf",
|
||||
)
|
||||
assert display is not None
|
||||
assert display.kind == "pmcid"
|
||||
assert display.value == "PMC2175868"
|
||||
|
|
@ -1,11 +1,14 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from app.db.models import PublicationPdfJob
|
||||
from app.services.domains.publications import pdf_queue
|
||||
from app.services.domains.publications.pdf_resolution_pipeline import PipelineOutcome
|
||||
from app.services.domains.unpaywall.application import OaResolutionOutcome
|
||||
|
||||
|
||||
def _job(
|
||||
|
|
@ -22,6 +25,25 @@ def _job(
|
|||
)
|
||||
|
||||
|
||||
def _row(*, pub_url: str | None = "https://scholar.google.com/citations?view_op=view_citation&citation_for_view=abc:xyz") -> SimpleNamespace:
|
||||
return SimpleNamespace(
|
||||
publication_id=1,
|
||||
scholar_profile_id=1,
|
||||
scholar_label="Ada Lovelace",
|
||||
title="A paper",
|
||||
year=2024,
|
||||
citation_count=0,
|
||||
venue_text=None,
|
||||
pub_url=pub_url,
|
||||
doi=None,
|
||||
pdf_url=None,
|
||||
is_read=False,
|
||||
is_favorite=False,
|
||||
first_seen_at=datetime(2026, 2, 22, 12, 0, tzinfo=timezone.utc),
|
||||
is_new_in_latest_run=True,
|
||||
)
|
||||
|
||||
|
||||
def test_pdf_queue_auto_enqueue_blocks_recent_attempt(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
now = datetime(2026, 2, 21, 12, 0, tzinfo=timezone.utc)
|
||||
monkeypatch.setattr(pdf_queue, "_utcnow", lambda: now)
|
||||
|
|
@ -107,3 +129,44 @@ def test_pdf_queue_manual_requeue_still_blocks_when_inflight() -> None:
|
|||
)
|
||||
assert pdf_queue._can_enqueue_job(running, force_retry=True) is False
|
||||
assert pdf_queue._can_enqueue_job(queued, force_retry=True) is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fetch_outcome_for_row_uses_pipeline_outcome(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
async def _fake_pipeline(*, row, request_email=None):
|
||||
assert request_email == "user@example.com"
|
||||
return PipelineOutcome(
|
||||
outcome=OaResolutionOutcome(
|
||||
publication_id=row.publication_id,
|
||||
doi=None,
|
||||
pdf_url="https://arxiv.org/pdf/1703.06103",
|
||||
failure_reason=None,
|
||||
source=pdf_queue.PDF_SOURCE_SCHOLAR_PUBLICATION_PAGE,
|
||||
used_crossref=False,
|
||||
),
|
||||
scholar_candidates=None,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(pdf_queue, "resolve_publication_pdf_outcome_for_row", _fake_pipeline)
|
||||
|
||||
outcome = await pdf_queue._fetch_outcome_for_row(row=_row(), request_email="user@example.com")
|
||||
|
||||
assert outcome.pdf_url == "https://arxiv.org/pdf/1703.06103"
|
||||
assert outcome.source == pdf_queue.PDF_SOURCE_SCHOLAR_PUBLICATION_PAGE
|
||||
assert outcome.used_crossref is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fetch_outcome_for_row_returns_failed_outcome_when_pipeline_returns_none(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
async def _fake_pipeline(*, row, request_email=None):
|
||||
assert request_email == "user@example.com"
|
||||
return PipelineOutcome(outcome=None, scholar_candidates=None)
|
||||
|
||||
monkeypatch.setattr(pdf_queue, "resolve_publication_pdf_outcome_for_row", _fake_pipeline)
|
||||
|
||||
outcome = await pdf_queue._fetch_outcome_for_row(row=_row(), request_email="user@example.com")
|
||||
|
||||
assert outcome.pdf_url is None
|
||||
assert outcome.failure_reason == pdf_queue.FAILURE_RESOLUTION_EXCEPTION
|
||||
|
|
|
|||
153
tests/unit/test_publication_pdf_resolution_pipeline.py
Normal file
153
tests/unit/test_publication_pdf_resolution_pipeline.py
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from app.services.domains.publications import pdf_resolution_pipeline as pipeline
|
||||
from app.services.domains.unpaywall.application import OaResolutionOutcome
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _Candidate:
|
||||
url: str
|
||||
confidence_score: float
|
||||
label_present: bool
|
||||
reason: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _Candidates:
|
||||
container_seen: bool
|
||||
labeled_candidate: _Candidate | None
|
||||
fallback_candidate: _Candidate | None
|
||||
warnings: tuple[str, ...] = ()
|
||||
|
||||
|
||||
def _row(*, doi: str | None = None) -> SimpleNamespace:
|
||||
return SimpleNamespace(
|
||||
publication_id=1,
|
||||
scholar_profile_id=1,
|
||||
scholar_label="Ada Lovelace",
|
||||
title="A paper",
|
||||
year=2024,
|
||||
citation_count=0,
|
||||
venue_text=None,
|
||||
pub_url="https://scholar.google.com/citations?view_op=view_citation&citation_for_view=abc:xyz",
|
||||
doi=doi,
|
||||
pdf_url=None,
|
||||
is_read=False,
|
||||
is_favorite=False,
|
||||
first_seen_at=datetime(2026, 2, 22, 12, 0, tzinfo=timezone.utc),
|
||||
is_new_in_latest_run=True,
|
||||
)
|
||||
|
||||
|
||||
def _oa_outcome(*, pdf_url: str | None, source: str = "unpaywall") -> OaResolutionOutcome:
|
||||
return OaResolutionOutcome(
|
||||
publication_id=1,
|
||||
doi="10.1000/example",
|
||||
pdf_url=pdf_url,
|
||||
failure_reason=None if pdf_url else "no_pdf_found",
|
||||
source=source,
|
||||
used_crossref=False,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pipeline_prefers_labeled_scholar_candidate_before_oa(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
async def _fake_candidates(_url):
|
||||
return _Candidates(
|
||||
container_seen=True,
|
||||
labeled_candidate=_Candidate(
|
||||
url="https://arxiv.org/pdf/1703.06103",
|
||||
confidence_score=0.98,
|
||||
label_present=True,
|
||||
reason="scholar_link_labeled_pdf",
|
||||
),
|
||||
fallback_candidate=None,
|
||||
)
|
||||
|
||||
async def _fail_oa(*, row, request_email):
|
||||
raise AssertionError(f"OA should not run when labeled Scholar candidate exists: {row.publication_id} {request_email}")
|
||||
|
||||
monkeypatch.setattr(pipeline, "fetch_link_candidates_from_scholar_publication_page", _fake_candidates)
|
||||
monkeypatch.setattr(pipeline, "_oa_outcome", _fail_oa)
|
||||
|
||||
result = await pipeline.resolve_publication_pdf_outcome_for_row(row=_row(), request_email="user@example.com")
|
||||
|
||||
assert result.outcome is not None
|
||||
assert result.outcome.pdf_url == "https://arxiv.org/pdf/1703.06103"
|
||||
assert result.outcome.source == pipeline.PDF_SOURCE_SCHOLAR_PUBLICATION_PAGE
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pipeline_uses_oa_result_before_unlabeled_fallback(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
async def _fake_candidates(_url):
|
||||
return _Candidates(
|
||||
container_seen=True,
|
||||
labeled_candidate=None,
|
||||
fallback_candidate=_Candidate(
|
||||
url="https://example.org/download/42",
|
||||
confidence_score=0.2,
|
||||
label_present=False,
|
||||
reason="scholar_link_unlabeled_fallback",
|
||||
),
|
||||
)
|
||||
|
||||
async def _fake_oa(*, row, request_email):
|
||||
assert request_email == "user@example.com"
|
||||
return _oa_outcome(pdf_url="https://oa.example.org/found.pdf")
|
||||
|
||||
async def _fail_fallback(*, row, candidate):
|
||||
raise AssertionError(f"Unlabeled fallback should not run when OA returns PDF: {row.publication_id} {candidate.url}")
|
||||
|
||||
monkeypatch.setattr(pipeline, "fetch_link_candidates_from_scholar_publication_page", _fake_candidates)
|
||||
monkeypatch.setattr(pipeline, "_oa_outcome", _fake_oa)
|
||||
monkeypatch.setattr(pipeline, "_unlabeled_fallback_outcome", _fail_fallback)
|
||||
|
||||
result = await pipeline.resolve_publication_pdf_outcome_for_row(row=_row(), request_email="user@example.com")
|
||||
|
||||
assert result.outcome is not None
|
||||
assert result.outcome.pdf_url == "https://oa.example.org/found.pdf"
|
||||
assert result.outcome.source == "unpaywall"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pipeline_uses_unlabeled_fallback_after_oa_failure(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
fallback_candidate = _Candidate(
|
||||
url="https://example.org/download/42",
|
||||
confidence_score=0.2,
|
||||
label_present=False,
|
||||
reason="scholar_link_unlabeled_fallback",
|
||||
)
|
||||
|
||||
async def _fake_candidates(_url):
|
||||
return _Candidates(container_seen=True, labeled_candidate=None, fallback_candidate=fallback_candidate)
|
||||
|
||||
async def _fake_oa(*, row, request_email):
|
||||
assert request_email == "user@example.com"
|
||||
return _oa_outcome(pdf_url=None)
|
||||
|
||||
async def _fake_fallback(*, row, candidate):
|
||||
assert candidate == fallback_candidate
|
||||
return OaResolutionOutcome(
|
||||
publication_id=row.publication_id,
|
||||
doi=row.doi,
|
||||
pdf_url="https://example.org/fallback.pdf",
|
||||
failure_reason=None,
|
||||
source=pipeline.PDF_SOURCE_SCHOLAR_PUBLICATION_PAGE_UNLABELED,
|
||||
used_crossref=False,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(pipeline, "fetch_link_candidates_from_scholar_publication_page", _fake_candidates)
|
||||
monkeypatch.setattr(pipeline, "_oa_outcome", _fake_oa)
|
||||
monkeypatch.setattr(pipeline, "_unlabeled_fallback_outcome", _fake_fallback)
|
||||
|
||||
result = await pipeline.resolve_publication_pdf_outcome_for_row(row=_row(), request_email="user@example.com")
|
||||
|
||||
assert result.outcome is not None
|
||||
assert result.outcome.pdf_url == "https://example.org/fallback.pdf"
|
||||
assert result.outcome.source == pipeline.PDF_SOURCE_SCHOLAR_PUBLICATION_PAGE_UNLABELED
|
||||
76
tests/unit/test_scholar_publication_pdf.py
Normal file
76
tests/unit/test_scholar_publication_pdf.py
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from app.services.domains.scholar.parser_types import ScholarDomInvariantError
|
||||
from app.services.domains.scholar.publication_pdf import (
|
||||
extract_link_candidates_from_publication_detail_html,
|
||||
is_scholar_publication_detail_url,
|
||||
)
|
||||
|
||||
|
||||
def test_extract_link_candidates_from_publication_detail_html_reads_gsc_oci_pdf_link() -> None:
|
||||
html = """
|
||||
<html><body>
|
||||
<div id="gsc_oci_title_gg">
|
||||
<div class="gsc_oci_title_ggi">
|
||||
<a href="https://arxiv.org/pdf/1703.06103" data-clk="x">
|
||||
<span class="gsc_vcd_title_ggt">[PDF]</span> from arxiv.org
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</body></html>
|
||||
"""
|
||||
candidates = extract_link_candidates_from_publication_detail_html(html)
|
||||
assert candidates.labeled_candidate is not None
|
||||
assert candidates.labeled_candidate.url == "https://arxiv.org/pdf/1703.06103"
|
||||
|
||||
|
||||
def test_extract_link_candidates_from_publication_detail_html_returns_no_candidates_when_container_missing() -> None:
|
||||
html = "<html><body><div id='gsc_oci_title'>No PDF section</div></body></html>"
|
||||
candidates = extract_link_candidates_from_publication_detail_html(html)
|
||||
assert candidates.container_seen is False
|
||||
assert candidates.labeled_candidate is None
|
||||
assert candidates.fallback_candidate is None
|
||||
|
||||
|
||||
def test_extract_pdf_url_from_publication_detail_html_fails_fast_on_malformed_pdf_container() -> None:
|
||||
html = """
|
||||
<html><body>
|
||||
<div id="gsc_oci_title_gg">
|
||||
<div class="gsc_oci_title_ggi">
|
||||
<a data-clk="x"><span class="gsc_vcd_title_ggt">[PDF]</span> from example.org</a>
|
||||
</div>
|
||||
</div>
|
||||
</body></html>
|
||||
"""
|
||||
with pytest.raises(ScholarDomInvariantError) as exc:
|
||||
extract_link_candidates_from_publication_detail_html(html)
|
||||
assert exc.value.code == "layout_publication_link_missing_href"
|
||||
|
||||
|
||||
def test_extract_link_candidates_from_publication_detail_html_keeps_unlabeled_fallback() -> None:
|
||||
html = """
|
||||
<html><body>
|
||||
<div id="gsc_oci_title_gg">
|
||||
<div class="gsc_oci_title_ggi">
|
||||
<a href="https://example.org/download?id=42">from example.org</a>
|
||||
</div>
|
||||
</div>
|
||||
</body></html>
|
||||
"""
|
||||
candidates = extract_link_candidates_from_publication_detail_html(html)
|
||||
assert candidates.container_seen is True
|
||||
assert candidates.labeled_candidate is None
|
||||
assert candidates.fallback_candidate is not None
|
||||
assert candidates.fallback_candidate.url == "https://example.org/download?id=42"
|
||||
assert candidates.fallback_candidate.label_present is False
|
||||
assert "scholar_publication_link_unlabeled_only" in candidates.warnings
|
||||
assert candidates.labeled_candidate is None
|
||||
|
||||
|
||||
def test_is_scholar_publication_detail_url_matches_view_citation_links() -> None:
|
||||
assert is_scholar_publication_detail_url(
|
||||
"https://scholar.google.com/citations?view_op=view_citation&hl=en&user=8200InoAAAAJ&citation_for_view=8200InoAAAAJ:gsN89kCJA0AC"
|
||||
) is True
|
||||
assert is_scholar_publication_detail_url("https://example.org/paper") is False
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from dataclasses import replace
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import pytest
|
||||
|
|
@ -38,6 +39,26 @@ def _item(publication_id: int) -> PublicationListItem:
|
|||
)
|
||||
|
||||
|
||||
def test_publication_doi_uses_stored_value_when_metadata_has_no_doi() -> None:
|
||||
item = replace(
|
||||
_item(99),
|
||||
pub_url="https://scholar.google.com/citations?view_op=view_citation&citation_for_view=abc:123",
|
||||
venue_text="Cell 130 (5), 2007",
|
||||
doi="10.1016/j.cell.2007.11.019",
|
||||
)
|
||||
assert unpaywall_app._publication_doi(item) == "10.1016/j.cell.2007.11.019"
|
||||
|
||||
|
||||
def test_publication_doi_prefers_explicit_metadata_doi_over_stored_value() -> None:
|
||||
item = replace(
|
||||
_item(100),
|
||||
pub_url="https://doi.org/10.2000/fresh-value",
|
||||
venue_text="Cell",
|
||||
doi="10.1000/stale-value",
|
||||
)
|
||||
assert unpaywall_app._publication_doi(item) == "10.2000/fresh-value"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unpaywall_resolve_prefers_direct_pdf_without_landing_crawl(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
payload = {
|
||||
|
|
@ -49,7 +70,7 @@ async def test_unpaywall_resolve_prefers_direct_pdf_without_landing_crawl(monkey
|
|||
}
|
||||
|
||||
async def _fake_resolve_item_payload(**_kwargs):
|
||||
return payload, False
|
||||
return payload, False, "10.1016/j.cell.2007.11.019"
|
||||
|
||||
async def _fail_crawl(_client, *, page_url: str):
|
||||
raise AssertionError(f"unexpected landing crawl: {page_url}")
|
||||
|
|
@ -75,7 +96,7 @@ async def test_unpaywall_resolve_crawls_landing_page_when_pdf_url_is_not_direct(
|
|||
crawled_pages: list[str] = []
|
||||
|
||||
async def _fake_resolve_item_payload(**_kwargs):
|
||||
return payload, False
|
||||
return payload, False, "10.1016/j.cell.2007.11.019"
|
||||
|
||||
async def _fake_crawl(_client, *, page_url: str):
|
||||
crawled_pages.append(page_url)
|
||||
|
|
@ -87,3 +108,22 @@ async def test_unpaywall_resolve_crawls_landing_page_when_pdf_url_is_not_direct(
|
|||
resolved = await unpaywall_app.resolve_publication_oa_metadata([_item(2)], request_email="user@example.com")
|
||||
assert resolved == {2: ("10.1016/j.cell.2007.11.019", "https://oa.example.org/files/paper-42.pdf")}
|
||||
assert "https://oa.example.org/landing/42" in crawled_pages
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unpaywall_preserves_crossref_doi_when_unpaywall_has_no_record(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
async def _fake_resolve_item_payload(**_kwargs):
|
||||
return None, True, "10.2000/crossref-only"
|
||||
|
||||
monkeypatch.setattr(unpaywall_app, "_resolve_item_payload", _fake_resolve_item_payload)
|
||||
monkeypatch.setattr("httpx.AsyncClient", _DummyAsyncClient)
|
||||
|
||||
outcomes = await unpaywall_app.resolve_publication_oa_outcomes([_item(3)], request_email="user@example.com")
|
||||
|
||||
outcome = outcomes[3]
|
||||
assert outcome.doi == "10.2000/crossref-only"
|
||||
assert outcome.pdf_url is None
|
||||
assert outcome.failure_reason == unpaywall_app.FAILURE_NO_RECORD
|
||||
assert outcome.used_crossref is True
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue