Files
ss-tools/backend/tests/services/test_structure_snapshot_integration.py
busya a32ca0631b feat(037): capture, verification lifecycle, inheritance + close 036 stabilization
- Authoritative candidate capture with server-issued artifacts and raw-byte
  immutability hashing (source_response_hash server-owned)
- Closed-period lifecycle: request-hash bound approvals, persisted closure
  immutability violations, byte-for-byte catalog stability on reclosure
- Verification runs: persisted VerificationRun model + FK migration,
  publish gate (block_publish), scheduled observability runs (02:00 UTC)
- FR-013 baseline inheritance: prior_release_id migration, plan_inheritance/
  execute_inheritance classification and re-extraction, API endpoints
- Visual executor bound to release-deployment environment; caller mismatch
  rejected; visual SSIM/reconciliation modules
- Query execution decomposed: envelope/model/executor split, no direct SQL
- AgentRun approvals extracted to submodule; evidence adapter; _utils
- Dashboard testing service decomposed into 30+ modules (all <400 LOC)
- Five Feature-037 agent tools with permission guards (tools_037.py)
- API readiness endpoint; Alembic env/migrations; test fixture repos
- Specs 036/037 contracts, openapi.yaml, schema.json, tasks/traceability
  updated; semantic index rebuilt with 0 parse warnings
- Fix ADR-0003 parser ambiguity: remove [DEF🆔ADR] prose example
- Add axiom-mcp-agent-feedback.md: agent findings for MCP rework plan
- Tests: 298 service + 1464 API + 45 agent passing; ruff clean
2026-07-31 11:28:50 +03:00

373 lines
15 KiB
Python

