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:
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
124
backend/tests/services/clean_release/test_candidate_service.py
Normal file
124
backend/tests/services/clean_release/test_candidate_service.py
Normal 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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
672
backend/tests/services/dataset_review/test_orchestrator.py
Normal file
672
backend/tests/services/dataset_review/test_orchestrator.py
Normal 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
|
||||
@@ -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
|
||||
@@ -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"),
|
||||
("", ""),
|
||||
|
||||
Reference in New Issue
Block a user