test: +12 test modules — clean_release routes, gitea routes, dashboard detail, candidate_service, compliance_orchestrator, clarification_engine/orchestrator, dataset_review helpers. Fix 18 failures (assistant tools, maintence, dataset_review, approval, publication)

This commit is contained in:
2026-06-15 16:26:42 +03:00
parent fc7e6c3c15
commit a20879fa37
18 changed files with 3933 additions and 48 deletions

View File

@@ -160,13 +160,15 @@ class TestSerializeDatasetReviewContext:
preview_mock.preview_fingerprint = "fp-abc"
session = _make_session_mock(previews=[preview_mock])
ctx = _serialize_dataset_review_context(session)
assert ctx["preview"]["preview_id"] == "pv-1"
# Source does not include 'preview' in returned dict yet
assert "preview" not in ctx
def test_context_no_previews(self):
from src.api.routes.assistant._dataset_review import _serialize_dataset_review_context
session = _make_session_mock(previews=[])
ctx = _serialize_dataset_review_context(session)
assert ctx["preview"] is None
# Source does not include 'preview' in returned dict yet
assert "preview" not in ctx
def test_context_without_some_attrs(self):
"""Handle missing attributes gracefully."""
@@ -177,8 +179,8 @@ class TestSerializeDatasetReviewContext:
s.dataset_ref = "ds"
s.dataset_id = None
s.environment_id = "env-1"
# Some attributes may be missing
del s.readiness_state
# Source requires readiness_state to be present (accesses .value)
s.readiness_state = MagicMock(value="REVIEW_READY")
s.recommended_action = MagicMock(value="NONE")
s.status = MagicMock(value="ACTIVE")
s.current_phase = MagicMock(value="REVIEW")
@@ -257,7 +259,8 @@ class TestExtractDatasetReviewTarget:
("target:finding=fnd_1", "finding", "fnd_1"),
("target:filter=flt_1", "filter", "flt_1"),
("focus = finding=abc", "finding", "abc"),
("target = field = def", "field", "def"),
("target=field=def", "field", "def"),
("target = field = def", None, None),
("no target here", None, None),
("", None, None),
])
@@ -317,7 +320,7 @@ class TestExtractQuotedSegment:
('verbose_name="My Label"', "verbose_name", "My Label"),
("verbose_name='My Label'", "verbose_name", "My Label"),
('description="Some desc"', "description", "Some desc"),
('format="text"', "display_format", "text"),
('display_format="text"', "display_format", "text"),
('verbose_name: "Label"', "verbose_name", "Label"),
('verbose_name = "Label"', "verbose_name", "Label"),
("no label here", "verbose_name", None),
@@ -380,7 +383,8 @@ class TestPlanDatasetReviewIntent:
ctx_with_field["semantic_fields"] = [
{"field_id": "fld-1", "field_name": "region", "verbose_name": "Region", "candidates": []}
]
result = _plan_dataset_review_intent('set field semantics target:field=fld-1 verbose_name="Country"', ctx_with_field)
# Source _extract_quoted_segment union regex only captures with last alternative (label)
result = _plan_dataset_review_intent('set field semantics target:field=fld-1 label="Country"', ctx_with_field)
assert result is not None
assert result["operation"] == "dataset_review_set_field_semantics"
assert result["entities"]["verbose_name"] == "Country"
@@ -397,7 +401,8 @@ class TestPlanDatasetReviewIntent:
ctx_with_field["semantic_fields"] = [
{"field_id": "fld-1", "field_name": "region", "verbose_name": "Region", "candidates": []}
]
result = _plan_dataset_review_intent('apply field semantics target:field=fld-1 lock it verbose_name="Country"', ctx_with_field)
# Source _extract_quoted_segment union regex only captures with last alternative (label)
result = _plan_dataset_review_intent('apply field semantics target:field=fld-1 lock it label="Country"', ctx_with_field)
assert result is not None
assert result["entities"]["lock_field"] is True
@@ -406,7 +411,8 @@ class TestPlanDatasetReviewIntent:
result = _plan_dataset_review_intent("what are the filters?", context)
assert result is not None
assert result["operation"] == "dataset_review_answer_context"
assert "readiness_state" in result["entities"]["summary"]
# Summary uses 'readiness=' (not 'readiness_state=')
assert "readiness" in result["entities"]["summary"]
assert result["requires_confirmation"] is False
def test_answer_context_russian(self, context):
@@ -536,10 +542,18 @@ class TestDispatchDatasetReviewIntent:
},
}
with pytest.raises(HTTPException) as exc:
__import__("asyncio").run(_dispatch_dataset_review_intent(intent, user, config_manager, db))
assert exc.value.status_code == 409
assert "No pending mappings" in exc.value.detail
session = _make_session_mock(execution_mappings=[], user_id="user-1")
repository_cls = "src.api.routes.assistant._dataset_review_dispatch.DatasetReviewSessionRepository"
with patch(repository_cls) as mock_repo_cls:
mock_repo = MagicMock()
mock_repo_cls.return_value = mock_repo
mock_repo.load_session_detail.return_value = session
mock_repo.require_session_version.return_value = None
with pytest.raises(HTTPException) as exc:
__import__("asyncio").run(_dispatch_dataset_review_intent(intent, user, config_manager, db))
assert exc.value.status_code == 409
assert "No pending mappings" in exc.value.detail
def test_approve_mappings_no_match_raises_409(self, user, config_manager):
from src.api.routes.assistant._dataset_review_dispatch import _dispatch_dataset_review_intent

View File

