Mirrors _sync_attachments. Idempotent dedup keyed on (parentItemID, title) so re-syncing is a no-op. Wired into both the create-item and update-existing-item branches of push_to_zotero. Adds 'notes' counter to the stats dict. Fixes test_sync_deeper mocks: both _sync_attachments and _sync_notes call store._con().execute(); updated the MagicMock side_effect to route by SQL fragment so the notes query returns [] while attachment queries return the fixture rows. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
284 lines
9.1 KiB
Python
284 lines
9.1 KiB
Python
"""Exercise bib.sync — push_to_zotero with real Zotero DB + mocked store."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from unittest.mock import MagicMock
|
|
|
|
from bib.item import Manual, Source
|
|
from bib.sync import _sync_attachments, _zotero_collection_path, push_to_zotero
|
|
from zot.db import TYPE_MAP, Db
|
|
from zot.schema import create_db
|
|
|
|
|
|
def _setup(tmp_path):
|
|
path = str(tmp_path / "z.sqlite")
|
|
con = create_db(path)
|
|
con.close()
|
|
return path
|
|
|
|
|
|
class TestSyncAttachments:
|
|
def test_no_attachments(self, tmp_path):
|
|
path = _setup(tmp_path)
|
|
store = MagicMock()
|
|
store._con.return_value.execute.return_value.fetchall.return_value = []
|
|
|
|
with Db(path) as db:
|
|
parent_id = db.create_item(TYPE_MAP["document"])
|
|
db.commit()
|
|
n = _sync_attachments(
|
|
db, store, MagicMock(key="K1"), parent_id, tmp_path / "storage"
|
|
)
|
|
assert n == 0
|
|
|
|
def test_with_attachment(self, tmp_path):
|
|
path = _setup(tmp_path)
|
|
storage = tmp_path / "storage"
|
|
storage.mkdir()
|
|
|
|
# Create a fake source file
|
|
src_file = tmp_path / "source" / "paper.pdf"
|
|
src_file.parent.mkdir()
|
|
src_file.write_bytes(b"%PDF content")
|
|
|
|
store = MagicMock()
|
|
store._con.return_value.execute.return_value.fetchall.return_value = [
|
|
{
|
|
"filename": "paper.pdf",
|
|
"content_type": "application/pdf",
|
|
"storage_path": str(src_file),
|
|
},
|
|
]
|
|
|
|
bib_item = MagicMock(key="K1")
|
|
|
|
with Db(path) as db:
|
|
parent_id = db.create_item(TYPE_MAP["document"])
|
|
db.commit()
|
|
n = _sync_attachments(db, store, bib_item, parent_id, storage)
|
|
db.commit()
|
|
assert n == 1
|
|
|
|
def test_dedup(self, tmp_path):
|
|
path = _setup(tmp_path)
|
|
storage = tmp_path / "storage"
|
|
storage.mkdir()
|
|
|
|
src_file = tmp_path / "source" / "paper.pdf"
|
|
src_file.parent.mkdir()
|
|
src_file.write_bytes(b"%PDF content")
|
|
|
|
store = MagicMock()
|
|
store._con.return_value.execute.return_value.fetchall.return_value = [
|
|
{
|
|
"filename": "paper.pdf",
|
|
"content_type": "application/pdf",
|
|
"storage_path": str(src_file),
|
|
},
|
|
]
|
|
bib_item = MagicMock(key="K1")
|
|
|
|
with Db(path) as db:
|
|
parent_id = db.create_item(TYPE_MAP["document"])
|
|
db.commit()
|
|
# First attach
|
|
n1 = _sync_attachments(db, store, bib_item, parent_id, storage)
|
|
db.commit()
|
|
# Second attach should skip (dedup)
|
|
n2 = _sync_attachments(db, store, bib_item, parent_id, storage)
|
|
assert n1 == 1
|
|
assert n2 == 0
|
|
|
|
def test_missing_file(self, tmp_path):
|
|
path = _setup(tmp_path)
|
|
store = MagicMock()
|
|
store._con.return_value.execute.return_value.fetchall.return_value = [
|
|
{
|
|
"filename": "missing.pdf",
|
|
"content_type": "application/pdf",
|
|
"storage_path": "/nonexistent/file.pdf",
|
|
},
|
|
]
|
|
|
|
with Db(path) as db:
|
|
parent_id = db.create_item(TYPE_MAP["document"])
|
|
db.commit()
|
|
n = _sync_attachments(
|
|
db, store, MagicMock(key="K1"), parent_id, tmp_path / "storage"
|
|
)
|
|
assert n == 0
|
|
|
|
|
|
class TestPushToZotero:
|
|
def test_creates_source(self, tmp_path):
|
|
path = _setup(tmp_path)
|
|
item = Source(title="Test Document", url="https://example.com/doc")
|
|
item.doc_type = "Public Comment"
|
|
item.add_tag("source:regulations-gov")
|
|
|
|
stats = push_to_zotero([item], zotero_db=path)
|
|
assert stats["created"] >= 1
|
|
|
|
def test_skips_existing_url(self, tmp_path):
|
|
path = _setup(tmp_path)
|
|
item = Source(title="Test", url="https://example.com/doc")
|
|
item.doc_type = "Public Comment"
|
|
|
|
# First push creates
|
|
push_to_zotero([item], zotero_db=path)
|
|
# Second push skips
|
|
stats = push_to_zotero([item], zotero_db=path)
|
|
assert stats["skipped"] >= 1
|
|
|
|
def test_collection_routing(self, tmp_path):
|
|
path = _setup(tmp_path)
|
|
item = Manual(title="IOM Ch1", manual_name="Claims Processing")
|
|
item.add_tag("source:iom")
|
|
|
|
stats = push_to_zotero([item], zotero_db=path)
|
|
assert stats["collections"] >= 1
|
|
|
|
def test_with_store_attachments(self, tmp_path):
|
|
path = _setup(tmp_path)
|
|
storage = tmp_path / "storage"
|
|
storage.mkdir()
|
|
|
|
src = tmp_path / "src" / "paper.pdf"
|
|
src.parent.mkdir()
|
|
src.write_bytes(b"%PDF content")
|
|
|
|
att_rows = [
|
|
{
|
|
"filename": "paper.pdf",
|
|
"content_type": "application/pdf",
|
|
"storage_path": str(src),
|
|
},
|
|
]
|
|
|
|
def _execute(sql, *args, **kwargs):
|
|
m = MagicMock()
|
|
# Notes query selects n.title; attachment query selects a.filename
|
|
m.fetchall.return_value = att_rows if "a.filename" in sql else []
|
|
return m
|
|
|
|
store = MagicMock()
|
|
store._con.return_value.execute.side_effect = _execute
|
|
|
|
item = Source(title="Test", url="https://unique-url-test.com/doc")
|
|
item.doc_type = "Public Comment"
|
|
|
|
stats = push_to_zotero(
|
|
[item], zotero_db=path, zotero_storage=str(storage), store=store
|
|
)
|
|
assert stats["created"] >= 1
|
|
|
|
|
|
class TestCollectionPathEdge:
|
|
def test_oig_with_guidance(self):
|
|
item = Source(title="SFA")
|
|
item.add_tag("agency:oig")
|
|
item.add_tag("guidance:sfa")
|
|
path = _zotero_collection_path(item)
|
|
assert "SFA" in path[-1]
|
|
|
|
def test_email_no_mailbox(self):
|
|
item = Source(title="Msg")
|
|
item.add_tag("source:email")
|
|
path = _zotero_collection_path(item)
|
|
assert path == ["Inbox"]
|
|
|
|
def test_oig_with_sector(self):
|
|
"""Line 421: OIG item with sector tag."""
|
|
item = Source(title="CPG")
|
|
item.add_tag("agency:oig")
|
|
item.add_tag("sector:hospitals")
|
|
path = _zotero_collection_path(item)
|
|
assert "Hospitals" in path[-1]
|
|
|
|
def test_oig_no_sector_no_guidance(self):
|
|
"""Line 421: OIG with neither sector nor guidance → base only."""
|
|
item = Source(title="OIG doc")
|
|
item.add_tag("agency:oig")
|
|
path = _zotero_collection_path(item)
|
|
assert path == ["Healthcare Data Platform", "OIG Guidance"]
|
|
|
|
|
|
class TestPushToZoteroCreators:
|
|
def test_journal_article_creators(self, tmp_path):
|
|
"""Lines 338, 339: creators are added for journal articles."""
|
|
path = _setup(tmp_path)
|
|
item = Source(
|
|
title="Test Article",
|
|
url="https://example.com/article",
|
|
doc_type="journal-article",
|
|
extra="Authors: Smith J; Jones K\nDOI: 10.1/x\nJournal: J\n",
|
|
date_published="2024-01-01",
|
|
)
|
|
|
|
stats = push_to_zotero([item], zotero_db=path)
|
|
assert stats["created"] >= 1
|
|
assert stats.get("creators", 0) >= 2
|
|
|
|
|
|
class TestPushToZoteroExistingWithAttachments:
|
|
def test_existing_url_syncs_attachments(self, tmp_path):
|
|
"""Lines 301, 312-314: existing URL item syncs attachments + collection path."""
|
|
path = _setup(tmp_path)
|
|
storage = tmp_path / "storage"
|
|
storage.mkdir()
|
|
|
|
src = tmp_path / "src" / "paper.pdf"
|
|
src.parent.mkdir()
|
|
src.write_bytes(b"%PDF content")
|
|
|
|
att_rows = [
|
|
{
|
|
"filename": "paper.pdf",
|
|
"content_type": "application/pdf",
|
|
"storage_path": str(src),
|
|
},
|
|
]
|
|
|
|
def _execute(sql, *args, **kwargs):
|
|
m = MagicMock()
|
|
# Notes query selects n.title; attachment query selects a.filename
|
|
m.fetchall.return_value = att_rows if "a.filename" in sql else []
|
|
return m
|
|
|
|
store = MagicMock()
|
|
store._con.return_value.execute.side_effect = _execute
|
|
|
|
item = Manual(
|
|
title="IOM Ch1",
|
|
url="https://example.com/iom-ch1",
|
|
manual_name="Claims Processing",
|
|
)
|
|
item.add_tag("source:iom")
|
|
|
|
# First push creates
|
|
stats1 = push_to_zotero(
|
|
[item], zotero_db=path, zotero_storage=str(storage), store=store
|
|
)
|
|
assert stats1["created"] >= 1
|
|
|
|
# Second push hits the existing-URL path (skips)
|
|
stats2 = push_to_zotero(
|
|
[item], zotero_db=path, zotero_storage=str(storage), store=store
|
|
)
|
|
assert stats2["skipped"] >= 1
|
|
|
|
|
|
class TestPushToZoteroCollectionPath:
|
|
def test_email_collection_path(self, tmp_path):
|
|
"""Lines 273: _resolve_path is cached across items."""
|
|
path = _setup(tmp_path)
|
|
item1 = Source(title="Email 1", url="https://x.com/e1")
|
|
item1.add_tag("source:email")
|
|
item1.add_tag("mailbox:updates")
|
|
item2 = Source(title="Email 2", url="https://x.com/e2")
|
|
item2.add_tag("source:email")
|
|
item2.add_tag("mailbox:updates")
|
|
|
|
stats = push_to_zotero([item1, item2], zotero_db=path)
|
|
assert stats["created"] >= 2
|