- 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
373 lines
15 KiB
Python
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
|