Files
stack/tests/cli/test_zot_fetch.py
kert bee5f57893 feat(zot): stack zot fetch — open-access full-text connector
Attaches an open-access PDF to Zotero items that lack a full text.
Built on the selection, ledger and attach machinery from PR #826
(feat/zot-fetch-z1, left untouched on its branch); delivery is the
stack's legal open-access cascade only — every Unpaywall location →
Semantic Scholar openAccessPdf → PMC S3 → PMC OA service → Europe PMC
(prisma.fetch.fetch_one with no proxied client, so its mirror tier
never runs). No DOI? a Crossref title match first. Downloads must be
real PDFs before they are attached; the run ledgers every attempt
(attached / no_candidate / error; misses wait 30 days unless --retry).

From the #828 review list:
- Lazy selection: select_ids() is one cheap query (deleted and
  full-text items excluded in SQL for --all) and iter_refs() reads each
  item only when the run reaches it, so --limit bites before ~167k
  file-less items are materialised while the desktop is held down.
- A locked database (--no-hold with the desktop writing) aborts the run
  with a usage error that names the Zotero desktop, instead of a raw
  sqlite3.OperationalError — or one error per item.
- --dry-run reads only (checked against the running desktop), so it no
  longer stops/starts zotero; UNPAYWALL_EMAIL is documented in --help.

Tests: 66 (scratch Zotero DB, no network), 100% of src/zot/fetch.
2026-10-27 14:20:05 -04:00

96 lines
2.6 KiB
Python

"""stack zot fetch — the open-access CLI (no network, no docker)."""
from __future__ import annotations
import sqlite3
import pytest
from typer.testing import CliRunner
import cli.zot as zot_cli
import zot.fetch as fetch_pkg
from cli import app
from tests.zot.fetch.conftest import mk_item
runner = CliRunner()
@pytest.fixture
def held(monkeypatch):
calls = []
def fake_hold(fn, hold):
calls.append(hold)
return fn()
monkeypatch.setattr(zot_cli, "_hold_zotero", fake_hold)
return calls
def _db_path(zdb) -> str:
return zdb.con.execute("PRAGMA database_list").fetchone()[2]
def test_dry_run_reads_only_and_never_stops_zotero(zdb, held, monkeypatch, tmp_path):
mk_item(zdb, title="Open", doi="10.1/o")
zdb.commit()
seen = {}
def fake_run(db, refs, **kw):
seen.update(refs=list(refs), **kw)
return {"seen": 1}
monkeypatch.setattr(fetch_pkg, "run", fake_run)
r = runner.invoke(
app,
[
"zot",
"fetch",
"--all",
"--dry-run",
"--db",
_db_path(zdb),
"--ledger",
str(tmp_path / "l.jsonl"),
],
)
assert r.exit_code == 0, r.output
assert held == [False]
assert "1 candidate item(s)" in r.output and "fetch: seen=1" in r.output
assert seen["dry_run"] is True and len(seen["refs"]) == 1
def test_real_run_holds_zotero(zdb, held, monkeypatch, tmp_path):
monkeypatch.setattr(fetch_pkg, "run", lambda db, refs, **kw: {"attempted": 0})
r = runner.invoke(
app, ["zot", "fetch", "--type", "journalArticle", "--db", _db_path(zdb)]
)
assert r.exit_code == 0, r.output
assert held == [True]
def test_bad_population_is_a_usage_error(zdb, held):
r = runner.invoke(app, ["zot", "fetch", "--db", _db_path(zdb)])
assert r.exit_code == 2 and "exactly one of" in r.output
def test_locked_database_names_the_desktop(zdb, held, monkeypatch):
def locked(db, refs, **kw):
raise sqlite3.OperationalError("database is locked")
monkeypatch.setattr(fetch_pkg, "run", locked)
r = runner.invoke(
app, ["zot", "fetch", "--all", "--no-hold", "--db", _db_path(zdb)]
)
assert r.exit_code == 2
assert "Zotero desktop" in r.output and held == [False]
def test_other_sqlite_errors_are_not_disguised(zdb, held, monkeypatch):
def broken(db, refs, **kw):
raise sqlite3.OperationalError("no such table: items")
monkeypatch.setattr(fetch_pkg, "run", broken)
r = runner.invoke(app, ["zot", "fetch", "--all", "--db", _db_path(zdb)])
assert r.exit_code == 1 and isinstance(r.exception, sqlite3.OperationalError)