This commit is contained in:
Justin Visser 2026-02-22 16:23:35 +01:00
parent 5499904cef
commit 1c01b29be7
31 changed files with 1725 additions and 53 deletions

View file

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

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

View file

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

View 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

View 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

View file

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