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.
857 lines
29 KiB
Python
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
|