Files
stack/tests/zot/test_db.py
kert 73407bf089 feat(bib,zot): deterministic URL lookup + nightly library-wide merge-dupes (refs #665)
find_item_by_url now ORDER BY i.itemID so bib.sync consistently keeps
updating the same "earliest" item when duplicates exist — the one
zot.merge treats as the merge keeper. Wires --merge-dupes/--no-merge-dupes
(default off) into `stack bib sync-zotero`, running merge_url_duplicates
library-wide after the push, inside the same stop window.
--dedupe-collection stays but is documented as deprecated (deletes
without merging). Regenerates .gitea/workflows/zotero-sync.yml (source:
dev/scripts/backends/gitea.py) to swap --dedupe-collection Cmsupdates
for --merge-dupes, since listserv re-sends are now aliased at ingest
(Task 3) and any same-URL twins are merged, not deleted.
2026-08-26 12:53:27 -04:00

857 lines
29 KiB
Python

"""Tests for zot.db — Zotero SQLite ORM."""
from __future__ import annotations
import sqlite3
import pytest
from zot.db import (
ALLOWED_KEY_CHARS,
CREATOR_TYPES,
FIELD_MAP,
TYPE_MAP,
Db,
generate_key,
is_valid_key,
normalize_date,
now_iso,
)
# ── Zotero schema for tests (minimal but sufficient) ────────────
ZOTERO_SCHEMA = """
CREATE TABLE IF NOT EXISTS libraries (
libraryID INTEGER PRIMARY KEY,
type TEXT NOT NULL,
editable INT NOT NULL,
filesEditable INT NOT NULL,
version INT NOT NULL DEFAULT 0,
storageVersion INT NOT NULL DEFAULT 0,
lastSync INT NOT NULL DEFAULT 0,
archived INT NOT NULL DEFAULT 0
);
INSERT OR IGNORE INTO libraries VALUES (1, 'user', 1, 1, 0, 0, 0, 0);
CREATE TABLE IF NOT EXISTS items (
itemID INTEGER PRIMARY KEY,
itemTypeID INT NOT NULL,
dateAdded TEXT,
dateModified TEXT,
clientDateModified TEXT,
libraryID INT NOT NULL DEFAULT 1,
key TEXT NOT NULL UNIQUE,
version INT DEFAULT 0,
synced INT DEFAULT 0
);
CREATE TABLE IF NOT EXISTS itemDataValues (
valueID INTEGER PRIMARY KEY AUTOINCREMENT,
value TEXT NOT NULL UNIQUE
);
CREATE TABLE IF NOT EXISTS itemData (
itemID INT NOT NULL,
fieldID INT NOT NULL,
valueID INT NOT NULL,
PRIMARY KEY (itemID, fieldID)
);
CREATE TABLE IF NOT EXISTS fields (
fieldID INTEGER PRIMARY KEY,
fieldName TEXT,
fieldFormatID INT
);
CREATE TABLE IF NOT EXISTS tags (
tagID INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE
);
CREATE TABLE IF NOT EXISTS itemTags (
itemID INT NOT NULL,
tagID INT NOT NULL,
type INT DEFAULT 0,
PRIMARY KEY (itemID, tagID)
);
CREATE TABLE IF NOT EXISTS creators (
creatorID INTEGER PRIMARY KEY AUTOINCREMENT,
firstName TEXT,
lastName TEXT,
fieldMode INT,
UNIQUE (lastName, firstName, fieldMode)
);
CREATE TABLE IF NOT EXISTS creatorTypes (
creatorTypeID INTEGER PRIMARY KEY,
creatorType TEXT
);
CREATE TABLE IF NOT EXISTS itemCreators (
itemID INT NOT NULL,
creatorID INT NOT NULL,
creatorTypeID INT NOT NULL DEFAULT 1,
orderIndex INT NOT NULL DEFAULT 0,
PRIMARY KEY (itemID, creatorID, creatorTypeID, orderIndex),
UNIQUE (itemID, orderIndex)
);
CREATE TABLE IF NOT EXISTS collections (
collectionID INTEGER PRIMARY KEY AUTOINCREMENT,
collectionName TEXT NOT NULL,
parentCollectionID INT DEFAULT NULL,
clientDateModified TEXT,
libraryID INT DEFAULT 1,
key TEXT NOT NULL UNIQUE,
version INT DEFAULT 0,
synced INT DEFAULT 0
);
CREATE TABLE IF NOT EXISTS collectionItems (
collectionID INT NOT NULL,
itemID INT NOT NULL,
orderIndex INT NOT NULL DEFAULT 0,
PRIMARY KEY (collectionID, itemID)
);
CREATE TABLE IF NOT EXISTS itemAttachments (
itemID INTEGER PRIMARY KEY,
parentItemID INT,
linkMode INT,
contentType TEXT,
charsetID INT,
path TEXT,
syncState INT DEFAULT 0,
storageModTime INT,
storageHash TEXT
);
CREATE TABLE IF NOT EXISTS itemNotes (
itemID INTEGER PRIMARY KEY,
parentItemID INT,
note TEXT,
title TEXT
);
CREATE TABLE IF NOT EXISTS itemTypes (
itemTypeID INTEGER PRIMARY KEY,
typeName TEXT
);
CREATE TABLE IF NOT EXISTS creatorTypes (
creatorTypeID INTEGER PRIMARY KEY,
creatorType TEXT
);
"""
def _seed_schema_maps(con: sqlite3.Connection) -> None:
"""Populate lookup tables so ``_verify_schema_parity`` passes."""
for name, tid in TYPE_MAP.items():
con.execute(
"INSERT OR IGNORE INTO itemTypes (itemTypeID, typeName) VALUES (?, ?)",
(tid, name),
)
for name, fid in FIELD_MAP.items():
con.execute(
"INSERT OR IGNORE INTO fields (fieldID, fieldName) VALUES (?, ?)",
(fid, name),
)
for name, cid in CREATOR_TYPES.items():
con.execute(
"INSERT OR IGNORE INTO creatorTypes (creatorTypeID, creatorType) VALUES (?, ?)",
(cid, name),
)
def _seed_fields(con: sqlite3.Connection) -> None:
"""Insert field definitions so get_fields() can join by name."""
for name, fid in FIELD_MAP.items():
con.execute(
"INSERT OR IGNORE INTO fields (fieldID, fieldName) VALUES (?, ?)",
(fid, name),
)
for name, cid in CREATOR_TYPES.items():
con.execute(
"INSERT OR IGNORE INTO creatorTypes (creatorTypeID, creatorType) "
"VALUES (?, ?)",
(cid, name),
)
con.commit()
@pytest.fixture()
def db(tmp_path) -> Db:
path = str(tmp_path / "zotero.sqlite")
con = sqlite3.connect(path)
con.executescript(ZOTERO_SCHEMA)
# Seed lookup tables BEFORE Db.__init__ so _verify_schema_parity passes.
_seed_schema_maps(con)
con.commit()
con.close()
d = Db(path)
yield d
d.close()
# ── Key utilities ────────────────────────────────────────────────
class TestKeyUtilities:
def test_generate_key_length(self):
assert len(generate_key()) == 8
def test_generate_key_charset(self):
# Pinned to Zotero.Utilities.allowedKeyChars in zotero/utilities.js.
# If this drifts again, the live Zotero UI flags every offending key
# as "not a valid key" — see prior incident around char L.
assert ALLOWED_KEY_CHARS == "23456789ABCDEFGHIJKMNPQRSTUVWXYZ"
for _ in range(200):
key = generate_key()
assert all(c in ALLOWED_KEY_CHARS for c in key), key
def test_is_valid_key_accepts_good(self):
assert is_valid_key("ABCD2345")
def test_is_valid_key_rejects_short(self):
assert not is_valid_key("SHORT")
def test_is_valid_key_rejects_lowercase(self):
assert not is_valid_key("abcd2345")
def test_is_valid_key_rejects_zero(self):
assert not is_valid_key("0BCD2345")
def test_is_valid_key_rejects_l(self):
assert not is_valid_key("LBCD2345")
def test_is_valid_key_rejects_o(self):
assert not is_valid_key("OBCD2345")
def test_now_iso_format(self):
result = now_iso()
assert " " in result
assert "T" not in result
assert not result.endswith("Z")
assert len(result) == 19
def test_normalize_date(self):
assert normalize_date("2026-01-01T12:00:00Z") == "2026-01-01 12:00:00"
assert normalize_date("2026-01-01") == "2026-01-01"
# ── Items ────────────────────────────────────────────────────────
class TestItems:
def test_create_item(self, db: Db):
item_id = db.create_item(TYPE_MAP["statute"])
assert isinstance(item_id, int)
assert item_id > 0
def test_create_item_with_key(self, db: Db):
item_id = db.create_item(TYPE_MAP["statute"], key="MNPQ2345")
assert item_id > 0
assert db.find_item_by_key("MNPQ2345") == item_id
def test_create_item_invalid_key_generates_new(self, db: Db):
item_id = db.create_item(TYPE_MAP["statute"], key="bad")
assert item_id > 0
def test_key_collision_retry(self, db: Db):
db.create_item(TYPE_MAP["statute"], key="CXYZ2345")
# Second item with same key should get a different key
item_id2 = db.create_item(TYPE_MAP["statute"], key="CXYZ2345")
assert item_id2 > 0
def test_key_exists(self, db: Db):
db.create_item(TYPE_MAP["statute"], key="ABCD2345")
assert db.key_exists("ABCD2345")
assert not db.key_exists("ZZZZ8888")
def test_find_item_by_key(self, db: Db):
item_id = db.create_item(TYPE_MAP["report"], key="EFGH5678")
assert db.find_item_by_key("EFGH5678") == item_id
assert db.find_item_by_key("ZZZZ9999") is None
def test_find_item_by_url(self, db: Db):
item_id = db.create_item(TYPE_MAP["webpage"])
db.set_field(item_id, "url", "https://example.com/test")
assert db.find_item_by_url("https://example.com/test") == item_id
assert db.find_item_by_url("https://example.com/nope") is None
def test_get_item_type(self, db: Db):
item_id = db.create_item(TYPE_MAP["document"])
assert db.get_item_type(item_id) == TYPE_MAP["document"]
# ── EAV Fields ───────────────────────────────────────────────────
class TestFields:
def test_ensure_value_creates(self, db: Db):
vid = db.ensure_value("test value")
assert isinstance(vid, int)
def test_ensure_value_deduplicates(self, db: Db):
v1 = db.ensure_value("same")
v2 = db.ensure_value("same")
assert v1 == v2
def test_set_and_get_field(self, db: Db):
item_id = db.create_item(TYPE_MAP["statute"])
db.set_field(item_id, "nameOfAct", "PFS Final Rule")
assert db.get_field(item_id, "nameOfAct") == "PFS Final Rule"
def test_set_field_skips_empty(self, db: Db):
item_id = db.create_item(TYPE_MAP["statute"])
db.set_field(item_id, "title", "")
assert db.get_field(item_id, "title") is None
def test_set_field_skips_unknown(self, db: Db):
item_id = db.create_item(TYPE_MAP["statute"])
db.set_field(item_id, "nonexistent", "value")
# Should not raise, just no-op
def test_set_field_normalizes_date(self, db: Db):
item_id = db.create_item(TYPE_MAP["statute"])
db.set_field(item_id, "accessDate", "2026-01-01T12:00:00Z")
assert db.get_field(item_id, "accessDate") == "2026-01-01 12:00:00"
def test_set_fields_batch(self, db: Db):
item_id = db.create_item(TYPE_MAP["report"])
db.set_fields(
item_id,
{
"title": "Test Report",
"institution": "CMS",
"date": "2026-01-01",
},
)
assert db.get_field(item_id, "title") == "Test Report"
assert db.get_field(item_id, "institution") == "CMS"
def test_get_fields_all(self, db: Db):
item_id = db.create_item(TYPE_MAP["statute"])
db.set_fields(item_id, {"nameOfAct": "Rule X", "code": "FR"})
fields = db.get_fields(item_id)
assert fields["nameOfAct"] == "Rule X"
assert fields["code"] == "FR"
def test_set_field_overwrites(self, db: Db):
item_id = db.create_item(TYPE_MAP["statute"])
db.set_field(item_id, "nameOfAct", "v1")
db.set_field(item_id, "nameOfAct", "v2")
assert db.get_field(item_id, "nameOfAct") == "v2"
# ── Creators ─────────────────────────────────────────────────────
class TestCreators:
def test_ensure_creator(self, db: Db):
cid = db.ensure_creator("John", "Doe")
assert isinstance(cid, int)
# Idempotent
assert db.ensure_creator("John", "Doe") == cid
def test_add_creators(self, db: Db):
item_id = db.create_item(TYPE_MAP["journalArticle"])
count = db.add_creators(item_id, [("Jane", "Smith"), ("Bob", "Lee")])
assert count == 2
def test_get_creators(self, db: Db):
item_id = db.create_item(TYPE_MAP["journalArticle"])
db.add_creators(item_id, [("Jane", "Smith"), ("Bob", "Lee")])
creators = db.get_creators(item_id)
assert len(creators) == 2
assert creators[0]["firstName"] == "Jane"
assert creators[0]["lastName"] == "Smith"
assert creators[0]["creatorType"] == "author"
assert creators[1]["firstName"] == "Bob"
def test_add_creators_editor(self, db: Db):
item_id = db.create_item(TYPE_MAP["book"])
db.add_creators(item_id, [("Ed", "Itor")], creator_type="editor")
creators = db.get_creators(item_id)
assert creators[0]["creatorType"] == "editor"
# ── Tags ─────────────────────────────────────────────────────────
class TestTags:
def test_ensure_tag(self, db: Db):
tid = db.ensure_tag("module:pfs")
assert isinstance(tid, int)
assert db.ensure_tag("module:pfs") == tid
def test_tag_item(self, db: Db):
item_id = db.create_item(TYPE_MAP["statute"])
db.tag_item(item_id, "year:2026")
assert "year:2026" in db.get_tags(item_id)
def test_tag_item_skips_blank(self, db: Db):
item_id = db.create_item(TYPE_MAP["statute"])
db.tag_item(item_id, "")
assert db.get_tags(item_id) == []
def test_sync_tags(self, db: Db):
item_id = db.create_item(TYPE_MAP["statute"])
db.sync_tags(item_id, ["a", "b", "", "c"])
tags = db.get_tags(item_id)
assert set(tags) == {"a", "b", "c"}
def test_sync_tags_idempotent(self, db: Db):
item_id = db.create_item(TYPE_MAP["statute"])
db.sync_tags(item_id, ["x", "y"])
db.sync_tags(item_id, ["x", "y"]) # no duplicates
assert len(db.get_tags(item_id)) == 2
# ── Collections ──────────────────────────────────────────────────
class TestCollections:
def test_ensure_collection_creates(self, db: Db):
key = db.ensure_collection("Test Collection")
assert is_valid_key(key)
def test_ensure_collection_finds_existing(self, db: Db):
k1 = db.ensure_collection("Dedup")
k2 = db.ensure_collection("Dedup")
assert k1 == k2
def test_ensure_collection_with_parent(self, db: Db):
parent_key = db.ensure_collection("Parent")
child_key = db.ensure_collection("Child", parent_key=parent_key)
assert parent_key != child_key
# Find again
assert db.ensure_collection("Child", parent_key=parent_key) == child_key
def test_add_to_collection(self, db: Db):
item_id = db.create_item(TYPE_MAP["statute"])
key = db.ensure_collection("My Collection")
assert db.add_to_collection(item_id, collection_key=key)
assert key in db.get_item_collections(item_id)
def test_add_to_collection_missing(self, db: Db):
item_id = db.create_item(TYPE_MAP["statute"])
assert not db.add_to_collection(item_id, collection_key="ZZZZ7777")
def test_find_collection(self, db: Db):
key = db.ensure_collection("Findable")
assert db.find_collection(key) is not None
assert db.find_collection("ZZZZ6666") is None
# ── Attachments & Notes ──────────────────────────────────────────
class TestAttachmentsAndNotes:
def test_add_attachment(self, db: Db):
parent = db.create_item(TYPE_MAP["journalArticle"])
att_id = db.add_attachment(
parent, content_type="application/pdf", path="storage/test.pdf"
)
assert att_id > 0
assert db.get_item_type(att_id) == TYPE_MAP["attachment"]
def test_add_note(self, db: Db):
parent = db.create_item(TYPE_MAP["statute"])
note_id = db.add_note(parent, "This is a note", title="My Note")
assert note_id > 0
assert db.get_item_type(note_id) == TYPE_MAP["note"]
# ── Counting ─────────────────────────────────────────────────────
class TestCounting:
def test_count_items(self, db: Db):
assert db.count_items() == 0
db.create_item(TYPE_MAP["statute"])
db.create_item(TYPE_MAP["report"])
assert db.count_items() == 2
assert db.count_items(TYPE_MAP["statute"]) == 1
def test_count_tags(self, db: Db):
assert db.count_tags() == 0
db.ensure_tag("a")
db.ensure_tag("b")
assert db.count_tags() == 2
def test_count_creators(self, db: Db):
assert db.count_creators() == 0
db.ensure_creator("J", "D")
assert db.count_creators() == 1
def test_count_collections(self, db: Db):
assert db.count_collections() == 0
db.ensure_collection("C1")
assert db.count_collections() == 1
# ── Context manager ──────────────────────────────────────────────
class TestContextManager:
def test_with_statement(self, tmp_path):
path = str(tmp_path / "zotero.sqlite")
con = sqlite3.connect(path)
con.executescript(ZOTERO_SCHEMA)
con.close()
with Db(path) as db:
_seed_fields(db.con)
item_id = db.create_item(TYPE_MAP["statute"], key="IJKK3456")
db.set_field(item_id, "nameOfAct", "Context Test")
db.commit()
# Verify data persists after close
with Db(path) as db:
_seed_fields(db.con)
assert db.find_item_by_key("IJKK3456") is not None
assert (
db.get_field(db.find_item_by_key("IJKK3456"), "nameOfAct")
== "Context Test"
)
# ── Constant completeness ───────────────────────────────────────
class TestConstants:
def test_type_map_has_all_standard_types(self):
assert len(TYPE_MAP) == 40
assert "journalArticle" in TYPE_MAP
assert "statute" in TYPE_MAP
assert "report" in TYPE_MAP
assert "webpage" in TYPE_MAP
assert "document" in TYPE_MAP
def test_field_map_has_core_fields(self):
assert len(FIELD_MAP) >= 100
assert FIELD_MAP["title"] == 1
assert FIELD_MAP["url"] == 10
assert FIELD_MAP["abstractNote"] == 2
assert FIELD_MAP["extra"] == 19
assert FIELD_MAP["DOI"] == 8
def test_creator_types_complete(self):
assert len(CREATOR_TYPES) == 37
assert CREATOR_TYPES["author"] == 10
assert CREATOR_TYPES["editor"] == 12
# ── Structured reads ─────────────────────────────────────────────
class TestGetItem:
def test_get_item_full(self, db: Db):
item_id = db.create_item(TYPE_MAP["statute"])
db.set_fields(item_id, {"nameOfAct": "Test Act", "code": "FR"})
db.sync_tags(item_id, ["test:tag"])
db.add_creators(item_id, [("Jane", "Doe")])
item = db.get_item(item_id)
assert item is not None
assert item["itemType"] == "statute"
assert item["fields"]["nameOfAct"] == "Test Act"
assert "test:tag" in item["tags"]
assert item["creators"][0]["lastName"] == "Doe"
def test_get_item_not_found(self, db: Db):
assert db.get_item(99999) is None
def test_get_item_by_key(self, db: Db):
db.create_item(TYPE_MAP["report"], key="QRST5678")
item = db.get_item_by_key("QRST5678")
assert item is not None
assert item["itemType"] == "report"
def test_get_item_by_key_missing(self, db: Db):
assert db.get_item_by_key("ZZZZ2222") is None
# ── Search ───────────────────────────────────────────────────────
class TestSearch:
def test_search_by_tag(self, db: Db):
id1 = db.create_item(TYPE_MAP["statute"])
id2 = db.create_item(TYPE_MAP["report"])
db.tag_item(id1, "module:pfs")
db.tag_item(id2, "module:pfs")
assert set(db.search_by_tag("module:pfs")) == {id1, id2}
def test_search_by_tag_limit(self, db: Db):
for _ in range(5):
iid = db.create_item(TYPE_MAP["statute"])
db.tag_item(iid, "bulk")
assert len(db.search_by_tag("bulk", limit=3)) == 3
def test_search_by_type(self, db: Db):
db.create_item(TYPE_MAP["statute"])
db.create_item(TYPE_MAP["report"])
db.create_item(TYPE_MAP["statute"])
assert len(db.search_by_type("statute")) == 2
def test_search_by_field_exact(self, db: Db):
id1 = db.create_item(TYPE_MAP["webpage"])
db.set_field(id1, "url", "https://example.com")
assert db.search_by_field("url", "https://example.com") == [id1]
def test_search_by_field_like(self, db: Db):
id1 = db.create_item(TYPE_MAP["webpage"])
db.set_field(id1, "url", "https://example.com/page")
assert db.search_by_field("url", "example.com", exact=False) == [id1]
def test_search_by_collection(self, db: Db):
key = db.ensure_collection("TestCol")
id1 = db.create_item(TYPE_MAP["statute"])
db.add_to_collection(id1, collection_key=key)
assert db.search_by_collection(key) == [id1]
def test_combined_search(self, db: Db):
id1 = db.create_item(TYPE_MAP["statute"])
id2 = db.create_item(TYPE_MAP["statute"])
db.tag_item(id1, "findme")
db.tag_item(id2, "other")
result = db.search(tag="findme", type_name="statute")
assert result == [id1]
def test_search_unknown_type(self, db: Db):
assert db.search_by_type("nonexistent") == []
def test_search_unknown_field(self, db: Db):
assert db.search_by_field("nonexistent", "val") == []
# ── Bulk operations ──────────────────────────────────────────────
class TestBulkOps:
def test_bulk_create(self, db: Db):
ids = db.bulk_create(
[
{"type": "statute", "fields": {"nameOfAct": "Act A"}, "tags": ["a"]},
{"type": "report", "fields": {"title": "Report B"}, "tags": ["b"]},
{"type": "webpage", "fields": {"title": "Page C"}},
]
)
assert len(ids) == 3
assert db.count_items() == 3
assert db.get_field(ids[0], "nameOfAct") == "Act A"
def test_bulk_create_with_creators(self, db: Db):
ids = db.bulk_create(
[
{
"type": "journalArticle",
"fields": {"title": "Paper"},
"creators": [("Jane", "Smith"), ("Bob", "Lee")],
},
]
)
assert len(db.get_creators(ids[0])) == 2
def test_bulk_create_skips_unknown_type(self, db: Db):
ids = db.bulk_create([{"type": "nonexistent"}])
assert ids == []
def test_export_items(self, db: Db):
db.bulk_create(
[
{"type": "statute", "fields": {"nameOfAct": "Act"}, "tags": ["t1"]},
]
)
exported = db.export_items()
assert len(exported) == 1
assert exported[0]["itemType"] == "statute"
assert exported[0]["fields"]["nameOfAct"] == "Act"
assert "t1" in exported[0]["tags"]
def test_export_specific_ids(self, db: Db):
ids = db.bulk_create(
[
{"type": "statute", "fields": {"nameOfAct": "A"}},
{"type": "report", "fields": {"title": "B"}},
]
)
exported = db.export_items([ids[0]])
assert len(exported) == 1
# ── Delete ───────────────────────────────────────────────────────
class TestDelete:
def test_delete_item(self, db: Db):
item_id = db.create_item(TYPE_MAP["statute"])
db.set_field(item_id, "nameOfAct", "Doomed")
db.tag_item(item_id, "rip")
assert db.delete_item(item_id)
assert db.get_item(item_id) is None
assert db.count_items() == 0
def test_delete_nonexistent(self, db: Db):
assert not db.delete_item(99999)
def test_delete_with_attachment(self, db: Db):
parent = db.create_item(TYPE_MAP["statute"])
db.add_attachment(parent, content_type="application/pdf")
assert db.count_items() == 2
assert db.delete_item(parent)
# attachment item still exists (orphaned)
# ── Stats ────────────────────────────────────────────────────────
class TestStats:
def test_stats_empty(self, db: Db):
s = db.stats()
assert s["items"] == 0
assert s["tags"] == 0
def test_stats_after_create(self, db: Db):
db.bulk_create(
[
{"type": "statute", "fields": {"nameOfAct": "X"}, "tags": ["y"]},
]
)
s = db.stats()
assert s["items"] == 1
assert s["tags"] == 1
assert s["data_rows"] >= 1
# ── Gap coverage — missed lines ────────────────────────────────
class TestSchemaDrift:
"""Lines 326-327: _verify_schema_parity raises on drift."""
def test_drift_raises(self, tmp_path):
path = str(tmp_path / "zotero.sqlite")
con = sqlite3.connect(path)
con.executescript(ZOTERO_SCHEMA)
_seed_schema_maps(con)
# Introduce drift: change a known type's ID
con.execute(
"UPDATE itemTypes SET itemTypeID = 999 WHERE typeName = 'attachment'"
)
con.commit()
con.close()
with pytest.raises(RuntimeError, match="schema drift"):
Db(path)
class TestGetFieldUnknown:
"""Line 455: get_field returns None for unknown field name."""
def test_unknown_field_name(self, db: Db):
item_id = db.create_item(TYPE_MAP["statute"])
assert db.get_field(item_id, "nonexistent_field") is None
class TestSearchWithLimit:
"""Lines 781, 806, 816: search_by_type/field/collection with limit."""
def test_search_by_type_limit(self, db: Db):
for _ in range(5):
db.create_item(TYPE_MAP["statute"])
assert len(db.search_by_type("statute", limit=3)) == 3
def test_search_by_field_limit(self, db: Db):
for i in range(5):
iid = db.create_item(TYPE_MAP["webpage"])
db.set_field(iid, "url", "https://same.com")
results = db.search_by_field("url", "https://same.com", limit=2)
assert len(results) == 2
def test_search_by_collection_limit(self, db: Db):
key = db.ensure_collection("LimitCol")
for _ in range(5):
iid = db.create_item(TYPE_MAP["statute"])
db.add_to_collection(iid, collection_key=key)
results = db.search_by_collection(key, limit=3)
assert len(results) == 3
class TestSearchCombined:
"""Lines 835, 837, 839: search() with field and collection_key criteria."""
def test_search_with_field(self, db: Db):
iid = db.create_item(TYPE_MAP["webpage"])
db.set_field(iid, "url", "https://unique.com/abc")
result = db.search(field=("url", "https://unique.com/abc"))
assert result == [iid]
def test_search_with_collection_key(self, db: Db):
key = db.ensure_collection("SearchCol")
iid = db.create_item(TYPE_MAP["statute"])
db.add_to_collection(iid, collection_key=key)
result = db.search(collection_key=key)
assert result == [iid]
def test_search_combined_all_criteria(self, db: Db):
key = db.ensure_collection("AllCritCol")
iid = db.create_item(TYPE_MAP["statute"])
db.set_field(iid, "nameOfAct", "TargetAct")
db.tag_item(iid, "marker:yes")
db.add_to_collection(iid, collection_key=key)
result = db.search(
tag="marker:yes",
type_name="statute",
field=("nameOfAct", "TargetAct"),
collection_key=key,
)
assert result == [iid]
class TestValidation:
"""Lines 855, 859, 868: valid_creator_types_for and validate_item."""
def test_validate_item_nonexistent(self, db: Db):
issues = db.validate_item(99999)
assert any("does not exist" in i for i in issues)
def test_valid_creator_types_for(self):
"""Requires real combined views — use create_db."""
from zot.schema import create_db
path = ":memory:"
con = create_db(path)
# Use a raw Db that reuses this connection
db = Db.__new__(Db)
db.path = path
db.con = con
db.con.row_factory = sqlite3.Row
# statute (36) has author, etc
result = db.valid_creator_types_for(TYPE_MAP["statute"])
assert isinstance(result, set)
# Should have at least author
assert len(result) >= 1
con.close()
class TestFindItemByUrlDeterministic:
def test_returns_lowest_item_id(self, tmp_path):
from zot.db import TYPE_MAP, Db
from zot.schema import create_db
p = tmp_path / "z.sqlite"
create_db(str(p)).close()
with Db(str(p)) as db:
a = db.create_item(TYPE_MAP["document"], key="AAAAAAAA")
b = db.create_item(TYPE_MAP["document"], key="BBBBBBBB")
# insert the URL row for the HIGHER itemID first so a
# scan-order query would return it
db.set_field(b, "url", "https://example.org/det")
db.set_field(a, "url", "https://example.org/det")
db.commit()
assert db.find_item_by_url("https://example.org/det") == a