"""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