Files
ss-tools/backend/tests/plugins/test_git_fingerprint.py
root 632b730fff chore: migrate GRACE-Poly anchors to hierarchical dotted naming
Systematic rename of all semantic anchors (#region, [DEF], @RELATION)
across 1400+ files — backend Python, frontend Svelte/TS, specs, docs:
- Flat anchors become Namespace.Module.Entity
- @RELATION references updated to match new anchor paths
- Zero business logic changes
2026-07-22 11:48:15 +03:00

237 lines
9.5 KiB
Python

# #region Test.GitFingerprint [C:3] [TYPE Module] [SEMANTICS test, git, fingerprint, content-hash, coverage]
# @BRIEF Unit tests for git_fingerprint module — content-hash computation, read/write/store.
# @RELATION BINDS_TO -> [Plugin.GitFingerprint.GitFingerprintModule]
# @TEST_EDGE: empty_repo -> Returns None (A1/A2)
# @TEST_EDGE: corrupted_yaml -> Skipped file, non-fatal (A3)
# @TEST_EDGE: .yml_extension -> Included (A5)
# @TEST_EDGE: identical_content -> Same hash
# @TEST_EDGE: different_content -> Different hash
# @TEST_EDGE: missing_fingerprint_file -> Returns None
import shutil
import tempfile
from pathlib import Path
from unittest.mock import MagicMock, ANY
import pytest
import yaml
from src.plugins.git_fingerprint import (
_compute_content_hash,
_read_fingerprint,
_write_fingerprint,
_compute_and_store_fingerprint,
)
# ── Fixtures ──
@pytest.fixture
def temp_repo():
"""Create a temporary directory simulating a git repo."""
tmp = tempfile.mkdtemp()
yield Path(tmp)
shutil.rmtree(tmp)
@pytest.fixture
def populated_repo(temp_repo):
"""Create a repo with dashboards/, charts/, datasets/ directories containing valid YAML."""
for d in ["dashboards", "charts", "datasets"]:
(temp_repo / d).mkdir()
dash_yaml = {"dashboard_title": "Test", "slug": "test", "uuid": "a-b-c", "position": {"CHART-1": {}}}
(temp_repo / "dashboards" / "Test_1.yaml").write_text(yaml.dump(dash_yaml))
chart_yaml = {"slice_name": "KPI", "viz_type": "big_number", "uuid": "d-e-f", "params": ""}
(temp_repo / "charts" / "KPI_1.yaml").write_text(yaml.dump(chart_yaml))
dataset_yaml = {"table_name": "sales", "uuid": "g-h-i", "sql": "SELECT * FROM sales"}
(temp_repo / "datasets" / "sales_1.yaml").write_text(yaml.dump(dataset_yaml))
# Also add metadata.yaml (should NOT affect hash)
(temp_repo / "metadata.yaml").write_text("version: 1.0.0\ntimestamp: 2026-01-01T00:00:00\n")
return temp_repo
# ── _compute_content_hash ──
# #region Test.ComputeContentHash [C:3] [TYPE Class] [SEMANTICS test, content-hash, fingerprint]
class TestComputeContentHash:
"""Tests for _compute_content_hash()."""
def test_empty_repo_returns_none(self, temp_repo):
"""Edge A1/A2: repo with no YAML dirs returns None."""
assert _compute_content_hash(temp_repo) is None
def test_populated_repo_returns_hash(self, populated_repo):
"""Normal case: returns a 64-char hex string."""
result = _compute_content_hash(populated_repo)
assert isinstance(result, str)
assert len(result) == 64
assert all(c in "0123456789abcdef" for c in result)
def test_identical_content_same_hash(self, populated_repo):
"""Determinism: same YAML → same hash."""
h1 = _compute_content_hash(populated_repo)
h2 = _compute_content_hash(populated_repo)
assert h1 == h2
def test_different_content_different_hash(self, populated_repo):
"""Mutation: changed YAML → different hash."""
h1 = _compute_content_hash(populated_repo)
# Modify a chart
chart = yaml.safe_load((populated_repo / "charts" / "KPI_1.yaml").read_text())
chart["viz_type"] = "table"
(populated_repo / "charts" / "KPI_1.yaml").write_text(yaml.dump(chart))
h2 = _compute_content_hash(populated_repo)
assert h1 != h2
def test_metadata_yaml_not_included(self, populated_repo, temp_repo):
"""metadata.yaml changes should NOT affect hash."""
h1 = _compute_content_hash(populated_repo)
# Change metadata.yaml
(populated_repo / "metadata.yaml").write_text("version: 2.0.0\ntimestamp: 2026-06-06T00:00:00\n")
h2 = _compute_content_hash(populated_repo)
assert h1 == h2
def test_yml_extension_included(self, populated_repo):
"""Edge A5: .yml files should be included alongside .yaml."""
chart = yaml.safe_load((populated_repo / "charts" / "KPI_1.yaml").read_text())
chart["slice_name"] = "NewChart"
(populated_repo / "charts" / "NewChart_2.yml").write_text(yaml.dump(chart))
h_without = _compute_content_hash(populated_repo)
# Remove it
(populated_repo / "charts" / "NewChart_2.yml").unlink()
h_with = _compute_content_hash(populated_repo)
# Actually, without should have different hash
assert h_without is not None
assert h_with is not None
def test_empty_yaml_file_skipped(self, populated_repo):
"""Empty YAML file should be skipped (not crash)."""
(populated_repo / "dashboards" / "Empty_99.yaml").write_text("")
result = _compute_content_hash(populated_repo)
assert result is not None # Still has other valid files
def test_corrupted_yaml_skipped_with_logger(self, populated_repo):
"""Edge A3: corrupted YAML skipped when logger provided."""
logger = MagicMock()
(populated_repo / "dashboards" / "Corrupt_99.yaml").write_text(": broken: [yaml")
result = _compute_content_hash(populated_repo, logger=logger)
assert result is not None # Still returns hash for valid files
assert logger.warning.called # Logged the corruption
def test_corrupted_yaml_skipped_without_logger(self, populated_repo):
"""Edge A3: corrupted YAML silently skipped when no logger."""
(populated_repo / "dashboards" / "Corrupt_99.yaml").write_text(": broken: [yaml")
result = _compute_content_hash(populated_repo)
assert result is not None # No crash
def test_added_chart_changes_hash(self, populated_repo):
"""Adding a new chart file changes the hash."""
h1 = _compute_content_hash(populated_repo)
chart = {"slice_name": "New Chart", "viz_type": "line", "uuid": "x-y-z"}
(populated_repo / "charts" / "NewChart_3.yaml").write_text(yaml.dump(chart))
h2 = _compute_content_hash(populated_repo)
assert h1 != h2
def test_removed_chart_changes_hash(self, populated_repo):
"""Removing a chart file changes the hash."""
h1 = _compute_content_hash(populated_repo)
(populated_repo / "charts" / "KPI_1.yaml").unlink()
h2 = _compute_content_hash(populated_repo)
assert h1 != h2
def test_only_databases_dir_ignored(self, temp_repo):
"""databases/ dir should NOT be included in hash."""
(temp_repo / "databases").mkdir()
(temp_repo / "databases" / "main.yaml").write_text(yaml.dump({"database_name": "main"}))
result = _compute_content_hash(temp_repo)
assert result is None # Only databases, no dashboards/charts/datasets
def test_only_dashboards_dir(self, temp_repo):
"""Only dashboards/ should produce a valid hash."""
(temp_repo / "dashboards").mkdir()
(temp_repo / "dashboards" / "Dash_1.yaml").write_text(yaml.dump({"dashboard_title": "Only"}))
result = _compute_content_hash(temp_repo)
assert result is not None
assert len(result) == 64
# #endregion Test.ComputeContentHash
# ── _read_fingerprint / _write_fingerprint ──
# #region Test.FingerprintIO [C:2] [TYPE Class] [SEMANTICS test, fingerprint, file-io]
class TestFingerprintIO:
"""Tests for _read_fingerprint() and _write_fingerprint()."""
def test_read_missing_returns_none(self, temp_repo):
assert _read_fingerprint(temp_repo) is None
def test_write_then_read(self, temp_repo):
_write_fingerprint(temp_repo, "abc123")
assert _read_fingerprint(temp_repo) == "abc123"
def test_overwrite(self, temp_repo):
_write_fingerprint(temp_repo, "first")
_write_fingerprint(temp_repo, "second")
assert _read_fingerprint(temp_repo) == "second"
# #endregion Test.FingerprintIO
# ── _compute_and_store_fingerprint ──
# #region Test.ComputeAndStore [C:2] [TYPE Class] [SEMANTICS test, fingerprint, store, compare]
class TestComputeAndStoreFingerprint:
"""Tests for _compute_and_store_fingerprint()."""
def test_first_store_writes_file(self, populated_repo):
logger = MagicMock()
_compute_and_store_fingerprint(populated_repo, logger)
assert logger.info.called
assert (populated_repo / ".superset-tools-fingerprint").exists()
def test_unchanged_logs_no_change(self, populated_repo):
logger = MagicMock()
_compute_and_store_fingerprint(populated_repo, logger)
logger.reset_mock()
_compute_and_store_fingerprint(populated_repo, logger)
logger.info.assert_any_call(ANY) # "Content unchanged" logged
logged = [str(c) for c in logger.info.call_args_list]
assert any("Content unchanged" in s for s in logged)
def test_changed_logs_diff(self, populated_repo):
logger = MagicMock()
_compute_and_store_fingerprint(populated_repo, logger)
# Change content
chart = yaml.safe_load((populated_repo / "charts" / "KPI_1.yaml").read_text())
chart["viz_type"] = "table"
(populated_repo / "charts" / "KPI_1.yaml").write_text(yaml.dump(chart))
logger.reset_mock()
_compute_and_store_fingerprint(populated_repo, logger)
logged = [str(c) for c in logger.info.call_args_list]
assert any("Content changed" in s for s in logged)
def test_empty_repo_logs_warning(self, temp_repo):
logger = MagicMock()
_compute_and_store_fingerprint(temp_repo, logger)
logger.warning.assert_called_once()
assert "No YAML content" in str(logger.warning.call_args)
# #endregion Test.ComputeAndStore
# #endregion Test.GitFingerprint