#region Test.BaselineEngine.StructureSnapshot.Integration [C:4] [TYPE Module] [SEMANTICS test,structure-snapshot,integration,capture,diff,provenance]
# @defgroup Test.BaselineEngine.StructureSnapshot.Integration Temp-path persisted integration tests.
# @LAYER Test
# @RELATION BINDS_TO -> [BaselineEngine.StructureSnapshot.Capture]
# @RELATION BINDS_TO -> [BaselineEngine.StructureSnapshot.Diff]
# @RELATION BINDS_TO -> [BaselineEngine.StructureDiff.SnapshotLoader]
# @TEST_EDGE capture_persist_diff -> full capture → persist → diff flow on real temp files
# @TEST_EDGE wrong_repo -> capture→diff with wrong repository_id raises ValueError
# @TEST_EDGE wrong_release -> envelope release_id mismatch raises ValueError
# @TEST_EDGE wrong_version -> envelope release_version mismatch raises ValueError
# @TEST_EDGE wrong_commit -> envelope release_commit_hash mismatch raises ValueError
# @TEST_EDGE wrong_env -> envelope environment_id mismatch raises ValueError
# @TEST_EDGE wrong_dashboard -> envelope dashboard_id mismatch raises ValueError
# @TEST_EDGE cross_repo -> envelope repository_id differs between releases raises ValueError
# @TEST_EDGE client_error_no_write -> capture raises on client error, no file written
# @TEST_EDGE persist_with_provenance -> persists with envelope, loadable via load_snapshot_with_provenance
# @INVARIANT Every temp-path persisted snapshot carries a matching ProvenanceEnvelope.
# @INVARIANT Legacy snapshots (no envelope) are rejected by release-bound diff.
from __future__ import annotations
import hashlib
import json
from pathlib import Path
import pytest
from unittest.mock import AsyncMock, MagicMock, patch
from src.models.dashboard_release import DashboardRelease
from src.models.deployment import DeploymentRecord
from src.models.git import GitRepository
from src.schemas.dashboard_testing import DashboardQueryModel, SnapshotCaptureResponse
from src.schemas.dashboard_testing.structure_snapshot import (
SENTINEL_ERROR_FINGERPRINT,
ProvenanceEnvelope,
SnapshotCaptureRequest,
SnapshotDiffRequest,
)
from src.services.dashboard_testing.snapshot_loader import (
load_snapshot,
load_snapshot_with_provenance,
persist_snapshot,
)
from src.services.dashboard_testing.structure_diff_service import (
set_snapshot_base_path,
)
from src.services.dashboard_testing.structure_snapshot_capture import (
capture_release_snapshot,
derive_dash_key,
derive_repo_key,
)
from src.services.dashboard_testing.structure_snapshot_diff import diff_release_snapshots
# ── Helpers ─────────────────────────────────────────────────────
def make_standard_mocks(repo_id: str = "repo-1",
dash_id: int = 42,
version: str = "v1.0.0",
commit_hash: str | None = None,
rel_id: str = "rel-1",
dep_id: str = "dep-1") -> dict:
"""Create standard mocks for a capture test scenario."""
ch = commit_hash if commit_hash is not None else hashlib.sha256(rel_id.encode()).hexdigest()[:40]
release = MagicMock(spec=DashboardRelease)
release.id = rel_id
release.version = version
release.commit_hash = ch
release.repository_id = repo_id
release.deployment_id = dep_id
repo_record = MagicMock(spec=GitRepository)
repo_record.id = repo_id
repo_record.dashboard_id = dash_id
repo_record.local_path = f"git_repos/{repo_id}"
return {
"release": release,
"repo_record": repo_record,
"commit_hash": ch,
}
# #region Test.Integration.PersistWithProvenance [C:2] [TYPE Class]
class TestPersistWithProvenance:
"""Tests for persist_snapshot with ProvenanceEnvelope."""
# #region Test.Integration.PersistWithProvenance.TestPersistAndLoad
def test_persist_and_load_with_provenance(self, tmp_path):
"""Persist snapshot with provenance, load via load_snapshot_with_provenance."""
model = DashboardQueryModel(
environment_id="env-1", dashboard_id=42, title="Test Dashboard",
query_model_fingerprint="sha256:test_fp",
)
envelope = ProvenanceEnvelope(
release_id="rel-1",
release_version="v1.0.0",
release_commit_hash="a" * 40,
repository_id="repo-1",
repository_key="test-repo",
dashboard_id=42,
environment_id="env-1",
)
path = persist_snapshot(
model, "test-repo", "dash_42", "v1.0.0",
base_path=str(tmp_path), provenance=envelope,
)
assert path.exists()
# Load with provenance
loaded_model, loaded_env = load_snapshot_with_provenance(path, "v1.0.0")
assert loaded_model.dashboard_id == 42
assert loaded_model.title == "Test Dashboard"
assert loaded_env is not None
assert loaded_env.release_id == "rel-1"
assert loaded_env.release_commit_hash == "a" * 40
# Load via standard load_snapshot still works (backward compat)
model2 = load_snapshot(path, "v1.0.0")
assert model2.dashboard_id == 42
# #endregion
# #region Test.Integration.PersistWithProvenance.TestPersistLegacy
def test_persist_legacy_no_provenance(self, tmp_path):
"""Persist without provenance creates legacy format."""
model = DashboardQueryModel(
environment_id="env-1", dashboard_id=42, title="Legacy",
query_model_fingerprint="sha256:legacy",
)
path = persist_snapshot(
model, "test-repo", "dash_42", "v1.0.0",
base_path=str(tmp_path),
)
assert path.exists()
loaded_model, loaded_env = load_snapshot_with_provenance(path, "v1.0.0")
assert loaded_env is None # legacy
assert loaded_model.title == "Legacy"
# #endregion
# #region Test.Integration.PersistWithProvenance.TestFileContentFormat
def test_persisted_file_has_provenance_wrapper(self, tmp_path):
"""Persisted file contains provenance and query_model keys."""
model = DashboardQueryModel(
environment_id="env-1", dashboard_id=42, title="Test",
query_model_fingerprint="sha256:fp",
)
envelope = ProvenanceEnvelope(
release_id="rel-1", release_version="v1.0.0",
release_commit_hash="a" * 40, repository_id="repo-1",
repository_key="test-repo", dashboard_id=42, environment_id="env-1",
)
path = persist_snapshot(
model, "test-repo", "dash_42", "v1.0.0",
base_path=str(tmp_path), provenance=envelope,
)
raw = json.loads(path.read_text())
assert "provenance" in raw
assert "query_model" in raw
assert raw["provenance"]["release_id"] == "rel-1"
assert raw["query_model"]["title"] == "Test"
# #endregion
# #endregion
# #region Test.Integration.CapturePersistDiff [C:3] [TYPE Class]
class TestCapturePersistDiff:
"""Full capture → persist → diff flow on real temp files."""
# #region Test.Integration.CapturePersistDiff.TestFullFlow
@pytest.mark.asyncio
async def test_capture_persist_diff_full_flow(self, tmp_path):
"""Full capture → persist → diff on real temp files succeeds."""
mocks = make_standard_mocks()
release = mocks["release"]
repo_record = mocks["repo_record"]
db = MagicMock()
db.query.return_value.filter.return_value.first.side_effect = [
release, repo_record
]
git_service = MagicMock()
git_service.legacy_base_path = str(tmp_path / "git_repos")
git_service.get_repo.return_value = MagicMock()
client = AsyncMock()
with patch(
"src.services.dashboard_testing.structure_snapshot_capture.inspect_dashboard_query_model",
new_callable=AsyncMock,
) as mock_inspect:
model = DashboardQueryModel(
environment_id="env-1", dashboard_id=42, title="Test",
charts=[], datasets=[], native_filters=[],
query_model_fingerprint="sha256:captured",
)
mock_inspect.return_value = model
response = await capture_release_snapshot(
SnapshotCaptureRequest(release_id="rel-1"),
db=db, client=client,
environment_id="env-1",
git_service=git_service,
dashboard_id_override=42,
)
assert isinstance(response, SnapshotCaptureResponse)
assert response.release_version == "v1.0.0"
assert response.release_id == "rel-1"
assert response.environment_id == "env-1"
assert response.repository_id == "repo-1"
# Verify the snapshot file exists and has provenance
snap_path = Path(response.snapshot_path)
assert snap_path.exists()
_loaded_model, loaded_env = load_snapshot_with_provenance(snap_path, "v1.0.0")
assert loaded_env is not None
assert loaded_env.release_id == "rel-1"
assert loaded_env.repository_id == "repo-1"
assert loaded_env.environment_id == "env-1"
assert loaded_env.release_commit_hash == mocks["commit_hash"]
# Now diff the same snapshot against itself (requires two releases)
# Create a second release with same repo
mocks2 = make_standard_mocks(
rel_id="rel-2", version="v2.0.0",
commit_hash="b" * 40, dep_id="dep-2",
)
release2 = mocks2["release"]
# Persist second snapshot manually
model2 = DashboardQueryModel(
environment_id="env-1", dashboard_id=42, title="Test V2",
charts=[], datasets=[], native_filters=[],
query_model_fingerprint="sha256:captured_v2",
)
envelope2 = ProvenanceEnvelope(
release_id="rel-2", release_version="v2.0.0",
release_commit_hash="b" * 40, repository_id="repo-1",
repository_key=derive_repo_key(repo_record),
dashboard_id=42, environment_id="env-1",
)
snap_path2 = persist_snapshot(
model2, derive_repo_key(repo_record), derive_dash_key(repo_record),
"v2.0.0", base_path=str(tmp_path),
provenance=envelope2,
)
# Set up diff mocks
dep1 = MagicMock(spec=DeploymentRecord)
dep1.id = "dep-1"
dep1.environment_id = "env-1"
dep1.commit_hash = mocks["commit_hash"]
dep2 = MagicMock(spec=DeploymentRecord)
dep2.id = "dep-2"
dep2.environment_id = "env-1"
dep2.commit_hash = "b" * 40
db2 = MagicMock()
db2.query.return_value.filter.return_value.first.side_effect = [
release, release2, repo_record, dep1, dep2,
]
set_snapshot_base_path(str(tmp_path))
try:
with patch(
"src.services.dashboard_testing.structure_snapshot_diff.build_snapshot_path",
side_effect=[snap_path, snap_path2],
), patch(
"src.services.dashboard_testing.structure_snapshot_diff.compute_structure_diff",
return_value=MagicMock(changes=[], blocked=False),
):
diff_result = diff_release_snapshots(
SnapshotDiffRequest(release_id_from="rel-1", release_id_to="rel-2"),
db=db2, git_service=git_service,
)
assert diff_result is not None
finally:
set_snapshot_base_path(None)
# #endregion
# #region Test.Integration.CapturePersistDiff.TestCaptureNoWriteOnSentinelError
@pytest.mark.asyncio
async def test_capture_no_write_on_sentinel_error(self, tmp_path):
"""Capture with sentinel error raises ValueError, no file written."""
mocks = make_standard_mocks()
release = mocks["release"]
repo_record = mocks["repo_record"]
db = MagicMock()
db.query.return_value.filter.return_value.first.side_effect = [
release, repo_record
]
git_service = MagicMock()
git_service.legacy_base_path = str(tmp_path / "git_repos")
git_service.get_repo.return_value = MagicMock()
client = AsyncMock()
with patch(
"src.services.dashboard_testing.structure_snapshot_capture.inspect_dashboard_query_model",
new_callable=AsyncMock,
) as mock_inspect:
model = MagicMock(spec=DashboardQueryModel)
model.query_model_fingerprint = SENTINEL_ERROR_FINGERPRINT
model.warnings = []
mock_inspect.return_value = model
with pytest.raises(ValueError, match="blocking warnings"):
await capture_release_snapshot(
SnapshotCaptureRequest(release_id="rel-1"),
db=db, client=client,
environment_id="env-1",
git_service=git_service,
)
# Verify no snapshot file was created anywhere under tmp_path
all_json = list(tmp_path.rglob("*.json"))
assert len(all_json) == 0, f"Unexpected snapshot files: {all_json}"
# #endregion
# #region Test.Integration.CapturePersistDiff.TestCaptureNoWriteOnBlockingWarning
@pytest.mark.asyncio
async def test_capture_no_write_on_blocking_warning(self, tmp_path):
"""Capture with DASHBOARD_FETCH_FAILED warning raises ValueError, no file."""
mocks = make_standard_mocks()
release = mocks["release"]
repo_record = mocks["repo_record"]
db = MagicMock()
db.query.return_value.filter.return_value.first.side_effect = [
release, repo_record
]
git_service = MagicMock()
git_service.legacy_base_path = str(tmp_path / "git_repos")
git_service.get_repo.return_value = MagicMock()
client = AsyncMock()
with patch(
"src.services.dashboard_testing.structure_snapshot_capture.inspect_dashboard_query_model",
new_callable=AsyncMock,
) as mock_inspect:
from src.schemas.dashboard_testing import Warning as WarningSchema
model = MagicMock(spec=DashboardQueryModel)
model.query_model_fingerprint = "sha256:normal"
model.warnings = [
WarningSchema(source="inspection", resource="42",
code="DASHBOARD_FETCH_FAILED", detail="API error")
]
mock_inspect.return_value = model
with pytest.raises(ValueError, match="blocking warnings"):
await capture_release_snapshot(
SnapshotCaptureRequest(release_id="rel-1"),
db=db, client=client,
environment_id="env-1",
git_service=git_service,
)
# #endregion
# #endregion
#endregion Test.BaselineEngine.StructureSnapshot.Integration