446 lines
18 KiB
Python
446 lines
18 KiB
Python
"""Integration tests: full collection cycles against real git repositories.
|
|
|
|
Covers the Section 40 processing sequence and the safety/edge-case list
|
|
from Section 41.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import contextlib
|
|
import subprocess
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from git_auto_tracker.application import ApplicationController
|
|
from git_auto_tracker.config import AppConfig
|
|
from git_auto_tracker.errors import (
|
|
ExitCode,
|
|
RepositoryError,
|
|
SecurityError,
|
|
TrackerError,
|
|
)
|
|
from git_auto_tracker.logging.setup import setup_logging
|
|
|
|
|
|
def _make_controller(config_dict: dict, tmp_path: Path) -> tuple[AppConfig, ApplicationController]:
|
|
config = AppConfig.from_dict(config_dict, config_dir=tmp_path)
|
|
setup_logging(config.logging, config_dir=tmp_path, repository_path=config.repository_path)
|
|
return config, ApplicationController(config)
|
|
|
|
|
|
def _git_out(*args: str, cwd: Path) -> str:
|
|
"""Run git; NUL-terminated output (-z) is returned as decoded text."""
|
|
proc = subprocess.run(
|
|
["git", *args],
|
|
cwd=str(cwd),
|
|
capture_output=True,
|
|
check=True,
|
|
)
|
|
return proc.stdout.decode("utf-8")
|
|
|
|
|
|
def _commit_log(repo: Path) -> list[str]:
|
|
out = _git_out("log", "--format=%s", cwd=repo)
|
|
return out.splitlines()
|
|
|
|
|
|
def _run(config_dict: dict, tmp_path: Path, dry_run: bool = False):
|
|
_, controller = _make_controller(config_dict, tmp_path)
|
|
return controller.collect(dry_run=dry_run)
|
|
|
|
|
|
class TestRunCycle:
|
|
def test_clean_repository_no_commit(
|
|
self, tracker_config: dict, tmp_path: Path
|
|
) -> None:
|
|
_, controller = _make_controller(tracker_config, tmp_path)
|
|
outcome = controller.collect()
|
|
assert outcome.commit_hash is None
|
|
assert outcome.change_count == 0
|
|
|
|
def test_add_modify_delete_in_one_commit(
|
|
self, git_repo: Path, tracker_config: dict, tmp_path: Path
|
|
) -> None:
|
|
stations = git_repo / "Stations" / "PS101"
|
|
stations.mkdir(parents=True)
|
|
(stations / "pump2.xml").write_text("<pump/>", encoding="utf-8")
|
|
(git_repo / "README.md").write_text("changed\n", encoding="utf-8")
|
|
(git_repo / "obsolete.xml").write_text("bye", encoding="utf-8")
|
|
_git_out("add", "obsolete.xml", cwd=git_repo)
|
|
_git_out("commit", "-m", "add obsolete", cwd=git_repo)
|
|
(git_repo / "obsolete.xml").unlink()
|
|
|
|
outcome = _run(tracker_config, tmp_path)
|
|
assert outcome.commit_hash is not None
|
|
assert outcome.change_count == 3
|
|
|
|
subjects = _commit_log(git_repo)
|
|
assert subjects[0].startswith("SCADA automatic change capture - ")
|
|
|
|
body = _git_out("log", "-1", "--format=%b", cwd=git_repo)
|
|
assert "Stations/PS101/pump2.xml" in body
|
|
assert "README.md" in body
|
|
assert "obsolete.xml" in body
|
|
assert "Tracker-Run: " in body
|
|
assert "Added: 1" in body
|
|
assert "Modified: 1" in body
|
|
assert "Deleted: 1" in body
|
|
|
|
# Author of the commit is the tracker identity (Section 17)
|
|
author = _git_out("log", "-1", "--format=%an <%ae>", cwd=git_repo).strip()
|
|
assert author == "SCADA Change Tracker <git-auto-tracker@localhost>"
|
|
|
|
def test_sidecar_attribution_appears_in_commit(
|
|
self, git_repo: Path, tracker_config: dict, tmp_path: Path
|
|
) -> None:
|
|
(git_repo / "config.xml").write_text("<c/>", encoding="utf-8")
|
|
(git_repo / "config.xml.metadata").write_text(
|
|
'last_modified_by=DOMAIN\\jsmith\n', encoding="utf-8"
|
|
)
|
|
_run(tracker_config, tmp_path)
|
|
body = _git_out("log", "-1", "--format=%b", cwd=git_repo)
|
|
# One-line entry: letter ; path ; author ; local-time mtime
|
|
assert " A ; config.xml ; DOMAIN\\jsmith ; " in body
|
|
|
|
def test_file_mtime_appears_in_commit_local_time(
|
|
self, git_repo: Path, tracker_config: dict, tmp_path: Path
|
|
) -> None:
|
|
"""Section 12 file_timestamp on the one-line entry (local time)."""
|
|
import re as re_mod
|
|
|
|
(git_repo / "config.xml").write_text("<c/>", encoding="utf-8")
|
|
_run(tracker_config, tmp_path)
|
|
body = _git_out("log", "-1", "--format=%b", cwd=git_repo)
|
|
# A ; path ; author ; local-time mtime with +/-HH:MM offset
|
|
assert re_mod.search(
|
|
r"^ A ; config\.xml ; UNKNOWN ; "
|
|
r"\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}[+-]\d{2}:\d{2}$",
|
|
body,
|
|
flags=re_mod.MULTILINE,
|
|
)
|
|
|
|
def test_custom_separator_in_real_commit(
|
|
self, git_repo: Path, tracker_config: dict, tmp_path: Path
|
|
) -> None:
|
|
"""commit.file_separator flows from configuration into the commit."""
|
|
tracker_config["commit"] = {"file_separator": "|"}
|
|
(git_repo / "config.xml").write_text("<c/>", encoding="utf-8")
|
|
_run(tracker_config, tmp_path)
|
|
body = _git_out("log", "-1", "--format=%b", cwd=git_repo)
|
|
assert " A | config.xml | UNKNOWN | " in body
|
|
assert "; config.xml" not in body
|
|
|
|
def test_excluded_files_not_committed(
|
|
self, git_repo: Path, tracker_config: dict, tmp_path: Path
|
|
) -> None:
|
|
(git_repo / "junk.log").write_text("noise", encoding="utf-8")
|
|
(git_repo / "real.xml").write_text("<r/>", encoding="utf-8")
|
|
outcome = _run(tracker_config, tmp_path)
|
|
assert outcome.change_count == 1
|
|
status = _git_out("status", "--porcelain", cwd=git_repo)
|
|
assert "junk.log" in status # still untracked, left alone
|
|
assert "real.xml" not in status
|
|
|
|
def test_dry_run_leaves_repo_untouched(
|
|
self, git_repo: Path, tracker_config: dict, tmp_path: Path
|
|
) -> None:
|
|
(git_repo / "new.xml").write_text("<n/>", encoding="utf-8")
|
|
before = _git_out("status", "--porcelain", "-z", cwd=git_repo)
|
|
outcome = _run(tracker_config, tmp_path, dry_run=True)
|
|
assert outcome.dry_run
|
|
assert outcome.commit_message is not None
|
|
assert "new.xml" in outcome.commit_message
|
|
after = _git_out("status", "--porcelain", "-z", cwd=git_repo)
|
|
assert before == after
|
|
assert len(_commit_log(git_repo)) == 1 # only the initial commit
|
|
|
|
def test_unicode_and_spaces(
|
|
self, git_repo: Path, tracker_config: dict, tmp_path: Path
|
|
) -> None:
|
|
tricky = git_repo / "Station PS101"
|
|
tricky.mkdir()
|
|
(tricky / "räumliche Datei.xml").write_text("<u/>", encoding="utf-8")
|
|
outcome = _run(tracker_config, tmp_path)
|
|
assert outcome.change_count == 1
|
|
# ls-files -z output is NUL-separated raw UTF-8 (never C-quoted)
|
|
raw = _git_out("ls-files", "-z", cwd=git_repo)
|
|
files = [part for part in raw.split("\x00") if part]
|
|
assert "Station PS101/räumliche Datei.xml" in files
|
|
|
|
|
|
class TestExtraction:
|
|
"""Section 45: derived artifacts flow through the full collection."""
|
|
|
|
def _make_sqlite(self, git_repo: Path) -> Path:
|
|
import sqlite3
|
|
|
|
history = git_repo / "history"
|
|
history.mkdir(exist_ok=True)
|
|
db = history / "alarms.sqlite"
|
|
connection = sqlite3.connect(db)
|
|
connection.execute("CREATE TABLE alarms (id INTEGER PRIMARY KEY, text TEXT)")
|
|
connection.execute("INSERT INTO alarms VALUES (1, 'alpha')")
|
|
connection.commit()
|
|
connection.close()
|
|
return db
|
|
|
|
def _config_with_extraction(self, tracker_config: dict) -> dict:
|
|
tracker_config["extraction"] = {
|
|
"enabled": True,
|
|
"output_root": "extracted",
|
|
"format": "jsonl",
|
|
"sources": [{"path": "history/alarms.sqlite"}],
|
|
}
|
|
tracker_config["files"]["exclude"].append("**/*.sqlite")
|
|
return tracker_config
|
|
|
|
def test_first_run_commits_artifacts(
|
|
self, git_repo: Path, tracker_config: dict, tmp_path: Path
|
|
) -> None:
|
|
self._make_sqlite(git_repo)
|
|
config = self._config_with_extraction(tracker_config)
|
|
outcome = _run(config, tmp_path)
|
|
# artifacts only: raw DB excluded, no other changes
|
|
assert outcome.change_count == 1
|
|
assert outcome.commit_hash is not None
|
|
body = _git_out("log", "-1", "--format=%b", cwd=git_repo)
|
|
assert "extracted/alarms/alarms.jsonl" in body
|
|
tracked = _git_out("ls-files", "-z", cwd=git_repo)
|
|
assert "alarms.sqlite" not in tracked # raw DB excluded
|
|
content = (git_repo / "extracted" / "alarms" / "alarms.jsonl").read_text(
|
|
encoding="utf-8"
|
|
)
|
|
assert '{"id":1,"text":"alpha"}' in content
|
|
|
|
def test_unchanged_db_no_second_commit(
|
|
self, git_repo: Path, tracker_config: dict, tmp_path: Path
|
|
) -> None:
|
|
self._make_sqlite(git_repo)
|
|
config = self._config_with_extraction(tracker_config)
|
|
_run(config, tmp_path)
|
|
outcome = _run(config, tmp_path)
|
|
assert outcome.change_count == 0 # FR-031: byte-identical artifacts
|
|
assert outcome.commit_hash is None
|
|
|
|
def test_changed_row_produces_artifact_commit(
|
|
self, git_repo: Path, tracker_config: dict, tmp_path: Path
|
|
) -> None:
|
|
import sqlite3
|
|
|
|
db = self._make_sqlite(git_repo)
|
|
config = self._config_with_extraction(tracker_config)
|
|
_run(config, tmp_path)
|
|
|
|
connection = sqlite3.connect(db)
|
|
connection.execute("INSERT INTO alarms VALUES (2, 'beta')")
|
|
connection.commit()
|
|
connection.close()
|
|
|
|
outcome = _run(config, tmp_path)
|
|
assert outcome.change_count == 1
|
|
body = _git_out("log", "-1", "--format=%b", cwd=git_repo)
|
|
assert "M ; extracted/alarms/alarms.jsonl" in body
|
|
|
|
def test_failed_source_skipped_with_warning_others_extracted(
|
|
self, git_repo: Path, tracker_config: dict, tmp_path: Path
|
|
) -> None:
|
|
"""Section 45.7: per-source isolation - one bad file, others proceed."""
|
|
self._make_sqlite(git_repo)
|
|
# A second, non-database source alongside the valid one
|
|
(git_repo / "history" / "impostor.db").write_text(
|
|
"definitely not a database", encoding="utf-8"
|
|
)
|
|
config = self._config_with_extraction(tracker_config)
|
|
config["extraction"]["sources"].append({"path": "history/impostor.db"})
|
|
config["files"]["exclude"].append("history/impostor.db") # raw file not tracked
|
|
|
|
outcome = _run(config, tmp_path)
|
|
|
|
# The valid source was still extracted and committed
|
|
assert outcome.change_count == 1
|
|
assert outcome.commit_hash is not None
|
|
body = _git_out("log", "-1", "--format=%b", cwd=git_repo)
|
|
assert "extracted/alarms/alarms.jsonl" in body
|
|
# Nothing was extracted for the bad source
|
|
assert not (git_repo / "extracted" / "impostor").exists()
|
|
|
|
def test_non_sqlite_file_not_committed_as_artifact(
|
|
self, git_repo: Path, tracker_config: dict, tmp_path: Path
|
|
) -> None:
|
|
"""A non-database source is skipped, never dumped or committed."""
|
|
history = git_repo / "history"
|
|
history.mkdir(exist_ok=True)
|
|
(git_repo / "history" / "notadb.sqlite").write_text(
|
|
"plain text wearing a database name", encoding="utf-8"
|
|
)
|
|
config = self._config_with_extraction(tracker_config)
|
|
config["extraction"]["sources"] = [{"path": "history/notadb.sqlite"}]
|
|
|
|
outcome = _run(config, tmp_path)
|
|
assert outcome.change_count == 0 # nothing extracted, nothing committed
|
|
assert not (git_repo / "extracted" / "notadb").exists()
|
|
|
|
def test_dry_run_skips_refresh(
|
|
self, git_repo: Path, tracker_config: dict, tmp_path: Path, caplog
|
|
) -> None:
|
|
self._make_sqlite(git_repo)
|
|
config = self._config_with_extraction(tracker_config)
|
|
before = _git_out("status", "--porcelain", "-z", cwd=git_repo)
|
|
outcome = _run(config, tmp_path, dry_run=True)
|
|
assert outcome.dry_run
|
|
# Artifacts never created: the refresh is the step that writes them
|
|
assert (git_repo / "extracted").exists() is False # tree untouched
|
|
after = _git_out("status", "--porcelain", "-z", cwd=git_repo)
|
|
assert before == after
|
|
|
|
|
|
class TestExportHistory:
|
|
"""export subcommand: collected history becomes analysis-ready rows."""
|
|
|
|
def _collect_two_cycles(self, git_repo: Path, config: dict, tmp_path: Path) -> None:
|
|
# Cycle 1: one addition with sidecar attribution
|
|
(git_repo / "config.xml").write_text("<c/>", encoding="utf-8")
|
|
(git_repo / "config.xml.metadata").write_text(
|
|
"last_modified_by=DOMAIN\\jsmith\n", encoding="utf-8"
|
|
)
|
|
_run(config, tmp_path)
|
|
# Cycle 2: one deletion (bare D line in the history)
|
|
(git_repo / "config.xml").unlink()
|
|
_run(config, tmp_path)
|
|
|
|
def test_export_csv_round_trip(
|
|
self, git_repo: Path, tracker_config: dict, tmp_path: Path
|
|
) -> None:
|
|
import csv
|
|
|
|
from git_auto_tracker.config import AppConfig
|
|
from git_auto_tracker.exporting.history import export_history
|
|
|
|
self._collect_two_cycles(git_repo, tracker_config, tmp_path)
|
|
app_config = AppConfig.from_dict(tracker_config, config_dir=tmp_path)
|
|
output = tmp_path / "history.csv"
|
|
count = export_history(app_config, output)
|
|
|
|
# 3 rows: A config.xml + A config.xml.metadata, then D config.xml
|
|
assert count == 3
|
|
with output.open(encoding="utf-8", newline="") as handle:
|
|
rows = list(csv.DictReader(handle))
|
|
assert rows[0]["letter"] == "D" # newest collection first
|
|
assert rows[0]["path"] == "config.xml"
|
|
assert rows[1]["letter"] == "A"
|
|
assert rows[1]["path"] == "config.xml"
|
|
assert rows[1]["author"] == "DOMAIN\\jsmith"
|
|
assert rows[1]["run_id"] # trailer parsed
|
|
|
|
def test_export_jsonl_and_since_filter(
|
|
self, git_repo: Path, tracker_config: dict, tmp_path: Path
|
|
) -> None:
|
|
import json
|
|
|
|
from git_auto_tracker.config import AppConfig
|
|
from git_auto_tracker.exporting.history import export_history
|
|
|
|
self._collect_two_cycles(git_repo, tracker_config, tmp_path)
|
|
app_config = AppConfig.from_dict(tracker_config, config_dir=tmp_path)
|
|
output = tmp_path / "history.jsonl"
|
|
|
|
# Future-dated --since excludes everything (2099, not 2999: Git's
|
|
# approxidate parser mishandles far-future years and returns all)
|
|
assert export_history(app_config, output, since="2099-01-01") == 0
|
|
assert output.read_text(encoding="utf-8") == ""
|
|
|
|
# No filter: both collections' rows present, newest first
|
|
assert export_history(app_config, output) == 3
|
|
lines = output.read_text(encoding="utf-8").splitlines()
|
|
parsed = [json.loads(line) for line in lines]
|
|
assert parsed[0]["letter"] == "D"
|
|
assert parsed[1]["letter"] == "A"
|
|
assert parsed[0]["collected_at"] >= parsed[-1]["collected_at"]
|
|
|
|
def test_cli_export_command(
|
|
self, git_repo: Path, tracker_config: dict, tmp_path: Path, capsys
|
|
) -> None:
|
|
from git_auto_tracker.cli import main
|
|
|
|
self._collect_two_cycles(git_repo, tracker_config, tmp_path)
|
|
import yaml
|
|
|
|
config_path = tmp_path / "cfg.yaml"
|
|
config_path.write_text(yaml.safe_dump(tracker_config), encoding="utf-8")
|
|
output = tmp_path / "out.csv"
|
|
|
|
exit_code = main(["--config", str(config_path), "export", "-o", str(output)])
|
|
assert exit_code == 0
|
|
assert output.is_file()
|
|
captured = capsys.readouterr().out
|
|
assert "3 file-change record(s)" in captured
|
|
|
|
|
|
class TestSafetyAborts:
|
|
def test_pre_existing_staged_changes_abort(
|
|
self, git_repo: Path, tracker_config: dict, tmp_path: Path
|
|
) -> None:
|
|
(git_repo / "staged.xml").write_text("<s/>", encoding="utf-8")
|
|
_git_out("add", "staged.xml", cwd=git_repo)
|
|
with pytest.raises(RepositoryError, match="staged changes"):
|
|
_run(tracker_config, tmp_path)
|
|
|
|
def test_unfinished_merge_state_aborts(
|
|
self, git_repo: Path, tracker_config: dict, tmp_path: Path
|
|
) -> None:
|
|
(git_repo / "conflict.txt").write_text("base", encoding="utf-8")
|
|
_git_out("add", "conflict.txt", cwd=git_repo)
|
|
_git_out("commit", "-m", "base", cwd=git_repo)
|
|
_git_out("checkout", "-b", "feature", cwd=git_repo)
|
|
(git_repo / "conflict.txt").write_text("feature", encoding="utf-8")
|
|
_git_out("commit", "-am", "feature", cwd=git_repo)
|
|
_git_out("checkout", "main", cwd=git_repo)
|
|
(git_repo / "conflict.txt").write_text("main", encoding="utf-8")
|
|
_git_out("commit", "-am", "main", cwd=git_repo)
|
|
with contextlib.suppress(subprocess.CalledProcessError):
|
|
_git_out("merge", "feature", cwd=git_repo) # expected conflict
|
|
with pytest.raises(RepositoryError, match="MERGE_HEAD"):
|
|
_run(tracker_config, tmp_path)
|
|
def test_wrong_branch_aborts(
|
|
self, git_repo: Path, tracker_config: dict, tmp_path: Path
|
|
) -> None:
|
|
_git_out("checkout", "-b", "other-branch", cwd=git_repo)
|
|
with pytest.raises(RepositoryError, match="configured branch"):
|
|
_run(tracker_config, tmp_path)
|
|
|
|
def test_forbidden_file_aborts_with_security_code(
|
|
self, git_repo: Path, tracker_config: dict, tmp_path: Path
|
|
) -> None:
|
|
(git_repo / "server.key").write_text("PRIVATE", encoding="utf-8")
|
|
with pytest.raises(SecurityError):
|
|
_run(tracker_config, tmp_path)
|
|
# Exit code mapping (Section 25)
|
|
assert int(SecurityError("x").exit_code) == ExitCode.SECURITY_VIOLATION
|
|
# Nothing was committed
|
|
assert len(_commit_log(git_repo)) == 1
|
|
|
|
|
|
class TestLockIntegration:
|
|
def test_concurrent_run_blocked(
|
|
self, git_repo: Path, tracker_config: dict, tmp_path: Path
|
|
) -> None:
|
|
from git_auto_tracker.locking.repository_lock import RepositoryLock, default_lock_path
|
|
|
|
_, controller = _make_controller(tracker_config, tmp_path)
|
|
lock = RepositoryLock(default_lock_path(controller.config.repository_path))
|
|
lock.acquire()
|
|
# A second execution attempts the same lock via the CLI path:
|
|
second = RepositoryLock(default_lock_path(controller.config.repository_path))
|
|
with pytest.raises(TrackerError):
|
|
second.acquire()
|
|
lock.release()
|
|
|
|
def test_lock_file_never_in_status(
|
|
self, git_repo: Path, tracker_config: dict, tmp_path: Path
|
|
) -> None:
|
|
(git_repo / "change.xml").write_text("<c/>", encoding="utf-8")
|
|
_run(tracker_config, tmp_path)
|
|
status = _git_out("status", "--porcelain", "--untracked-files=all", cwd=git_repo)
|
|
assert "git-auto-tracker.lock" not in status
|