Files
git-automatic-tracker-py/tests/unit/test_extraction.py
T
2026-09-12 23:32:19 +02:00

186 lines
7.5 KiB
Python

"""Unit tests for the SQLite artifact extractor (Section 45).
Covers the Section 41 extraction test list: artifacts produced per
table, byte-identical output for unchanged content, deterministic
ordering and serialization, stale removal, failure policy, and
read-only source access.
"""
from __future__ import annotations
import sqlite3
from pathlib import Path
import pytest
from git_auto_tracker.errors import CollectionError
from git_auto_tracker.extraction.base import (
ExtractionError,
ExtractionSource,
artifact_filename,
artifact_folder,
to_jsonl,
write_if_changed,
)
from git_auto_tracker.extraction.sqlite import SqliteExtractor, extractor_for
def _make_db(path: Path, tables: dict[str, list[tuple[str, ...]]]) -> None:
"""Create the fixture database; idempotent for repeat extraction runs."""
connection = sqlite3.connect(path)
try:
for ddl in (
"CREATE TABLE IF NOT EXISTS alarms (id INTEGER PRIMARY KEY, text TEXT)",
"CREATE TABLE IF NOT EXISTS events "
"(id INTEGER PRIMARY KEY, source TEXT, value REAL)",
):
if ddl.split()[5] in tables:
connection.execute(ddl)
for table, rows in tables.items():
for row in rows:
connection.execute(
f'INSERT OR IGNORE INTO "{table}" VALUES ({",".join("?" * len(row))})',
row,
)
connection.commit()
finally:
connection.close()
def _read(path: Path) -> str:
return path.read_text(encoding="utf-8")
class TestHelpers:
def test_artifact_folder_named_after_database(self) -> None:
source = ExtractionSource(path="history/alarms.sqlite")
assert artifact_folder(source) == "alarms"
def test_artifact_filename_sanitized(self) -> None:
assert artifact_filename("alarms") == "alarms.jsonl"
assert artifact_filename("weird name") == "weird name.jsonl"
assert artifact_filename('bad"quote') == 'bad_quote.jsonl'
assert artifact_filename("com1") == "_com1.jsonl"
assert artifact_filename("") == "table.jsonl"
def test_to_jsonl_sorted_and_stable(self) -> None:
rows = [{"b": 1, "a": 2}, {"a": 3, "b": 4}]
assert to_jsonl(rows) == '{"a":2,"b":1}\n{"a":3,"b":4}\n'
def test_to_jsonl_empty(self) -> None:
assert to_jsonl([]) == ""
def test_write_if_changed_skips_identical(self, tmp_path: Path) -> None:
target = tmp_path / "x.jsonl"
assert write_if_changed(target, "a\n") is True
assert write_if_changed(target, "a\n") is False # identical, no write
assert write_if_changed(target, "b\n") is True
def test_extractor_for_unknown_name(self) -> None:
with pytest.raises(CollectionError, match="unknown extraction"):
extractor_for("csv")
class TestSqliteExtractor:
def _extract(self, tmp_path: Path, tables: tuple[str, ...] = ()) -> object:
repo = tmp_path / "repo"
repo.mkdir(exist_ok=True)
db = repo / "history"
db.mkdir(exist_ok=True)
_make_db(
repo / "history" / "alarms.sqlite",
{
"alarms": [(2, "beta"), (1, "alpha")],
"events": [(1, "s1", 1.5), (2, "s2", 2.5)],
},
)
source = ExtractionSource(path="history/alarms.sqlite", tables=tables)
extractor = SqliteExtractor()
return extractor.extract(source, repo, repo / "extracted")
def test_all_user_tables_extracted(self, tmp_path: Path) -> None:
result = self._extract(tmp_path)
repo = tmp_path / "repo"
assert "extracted/alarms/alarms.jsonl" in result.artifacts
assert "extracted/alarms/events.jsonl" in result.artifacts
content = _read(repo / "extracted" / "alarms" / "alarms.jsonl")
# Ordered by primary key: 1 before 2 regardless of insertion order
assert content == '{"id":1,"text":"alpha"}\n{"id":2,"text":"beta"}\n'
def test_selected_tables_only(self, tmp_path: Path) -> None:
result = self._extract(tmp_path, tables=("alarms",))
assert "extracted/alarms/alarms.jsonl" in result.artifacts
assert not (tmp_path / "repo" / "extracted" / "alarms" / "events.jsonl").exists()
def test_unknown_table_rejected(self, tmp_path: Path) -> None:
with pytest.raises(ExtractionError, match="does not exist"):
self._extract(tmp_path, tables=("nope",))
def test_byte_identical_on_re_extraction(self, tmp_path: Path) -> None:
self._extract(tmp_path)
repo = tmp_path / "repo"
artifact = repo / "extracted" / "alarms" / "alarms.jsonl"
first = _read(artifact)
result2 = self._extract(tmp_path)
assert _read(artifact) == first # FR-031 byte-stable
assert result2.artifacts == [] # nothing rewritten
assert result2.unchanged is True
def test_changed_row_updates_artifact(self, tmp_path: Path) -> None:
self._extract(tmp_path)
repo = tmp_path / "repo"
connection = sqlite3.connect(repo / "history" / "alarms.sqlite")
connection.execute("INSERT INTO alarms VALUES (3, 'gamma')")
connection.commit()
connection.close()
result = self._extract(tmp_path)
assert "extracted/alarms/alarms.jsonl" in result.artifacts
assert '{"id":3,"text":"gamma"}' in _read(
repo / "extracted" / "alarms" / "alarms.jsonl"
)
def test_stale_table_artifact_removed(self, tmp_path: Path) -> None:
self._extract(tmp_path)
repo = tmp_path / "repo"
connection = sqlite3.connect(repo / "history" / "alarms.sqlite")
connection.execute("DROP TABLE events")
connection.commit()
connection.close()
result = self._extract(tmp_path, tables=("alarms",))
# events artifact from the earlier run has no source table anymore
assert "extracted/alarms/events.jsonl" in result.removed
assert not (repo / "extracted" / "alarms" / "events.jsonl").exists()
def test_missing_source_raises(self, tmp_path: Path) -> None:
repo = tmp_path / "repo"
repo.mkdir(exist_ok=True)
source = ExtractionSource(path="history/absent.sqlite")
with pytest.raises(ExtractionError, match="not found"):
SqliteExtractor().extract(source, repo, repo / "extracted")
def test_source_never_modified(self, tmp_path: Path) -> None:
"""FR-034: extraction must not change the source database file."""
result = self._extract(tmp_path)
db = tmp_path / "repo" / "history" / "alarms.sqlite"
connection = sqlite3.connect(f"file:{db}?mode=ro", uri=True)
rows = list(connection.execute("SELECT COUNT(*) FROM alarms"))
connection.close()
assert rows[0][0] == 2
_ = result
def test_wal_database_extracted_consistently(self, tmp_path: Path) -> None:
"""Section 45.5: a WAL-mode source is snapshotted, not locked."""
repo = tmp_path / "repo"
repo.mkdir(exist_ok=True)
(repo / "history").mkdir(exist_ok=True)
db = repo / "history" / "wal.sqlite"
connection = sqlite3.connect(db)
connection.execute("PRAGMA journal_mode=WAL")
connection.execute("CREATE TABLE t (id INTEGER PRIMARY KEY, v TEXT)")
connection.execute("INSERT INTO t VALUES (1, 'x')")
connection.commit()
connection.close()
source = ExtractionSource(path="history/wal.sqlite")
result = SqliteExtractor().extract(source, repo, repo / "extracted")
assert "extracted/wal/t.jsonl" in result.artifacts