#region Test.BaselineEngine.StructureSnapshot.Service [C:5] [TYPE Module] [SEMANTICS test,structure-snapshot,service,capture,diff,release-bound] # @defgroup Test.BaselineEngine.StructureSnapshot Service-level tests for release-bound snapshot capture + diff. # @LAYER Test # @RELATION BINDS_TO -> [BaselineEngine.StructureSnapshot.Service] # @RELATION VERIFIES -> [BaselineEngine.StructureSnapshot.Capture] # @RELATION VERIFIES -> [BaselineEngine.StructureSnapshot.Diff] # @TEST_EDGE capture_persist_diff -> full capture → persist → diff flow # @TEST_EDGE failed_inspection_no_write -> sentinel error / blocking warnings prevent persistence # @TEST_EDGE unknown_repo -> unknown / unauthorized repository raises ValueError # @TEST_EDGE wrong_dashboard -> snapshot metadata mismatch on diff raises ValueError # @TEST_EDGE semver_validation -> invalid semver raises ValueError # @TEST_EDGE commit_validation -> invalid commit hash raises ValueError # @TEST_EDGE omitted_semantic_dimensions -> diff covers dataset identity, chart dataset, # filter dataset, type, column attributes, metric definition, access capability # @INVARIANT Every capture is bound to a real DashboardRelease record. # @INVARIANT Never persist if inspection returns sentinel errors / blocking warnings. # @INVARIANT Path resolution uses GitService base_path, never CWD. # @INVARIANT Diff cross-verifies loaded snapshot metadata against release records. from __future__ import annotations import pytest from unittest.mock import AsyncMock, MagicMock, patch from src.models.dashboard_release import DashboardRelease from src.models.git import GitRepository from src.schemas.dashboard_testing import DashboardQueryModel, SnapshotCaptureResponse, Warning as WarningSchema from src.schemas.dashboard_testing.structure_snapshot import ( SENTINEL_ERROR_FINGERPRINT, SnapshotCaptureRequest, SnapshotDiffRequest, ) from src.services.dashboard_testing.structure_snapshot_capture import ( capture_release_snapshot, derive_dash_key, derive_repo_key, has_blocking_warnings, validate_commit_hash, validate_semver, ) # #region Test.StructureSnapshot.Validation [C:2] [TYPE Class] class TestValidation: """Unit tests for helper validators.""" # #region Test.StructureSnapshot.Validation.TestSemverValid def test_semver_valid(self): assert validate_semver("v1.0.0") == "v1.0.0" assert validate_semver("v2.3.4-rc1") == "v2.3.4-rc1" assert validate_semver("v10.20.30") == "v10.20.30" assert validate_semver("v0.0.1+build123") == "v0.0.1+build123" assert validate_semver("v1.2.3-alpha.1") == "v1.2.3-alpha.1" # #endregion # #region Test.StructureSnapshot.Validation.TestSemverInvalid def test_semver_invalid(self): with pytest.raises(ValueError, match="v-prefixed SemVer"): validate_semver("1.0.0") # missing v prefix with pytest.raises(ValueError, match="v-prefixed SemVer"): validate_semver("v1.0") # incomplete with pytest.raises(ValueError, match="v-prefixed SemVer"): validate_semver("") # empty with pytest.raises(ValueError, match="v-prefixed SemVer"): validate_semver("v1.0.0.0") # too many parts # #endregion # #region Test.StructureSnapshot.Validation.TestCommitValid def test_commit_hash_valid(self): assert validate_commit_hash("a" * 40) == "a" * 40 assert validate_commit_hash("abcdef0123456789abcdef0123456789abcdef01") == "abcdef0123456789abcdef0123456789abcdef01" # #endregion # #region Test.StructureSnapshot.Validation.TestCommitInvalid def test_commit_hash_invalid(self): with pytest.raises(ValueError, match="40-char git SHA"): validate_commit_hash("") # empty with pytest.raises(ValueError, match="40-char git SHA"): validate_commit_hash("xyz") # invalid char with pytest.raises(ValueError, match="40-char git SHA"): validate_commit_hash("abc123") # too short (6) with pytest.raises(ValueError, match="40-char git SHA"): validate_commit_hash("z" * 40) # invalid hex char with pytest.raises(ValueError, match="40-char git SHA"): validate_commit_hash("abc1234") # 7-char, rejected with pytest.raises(ValueError, match="40-char git SHA"): validate_commit_hash("a" * 39) # 39 chars, rejected with pytest.raises(ValueError, match="uppercase"): validate_commit_hash("ABCDEF0123456789abcdef0123456789abcdef01") # uppercase rejected # #endregion # #region Test.StructureSnapshot.Validation.TestBlockingWarnings def testhas_blocking_warnings_sentinel_fingerprint(self): model = MagicMock(spec=DashboardQueryModel) model.query_model_fingerprint = SENTINEL_ERROR_FINGERPRINT model.warnings = [] assert has_blocking_warnings(model) is True # #endregion # #region Test.StructureSnapshot.Validation.TestBlockingWarningsFetchFailed def testhas_blocking_warnings_fetch_failed(self): model = MagicMock(spec=DashboardQueryModel) model.query_model_fingerprint = "sha256:normal_hash" model.warnings = [WarningSchema(source="inspection", resource="42", code="DASHBOARD_FETCH_FAILED", detail="timeout")] assert has_blocking_warnings(model) is True # #endregion # #region Test.StructureSnapshot.Validation.TestBlockingWarningsInaccessibleChart def testhas_blocking_warnings_inaccessible_chart_not_blocking(self): """INACCESSIBLE_CHART is not a blocking code — charts can be individually broken.""" model = MagicMock(spec=DashboardQueryModel) model.query_model_fingerprint = "sha256:normal" model.warnings = [WarningSchema(source="inspection", resource="129", code="INACCESSIBLE_CHART", detail="timeout")] assert has_blocking_warnings(model) is False # #endregion # #region Test.StructureSnapshot.Validation.TestBlockingWarningsNone def testhas_blocking_warnings_no_warnings(self): model = MagicMock(spec=DashboardQueryModel) model.query_model_fingerprint = "sha256:ok" model.warnings = [] assert has_blocking_warnings(model) is False # #endregion # #region Test.StructureSnapshot.Validation.TestDeriveRepoKey def testderive_repo_key(self): repo = MagicMock(spec=GitRepository) repo.local_path = "git_repos/my-project" repo.id = "uuid-123" repo.dashboard_id = 42 key = derive_repo_key(repo) assert key == "my-project" # basename of local_path # #endregion # #region Test.StructureSnapshot.Validation.TestDeriveDashKey def testderive_dash_key(self): repo = MagicMock(spec=GitRepository) repo.dashboard_id = 99 key = derive_dash_key(repo) assert key == "dash_99" # #endregion # #endregion # #region Test.StructureSnapshot.SemverCommitValidation [C:2] [TYPE Class] class TestSchemaValidators: """Test that request schemas enforce their validation rules.""" # #region Test.StructureSnapshot.SemverCommitValidation.TestCaptureRequest def test_capture_request_empty_release_id(self): with pytest.raises(ValueError, match="non-empty"): SnapshotCaptureRequest(release_id="") def test_capture_request_valid(self): req = SnapshotCaptureRequest(release_id="abc-123") assert req.release_id == "abc-123" # #endregion # #region Test.StructureSnapshot.SemverCommitValidation.TestDiffRequest def test_diff_request_same_ids(self): with pytest.raises(ValueError, match="must differ"): SnapshotDiffRequest(release_id_from="same", release_id_to="same") def test_diff_request_empty(self): with pytest.raises(ValueError, match="non-empty"): SnapshotDiffRequest(release_id_from="", release_id_to="other") def test_diff_request_valid(self): req = SnapshotDiffRequest(release_id_from="id-1", release_id_to="id-2") assert req.release_id_from == "id-1" assert req.release_id_to == "id-2" # #endregion # #endregion # #region Test.StructureSnapshot.Capture [C:3] [TYPE Class] class TestCaptureReleaseSnapshot: """Tests for capture_release_snapshot().""" # #region Test.StructureSnapshot.Capture.TestEnvironmentIdEmpty @pytest.mark.asyncio async def test_environment_id_empty_rejected(self): """Empty environment_id raises ValueError.""" db = MagicMock() with pytest.raises(ValueError, match="environment_id is required"): await capture_release_snapshot( SnapshotCaptureRequest(release_id="rel-1"), db=db, client=MagicMock(), environment_id="", ) @pytest.mark.asyncio async def test_environment_id_unknown_rejected(self): """'unknown' environment_id raises ValueError.""" db = MagicMock() with pytest.raises(ValueError, match="environment_id is required"): await capture_release_snapshot( SnapshotCaptureRequest(release_id="rel-1"), db=db, client=MagicMock(), environment_id="unknown", ) @pytest.mark.asyncio async def test_environment_id_none_rejected(self): """None environment_id raises ValueError.""" db = MagicMock() with pytest.raises(ValueError, match="environment_id is required"): await capture_release_snapshot( SnapshotCaptureRequest(release_id="rel-1"), db=db, client=MagicMock(), environment_id=None, # type: ignore[arg-type] ) # #endregion # #region Test.StructureSnapshot.Capture.TestReleaseNotFound @pytest.mark.asyncio async def test_release_not_found(self): """Unknown release_id raises ValueError.""" db = MagicMock() db.query.return_value.filter.return_value.first.return_value = None with pytest.raises(ValueError, match="not found"): await capture_release_snapshot( SnapshotCaptureRequest(release_id="nonexistent"), db=db, client=MagicMock(), environment_id="env-1", ) # #endregion # #region Test.StructureSnapshot.Capture.TestSemverInvalid @pytest.mark.asyncio async def test_release_invalid_semver(self): """Release with non-v-prefixed version raises ValueError.""" release = MagicMock(spec=DashboardRelease) release.id = "rel-1" release.version = "1.0.0" # missing v prefix release.commit_hash = "a" * 40 release.repository_id = "repo-1" db = MagicMock() db.query.return_value.filter.return_value.first.return_value = release with pytest.raises(ValueError, match="v-prefixed SemVer"): await capture_release_snapshot( SnapshotCaptureRequest(release_id="rel-1"), db=db, client=MagicMock(), environment_id="env-1", ) # #endregion # #region Test.StructureSnapshot.Capture.TestCommitInvalid @pytest.mark.asyncio async def test_release_invalid_commit(self): """Release with non-canonical commit hash raises ValueError.""" release = MagicMock(spec=DashboardRelease) release.id = "rel-1" release.version = "v1.0.0" release.commit_hash = "xyz123" # invalid hex release.repository_id = "repo-1" db = MagicMock() db.query.return_value.filter.return_value.first.return_value = release with pytest.raises(ValueError, match="40-char git SHA"): await capture_release_snapshot( SnapshotCaptureRequest(release_id="rel-1"), db=db, client=MagicMock(), environment_id="env-1", ) # #endregion # #region Test.StructureSnapshot.Capture.TestRepoNotFound @pytest.mark.asyncio async def test_repository_not_found(self): """Release's GitRepository not found raises ValueError.""" release = MagicMock(spec=DashboardRelease) release.id = "rel-1" release.version = "v1.0.0" release.commit_hash = "a" * 40 release.repository_id = "repo-1" db = MagicMock() # First call returns release, second call (for repository) returns None db.query.return_value.filter.side_effect = [ MagicMock(first=MagicMock(return_value=release)), MagicMock(first=MagicMock(return_value=None)), ] # Override the mock properly db.query.side_effect = None db.query.return_value.filter.return_value.first.side_effect = [ release, None ] with pytest.raises(ValueError, match="not found"): await capture_release_snapshot( SnapshotCaptureRequest(release_id="rel-1"), db=db, client=MagicMock(), environment_id="env-1", ) # #endregion # #region Test.StructureSnapshot.Capture.TestGitServiceUnavailable @pytest.mark.asyncio async def test_git_service_cannot_access_repo(self): """GitService cannot access the repository raises ValueError.""" release = MagicMock(spec=DashboardRelease) release.id = "rel-1" release.version = "v1.0.0" release.commit_hash = "a" * 40 release.repository_id = "repo-1" release.deployment = MagicMock() release.deployment.environment_id = "env-1" repo_record = MagicMock(spec=GitRepository) repo_record.id = "repo-1" repo_record.dashboard_id = 42 repo_record.local_path = "git_repos/my-project" db = MagicMock() db.query.return_value.filter.return_value.first.side_effect = [ release, repo_record ] git_service = MagicMock() git_service.get_repo.side_effect = Exception("Not cloned") with pytest.raises(ValueError, match="not accessible"): await capture_release_snapshot( SnapshotCaptureRequest(release_id="rel-1"), db=db, client=MagicMock(), environment_id="env-1", git_service=git_service, ) # #endregion # #region Test.StructureSnapshot.Capture.TestBlockingWarningsPreventPersist @pytest.mark.asyncio async def test_blocking_warnings_prevent_persistence(self): """Inspection with DASHBOARD_FETCH_FAILED warning does NOT persist.""" release = MagicMock(spec=DashboardRelease) release.id = "rel-1" release.version = "v1.0.0" release.commit_hash = "a" * 40 release.repository_id = "repo-1" release.deployment = MagicMock() release.deployment.environment_id = "env-1" repo_record = MagicMock(spec=GitRepository) repo_record.id = "repo-1" repo_record.dashboard_id = 42 repo_record.local_path = "git_repos/my-project" db = MagicMock() db.query.return_value.filter.return_value.first.side_effect = [ release, repo_record ] git_service = MagicMock() git_service.legacy_base_path = "/tmp/git_repos" git_service.get_repo.return_value = MagicMock() client = AsyncMock() # Mock inspect_dashboard_query_model to return a model with sentinel error 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 = "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 # #region Test.StructureSnapshot.Capture.TestSentinelFingerprintPreventsPersist @pytest.mark.asyncio async def test_sentinel_fingerprint_prevents_persistence(self): """Sentinel error fingerprint does NOT persist.""" release = MagicMock(spec=DashboardRelease) release.id = "rel-2" release.version = "v2.0.0" release.commit_hash = "b" * 40 release.repository_id = "repo-2" release.deployment = MagicMock() release.deployment.environment_id = "env-1" repo_record = MagicMock(spec=GitRepository) repo_record.id = "repo-2" repo_record.dashboard_id = 43 repo_record.local_path = "git_repos/other-project" db = MagicMock() db.query.return_value.filter.return_value.first.side_effect = [ release, repo_record ] git_service = MagicMock() git_service.legacy_base_path = "/tmp/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-2"), db=db, client=client, environment_id="env-1", git_service=git_service, ) # #endregion # #region Test.StructureSnapshot.Capture.TestSuccessfulCapture @pytest.mark.asyncio async def test_successful_capture(self, tmp_path): """Full capture → persist flow succeeds with valid release.""" release = MagicMock(spec=DashboardRelease) release.id = "rel-3" release.version = "v3.0.0" release.commit_hash = "c" * 40 release.repository_id = "repo-3" release.deployment = MagicMock() release.deployment.environment_id = "env-1" repo_record = MagicMock(spec=GitRepository) repo_record.id = "repo-3" repo_record.dashboard_id = 44 repo_record.local_path = "git_repos/success-project" 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=44, title="Test Dashboard", charts=[], datasets=[], native_filters=[], query_model_fingerprint="sha256:valid_fp", ) mock_inspect.return_value = model response = await capture_release_snapshot( SnapshotCaptureRequest(release_id="rel-3"), db=db, client=client, environment_id="env-1", git_service=git_service, dashboard_id_override=44, ) assert isinstance(response, SnapshotCaptureResponse) assert response.release_version == "v3.0.0" assert response.environment_id == "env-1" assert response.query_model_fingerprint == "sha256:valid_fp" assert response.snapshot_path is not None assert "v3.0.0.json" in response.snapshot_path or "v3.0.0" in response.snapshot_path # #endregion # #endregion #endregion Test.BaselineEngine.StructureSnapshot.Service