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

@@ -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"),
("", ""),