Files
ss-tools/backend/tests/services/test_structure_snapshot_service.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

497 lines
20 KiB
Python

#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