Files
stack/tests/prisma/test_fetch_tiers.py
kert bb092c92d7 feat(prisma): fetch tiers — every Unpaywall location, Semantic Scholar openAccessPdf, idconv PMCID resolution (refs #723)
On the #650 full-text pass the cascade found 0 of 30 items that a
one-off sweep recovered 4 of, all via Semantic Scholar; 7 more had
PMCIDs Zotero lacked that NCBI idconv resolved in one call.

- unpaywall_urls(): best_oa_location plus every oa_locations entry,
  direct PDF URLs before landing pages; fetch_one tries each.
- fetch_semantic_scholar(): graph API openAccessPdf after Unpaywall,
  throttled to 1 req/s across workers; its externalIds.PubMedCentral
  fills a missing PMCID so the PMC tiers run.
- pending_queue(client=, email=): PMIDs without a PMCID resolved via
  idconv in 200-id batches, persisted to extra as a PMCID: line.
- run() reports an s2 counter; CLI help + docs page regenerated.
2026-09-22 15:32:32 -04:00

313 lines
12 KiB
Python

"""Tests for the #723 fetch tiers — Unpaywall all-locations, Semantic
Scholar openAccessPdf, and idconv PMCID resolution in the queue."""
from __future__ import annotations
from unittest.mock import MagicMock
import httpx
import pytest
from prisma import fetch as fetch_mod
from prisma.fetch import (
PendingItem,
fetch_one,
fetch_semantic_scholar,
pending_queue,
resolve_pmcids,
unpaywall_urls,
)
from zot.db import TYPE_MAP, Db
from zot.schema import create_db
@pytest.fixture(autouse=True)
def _no_s2_throttle(monkeypatch):
monkeypatch.setattr(fetch_mod, "_S2_MIN_INTERVAL", 0.0)
def _json_client(routes: dict[str, object], seen: list[str] | None = None):
"""MockTransport client: substring of URL → JSON body (or status int)."""
def handler(request):
url = str(request.url)
if seen is not None:
seen.append(url)
for needle, body in routes.items():
if needle in url:
if isinstance(body, int):
return httpx.Response(body)
return httpx.Response(200, json=body)
return httpx.Response(404)
return httpx.Client(transport=httpx.MockTransport(handler))
# ── unpaywall_urls ────────────────────────────────────────────────
class TestUnpaywallUrls:
def test_all_locations_pdf_first(self):
body = {
"best_oa_location": {"url_for_pdf": None, "url": "https://a/landing"},
"oa_locations": [
{"url_for_pdf": None, "url": "https://a/landing"},
{"url_for_pdf": "https://b/paper.pdf", "url": "https://b/landing"},
{"url_for_pdf": "https://c/paper.pdf", "url": None},
],
}
urls = unpaywall_urls(_json_client({"unpaywall": body}), "10.1/x", "e@x")
# every PDF URL before any landing page; no duplicates
assert urls == [
"https://b/paper.pdf",
"https://c/paper.pdf",
"https://a/landing",
"https://b/landing",
]
def test_best_location_only_legacy_shape(self):
body = {"best_oa_location": {"url_for_pdf": "https://oa/p.pdf"}}
assert unpaywall_urls(_json_client({"unpaywall": body}), "10.1/x", "e@x") == [
"https://oa/p.pdf"
]
def test_no_doi(self):
assert unpaywall_urls(MagicMock(), "", "e@x") == []
def test_404(self):
assert unpaywall_urls(_json_client({"unpaywall": 404}), "10.1/x", "e@x") == []
def test_no_locations(self):
body = {"best_oa_location": None, "oa_locations": []}
assert unpaywall_urls(_json_client({"unpaywall": body}), "10.1/x", "e@x") == []
# ── fetch_semantic_scholar ────────────────────────────────────────
class TestFetchSemanticScholar:
def test_pdf_and_pmcid(self):
seen: list[str] = []
body = {
"openAccessPdf": {"url": "https://s2/p.pdf", "status": "GREEN"},
"externalIds": {"PubMedCentral": "5226373", "DOI": "10.1/x"},
}
hit = fetch_semantic_scholar(
_json_client({"semanticscholar": body}, seen), "10.1/x"
)
assert hit is not None
assert hit.pdf_url == "https://s2/p.pdf"
assert hit.pmcid == "PMC5226373"
assert "paper/DOI:10.1/x" in seen[0] and "openAccessPdf" in seen[0]
def test_pmcid_only(self):
body = {"openAccessPdf": None, "externalIds": {"PubMedCentral": "PMC42"}}
hit = fetch_semantic_scholar(_json_client({"semanticscholar": body}), "10.1/x")
assert hit is not None
assert hit.pdf_url == ""
assert hit.pmcid == "PMC42"
def test_nothing_useful(self):
body = {"openAccessPdf": None, "externalIds": {"DOI": "10.1/x"}}
assert (
fetch_semantic_scholar(_json_client({"semanticscholar": body}), "10.1/x")
is None
)
def test_404(self):
assert (
fetch_semantic_scholar(_json_client({"semanticscholar": 404}), "10.1/x")
is None
)
def test_no_doi(self):
assert fetch_semantic_scholar(MagicMock(), "") is None
def test_throttled(self, monkeypatch):
"""Calls are spaced by _S2_MIN_INTERVAL even from a cold start."""
monkeypatch.setattr(fetch_mod, "_S2_MIN_INTERVAL", 0.2)
monkeypatch.setattr(fetch_mod, "_S2_LAST", [0.0])
client = _json_client({"semanticscholar": 404})
import time
t0 = time.monotonic()
fetch_semantic_scholar(client, "10.1/a")
fetch_semantic_scholar(client, "10.1/b")
assert time.monotonic() - t0 >= 0.2
# ── resolve_pmcids ────────────────────────────────────────────────
class TestResolvePmcids:
_RECORDS = {
"status": "ok",
"records": [
{
"doi": "10.1/x",
"pmcid": "PMC5226373",
"pmid": 27893131,
"requested-id": "27893131",
},
{
"pmid": 28679817,
"requested-id": "28679817",
"status": "error",
"errmsg": "Identifier not found in PMC",
},
],
}
def test_maps_found_only(self):
seen: list[str] = []
out = resolve_pmcids(
_json_client({"idconv": self._RECORDS}, seen),
["27893131", "28679817"],
"e@x",
)
assert out == {"27893131": "PMC5226373"}
assert "idtype=pmid" in seen[0] and "ids=27893131%2C28679817" in seen[0]
assert "email=e%40x" in seen[0]
def test_batches_of_200(self):
seen: list[str] = []
client = _json_client({"idconv": {"status": "ok", "records": []}}, seen)
resolve_pmcids(client, [str(i) for i in range(1, 402)], "e@x")
assert len(seen) == 3
def test_skips_non_numeric_and_dupes(self):
seen: list[str] = []
client = _json_client({"idconv": {"status": "ok", "records": []}}, seen)
resolve_pmcids(client, ["", "abc", "12", "12"], "e@x")
assert len(seen) == 1 and "ids=12&" in seen[0]
def test_empty(self):
assert resolve_pmcids(MagicMock(), [], "e@x") == {}
def test_http_error(self):
assert resolve_pmcids(_json_client({"idconv": 500}), ["1"], "e@x") == {}
# ── pending_queue PMCID resolution ────────────────────────────────
def _db(tmp_path):
path = str(tmp_path / "z.sqlite")
create_db(path).close()
return path
def _item(db, project, extra, doi="10.1/x"):
iid = db.create_item(TYPE_MAP["journalArticle"])
db.set_fields(iid, {"title": "Test Item", "DOI": doi, "extra": extra})
db.sync_tags(iid, [f"project:{project}", "screen:include"])
db.commit()
return iid
class TestPendingQueueIdconv:
def test_resolves_and_persists(self, tmp_path):
path = _db(tmp_path)
recs = {
"status": "ok",
"records": [
{"pmcid": "PMC777", "pmid": 111, "requested-id": "111"},
],
}
seen: list[str] = []
client = _json_client({"idconv": recs}, seen)
with Db(path) as db:
a = _item(db, "p", "PMID: 111")
b = _item(db, "p", "PMID: 222\nPMCID: PMC222", doi="10.1/y")
c = _item(db, "p", "", doi="10.1/z")
items = pending_queue(db, "p", client=client, email="e@x")
by_id = {it.zot_id: it for it in items}
assert by_id[a].pmcid == "PMC777"
assert by_id[b].pmcid == "PMC222"
assert by_id[c].pmcid == ""
# only the one PMID lacking a PMCID was sent
assert len(seen) == 1 and "ids=111&" in seen[0]
# persisted into extra, existing lines kept
assert fetch_mod._field(db, a, "extra") == "PMID: 111\nPMCID: PMC777"
# second call: nothing left to resolve
pending_queue(db, "p", client=client, email="e@x")
assert len(seen) == 1
def test_no_client_no_network(self, tmp_path):
path = _db(tmp_path)
with Db(path) as db:
a = _item(db, "p", "PMID: 111")
items = pending_queue(db, "p")
assert items[0].zot_id == a and items[0].pmcid == ""
# ── fetch_one cascade order ───────────────────────────────────────
def _pdf_client(routes_json: dict[str, object], pdf_urls: set[str], seen: list[str]):
def handler(request):
url = str(request.url)
seen.append(url)
if url in pdf_urls:
return httpx.Response(
200,
content=b"%PDF-1.7\n" + b"x" * 2000,
headers={"content-type": "application/pdf"},
)
for needle, body in routes_json.items():
if needle in url:
return (
httpx.Response(200, json=body)
if not isinstance(body, int)
else httpx.Response(body)
)
return httpx.Response(404)
return httpx.Client(transport=httpx.MockTransport(handler))
class TestFetchOneTiers:
def test_second_unpaywall_location_wins(self, tmp_path):
seen: list[str] = []
up = {
"best_oa_location": {"url_for_pdf": "https://dead/p.pdf"},
"oa_locations": [
{"url_for_pdf": "https://dead/p.pdf"},
{"url_for_pdf": "https://alive/p.pdf"},
],
}
client = _pdf_client({"unpaywall": up}, {"https://alive/p.pdf"}, seen)
item = PendingItem(zot_id=1, doi="10.1/x", pmid="", pmcid="", title="T")
res = fetch_one(item, email="e@x", scratch=tmp_path, client=client)
assert res is not None and res[0] == "unpaywall"
assert "https://dead/p.pdf" in seen and "https://alive/p.pdf" in seen
assert not any("semanticscholar" in u for u in seen)
def test_s2_pdf_after_unpaywall_miss(self, tmp_path):
seen: list[str] = []
s2 = {"openAccessPdf": {"url": "https://s2/p.pdf"}, "externalIds": {}}
client = _pdf_client(
{"unpaywall": 404, "semanticscholar": s2}, {"https://s2/p.pdf"}, seen
)
item = PendingItem(zot_id=1, doi="10.1/x", pmid="", pmcid="", title="T")
res = fetch_one(item, email="e@x", scratch=tmp_path, client=client)
assert res is not None and res[0] == "s2"
def test_s2_pmcid_feeds_pmc_tiers(self, tmp_path):
seen: list[str] = []
s2 = {"openAccessPdf": None, "externalIds": {"PubMedCentral": "99"}}
client = _pdf_client({"unpaywall": 404, "semanticscholar": s2}, set(), seen)
item = PendingItem(zot_id=1, doi="10.1/x", pmid="", pmcid="", title="T")
assert fetch_one(item, email="e@x", scratch=tmp_path, client=client) is None
assert any("prefix=PMC99." in u for u in seen), seen # S3 listing tried
assert any("id=PMC99" in u for u in seen), seen # OA service tried
def test_item_pmcid_not_overridden(self, tmp_path):
seen: list[str] = []
s2 = {"openAccessPdf": None, "externalIds": {"PubMedCentral": "99"}}
client = _pdf_client({"unpaywall": 404, "semanticscholar": s2}, set(), seen)
item = PendingItem(zot_id=1, doi="10.1/x", pmid="", pmcid="PMC1", title="T")
fetch_one(item, email="e@x", scratch=tmp_path, client=client)
assert not any("PMC99" in u for u in seen)
assert any("prefix=PMC1." in u for u in seen)