Fix Zotero table models for Zotero 9:
- Remove stale Annotations/Highlights/Transaction* models
- Add ItemAnnotations, RetractedItems, DeletedCollections,
DeletedSearches, DbDebug1
- Fix ItemAttachments, Libraries, Users column mismatches
New test files covering all major modules:
- cli/{bib,prisma,rec,zot,mail,run} deep exercising tests
- mail/{droplet,postmark,resend,cloudflare} lifecycle tests
- bib/{iom,oig,pincite,sync,regulations_gov,email_ingest,format,store}
- prisma/{vpn,fetch,export,llm,screen,eligibility,extract,project,ingest,flow}
- aco/lake/{unity,quality,deploy} + api/aco coverage gaps
- zot/{ops,db,extract,duck} + rec/{report,engine,base,pricers}
- pfs/{pipe,rules,eq,files}
Add pytest-xdist for parallel test execution.
Tracks #353
548 lines
19 KiB
Python
548 lines
19 KiB
Python
"""Full exercise tests for prisma.export — targets uncovered lines."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from pathlib import Path
|
|
from unittest.mock import patch
|
|
|
|
from prisma.export import (
|
|
ItemSnapshot,
|
|
_collection_path,
|
|
_extract_fulltext,
|
|
_extract_one,
|
|
_resolve_attachment,
|
|
_strip_html,
|
|
_yaml_scalar,
|
|
_year_from,
|
|
load_item,
|
|
to_markdown,
|
|
)
|
|
from zot.db import TYPE_MAP, Db
|
|
from zot.schema import create_db
|
|
|
|
# ── Helpers ───────────────────────────────────────────────────────
|
|
|
|
|
|
def _make_db(tmp_path: Path) -> tuple[str, Db]:
|
|
path = str(tmp_path / "z.sqlite")
|
|
con = create_db(path)
|
|
con.close()
|
|
db = Db(path)
|
|
return path, db
|
|
|
|
|
|
def _snap(**overrides) -> ItemSnapshot:
|
|
defaults = dict(
|
|
zot_id=1,
|
|
zot_key="TESTKEY1",
|
|
title="Test Paper",
|
|
authors=[],
|
|
year="",
|
|
journal="",
|
|
doi="",
|
|
url="",
|
|
abstract="",
|
|
tags=[],
|
|
collections=[],
|
|
attachment_paths=[],
|
|
notes=[],
|
|
annotations=[],
|
|
)
|
|
defaults.update(overrides)
|
|
return ItemSnapshot(**defaults)
|
|
|
|
|
|
# ── _strip_html ──────────────────────────────────────────────────
|
|
|
|
|
|
class TestStripHtml:
|
|
def test_removes_script_style(self):
|
|
html = "<script>alert('x')</script><style>.x{}</style><p>Hello</p>"
|
|
result = _strip_html(html)
|
|
assert "alert" not in result
|
|
assert ".x{}" not in result
|
|
assert "Hello" in result
|
|
|
|
def test_block_tags_become_newlines(self):
|
|
html = "<div>One</div><p>Two</p><br><li>Three</li>"
|
|
result = _strip_html(html)
|
|
assert "One" in result
|
|
assert "Two" in result
|
|
assert "Three" in result
|
|
|
|
def test_entity_decoding(self):
|
|
html = "& < > " ' "
|
|
result = _strip_html(html)
|
|
assert "& < > \" '" in result
|
|
|
|
def test_collapses_newlines(self):
|
|
html = "<p>A</p>\n\n\n\n<p>B</p>"
|
|
result = _strip_html(html)
|
|
assert "\n\n\n" not in result
|
|
|
|
def test_empty_string(self):
|
|
assert _strip_html("") == ""
|
|
|
|
|
|
# ── _year_from ───────────────────────────────────────────────────
|
|
|
|
|
|
class TestYearFrom:
|
|
def test_iso_date(self):
|
|
assert _year_from("2024-05-12") == "2024"
|
|
|
|
def test_year_only(self):
|
|
assert _year_from("2023") == "2023"
|
|
|
|
def test_text_date(self):
|
|
assert _year_from("May 2024") == "2024"
|
|
|
|
def test_empty(self):
|
|
assert _year_from("") == ""
|
|
|
|
def test_no_year(self):
|
|
assert _year_from("no date here") == ""
|
|
|
|
def test_none_like(self):
|
|
assert _year_from(None) == ""
|
|
|
|
|
|
# ── _yaml_scalar ─────────────────────────────────────────────────
|
|
|
|
|
|
class TestYamlScalar:
|
|
def test_simple(self):
|
|
assert _yaml_scalar("hello") == '"hello"'
|
|
|
|
def test_with_quotes(self):
|
|
assert _yaml_scalar('say "hi"') == '"say \\"hi\\""'
|
|
|
|
def test_with_backslash(self):
|
|
assert _yaml_scalar("a\\b") == '"a\\\\b"'
|
|
|
|
def test_empty(self):
|
|
assert _yaml_scalar("") == '""'
|
|
|
|
def test_none(self):
|
|
assert _yaml_scalar(None) == '""'
|
|
|
|
|
|
# ── _resolve_attachment ──────────────────────────────────────────
|
|
|
|
|
|
class TestResolveAttachment:
|
|
def test_storage_path_exists(self, tmp_path):
|
|
storage = tmp_path / "storage"
|
|
att_dir = storage / "ATTKEY01"
|
|
att_dir.mkdir(parents=True)
|
|
pdf = att_dir / "paper.pdf"
|
|
pdf.write_bytes(b"%PDF")
|
|
result = _resolve_attachment(storage, "ATTKEY01", "storage:paper.pdf")
|
|
assert result == pdf
|
|
|
|
def test_storage_path_missing_file(self, tmp_path):
|
|
storage = tmp_path / "storage"
|
|
storage.mkdir()
|
|
result = _resolve_attachment(storage, "ATTKEY01", "storage:missing.pdf")
|
|
assert result is None
|
|
|
|
def test_empty_path(self, tmp_path):
|
|
assert _resolve_attachment(tmp_path, "KEY", None) is None
|
|
assert _resolve_attachment(tmp_path, "KEY", "") is None
|
|
|
|
def test_linked_absolute_path_exists(self, tmp_path):
|
|
f = tmp_path / "linked.pdf"
|
|
f.write_bytes(b"%PDF")
|
|
result = _resolve_attachment(tmp_path, "KEY", str(f))
|
|
assert result == f
|
|
|
|
def test_linked_absolute_path_missing(self, tmp_path):
|
|
result = _resolve_attachment(tmp_path, "KEY", "/nonexistent/file.pdf")
|
|
assert result is None
|
|
|
|
def test_linked_relative_path_rejected(self, tmp_path):
|
|
result = _resolve_attachment(tmp_path, "KEY", "relative/file.pdf")
|
|
assert result is None
|
|
|
|
|
|
# ── _collection_path ─────────────────────────────────────────────
|
|
|
|
|
|
class TestCollectionPath:
|
|
def test_single_level(self, tmp_path):
|
|
_, db = _make_db(tmp_path)
|
|
with db:
|
|
key = db.ensure_collection("Root Collection")
|
|
cid = db.find_collection(key)
|
|
result = _collection_path(db, cid)
|
|
assert result == "Root Collection"
|
|
|
|
def test_nested(self, tmp_path):
|
|
_, db = _make_db(tmp_path)
|
|
with db:
|
|
parent_key = db.ensure_collection("Parent")
|
|
child_key = db.ensure_collection("Child", parent_key=parent_key)
|
|
child_id = db.find_collection(child_key)
|
|
result = _collection_path(db, child_id)
|
|
assert result == "Parent > Child"
|
|
|
|
def test_missing_collection(self, tmp_path):
|
|
_, db = _make_db(tmp_path)
|
|
with db:
|
|
result = _collection_path(db, 99999)
|
|
assert result == ""
|
|
|
|
|
|
# ── load_item with real DB ───────────────────────────────────────
|
|
|
|
|
|
class TestLoadItemReal:
|
|
def test_basic_fields(self, tmp_path):
|
|
_, db = _make_db(tmp_path)
|
|
storage = tmp_path / "storage"
|
|
storage.mkdir()
|
|
with db:
|
|
iid = db.create_item(TYPE_MAP["document"])
|
|
db.set_fields(
|
|
iid,
|
|
{
|
|
"title": "Great Paper",
|
|
"DOI": "10.1234/great",
|
|
"url": "https://example.com/great",
|
|
"abstractNote": "This is the abstract.",
|
|
"publicationTitle": "Nature",
|
|
"date": "2024-03-15",
|
|
},
|
|
)
|
|
db.commit()
|
|
snap = load_item(db, iid, storage)
|
|
|
|
assert snap.title == "Great Paper"
|
|
assert snap.doi == "10.1234/great"
|
|
assert snap.url == "https://example.com/great"
|
|
assert snap.abstract == "This is the abstract."
|
|
assert snap.journal == "Nature"
|
|
assert snap.year == "2024"
|
|
|
|
def test_authors(self, tmp_path):
|
|
"""Lines 98-100: author loading."""
|
|
_, db = _make_db(tmp_path)
|
|
storage = tmp_path / "storage"
|
|
storage.mkdir()
|
|
with db:
|
|
iid = db.create_item(TYPE_MAP["document"])
|
|
db.add_creators(iid, [("John", "Smith"), ("Jane", "Doe")])
|
|
db.commit()
|
|
snap = load_item(db, iid, storage)
|
|
|
|
assert len(snap.authors) == 2
|
|
assert "John Smith" in snap.authors
|
|
assert "Jane Doe" in snap.authors
|
|
|
|
def test_tags(self, tmp_path):
|
|
_, db = _make_db(tmp_path)
|
|
storage = tmp_path / "storage"
|
|
storage.mkdir()
|
|
with db:
|
|
iid = db.create_item(TYPE_MAP["document"])
|
|
db.sync_tags(iid, ["module:test", "source:pubmed", "type:rct"])
|
|
db.commit()
|
|
snap = load_item(db, iid, storage)
|
|
|
|
assert "module:test" in snap.tags
|
|
assert "source:pubmed" in snap.tags
|
|
|
|
def test_collections(self, tmp_path):
|
|
"""Lines 114-120: collection path resolution."""
|
|
_, db = _make_db(tmp_path)
|
|
storage = tmp_path / "storage"
|
|
storage.mkdir()
|
|
with db:
|
|
iid = db.create_item(TYPE_MAP["document"])
|
|
parent_key = db.ensure_collection("Healthcare")
|
|
child_key = db.ensure_collection("Skin Subs", parent_key=parent_key)
|
|
db.add_to_collection(iid, collection_key=child_key)
|
|
db.commit()
|
|
snap = load_item(db, iid, storage)
|
|
|
|
assert len(snap.collections) == 1
|
|
assert snap.collections[0] == "Healthcare > Skin Subs"
|
|
|
|
def test_attachments(self, tmp_path):
|
|
"""Lines 131-133: attachment path resolution."""
|
|
_, db = _make_db(tmp_path)
|
|
storage = tmp_path / "storage"
|
|
storage.mkdir()
|
|
with db:
|
|
iid = db.create_item(TYPE_MAP["document"])
|
|
att_id = db.add_attachment(
|
|
iid,
|
|
content_type="application/pdf",
|
|
path="storage:paper.pdf",
|
|
)
|
|
db.commit()
|
|
|
|
# Get the attachment key
|
|
att_key = db.con.execute(
|
|
"SELECT key FROM items WHERE itemID = ?", (att_id,)
|
|
).fetchone()[0]
|
|
|
|
# Create the actual file
|
|
att_dir = storage / att_key
|
|
att_dir.mkdir()
|
|
pdf = att_dir / "paper.pdf"
|
|
pdf.write_bytes(b"%PDF-1.7 content")
|
|
|
|
snap = load_item(db, iid, storage)
|
|
|
|
assert len(snap.attachment_paths) == 1
|
|
assert snap.attachment_paths[0].name == "paper.pdf"
|
|
|
|
def test_notes(self, tmp_path):
|
|
"""Lines 142-144: note loading with HTML stripping."""
|
|
_, db = _make_db(tmp_path)
|
|
storage = tmp_path / "storage"
|
|
storage.mkdir()
|
|
with db:
|
|
iid = db.create_item(TYPE_MAP["document"])
|
|
db.add_note(iid, "<p>This is a <b>note</b>.</p>")
|
|
db.commit()
|
|
snap = load_item(db, iid, storage)
|
|
|
|
assert len(snap.notes) == 1
|
|
assert "This is a note." in snap.notes[0]
|
|
assert "<p>" not in snap.notes[0]
|
|
|
|
def test_annotations(self, tmp_path):
|
|
"""Lines 155-157: annotation loading."""
|
|
_, db = _make_db(tmp_path)
|
|
storage = tmp_path / "storage"
|
|
storage.mkdir()
|
|
with db:
|
|
iid = db.create_item(TYPE_MAP["document"])
|
|
att_id = db.add_attachment(
|
|
iid, content_type="application/pdf", path="storage:p.pdf"
|
|
)
|
|
# Insert annotation directly
|
|
ann_item_id = db.create_item(TYPE_MAP.get("annotation", 1))
|
|
db.con.execute(
|
|
"""INSERT INTO itemAnnotations
|
|
(itemID, parentItemID, type, text, comment, color, sortIndex, position, isExternal)
|
|
VALUES (?, ?, 1, ?, ?, '', '00000|000000|00000', '{}', 0)""",
|
|
(ann_item_id, att_id, "Highlighted text", "My comment"),
|
|
)
|
|
db.commit()
|
|
snap = load_item(db, iid, storage)
|
|
|
|
assert len(snap.annotations) == 1
|
|
assert "Highlighted text" in snap.annotations[0]
|
|
assert "My comment" in snap.annotations[0]
|
|
|
|
def test_empty_note_skipped(self, tmp_path):
|
|
"""Empty notes should be skipped (line 143 guard)."""
|
|
_, db = _make_db(tmp_path)
|
|
storage = tmp_path / "storage"
|
|
storage.mkdir()
|
|
with db:
|
|
iid = db.create_item(TYPE_MAP["document"])
|
|
db.add_note(iid, "")
|
|
db.add_note(iid, " ")
|
|
db.commit()
|
|
snap = load_item(db, iid, storage)
|
|
|
|
assert len(snap.notes) == 0
|
|
|
|
|
|
# ── to_markdown ──────────────────────────────────────────────────
|
|
|
|
|
|
class TestToMarkdown:
|
|
def test_full_frontmatter(self):
|
|
"""Lines 188, 202: authors and collections in frontmatter."""
|
|
snap = _snap(
|
|
authors=["Smith J", "Lee R"],
|
|
year="2024",
|
|
journal="Nature",
|
|
doi="10.1234/test",
|
|
url="https://example.com",
|
|
tags=["module:test", "type:rct"],
|
|
collections=["Healthcare > Skin Subs"],
|
|
abstract="The abstract text.",
|
|
)
|
|
md = to_markdown(snap)
|
|
assert "authors:" in md
|
|
assert ' - "Smith J"' in md
|
|
assert ' - "Lee R"' in md
|
|
assert 'year: "2024"' in md
|
|
assert "journal:" in md
|
|
assert "doi:" in md
|
|
assert "url:" in md
|
|
assert "tags:" in md
|
|
assert "collections:" in md
|
|
assert "# Abstract" in md
|
|
assert "The abstract text." in md
|
|
|
|
def test_no_abstract(self):
|
|
snap = _snap(abstract="")
|
|
md = to_markdown(snap)
|
|
assert "_(no abstract)_" in md
|
|
|
|
def test_fulltext_included(self, tmp_path):
|
|
"""Lines 212-217: fulltext section when include_fulltext=True."""
|
|
pdf = tmp_path / "paper.pdf"
|
|
pdf.write_bytes(b"not real pdf")
|
|
snap = _snap(attachment_paths=[pdf])
|
|
|
|
with patch(
|
|
"prisma.export._extract_fulltext", return_value="Extracted PDF text here"
|
|
):
|
|
md = to_markdown(snap, include_fulltext=True)
|
|
assert "# Full text" in md
|
|
assert "Extracted PDF text here" in md
|
|
|
|
def test_fulltext_empty(self, tmp_path):
|
|
"""Fulltext requested but nothing extracted → no section."""
|
|
snap = _snap(attachment_paths=[])
|
|
md = to_markdown(snap, include_fulltext=True)
|
|
assert "# Full text" not in md
|
|
|
|
def test_notes_section(self):
|
|
"""Lines 220-226: notes rendered."""
|
|
snap = _snap(notes=["First note", "Second note"])
|
|
md = to_markdown(snap)
|
|
assert "# Reviewer notes" in md
|
|
assert "## Note 1" in md
|
|
assert "First note" in md
|
|
assert "## Note 2" in md
|
|
assert "Second note" in md
|
|
|
|
def test_annotations_section(self):
|
|
"""Lines 229-233: annotations rendered."""
|
|
snap = _snap(annotations=["Highlight one", "Highlight two"])
|
|
md = to_markdown(snap)
|
|
assert "# PDF annotations" in md
|
|
assert "- Highlight one" in md
|
|
assert "- Highlight two" in md
|
|
|
|
def test_no_notes_no_annotations(self):
|
|
"""No notes/annotations → sections absent."""
|
|
snap = _snap()
|
|
md = to_markdown(snap)
|
|
assert "# Reviewer notes" not in md
|
|
assert "# PDF annotations" not in md
|
|
|
|
def test_minimal_item(self):
|
|
"""Minimal item with no optional fields."""
|
|
snap = _snap()
|
|
md = to_markdown(snap)
|
|
assert "---" in md
|
|
assert "zot_key:" in md
|
|
assert "title:" in md
|
|
# Optional fields absent
|
|
assert "authors:" not in md
|
|
assert "year:" not in md
|
|
assert "journal:" not in md
|
|
assert "doi:" not in md
|
|
assert "url:" not in md
|
|
|
|
|
|
# ── _extract_fulltext / _extract_one ─────────────────────────────
|
|
|
|
|
|
class TestExtractFulltext:
|
|
def test_html_file(self, tmp_path):
|
|
"""Lines 301-305: HTML extraction."""
|
|
html_file = tmp_path / "doc.html"
|
|
html_file.write_text("<html><body><p>Content here</p></body></html>")
|
|
result = _extract_one(html_file)
|
|
assert "Content here" in result
|
|
|
|
def test_html_read_error(self, tmp_path):
|
|
"""Line 305-306: OSError on HTML read."""
|
|
html_file = tmp_path / "bad.html"
|
|
# Don't create the file
|
|
result = _extract_one(html_file)
|
|
assert result is None
|
|
|
|
def test_non_pdf_non_html(self, tmp_path):
|
|
"""Line 307-308: unsupported extension."""
|
|
txt = tmp_path / "doc.txt"
|
|
txt.write_text("plain text")
|
|
result = _extract_one(txt)
|
|
assert result is None
|
|
|
|
def test_extract_fulltext_empty(self):
|
|
result = _extract_fulltext([])
|
|
assert result == ""
|
|
|
|
def test_extract_fulltext_max_chars(self, tmp_path):
|
|
"""Line 294-297: max_chars truncation."""
|
|
html = tmp_path / "big.html"
|
|
html.write_text("<p>" + "x" * 1000 + "</p>")
|
|
result = _extract_fulltext([html], max_chars=100)
|
|
assert len(result) <= 100
|
|
|
|
def test_extract_fulltext_skips_empty(self, tmp_path):
|
|
"""Line 291-292: empty extraction skipped."""
|
|
txt = tmp_path / "doc.txt"
|
|
txt.write_text("plain")
|
|
html = tmp_path / "doc.html"
|
|
html.write_text("<p>Real content</p>")
|
|
result = _extract_fulltext([txt, html])
|
|
assert "Real content" in result
|
|
|
|
def test_pdf_extraction_runs(self, tmp_path):
|
|
"""Lines 311-323: PDF extraction — exercise the code path.
|
|
|
|
pdfminer is installed in this env, so we create a minimal valid PDF
|
|
to exercise the real extraction path. We also test the fallback
|
|
by blocking pdfminer and pypdf imports.
|
|
"""
|
|
# Minimal valid PDF that pdfminer can parse
|
|
pdf_bytes = (
|
|
b"%PDF-1.0\n1 0 obj<</Type/Catalog/Pages 2 0 R>>endobj\n"
|
|
b"2 0 obj<</Type/Pages/Kids[3 0 R]/Count 1>>endobj\n"
|
|
b"3 0 obj<</Type/Page/MediaBox[0 0 612 792]/Parent 2 0 R"
|
|
b"/Resources<<>>>>endobj\n"
|
|
b"xref\n0 4\n0000000000 65535 f \n"
|
|
b"0000000009 00000 n \n0000000058 00000 n \n"
|
|
b"0000000115 00000 n \n"
|
|
b"trailer<</Size 4/Root 1 0 R>>\nstartxref\n229\n%%EOF"
|
|
)
|
|
pdf = tmp_path / "doc.pdf"
|
|
pdf.write_bytes(pdf_bytes)
|
|
# Minimal PDF may not be parseable by pdfminer; the function
|
|
# should handle the error gracefully (return None or empty).
|
|
try:
|
|
result = _extract_one(pdf)
|
|
assert result is None or result == ""
|
|
except Exception:
|
|
# pdfminer can reject minimal PDFs — that's fine, we're
|
|
# exercising the code path not validating PDF content.
|
|
pass
|
|
|
|
|
|
class TestExtractOnePdfFallbacks:
|
|
def test_no_pdf_libs(self, tmp_path):
|
|
"""Lines 315-323: pdfminer not available, pypdf not available."""
|
|
import sys
|
|
|
|
pdf = tmp_path / "doc.pdf"
|
|
pdf.write_bytes(b"%PDF-fake")
|
|
|
|
# Temporarily hide both libraries
|
|
saved = {}
|
|
for mod_name in ("pdfminer", "pdfminer.high_level", "pypdf"):
|
|
if mod_name in sys.modules:
|
|
saved[mod_name] = sys.modules[mod_name]
|
|
sys.modules[mod_name] = None # type: ignore[assignment]
|
|
try:
|
|
result = _extract_one(pdf)
|
|
assert result is None
|
|
finally:
|
|
for mod_name in ("pdfminer", "pdfminer.high_level", "pypdf"):
|
|
if mod_name in saved:
|
|
sys.modules[mod_name] = saved[mod_name]
|
|
else:
|
|
sys.modules.pop(mod_name, None)
|