210 lines
8 KiB
Python
210 lines
8 KiB
Python
from __future__ import annotations
|
|
|
|
from datetime import UTC, datetime
|
|
from types import SimpleNamespace
|
|
|
|
import pytest
|
|
|
|
from app.services.arxiv.errors import ArxivRateLimitError
|
|
from app.services.publication_identifiers.types import DisplayIdentifier
|
|
from app.services.publications import pdf_resolution_pipeline as pipeline
|
|
from app.services.unpaywall.application import OaResolutionOutcome
|
|
|
|
|
|
def _row(*, display_identifier: DisplayIdentifier | 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",
|
|
display_identifier=display_identifier,
|
|
pdf_url=None,
|
|
is_read=False,
|
|
is_favorite=False,
|
|
first_seen_at=datetime(2026, 2, 22, 12, 0, tzinfo=UTC),
|
|
is_new_in_latest_run=True,
|
|
)
|
|
|
|
|
|
def _api_outcome(*, pdf_url: str | None, source: str = "unpaywall") -> OaResolutionOutcome | None:
|
|
if not pdf_url:
|
|
return None
|
|
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,
|
|
)
|
|
|
|
|
|
def _oa_fallback_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_openalex_before_arxiv(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
async def _fake_openalex(row, request_email: str | None = None, openalex_api_key: str | None = None):
|
|
return _api_outcome(pdf_url="https://oa.example.org/found.pdf", source="openalex")
|
|
|
|
async def _fail_arxiv(row, *, request_email: str | None = None, allow_lookup: bool = True):
|
|
_ = (row, request_email, allow_lookup)
|
|
raise AssertionError("arXiv should not run when OpenAlex candidate exists.")
|
|
|
|
monkeypatch.setattr(pipeline, "_openalex_outcome", _fake_openalex)
|
|
monkeypatch.setattr(pipeline, "_arxiv_outcome", _fail_arxiv)
|
|
|
|
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 == "openalex"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_pipeline_uses_arxiv_after_openalex_failure(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
async def _fake_openalex(row, request_email: str | None = None, openalex_api_key: str | None = None):
|
|
return None
|
|
|
|
async def _fake_arxiv(row, *, request_email: str | None = None, allow_lookup: bool = True):
|
|
_ = allow_lookup
|
|
return _api_outcome(pdf_url="https://arxiv.org/pdf/1234.5678.pdf", source="arxiv")
|
|
|
|
async def _fail_oa(*, row, request_email):
|
|
raise AssertionError("Unpaywall should not run when arXiv returns PDF.")
|
|
|
|
monkeypatch.setattr(pipeline, "_openalex_outcome", _fake_openalex)
|
|
monkeypatch.setattr(pipeline, "_arxiv_outcome", _fake_arxiv)
|
|
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/1234.5678.pdf"
|
|
assert result.outcome.source == "arxiv"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_pipeline_uses_unpaywall_after_arxiv_failure(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
async def _fake_openalex(row, request_email: str | None = None, openalex_api_key: str | None = None):
|
|
return None
|
|
|
|
async def _fake_arxiv(row, *, request_email: str | None = None, allow_lookup: bool = True):
|
|
_ = (request_email, allow_lookup)
|
|
return None
|
|
|
|
async def _fake_oa(*, row, request_email):
|
|
assert request_email == "user@example.com"
|
|
return _oa_fallback_outcome(pdf_url="https://example.org/fallback.pdf", source="unpaywall")
|
|
|
|
monkeypatch.setattr(pipeline, "_openalex_outcome", _fake_openalex)
|
|
monkeypatch.setattr(pipeline, "_arxiv_outcome", _fake_arxiv)
|
|
monkeypatch.setattr(pipeline, "_oa_outcome", _fake_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://example.org/fallback.pdf"
|
|
assert result.outcome.source == "unpaywall"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_pipeline_falls_back_to_unpaywall_when_arxiv_is_rate_limited(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
async def _fake_openalex(row, request_email: str | None = None, openalex_api_key: str | None = None):
|
|
_ = (row, request_email, openalex_api_key)
|
|
return None
|
|
|
|
async def _raise_rate_limit(row, *, request_email: str | None = None, allow_lookup: bool = True):
|
|
_ = (row, request_email, allow_lookup)
|
|
raise ArxivRateLimitError("arXiv rate limit hit (429) — stopping batch")
|
|
|
|
async def _fake_oa(*, row, request_email):
|
|
_ = (row, request_email)
|
|
return _oa_fallback_outcome(pdf_url="https://example.org/fallback.pdf", source="unpaywall")
|
|
|
|
monkeypatch.setattr(pipeline, "_openalex_outcome", _fake_openalex)
|
|
monkeypatch.setattr(pipeline, "_arxiv_outcome", _raise_rate_limit)
|
|
monkeypatch.setattr(pipeline, "_oa_outcome", _fake_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.source == "unpaywall"
|
|
assert result.arxiv_rate_limited is True
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_arxiv_outcome_skips_when_strong_doi_identifier(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
row = _row(
|
|
display_identifier=DisplayIdentifier(
|
|
kind="doi",
|
|
value="10.1000/example",
|
|
label="DOI",
|
|
url="https://doi.org/10.1000/example",
|
|
confidence_score=1.0,
|
|
)
|
|
)
|
|
|
|
async def _fail_discover(*, item, request_email: str | None = None, timeout_seconds: float | None = None):
|
|
raise AssertionError("arXiv lookup should be skipped when DOI evidence is strong.")
|
|
|
|
monkeypatch.setattr(
|
|
"app.services.arxiv.application.discover_arxiv_id_for_publication",
|
|
_fail_discover,
|
|
)
|
|
outcome = await pipeline._arxiv_outcome(row, request_email="user@example.com")
|
|
assert outcome is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_arxiv_outcome_skips_when_title_quality_is_low(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
row = _row()
|
|
row.title = "AI 2024"
|
|
|
|
async def _fail_discover(*, item, request_email: str | None = None, timeout_seconds: float | None = None):
|
|
raise AssertionError("arXiv lookup should be skipped for low-quality titles.")
|
|
|
|
monkeypatch.setattr(
|
|
"app.services.arxiv.application.discover_arxiv_id_for_publication",
|
|
_fail_discover,
|
|
)
|
|
outcome = await pipeline._arxiv_outcome(row, request_email="user@example.com")
|
|
assert outcome is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_arxiv_outcome_calls_arxiv_when_eligible(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
async def _fake_discover(*, item, request_email: str | None = None, timeout_seconds: float | None = None):
|
|
return "1234.5678"
|
|
|
|
monkeypatch.setattr(
|
|
"app.services.arxiv.application.discover_arxiv_id_for_publication",
|
|
_fake_discover,
|
|
)
|
|
row = _row()
|
|
row.title = "Reliable Graph Neural Network Benchmark across Multiple Datasets"
|
|
outcome = await pipeline._arxiv_outcome(row, request_email="user@example.com")
|
|
|
|
assert outcome is not None
|
|
assert outcome.source == "arxiv"
|
|
assert outcome.pdf_url == "https://arxiv.org/pdf/1234.5678.pdf"
|