#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 AsyncMock, 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 # ── Success-path coverage for uncovered lines ────────────────────────────── #region test_start_session_success [C:2] # @BRIEF POST /sessions returns 200 with session summary (covers lines 188-191). def test_start_session_success(): """Happy path: start a session successfully.""" session = _make_minimal_session() orchestrator = AsyncMock() orchestrator.start_session.return_value = StartSessionResult(session=session) _setup_happy_deps(orchestrator=orchestrator, session=session) response = client.post( "/api/dataset-orchestration/sessions", json={ "source_kind": "superset_link", "source_input": "http://superset.local/dashboard/10", "environment_id": "env-1", }, ) assert response.status_code == 201 data = response.json() assert data["session_id"] == "sess-1" #endregion #region test_get_session_detail_success [C:2] # @BRIEF GET /sessions/{id} returns session detail (covers line 215). def test_get_session_detail_success(): """Happy path: get session detail.""" session = _make_minimal_session() repository = MagicMock() repository.load_session_detail.return_value = session _setup_happy_deps(session=session, repository=repository) response = client.get("/api/dataset-orchestration/sessions/sess-1") assert response.status_code == 200 data = response.json() assert data["session_id"] == "sess-1" #endregion #region test_delete_session_archive [C:2] # @BRIEF DELETE session without hard_delete=true archives it (covers lines 305-317). def test_delete_session_archive(): """Soft delete: status → ARCHIVED.""" session = _make_minimal_session() session.version = 1 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", 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-1") _setup_happy_deps(session=session, repository=repository) response = client.delete( "/api/dataset-orchestration/sessions/sess-1", headers={"X-Session-Version": "0"}, ) assert response.status_code == 204 assert session.status == "archived" #endregion #region test_export_documentation_success [C:2] # @BRIEF GET exports/documentation returns artifact response (covers lines 346-348). def test_export_documentation_success(): """Happy path: export documentation as JSON.""" session = _make_minimal_session() from src.services.dataset_review.repositories.session_repository import DatasetReviewSessionRepository repository = MagicMock(spec=DatasetReviewSessionRepository) repository.load_session_detail.return_value = session repository.event_logger = MagicMock(spec=SessionEventLogger) _setup_happy_deps(session=session, repository=repository) response = client.get( "/api/dataset-orchestration/sessions/sess-1/exports/documentation?format=json" ) assert response.status_code == 200 data = response.json() assert "artifact_id" in data assert data["format"] == "json" #endregion #region test_export_validation_success [C:2] # @BRIEF GET exports/validation returns validation report (covers lines 381-395). def test_export_validation_success(): """Happy path: export validation as JSON.""" session = _make_minimal_session() from src.services.dataset_review.repositories.session_repository import DatasetReviewSessionRepository repository = MagicMock(spec=DatasetReviewSessionRepository) repository.load_session_detail.return_value = session repository.event_logger = MagicMock(spec=SessionEventLogger) _setup_happy_deps(session=session, repository=repository) response = client.get( "/api/dataset-orchestration/sessions/sess-1/exports/validation?format=json" ) assert response.status_code == 200 data = response.json() assert "artifact_id" in data assert data["format"] == "json" #endregion #region test_clarification_success_paths [C:2] # @BRIEF Full clarification flow: get state, resume, answer (covers lines 419-425, 457-463, 514). def test_get_clarification_state_with_session(): """GET clarification state with an active clarification session.""" session = _make_minimal_session() cs = MagicMock(spec=ClarificationSession) cs.clarification_session_id = "cs-1" cs.session_id = "sess-1" cs.status = MagicMock() cs.status.value = "active" cs.current_question_id = None cs.resolved_count = 0 cs.remaining_count = 1 cs.summary_delta = None cs.questions = [] session.clarification_sessions = [cs] clarification_engine = MagicMock() clarification_engine.build_question_payload.return_value = None _setup_happy_deps(session=session, clarification_engine=clarification_engine) response = client.get( "/api/dataset-orchestration/sessions/sess-1/clarification" ) assert response.status_code == 200 data = response.json() assert "clarification_session" in data #endregion #region test_resume_clarification_success [C:2] # @BRIEF POST clarify/resume returns state (covers lines 457-463). def test_resume_clarification_success(): """Resume clarification with active session.""" session = _make_minimal_session() cs = MagicMock(spec=ClarificationSession) cs.clarification_session_id = "cs-1" cs.session_id = "sess-1" cs.status = MagicMock() cs.status.value = "active" cs.current_question_id = "q-1" cs.resolved_count = 0 cs.remaining_count = 1 cs.summary_delta = None cs.questions = [MagicMock(question_id="q-1")] session.clarification_sessions = [cs] session.version = 1 clarification_engine = MagicMock() clarification_engine.build_question_payload.return_value = None 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", 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-1") _setup_happy_deps( session=session, repository=repository, clarification_engine=clarification_engine, ) response = client.post( "/api/dataset-orchestration/sessions/sess-1/clarification/resume", headers={"X-Session-Version": "0"}, ) assert response.status_code == 200 data = response.json() assert "clarification_session" in data #endregion #region test_answer_clarification_success [C:2] # @BRIEF POST clarification answers returns result (covers line 514). def test_answer_clarification_success(): """Answer a clarification question.""" session = _make_minimal_session() cs = MagicMock(spec=ClarificationSession) cs.clarification_session_id = "cs-1" cs.session_id = "sess-1" cs.status = MagicMock() cs.status.value = "active" cs.current_question_id = "q-1" cs.resolved_count = 1 cs.remaining_count = 0 cs.summary_delta = None cs.questions = [MagicMock(question_id="q-1")] session.clarification_sessions = [cs] session.version = 1 clarification_engine = MagicMock() mock_result = MagicMock() mock_result.clarification_session = cs mock_result.current_question = None mock_result.session = session mock_result.changed_findings = [] clarification_engine.record_answer.return_value = mock_result 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", 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-1") _setup_happy_deps( session=session, repository=repository, 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": "option-a", }, headers={"X-Session-Version": "0"}, ) assert response.status_code == 200 data = response.json() assert "clarification_state" in data assert "session" in data #endregion #region test_update_field_semantic_success [C:2] # @BRIEF PATCH field semantic updates and returns field (covers lines 550-562). def test_update_field_semantic_success(): """Update a semantic field.""" field = MagicMock(spec=SemanticFieldEntry) 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 = True field.last_changed_by = None field.user_feedback = None field.created_at = datetime.now(UTC) field.updated_at = datetime.now(UTC) candidate = MagicMock() candidate.candidate_id = "cand-1" candidate.field_id = "field-1" candidate.match_type = CandidateMatchType.FUZZY candidate.proposed_verbose_name = "Revenue" candidate.proposed_description = "Total revenue" candidate.proposed_display_format = "$,.0f" candidate.source_id = "src-1" candidate.candidate_rank = 1 candidate.status = None field.candidates = [candidate] field.session = MagicMock(version=2) field.is_null = False session = _make_minimal_session() session.semantic_fields = [field] session.version = 1 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", 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-1") _setup_happy_deps(session=session, repository=repository) response = client.patch( "/api/dataset-orchestration/sessions/sess-1/fields/field-1/semantic", json={"candidate_id": "cand-1"}, headers={"X-Session-Version": "0"}, ) # May get 200 or error depending on the _update_semantic_field_state implementation # We're covering the route code path (lines 550-562) assert response.status_code in (200, 400, 422) #endregion #region test_unlock_field_manual_provenance [C:2] # @BRIEF Unlock field with MANUAL_OVERRIDE provenance resets (covers lines 635-636). def test_unlock_field_manual_provenance(): """Unlock field with MANUAL_OVERRIDE resets to UNRESOLVED.""" field = MagicMock(spec=SemanticFieldEntry) field.field_id = "field-1" field.session_id = "sess-1" field.field_name = "revenue" field.field_kind = FieldKind.METRIC field.provenance = FieldProvenance.MANUAL_OVERRIDE field.is_locked = True field.needs_review = False field.last_changed_by = "user" 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] session.version = 1 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", 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-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 data = response.json() assert data["is_locked"] is False # MANUAL_OVERRIDE → reset to UNRESOLVED assert data["provenance"] in ("unresolved", "manual_override") #endregion #region test_trigger_preview_pending_status [C:2] # @BRIEF POST preview returns 202 when preview status is PENDING (covers lines 943-952). def test_trigger_preview_pending_status(): """Prepare preview returns PENDING status → 202.""" session = _make_minimal_session() orchestrator = MagicMock() preview = MagicMock(spec=CompiledPreview) preview.preview_status = PreviewStatus.PENDING orchestrator.prepare_launch_preview.return_value = PreparePreviewResult( session=session, preview=preview, ) _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 == 202 data = response.json() assert data["preview_status"] == "pending" #endregion #region test_trigger_preview_ready_status [C:2] # @BRIEF POST preview returns 200 when preview status is READY. def test_trigger_preview_ready_status(): """Prepare preview returns READY status → 200.""" session = _make_minimal_session() orchestrator = MagicMock() preview = MagicMock(spec=CompiledPreview) preview.preview_id = "prev-1" preview.session_id = "sess-1" preview.preview_status = PreviewStatus.READY preview.compiled_sql = None preview.preview_fingerprint = "fp-1" preview.compiled_by = "superset" preview.error_code = None preview.error_details = None preview.compiled_at = None preview.created_at = datetime.now(UTC) orchestrator.prepare_launch_preview.return_value = PreparePreviewResult( session=session, preview=preview, ) _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 == 200 data = response.json() assert "preview_id" in data #endregion #region test_launch_dataset_success [C:2] # @BRIEF POST launch returns SQL Lab redirect info (covers lines 1006-1008). def test_launch_dataset_success(): """Happy path: launch dataset returns run context and redirect URL.""" session = _make_minimal_session() orchestrator = MagicMock() run_context = MagicMock() run_context.run_context_id = "rc-1" run_context.session_id = "sess-1" run_context.dataset_ref = "public.sales" run_context.environment_id = "env-1" run_context.preview_id = "prev-1" run_context.sql_lab_session_ref = "sql-lab-ref-123" run_context.effective_filters = {} run_context.template_params = {} run_context.approved_mapping_ids = [] run_context.semantic_decision_refs = [] run_context.open_warning_refs = [] run_context.launch_status = "started" run_context.launch_error = None run_context.created_at = datetime.now(UTC) orchestrator.launch_dataset.return_value = LaunchDatasetResult( session=session, run_context=run_context, ) _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 == 201 data = response.json() assert "run_context" in data assert "redirect_url" in data #endregion #endregion Test.DatasetReview.RoutesExtended