#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