194 lines
6.4 KiB
Python
194 lines
6.4 KiB
Python
"""Deeper tests for prisma.fetch — run() orchestrator + attach_pdf."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import threading
|
|
import time
|
|
|
|
import httpx
|
|
|
|
from prisma import fetch as fetch_mod
|
|
from prisma.fetch import PendingItem, attach_pdf, fetch_one, pending_queue, run
|
|
from zot.db import TYPE_MAP, Db
|
|
from zot.schema import create_db
|
|
|
|
|
|
class TestAttachPdf:
|
|
def test_creates_attachment(self, tmp_path):
|
|
path = str(tmp_path / "z.sqlite")
|
|
con = create_db(path)
|
|
con.close()
|
|
with Db(path) as db:
|
|
iid = db.create_item(TYPE_MAP["document"])
|
|
db.commit()
|
|
pdf = tmp_path / "test.pdf"
|
|
pdf.write_bytes(b"%PDF-1.7 test content")
|
|
storage = tmp_path / "storage"
|
|
storage.mkdir()
|
|
att_id = attach_pdf(db, iid, pdf, storage, title="test.pdf")
|
|
assert att_id > 0
|
|
|
|
|
|
class TestPendingQueue:
|
|
def test_empty_queue(self, tmp_path):
|
|
path = str(tmp_path / "z.sqlite")
|
|
con = create_db(path)
|
|
con.close()
|
|
with Db(path) as db:
|
|
result = pending_queue(db, "nonexistent")
|
|
assert result == []
|
|
|
|
|
|
class TestFallbackSerialized:
|
|
def test_fallback_tier_never_concurrent(self, tmp_path, monkeypatch):
|
|
"""Parallel fetch_one calls must enter the fallback tier one at a time."""
|
|
state = {"active": 0, "max_active": 0}
|
|
gauge = threading.Lock()
|
|
|
|
def fake_fallback(client, doi):
|
|
with gauge:
|
|
state["active"] += 1
|
|
state["max_active"] = max(state["max_active"], state["active"])
|
|
time.sleep(0.05)
|
|
with gauge:
|
|
state["active"] -= 1
|
|
return None # miss → cascade ends, no download
|
|
|
|
# direct tiers all miss so every call reaches the fallback tier
|
|
monkeypatch.setattr(fetch_mod, "fetch_unpaywall", lambda c, d, e: None)
|
|
monkeypatch.setattr(fetch_mod, "fetch_pmc", lambda c, p: None)
|
|
monkeypatch.setattr(fetch_mod, "fetch_fallback", fake_fallback)
|
|
|
|
client = httpx.Client(
|
|
transport=httpx.MockTransport(lambda r: httpx.Response(404))
|
|
)
|
|
items = [
|
|
PendingItem(zot_id=i, doi=f"10.1/{i}", pmid="", pmcid="", title=f"t{i}")
|
|
for i in range(6)
|
|
]
|
|
threads = [
|
|
threading.Thread(
|
|
target=fetch_one,
|
|
args=(it,),
|
|
kwargs={
|
|
"email": "e@x.com",
|
|
"scratch": tmp_path,
|
|
"client": client,
|
|
"client_proxied": client,
|
|
},
|
|
)
|
|
for it in items
|
|
]
|
|
for t in threads:
|
|
t.start()
|
|
for t in threads:
|
|
t.join()
|
|
assert state["max_active"] == 1
|
|
|
|
|
|
def _seed_project(path, n):
|
|
"""Seed *n* screen:include items in project:test into an existing DB."""
|
|
ids = []
|
|
with Db(path) as db:
|
|
for i in range(n):
|
|
item_id = db.create_item(TYPE_MAP["journalArticle"])
|
|
db.set_fields(item_id, {"title": f"Article {i}", "DOI": f"10.1/{i}"})
|
|
db.tag_item(item_id, "project:test")
|
|
db.tag_item(item_id, "screen:include")
|
|
ids.append(item_id)
|
|
db.commit()
|
|
return ids
|
|
|
|
|
|
class TestRunParallel:
|
|
def test_all_items_attached_and_counted(self, zotero_db, tmp_path, monkeypatch):
|
|
_seed_project(zotero_db, 6)
|
|
|
|
def fake_fetch_one(item, *, email, scratch, client, client_proxied=None):
|
|
time.sleep(0.05)
|
|
p = scratch / f"{item.zot_id}.pdf"
|
|
p.write_bytes(b"%PDF-1.4 fake")
|
|
return "unpaywall", p
|
|
|
|
monkeypatch.setattr(fetch_mod, "fetch_one", fake_fetch_one)
|
|
with Db(zotero_db) as db:
|
|
stats = fetch_mod.run(
|
|
db,
|
|
project="test",
|
|
storage_dir=tmp_path / "storage",
|
|
scratch_dir=tmp_path / "scratch",
|
|
email="e@x.com",
|
|
fetch_proxy=None,
|
|
workers=4,
|
|
)
|
|
assert stats["unpaywall"] == 6
|
|
assert stats["missed"] == 0
|
|
assert stats["errors"] == 0
|
|
# every item now has a PDF attachment → queue drains
|
|
assert fetch_mod.pending_queue(db, "test") == []
|
|
|
|
def test_fetches_overlap(self, zotero_db, tmp_path, monkeypatch):
|
|
_seed_project(zotero_db, 6)
|
|
state = {"active": 0, "max_active": 0}
|
|
gauge = threading.Lock()
|
|
|
|
def slow_fetch_one(item, *, email, scratch, client, client_proxied=None):
|
|
with gauge:
|
|
state["active"] += 1
|
|
state["max_active"] = max(state["max_active"], state["active"])
|
|
time.sleep(0.2)
|
|
with gauge:
|
|
state["active"] -= 1
|
|
return None
|
|
|
|
monkeypatch.setattr(fetch_mod, "fetch_one", slow_fetch_one)
|
|
with Db(zotero_db) as db:
|
|
stats = fetch_mod.run(
|
|
db,
|
|
project="test",
|
|
storage_dir=tmp_path / "storage",
|
|
scratch_dir=tmp_path / "scratch",
|
|
email="e@x.com",
|
|
fetch_proxy=None,
|
|
workers=6,
|
|
)
|
|
assert stats["missed"] == 6
|
|
assert state["max_active"] >= 2
|
|
|
|
def test_worker_exception_counts_as_error(self, zotero_db, tmp_path, monkeypatch):
|
|
_seed_project(zotero_db, 3)
|
|
|
|
def boom(item, *, email, scratch, client, client_proxied=None):
|
|
raise RuntimeError("kaput")
|
|
|
|
monkeypatch.setattr(fetch_mod, "fetch_one", boom)
|
|
with Db(zotero_db) as db:
|
|
stats = fetch_mod.run(
|
|
db,
|
|
project="test",
|
|
storage_dir=tmp_path / "storage",
|
|
scratch_dir=tmp_path / "scratch",
|
|
email="e@x.com",
|
|
fetch_proxy=None,
|
|
workers=2,
|
|
)
|
|
assert stats["errors"] == 3
|
|
|
|
|
|
class TestRun:
|
|
def test_empty_queue(self, tmp_path):
|
|
path = str(tmp_path / "z.sqlite")
|
|
con = create_db(path)
|
|
con.close()
|
|
with Db(path) as db:
|
|
stats = run(
|
|
db,
|
|
project="test",
|
|
storage_dir=tmp_path / "storage",
|
|
scratch_dir=tmp_path / "scratch",
|
|
email="test@test.com",
|
|
fetch_proxy=None,
|
|
)
|
|
assert stats["missed"] == 0
|
|
assert stats["unpaywall"] == 0
|