"""Tests for scripts/adapters/zotero.py.""" from pathlib import Path import subprocess import json REPO_ROOT = Path(__file__).resolve().parents[3] ADAPTER = REPO_ROOT / "scripts/adapters/zotero.py" FIXTURE_INPUT = REPO_ROOT / "scripts/adapters/examples/zotero/input_fixture/export.json" EXPECTED_PASSPORT = REPO_ROOT / "scripts/adapters/examples/zotero/expected_passport.yaml" EXPECTED_REJECTION = REPO_ROOT / "scripts/adapters/examples/zotero/expected_rejection_log.yaml" def _run(*args): return subprocess.run( ["python", str(ADAPTER)] + list(args), capture_output=True, text=True, cwd=REPO_ROOT, ) def test_adapter_exists(): assert ADAPTER.exists() def test_happy_path(tmp_path, load_yaml, clean_timestamps): p = tmp_path / "p.yaml" r = tmp_path / "r.yaml" res = _run("--input", str(FIXTURE_INPUT), "--passport", str(p), "--rejection-log", str(r)) assert res.returncode == 0, res.stderr assert clean_timestamps(load_yaml(p)) == clean_timestamps(load_yaml(EXPECTED_PASSPORT)) assert clean_timestamps(load_yaml(r)) == clean_timestamps(load_yaml(EXPECTED_REJECTION)) def test_malformed_json_fails_loud(tmp_path): bad = tmp_path / "bad.json" bad.write_text("{not json", encoding="utf-8") p = tmp_path / "p.yaml" r = tmp_path / "r.yaml" res = _run("--input", str(bad), "--passport", str(p), "--rejection-log", str(r)) assert res.returncode == 1 assert "json" in res.stderr.lower() or "parse" in res.stderr.lower() def test_empty_array_emits_empty_passport(tmp_path, load_yaml): empty = tmp_path / "empty.json" empty.write_text("[]", encoding="utf-8") p = tmp_path / "p.yaml" r = tmp_path / "r.yaml" res = _run("--input", str(empty), "--passport", str(p), "--rejection-log", str(r)) assert res.returncode == 0 doc = load_yaml(p) assert doc == {"literature_corpus": []} def test_institution_author_preserved(tmp_path, load_yaml): data = [ { "citationKey": "who2024", "itemType": "report", "title": "World report", "creators": [{"creatorType": "author", "name": "World Health Organization"}], "date": "2024", "itemID": "AAAA1111", } ] infile = tmp_path / "i.json" infile.write_text(json.dumps(data), encoding="utf-8") p = tmp_path / "p.yaml" r = tmp_path / "r.yaml" res = _run("--input", str(infile), "--passport", str(p), "--rejection-log", str(r)) assert res.returncode == 0 doc = load_yaml(p) assert doc["literature_corpus"][0]["authors"] == [{"literal": "World Health Organization"}] def test_no_authors_rejected(tmp_path, load_yaml): data = [ { "citationKey": "anon2024", "itemType": "journalArticle", "title": "Untitled", "creators": [{"creatorType": "editor", "firstName": "A", "lastName": "B"}], "date": "2024", "itemID": "BBBB1111", } ] infile = tmp_path / "i.json" infile.write_text(json.dumps(data), encoding="utf-8") p = tmp_path / "p.yaml" r = tmp_path / "r.yaml" res = _run("--input", str(infile), "--passport", str(p), "--rejection-log", str(r)) assert res.returncode == 0 pp = load_yaml(p) rr = load_yaml(r) assert pp["literature_corpus"] == [] assert len(rr["rejected"]) == 1 assert rr["rejected"][0]["reason"] == "authors_unparseable" def test_unparseable_date_rejected(tmp_path, load_yaml): data = [ { "citationKey": "fwd2024", "itemType": "journalArticle", "title": "Forthcoming", "creators": [{"creatorType": "author", "firstName": "A", "lastName": "B"}], "date": "forthcoming", "itemID": "CCCC1111", } ] infile = tmp_path / "i.json" infile.write_text(json.dumps(data), encoding="utf-8") p = tmp_path / "p.yaml" r = tmp_path / "r.yaml" res = _run("--input", str(infile), "--passport", str(p), "--rejection-log", str(r)) assert res.returncode == 0 rr = load_yaml(r) assert rr["rejected"][0]["reason"] == "year_unparseable" # --------------------------------------------------------------------------- # P1 — schema-violating empty values # --------------------------------------------------------------------------- def test_blank_corporate_author_rejected(tmp_path, load_yaml): """Blank literal author (name='') must be rejected, not emit literal: ''.""" data = [ { "citationKey": "blank_corp2024", "itemType": "report", "title": "Some report", "creators": [{"creatorType": "author", "name": " "}], "date": "2024", "itemID": "ZZZZ0001", } ] infile = tmp_path / "i.json" infile.write_text(json.dumps(data), encoding="utf-8") p = tmp_path / "p.yaml" r = tmp_path / "r.yaml" res = _run("--input", str(infile), "--passport", str(p), "--rejection-log", str(r)) assert res.returncode == 0 pp = load_yaml(p) rr = load_yaml(r) assert pp["literature_corpus"] == [], "blank corporate author must not reach passport" assert len(rr["rejected"]) == 1 assert rr["rejected"][0]["reason"] == "missing_required_field" assert "authors" in rr["rejected"][0]["missing_fields"] def test_missing_title_rejected(tmp_path, load_yaml): """Item without a title must be rejected with missing_required_field.""" data = [ { "citationKey": "notitle2024", "itemType": "journalArticle", "creators": [{"creatorType": "author", "firstName": "A", "lastName": "B"}], "date": "2024", "itemID": "ZZZZ0002", } ] infile = tmp_path / "i.json" infile.write_text(json.dumps(data), encoding="utf-8") p = tmp_path / "p.yaml" r = tmp_path / "r.yaml" res = _run("--input", str(infile), "--passport", str(p), "--rejection-log", str(r)) assert res.returncode == 0 pp = load_yaml(p) rr = load_yaml(r) assert pp["literature_corpus"] == [], "missing title must not reach passport" assert len(rr["rejected"]) == 1 assert rr["rejected"][0]["reason"] == "missing_required_field" assert "title" in rr["rejected"][0]["missing_fields"] def test_missing_citation_key_rejected(tmp_path, load_yaml): """Item without citationKey and without key must be rejected.""" data = [ { "itemType": "journalArticle", "title": "No citekey paper", "creators": [{"creatorType": "author", "firstName": "A", "lastName": "B"}], "date": "2024", "itemID": "ZZZZ0003", } ] infile = tmp_path / "i.json" infile.write_text(json.dumps(data), encoding="utf-8") p = tmp_path / "p.yaml" r = tmp_path / "r.yaml" res = _run("--input", str(infile), "--passport", str(p), "--rejection-log", str(r)) assert res.returncode == 0 pp = load_yaml(p) rr = load_yaml(r) assert pp["literature_corpus"] == [], "missing citationKey must not reach passport" assert len(rr["rejected"]) == 1 assert rr["rejected"][0]["reason"] == "missing_required_field" assert "citation_key" in rr["rejected"][0]["missing_fields"] # --------------------------------------------------------------------------- # P2-A — duplicate citekey handling # --------------------------------------------------------------------------- def test_duplicate_citekey_disambiguated(tmp_path, load_yaml): """Two items with the same citationKey must both be accepted with suffixed keys.""" data = [ { "citationKey": "smith2024", "itemType": "journalArticle", "title": "First paper", "creators": [{"creatorType": "author", "firstName": "Alice", "lastName": "Smith"}], "date": "2024", "itemID": "DUP10001", }, { "citationKey": "smith2024", "itemType": "journalArticle", "title": "Second paper", "creators": [{"creatorType": "author", "firstName": "Alice", "lastName": "Smith"}], "date": "2024", "itemID": "DUP10002", }, ] infile = tmp_path / "i.json" infile.write_text(json.dumps(data), encoding="utf-8") p = tmp_path / "p.yaml" r = tmp_path / "r.yaml" res = _run("--input", str(infile), "--passport", str(p), "--rejection-log", str(r)) assert res.returncode == 0 pp = load_yaml(p) entries = pp["literature_corpus"] assert len(entries) == 2, "both items must be accepted" keys = {e["citation_key"] for e in entries} assert len(keys) == 2, f"citation keys must be unique, got: {keys}" # first item keeps original key, second gets a suffix assert "smith2024" in keys assert any(k.startswith("smith2024") and k != "smith2024" for k in keys) # --------------------------------------------------------------------------- # P2-B — issued fallback + seasonal date rejection + source_pointer contract # --------------------------------------------------------------------------- def test_issued_fallback_no_date_field(tmp_path, load_yaml): """Item with only `issued` (no `date`) must resolve year from issued.""" data = [ { "citationKey": "issued2023", "itemType": "journalArticle", "title": "Issued-only paper", "creators": [{"creatorType": "author", "firstName": "A", "lastName": "B"}], "issued": {"date-parts": [[2023]]}, "itemID": "ISS10001", } ] infile = tmp_path / "i.json" infile.write_text(json.dumps(data), encoding="utf-8") p = tmp_path / "p.yaml" r = tmp_path / "r.yaml" res = _run("--input", str(infile), "--passport", str(p), "--rejection-log", str(r)) assert res.returncode == 0 pp = load_yaml(p) entries = pp["literature_corpus"] assert len(entries) == 1 assert entries[0]["year"] == 2023 def test_issued_as_string_fallback(tmp_path, load_yaml): """Item with `issued` as string (no `date`) must resolve year from issued string.""" data = [ { "citationKey": "issued2022", "itemType": "journalArticle", "title": "Issued string paper", "creators": [{"creatorType": "author", "firstName": "A", "lastName": "B"}], "issued": "2022-06-15", "itemID": "ISS10002", } ] infile = tmp_path / "i.json" infile.write_text(json.dumps(data), encoding="utf-8") p = tmp_path / "p.yaml" r = tmp_path / "r.yaml" res = _run("--input", str(infile), "--passport", str(p), "--rejection-log", str(r)) assert res.returncode == 0 pp = load_yaml(p) entries = pp["literature_corpus"] assert len(entries) == 1 assert entries[0]["year"] == 2022 def test_seasonal_date_rejected(tmp_path, load_yaml): """'Spring 2024' must be rejected — seasonal strings are not valid dates.""" data = [ { "citationKey": "spring2024", "itemType": "journalArticle", "title": "Spring paper", "creators": [{"creatorType": "author", "firstName": "A", "lastName": "B"}], "date": "Spring 2024", "itemID": "SEA10001", } ] infile = tmp_path / "i.json" infile.write_text(json.dumps(data), encoding="utf-8") p = tmp_path / "p.yaml" r = tmp_path / "r.yaml" res = _run("--input", str(infile), "--passport", str(p), "--rejection-log", str(r)) assert res.returncode == 0 pp = load_yaml(p) rr = load_yaml(r) assert pp["literature_corpus"] == [], "seasonal date must not reach passport" assert len(rr["rejected"]) == 1 assert rr["rejected"][0]["reason"] == "year_unparseable" def test_nd_date_rejected(tmp_path, load_yaml): """'n.d.' (no date) must be rejected.""" data = [ { "citationKey": "nodate2024", "itemType": "journalArticle", "title": "No date paper", "creators": [{"creatorType": "author", "firstName": "A", "lastName": "B"}], "date": "n.d.", "itemID": "ND100001", } ] infile = tmp_path / "i.json" infile.write_text(json.dumps(data), encoding="utf-8") p = tmp_path / "p.yaml" r = tmp_path / "r.yaml" res = _run("--input", str(infile), "--passport", str(p), "--rejection-log", str(r)) assert res.returncode == 0 pp = load_yaml(p) rr = load_yaml(r) assert pp["literature_corpus"] == [], "n.d. date must not reach passport" assert len(rr["rejected"]) == 1 assert rr["rejected"][0]["reason"] == "year_unparseable" def test_missing_source_pointer_rejected(tmp_path, load_yaml): """Item with neither itemID nor key must be rejected (no valid source_pointer).""" data = [ { "citationKey": "noptr2024", "itemType": "journalArticle", "title": "No pointer paper", "creators": [{"creatorType": "author", "firstName": "A", "lastName": "B"}], "date": "2024", } ] infile = tmp_path / "i.json" infile.write_text(json.dumps(data), encoding="utf-8") p = tmp_path / "p.yaml" r = tmp_path / "r.yaml" res = _run("--input", str(infile), "--passport", str(p), "--rejection-log", str(r)) assert res.returncode == 0 pp = load_yaml(p) rr = load_yaml(r) assert pp["literature_corpus"] == [], "item without source_pointer must not reach passport" assert len(rr["rejected"]) == 1 assert rr["rejected"][0]["reason"] == "missing_required_field" assert "source_pointer" in rr["rejected"][0]["missing_fields"] def test_zotero_key_used_as_source_pointer(tmp_path, load_yaml): """Item with Zotero `key` (8-char) but no itemID uses key as source_pointer.""" data = [ { "citationKey": "keyonly2024", "itemType": "journalArticle", "title": "Key-only paper", "creators": [{"creatorType": "author", "firstName": "A", "lastName": "B"}], "date": "2024", "key": "ABCD1234", } ] infile = tmp_path / "i.json" infile.write_text(json.dumps(data), encoding="utf-8") p = tmp_path / "p.yaml" r = tmp_path / "r.yaml" res = _run("--input", str(infile), "--passport", str(p), "--rejection-log", str(r)) assert res.returncode == 0 pp = load_yaml(p) entries = pp["literature_corpus"] assert len(entries) == 1 # source_pointer must use the Zotero key, not @citekey assert entries[0]["source_pointer"] == "zotero://select/items/ABCD1234" assert "@" not in entries[0]["source_pointer"] # --------------------------------------------------------------------------- # P3 — all-rejected corpus # --------------------------------------------------------------------------- def test_all_rejected_corpus(tmp_path, load_yaml): """When every item is rejected, passport must be empty and rejection_log populated.""" data = [ { "citationKey": "noauth2024", "itemType": "journalArticle", "title": "No author", "creators": [], "date": "2024", "itemID": "AR100001", }, { "citationKey": "noyr2024", "itemType": "journalArticle", "title": "No year", "creators": [{"creatorType": "author", "firstName": "A", "lastName": "B"}], "date": "forthcoming", "itemID": "AR100002", }, ] infile = tmp_path / "i.json" infile.write_text(json.dumps(data), encoding="utf-8") p = tmp_path / "p.yaml" r = tmp_path / "r.yaml" res = _run("--input", str(infile), "--passport", str(p), "--rejection-log", str(r)) assert res.returncode == 0 pp = load_yaml(p) rr = load_yaml(r) assert pp["literature_corpus"] == [] assert rr["summary"]["total_rejected"] == 2 # --- v3.10 venue_type mapping (spec §3 PR-B item 13) --- def _import_zotero(): import importlib.util spec = importlib.util.spec_from_file_location("zotero_adapter", ADAPTER) mod = importlib.util.module_from_spec(spec) spec.loader.exec_module(mod) return mod def test_zotero_map_venue_type_known_types(): z = _import_zotero() assert z.map_venue_type("journalArticle") == ("journal-article", "adapter_declared") assert z.map_venue_type("conferencePaper") == ("conference-paper", "adapter_declared") assert z.map_venue_type("book") == ("book", "adapter_declared") assert z.map_venue_type("thesis") == ("dissertation", "adapter_declared") def test_zotero_map_venue_type_unknown_and_absent(): z = _import_zotero() # Unknown item type → unknown/unknown (honors pair invariant; never guessed). assert z.map_venue_type("blogPost") == ("unknown", "unknown") assert z.map_venue_type(None) == ("unknown", "unknown") assert z.map_venue_type("") == ("unknown", "unknown")