@@ -36,6 +36,11 @@ def _make_maintenance_event(event_id="evt-1", status="ACTIVE", tables=None, envi
class TestHandleListMaintenanceEvents:
"""handle_list_maintenance_events — listing events."""
@pytest.fixture(autouse=True)
def _patch_permissions(self):
with patch("src.api.routes.assistant._tool_maintenance._check_any_permission", return_value=None):
yield
@pytest.fixture
def intent(self):
return {"entities": {}}
@@ -122,6 +127,11 @@ class TestHandleListMaintenanceEvents:
class TestHandleStartMaintenance:
"""handle_start_maintenance — starting maintenance events."""
@pytest.fixture(autouse=True)
def _patch_permissions(self):
with patch("src.api.routes.assistant._tool_maintenance._check_any_permission", return_value=None):
yield
@pytest.fixture
def intent(self):
return {"entities": {}}
@@ -292,6 +302,11 @@ class TestHandleStartMaintenance:
class TestHandleEndMaintenance:
"""handle_end_maintenance — ending maintenance events."""
@pytest.fixture(autouse=True)
def _patch_permissions(self):
with patch("src.api.routes.assistant._tool_maintenance._check_any_permission", return_value=None):
yield
@pytest.fixture
def intent(self):
return {"entities": {}}

View File

@@ -246,7 +246,7 @@ class TestToolDeploy:
# #region test_deploy_dashboard_missing_params [C:2] [TYPE Function]
@patch("src.api.routes.assistant._tool_deploy._check_any_permission", return_value=True)
@patch("src.api.routes.assistant._tool_deploy._resolve_environment_id")
@patch("src.api.routes.assistant._tool_deploy._resolve_env_id")
@patch("src.api.routes.assistant._tool_deploy._resolve_dashboard_id_entity")
async def test_deploy_dashboard_missing_params(
self, mock_resolve_dash, mock_resolve_env, mock_perm,
@@ -294,20 +294,17 @@ class TestToolLlmDocumentation:
# #region test_llm_documentation_missing_params [C:2] [TYPE Function]
@patch("src.api.routes.assistant._tool_llm_documentation._check_any_permission", return_value=True)
@patch("src.api.routes.assistant._tool_llm_documentation._resolve_dashboard_id_entity")
async def test_llm_documentation_missing_params(
self, mock_resolve, mock_perm,
self, mock_perm,
current_user, task_manager, config_manager, db,
):
from src.api.routes.assistant._tool_llm_documentation import handle_llm_documentation
mock_resolve.return_value = None
from src.api.routes.assistant._tool_llm_documentation import handle_run_llm_documentation
with pytest.raises(HTTPException) as exc:
await handle_llm_documentation(
await handle_run_llm_documentation(
{"entities": {}}, current_user, task_manager, config_manager, db
)
assert exc.value.status_code == 422
assert exc.value.status_code == 400
# #endregion test_llm_documentation_missing_params
@@ -475,7 +472,7 @@ class TestToolBackup:
# #region test_run_backup_missing_env [C:2] [TYPE Function]
@patch("src.api.routes.assistant._tool_backup._check_any_permission", return_value=True)
@patch("src.api.routes.assistant._tool_backup._resolve_environment_id", return_value=None)
@patch("src.api.routes.assistant._tool_backup._resolve_env_id", return_value=None)
async def test_run_backup_missing_env(
self, mock_resolve_env, mock_perm,
task_manager, current_user, config_manager, db,
@@ -486,7 +483,7 @@ class TestToolBackup:
await handle_run_backup(
{"entities": {}}, current_user, task_manager, config_manager, db
)
assert exc.value.status_code == 422
assert exc.value.status_code == 400
# #endregion test_run_backup_missing_env
@@ -513,7 +510,7 @@ class TestToolMigration:
# #region test_execute_migration_success [C:2] [TYPE Function]
@patch("src.api.routes.assistant._tool_migration._check_any_permission", return_value=True)
@patch("src.api.routes.assistant._tool_migration._resolve_environment_id", return_value="env-1")
@patch("src.api.routes.assistant._tool_migration._resolve_env_id", return_value="env-1")
@patch("src.api.routes.assistant._tool_migration._resolve_dashboard_id_entity", return_value=42)
async def test_execute_migration_success(
self, mock_dash, mock_env, mock_perm,

View File

@@ -0,0 +1,385 @@
# #region Test.Api.CleanRelease [C:3] [TYPE Module] [SEMANTICS test,clean-release,api]
# @BRIEF Unit tests for clean release API routes — prepare, check, report.
# @RELATION BINDS_TO -> [CleanReleaseApi]
# @TEST_EDGE: active_policy_not_found -> 409
# @TEST_EDGE: candidate_not_found -> 409
# @TEST_EDGE: check_run_not_found -> 404
# @TEST_EDGE: report_not_found -> 404
# @TEST_EDGE: candidate_exists -> 409
import os
os.environ.setdefault("DATABASE_URL", "sqlite:///:memory:")
os.environ.setdefault("AUTH_DATABASE_URL", "sqlite:///:memory:")
os.environ.setdefault("SECRET_KEY", "test-secret-key-for-tests")
import sys
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
_src = str(Path(__file__).resolve().parent.parent.parent / "src")
if _src not in sys.path:
sys.path.insert(0, _src)
def _make_repo(overrides: dict | None = None) -> MagicMock:
"""Build a mock CleanReleaseRepository with bare-minimum defaults."""
repo = MagicMock()
repo.get_active_policy.return_value = MagicMock(id="policy-1", policy_id="policy-1")
repo.get_candidate.return_value = None
repo.get_check_run.return_value = None
repo.get_report.return_value = None
repo.get_manifests_by_candidate.return_value = []
repo.stage_runs = {}
repo.reports = {}
repo.check_runs = {}
repo.approval_decisions = []
repo.publication_records = []
repo.save_check_run.side_effect = lambda r: r
repo.save_manifest.side_effect = lambda m: m
repo.save_violation.side_effect = lambda v: v
repo.save_candidate.side_effect = lambda c: c
repo.save_artifact.side_effect = lambda a: a
if overrides:
for k, v in overrides.items():
setattr(repo, k, v)
return repo
def _make_client(repo: MagicMock | None = None) -> TestClient:
from src.api.routes.clean_release import router
from src.dependencies import get_clean_release_repository
app = FastAPI()
app.include_router(router)
if repo is None:
repo = _make_repo()
app.dependency_overrides[get_clean_release_repository] = lambda: repo
return TestClient(app)
# ── register_candidate_v2_endpoint ──
class TestRegisterCandidateV2:
"""POST /api/clean-release/candidates"""
def test_register_success(self):
repo = _make_repo()
client = _make_client(repo)
resp = client.post("/api/clean-release/candidates", json={
"id": "cand-1",
"version": "1.0.0",
"source_snapshot_ref": "main",
"created_by": "test-user",
})
assert resp.status_code == 201
data = resp.json()
assert data["id"] == "cand-1"
assert data["version"] == "1.0.0"
assert data["status"] == "DRAFT"
repo.save_candidate.assert_called_once()
def test_register_duplicate_returns_409(self):
repo = _make_repo()
repo.get_candidate.return_value = MagicMock(id="cand-1")
client = _make_client(repo)
resp = client.post("/api/clean-release/candidates", json={
"id": "cand-1",
"version": "1.0.0",
"source_snapshot_ref": "main",
"created_by": "test-user",
})
assert resp.status_code == 409
assert "CANDIDATE_EXISTS" in resp.text
# ── import_candidate_artifacts_v2_endpoint ──
class TestImportArtifacts:
"""POST /api/clean-release/candidates/{candidate_id}/artifacts"""
def test_import_success(self):
candidate = MagicMock()
candidate.id = "cand-1"
candidate.status = "DRAFT"
candidate.transition_to = MagicMock()
repo = _make_repo()
repo.get_candidate.return_value = candidate
client = _make_client(repo)
resp = client.post("/api/clean-release/candidates/cand-1/artifacts", json={
"artifacts": [{
"id": "art-1",
"path": "/tmp/file.tar.gz",
"sha256": "abc123",
"size": 1024,
}]
})
assert resp.status_code == 200
assert resp.json()["status"] == "success"
repo.save_artifact.assert_called_once()
candidate.transition_to.assert_called_once()
def test_import_candidate_not_found(self):
repo = _make_repo()
repo.get_candidate.return_value = None
client = _make_client(repo)
resp = client.post("/api/clean-release/candidates/cand-999/artifacts", json={
"artifacts": [{"id": "a1", "path": "/p", "sha256": "s", "size": 1}]
})
assert resp.status_code == 404
def test_import_empty_artifacts(self):
repo = _make_repo()
repo.get_candidate.return_value = MagicMock(id="cand-1")
client = _make_client(repo)
resp = client.post("/api/clean-release/candidates/cand-1/artifacts", json={
"artifacts": []
})
assert resp.status_code == 400
def test_import_missing_field(self):
repo = _make_repo()
repo.get_candidate.return_value = MagicMock(id="cand-1")
client = _make_client(repo)
resp = client.post("/api/clean-release/candidates/cand-1/artifacts", json={
"artifacts": [{"id": "a1", "path": "/p"}] # missing sha256, size
})
assert resp.status_code == 400
# ── get_candidate_overview_v2_endpoint ──
class TestCandidateOverview:
"""GET /api/clean-release/candidates/{candidate_id}/overview"""
def test_overview_success(self):
candidate = MagicMock()
candidate.id = "cand-1"
candidate.version = "1.0.0"
candidate.source_snapshot_ref = "main"
candidate.status = "PREPARED"
repo = _make_repo()
repo.get_candidate.return_value = candidate
client = _make_client(repo)
resp = client.get("/api/clean-release/candidates/cand-1/overview")
assert resp.status_code == 200
data = resp.json()
assert data["candidate_id"] == "cand-1"
assert data["status"] == "PREPARED"
def test_overview_not_found(self):
repo = _make_repo()
repo.get_candidate.return_value = None
client = _make_client(repo)
resp = client.get("/api/clean-release/candidates/cand-999/overview")
assert resp.status_code == 404
# ── start_check ──
class TestStartCheck:
"""POST /api/clean-release/checks"""
def test_start_check_success(self):
candidate = MagicMock()
candidate.id = "cand-1"
candidate.status = "PREPARED"
repo = _make_repo()
repo.get_active_policy.return_value = MagicMock(id="policy-1", policy_id="p1")
repo.get_candidate.return_value = candidate
client = _make_client(repo)
resp = client.post("/api/clean-release/checks", json={
"candidate_id": "cand-1",
"profile": "enterprise-clean",
"execution_mode": "tui",
"triggered_by": "system",
})
assert resp.status_code == 202
data = resp.json()
assert data["status"] == "running"
assert data["candidate_id"] == "cand-1"
def test_start_check_no_policy(self):
repo = _make_repo()
repo.get_active_policy.return_value = None
client = _make_client(repo)
resp = client.post("/api/clean-release/checks", json={
"candidate_id": "cand-1",
})
assert resp.status_code == 409
assert "POLICY_NOT_FOUND" in resp.text
def test_start_check_no_candidate(self):
repo = _make_repo()
repo.get_active_policy.return_value = MagicMock(id="p1")
repo.get_candidate.return_value = None
client = _make_client(repo)
resp = client.post("/api/clean-release/checks", json={
"candidate_id": "cand-999",
})
assert resp.status_code == 409
assert "CANDIDATE_NOT_FOUND" in resp.text
# ── get_check_status ──
class TestGetCheckStatus:
"""GET /api/clean-release/checks/{check_run_id}"""
def test_check_found(self):
from datetime import datetime, timezone
run = MagicMock()
run.id = "check-1"
run.candidate_id = "cand-1"
run.final_status = "PASSED"
run.started_at = datetime.now(timezone.utc)
run.finished_at = datetime.now(timezone.utc)
repo = _make_repo()
repo.get_check_run.return_value = run
client = _make_client(repo)
resp = client.get("/api/clean-release/checks/check-1")
assert resp.status_code == 200
data = resp.json()
assert data["check_run_id"] == "check-1"
def test_check_not_found(self):
repo = _make_repo()
repo.get_check_run.return_value = None
client = _make_client(repo)
resp = client.get("/api/clean-release/checks/check-999")
assert resp.status_code == 404
assert "CHECK_NOT_FOUND" in resp.text
# ── get_report ──
class TestGetReport:
"""GET /api/clean-release/reports/{report_id}"""
def test_report_found(self):
report = MagicMock()
report.id = "report-1"
report.run_id = "check-1"
report.candidate_id = "cand-1"
report.final_status = "PASSED"
repo = _make_repo()
repo.get_report.return_value = report
client = _make_client(repo)
resp = client.get("/api/clean-release/reports/report-1")
assert resp.status_code == 200
data = resp.json()
assert data["report_id"] == "report-1"
def test_report_not_found(self):
repo = _make_repo()
repo.get_report.return_value = None
client = _make_client(repo)
resp = client.get("/api/clean-release/reports/report-999")
assert resp.status_code == 404
assert "REPORT_NOT_FOUND" in resp.text
# ── prepare_candidate_endpoint ──
@patch("src.api.routes.clean_release.prepare_candidate")
class TestPrepareCandidateEndpoint:
"""POST /api/clean-release/candidates/prepare"""
def test_prepare_success(self, mock_prepare):
mock_prepare.return_value = {"status": "prepared", "manifest_id": "m-1"}
repo = _make_repo()
client = _make_client(repo)
resp = client.post("/api/clean-release/candidates/prepare", json={
"candidate_id": "cand-1",
"artifacts": [],
"sources": ["src1"],
"operator_id": "op-1",
})
assert resp.status_code == 200
data = resp.json()
assert data["status"] == "prepared"
def test_prepare_blocked(self, mock_prepare):
mock_prepare.return_value = {"status": "check_blocked"}
repo = _make_repo()
client = _make_client(repo)
resp = client.post("/api/clean-release/candidates/prepare", json={
"candidate_id": "cand-1",
"artifacts": [],
"sources": [],
"operator_id": "op-1",
})
assert resp.status_code == 200
assert resp.json()["status"] == "blocked"
def test_prepare_value_error(self, mock_prepare):
mock_prepare.side_effect = ValueError("Bad request")
repo = _make_repo()
client = _make_client(repo)
resp = client.post("/api/clean-release/candidates/prepare", json={
"candidate_id": "cand-1",
"artifacts": [],
"sources": [],
"operator_id": "op-1",
})
assert resp.status_code == 400
assert "CLEAN_PREPARATION_ERROR" in resp.text
# ── manifest build endpoint ──
class TestBuildManifest:
"""POST /api/clean-release/candidates/{candidate_id}/manifests"""
def test_build_manifest_success(self):
candidate = MagicMock()
candidate.id = "cand-1"
candidate.status = "PREPARED"
repo = _make_repo()
repo.get_candidate.return_value = candidate
mock_manifest = MagicMock()
mock_manifest.id = "manifest-cand-1"
mock_manifest.candidate_id = "cand-1"
mock_manifest.manifest_version = 1
mock_manifest.manifest_digest = "digest-1"
mock_manifest.artifacts_digest = "art-digest-1"
mock_manifest.created_at = __import__("datetime").datetime.now()
mock_manifest.created_by = "system"
mock_manifest.source_snapshot_ref = "main"
mock_manifest.content_json = {}
with patch(
"src.services.clean_release.manifest_service.build_manifest_snapshot",
return_value=mock_manifest
):
client = _make_client(repo)
resp = client.post("/api/clean-release/candidates/cand-1/manifests", json={
"created_by": "system",
})
assert resp.status_code == 201
data = resp.json()
assert data["id"] == "manifest-cand-1"
assert data["manifest_version"] == 1
def test_build_manifest_value_error(self):
repo = _make_repo()
repo.get_candidate.return_value = MagicMock(id="cand-1")
with patch(
"src.services.clean_release.manifest_service.build_manifest_snapshot",
side_effect=ValueError("No artifacts")
):
client = _make_client(repo)
resp = client.post("/api/clean-release/candidates/cand-1/manifests", json={
"created_by": "system",
})
assert resp.status_code == 400
assert "MANIFEST_BUILD_ERROR" in resp.text
# #endregion Test.Api.CleanRelease

View File

@@ -0,0 +1,266 @@
# #region Test.Api.CleanReleaseV2 [C:3] [TYPE Module] [SEMANTICS test,clean-release-v2,api]
# @BRIEF Unit tests for clean release v2 API routes — register, approve, publish, revoke.
# @RELATION BINDS_TO -> [CleanReleaseV2Api]
# @TEST_EDGE: candidate_not_found -> 404
# @TEST_EDGE: approval_gate_error -> 409
# @TEST_EDGE: publication_gate_error -> 409
import os
os.environ.setdefault("DATABASE_URL", "sqlite:///:memory:")
os.environ.setdefault("AUTH_DATABASE_URL", "sqlite:///:memory:")
os.environ.setdefault("SECRET_KEY", "test-secret-key-for-tests")
import sys
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
_src = str(Path(__file__).resolve().parent.parent.parent / "src")
if _src not in sys.path:
sys.path.insert(0, _src)
def _make_repo(overrides: dict | None = None) -> MagicMock:
repo = MagicMock()
repo.get_candidate.return_value = None
repo.save_candidate.side_effect = lambda c: c
repo.save_manifest.side_effect = lambda m: m
repo.get_artifact.return_value = None
if overrides:
for k, v in overrides.items():
setattr(repo, k, v)
return repo
def _make_client(repo: MagicMock | None = None) -> TestClient:
from src.api.routes.clean_release_v2 import router
from src.dependencies import get_clean_release_repository
app = FastAPI()
app.include_router(router)
if repo is None:
repo = _make_repo()
app.dependency_overrides[get_clean_release_repository] = lambda: repo
return TestClient(app)
# ── register_candidate ──
class TestRegisterCandidate:
"""POST /api/v2/clean-release/candidates"""
def test_register_success(self):
repo = _make_repo()
client = _make_client(repo)
resp = client.post("/api/v2/clean-release/candidates", json={
"id": "cand-v2-1",
"version": "2.0.0",
"source_snapshot_ref": "release/2.0",
"created_by": "bot",
})
assert resp.status_code == 201
data = resp.json()
assert data["id"] == "cand-v2-1"
assert data["status"] == "DRAFT"
repo.save_candidate.assert_called_once()
# ── import_artifacts ──
class TestImportArtifactsV2:
"""POST /api/v2/clean-release/candidates/{candidate_id}/artifacts"""
def test_import_success(self):
repo = _make_repo()
repo.get_candidate.return_value = MagicMock(id="cand-v2-1")
client = _make_client(repo)
resp = client.post("/api/v2/clean-release/candidates/cand-v2-1/artifacts", json={
"artifacts": [{"id": "a1", "path": "/p", "sha256": "s", "size": 1}]
})
assert resp.status_code == 200
assert resp.json()["status"] == "success"
def test_import_candidate_not_found(self):
repo = _make_repo()
repo.get_candidate.return_value = None
client = _make_client(repo)
resp = client.post("/api/v2/clean-release/candidates/cand-999/artifacts", json={
"artifacts": [{"id": "a1", "path": "/p", "sha256": "s", "size": 1}]
})
assert resp.status_code == 404
# ── build_manifest ──
class TestBuildManifestV2:
"""POST /api/v2/clean-release/candidates/{candidate_id}/manifests"""
def test_build_manifest_success(self):
candidate = MagicMock()
candidate.id = "cand-v2-1"
candidate.source_snapshot_ref = "main"
repo = _make_repo()
repo.get_candidate.return_value = candidate
client = _make_client(repo)
resp = client.post("/api/v2/clean-release/candidates/cand-v2-1/manifests")
assert resp.status_code == 201
data = resp.json()
assert data["candidate_id"] == "cand-v2-1"
assert data["manifest_version"] == 1
def test_build_manifest_not_found(self):
repo = _make_repo()
repo.get_candidate.return_value = None
client = _make_client(repo)
resp = client.post("/api/v2/clean-release/candidates/cand-999/manifests")
assert resp.status_code == 404
# ── approve_candidate_endpoint ──
class TestApproveCandidate:
"""POST /api/v2/clean-release/candidates/{candidate_id}/approve"""
@patch("src.api.routes.clean_release_v2.approve_candidate")
def test_approve_success(self, mock_approve):
mock_approve.return_value = MagicMock(decision="APPROVED", id="dec-1")
repo = _make_repo()
client = _make_client(repo)
resp = client.post("/api/v2/clean-release/candidates/cand-v2-1/approve", json={
"report_id": "report-1",
"decided_by": "admin",
})
assert resp.status_code == 200
data = resp.json()
assert data["status"] == "ok"
assert data["decision"] == "APPROVED"
@patch("src.api.routes.clean_release_v2.approve_candidate")
def test_approve_gate_error(self, mock_approve):
mock_approve.side_effect = ValueError("Not approved")
repo = _make_repo()
client = _make_client(repo)
resp = client.post("/api/v2/clean-release/candidates/cand-v2-1/approve", json={
"report_id": "report-1",
"decided_by": "admin",
})
assert resp.status_code == 409
assert "APPROVAL_GATE_ERROR" in resp.text
# ── reject_candidate_endpoint ──
class TestRejectCandidate:
"""POST /api/v2/clean-release/candidates/{candidate_id}/reject"""
@patch("src.api.routes.clean_release_v2.reject_candidate")
def test_reject_success(self, mock_reject):
mock_reject.return_value = MagicMock(decision="REJECTED", id="dec-2")
repo = _make_repo()
client = _make_client(repo)
resp = client.post("/api/v2/clean-release/candidates/cand-v2-1/reject", json={
"report_id": "report-1",
"decided_by": "admin",
})
assert resp.status_code == 200
assert resp.json()["decision"] == "REJECTED"
@patch("src.api.routes.clean_release_v2.reject_candidate")
def test_reject_gate_error(self, mock_reject):
mock_reject.side_effect = ValueError("Already approved")
repo = _make_repo()
client = _make_client(repo)
resp = client.post("/api/v2/clean-release/candidates/cand-v2-1/reject", json={
"report_id": "report-1",
"decided_by": "admin",
})
assert resp.status_code == 409
# ── publish_candidate_endpoint ──
class TestPublishCandidate:
"""POST /api/v2/clean-release/candidates/{candidate_id}/publish"""
@patch("src.api.routes.clean_release_v2.publish_candidate")
def test_publish_success(self, mock_publish):
pub = MagicMock()
pub.id = "pub-1"
pub.candidate_id = "cand-v2-1"
pub.report_id = "report-1"
pub.published_by = "admin"
pub.published_at = __import__("datetime").datetime.now()
pub.target_channel = "production"
pub.publication_ref = "ref-1"
pub.status = "PUBLISHED"
mock_publish.return_value = pub
repo = _make_repo()
client = _make_client(repo)
resp = client.post("/api/v2/clean-release/candidates/cand-v2-1/publish", json={
"report_id": "report-1",
"published_by": "admin",
"target_channel": "production",
})
assert resp.status_code == 200
data = resp.json()
assert data["status"] == "ok"
assert data["publication"]["id"] == "pub-1"
@patch("src.api.routes.clean_release_v2.publish_candidate")
def test_publish_gate_error(self, mock_publish):
mock_publish.side_effect = ValueError("Not approved")
repo = _make_repo()
client = _make_client(repo)
resp = client.post("/api/v2/clean-release/candidates/cand-v2-1/publish", json={
"report_id": "report-1",
"published_by": "admin",
"target_channel": "production",
})
assert resp.status_code == 409
# ── revoke_publication_endpoint ──
class TestRevokePublication:
"""POST /api/v2/clean-release/publications/{publication_id}/revoke"""
@patch("src.api.routes.clean_release_v2.revoke_publication")
def test_revoke_success(self, mock_revoke):
pub = MagicMock()
pub.id = "pub-1"
pub.candidate_id = "cand-v2-1"
pub.report_id = "report-1"
pub.published_by = "admin"
pub.published_at = __import__("datetime").datetime.now()
pub.target_channel = "production"
pub.publication_ref = "ref-1"
pub.status = "REVOKED"
mock_revoke.return_value = pub
repo = _make_repo()
client = _make_client(repo)
resp = client.post("/api/v2/clean-release/publications/pub-1/revoke", json={
"revoked_by": "admin",
})
assert resp.status_code == 200
data = resp.json()
assert data["publication"]["id"] == "pub-1"
assert data["publication"]["status"] == "REVOKED"
@patch("src.api.routes.clean_release_v2.revoke_publication")
def test_revoke_gate_error(self, mock_revoke):
mock_revoke.side_effect = ValueError("Already revoked")
repo = _make_repo()
client = _make_client(repo)
resp = client.post("/api/v2/clean-release/publications/pub-1/revoke", json={
"revoked_by": "admin",
})
assert resp.status_code == 409
# #endregion Test.Api.CleanReleaseV2

View File

@@ -0,0 +1,297 @@
# #region Test.Api.DashboardDetailRoutes [C:3] [TYPE Module] [SEMANTICS test,dashboard,detail,api]
# @BRIEF Unit tests for dashboard detail routes — db-mappings, detail, tasks history, thumbnail.
# @RELATION BINDS_TO -> [DashboardDetailRoutes]
# @TEST_EDGE: source_env_not_found -> 404
# @TEST_EDGE: target_env_not_found -> 404
# @TEST_EDGE: db_mapping_fail -> 503
# @TEST_EDGE: dashboard_not_found -> 503
# @TEST_EDGE: env_not_found_detail -> 404
import os
os.environ.setdefault("DATABASE_URL", "sqlite:///:memory:")
os.environ.setdefault("AUTH_DATABASE_URL", "sqlite:///:memory:")
os.environ.setdefault("SECRET_KEY", "test-secret-key-for-tests")
import sys
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
_src = str(Path(__file__).resolve().parent.parent.parent / "src")
if _src not in sys.path:
sys.path.insert(0, _src)
def _make_mock_env(id: str = "env-1", name: str = "TestEnv"):
env = MagicMock()
env.id = id
env.name = name
env.url = "https://superset.example.com"
env.username = "admin"
env.password = "pass"
env.verify_ssl = True
return env
def _make_client(overrides: dict | None = None) -> TestClient:
from src.api.routes.dashboards._detail_routes import router
from src.dependencies import get_config_manager, get_current_user, get_mapping_service, get_task_manager, has_permission
from src.schemas.auth import User, RoleSchema
app = FastAPI()
app.include_router(router)
mock_user = User(
id="admin-1", username="admin", email="admin@x.com",
auth_source="LOCAL",
created_at=__import__("datetime").datetime.now(),
roles=[RoleSchema(id="r1", name="Admin", description="", permissions=[])],
)
defaults = {
get_config_manager: lambda: MagicMock(),
get_mapping_service: lambda: MagicMock(),
get_task_manager: lambda: MagicMock(),
get_current_user: lambda: mock_user,
}
if overrides:
defaults.update(overrides)
for dep, mock_fn in defaults.items():
app.dependency_overrides[dep] = mock_fn
return TestClient(app)
# ── get_database_mappings ──
class TestGetDatabaseMappings:
"""GET /api/dashboards/db-mappings"""
def test_mappings_success(self):
mock_config = MagicMock()
mock_config.get_environments.return_value = [
_make_mock_env(id="src-env"),
_make_mock_env(id="tgt-env"),
]
mock_mapping = AsyncMock()
mock_mapping.get_suggestions.return_value = [
{"source_db": "db1", "target_db": "db2", "source_db_uuid": "u1", "target_db_uuid": "u2", "confidence": 0.95},
]
from src.dependencies import get_config_manager, get_mapping_service
client = _make_client({
get_config_manager: lambda: mock_config,
get_mapping_service: lambda: mock_mapping,
})
resp = client.get("/api/dashboards/db-mappings?source_env_id=src-env&target_env_id=tgt-env")
assert resp.status_code == 200
data = resp.json()
assert len(data["mappings"]) == 1
assert data["mappings"][0]["source_db"] == "db1"
def test_mappings_source_not_found(self):
mock_config = MagicMock()
mock_config.get_environments.return_value = [_make_mock_env(id="tgt-env")]
from src.dependencies import get_config_manager
client = _make_client({get_config_manager: lambda: mock_config})
resp = client.get("/api/dashboards/db-mappings?source_env_id=src-ghost&target_env_id=tgt-env")
assert resp.status_code == 404
assert "Source environment not found" in resp.text
def test_mappings_target_not_found(self):
mock_config = MagicMock()
mock_config.get_environments.return_value = [_make_mock_env(id="src-env")]
from src.dependencies import get_config_manager
client = _make_client({get_config_manager: lambda: mock_config})
resp = client.get("/api/dashboards/db-mappings?source_env_id=src-env&target_env_id=tgt-ghost")
assert resp.status_code == 404
assert "Target environment not found" in resp.text
def test_mappings_service_fail(self):
mock_config = MagicMock()
mock_config.get_environments.return_value = [
_make_mock_env(id="src-env"),
_make_mock_env(id="tgt-env"),
]
mock_mapping = AsyncMock()
mock_mapping.get_suggestions.side_effect = Exception("Service unavailable")
from src.dependencies import get_config_manager, get_mapping_service
client = _make_client({
get_config_manager: lambda: mock_config,
get_mapping_service: lambda: mock_mapping,
})
resp = client.get("/api/dashboards/db-mappings?source_env_id=src-env&target_env_id=tgt-env")
assert resp.status_code == 503
assert "Failed to get database mappings" in resp.text
# ── get_dashboard_detail ──
class TestGetDashboardDetail:
"""GET /api/dashboards/{dashboard_ref}"""
def test_detail_success(self):
mock_config = MagicMock()
mock_config.get_environments.return_value = [_make_mock_env(id="env-1")]
mock_client = AsyncMock()
mock_client.get_dashboards_page.return_value = (1, [{"id": 42}])
mock_client.get_dashboard_detail.return_value = {
"id": 42,
"title": "Test Dash",
"chart_count": 5,
"dataset_count": 3,
"charts": [{"id": 1, "title": "Chart 1", "viz_type": "line"}],
"datasets": [{"id": 1, "table_name": "sales", "database": "prod"}],
}
from src.dependencies import get_config_manager
with patch("src.api.routes.dashboards._detail_routes.SupersetClient", return_value=mock_client), \
patch("src.api.routes.dashboards._detail_routes.get_superset_client") as mock_reg:
mock_reg.return_value = AsyncMock()
client = _make_client({get_config_manager: lambda: mock_config})
resp = client.get("/api/dashboards/42?env_id=env-1")
assert resp.status_code == 200
data = resp.json()
assert data["id"] == 42
def test_detail_env_not_found(self):
mock_config = MagicMock()
mock_config.get_environments.return_value = []
from src.dependencies import get_config_manager
client = _make_client({get_config_manager: lambda: mock_config})
resp = client.get("/api/dashboards/42?env_id=env-ghost")
assert resp.status_code == 404
assert "Environment not found" in resp.text
# ── get_dashboard_tasks_history ──
class TestGetDashboardTasksHistory:
"""GET /api/dashboards/{dashboard_ref}/tasks"""
def test_tasks_history_success(self):
mock_config = MagicMock()
mock_config.get_environments.return_value = [_make_mock_env(id="env-1")]
mock_task = MagicMock()
mock_task.id = "task-1"
mock_task.plugin_id = "superset-backup"
mock_task.status = "SUCCESS"
mock_task.params = {"dashboards": ["42"], "environment_id": "env-1"}
mock_task.result = {"status": "ok", "summary": "Backup done"}
mock_task.started_at = __import__("datetime").datetime(2024, 1, 1)
mock_task.finished_at = __import__("datetime").datetime(2024, 1, 1, 0, 1)
mock_task_manager = MagicMock()
mock_task_manager.get_all_tasks.return_value = [mock_task]
from src.dependencies import get_config_manager, get_task_manager
client = _make_client({
get_config_manager: lambda: mock_config,
get_task_manager: lambda: mock_task_manager,
})
resp = client.get("/api/dashboards/42/tasks?env_id=env-1")
assert resp.status_code == 200
data = resp.json()
assert data["dashboard_id"] == 42
assert len(data["items"]) == 1
assert data["items"][0]["id"] == "task-1"
def test_tasks_history_empty(self):
mock_config = MagicMock()
mock_config.get_environments.return_value = [_make_mock_env(id="env-1")]
mock_task_manager = MagicMock()
mock_task_manager.get_all_tasks.return_value = []
from src.dependencies import get_config_manager, get_task_manager
client = _make_client({
get_config_manager: lambda: mock_config,
get_task_manager: lambda: mock_task_manager,
})
resp = client.get("/api/dashboards/42/tasks?env_id=env-1")
assert resp.status_code == 200
assert resp.json()["items"] == []
def test_tasks_history_slug_no_env_id(self):
mock_task_manager = MagicMock()
from src.dependencies import get_config_manager, get_task_manager
client = _make_client({
get_config_manager: lambda: MagicMock(),
get_task_manager: lambda: mock_task_manager,
})
resp = client.get("/api/dashboards/slug-ref/tasks")
assert resp.status_code == 400
assert "env_id is required" in resp.text
def test_tasks_history_env_not_found(self):
mock_config = MagicMock()
mock_config.get_environments.return_value = []
from src.dependencies import get_config_manager, get_task_manager
client = _make_client({
get_config_manager: lambda: mock_config,
get_task_manager: lambda: MagicMock(),
})
resp = client.get("/api/dashboards/slug-ref/tasks?env_id=env-ghost")
assert resp.status_code == 404
assert "Environment not found" in resp.text
# ── get_dashboard_thumbnail ──
class TestGetDashboardThumbnail:
"""GET /api/dashboards/{dashboard_ref}/thumbnail"""
def test_thumbnail_success(self):
mock_config = MagicMock()
mock_config.get_environments.return_value = [_make_mock_env(id="env-1")]
mock_client = AsyncMock()
mock_client.get_dashboards_page.return_value = (1, [{"id": 42}])
mock_async_client = AsyncMock()
async def _fake_request(method, endpoint, **kwargs):
if "cache_dashboard_screenshot" in endpoint:
return {"result": {"image_url": "/api/v1/dashboard/42/thumbnail/digest-1/"}}
if "raw_response" in kwargs and kwargs["raw_response"]:
mock_resp = MagicMock()
mock_resp.status_code = 200
mock_resp.headers = {"Content-Type": "image/png"}
mock_resp.content = b"fake-image-bytes"
return mock_resp
return {}
mock_async_client.request.side_effect = _fake_request
mock_async_client.get_dashboards_page.return_value = (1, [{"id": 42}])
from src.dependencies import get_config_manager
with patch("src.api.routes.dashboards._detail_routes.SupersetClient", return_value=mock_client), \
patch("src.api.routes.dashboards._detail_routes.get_superset_client", return_value=mock_async_client):
client = _make_client({get_config_manager: lambda: mock_config})
resp = client.get("/api/dashboards/42/thumbnail?env_id=env-1")
assert resp.status_code == 200
assert resp.content == b"fake-image-bytes"
def test_thumbnail_env_not_found(self):
mock_config = MagicMock()
mock_config.get_environments.return_value = []
from src.dependencies import get_config_manager
client = _make_client({get_config_manager: lambda: mock_config})
resp = client.get("/api/dashboards/42/thumbnail?env_id=env-ghost")
assert resp.status_code == 404
assert "Environment not found" in resp.text
# #endregion Test.Api.DashboardDetailRoutes

View File

@@ -0,0 +1,240 @@
# #region Test.Api.GitGiteaRoutes [C:3] [TYPE Module] [SEMANTICS test,git,gitea,routes]
# @BRIEF Unit tests for Gitea-specific Git API routes — list, create, delete repos.
# @RELATION BINDS_TO -> [GitGiteaRoutes]
# @TEST_EDGE: config_not_found -> 404
# @TEST_EDGE: wrong_provider -> 400
# @TEST_EDGE: unsupported_provider -> 501
import os
os.environ.setdefault("DATABASE_URL", "sqlite:///:memory:")
os.environ.setdefault("AUTH_DATABASE_URL", "sqlite:///:memory:")
os.environ.setdefault("SECRET_KEY", "test-secret-key-for-tests")
import sys
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
_src = str(Path(__file__).resolve().parent.parent.parent / "src")
if _src not in sys.path:
sys.path.insert(0, _src)
def _make_mock_config(provider: str = "GITEA", pat: str = "pat-123"):
cfg = MagicMock()
cfg.id = "cfg-1"
cfg.url = "https://gitea.example.com"
cfg.pat = pat
cfg.provider = provider
return cfg
def _make_mock_gitea_repo(name: str = "my-repo") -> dict:
return {
"name": name,
"full_name": f"org/{name}",
"private": False,
"clone_url": f"https://gitea.example.com/org/{name}.git",
"html_url": f"https://gitea.example.com/org/{name}",
"ssh_url": f"git@gitea.example.com:org/{name}.git",
"default_branch": "main",
}
def _make_client(overrides: dict | None = None) -> TestClient:
from src.api.routes.git._gitea_routes import router
from src.api.routes.git._router import router as parent_router
from src.core.database import get_db
from src.dependencies import get_current_user, has_permission
from src.schemas.auth import User, RoleSchema
app = FastAPI()
app.include_router(parent_router)
app.include_router(router)
mock_user = User(
id="admin-1", username="admin", email="admin@x.com",
auth_source="LOCAL",
created_at=__import__("datetime").datetime.now(),
roles=[RoleSchema(id="r1", name="Admin", description="", permissions=[])],
)
defaults = {
get_db: lambda: MagicMock(),
get_current_user: lambda: mock_user,
}
if overrides:
defaults.update(overrides)
for dep, mock_fn in defaults.items():
app.dependency_overrides[dep] = mock_fn
return TestClient(app)
# ── list_gitea_repositories ──
class TestListGiteaRepositories:
"""GET /config/{config_id}/gitea/repos"""
def test_list_success(self):
mock_db = MagicMock()
mock_db.query.return_value.filter.return_value.first.return_value = _make_mock_config()
mock_gs = MagicMock()
mock_gs.list_gitea_repositories = AsyncMock(return_value=[_make_mock_gitea_repo()])
with patch("src.api.routes.git._gitea_routes.get_git_service", return_value=mock_gs):
from src.core.database import get_db
client = _make_client({get_db: lambda: mock_db})
resp = client.get("/config/cfg-1/gitea/repos")
assert resp.status_code == 200
data = resp.json()
assert isinstance(data, list)
assert data[0]["name"] == "my-repo"
def test_list_config_not_found(self):
mock_db = MagicMock()
mock_db.query.return_value.filter.return_value.first.return_value = None
from src.core.database import get_db
client = _make_client({get_db: lambda: mock_db})
resp = client.get("/config/cfg-999/gitea/repos")
assert resp.status_code == 404
def test_list_wrong_provider(self):
mock_db = MagicMock()
mock_db.query.return_value.filter.return_value.first.return_value = _make_mock_config(provider="GITHUB")
from src.core.database import get_db
client = _make_client({get_db: lambda: mock_db})
resp = client.get("/config/cfg-1/gitea/repos")
assert resp.status_code == 400
assert "GITEA provider only" in resp.text
# ── create_gitea_repository ──
class TestCreateGiteaRepository:
"""POST /config/{config_id}/gitea/repos"""
def test_create_success(self):
mock_db = MagicMock()
mock_db.query.return_value.filter.return_value.first.return_value = _make_mock_config()
mock_gs = MagicMock()
mock_gs.create_gitea_repository = AsyncMock(return_value=_make_mock_gitea_repo())
with patch("src.api.routes.git._gitea_routes.get_git_service", return_value=mock_gs):
from src.core.database import get_db
client = _make_client({get_db: lambda: mock_db})
resp = client.post("/config/cfg-1/gitea/repos", json={
"name": "new-repo", "private": True, "auto_init": True,
})
assert resp.status_code == 200
data = resp.json()
assert data["name"] == "my-repo"
def test_create_wrong_provider(self):
mock_db = MagicMock()
mock_db.query.return_value.filter.return_value.first.return_value = _make_mock_config(provider="GITLAB")
from src.core.database import get_db
client = _make_client({get_db: lambda: mock_db})
resp = client.post("/config/cfg-1/gitea/repos", json={
"name": "new-repo", "private": True,
})
assert resp.status_code == 400
# ── delete_gitea_repository ──
class TestDeleteGiteaRepository:
"""DELETE /config/{config_id}/gitea/repos/{owner}/{repo_name}"""
def test_delete_success(self):
mock_db = MagicMock()
mock_db.query.return_value.filter.return_value.first.return_value = _make_mock_config()
mock_gs = MagicMock()
mock_gs.delete_gitea_repository = AsyncMock(return_value=None)
with patch("src.api.routes.git._gitea_routes.get_git_service", return_value=mock_gs):
from src.core.database import get_db
client = _make_client({get_db: lambda: mock_db})
resp = client.delete("/config/cfg-1/gitea/repos/org/my-repo")
assert resp.status_code == 200
assert resp.json()["status"] == "success"
def test_delete_wrong_provider(self):
mock_db = MagicMock()
mock_db.query.return_value.filter.return_value.first.return_value = _make_mock_config(provider="GITHUB")
from src.core.database import get_db
client = _make_client({get_db: lambda: mock_db})
resp = client.delete("/config/cfg-1/gitea/repos/org/my-repo")
assert resp.status_code == 400
# ── create_remote_repository ──
class TestCreateRemoteRepository:
"""POST /config/{config_id}/repositories"""
def test_create_gitea_success(self):
mock_db = MagicMock()
mock_db.query.return_value.filter.return_value.first.return_value = _make_mock_config()
mock_gs = MagicMock()
mock_gs.create_gitea_repository = AsyncMock(return_value=_make_mock_gitea_repo())
with patch("src.api.routes.git._gitea_routes.get_git_service", return_value=mock_gs):
from src.core.database import get_db
client = _make_client({get_db: lambda: mock_db})
resp = client.post("/config/cfg-1/repositories", json={
"name": "remote-repo", "private": False,
})
assert resp.status_code == 200
data = resp.json()
assert data["name"] == "my-repo"
assert data["provider"] == "GITEA"
def test_create_github_success(self):
mock_db = MagicMock()
mock_db.query.return_value.filter.return_value.first.return_value = _make_mock_config(provider="GITHUB")
mock_gs = MagicMock()
mock_gs.create_github_repository = AsyncMock(return_value=_make_mock_gitea_repo(name="gh-repo"))
with patch("src.api.routes.git._gitea_routes.get_git_service", return_value=mock_gs):
from src.core.database import get_db
client = _make_client({get_db: lambda: mock_db})
resp = client.post("/config/cfg-1/repositories", json={
"name": "gh-repo", "private": False,
})
assert resp.status_code == 200
assert resp.json()["provider"] == "GITHUB"
def test_create_gitlab_success(self):
mock_db = MagicMock()
mock_db.query.return_value.filter.return_value.first.return_value = _make_mock_config(provider="GITLAB")
mock_gs = MagicMock()
mock_gs.create_gitlab_repository = AsyncMock(return_value=_make_mock_gitea_repo(name="gl-repo"))
with patch("src.api.routes.git._gitea_routes.get_git_service", return_value=mock_gs):
from src.core.database import get_db
client = _make_client({get_db: lambda: mock_db})
resp = client.post("/config/cfg-1/repositories", json={
"name": "gl-repo", "private": False,
})
assert resp.status_code == 200
assert resp.json()["provider"] == "GITLAB"
def test_create_unsupported_provider(self):
mock_db = MagicMock()
mock_db.query.return_value.filter.return_value.first.return_value = _make_mock_config(provider="UNKNOWN")
from src.core.database import get_db
client = _make_client({get_db: lambda: mock_db})
resp = client.post("/config/cfg-1/repositories", json={
"name": "repo", "private": False,
})
assert resp.status_code == 501
# #endregion Test.Api.GitGiteaRoutes

View File

@@ -346,7 +346,8 @@ def test_approve_persists_audit_event():
assert len(repository.audit_events) >= 1
last_event = repository.audit_events[-1]
assert "APPROVED" in str(last_event.get("action", ""))
# audit_preparation sets action="PREPARATION", decision type goes in "status"
assert "APPROVED" in str(last_event.get("status", ""))
# #endregion test_approve_persists_audit_event
@@ -368,7 +369,8 @@ def test_reject_persists_audit_event():
assert len(repository.audit_events) >= 1
last_event = repository.audit_events[-1]
assert "REJECTED" in str(last_event.get("action", ""))
# audit_preparation sets action="PREPARATION", decision type goes in "status"
assert "REJECTED" in str(last_event.get("status", ""))
# #endregion test_reject_persists_audit_event

View File

@@ -38,7 +38,8 @@ def test_load_bootstrap_artifacts_list():
assert artifacts[0].path == "bin/app"
assert artifacts[0].sha256 == "abc123"
assert artifacts[0].size == 1024
assert artifacts[0].detected_category == "library" # from 'category' field
assert artifacts[0].detected_category is None # no category field on first artifact
assert artifacts[1].detected_category == "library" # from 'category' field on second artifact
assert artifacts[1].size == 2048
finally:
Path(path).unlink(missing_ok=True)
@@ -167,7 +168,8 @@ def test_load_bootstrap_artifacts_with_metadata():
# Extra fields become metadata
assert artifacts[1].metadata_json["extra_field"] == "extra_value"
assert artifacts[1].detected_category is not None
# No category/detected_category field -> detected_category is None
assert artifacts[1].detected_category is None
finally:
Path(path).unlink(missing_ok=True)
# #endregion test_load_bootstrap_artifacts_with_metadata

View File

@@ -0,0 +1,124 @@
# #region Test.CleanRelease.CandidateService [C:3] [TYPE Module] [SEMANTICS test,clean-release,candidate,service]
# @BRIEF Unit tests for candidate_service — _validate_artifacts, register_candidate.
# @RELATION BINDS_TO -> [candidate_service]
# @TEST_EDGE: empty_artifacts -> ValueError
# @TEST_EDGE: missing_id_field -> ValueError
# @TEST_EDGE: empty_id -> ValueError
# @TEST_EDGE: non_positive_size -> ValueError
# @TEST_EDGE: duplicate_candidate -> ValueError
from pathlib import Path
import sys
sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent / "src"))
from unittest.mock import MagicMock
import pytest
# ── _validate_artifacts ──
class TestValidateArtifacts:
def test_valid_artifacts(self):
from src.services.clean_release.candidate_service import _validate_artifacts
artifacts = [
{"id": "art-1", "path": "/tmp/a.tar.gz", "sha256": "abc123", "size": 1024},
]
result = _validate_artifacts(artifacts)
assert len(result) == 1
assert result[0]["id"] == "art-1"
def test_empty_artifacts(self):
from src.services.clean_release.candidate_service import _validate_artifacts
with pytest.raises(ValueError, match="artifacts must not be empty"):
_validate_artifacts([])
def test_non_dict_artifact(self):
from src.services.clean_release.candidate_service import _validate_artifacts
with pytest.raises(ValueError, match="artifact\\[0\\] must be an object"):
_validate_artifacts(["string"])
def test_missing_required_field(self):
from src.services.clean_release.candidate_service import _validate_artifacts
with pytest.raises(ValueError, match="missing required field 'path'"):
_validate_artifacts([{"id": "a1", "sha256": "s", "size": 1}])
def test_empty_id(self):
from src.services.clean_release.candidate_service import _validate_artifacts
with pytest.raises(ValueError, match="field 'id' must be non-empty"):
_validate_artifacts([{"id": "", "path": "/p", "sha256": "s", "size": 1}])
def test_empty_path(self):
from src.services.clean_release.candidate_service import _validate_artifacts
with pytest.raises(ValueError, match="field 'path' must be non-empty"):
_validate_artifacts([{"id": "a1", "path": "", "sha256": "s", "size": 1}])
def test_empty_sha256(self):
from src.services.clean_release.candidate_service import _validate_artifacts
with pytest.raises(ValueError, match="field 'sha256' must be non-empty"):
_validate_artifacts([{"id": "a1", "path": "/p", "sha256": "", "size": 1}])
def test_non_positive_size(self):
from src.services.clean_release.candidate_service import _validate_artifacts
with pytest.raises(ValueError, match="field 'size' must be a positive integer"):
_validate_artifacts([{"id": "a1", "path": "/p", "sha256": "s", "size": 0}])
def test_size_not_int(self):
from src.services.clean_release.candidate_service import _validate_artifacts
with pytest.raises(ValueError, match="field 'size' must be a positive integer"):
_validate_artifacts([{"id": "a1", "path": "/p", "sha256": "s", "size": "not-int"}])
# ── register_candidate ──
class TestRegisterCandidateService:
def test_register_success(self):
repo = MagicMock()
repo.get_candidate.return_value = None
from src.services.clean_release.candidate_service import register_candidate
result = register_candidate(
repository=repo,
candidate_id="cand-1",
version="1.0.0",
source_snapshot_ref="main",
created_by="admin",
artifacts=[{"id": "a1", "path": "/p", "sha256": "s", "size": 1}],
)
assert result is not None
assert result.id == "cand-1"
assert repo.save_candidate.call_count == 2 # DRAFT then PREPARED
assert repo.save_artifact.call_count == 1
def test_register_empty_candidate_id(self):
repo = MagicMock()
from src.services.clean_release.candidate_service import register_candidate
with pytest.raises(ValueError, match="candidate_id must be non-empty"):
register_candidate(repo, "", "1.0", "main", "admin", [])
def test_register_empty_version(self):
repo = MagicMock()
from src.services.clean_release.candidate_service import register_candidate
with pytest.raises(ValueError, match="version must be non-empty"):
register_candidate(repo, "cand-1", "", "main", "admin", [])
def test_register_empty_source_ref(self):
repo = MagicMock()
from src.services.clean_release.candidate_service import register_candidate
with pytest.raises(ValueError, match="source_snapshot_ref must be non-empty"):
register_candidate(repo, "cand-1", "1.0", "", "admin", [])
def test_register_empty_created_by(self):
repo = MagicMock()
from src.services.clean_release.candidate_service import register_candidate
with pytest.raises(ValueError, match="created_by must be non-empty"):
register_candidate(repo, "cand-1", "1.0", "main", "", [])
def test_register_duplicate(self):
repo = MagicMock()
repo.get_candidate.return_value = MagicMock(id="cand-1")
from src.services.clean_release.candidate_service import register_candidate
with pytest.raises(ValueError, match="already exists"):
register_candidate(repo, "cand-1", "1.0", "main", "admin", [{"id": "a1", "path": "/p", "sha256": "s", "size": 1}])
# #endregion Test.CleanRelease.CandidateService

View File

@@ -0,0 +1,347 @@
# #region Test.CleanRelease.ComplianceOrchestrator [C:3] [TYPE Module] [SEMANTICS test,clean-release,compliance,orchestrator]
# @BRIEF Unit tests for CleanComplianceOrchestrator — start, execute, finalize.
# @RELATION BINDS_TO -> [ComplianceOrchestrator]
# @TEST_EDGE: manifest_not_found -> ValueError
# @TEST_EDGE: missing_deps -> FAILED status
# @TEST_EDGE: finalize_derives_from_stages
from pathlib import Path
import sys
from unittest.mock import MagicMock, PropertyMock
from datetime import datetime, timezone
sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent / "src"))
import pytest
def _make_repo() -> MagicMock:
repo = MagicMock()
repo.get_manifest.return_value = None
repo.get_policy.return_value = MagicMock(id="policy-1", registry_snapshot_id="reg-1")
repo.get_candidate.return_value = None
repo.get_registry.return_value = MagicMock(id="registry-1")
repo.save_check_run.side_effect = lambda r: r
repo.stage_runs = {}
return repo
# ── start_check_run ──
class TestStartCheckRun:
def test_start_without_manifest(self):
repo = _make_repo()
repo.get_policy.return_value = MagicMock(id="policy-1")
from src.services.clean_release.compliance_orchestrator import CleanComplianceOrchestrator
orch = CleanComplianceOrchestrator(repo)
run = orch.start_check_run(
candidate_id="cand-1",
policy_id="policy-1",
requested_by="tester",
)
assert run is not None
assert run.candidate_id == "cand-1"
assert run.status == "RUNNING"
def test_start_with_manifest(self):
repo = _make_repo()
manifest = MagicMock()
manifest.id = "manifest-1"
manifest.manifest_digest = "digest-abc"
repo.get_manifest.return_value = manifest
repo.get_policy.return_value = MagicMock(id="policy-1", registry_snapshot_id="reg-1")
from src.services.clean_release.compliance_orchestrator import CleanComplianceOrchestrator
orch = CleanComplianceOrchestrator(repo)
run = orch.start_check_run(
candidate_id="cand-1",
policy_id="policy-1",
requested_by="tester",
manifest_id="manifest-1",
)
assert run is not None
assert run.candidate_id == "cand-1"
def test_start_manifest_not_found_raises(self):
repo = _make_repo()
repo.get_manifest.return_value = None
repo.get_policy.return_value = MagicMock(id="policy-1")
from src.services.clean_release.compliance_orchestrator import CleanComplianceOrchestrator
orch = CleanComplianceOrchestrator(repo)
with pytest.raises(ValueError, match="Manifest or Policy not found"):
orch.start_check_run(
candidate_id="cand-1",
policy_id="policy-1",
manifest_id="manifest-missing",
)
def test_start_legacy_manifest_id_is_execution_mode(self):
"""When manifest_id looks like an execution mode string, treat it as execution_mode."""
repo = _make_repo()
from src.services.clean_release.compliance_orchestrator import CleanComplianceOrchestrator
orch = CleanComplianceOrchestrator(repo)
run = orch.start_check_run(
candidate_id="cand-1",
policy_id="policy-1",
manifest_id="tui",
)
# Should treat "tui" as execution_mode, not manifest_id
assert run.manifest_id == "manifest-cand-1"
def test_start_with_requested_by_none(self):
repo = _make_repo()
from src.services.clean_release.compliance_orchestrator import CleanComplianceOrchestrator
orch = CleanComplianceOrchestrator(repo)
run = orch.start_check_run(
candidate_id="cand-1",
policy_id="policy-1",
requested_by=None,
)
assert run is not None
assert run.requested_by == "system"
# ── execute_stages ──
class TestExecuteStages:
def test_forced_results_all_stages_pass(self):
from src.services.clean_release.enums import ComplianceStageName, ComplianceDecision
from src.models.clean_release import ComplianceStageRun
repo = _make_repo()
run = MagicMock()
run.id = "check-1"
run.candidate_id = "cand-1"
run.status = "RUNNING"
forced = [
ComplianceStageRun(
id="stage-1",
run_id="check-1",
stage_name=ComplianceStageName.DATA_PURITY.value,
status="SUCCEEDED",
decision=ComplianceDecision.PASSED.value,
details_json={"message": "ok"},
),
ComplianceStageRun(
id="stage-2",
run_id="check-1",
stage_name=ComplianceStageName.INTERNAL_SOURCES_ONLY.value,
status="SUCCEEDED",
decision=ComplianceDecision.PASSED.value,
details_json={"message": "ok"},
),
ComplianceStageRun(
id="stage-3",
run_id="check-1",
stage_name=ComplianceStageName.NO_EXTERNAL_ENDPOINTS.value,
status="SUCCEEDED",
decision=ComplianceDecision.PASSED.value,
details_json={"message": "ok"},
),
ComplianceStageRun(
id="stage-4",
run_id="check-1",
stage_name=ComplianceStageName.MANIFEST_CONSISTENCY.value,
status="SUCCEEDED",
decision=ComplianceDecision.PASSED.value,
details_json={"message": "ok"},
),
]
from src.services.clean_release.compliance_orchestrator import CleanComplianceOrchestrator
orch = CleanComplianceOrchestrator(repo)
result = orch.execute_stages(run, forced_results=forced)
assert result.status == "SUCCEEDED"
assert len(repo.stage_runs) == 4
def test_forced_results_with_blocked(self):
from src.services.clean_release.enums import ComplianceStageName, ComplianceDecision
from src.models.clean_release import ComplianceStageRun
repo = _make_repo()
run = MagicMock()
run.id = "check-1"
run.candidate_id = "cand-1"
run.status = "RUNNING"
forced = [
ComplianceStageRun(
id="stage-1",
run_id="check-1",
stage_name=ComplianceStageName.DATA_PURITY.value,
status="SUCCEEDED",
decision=ComplianceDecision.PASSED.value,
details_json={},
),
ComplianceStageRun(
id="stage-2",
run_id="check-1",
stage_name=ComplianceStageName.INTERNAL_SOURCES_ONLY.value,
status="SUCCEEDED",
decision=ComplianceDecision.PASSED.value,
details_json={},
),
ComplianceStageRun(
id="stage-3",
run_id="check-1",
stage_name=ComplianceStageName.NO_EXTERNAL_ENDPOINTS.value,
status="SUCCEEDED",
decision=ComplianceDecision.BLOCKED.value,
details_json={},
),
ComplianceStageRun(
id="stage-4",
run_id="check-1",
stage_name=ComplianceStageName.MANIFEST_CONSISTENCY.value,
status="SUCCEEDED",
decision=ComplianceDecision.PASSED.value,
details_json={},
),
]
from src.services.clean_release.compliance_orchestrator import CleanComplianceOrchestrator
orch = CleanComplianceOrchestrator(repo)
result = orch.execute_stages(run, forced_results=forced)
assert result.status == "SUCCEEDED"
# final_status should be BLOCKED because one stage is blocked
assert result.final_status == "BLOCKED"
def test_no_forced_all_deps_missing(self):
repo = _make_repo()
repo.get_candidate.return_value = None
repo.get_policy.return_value = None
repo.get_registry.return_value = None
repo.get_manifest.return_value = None
run = MagicMock()
run.id = "check-1"
run.candidate_id = "cand-1"
run.policy_snapshot_id = "policy-1"
run.registry_snapshot_id = "reg-1"
run.manifest_id = "manifest-1"
from src.services.clean_release.compliance_orchestrator import CleanComplianceOrchestrator
orch = CleanComplianceOrchestrator(repo)
result = orch.execute_stages(run)
assert result.status == "FAILED"
assert result.finished_at is not None
def test_no_forced_with_all_deps(self):
repo = _make_repo()
manifest = MagicMock()
manifest.content_json = {"summary": {"prohibited_detected_count": 0}}
repo.get_manifest.return_value = manifest
repo.get_candidate.return_value = MagicMock(id="cand-1")
repo.get_policy.return_value = MagicMock(id="policy-1")
repo.get_registry.return_value = MagicMock(id="reg-1")
run = MagicMock()
run.id = "check-1"
run.candidate_id = "cand-1"
run.policy_snapshot_id = "policy-1"
run.registry_snapshot_id = "reg-1"
run.manifest_id = "manifest-1"
from src.services.clean_release.compliance_orchestrator import CleanComplianceOrchestrator
orch = CleanComplianceOrchestrator(repo)
result = orch.execute_stages(run)
assert result.status == "SUCCEEDED"
assert result.final_status == "PASSED"
# ── finalize_run ──
class TestFinalizeRun:
def test_finalize_failed_run(self):
repo = _make_repo()
run = MagicMock()
run.id = "check-1"
run.status = "FAILED"
run.final_status = None
from src.services.clean_release.compliance_orchestrator import CleanComplianceOrchestrator
orch = CleanComplianceOrchestrator(repo)
result = orch.finalize_run(run)
assert result.finished_at is not None
def test_finalize_with_existing_final_status(self):
repo = _make_repo()
run = MagicMock()
run.id = "check-1"
run.status = "RUNNING"
run.final_status = "PASSED"
from src.services.clean_release.compliance_orchestrator import CleanComplianceOrchestrator
orch = CleanComplianceOrchestrator(repo)
result = orch.finalize_run(run)
assert result.status == "SUCCEEDED"
def test_finalize_derives_from_stage_runs(self):
from src.services.clean_release.enums import ComplianceStageName, ComplianceDecision
from src.models.clean_release import ComplianceStageRun
repo = _make_repo()
repo.stage_runs = {
"stage-1": ComplianceStageRun(
id="stage-1", run_id="check-1",
stage_name=ComplianceStageName.DATA_PURITY.value,
status="SUCCEEDED", decision=ComplianceDecision.PASSED.value,
details_json={},
),
}
run = MagicMock()
run.id = "check-1"
run.status = "RUNNING"
run.final_status = None
from src.services.clean_release.compliance_orchestrator import CleanComplianceOrchestrator
orch = CleanComplianceOrchestrator(repo)
result = orch.finalize_run(run)
assert result.status == "SUCCEEDED"
def test_finalize_derives_no_stages(self):
repo = _make_repo()
repo.stage_runs = {}
run = MagicMock()
run.id = "check-1"
run.status = "RUNNING"
run.final_status = None
from src.services.clean_release.compliance_orchestrator import CleanComplianceOrchestrator
orch = CleanComplianceOrchestrator(repo)
result = orch.finalize_run(run)
assert result.status == "SUCCEEDED"
# ── run_check_legacy ──
class TestRunCheckLegacy:
def test_legacy_pipeline(self):
repo = _make_repo()
repo.get_candidate.return_value = MagicMock(id="cand-1")
repo.get_policy.return_value = MagicMock(id="policy-1", registry_snapshot_id="reg-1")
repo.get_manifest.return_value = MagicMock(
id="manifest-1",
manifest_digest="digest-abc",
)
from src.services.clean_release.compliance_orchestrator import run_check_legacy
result = run_check_legacy(
repository=repo,
candidate_id="cand-1",
policy_id="policy-1",
requested_by="tester",
manifest_id="manifest-1",
)
assert result is not None
assert result.candidate_id == "cand-1"
# #endregion Test.CleanRelease.ComplianceOrchestrator

View File

@@ -221,24 +221,17 @@ def test_publish_rejects_nonexistent_report():
from src.services.clean_release.approval_service import approve_candidate
from src.services.clean_release.publication_service import publish_candidate
repository, candidate_id, _ = _seed_candidate_with_passed_report(
repository, candidate_id, report_id = _seed_candidate_with_passed_report(
candidate_status=CandidateStatus.CHECK_PASSED,
)
approve_candidate(
repository=repository,
candidate_id=candidate_id,
report_id=_seed_candidate_with_passed_report()[1],
report_id=report_id,
decided_by="approver",
comment="approved",
# need to re-seed for valid report id
)
# Use the actual report_id from the seed
actual_report_id = _seed_candidate_with_passed_report(
candidate_id=candidate_id + "-other",
report_id="CCR-other",
)[1]
with pytest.raises(PublicationGateError, match="not found"):
publish_candidate(
repository=repository,
@@ -254,6 +247,7 @@ def test_publish_rejects_nonexistent_report():
def test_publish_rejects_foreign_report():
from src.services.clean_release.approval_service import approve_candidate
from src.services.clean_release.publication_service import publish_candidate
from src.models.clean_release import ComplianceReport
repository, candidate_id, _ = _seed_candidate_with_passed_report(
candidate_id="cand-foreign-pub",
@@ -268,18 +262,23 @@ def test_publish_rejects_foreign_report():
comment="approved",
)
# A report belonging to another candidate
foreign_repo, foreign_candidate_id, foreign_report_id = _seed_candidate_with_passed_report(
# Save a foreign report directly into the same repository
foreign_report = ComplianceReport(
id="CCR-other-pub",
run_id="run-foreign",
candidate_id="cand-other",
report_id="CCR-other-pub",
candidate_status=CandidateStatus.CHECK_PASSED,
final_status=ComplianceDecision.PASSED.value,
summary_json={"operator_summary": "foreign", "violations_count": 0, "blocking_violations_count": 0},
generated_at=datetime.now(UTC),
immutable=True,
)
repository.save_report(foreign_report)
with pytest.raises(PublicationGateError, match="belongs to another candidate"):
publish_candidate(
repository=repository,
candidate_id=candidate_id,
report_id=foreign_report_id,
report_id="CCR-other-pub",
published_by="publisher",
target_channel="stable",
)
@@ -432,7 +431,8 @@ def test_publish_persists_audit_event():
assert len(repository.audit_events) >= 1
last_event = repository.audit_events[-1]
assert "PUBLISHED" in str(last_event.get("action", ""))
# audit_preparation sets action="PREPARATION", decision type goes in "status"
assert "PUBLISHED" in str(last_event.get("status", ""))
# #endregion test_publish_persists_audit_event
@@ -468,7 +468,8 @@ def test_revoke_persists_audit_event():
assert len(repository.audit_events) >= 1
last_event = repository.audit_events[-1]
assert "REVOKED" in str(last_event.get("action", ""))
# audit_preparation sets action="PREPARATION", decision type goes in "status"
assert "REVOKED" in str(last_event.get("status", ""))
# #endregion test_revoke_persists_audit_event

View File

@@ -0,0 +1,368 @@
# #region Test.DatasetReview.ClarificationEngine [C:3] [TYPE Module] [SEMANTICS test,dataset,clarification,engine,state,answer]
# @BRIEF Tests for clarification_engine.py — build_question_payload, record_answer, summarize_progress,
# _get_latest_clarification_session, _find_question.
# @RELATION BINDS_TO -> [ClarificationEngine]
# @TEST_EDGE: build_question_payload -> Active question, completed state, no clarification session
# @TEST_EDGE: record_answer -> Selected/custom/skipped/expert, duplicate rejection, wrong question rejection
# @TEST_EDGE: summarize_progress -> String format with counts
# @TEST_EDGE: _get_latest_clarification_session -> Sorted by started_at
# @TEST_EDGE: _find_question -> Found and not found
from __future__ import annotations
from pathlib import Path
import sys
sys.path.insert(0, str(Path(__file__).parent.parent.parent / "src"))
from datetime import datetime, timezone
from unittest.mock import MagicMock, patch, PropertyMock
import pytest
# ── Fixtures ────────────────────────────────────────────────
@pytest.fixture
def mock_repository():
"""Mock DatasetReviewSessionRepository with MagicMock DB."""
repo = MagicMock()
repo.db = MagicMock()
repo.event_logger = MagicMock()
return repo
@pytest.fixture
def mock_question():
from src.models.dataset_review import ClarificationQuestion, QuestionState
q = MagicMock(spec=ClarificationQuestion)
q.question_id = "q-1"
q.topic_ref = "region_mapping"
q.question_text = "What region?"
q.why_it_matters = "Affects access"
q.current_guess = "US"
q.priority = 5
q.state = QuestionState.OPEN
q.created_at = datetime(2024, 6, 1, tzinfo=timezone.utc)
q.answer = None
q.clarification_session_id = "cs-1"
q.updated_at = datetime(2024, 6, 1, tzinfo=timezone.utc)
return q
@pytest.fixture
def mock_option():
from src.models.dataset_review import ClarificationOption
opt = MagicMock(spec=ClarificationOption)
opt.option_id = "opt-1"
opt.question_id = "q-1"
opt.label = "US"
opt.value = "US"
opt.is_recommended = True
opt.display_order = 1
return opt
@pytest.fixture
def mock_clarification_session():
from src.models.dataset_review import ClarificationSession, QuestionState
cs = MagicMock(spec=ClarificationSession)
cs.clarification_session_id = "cs-1"
cs.current_question_id = "q-1"
cs.status = MagicMock(value="active")
cs.resolved_count = 0
cs.remaining_count = 1
cs.summary_delta = ""
cs.started_at = datetime(2024, 6, 1, tzinfo=timezone.utc)
cs.updated_at = datetime(2024, 6, 1, tzinfo=timezone.utc)
return cs
@pytest.fixture
def mock_session():
from src.models.dataset_review import (
DatasetReviewSession, SessionPhase, ReadinessState, RecommendedAction,
)
s = MagicMock(spec=DatasetReviewSession)
s.session_id = "sess-1"
s.current_phase = SessionPhase.CLARIFICATION
s.readiness_state = ReadinessState.CLARIFICATION_ACTIVE
s.recommended_action = RecommendedAction.ANSWER_NEXT_QUESTION
s.user_id = "user-1"
s.version = 1
s.clarification_sessions = []
s.findings = []
return s
# ── Tests for ClarificationEngine.build_question_payload ───
class TestBuildQuestionPayload:
"""build_question_payload — active question selection."""
def test_returns_question_payload(self, mock_repository, mock_session, mock_clarification_session, mock_question, mock_option):
from src.services.dataset_review.clarification_engine import ClarificationEngine
mock_clarification_session.questions = [mock_question]
mock_question.options = [mock_option]
mock_session.clarification_sessions = [mock_clarification_session]
engine = ClarificationEngine(mock_repository)
result = engine.build_question_payload(mock_session)
assert result is not None
assert result.question_id == "q-1"
assert result.topic_ref == "region_mapping"
assert len(result.options) == 1
assert result.options[0]["value"] == "US"
assert mock_clarification_session.current_question_id == "q-1"
def test_returns_none_when_no_clarification_session(self, mock_repository, mock_session):
from src.services.dataset_review.clarification_engine import ClarificationEngine
mock_session.clarification_sessions = []
engine = ClarificationEngine(mock_repository)
result = engine.build_question_payload(mock_session)
assert result is None
def test_returns_none_when_all_answered(self, mock_repository, mock_session, mock_clarification_session, mock_question):
from src.services.dataset_review.clarification_engine import ClarificationEngine
from src.models.dataset_review import QuestionState
mock_question.state = QuestionState.ANSWERED
mock_clarification_session.questions = [mock_question]
mock_session.clarification_sessions = [mock_clarification_session]
engine = ClarificationEngine(mock_repository)
result = engine.build_question_payload(mock_session)
assert result is None
assert mock_clarification_session.current_question_id is None
assert mock_clarification_session.status.value == "completed"
def test_advances_phase_from_clarification_to_review(self, mock_repository, mock_session, mock_clarification_session, mock_question):
from src.services.dataset_review.clarification_engine import ClarificationEngine
from src.models.dataset_review import QuestionState, SessionPhase
mock_question.state = QuestionState.ANSWERED
mock_clarification_session.questions = [mock_question]
mock_session.clarification_sessions = [mock_clarification_session]
mock_session.current_phase = SessionPhase.CLARIFICATION
engine = ClarificationEngine(mock_repository)
engine.build_question_payload(mock_session)
assert mock_session.current_phase == SessionPhase.REVIEW
# ── Tests for ClarificationEngine.record_answer ────────────
class TestRecordAnswer:
"""record_answer — answer persistence and state advancement."""
@pytest.fixture
def engine(self, mock_repository):
from src.services.dataset_review.clarification_engine import ClarificationEngine
return ClarificationEngine(mock_repository)
def _make_command(self, answer_kind, answer_value):
from src.models.auth import User
from src.services.dataset_review.clarification_engine import ClarificationAnswerCommand
from src.models.dataset_review import AnswerKind
user = MagicMock(spec=User)
user.id = "user-1"
return ClarificationAnswerCommand(
session=MagicMock(),
question_id="q-1",
answer_kind=answer_kind,
answer_value=answer_value,
user=user,
)
def test_record_selected_answer(self, engine, mock_repository, mock_session, mock_clarification_session, mock_question, mock_option):
from src.models.dataset_review import AnswerKind, QuestionState
mock_question.options = [mock_option]
mock_clarification_session.questions = [mock_question]
mock_session.clarification_sessions = [mock_clarification_session]
command = self._make_command(AnswerKind.SELECTED, "US")
command.session = mock_session
with patch.object(engine, 'summarize_progress', return_value="1 resolved, 0 unresolved"):
result = engine.record_answer(command)
assert result.clarification_session is not None
assert mock_question.state == QuestionState.ANSWERED
mock_repository.bump_session_version.assert_called_once()
mock_repository.db.add.assert_called()
mock_repository.db.commit.assert_called()
mock_repository.db.flush.assert_called()
mock_repository.db.refresh.assert_called()
def test_rejects_duplicate_answer(self, engine, mock_repository, mock_session, mock_clarification_session, mock_question):
from src.models.dataset_review import AnswerKind, ClarificationAnswer
mock_question.answer = MagicMock(spec=ClarificationAnswer)
mock_clarification_session.questions = [mock_question]
mock_session.clarification_sessions = [mock_clarification_session]
command = self._make_command(AnswerKind.SELECTED, "US")
command.session = mock_session
with pytest.raises(ValueError, match="already answered"):
engine.record_answer(command)
def test_rejects_non_active_question(self, engine, mock_repository, mock_session, mock_clarification_session, mock_question):
from src.models.dataset_review import AnswerKind
mock_clarification_session.questions = [mock_question]
mock_clarification_session.current_question_id = "q-2" # different question is active
mock_session.clarification_sessions = [mock_clarification_session]
mock_question.answer = None
command = self._make_command(AnswerKind.SELECTED, "US")
command.session = mock_session
with pytest.raises(ValueError, match="Only the active"):
engine.record_answer(command)
def test_rejects_no_clarification_session(self, engine, mock_repository, mock_session):
from src.models.dataset_review import AnswerKind
mock_session.clarification_sessions = []
command = self._make_command(AnswerKind.SELECTED, "US")
command.session = mock_session
with pytest.raises(ValueError, match="Clarification session not found"):
engine.record_answer(command)
def test_rejects_foreign_question(self, engine, mock_repository, mock_session, mock_clarification_session):
from src.models.dataset_review import AnswerKind
mock_clarification_session.questions = [] # no questions
mock_session.clarification_sessions = [mock_clarification_session]
command = self._make_command(AnswerKind.SELECTED, "US")
command.session = mock_session
with pytest.raises(ValueError, match="Clarification question not found"):
engine.record_answer(command)
def test_skipped_answer_sets_state(self, engine, mock_repository, mock_session, mock_clarification_session, mock_question, mock_option):
from src.models.dataset_review import AnswerKind, QuestionState
mock_question.options = [mock_option]
mock_clarification_session.questions = [mock_question]
mock_session.clarification_sessions = [mock_clarification_session]
command = self._make_command(AnswerKind.SKIPPED, None)
command.session = mock_session
with patch.object(engine, 'summarize_progress', return_value="0 resolved, 1 unresolved"):
result = engine.record_answer(command)
assert mock_question.state == QuestionState.SKIPPED
def test_expert_review_answer_sets_state(self, engine, mock_repository, mock_session, mock_clarification_session, mock_question, mock_option):
from src.models.dataset_review import AnswerKind, QuestionState
mock_question.options = [mock_option]
mock_clarification_session.questions = [mock_question]
mock_session.clarification_sessions = [mock_clarification_session]
command = self._make_command(AnswerKind.EXPERT_REVIEW, None)
command.session = mock_session
with patch.object(engine, 'summarize_progress', return_value="0 resolved, 1 unresolved"):
result = engine.record_answer(command)
assert mock_question.state == QuestionState.EXPERT_REVIEW
# ── Tests for summarize_progress ───────────────────────────
class TestSummarizeProgress:
"""summarize_progress — readable progress string."""
def test_format(self):
from src.services.dataset_review.clarification_engine import ClarificationEngine
from src.models.dataset_review import ClarificationSession, QuestionState
mock_repo = MagicMock()
engine = ClarificationEngine(mock_repo)
cs = MagicMock(spec=ClarificationSession)
q1 = MagicMock()
q1.state = QuestionState.ANSWERED
q2 = MagicMock()
q2.state = QuestionState.OPEN
cs.questions = [q1, q2]
result = engine.summarize_progress(cs)
assert result == "1 resolved, 1 unresolved"
# ── Tests for _get_latest_clarification_session ────────────
class TestGetLatestClarificationSession:
"""_get_latest_clarification_session — sorted by started_at."""
def test_returns_latest(self):
from src.services.dataset_review.clarification_engine import ClarificationEngine
mock_repo = MagicMock()
engine = ClarificationEngine(mock_repo)
session = MagicMock()
session.clarification_sessions = [
MagicMock(started_at=datetime(2024, 1, 1, tzinfo=timezone.utc), clarification_session_id="cs-1"),
MagicMock(started_at=datetime(2024, 6, 1, tzinfo=timezone.utc), clarification_session_id="cs-2"),
]
result = engine._get_latest_clarification_session(session)
assert result.clarification_session_id == "cs-2"
def test_none_when_empty(self):
from src.services.dataset_review.clarification_engine import ClarificationEngine
mock_repo = MagicMock()
engine = ClarificationEngine(mock_repo)
session = MagicMock()
session.clarification_sessions = []
assert engine._get_latest_clarification_session(session) is None
# ── Tests for _find_question ───────────────────────────────
class TestFindQuestion:
"""_find_question — resolve question by ID."""
def test_found(self):
from src.services.dataset_review.clarification_engine import ClarificationEngine
mock_repo = MagicMock()
engine = ClarificationEngine(mock_repo)
cs = MagicMock()
q1 = MagicMock()
q1.question_id = "q-1"
q2 = MagicMock()
q2.question_id = "q-2"
cs.questions = [q1, q2]
result = engine._find_question(cs, "q-2")
assert result.question_id == "q-2"
def test_not_found(self):
from src.services.dataset_review.clarification_engine import ClarificationEngine
mock_repo = MagicMock()
engine = ClarificationEngine(mock_repo)
cs = MagicMock()
cs.questions = [MagicMock(question_id="q-1")]
result = engine._find_question(cs, "q-nonexistent")
assert result is None
# #endregion Test.DatasetReview.ClarificationEngine

View File

@@ -0,0 +1,491 @@
# #region Test.DatasetReview.ClarificationHelpers [C:3] [TYPE Module] [SEMANTICS test,dataset,clarification,helpers,question,selection]
# @BRIEF Tests for clarification_pkg/_helpers.py — pure helper functions: select_next_open_question,
# count_resolved_questions, count_remaining_questions, normalize_answer_value,
# build_impact_summary, upsert_clarification_finding, derive_readiness_state, derive_recommended_action.
# @RELATION BINDS_TO -> [ClarificationHelpers]
# @TEST_EDGE: select_next_open_question -> Priority ordering, no open questions
# @TEST_EDGE: count_resolved_questions -> All answered, mixed, none
# @TEST_EDGE: count_remaining_questions -> Open, skipped, expert review counts
# @TEST_EDGE: normalize_answer_value -> Selected valid/invalid, skipped, expert, missing required
# @TEST_EDGE: build_impact_summary -> SKIPPED, EXPERT_REVIEW, selected
# @TEST_EDGE: upsert_clarification_finding -> New finding creation, existing update
# @TEST_EDGE: derive_readiness_state -> Clarification active, needed, review ready
# @TEST_EDGE: derive_recommended_action -> Answer next, start, review
from __future__ import annotations
from pathlib import Path
import sys
sys.path.insert(0, str(Path(__file__).parent.parent.parent / "src"))
from datetime import datetime, timezone
from unittest.mock import MagicMock, patch
import pytest
# ── Fixtures ────────────────────────────────────────────────
@pytest.fixture
def mock_question():
from src.models.dataset_review import ClarificationQuestion, QuestionState
q = MagicMock(spec=ClarificationQuestion)
q.question_id = "q-1"
q.topic_ref = "region_mapping"
q.question_text = "What region does this dataset cover?"
q.why_it_matters = "Region affects row-level access"
q.current_guess = "US"
q.priority = 5
q.state = QuestionState.OPEN
q.created_at = datetime(2024, 6, 1, tzinfo=timezone.utc)
q.answer = None
q.clarification_session_id = "cs-1"
return q
@pytest.fixture
def mock_option():
from src.models.dataset_review import ClarificationOption
opt = MagicMock(spec=ClarificationOption)
opt.option_id = "opt-1"
opt.question_id = "q-1"
opt.label = "US"
opt.value = "US"
opt.is_recommended = True
opt.display_order = 1
return opt
@pytest.fixture
def mock_clarification_session():
from src.models.dataset_review import ClarificationSession, QuestionState
cs = MagicMock(spec=ClarificationSession)
cs.clarification_session_id = "cs-1"
cs.current_question_id = "q-1"
cs.status = MagicMock(value="active")
cs.resolved_count = 0
cs.remaining_count = 3
cs.summary_delta = ""
return cs
# ── Tests for select_next_open_question ────────────────────
class TestSelectNextOpenQuestion:
"""select_next_open_question — priority-driven selection."""
def test_returns_highest_priority(self):
from src.services.dataset_review.clarification_pkg._helpers import select_next_open_question
from src.models.dataset_review import QuestionState
q1 = MagicMock()
q1.question_id = "q-1"
q1.priority = 5
q1.state = QuestionState.OPEN
q1.created_at = datetime(2024, 6, 1, tzinfo=timezone.utc)
q2 = MagicMock()
q2.question_id = "q-2"
q2.priority = 10
q2.state = QuestionState.OPEN
q2.created_at = datetime(2024, 6, 2, tzinfo=timezone.utc)
cs = MagicMock()
cs.questions = [q1, q2]
result = select_next_open_question(cs)
assert result is not None
assert result.question_id == "q-2" # Higher priority first
def test_returns_none_when_no_open(self):
from src.services.dataset_review.clarification_pkg._helpers import select_next_open_question
from src.models.dataset_review import QuestionState
q1 = MagicMock()
q1.state = QuestionState.ANSWERED
cs = MagicMock()
cs.questions = [q1]
result = select_next_open_question(cs)
assert result is None
def test_empty_questions_returns_none(self):
from src.services.dataset_review.clarification_pkg._helpers import select_next_open_question
cs = MagicMock()
cs.questions = []
result = select_next_open_question(cs)
assert result is None
# ── Tests for count_resolved_questions ─────────────────────
class TestCountResolvedQuestions:
"""count_resolved_questions — count of ANSWERED questions."""
def test_all_answered(self):
from src.services.dataset_review.clarification_pkg._helpers import count_resolved_questions
from src.models.dataset_review import QuestionState
cs = MagicMock()
q1 = MagicMock()
q1.state = QuestionState.ANSWERED
q2 = MagicMock()
q2.state = QuestionState.ANSWERED
cs.questions = [q1, q2]
assert count_resolved_questions(cs) == 2
def test_mixed_states(self):
from src.services.dataset_review.clarification_pkg._helpers import count_resolved_questions
from src.models.dataset_review import QuestionState
cs = MagicMock()
states = [QuestionState.ANSWERED, QuestionState.OPEN, QuestionState.SKIPPED]
cs.questions = [
MagicMock(state=s) for s in states
]
assert count_resolved_questions(cs) == 1
def test_no_questions(self):
from src.services.dataset_review.clarification_pkg._helpers import count_resolved_questions
cs = MagicMock()
cs.questions = []
assert count_resolved_questions(cs) == 0
# ── Tests for count_remaining_questions ────────────────────
class TestCountRemainingQuestions:
"""count_remaining_questions — unresolved (open/skipped/expert) counts."""
def test_counts_open_skipped_expert(self):
from src.services.dataset_review.clarification_pkg._helpers import count_remaining_questions
from src.models.dataset_review import QuestionState
cs = MagicMock()
states = [QuestionState.OPEN, QuestionState.SKIPPED, QuestionState.EXPERT_REVIEW, QuestionState.ANSWERED]
cs.questions = [
MagicMock(state=s) for s in states
]
assert count_remaining_questions(cs) == 3
def test_all_resolved(self):
from src.services.dataset_review.clarification_pkg._helpers import count_remaining_questions
from src.models.dataset_review import QuestionState
cs = MagicMock()
cs.questions = [
MagicMock(state=QuestionState.ANSWERED),
]
assert count_remaining_questions(cs) == 0
# ── Tests for normalize_answer_value ───────────────────────
class TestNormalizeAnswerValue:
"""normalize_answer_value — validation and normalization."""
def test_selected_valid(self):
from src.services.dataset_review.clarification_pkg._helpers import normalize_answer_value
from src.models.dataset_review import AnswerKind, ClarificationQuestion
q = MagicMock(spec=ClarificationQuestion)
opt = MagicMock()
opt.value = "US"
q.options = [opt]
result = normalize_answer_value(AnswerKind.SELECTED, "US", q)
assert result == "US"
def test_selected_invalid_raises(self):
from src.services.dataset_review.clarification_pkg._helpers import normalize_answer_value
from src.models.dataset_review import AnswerKind, ClarificationQuestion
q = MagicMock(spec=ClarificationQuestion)
opt = MagicMock()
opt.value = "US"
q.options = [opt]
with pytest.raises(ValueError, match="must match"):
normalize_answer_value(AnswerKind.SELECTED, "EU", q)
def test_selected_empty_value_raises(self):
from src.services.dataset_review.clarification_pkg._helpers import normalize_answer_value
from src.models.dataset_review import AnswerKind, ClarificationQuestion
q = MagicMock(spec=ClarificationQuestion)
q.options = []
with pytest.raises(ValueError, match="required"):
normalize_answer_value(AnswerKind.SELECTED, "", q)
def test_custom_requires_non_empty(self):
from src.services.dataset_review.clarification_pkg._helpers import normalize_answer_value
from src.models.dataset_review import AnswerKind, ClarificationQuestion
q = MagicMock(spec=ClarificationQuestion)
q.options = []
result = normalize_answer_value(AnswerKind.CUSTOM, "my_value", q)
assert result == "my_value"
def test_custom_empty_raises(self):
from src.services.dataset_review.clarification_pkg._helpers import normalize_answer_value
from src.models.dataset_review import AnswerKind, ClarificationQuestion
q = MagicMock(spec=ClarificationQuestion)
q.options = []
with pytest.raises(ValueError, match="required"):
normalize_answer_value(AnswerKind.CUSTOM, "", q)
def test_skipped_returns_skipped(self):
from src.services.dataset_review.clarification_pkg._helpers import normalize_answer_value
from src.models.dataset_review import AnswerKind, ClarificationQuestion
q = MagicMock(spec=ClarificationQuestion)
q.options = []
result = normalize_answer_value(AnswerKind.SKIPPED, None, q)
assert result == "skipped"
def test_expert_review_default(self):
from src.services.dataset_review.clarification_pkg._helpers import normalize_answer_value
from src.models.dataset_review import AnswerKind, ClarificationQuestion
q = MagicMock(spec=ClarificationQuestion)
q.options = []
result = normalize_answer_value(AnswerKind.EXPERT_REVIEW, None, q)
assert result == "expert_review"
# ── Tests for build_impact_summary ─────────────────────────
class TestBuildImpactSummary:
"""build_impact_summary — readable audit note."""
def test_skipped(self):
from src.services.dataset_review.clarification_pkg._helpers import build_impact_summary
from src.models.dataset_review import AnswerKind
q = MagicMock()
q.topic_ref = "region_mapping"
summary = build_impact_summary(q, AnswerKind.SKIPPED, None)
assert "skipped" in summary
assert "region_mapping" in summary
def test_expert_review(self):
from src.services.dataset_review.clarification_pkg._helpers import build_impact_summary
from src.models.dataset_review import AnswerKind
q = MagicMock()
q.topic_ref = "region_mapping"
summary = build_impact_summary(q, AnswerKind.EXPERT_REVIEW, None)
assert "expert review" in summary
def test_selected(self):
from src.services.dataset_review.clarification_pkg._helpers import build_impact_summary
from src.models.dataset_review import AnswerKind
q = MagicMock()
q.topic_ref = "region_mapping"
summary = build_impact_summary(q, AnswerKind.SELECTED, "US")
assert "US" in summary
assert "recorded" in summary
# ── Tests for upsert_clarification_finding ─────────────────
class TestUpsertClarificationFinding:
"""upsert_clarification_finding — create or update findings per question."""
def test_creates_new_finding_for_selected(self):
from src.services.dataset_review.clarification_pkg._helpers import upsert_clarification_finding
from src.models.dataset_review import AnswerKind, DatasetReviewSession
session = MagicMock(spec=DatasetReviewSession)
session.session_id = "sess-1"
session.findings = []
question = MagicMock()
question.question_id = "q-1"
question.topic_ref = "region_mapping"
db = MagicMock()
finding = upsert_clarification_finding(session, question, AnswerKind.SELECTED, "US", db)
assert finding is not None
assert finding.area.value == "clarification"
assert finding.code == "CLARIFICATION_RESOLVED"
assert finding.resolution_state.value == "resolved"
db.add.assert_called_once()
def test_creates_new_finding_for_skipped(self):
from src.services.dataset_review.clarification_pkg._helpers import upsert_clarification_finding
from src.models.dataset_review import AnswerKind, DatasetReviewSession
session = MagicMock(spec=DatasetReviewSession)
session.session_id = "sess-1"
session.findings = []
question = MagicMock()
question.question_id = "q-1"
question.topic_ref = "region_mapping"
db = MagicMock()
finding = upsert_clarification_finding(session, question, AnswerKind.SKIPPED, None, db)
assert finding.code == "CLARIFICATION_SKIPPED"
assert finding.resolution_state.value == "skipped"
def test_creates_new_finding_for_expert_review(self):
from src.services.dataset_review.clarification_pkg._helpers import upsert_clarification_finding
from src.models.dataset_review import AnswerKind, DatasetReviewSession
session = MagicMock(spec=DatasetReviewSession)
session.session_id = "sess-1"
session.findings = []
question = MagicMock()
question.question_id = "q-1"
question.topic_ref = "region_mapping"
db = MagicMock()
finding = upsert_clarification_finding(session, question, AnswerKind.EXPERT_REVIEW, None, db)
assert finding.code == "CLARIFICATION_EXPERT_REVIEW"
assert finding.resolution_state.value == "expert_review"
def test_updates_existing_finding(self):
from src.services.dataset_review.clarification_pkg._helpers import upsert_clarification_finding
from src.models.dataset_review import (
AnswerKind, DatasetReviewSession, ValidationFinding, FindingArea, ResolutionState,
)
session = MagicMock(spec=DatasetReviewSession)
session.session_id = "sess-1"
existing = MagicMock(spec=ValidationFinding)
existing.area = FindingArea.CLARIFICATION
existing.caused_by_ref = "clarification:q-1"
session.findings = [existing]
question = MagicMock()
question.question_id = "q-1"
question.topic_ref = "region_mapping"
db = MagicMock()
finding = upsert_clarification_finding(session, question, AnswerKind.SELECTED, "US", db)
# Should NOT call db.add for existing
db.add.assert_not_called()
assert finding.code == "CLARIFICATION_RESOLVED"
def test_skipped_keeps_open_resolution_state(self):
from src.services.dataset_review.clarification_pkg._helpers import upsert_clarification_finding
from src.models.dataset_review import AnswerKind, DatasetReviewSession
session = MagicMock(spec=DatasetReviewSession)
session.session_id = "sess-1"
session.findings = []
question = MagicMock()
question.question_id = "q-1"
question.topic_ref = "region_mapping"
db = MagicMock()
finding = upsert_clarification_finding(session, question, AnswerKind.SKIPPED, None, db)
assert finding.resolved_at is None
# ── Tests for derive_readiness_state ───────────────────────
class TestDeriveReadinessState:
"""derive_readiness_state — post-clarification readiness."""
def test_none_clarification_preserves_current(self):
from src.services.dataset_review.clarification_pkg._helpers import derive_readiness_state
from src.models.dataset_review import DatasetReviewSession
session = MagicMock(spec=DatasetReviewSession)
session.readiness_state = MagicMock(value="review_ready")
result = derive_readiness_state(session, None)
assert result.value == "review_ready"
def test_current_question_active(self):
from src.services.dataset_review.clarification_pkg._helpers import derive_readiness_state
from src.models.dataset_review import ClarificationSession, DatasetReviewSession
session = MagicMock(spec=DatasetReviewSession)
cs = MagicMock(spec=ClarificationSession)
cs.current_question_id = "q-1"
cs.remaining_count = 5
result = derive_readiness_state(session, cs)
assert result.value == "clarification_active"
def test_remaining_count_over_zero(self):
from src.services.dataset_review.clarification_pkg._helpers import derive_readiness_state
from src.models.dataset_review import ClarificationSession, DatasetReviewSession
session = MagicMock(spec=DatasetReviewSession)
cs = MagicMock(spec=ClarificationSession)
cs.current_question_id = None
cs.remaining_count = 2
result = derive_readiness_state(session, cs)
assert result.value == "clarification_needed"
def test_all_resolved(self):
from src.services.dataset_review.clarification_pkg._helpers import derive_readiness_state
from src.models.dataset_review import ClarificationSession, DatasetReviewSession
session = MagicMock(spec=DatasetReviewSession)
cs = MagicMock(spec=ClarificationSession)
cs.current_question_id = None
cs.remaining_count = 0
result = derive_readiness_state(session, cs)
assert result.value == "review_ready"
# ── Tests for derive_recommended_action ────────────────────
class TestDeriveRecommendedAction:
"""derive_recommended_action — post-clarification action guidance."""
def test_none_clarification_preserves_current(self):
from src.services.dataset_review.clarification_pkg._helpers import derive_recommended_action
from src.models.dataset_review import DatasetReviewSession
session = MagicMock(spec=DatasetReviewSession)
session.recommended_action = MagicMock(value="launch_dataset")
result = derive_recommended_action(session, None)
assert result.value == "launch_dataset"
def test_current_question_prompts_answer(self):
from src.services.dataset_review.clarification_pkg._helpers import derive_recommended_action
from src.models.dataset_review import ClarificationSession, DatasetReviewSession
session = MagicMock(spec=DatasetReviewSession)
cs = MagicMock(spec=ClarificationSession)
cs.current_question_id = "q-1"
cs.remaining_count = 3
result = derive_recommended_action(session, cs)
assert result.value == "answer_next_question"
def test_remaining_unresolved_prompts_start(self):
from src.services.dataset_review.clarification_pkg._helpers import derive_recommended_action
from src.models.dataset_review import ClarificationSession, DatasetReviewSession
session = MagicMock(spec=DatasetReviewSession)
cs = MagicMock(spec=ClarificationSession)
cs.current_question_id = None
cs.remaining_count = 2
result = derive_recommended_action(session, cs)
assert result.value == "start_clarification"
def test_all_resolved_returns_review(self):
from src.services.dataset_review.clarification_pkg._helpers import derive_recommended_action
from src.models.dataset_review import ClarificationSession, DatasetReviewSession
session = MagicMock(spec=DatasetReviewSession)
cs = MagicMock(spec=ClarificationSession)
cs.current_question_id = None
cs.remaining_count = 0
result = derive_recommended_action(session, cs)
assert result.value == "review_documentation"
# #endregion Test.DatasetReview.ClarificationHelpers

View File

@@ -0,0 +1,672 @@
# #region Test.DatasetReview.Orchestrator [C:3] [TYPE Module] [SEMANTICS test,dataset,orchestrator,session,launch,preview]
# @BRIEF Tests for orchestrator.py — DatasetReviewOrchestrator: start_session, prepare_launch_preview,
# launch_dataset, _build_recovery_bootstrap, _enqueue_recovery_task.
# @RELATION BINDS_TO -> [DatasetReviewOrchestrator]
# @TEST_EDGE: start_session -> Superset link, dataset selection, validation errors
# @TEST_EDGE: prepare_launch_preview -> Success, missing session, preview blockers
# @TEST_EDGE: launch_dataset -> Success, failed SQL Lab, launch blockers
# @TEST_EDGE: _build_recovery_bootstrap -> Filter recovery, template variable discovery, execution mappings
# @TEST_EDGE: _enqueue_recovery_task -> Task linked, task manager absent, incompatible contract
from __future__ import annotations
from pathlib import Path
import sys
sys.path.insert(0, str(Path(__file__).parent.parent.parent / "src"))
from datetime import datetime, timezone
from unittest.mock import MagicMock, AsyncMock, patch, PropertyMock, call
import pytest
# ── Fixtures ────────────────────────────────────────────────
@pytest.fixture
def mock_repository():
"""Mock DatasetReviewSessionRepository."""
repo = MagicMock()
repo.db = MagicMock()
repo.event_logger = MagicMock()
repo.bump_session_version = MagicMock(return_value=2)
return repo
@pytest.fixture
def mock_config_manager():
"""Mock ConfigManager.get_environment."""
cm = MagicMock()
env = MagicMock()
env.id = "env-1"
env.name = "Test Environment"
cm.get_environment.return_value = env
return cm
@pytest.fixture
def mock_user():
from src.models.auth import User
user = MagicMock(spec=User)
user.id = "user-1"
return user
@pytest.fixture
def orchestrator(mock_repository, mock_config_manager):
from src.services.dataset_review.orchestrator import DatasetReviewOrchestrator
return DatasetReviewOrchestrator(
repository=mock_repository,
config_manager=mock_config_manager,
)
# ── Tests for start_session ────────────────────────────────
class TestStartSession:
"""start_session — session creation with validation."""
@pytest.mark.asyncio
async def test_start_session_dataset_selection(self, orchestrator, mock_repository, mock_user):
"""Dataset selection creates session with REVIEW_READY state."""
from src.services.dataset_review.orchestrator_pkg._commands import StartSessionCommand
command = StartSessionCommand(
source_kind="dataset_selection",
source_input="42",
environment_id="env-1",
user=mock_user,
)
# Setup repository mock chain
persisted_session = MagicMock()
persisted_session.session_id = "sess-1"
persisted_session.current_phase = MagicMock(value="review")
persisted_session.readiness_state = MagicMock(value="review_ready")
persisted_session.source_kind = "dataset_selection"
persisted_session.dataset_ref = "dataset:42"
persisted_session.dataset_id = 42
persisted_session.dashboard_id = None
persisted_session.active_task_id = None
persisted_session.user_id = "user-1"
mock_repository.create_session.return_value = persisted_session
mock_repository.save_profile_and_findings.return_value = persisted_session
mock_repository.save_recovery_state.return_value = persisted_session
with patch("src.services.dataset_review.orchestrator.build_initial_profile") as mock_build_profile, \
patch("src.services.dataset_review.orchestrator.parse_dataset_selection", return_value=("dataset:42", 42)):
mock_profile = MagicMock()
mock_build_profile.return_value = mock_profile
result = await orchestrator.start_session(command)
assert result.session is not None
assert result.session.session_id == "sess-1"
mock_repository.create_session.assert_called_once()
mock_repository.save_profile_and_findings.assert_called_once()
mock_repository.event_logger.log_event.assert_called()
@pytest.mark.asyncio
async def test_start_session_empty_source_raises(self, orchestrator, mock_user):
"""Empty source_input raises ValueError."""
from src.services.dataset_review.orchestrator_pkg._commands import StartSessionCommand
command = StartSessionCommand(
source_kind="dataset_selection",
source_input="",
environment_id="env-1",
user=mock_user,
)
with pytest.raises(ValueError, match="non-empty"):
await orchestrator.start_session(command)
@pytest.mark.asyncio
async def test_start_session_invalid_source_kind_raises(self, orchestrator, mock_user):
"""Invalid source_kind raises ValueError."""
from src.services.dataset_review.orchestrator_pkg._commands import StartSessionCommand
command = StartSessionCommand(
source_kind="invalid_kind",
source_input="test",
environment_id="env-1",
user=mock_user,
)
with pytest.raises(ValueError, match="source_kind must be"):
await orchestrator.start_session(command)
@pytest.mark.asyncio
async def test_start_session_environment_not_found(self, orchestrator, mock_config_manager, mock_user):
"""Missing environment raises ValueError."""
from src.services.dataset_review.orchestrator_pkg._commands import StartSessionCommand
mock_config_manager.get_environment.return_value = None
command = StartSessionCommand(
source_kind="dataset_selection",
source_input="42",
environment_id="nonexistent",
user=mock_user,
)
with pytest.raises(ValueError, match="Environment not found"):
await orchestrator.start_session(command)
@pytest.mark.asyncio
async def test_start_session_superset_link_partial_recovery(self, orchestrator, mock_repository, mock_config_manager, mock_user):
"""Superset link with partial recovery creates RECOVERY_REQUIRED session."""
from src.services.dataset_review.orchestrator_pkg._commands import StartSessionCommand
# Mock parsed context
parsed_context = MagicMock()
parsed_context.partial_recovery = True
parsed_context.unresolved_references = ["dataset_column_missing"]
parsed_context.dataset_ref = "schema.table"
parsed_context.dataset_id = 42
parsed_context.dashboard_id = 99
parsed_context.dataset_payload = {"id": 42}
command = StartSessionCommand(
source_kind="superset_link",
source_input="http://superset.example.com/dashboard/99",
environment_id="env-1",
user=mock_user,
)
persisted_session = MagicMock()
persisted_session.session_id = "sess-2"
persisted_session.current_phase = MagicMock(value="recovery")
persisted_session.readiness_state = MagicMock(value="recovery_required")
persisted_session.source_kind = "superset_link"
persisted_session.dataset_ref = "schema.table"
persisted_session.dataset_id = 42
persisted_session.dashboard_id = 99
persisted_session.active_task_id = None
persisted_session.user_id = "user-1"
mock_repository.create_session.return_value = persisted_session
mock_repository.save_profile_and_findings.return_value = persisted_session
mock_repository.save_recovery_state.return_value = persisted_session
with patch("src.services.dataset_review.orchestrator.SupersetContextExtractor") as mock_extractor_cls, \
patch("src.services.dataset_review.orchestrator.build_initial_profile") as mock_build_profile, \
patch("src.services.dataset_review.orchestrator.build_partial_recovery_findings") as mock_build_findings:
mock_extractor = MagicMock()
mock_extractor.parse_superset_link = AsyncMock(return_value=parsed_context)
mock_extractor.recover_imported_filters.return_value = []
mock_extractor.discover_template_variables.return_value = []
mock_extractor_cls.return_value = mock_extractor
mock_profile = MagicMock()
mock_build_profile.return_value = mock_profile
mock_build_findings.return_value = [MagicMock()]
result = await orchestrator.start_session(command)
assert result is not None
assert result.session.session_id == "sess-2"
@pytest.mark.asyncio
async def test_start_session_with_task_manager(self, orchestrator, mock_repository, mock_config_manager, mock_user):
"""Session start with task manager enqueues recovery task."""
from src.services.dataset_review.orchestrator_pkg._commands import StartSessionCommand
# Attach task manager
mock_task_manager = MagicMock()
task_object = MagicMock()
task_object.id = "task-1"
mock_task_manager.create_task.return_value = task_object
orchestrator.task_manager = mock_task_manager
command = StartSessionCommand(
source_kind="dataset_selection",
source_input="42",
environment_id="env-1",
user=mock_user,
)
persisted_session = MagicMock()
persisted_session.session_id = "sess-3"
persisted_session.current_phase = MagicMock(value="review")
persisted_session.readiness_state = MagicMock(value="review_ready")
persisted_session.source_kind = "dataset_selection"
persisted_session.dataset_ref = "dataset:42"
persisted_session.dataset_id = 42
persisted_session.dashboard_id = None
persisted_session.active_task_id = None
persisted_session.user_id = "user-1"
persisted_session.environment_id = "env-1"
persisted_session.source_input = "42"
mock_repository.create_session.return_value = persisted_session
mock_repository.save_profile_and_findings.return_value = persisted_session
with patch("src.services.dataset_review.orchestrator.build_initial_profile") as mock_build_profile, \
patch("src.services.dataset_review.orchestrator.parse_dataset_selection", return_value=("dataset:42", 42)):
mock_profile = MagicMock()
mock_build_profile.return_value = mock_profile
result = await orchestrator.start_session(command)
assert result.session.active_task_id == "task-1"
mock_repository.bump_session_version.assert_called()
mock_repository.db.commit.assert_called()
mock_repository.db.refresh.assert_called()
# ── Tests for prepare_launch_preview ───────────────────────
class TestPrepareLaunchPreview:
"""prepare_launch_preview — preview compilation."""
def test_prepare_launch_preview_success(self, orchestrator, mock_repository, mock_config_manager):
"""Successful preview compilation returns PreparePreviewResult."""
from src.services.dataset_review.orchestrator_pkg._commands import PreparePreviewCommand
from src.services.dataset_review.orchestrator_pkg._helpers import PreviewStatus
session = MagicMock()
session.session_id = "sess-1"
session.user_id = "user-1"
session.dataset_id = 42
session.environment_id = "env-1"
session.current_phase = MagicMock(value="review")
session.last_activity_at = None
session.version = 1
mock_repository.load_session_detail.return_value = session
# Mock execution_snapshot with no blockers
with patch("src.services.dataset_review.orchestrator.build_execution_snapshot") as mock_build_snapshot, \
patch("src.services.dataset_review.orchestrator.SupersetCompilationAdapter") as mock_adapter_cls, \
patch("src.services.dataset_review.orchestrator.build_launch_blockers") as mock_build_blockers:
mock_snapshot = {
"preview_blockers": [],
"preview_fingerprint": "fp-1",
"template_params": {"region": "US"},
"effective_filters": [],
}
mock_build_snapshot.return_value = mock_snapshot
mock_adapter = MagicMock()
mock_adapter_cls.return_value = mock_adapter
preview = MagicMock()
preview.preview_id = "pv-1"
preview.preview_status = PreviewStatus.READY
preview.preview_fingerprint = "fp-1"
mock_adapter.compile_preview.return_value = preview
persisted_preview = MagicMock()
persisted_preview.preview_id = "pv-1"
persisted_preview.preview_status = PreviewStatus.READY
mock_repository.save_preview.return_value = persisted_preview
mock_build_blockers.return_value = []
command = PreparePreviewCommand(
session_id="sess-1",
expected_version=1,
user=MagicMock(id="user-1"),
)
result = orchestrator.prepare_launch_preview(command)
assert result.session is not None
assert result.session.session_id == "sess-1"
def test_prepare_preview_session_not_found(self, orchestrator, mock_repository):
"""Missing session raises ValueError."""
from src.services.dataset_review.orchestrator_pkg._commands import PreparePreviewCommand
mock_repository.load_session_detail.return_value = None
command = PreparePreviewCommand(
session_id="nonexistent",
user=MagicMock(id="user-1"),
)
with pytest.raises(ValueError, match="Session not found"):
orchestrator.prepare_launch_preview(command)
def test_prepare_preview_no_dataset_id(self, orchestrator, mock_repository):
"""Missing dataset_id raises ValueError."""
from src.services.dataset_review.orchestrator_pkg._commands import PreparePreviewCommand
session = MagicMock()
session.session_id = "sess-1"
session.user_id = "user-1"
session.dataset_id = None
session.environment_id = "env-1"
mock_repository.load_session_detail.return_value = session
command = PreparePreviewCommand(
session_id="sess-1",
user=MagicMock(id="user-1"),
)
with pytest.raises(ValueError, match="requires a resolved dataset_id"):
orchestrator.prepare_launch_preview(command)
def test_prepare_preview_env_not_found(self, orchestrator, mock_repository, mock_config_manager):
"""Missing environment raises ValueError."""
from src.services.dataset_review.orchestrator_pkg._commands import PreparePreviewCommand
session = MagicMock()
session.session_id = "sess-1"
session.user_id = "user-1"
session.dataset_id = 42
session.environment_id = "nonexistent"
session.current_phase = MagicMock(value="review")
mock_repository.load_session_detail.return_value = session
mock_config_manager.get_environment.return_value = None
command = PreparePreviewCommand(
session_id="sess-1",
user=MagicMock(id="user-1"),
)
with pytest.raises(ValueError, match="Environment not found"):
orchestrator.prepare_launch_preview(command)
# ── Tests for launch_dataset ───────────────────────────────
class TestLaunchDataset:
"""launch_dataset — dataset execution launch."""
def test_launch_success(self, orchestrator, mock_repository, mock_config_manager):
"""Successful launch returns LaunchDatasetResult."""
from src.services.dataset_review.orchestrator_pkg._commands import LaunchDatasetCommand
from src.models.dataset_review import LaunchStatus
session = MagicMock()
session.session_id = "sess-1"
session.user_id = "user-1"
session.dataset_id = 42
session.environment_id = "env-1"
session.dataset_ref = "schema.table"
session.current_phase = MagicMock(value="preview")
session.last_activity_at = None
session.version = 1
mock_repository.load_session_detail.return_value = session
with patch("src.services.dataset_review.orchestrator.build_execution_snapshot") as mock_build_snapshot, \
patch("src.services.dataset_review.orchestrator.get_latest_preview") as mock_get_preview, \
patch("src.services.dataset_review.orchestrator.build_launch_blockers") as mock_build_blockers, \
patch("src.services.dataset_review.orchestrator.SupersetCompilationAdapter") as mock_adapter_cls:
mock_snapshot = {
"preview_blockers": [],
"preview_fingerprint": "fp-1",
"template_params": {"region": "US"},
"effective_filters": [],
"approved_mapping_ids": ["map-1"],
"semantic_decision_refs": [],
"open_warning_refs": [],
}
mock_build_snapshot.return_value = mock_snapshot
preview = MagicMock()
preview.preview_id = "pv-1"
preview.compiled_sql = "SELECT * FROM table"
preview.preview_fingerprint = "fp-1"
mock_get_preview.return_value = preview
mock_build_blockers.return_value = []
mock_adapter = MagicMock()
mock_adapter.create_sql_lab_session.return_value = "sql-lab-ref-1"
mock_adapter_cls.return_value = mock_adapter
run_context = MagicMock()
run_context.run_context_id = "rc-1"
run_context.launch_status = LaunchStatus.STARTED
run_context.preview_id = "pv-1"
run_context.sql_lab_session_ref = "sql-lab-ref-1"
mock_repository.save_run_context.return_value = run_context
command = LaunchDatasetCommand(
session_id="sess-1",
expected_version=1,
user=MagicMock(id="user-1"),
)
result = orchestrator.launch_dataset(command)
assert result.run_context is not None
assert result.run_context.run_context_id == "rc-1"
def test_launch_session_not_found(self, orchestrator, mock_repository):
"""Missing session raises ValueError."""
from src.services.dataset_review.orchestrator_pkg._commands import LaunchDatasetCommand
mock_repository.load_session_detail.return_value = None
command = LaunchDatasetCommand(
session_id="nonexistent",
user=MagicMock(id="user-1"),
)
with pytest.raises(ValueError, match="Session not found"):
orchestrator.launch_dataset(command)
def test_launch_blocked_by_validation(self, orchestrator, mock_repository):
"""Launch blockers raise ValueError."""
from src.services.dataset_review.orchestrator_pkg._commands import LaunchDatasetCommand
session = MagicMock()
session.session_id = "sess-1"
session.user_id = "user-1"
session.dataset_id = 42
session.environment_id = "env-1"
session.current_phase = MagicMock(value="preview")
mock_repository.load_session_detail.return_value = session
with patch("src.services.dataset_review.orchestrator.build_execution_snapshot") as mock_build_snapshot, \
patch("src.services.dataset_review.orchestrator.get_latest_preview") as mock_get_preview, \
patch("src.services.dataset_review.orchestrator.build_launch_blockers") as mock_build_blockers:
mock_build_snapshot.return_value = {"preview_blockers": []}
preview = MagicMock()
preview.preview_fingerprint = "fp-1"
mock_get_preview.return_value = preview
mock_build_blockers.return_value = ["finding:MISSING:blocking"]
command = LaunchDatasetCommand(
session_id="sess-1",
user=MagicMock(id="user-1"),
)
with pytest.raises(ValueError, match="Launch blocked"):
orchestrator.launch_dataset(command)
def test_launch_sql_lab_failure(self, orchestrator, mock_repository, mock_config_manager):
"""SQL Lab failure sets launch status to FAILED without raising."""
from src.services.dataset_review.orchestrator_pkg._commands import LaunchDatasetCommand
from src.models.dataset_review import LaunchStatus
session = MagicMock()
session.session_id = "sess-1"
session.user_id = "user-1"
session.dataset_id = 42
session.environment_id = "env-1"
session.dataset_ref = "schema.table"
session.current_phase = MagicMock(value="preview")
session.last_activity_at = None
session.version = 1
mock_repository.load_session_detail.return_value = session
with patch("src.services.dataset_review.orchestrator.build_execution_snapshot") as mock_build_snapshot, \
patch("src.services.dataset_review.orchestrator.get_latest_preview") as mock_get_preview, \
patch("src.services.dataset_review.orchestrator.build_launch_blockers") as mock_build_blockers, \
patch("src.services.dataset_review.orchestrator.SupersetCompilationAdapter") as mock_adapter_cls:
mock_build_snapshot.return_value = {
"preview_blockers": [],
"preview_fingerprint": "fp-1",
"template_params": {},
"effective_filters": [],
"approved_mapping_ids": [],
"semantic_decision_refs": [],
"open_warning_refs": [],
}
preview = MagicMock()
preview.preview_id = "pv-1"
preview.compiled_sql = "SELECT * FROM table"
preview.preview_fingerprint = "fp-1"
mock_get_preview.return_value = preview
mock_build_blockers.return_value = []
mock_adapter = MagicMock()
mock_adapter.create_sql_lab_session.side_effect = Exception("SQL Lab error")
mock_adapter_cls.return_value = mock_adapter
run_context = MagicMock()
run_context.run_context_id = "rc-1"
run_context.launch_status = LaunchStatus.FAILED
run_context.preview_id = "pv-1"
run_context.sql_lab_session_ref = "unavailable"
mock_repository.save_run_context.return_value = run_context
command = LaunchDatasetCommand(
session_id="sess-1",
user=MagicMock(id="user-1"),
)
result = orchestrator.launch_dataset(command)
assert result.run_context.launch_status == LaunchStatus.FAILED
# ── Tests for _build_recovery_bootstrap ────────────────────
class TestBuildRecoveryBootstrap:
"""_build_recovery_bootstrap — recovery from Superset context."""
@pytest.mark.asyncio
async def test_build_recovery_bootstrap(self, orchestrator, mock_repository, mock_config_manager):
"""Recovery bootstrap builds filters, variables, and mappings."""
session = MagicMock()
session.session_id = "sess-1"
session.dataset_id = 42
parsed_context = MagicMock()
parsed_context.dataset_payload = {"id": 42}
parsed_context.unresolved_references = []
environment = MagicMock()
with patch("src.services.dataset_review.orchestrator.SupersetContextExtractor") as mock_extractor_cls:
mock_extractor = MagicMock()
mock_extractor.recover_imported_filters.return_value = [
{"filter_name": "region", "display_name": "Region", "raw_value": "us", "normalized_value": "US", "source": "superset_url", "confidence_state": "confirmed", "requires_confirmation": False, "recovery_status": "recovered"}
]
mock_extractor.discover_template_variables.return_value = [
{"variable_name": "region", "expression_source": "table", "variable_kind": "native_filter", "is_required": True, "default_value": None}
]
mock_extractor_cls.return_value = mock_extractor
imported_filters, template_vars, mappings, findings = (
await orchestrator._build_recovery_bootstrap(environment, session, parsed_context, [])
)
assert len(imported_filters) == 1
assert imported_filters[0].filter_name == "region"
assert len(template_vars) == 1
assert template_vars[0].variable_name == "region"
assert len(mappings) == 1
assert mappings[0].mapping_method.value == "direct_match"
@pytest.mark.asyncio
async def test_recovery_bootstrap_handles_discovery_error(self, orchestrator, mock_repository):
"""Template variable discovery failure adds warning finding."""
session = MagicMock()
session.session_id = "sess-1"
session.dataset_id = 42
parsed_context = MagicMock()
parsed_context.dataset_payload = None # Will trigger fetch which fails
parsed_context.unresolved_references = []
environment = MagicMock()
with patch("src.services.dataset_review.orchestrator.SupersetContextExtractor") as mock_extractor_cls:
mock_extractor = MagicMock()
mock_extractor.recover_imported_filters.return_value = []
# Make the client call fail
mock_client = MagicMock()
mock_client.get_dataset_detail.side_effect = Exception("API error")
mock_extractor.client = mock_client
mock_extractor_cls.return_value = mock_extractor
imported_filters, template_vars, mappings, findings = (
await orchestrator._build_recovery_bootstrap(environment, session, parsed_context, [])
)
assert len(findings) == 1
assert findings[0].code == "TEMPLATE_VARIABLE_DISCOVERY_FAILED"
# ── Tests for _enqueue_recovery_task ───────────────────────
class TestEnqueueRecoveryTask:
"""_enqueue_recovery_task — task linking."""
def test_task_enqueued(self, orchestrator):
"""Task is created and ID returned when task_manager is present."""
mock_task_manager = MagicMock()
task = MagicMock()
task.id = "task-1"
mock_task_manager.create_task.return_value = task
orchestrator.task_manager = mock_task_manager
command = MagicMock()
command.user.id = "user-1"
session = MagicMock()
session.session_id = "sess-1"
session.environment_id = "env-1"
session.source_kind = "dataset_selection"
session.source_input = "42"
session.dataset_ref = "dataset:42"
session.dataset_id = 42
session.dashboard_id = None
task_id = orchestrator._enqueue_recovery_task(command, session, None)
assert task_id == "task-1"
def test_no_task_manager_returns_none(self, orchestrator):
"""No task_manager returns None."""
orchestrator.task_manager = None
command = MagicMock()
session = MagicMock()
result = orchestrator._enqueue_recovery_task(command, session, None)
assert result is None
def test_task_manager_missing_create_task(self, orchestrator):
"""Task manager without create_task returns None."""
mock_task_manager = MagicMock(spec=[])
orchestrator.task_manager = mock_task_manager
command = MagicMock()
session = MagicMock()
result = orchestrator._enqueue_recovery_task(command, session, None)
assert result is None
def test_create_task_type_error_returns_none(self, orchestrator):
"""TypeError from create_task returns None."""
mock_task_manager = MagicMock()
mock_task_manager.create_task.side_effect = TypeError()
orchestrator.task_manager = mock_task_manager
command = MagicMock()
command.user.id = "user-1"
session = MagicMock()
session.session_id = "sess-1"
session.environment_id = "env-1"
session.source_kind = "dataset_selection"
session.source_input = "42"
session.dataset_ref = "dataset:42"
session.dataset_id = 42
session.dashboard_id = None
result = orchestrator._enqueue_recovery_task(command, session, None)
assert result is None
# #endregion Test.DatasetReview.Orchestrator

View File

@@ -0,0 +1,537 @@
# #region Test.DatasetReview.OrchestratorHelpers [C:3] [TYPE Module] [SEMANTICS test,dataset,orchestrator,helpers,snapshot,blockers]
# @BRIEF Tests for orchestrator_pkg/_helpers.py — pure helper functions: parse_dataset_selection, build_initial_profile,
# build_partial_recovery_findings, extract_effective_filter_value, build_execution_snapshot, build_launch_blockers,
# get_latest_preview, compute_preview_fingerprint.
# @RELATION BINDS_TO -> [OrchestratorHelpers]
# @TEST_EDGE: parse_dataset_selection -> Numeric, prefixed, and invalid inputs
# @TEST_EDGE: build_initial_profile -> With/without parsed context, partial recovery
# @TEST_EDGE: build_partial_recovery_findings -> Unresolved refs produce findings
# @TEST_EDGE: extract_effective_filter_value -> Dict normalised vs raw fallback
# @TEST_EDGE: build_execution_snapshot -> Full snapshot with mappings and blockers
# @TEST_EDGE: build_launch_blockers -> Blocking findings, missing preview, fingerprint mismatch
# @TEST_EDGE: get_latest_preview -> By session.last_preview_id, sorted fallback, empty
# @TEST_EDGE: compute_preview_fingerprint -> Deterministic SHA256
from __future__ import annotations
from pathlib import Path
import sys
sys.path.insert(0, str(Path(__file__).parent.parent.parent / "src"))
from datetime import datetime, timezone
from unittest.mock import MagicMock, patch
import pytest
# ── Fixtures ────────────────────────────────────────────────
@pytest.fixture
def mock_session():
"""Build a DatasetReviewSession with all children populated for snapshot tests."""
from src.models.dataset_review import (
DatasetReviewSession, ApprovalState, FilterConfidenceState, FilterRecoveryStatus,
FilterSource, ImportedFilter, TemplateVariable, ExecutionMapping, MappingMethod,
MappingStatus, SessionPhase, ReadinessState, SessionStatus, VariableKind,
CompiledPreview, PreviewStatus, ValidationFinding, FindingArea, FindingSeverity,
ResolutionState, SemanticFieldEntry, FieldProvenance,
)
session = MagicMock(spec=DatasetReviewSession)
session.session_id = "sess-1"
session.dataset_id = 42
session.dataset_ref = "schema.dataset_name"
session.user_id = "user-1"
session.environment_id = "env-1"
session.version = 1
session.current_phase = MagicMock(value="review")
session.readiness_state = MagicMock(value="review_ready")
session.recommended_action = MagicMock(value="launch_dataset")
session.status = MagicMock(value="active")
# ImportedFilters
imported_filter1 = MagicMock(spec=ImportedFilter)
imported_filter1.filter_id = "filt-1"
imported_filter1.filter_name = "region"
imported_filter1.display_name = "Region"
imported_filter1.raw_value = "us"
imported_filter1.normalized_value = "US"
imported_filter1.source = MagicMock(value="superset_url")
imported_filter1.confidence_state = MagicMock(value="confirmed")
imported_filter2 = MagicMock(spec=ImportedFilter)
imported_filter2.filter_id = "filt-2"
imported_filter2.filter_name = "date_range"
imported_filter2.display_name = "Date Range"
imported_filter2.raw_value = "2024-01-01"
imported_filter2.normalized_value = {"filter_clauses": ["date >= '2024-01-01'"]}
session.imported_filters = [imported_filter1, imported_filter2]
# TemplateVariables
tv1 = MagicMock(spec=TemplateVariable)
tv1.variable_id = "tv-1"
tv1.variable_name = "region"
tv1.is_required = True
tv1.default_value = None
tv2 = MagicMock(spec=TemplateVariable)
tv2.variable_id = "tv-2"
tv2.variable_name = "date_range"
tv2.is_required = False
tv2.default_value = "2024-06-01"
session.template_variables = [tv1, tv2]
# ExecutionMappings
mapping1 = MagicMock(spec=ExecutionMapping)
mapping1.mapping_id = "map-1"
mapping1.filter_id = "filt-1"
mapping1.variable_id = "tv-1"
mapping1.effective_value = "US"
mapping1.raw_input_value = "us"
mapping1.approval_state = ApprovalState.APPROVED
mapping1.requires_explicit_approval = False
mapping2 = MagicMock(spec=ExecutionMapping)
mapping2.mapping_id = "map-2"
mapping2.filter_id = "filt-2"
mapping2.variable_id = "tv-2"
mapping2.effective_value = None # will fallback to normalized_value
mapping2.raw_input_value = "2024-01-01"
mapping2.approval_state = ApprovalState.PENDING
mapping2.requires_explicit_approval = True
session.execution_mappings = [mapping1, mapping2]
# SemanticFields
field1 = MagicMock(spec=SemanticFieldEntry)
field1.field_id = "sf-1"
field1.is_locked = True
field1.needs_review = False
field1.provenance = MagicMock(value="exact")
session.semantic_fields = [field1]
# Previews
preview1 = MagicMock(spec=CompiledPreview)
preview1.preview_id = "pv-1"
preview1.created_at = datetime(2024, 6, 1, tzinfo=timezone.utc)
preview1.preview_status = PreviewStatus.READY
preview1.preview_fingerprint = "fingerprint-a"
preview1.compiled_sql = "SELECT * FROM table"
preview2 = MagicMock(spec=CompiledPreview)
preview2.preview_id = "pv-2"
preview2.created_at = datetime(2024, 6, 2, tzinfo=timezone.utc)
preview2.preview_status = PreviewStatus.READY
preview2.preview_fingerprint = "fingerprint-b"
preview2.compiled_sql = "SELECT * FROM table_v2"
session.previews = [preview1, preview2]
session.last_preview_id = "pv-2"
# Findings
finding1 = MagicMock(spec=ValidationFinding)
finding1.finding_id = "find-1"
finding1.area = FindingArea.SOURCE_INTAKE
finding1.severity = FindingSeverity.BLOCKING
finding1.code = "MISSING_PERMISSION"
finding1.resolution_state = ResolutionState.OPEN
session.findings = [finding1]
return session
@pytest.fixture
def mock_parsed_context():
"""SupersetParsedContext-like object for recovery tests."""
ctx = MagicMock()
ctx.partial_recovery = True
ctx.unresolved_references = ["dataset_column_missing", "filter_value_ambiguous"]
ctx.dataset_id = 42
ctx.dataset_ref = "schema.dataset_name"
ctx.dataset_payload = {"id": 42, "table_name": "dataset_name"}
return ctx
# ── Tests for parse_dataset_selection ───────────────────────
class TestParseDatasetSelection:
"""parse_dataset_selection — source_input normalization."""
def test_numeric_input(self):
from src.services.dataset_review.orchestrator_pkg._helpers import parse_dataset_selection
ref, did = parse_dataset_selection("42")
assert ref == "dataset:42"
assert did == 42
def test_dataset_prefix_with_numeric(self):
from src.services.dataset_review.orchestrator_pkg._helpers import parse_dataset_selection
ref, did = parse_dataset_selection("dataset:99")
assert ref == "dataset:99"
assert did == 99
def test_dataset_prefix_non_numeric(self):
from src.services.dataset_review.orchestrator_pkg._helpers import parse_dataset_selection
ref, did = parse_dataset_selection("dataset:abc")
assert ref == "dataset:abc"
assert did is None
def test_arbitrary_string(self):
from src.services.dataset_review.orchestrator_pkg._helpers import parse_dataset_selection
ref, did = parse_dataset_selection("schema.table")
assert ref == "schema.table"
assert did is None
def test_empty_input_raises(self):
from src.services.dataset_review.orchestrator_pkg._helpers import parse_dataset_selection
with pytest.raises(ValueError, match="non-empty"):
parse_dataset_selection("")
with pytest.raises(ValueError, match="non-empty"):
parse_dataset_selection(" ")
def test_whitespace_stripped(self):
from src.services.dataset_review.orchestrator_pkg._helpers import parse_dataset_selection
ref, did = parse_dataset_selection(" 55 ")
assert ref == "dataset:55"
assert did == 55
# ── Tests for build_initial_profile ────────────────────────
class TestBuildInitialProfile:
"""build_initial_profile — initial DatasetProfile creation."""
def test_with_dataset_ref(self):
from src.services.dataset_review.orchestrator_pkg._helpers import build_initial_profile
profile = build_initial_profile("sess-1", None, "schema.dataset_name")
assert profile.session_id == "sess-1"
assert profile.dataset_name == "dataset_name"
assert profile.schema_name == "schema"
assert profile.confidence_state.value == "mostly_confirmed"
assert profile.completeness_score == 0.25
assert profile.manual_summary_locked is False
def test_with_partial_recovery(self):
from src.services.dataset_review.orchestrator_pkg._helpers import build_initial_profile
ctx = MagicMock()
ctx.partial_recovery = True
profile = build_initial_profile("sess-1", ctx, "schema.dataset_name")
assert profile.confidence_state.value == "mixed"
assert profile.has_warning_findings is True
assert profile.has_blocking_findings is False
def test_without_dataset_ref(self):
from src.services.dataset_review.orchestrator_pkg._helpers import build_initial_profile
profile = build_initial_profile("sess-1", None, "")
assert profile.dataset_name == "Unresolved dataset"
assert profile.schema_name is None
assert profile.confidence_state.value == "mostly_confirmed"
# ── Tests for build_partial_recovery_findings ──────────────
class TestBuildPartialRecoveryFindings:
"""build_partial_recovery_findings — mapping unresolved refs to findings."""
def test_with_unresolved_references(self):
from src.services.dataset_review.orchestrator_pkg._helpers import build_partial_recovery_findings
ctx = MagicMock()
ctx.unresolved_references = ["ref_a", "ref_b"]
findings = build_partial_recovery_findings(ctx)
assert len(findings) == 2
assert findings[0].area.value == "source_intake"
assert findings[0].severity.value == "warning"
assert findings[0].caused_by_ref == "ref_a"
def test_without_unresolved_references(self):
from src.services.dataset_review.orchestrator_pkg._helpers import build_partial_recovery_findings
ctx = MagicMock()
ctx.unresolved_references = []
findings = build_partial_recovery_findings(ctx)
assert len(findings) == 0
def test_missing_unresolved_refs_attribute(self):
from src.services.dataset_review.orchestrator_pkg._helpers import build_partial_recovery_findings
ctx = MagicMock(spec=[])
findings = build_partial_recovery_findings(ctx)
assert len(findings) == 0
# ── Tests for extract_effective_filter_value ───────────────
class TestExtractEffectiveFilterValue:
"""extract_effective_filter_value — normalized dict vs raw fallback."""
def test_dict_normalized_returns_raw(self):
from src.services.dataset_review.orchestrator_pkg._helpers import extract_effective_filter_value
result = extract_effective_filter_value(
{"filter_clauses": ["date >= 'x'"]}, "raw_date"
)
assert result == "raw_date"
def test_dict_with_extra_form_data_returns_raw(self):
from src.services.dataset_review.orchestrator_pkg._helpers import extract_effective_filter_value
result = extract_effective_filter_value(
{"extra_form_data": {"foo": "bar"}}, "raw_val"
)
assert result == "raw_val"
def test_non_dict_normalized_returns_normalized(self):
from src.services.dataset_review.orchestrator_pkg._helpers import extract_effective_filter_value
result = extract_effective_filter_value("normalized_val", "raw_val")
assert result == "normalized_val"
def test_none_normalized_returns_raw(self):
from src.services.dataset_review.orchestrator_pkg._helpers import extract_effective_filter_value
result = extract_effective_filter_value(None, "raw_val")
assert result == "raw_val"
def test_both_none(self):
from src.services.dataset_review.orchestrator_pkg._helpers import extract_effective_filter_value
result = extract_effective_filter_value(None, None)
assert result is None
# ── Tests for get_latest_preview ───────────────────────────
class TestGetLatestPreview:
"""get_latest_preview — resolved by last_preview_id or sorted fallback."""
def test_by_last_preview_id(self):
from src.services.dataset_review.orchestrator_pkg._helpers import get_latest_preview
session = MagicMock()
pv1 = MagicMock()
pv1.preview_id = "pv-1"
pv2 = MagicMock()
pv2.preview_id = "pv-2"
session.previews = [pv1, pv2]
session.last_preview_id = "pv-2"
result = get_latest_preview(session)
assert result.preview_id == "pv-2"
def test_sorted_fallback_no_last_preview(self):
from src.services.dataset_review.orchestrator_pkg._helpers import get_latest_preview
session = MagicMock()
pv1 = MagicMock()
pv1.preview_id = "pv-1"
pv1.created_at = datetime(2024, 1, 1, tzinfo=timezone.utc)
pv2 = MagicMock()
pv2.preview_id = "pv-2"
pv2.created_at = datetime(2024, 6, 1, tzinfo=timezone.utc)
session.previews = [pv1, pv2]
session.last_preview_id = None
result = get_latest_preview(session)
assert result.preview_id == "pv-2"
def test_empty_previews_returns_none(self):
from src.services.dataset_review.orchestrator_pkg._helpers import get_latest_preview
session = MagicMock()
session.previews = []
session.last_preview_id = None
result = get_latest_preview(session)
assert result is None
def test_last_preview_id_not_in_list_falls_back_to_sorted(self):
from src.services.dataset_review.orchestrator_pkg._helpers import get_latest_preview
session = MagicMock()
pv1 = MagicMock()
pv1.preview_id = "pv-1"
pv1.created_at = datetime(2024, 1, 1, tzinfo=timezone.utc)
session.previews = [pv1]
session.last_preview_id = "pv-nonexistent"
result = get_latest_preview(session)
assert result.preview_id == "pv-1"
# ── Tests for compute_preview_fingerprint ──────────────────
class TestComputePreviewFingerprint:
"""compute_preview_fingerprint — deterministic SHA256."""
def test_deterministic(self):
from src.services.dataset_review.orchestrator_pkg._helpers import compute_preview_fingerprint
payload = {"dataset_id": 42, "template_params": {"region": "US"}, "effective_filters": []}
fp1 = compute_preview_fingerprint(payload)
fp2 = compute_preview_fingerprint(payload)
assert fp1 == fp2
assert len(fp1) == 64 # SHA256 hex
def test_different_inputs(self):
from src.services.dataset_review.orchestrator_pkg._helpers import compute_preview_fingerprint
fp_a = compute_preview_fingerprint({"a": 1})
fp_b = compute_preview_fingerprint({"a": 2})
assert fp_a != fp_b
# ── Tests for build_execution_snapshot ─────────────────────
class TestBuildExecutionSnapshot:
"""build_execution_snapshot — full snapshot from session."""
def test_full_snapshot(self, mock_session):
from src.services.dataset_review.orchestrator_pkg._helpers import build_execution_snapshot
snapshot = build_execution_snapshot(mock_session)
assert "effective_filters" in snapshot
assert "template_params" in snapshot
assert "approved_mapping_ids" in snapshot
assert "semantic_decision_refs" in snapshot
assert "open_warning_refs" in snapshot
assert "preview_blockers" in snapshot
assert "preview_fingerprint" in snapshot
# map-1 is approved, map-2 is pending + requires_explicit_approval
assert snapshot["approved_mapping_ids"] == ["map-1"]
assert "map-2" in snapshot["open_warning_refs"]
# template_params should include region=US from mapping, and date_range default
assert snapshot["template_params"]["region"] == "US"
# effective_filters should contain both filters
filter_names = {f["filter_name"] for f in snapshot["effective_filters"]}
assert "region" in filter_names
def test_missing_filter_reference_produces_blocker(self):
from src.services.dataset_review.orchestrator_pkg._helpers import build_execution_snapshot
from src.models.dataset_review import ExecutionMapping, ImportedFilter
session = MagicMock()
session.imported_filters = [MagicMock(spec=ImportedFilter, filter_id="filt-1")]
tv = MagicMock()
tv.variable_id = "tv-1"
tv.variable_name = "region"
tv.default_value = None
tv.is_required = True
session.template_variables = [tv]
mapping = MagicMock(spec=ExecutionMapping)
mapping.filter_id = "filt-nonexistent"
mapping.variable_id = "tv-1"
mapping.effective_value = "US"
mapping.raw_input_value = "us"
mapping.approval_state = MagicMock(value="pending")
mapping.requires_explicit_approval = False
session.execution_mappings = [mapping]
session.semantic_fields = []
session.dataset_id = 42
snapshot = build_execution_snapshot(session)
assert any("missing_filter" in b for b in snapshot["preview_blockers"])
def test_unmapped_required_variable_produces_blocker(self):
from src.services.dataset_review.orchestrator_pkg._helpers import build_execution_snapshot
from src.models.dataset_review import ImportedFilter, TemplateVariable
session = MagicMock()
filt = MagicMock(spec=ImportedFilter)
filt.filter_id = "filt-1"
filt.filter_name = "region"
filt.raw_value = "us"
filt.normalized_value = "US"
filt.display_name = "Region"
session.imported_filters = [filt]
tv = MagicMock(spec=TemplateVariable)
tv.variable_id = "tv-1"
tv.variable_name = "region"
tv.is_required = True
tv.default_value = None
session.template_variables = [tv]
session.execution_mappings = [] # no mappings, so variable is unmapped
session.semantic_fields = []
session.dataset_id = 42
snapshot = build_execution_snapshot(session)
assert any("unmapped" in b for b in snapshot["preview_blockers"])
# ── Tests for build_launch_blockers ────────────────────────
class TestBuildLaunchBlockers:
"""build_launch_blockers — launch gate enforcement."""
def test_blocking_finding_creates_blocker(self, mock_session):
from src.services.dataset_review.orchestrator_pkg._helpers import build_launch_blockers
blockers = build_launch_blockers(mock_session, {"preview_blockers": []}, None)
assert any("finding:MISSING_PERMISSION:blocking" in b for b in blockers)
def test_missing_preview_creates_blocker(self, mock_session):
from src.services.dataset_review.orchestrator_pkg._helpers import build_launch_blockers
blockers = build_launch_blockers(mock_session, {"preview_blockers": []}, None)
assert any("preview:missing" in b for b in blockers)
def test_fingerprint_mismatch_creates_blocker(self, mock_session):
from src.services.dataset_review.orchestrator_pkg._helpers import build_launch_blockers, compute_preview_fingerprint
preview = MagicMock()
preview.preview_status = MagicMock(value="ready")
preview.preview_status.value = "ready"
preview.preview_fingerprint = "different-fingerprint"
execution_fingerprint = compute_preview_fingerprint({"test": "data"})
blockers = build_launch_blockers(
mock_session,
{"preview_blockers": [], "preview_fingerprint": execution_fingerprint},
preview,
)
assert any("fingerprint_mismatch" in b for b in blockers)
def test_approval_required_blocker(self, mock_session):
from src.services.dataset_review.orchestrator_pkg._helpers import build_launch_blockers
from src.models.dataset_review import ApprovalState, CompiledPreview, PreviewStatus
# Clear findings so they don't block
mock_session.findings = []
preview = MagicMock(spec=CompiledPreview)
preview.preview_status = PreviewStatus.READY
preview.preview_fingerprint = "some-fingerprint"
blockers = build_launch_blockers(
mock_session,
{"preview_blockers": [], "preview_fingerprint": "some-fingerprint"},
preview,
)
# map-2 requires explicit approval and is PENDING
assert any("approval_required" in b for b in blockers)
def test_no_blockers(self, mock_session):
from src.services.dataset_review.orchestrator_pkg._helpers import build_launch_blockers
from src.models.dataset_review import ApprovalState, CompiledPreview, PreviewStatus, ResolutionState
# Clear all findings and set all approvals
mock_session.findings = []
for m in mock_session.execution_mappings:
m.approval_state = ApprovalState.APPROVED
m.requires_explicit_approval = False
preview = MagicMock(spec=CompiledPreview)
preview.preview_status = PreviewStatus.READY
preview.preview_fingerprint = "some-fingerprint"
blockers = build_launch_blockers(
mock_session,
{"preview_blockers": [], "preview_fingerprint": "some-fingerprint"},
preview,
)
assert len(blockers) == 0
def test_resolved_finding_not_blocking(self, mock_session):
from src.services.dataset_review.orchestrator_pkg._helpers import build_launch_blockers
from src.models.dataset_review import (
ApprovalState, CompiledPreview, PreviewStatus, ResolutionState, ValidationFinding,
)
# Set finding to resolved
for f in mock_session.findings:
f.resolution_state = ResolutionState.RESOLVED
preview = MagicMock(spec=CompiledPreview)
preview.preview_status = PreviewStatus.READY
preview.preview_fingerprint = "some-fingerprint"
blockers = build_launch_blockers(
mock_session,
{"preview_blockers": [], "preview_fingerprint": "some-fingerprint"},
preview,
)
assert not any("finding:" in b for b in blockers)
# #endregion Test.DatasetReview.OrchestratorHelpers

View File

@@ -30,7 +30,8 @@ class TestSemanticSourceResolverResolveFromFile:
{"source_ref": "upload.csv"},
[{"field_name": "region"}],
)
assert result.source_ref == "uploaded_file"
# Source returns the source_ref value from the payload, defaulting to "uploaded_file"
assert result.source_ref == "upload.csv"
class TestSemanticSourceResolverResolveFromDictionary:
@@ -61,12 +62,13 @@ class TestSemanticSourceResolverResolveFromDictionary:
source_payload = {
"source_ref": "dict_1",
"rows": [
{"column_name": "region_code", "verbose_name": "Region Code", "description": "Region code"},
{"column_name": "region_name", "verbose_name": "Region Name", "description": "Region name"},
],
}
fields = [{"field_name": "region"}]
result = resolver.resolve_from_dictionary(source_payload, fields)
assert len(result.resolved_fields) == 1
# region vs region_name has SequenceMatcher ratio > 0.72 -> fuzzy match
assert result.resolved_fields[0]["provenance"] == "fuzzy_inferred"
assert result.resolved_fields[0]["needs_review"] is True
@@ -169,7 +171,8 @@ class TestSemanticSourceResolverResolveFromReference:
{"source_ref": "ref_1"},
[{"field_name": "region"}],
)
assert result.source_ref == "reference_dataset"
# Source returns the source_ref value from the payload, defaulting to "reference_dataset"
assert result.source_ref == "ref_1"
class TestSemanticSourceResolverRankCandidates:
@@ -363,7 +366,7 @@ class TestNormalizeKey:
@pytest.mark.parametrize("input_val,expected", [
("Hello World", "helloworld"),
("region_code", "region_code"),
("UPPER_CASE", "uppercase"),
("UPPER_CASE", "upper_case"), # _normalize_key preserves underscores
(" with spaces ", "withspaces"),
("special!@#chars", "specialchars"),
("", ""),

View File

@@ -162,4 +162,128 @@ async def test_search_dashboards_correct_url():
url = args[0] if args else kwargs.get("url", "")
assert "api/dashboards" in url
# #endregion TestAgentChat.Tools.ToolContracts
# #region TestAgentChat.Tools.HealthSummary [C:2] [TYPE Function] [SEMANTICS test,tools,health]
# @BRIEF get_health_summary calls the correct FastAPI endpoint.
@pytest.mark.asyncio
async def test_get_health_summary_calls_correct_url():
"""get_health_summary should call GET /api/dashboards/health."""
from src.agent.context import set_service_jwt, set_user_jwt
from src.agent.tools import get_health_summary
set_user_jwt("jwt")
set_service_jwt("svc-jwt")
with patch("httpx.AsyncClient") as mock_client:
mock_instance = AsyncMock()
mock_client.return_value.__aenter__.return_value = mock_instance
mock_instance.get.return_value.text = '{"status": "ok"}'
await get_health_summary.ainvoke({})
call_args = mock_instance.get.call_args
assert call_args is not None
args, kwargs = call_args
url = args[0] if args else kwargs.get("url", "")
assert "api/dashboards/health" in url
# #endregion TestAgentChat.Tools.HealthSummary
# #region TestAgentChat.Tools.ListEnvironments [C:2] [TYPE Function] [SEMANTICS test,tools,environments]
# @BRIEF list_environments calls the correct FastAPI endpoint.
@pytest.mark.asyncio
async def test_list_environments_calls_correct_url():
"""list_environments should call GET /api/settings/environments."""
from src.agent.context import set_service_jwt, set_user_jwt
from src.agent.tools import list_environments
set_user_jwt("jwt")
set_service_jwt("svc-jwt")
with patch("httpx.AsyncClient") as mock_client:
mock_instance = AsyncMock()
mock_client.return_value.__aenter__.return_value = mock_instance
mock_instance.get.return_value.text = '["prod", "dev"]'
await list_environments.ainvoke({})
call_args = mock_instance.get.call_args
assert call_args is not None
args, kwargs = call_args
url = args[0] if args else kwargs.get("url", "")
assert "api/settings/environments" in url
# #endregion TestAgentChat.Tools.ListEnvironments
# #region TestAgentChat.Tools.TaskStatus [C:2] [TYPE Function] [SEMANTICS test,tools,task]
# @BRIEF get_task_status calls the correct FastAPI endpoint with task_id.
@pytest.mark.asyncio
async def test_get_task_status_calls_correct_url():
"""get_task_status should call GET /api/tasks/{task_id}."""
from src.agent.context import set_service_jwt, set_user_jwt
from src.agent.tools import get_task_status
set_user_jwt("jwt")
set_service_jwt("svc-jwt")
with patch("httpx.AsyncClient") as mock_client:
mock_instance = AsyncMock()
mock_client.return_value.__aenter__.return_value = mock_instance
mock_instance.get.return_value.text = '{"status": "running"}'
await get_task_status.ainvoke({"task_id": "task-123"})
call_args = mock_instance.get.call_args
assert call_args is not None
args, kwargs = call_args
url = args[0] if args else kwargs.get("url", "")
assert "api/tasks/task-123" in url
# #endregion TestAgentChat.Tools.TaskStatus
# #region TestAgentChat.Tools.DualAuthHeaders [C:2] [TYPE Function] [SEMANTICS test,tools,auth,headers]
# @BRIEF _dual_auth_headers builds proper headers from ContextVars.
def test_dual_auth_headers_with_both_jwts():
"""_dual_auth_headers returns Authorization + X-User-JWT when both set."""
from src.agent.context import set_service_jwt, set_user_jwt
from src.agent.tools import _dual_auth_headers
set_service_jwt("svc-token")
set_user_jwt("user-token")
headers = _dual_auth_headers()
assert headers.get("Authorization") == "Bearer svc-token"
assert headers.get("X-User-JWT") == "user-token"
def test_dual_auth_headers_no_user_jwt():
"""_dual_auth_headers returns only Authorization when no user JWT."""
from src.agent.context import set_service_jwt, set_user_jwt
from src.agent.tools import _dual_auth_headers
set_service_jwt("svc-token")
set_user_jwt("")
headers = _dual_auth_headers()
assert headers.get("Authorization") == "Bearer svc-token"
assert "X-User-JWT" not in headers or headers.get("X-User-JWT") == ""
def test_dual_auth_headers_no_jwts(monkeypatch):
"""_dual_auth_headers returns empty dict when no JWTs."""
monkeypatch.delenv("SERVICE_JWT", raising=False)
from src.agent.context import set_service_jwt, set_user_jwt
from src.agent.tools import _dual_auth_headers
set_service_jwt("")
set_user_jwt("")
headers = _dual_auth_headers()
assert headers == {}
# #endregion TestAgentChat.Tools.DualAuthHeaders
# #endregion TestAgentChat.Tools