872 lines
33 KiB
Python
872 lines
33 KiB
Python
#region Test.DatasetReview.RoutesExtended [C:2] [TYPE Module] [SEMANTICS test,dataset,review,routes,error,edge]
|
|
# @BRIEF Extended API route tests for dataset_review_pkg/_routes.py — error paths and edge cases.
|
|
# @RELATION BINDS_TO -> [DatasetReviewRoutes]
|
|
# @TEST_EDGE: all_flags_disabled -> All endpoints return 404 when dataset_review feature is off
|
|
# @TEST_EDGE: non_owner_mutation -> Mutation endpoints return 403 for non-owner access
|
|
# @TEST_EDGE: missing_session_version_header -> 422 when X-Session-Version missing
|
|
# @TEST_EDGE: preview_blocked -> 409 when preview is blocked by readiness gates
|
|
# @TEST_EDGE: launch_blocked -> 409 when launch is blocked by readiness gates
|
|
# @TEST_EDGE: unsupported_export_format -> 400 for non-json/non-markdown export format
|
|
# @TEST_EDGE: start_session_env_not_found -> 404 when environment not found
|
|
# @TEST_EDGE: clarification_feedback_no_answer -> 400 when question has no answer yet
|
|
# @TEST_EDGE: mapping_update_no_effective_value -> 400 when effective_value is null
|
|
# @TEST_EDGE: preview_session_not_found -> 404 when session not found by orchestrator
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import UTC, datetime
|
|
from pathlib import Path
|
|
import sys
|
|
from types import SimpleNamespace
|
|
|
|
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
|
|
|
|
import pytest
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
from fastapi.testclient import TestClient
|
|
from fastapi import status
|
|
|
|
from src.api.routes.dataset_review import (
|
|
_get_orchestrator,
|
|
_get_repository,
|
|
_get_clarification_engine,
|
|
)
|
|
from src.app import app
|
|
from src.core.config_models import AppConfig, Environment, GlobalSettings
|
|
from src.dependencies import get_config_manager, get_current_user, get_task_manager
|
|
from src.models.dataset_review import (
|
|
ApprovalState,
|
|
ArtifactFormat,
|
|
CandidateMatchType,
|
|
CandidateStatus,
|
|
ClarificationAnswer,
|
|
ClarificationOption,
|
|
ClarificationQuestion,
|
|
ClarificationSession,
|
|
ClarificationStatus,
|
|
CompiledPreview,
|
|
DatasetReviewSession,
|
|
ExecutionMapping,
|
|
FieldKind,
|
|
FieldProvenance,
|
|
FindingArea,
|
|
FindingSeverity,
|
|
ImportedFilter,
|
|
LaunchStatus,
|
|
MappingMethod,
|
|
MappingWarningLevel,
|
|
PreviewStatus,
|
|
QuestionState,
|
|
ReadinessState,
|
|
RecommendedAction,
|
|
ResolutionState,
|
|
SemanticCandidate,
|
|
SemanticFieldEntry,
|
|
SemanticSource,
|
|
SemanticSourceStatus,
|
|
SemanticSourceType,
|
|
SessionPhase,
|
|
SessionStatus,
|
|
TemplateVariable,
|
|
TrustLevel,
|
|
VariableKind,
|
|
)
|
|
from src.models.auth import User
|
|
from src.services.dataset_review.event_logger import SessionEventLogger
|
|
from src.services.dataset_review.orchestrator import (
|
|
DatasetReviewOrchestrator,
|
|
LaunchDatasetResult,
|
|
PreparePreviewResult,
|
|
StartSessionCommand,
|
|
StartSessionResult,
|
|
)
|
|
from src.services.dataset_review.repositories.session_repository import (
|
|
DatasetReviewSessionVersionConflictError,
|
|
)
|
|
|
|
client = TestClient(app)
|
|
|
|
|
|
# ── Shared fixture: setup mocks ────────────────────────────────────────
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _clean_overrides():
|
|
"""Reset dependency overrides before and after each test."""
|
|
app.dependency_overrides.clear()
|
|
yield
|
|
app.dependency_overrides.clear()
|
|
|
|
|
|
def _make_user(roles=None):
|
|
"""Build a minimal User-like object with permissions."""
|
|
if roles is None:
|
|
permissions = [
|
|
SimpleNamespace(resource="dataset:session", action="READ"),
|
|
SimpleNamespace(resource="dataset:session", action="MANAGE"),
|
|
SimpleNamespace(resource="dataset:execution:launch", action="EXECUTE"),
|
|
]
|
|
role = SimpleNamespace(name="DatasetReviewOperator", permissions=permissions)
|
|
roles = [role]
|
|
return SimpleNamespace(id="user-1", username="tester", roles=roles)
|
|
|
|
|
|
def _make_config_manager(feature_flag=True, ff_auto=True, ff_clarification=True, ff_execution=True):
|
|
"""Build a mock config_manager with controllable feature flags."""
|
|
env = Environment(
|
|
id="env-1",
|
|
name="DEV",
|
|
url="http://superset.local",
|
|
username="demo",
|
|
password="secret",
|
|
)
|
|
config = AppConfig(environments=[env], settings=GlobalSettings())
|
|
config.settings.features.dataset_review = feature_flag
|
|
config.settings.ff_dataset_auto_review = ff_auto
|
|
config.settings.ff_dataset_clarification = ff_clarification
|
|
config.settings.ff_dataset_execution = ff_execution
|
|
manager = MagicMock()
|
|
manager.get_environment.side_effect = (
|
|
lambda env_id: env if env_id == "env-1" else None
|
|
)
|
|
manager.get_config.return_value = config
|
|
return manager
|
|
|
|
|
|
def _setup_feature_disabled(flags=("auto",)):
|
|
"""Override deps so all features are disabled."""
|
|
cm = _make_config_manager(
|
|
feature_flag=False,
|
|
ff_auto="auto" in flags,
|
|
ff_clarification="clarification" in flags,
|
|
ff_execution="execution" in flags,
|
|
)
|
|
user = _make_user()
|
|
app.dependency_overrides[get_config_manager] = lambda: cm
|
|
app.dependency_overrides[get_current_user] = lambda: user
|
|
|
|
|
|
def _setup_happy_deps(session=None, repository=None, orchestrator=None, clarification_engine=None,
|
|
feature_flag=True, use_launch_permission=False):
|
|
"""Standard dependency setup with feature flags enabled."""
|
|
if use_launch_permission:
|
|
user_roles = [
|
|
SimpleNamespace(
|
|
name="DatasetReviewOperator",
|
|
permissions=[
|
|
SimpleNamespace(resource="dataset:session", action="READ"),
|
|
SimpleNamespace(resource="dataset:session", action="MANAGE"),
|
|
SimpleNamespace(resource="dataset:execution:launch", action="EXECUTE"),
|
|
],
|
|
)
|
|
]
|
|
else:
|
|
user_roles = None
|
|
user = _make_user(roles=user_roles)
|
|
cm = _make_config_manager(feature_flag=feature_flag)
|
|
effective_session = session or _make_minimal_session()
|
|
|
|
if repository is None:
|
|
repository = MagicMock()
|
|
repository.load_session_detail.return_value = effective_session
|
|
repository.list_sessions_for_user.return_value = [effective_session]
|
|
repository.require_session_version.side_effect = lambda s, v: s
|
|
repository.bump_session_version.side_effect = (
|
|
lambda current: setattr(
|
|
current, "version", int(getattr(current, "version", 0) or 0) + 1
|
|
)
|
|
or getattr(current, "version", 0)
|
|
)
|
|
repository.db = MagicMock()
|
|
repository.event_logger = MagicMock(spec=SessionEventLogger)
|
|
repository.event_logger.log_for_session.return_value = SimpleNamespace(
|
|
session_event_id="evt-0"
|
|
)
|
|
|
|
if orchestrator is None:
|
|
orchestrator = MagicMock()
|
|
orchestrator.start_session.return_value = StartSessionResult(
|
|
session=effective_session
|
|
)
|
|
|
|
app.dependency_overrides[get_config_manager] = lambda: cm
|
|
app.dependency_overrides[get_current_user] = lambda: user
|
|
if repository is not None:
|
|
app.dependency_overrides[_get_repository] = lambda: repository
|
|
if orchestrator is not None:
|
|
app.dependency_overrides[_get_orchestrator] = lambda: orchestrator
|
|
if clarification_engine is not None:
|
|
app.dependency_overrides[_get_clarification_engine] = lambda: clarification_engine
|
|
|
|
return {
|
|
"user": user,
|
|
"config_manager": cm,
|
|
"repository": repository,
|
|
"orchestrator": orchestrator,
|
|
}
|
|
|
|
|
|
def _make_minimal_session():
|
|
"""Build a minimal session mock for error path tests."""
|
|
now = datetime.now(UTC)
|
|
s = MagicMock(spec=DatasetReviewSession)
|
|
s.session_id = "sess-1"
|
|
s.user_id = "user-1"
|
|
s.environment_id = "env-1"
|
|
s.source_kind = "superset_link"
|
|
s.source_input = "http://superset.local/dashboard/10"
|
|
s.dataset_ref = "public.sales"
|
|
s.dataset_id = 42
|
|
s.dashboard_id = 10
|
|
s.readiness_state = ReadinessState.REVIEW_READY
|
|
s.recommended_action = RecommendedAction.REVIEW_DOCUMENTATION
|
|
s.status = SessionStatus.ACTIVE
|
|
s.current_phase = SessionPhase.REVIEW
|
|
s.version = 1
|
|
s.created_at = now
|
|
s.updated_at = now
|
|
s.last_activity_at = now
|
|
s.profile = None
|
|
s.findings = []
|
|
s.collaborators = []
|
|
s.semantic_sources = []
|
|
s.semantic_fields = []
|
|
s.imported_filters = []
|
|
s.template_variables = []
|
|
s.execution_mappings = []
|
|
s.clarification_sessions = []
|
|
s.previews = []
|
|
s.run_contexts = []
|
|
return s
|
|
|
|
|
|
# ── Feature-flag disabled → 404 all endpoints ──────────────────────────
|
|
|
|
|
|
FEATURE_DISABLED_ENDPOINTS = [
|
|
("GET", "/api/dataset-orchestration/sessions"),
|
|
("POST", "/api/dataset-orchestration/sessions"),
|
|
("GET", "/api/dataset-orchestration/sessions/sess-1"),
|
|
("PATCH", "/api/dataset-orchestration/sessions/sess-1"),
|
|
("DELETE", "/api/dataset-orchestration/sessions/sess-1"),
|
|
("GET", "/api/dataset-orchestration/sessions/sess-1/exports/documentation"),
|
|
("GET", "/api/dataset-orchestration/sessions/sess-1/exports/validation"),
|
|
("GET", "/api/dataset-orchestration/sessions/sess-1/clarification"),
|
|
("POST", "/api/dataset-orchestration/sessions/sess-1/clarification/resume"),
|
|
("POST", "/api/dataset-orchestration/sessions/sess-1/clarification/answers"),
|
|
("PATCH", "/api/dataset-orchestration/sessions/sess-1/fields/field-1/semantic"),
|
|
("POST", "/api/dataset-orchestration/sessions/sess-1/fields/field-1/lock"),
|
|
("POST", "/api/dataset-orchestration/sessions/sess-1/fields/field-1/unlock"),
|
|
("POST", "/api/dataset-orchestration/sessions/sess-1/fields/semantic/approve-batch"),
|
|
("GET", "/api/dataset-orchestration/sessions/sess-1/mappings"),
|
|
("PATCH", "/api/dataset-orchestration/sessions/sess-1/mappings/map-1"),
|
|
("POST", "/api/dataset-orchestration/sessions/sess-1/mappings/map-1/approve"),
|
|
("POST", "/api/dataset-orchestration/sessions/sess-1/mappings/approve-batch"),
|
|
("POST", "/api/dataset-orchestration/sessions/sess-1/preview"),
|
|
("POST", "/api/dataset-orchestration/sessions/sess-1/launch"),
|
|
("POST", "/api/dataset-orchestration/sessions/sess-1/fields/field-1/feedback"),
|
|
("POST", "/api/dataset-orchestration/sessions/sess-1/clarification/questions/q-1/feedback"),
|
|
]
|
|
|
|
|
|
#region test_all_endpoints_return_404_when_feature_disabled [C:2]
|
|
# @BRIEF Every dataset-orchestration endpoint returns 404 when dataset_review feature is off.
|
|
@pytest.mark.parametrize("method,path", FEATURE_DISABLED_ENDPOINTS)
|
|
def test_all_endpoints_return_404_when_feature_disabled(method, path):
|
|
_setup_feature_disabled()
|
|
_setup_happy_deps(feature_flag=False)
|
|
|
|
kwargs = {}
|
|
if method in ("POST", "PATCH"):
|
|
kwargs["json"] = {}
|
|
kwargs["headers"] = {"X-Session-Version": "0"}
|
|
|
|
response = client.request(method, path, **kwargs)
|
|
assert response.status_code == 404, f"{method} {path} should return 404"
|
|
assert "Dataset review feature is disabled" in response.json()["detail"]
|
|
#endregion
|
|
|
|
|
|
# ── Missing X-Session-Version header → 422 ──────────────────────────────
|
|
|
|
|
|
#region test_missing_session_version_header_422 [C:2]
|
|
# @BRIEF PATCH session returns 422 when X-Session-Version header missing.
|
|
def test_missing_session_version_header_422():
|
|
_setup_happy_deps()
|
|
response = client.patch(
|
|
"/api/dataset-orchestration/sessions/sess-1",
|
|
json={"status": "paused"},
|
|
# No X-Session-Version header
|
|
)
|
|
# FastAPI returns 422 for missing required header
|
|
assert response.status_code == 422
|
|
#endregion
|
|
|
|
|
|
#region test_missing_session_version_header_all_mutation_endpoints [C:2]
|
|
# @BRIEF All mutation endpoints return 422 when X-Session-Version missing.
|
|
@pytest.mark.parametrize("method,path", [
|
|
("PATCH", "/api/dataset-orchestration/sessions/sess-1"),
|
|
("DELETE", "/api/dataset-orchestration/sessions/sess-1"),
|
|
("POST", "/api/dataset-orchestration/sessions/sess-1/clarification/resume"),
|
|
("POST", "/api/dataset-orchestration/sessions/sess-1/clarification/answers"),
|
|
("PATCH", "/api/dataset-orchestration/sessions/sess-1/fields/field-1/semantic"),
|
|
("POST", "/api/dataset-orchestration/sessions/sess-1/fields/field-1/lock"),
|
|
("POST", "/api/dataset-orchestration/sessions/sess-1/fields/field-1/unlock"),
|
|
("POST", "/api/dataset-orchestration/sessions/sess-1/fields/semantic/approve-batch"),
|
|
("PATCH", "/api/dataset-orchestration/sessions/sess-1/mappings/map-1"),
|
|
("POST", "/api/dataset-orchestration/sessions/sess-1/mappings/map-1/approve"),
|
|
("POST", "/api/dataset-orchestration/sessions/sess-1/mappings/approve-batch"),
|
|
("POST", "/api/dataset-orchestration/sessions/sess-1/preview"),
|
|
("POST", "/api/dataset-orchestration/sessions/sess-1/launch"),
|
|
("POST", "/api/dataset-orchestration/sessions/sess-1/fields/field-1/feedback"),
|
|
("POST", "/api/dataset-orchestration/sessions/sess-1/clarification/questions/q-1/feedback"),
|
|
])
|
|
def test_missing_session_version_header_422_all(method, path):
|
|
_setup_happy_deps()
|
|
kwargs = {"json": {}} if method in ("POST", "PATCH") else {}
|
|
response = client.request(method, path, **kwargs)
|
|
assert response.status_code == 422, f"{method} {path} should return 422"
|
|
#endregion
|
|
|
|
|
|
# ── Non-owner mutation → 403 ────────────────────────────────────────────
|
|
|
|
|
|
def _make_non_owner_session():
|
|
s = _make_minimal_session()
|
|
s.user_id = "user-2" # Different from current user (user-1)
|
|
return s
|
|
|
|
|
|
#region test_patch_session_non_owner_403 [C:2]
|
|
# @BRIEF PATCH session returns 403 for non-owner.
|
|
def test_patch_session_non_owner_403():
|
|
session = _make_non_owner_session()
|
|
_setup_happy_deps(session=session)
|
|
response = client.patch(
|
|
"/api/dataset-orchestration/sessions/sess-1",
|
|
json={"status": "paused"},
|
|
headers={"X-Session-Version": "0"},
|
|
)
|
|
assert response.status_code == 403
|
|
assert "Only the owner can mutate" in response.json()["detail"]
|
|
#endregion
|
|
|
|
|
|
#region test_delete_session_non_owner_403 [C:2]
|
|
# @BRIEF DELETE session returns 403 for non-owner.
|
|
def test_delete_session_non_owner_403():
|
|
session = _make_non_owner_session()
|
|
_setup_happy_deps(session=session)
|
|
response = client.delete(
|
|
"/api/dataset-orchestration/sessions/sess-1",
|
|
headers={"X-Session-Version": "0"},
|
|
)
|
|
assert response.status_code == 403
|
|
#endregion
|
|
|
|
|
|
#region test_update_field_semantic_non_owner_403 [C:2]
|
|
# @BRIEF Field semantic update returns 403 for non-owner.
|
|
def test_update_field_semantic_non_owner_403():
|
|
session = _make_non_owner_session()
|
|
_setup_happy_deps(session=session)
|
|
response = client.patch(
|
|
"/api/dataset-orchestration/sessions/sess-1/fields/field-1/semantic",
|
|
json={"candidate_id": "cand-1"},
|
|
headers={"X-Session-Version": "0"},
|
|
)
|
|
assert response.status_code == 403
|
|
#endregion
|
|
|
|
|
|
# ── Start session error paths ──────────────────────────────────────────
|
|
|
|
|
|
#region test_start_session_environment_not_found_404 [C:2]
|
|
# @BRIEF POST session returns 404 when environment not found.
|
|
def test_start_session_environment_not_found_404():
|
|
orchestrator = MagicMock()
|
|
orchestrator.start_session.side_effect = ValueError("Environment not found")
|
|
_setup_happy_deps(orchestrator=orchestrator)
|
|
response = client.post(
|
|
"/api/dataset-orchestration/sessions",
|
|
json={
|
|
"source_kind": "superset_link",
|
|
"source_input": "http://superset.local/dashboard/10",
|
|
"environment_id": "env-999",
|
|
},
|
|
)
|
|
assert response.status_code == 404
|
|
assert "Environment not found" in response.json()["detail"]
|
|
#endregion
|
|
|
|
|
|
#region test_start_session_other_error_400 [C:2]
|
|
# @BRIEF POST session returns 400 for generic ValueError.
|
|
def test_start_session_other_error_400():
|
|
orchestrator = MagicMock()
|
|
orchestrator.start_session.side_effect = ValueError("Invalid input")
|
|
_setup_happy_deps(orchestrator=orchestrator)
|
|
response = client.post(
|
|
"/api/dataset-orchestration/sessions",
|
|
json={
|
|
"source_kind": "superset_link",
|
|
"source_input": "invalid",
|
|
"environment_id": "env-1",
|
|
},
|
|
)
|
|
assert response.status_code == 400
|
|
assert "Invalid input" in response.json()["detail"]
|
|
#endregion
|
|
|
|
|
|
# ── Export error paths ──────────────────────────────────────────────────
|
|
|
|
|
|
#region test_export_documentation_unsupported_format_400 [C:2]
|
|
# @BRIEF GET exports/documentation returns 400 for unsupported format.
|
|
def test_export_documentation_unsupported_format_400():
|
|
_setup_happy_deps()
|
|
response = client.get(
|
|
"/api/dataset-orchestration/sessions/sess-1/exports/documentation?format=csv"
|
|
)
|
|
assert response.status_code == 400
|
|
assert "Only json and markdown exports are supported" in response.json()["detail"]
|
|
#endregion
|
|
|
|
|
|
#region test_export_validation_unsupported_format_422 [C:2]
|
|
# @BRIEF GET exports/validation returns 422 for unsupported format (FastAPI enum validation).
|
|
def test_export_validation_unsupported_format_422():
|
|
_setup_happy_deps()
|
|
response = client.get(
|
|
"/api/dataset-orchestration/sessions/sess-1/exports/validation?format=xml"
|
|
)
|
|
assert response.status_code == 422 # FastAPI validates against ArtifactFormat enum
|
|
#endregion
|
|
|
|
|
|
# ── Preview error paths ─────────────────────────────────────────────────
|
|
|
|
|
|
#region test_preview_blocked_409 [C:2]
|
|
# @BRIEF POST preview returns 409 when preview is blocked by readiness gates.
|
|
def test_preview_blocked_409():
|
|
session = _make_minimal_session()
|
|
orchestrator = MagicMock()
|
|
orchestrator.prepare_launch_preview.side_effect = ValueError(
|
|
"Preview blocked: unresolved mappings"
|
|
)
|
|
_setup_happy_deps(session=session, orchestrator=orchestrator)
|
|
response = client.post(
|
|
"/api/dataset-orchestration/sessions/sess-1/preview",
|
|
headers={"X-Session-Version": "0"},
|
|
)
|
|
assert response.status_code == 409
|
|
assert "Preview blocked" in response.json()["detail"]
|
|
#endregion
|
|
|
|
|
|
#region test_preview_session_not_found_404 [C:2]
|
|
# @BRIEF POST preview returns 404 when session not found by orchestrator.
|
|
def test_preview_session_not_found_404():
|
|
orchestrator = MagicMock()
|
|
orchestrator.prepare_launch_preview.side_effect = ValueError("Session not found")
|
|
_setup_happy_deps(orchestrator=orchestrator)
|
|
response = client.post(
|
|
"/api/dataset-orchestration/sessions/sess-1/preview",
|
|
headers={"X-Session-Version": "0"},
|
|
)
|
|
assert response.status_code == 404
|
|
assert "Session not found" in response.json()["detail"]
|
|
#endregion
|
|
|
|
|
|
#region test_preview_env_not_found_404 [C:2]
|
|
# @BRIEF POST preview returns 404 when environment not found.
|
|
def test_preview_env_not_found_404():
|
|
orchestrator = MagicMock()
|
|
orchestrator.prepare_launch_preview.side_effect = ValueError("Environment not found")
|
|
_setup_happy_deps(orchestrator=orchestrator)
|
|
response = client.post(
|
|
"/api/dataset-orchestration/sessions/sess-1/preview",
|
|
headers={"X-Session-Version": "0"},
|
|
)
|
|
assert response.status_code == 404
|
|
assert "Environment not found" in response.json()["detail"]
|
|
#endregion
|
|
|
|
|
|
#region test_preview_other_value_error_400 [C:2]
|
|
# @BRIEF POST preview returns 400 for other ValueError.
|
|
def test_preview_other_value_error_400():
|
|
orchestrator = MagicMock()
|
|
orchestrator.prepare_launch_preview.side_effect = ValueError("Some other error")
|
|
_setup_happy_deps(orchestrator=orchestrator)
|
|
response = client.post(
|
|
"/api/dataset-orchestration/sessions/sess-1/preview",
|
|
headers={"X-Session-Version": "0"},
|
|
)
|
|
assert response.status_code == 400
|
|
assert "Some other error" in response.json()["detail"]
|
|
#endregion
|
|
|
|
|
|
#region test_preview_version_conflict_409 [C:2]
|
|
# @BRIEF POST preview returns 409 when session version conflicts.
|
|
def test_preview_version_conflict_409():
|
|
orchestrator = MagicMock()
|
|
orchestrator.prepare_launch_preview.side_effect = (
|
|
DatasetReviewSessionVersionConflictError("sess-1", 0, 3)
|
|
)
|
|
_setup_happy_deps(orchestrator=orchestrator)
|
|
response = client.post(
|
|
"/api/dataset-orchestration/sessions/sess-1/preview",
|
|
headers={"X-Session-Version": "0"},
|
|
)
|
|
assert response.status_code == 409
|
|
payload = response.json()["detail"]
|
|
assert payload["error_code"] == "session_version_conflict"
|
|
#endregion
|
|
|
|
|
|
# ── Launch error paths ──────────────────────────────────────────────────
|
|
|
|
|
|
#region test_launch_blocked_409 [C:2]
|
|
# @BRIEF POST launch returns 409 when launch is blocked.
|
|
def test_launch_blocked_409():
|
|
session = _make_minimal_session()
|
|
orchestrator = MagicMock()
|
|
orchestrator.launch_dataset.side_effect = ValueError(
|
|
"Launch blocked: preview required"
|
|
)
|
|
_setup_happy_deps(session=session, orchestrator=orchestrator,
|
|
use_launch_permission=True)
|
|
response = client.post(
|
|
"/api/dataset-orchestration/sessions/sess-1/launch",
|
|
headers={"X-Session-Version": "0"},
|
|
)
|
|
assert response.status_code == 409
|
|
assert "Launch blocked" in response.json()["detail"]
|
|
#endregion
|
|
|
|
|
|
#region test_launch_session_not_found_404 [C:2]
|
|
# @BRIEF POST launch returns 404 when session not found.
|
|
def test_launch_session_not_found_404():
|
|
orchestrator = MagicMock()
|
|
orchestrator.launch_dataset.side_effect = ValueError("Session not found")
|
|
_setup_happy_deps(orchestrator=orchestrator, use_launch_permission=True)
|
|
response = client.post(
|
|
"/api/dataset-orchestration/sessions/sess-1/launch",
|
|
headers={"X-Session-Version": "0"},
|
|
)
|
|
assert response.status_code == 404
|
|
#endregion
|
|
|
|
|
|
#region test_launch_env_not_found_404 [C:2]
|
|
# @BRIEF POST launch returns 404 when environment not found.
|
|
def test_launch_env_not_found_404():
|
|
orchestrator = MagicMock()
|
|
orchestrator.launch_dataset.side_effect = ValueError("Environment not found")
|
|
_setup_happy_deps(orchestrator=orchestrator, use_launch_permission=True)
|
|
response = client.post(
|
|
"/api/dataset-orchestration/sessions/sess-1/launch",
|
|
headers={"X-Session-Version": "0"},
|
|
)
|
|
assert response.status_code == 404
|
|
#endregion
|
|
|
|
|
|
#region test_launch_version_conflict_409 [C:2]
|
|
# @BRIEF POST launch returns 409 when session version conflicts.
|
|
def test_launch_version_conflict_409():
|
|
orchestrator = MagicMock()
|
|
orchestrator.launch_dataset.side_effect = (
|
|
DatasetReviewSessionVersionConflictError("sess-1", 0, 3)
|
|
)
|
|
_setup_happy_deps(orchestrator=orchestrator, use_launch_permission=True)
|
|
response = client.post(
|
|
"/api/dataset-orchestration/sessions/sess-1/launch",
|
|
headers={"X-Session-Version": "0"},
|
|
)
|
|
assert response.status_code == 409
|
|
payload = response.json()["detail"]
|
|
assert payload["error_code"] == "session_version_conflict"
|
|
#endregion
|
|
|
|
|
|
# ── Clarification error paths ──────────────────────────────────────────
|
|
|
|
|
|
#region test_clarification_feedback_no_answer_400 [C:2]
|
|
# @BRIEF POST clarification feedback returns 400 when question has no answer yet.
|
|
def test_clarification_feedback_no_answer_400():
|
|
"""Feedback on unanswered question → 400."""
|
|
session = _make_minimal_session()
|
|
q = MagicMock()
|
|
q.question_id = "q-1"
|
|
q.answer = None
|
|
cs = MagicMock()
|
|
cs.questions = [q]
|
|
session.clarification_sessions = [cs]
|
|
|
|
_setup_happy_deps(session=session)
|
|
response = client.post(
|
|
"/api/dataset-orchestration/sessions/sess-1/clarification/questions/q-1/feedback",
|
|
json={"feedback": "up"},
|
|
headers={"X-Session-Version": "0"},
|
|
)
|
|
assert response.status_code == 400
|
|
assert "Clarification answer not found" in response.json()["detail"]
|
|
#endregion
|
|
|
|
|
|
#region test_clarification_feedback_question_not_found_404 [C:2]
|
|
# @BRIEF POST clarification feedback returns 404 when question not found.
|
|
def test_clarification_feedback_question_not_found_404():
|
|
session = _make_minimal_session()
|
|
cs = MagicMock()
|
|
cs.questions = [MagicMock(question_id="q-other")]
|
|
session.clarification_sessions = [cs]
|
|
|
|
_setup_happy_deps(session=session)
|
|
response = client.post(
|
|
"/api/dataset-orchestration/sessions/sess-1/clarification/questions/q-999/feedback",
|
|
json={"feedback": "up"},
|
|
headers={"X-Session-Version": "0"},
|
|
)
|
|
assert response.status_code == 404
|
|
assert "Clarification question not found" in response.json()["detail"]
|
|
#endregion
|
|
|
|
|
|
#region test_clarification_answer_error_400 [C:2]
|
|
# @BRIEF POST clarification answer returns 400 when engine raises ValueError.
|
|
def test_clarification_answer_error_400():
|
|
session = _make_minimal_session()
|
|
cs = MagicMock()
|
|
cs.questions = []
|
|
session.clarification_sessions = [cs]
|
|
|
|
clarification_engine = MagicMock()
|
|
clarification_engine.record_answer.side_effect = ValueError("Invalid answer kind")
|
|
|
|
_setup_happy_deps(session=session, clarification_engine=clarification_engine)
|
|
response = client.post(
|
|
"/api/dataset-orchestration/sessions/sess-1/clarification/answers",
|
|
json={
|
|
"question_id": "q-1",
|
|
"answer_kind": "selected",
|
|
"answer_value": "test",
|
|
},
|
|
headers={"X-Session-Version": "0"},
|
|
)
|
|
assert response.status_code == 400
|
|
#endregion
|
|
|
|
|
|
# ── Execution mapping error paths ──────────────────────────────────────
|
|
|
|
|
|
#region test_update_execution_mapping_no_effective_value_400 [C:2]
|
|
# @BRIEF PATCH mapping returns 400 when effective_value is null.
|
|
def test_update_execution_mapping_no_effective_value_400():
|
|
session = _make_minimal_session()
|
|
mapping = MagicMock()
|
|
mapping.mapping_id = "map-1"
|
|
session.execution_mappings = [mapping]
|
|
|
|
_setup_happy_deps(session=session)
|
|
response = client.patch(
|
|
"/api/dataset-orchestration/sessions/sess-1/mappings/map-1",
|
|
json={"effective_value": None},
|
|
headers={"X-Session-Version": "0"},
|
|
)
|
|
assert response.status_code == 400
|
|
assert "effective_value is required" in response.json()["detail"]
|
|
#endregion
|
|
|
|
|
|
#region test_get_mapping_404 [C:2]
|
|
# @BRIEF GET mappings returns empty list when session has no mappings.
|
|
def test_get_mapping_404():
|
|
session = _make_minimal_session()
|
|
session.execution_mappings = []
|
|
_setup_happy_deps(session=session)
|
|
response = client.get(
|
|
"/api/dataset-orchestration/sessions/sess-1/mappings",
|
|
)
|
|
assert response.status_code == 200
|
|
assert response.json()["items"] == []
|
|
#endregion
|
|
|
|
|
|
# ── Session not found for detail endpoint ───────────────────────────────
|
|
|
|
|
|
#region test_get_session_detail_404 [C:2]
|
|
# @BRIEF GET session detail returns 404 when session not found.
|
|
def test_get_session_detail_404():
|
|
repository = MagicMock()
|
|
repository.load_session_detail.return_value = None
|
|
_setup_happy_deps(repository=repository)
|
|
response = client.get("/api/dataset-orchestration/sessions/sess-999")
|
|
assert response.status_code == 404
|
|
#endregion
|
|
|
|
|
|
# ── DELETE session hard_delete ──────────────────────────────────────────
|
|
|
|
|
|
#region test_delete_session_hard_delete [C:2]
|
|
# @BRIEF DELETE session with hard_delete=true deletes from DB.
|
|
def test_delete_session_hard_delete():
|
|
session = _make_minimal_session()
|
|
repository = MagicMock()
|
|
repository.load_session_detail.return_value = session
|
|
repository.require_session_version.side_effect = lambda s, v: s
|
|
repository.db = MagicMock()
|
|
|
|
_setup_happy_deps(session=session, repository=repository)
|
|
response = client.delete(
|
|
"/api/dataset-orchestration/sessions/sess-1?hard_delete=true",
|
|
headers={"X-Session-Version": "0"},
|
|
)
|
|
assert response.status_code == 204
|
|
repository.db.delete.assert_called_once_with(session)
|
|
repository.db.commit.assert_called_once()
|
|
#endregion
|
|
|
|
|
|
# ── Lock field ──────────────────────────────────────────────────────────
|
|
|
|
|
|
#region test_lock_field_endpoint [C:2]
|
|
# @BRIEF POST field lock locks the field and marks provenance.
|
|
def test_lock_field_endpoint():
|
|
field = MagicMock()
|
|
field.field_id = "field-1"
|
|
field.session_id = "sess-1"
|
|
field.field_name = "revenue"
|
|
field.field_kind = FieldKind.METRIC
|
|
field.verbose_name = None
|
|
field.description = None
|
|
field.display_format = None
|
|
field.provenance = FieldProvenance.UNRESOLVED
|
|
field.source_id = None
|
|
field.source_version = None
|
|
field.confidence_rank = None
|
|
field.is_locked = False
|
|
field.has_conflict = False
|
|
field.needs_review = False
|
|
field.last_changed_by = None
|
|
field.user_feedback = None
|
|
field.created_at = datetime.now(UTC)
|
|
field.updated_at = datetime.now(UTC)
|
|
field.candidates = []
|
|
field.session = MagicMock(version=2)
|
|
|
|
session = _make_minimal_session()
|
|
session.semantic_fields = [field]
|
|
|
|
repository = MagicMock()
|
|
repository.load_session_detail.return_value = session
|
|
repository.require_session_version.side_effect = lambda s, v: s
|
|
repository.bump_session_version.side_effect = (
|
|
lambda current: setattr(current, "version", getattr(current, "version", 0) + 1)
|
|
or getattr(current, "version", 0)
|
|
)
|
|
repository.db = MagicMock()
|
|
repository.event_logger = MagicMock(spec=SessionEventLogger)
|
|
repository.event_logger.log_for_session.return_value = SimpleNamespace(
|
|
session_event_id="evt-1"
|
|
)
|
|
|
|
_setup_happy_deps(session=session, repository=repository)
|
|
response = client.post(
|
|
"/api/dataset-orchestration/sessions/sess-1/fields/field-1/lock",
|
|
headers={"X-Session-Version": "0"},
|
|
)
|
|
assert response.status_code == 200
|
|
assert response.json()["is_locked"] is True
|
|
assert response.json()["field_id"] == "field-1"
|
|
#endregion
|
|
|
|
|
|
# ── Unlock field with non-manual provenance ─────────────────────────────
|
|
|
|
|
|
#region test_unlock_field_non_manual_provenance [C:2]
|
|
# @BRIEF Unlock field with non-manual provenance doesn't reset to UNRESOLVED.
|
|
def test_unlock_field_non_manual_provenance():
|
|
field = MagicMock()
|
|
field.field_id = "field-1"
|
|
field.session_id = "sess-1"
|
|
field.field_name = "revenue"
|
|
field.field_kind = FieldKind.METRIC
|
|
field.provenance = FieldProvenance.DICTIONARY_EXACT
|
|
field.is_locked = True
|
|
field.needs_review = False
|
|
field.last_changed_by = "system"
|
|
field.verbose_name = "Revenue"
|
|
field.description = "Total revenue"
|
|
field.display_format = "$,.0f"
|
|
field.source_id = None
|
|
field.source_version = None
|
|
field.confidence_rank = 1
|
|
field.has_conflict = False
|
|
field.user_feedback = None
|
|
field.created_at = datetime.now(UTC)
|
|
field.updated_at = datetime.now(UTC)
|
|
field.candidates = []
|
|
field.session = MagicMock(version=2)
|
|
|
|
session = _make_minimal_session()
|
|
session.semantic_fields = [field]
|
|
|
|
repository = MagicMock()
|
|
repository.load_session_detail.return_value = session
|
|
repository.require_session_version.side_effect = lambda s, v: s
|
|
repository.bump_session_version.side_effect = (
|
|
lambda current: setattr(current, "version", getattr(current, "version", 0) + 1)
|
|
or getattr(current, "version", 0)
|
|
)
|
|
repository.db = MagicMock()
|
|
repository.event_logger = MagicMock(spec=SessionEventLogger)
|
|
repository.event_logger.log_for_session.return_value = SimpleNamespace(
|
|
session_event_id="evt-1"
|
|
)
|
|
|
|
_setup_happy_deps(session=session, repository=repository)
|
|
response = client.post(
|
|
"/api/dataset-orchestration/sessions/sess-1/fields/field-1/unlock",
|
|
headers={"X-Session-Version": "0"},
|
|
)
|
|
assert response.status_code == 200
|
|
assert response.json()["is_locked"] is False
|
|
# Non-MANUAL_OVERRIDE provenance stays intact
|
|
assert response.json()["provenance"] == "dictionary_exact"
|
|
#endregion
|
|
|
|
|
|
# ── Endpoint not found ──────────────────────────────────────────────────
|
|
|
|
|
|
#region test_endpoint_not_found_404 [C:2]
|
|
# @BRIEF Returns 404 for unknown dataset-orchestration routes.
|
|
def test_endpoint_not_found_404():
|
|
_setup_happy_deps()
|
|
response = client.get("/api/dataset-orchestration/nonexistent")
|
|
assert response.status_code == 404
|
|
#endregion
|
|
|
|
|
|
#endregion Test.DatasetReview.RoutesExtended
|