# #region Test.DatasetReview.SessionLifecycle [C:3] [TYPE Module] [SEMANTICS test,dataset,review,sessions,lifecycle] # @BRIEF Session lifecycle tests for dataset review routes — covers list, update, delete, export, batch, mapping & feedback. # @RELATION BINDS_TO -> [DatasetReviewRoutes] # @TEST_EDGE: list_sessions_pagination -> paginated listing returns correct items/total/has_next # @TEST_EDGE: update_session_pause -> PATCH session status to PAUSED sets recommended_action=RESUME_SESSION # @TEST_EDGE: update_session_archive -> PATCH session to ARCHIVED clears active_task_id # @TEST_EDGE: delete_session_hard -> DELETE hard_delete removes from DB # @TEST_EDGE: delete_session_archive -> DELETE without hard_delete archives # @TEST_EDGE: export_validation_markdown -> export validation in markdown format # @TEST_EDGE: batch_approve_semantic -> batch approve semantic fields # @TEST_EDGE: update_execution_mapping_full -> full execution mapping update # @TEST_EDGE: approve_execution_mapping_with_note -> approve mapping with note # @TEST_EDGE: batch_approve_mappings -> batch approve mappings # @TEST_EDGE: record_field_feedback -> persist field feedback # @TEST_EDGE: record_clarification_feedback -> persist clarification feedback 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, MappingMethod, PreviewStatus, ReadinessState, RecommendedAction, SessionStatus, FieldProvenance, ) 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: cleanup ─────────────────────────────────── @pytest.fixture(autouse=True) def _clean_overrides(): app.dependency_overrides.clear() yield app.dependency_overrides.clear() # ── Builders ────────────────────────────────────────────────── def _make_user(): 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) return SimpleNamespace(id="user-1", username="tester", roles=[role]) def _make_config_manager(ff_auto=True, ff_clarification=True, ff_execution=True): 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 = True 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 _make_session(**kw): now = datetime.now(UTC) s = MagicMock() s.session_id = kw.get("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 = kw.get("readiness_state", ReadinessState.REVIEW_READY) s.recommended_action = kw.get("recommended_action", RecommendedAction.REVIEW_DOCUMENTATION) s.status = kw.get("status", SessionStatus.ACTIVE) s.current_phase = "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 = kw.get("semantic_fields", []) s.imported_filters = [] s.template_variables = [] s.execution_mappings = kw.get("execution_mappings", []) s.clarification_sessions = kw.get("clarification_sessions", []) s.previews = [] s.run_contexts = [] return s def _make_field(field_id="field-1", **kw): f = MagicMock() f.field_id = field_id f.is_locked = kw.get("is_locked", False) f.provenance = kw.get("provenance", FieldProvenance.AI_GENERATED) f.last_changed_by = "system" f.needs_review = kw.get("needs_review", True) f.user_feedback = None return f def _make_mapping(mapping_id="map-1", **kw): m = MagicMock() m.mapping_id = mapping_id m.effective_value = kw.get("effective_value", "old_value") m.mapping_method = MappingMethod.SEMANTIC_MATCH m.transformation_note = None m.approval_state = kw.get("approval_state", ApprovalState.PENDING) m.approved_by_user_id = None m.approved_at = None m.preferred_name = kw.get("preferred_name", "field") m.source_field = "src_field" m.target_field = "tgt_field" m.warning_level = None return m def _make_clarification_session(**kw): cs = MagicMock() cs.questions = kw.get("questions", []) return cs def _make_question(question_id="q-1", **kw): q = MagicMock() q.question_id = question_id q.answer = kw.get("answer", None) return q def _make_answer(**kw): a = MagicMock() a.user_feedback = kw.get("user_feedback", None) return a def _setup_deps(repository=None, orchestrator=None, clarification_engine=None, session=None, config_manager=None, user=None): user = user or _make_user() cm = config_manager or _make_config_manager() effective_session = session or _make_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.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 app.dependency_overrides[_get_repository] = lambda: repository app.dependency_overrides[_get_orchestrator] = lambda: orchestrator if clarification_engine is not None: app.dependency_overrides[_get_clarification_engine] = lambda: clarification_engine return {"repository": repository, "orchestrator": orchestrator} # ── list_sessions ───────────────────────────────────────────── class TestListSessions: """GET /api/dataset-orchestration/sessions""" # #region test_list_sessions_paginated [C:2] [TYPE Function] def test_list_sessions_paginated(self): s1 = _make_session(session_id="sess-1") s2 = _make_session(session_id="sess-2") repo = MagicMock() repo.list_sessions_for_user.return_value = [s1, s2] _setup_deps(repository=repo) resp = client.get("/api/dataset-orchestration/sessions?page=1&page_size=1") assert resp.status_code == 200 data = resp.json() assert data["total"] == 2 assert len(data["items"]) == 1 assert data["page"] == 1 assert data["page_size"] == 1 assert data["has_next"] is True # #endregion test_list_sessions_paginated # #region test_list_sessions_last_page [C:2] [TYPE Function] def test_list_sessions_last_page(self): s1 = _make_session(session_id="sess-1") repo = MagicMock() repo.list_sessions_for_user.return_value = [s1] _setup_deps(repository=repo) resp = client.get("/api/dataset-orchestration/sessions?page=1&page_size=20") assert resp.status_code == 200 data = resp.json() assert data["total"] == 1 assert data["has_next"] is False # #endregion test_list_sessions_last_page # ── update_session ──────────────────────────────────────────── class TestUpdateSession: """PATCH /api/dataset-orchestration/sessions/{session_id}""" # #region test_update_session_pause [C:2] [TYPE Function] def test_update_session_pause(self): session = _make_session(version=1) repo = MagicMock() repo.list_sessions_for_user.return_value = [session] repo.load_session_detail.return_value = session repo.bump_session_version = MagicMock() repo.require_session_version = MagicMock(return_value=session) repo.commit_session_mutation = MagicMock() repo.db = MagicMock() repo.event_logger = MagicMock(spec=SessionEventLogger) repo.event_logger.log_for_session.return_value = SimpleNamespace( session_event_id="evt-0" ) _setup_deps(repository=repo, session=session) resp = client.patch( "/api/dataset-orchestration/sessions/sess-1", json={"status": "paused"}, headers={"X-Session-Version": "1"}, ) assert resp.status_code == 200 data = resp.json() assert data is not None # #endregion test_update_session_pause # #region test_update_session_archive [C:2] [TYPE Function] def test_update_session_archive(self): session = _make_session(version=1) repo = MagicMock() repo.list_sessions_for_user.return_value = [session] repo.load_session_detail.return_value = session repo.bump_session_version = MagicMock() repo.require_session_version = MagicMock(return_value=session) repo.commit_session_mutation = MagicMock() repo.db = MagicMock() repo.event_logger = MagicMock(spec=SessionEventLogger) repo.event_logger.log_for_session.return_value = SimpleNamespace( session_event_id="evt-0" ) _setup_deps(repository=repo, session=session) resp = client.patch( "/api/dataset-orchestration/sessions/sess-1", json={"status": "archived"}, headers={"X-Session-Version": "1"}, ) assert resp.status_code == 200 # #endregion test_update_session_archive # ── delete_session ──────────────────────────────────────────── class TestDeleteSession: """DELETE /api/dataset-orchestration/sessions/{session_id}""" # #region test_delete_session_hard [C:2] [TYPE Function] def test_delete_session_hard(self): session = _make_session(version=1) repo = MagicMock() repo.list_sessions_for_user.return_value = [session] repo.load_session_detail.return_value = session repo.require_session_version = MagicMock(return_value=session) repo.db = MagicMock() repo.event_logger = MagicMock(spec=SessionEventLogger) repo.event_logger.log_for_session.return_value = SimpleNamespace( session_event_id="evt-0" ) _setup_deps(repository=repo, session=session) resp = client.delete( "/api/dataset-orchestration/sessions/sess-1?hard_delete=true", headers={"X-Session-Version": "1"}, ) assert resp.status_code == 204 # #endregion test_delete_session_hard # ── export_validation ───────────────────────────────────────── class TestExportValidation: """GET /api/dataset-orchestration/sessions/{session_id}/exports/validation""" # #region test_export_validation_json [C:2] [TYPE Function] def test_export_validation_json(self): session = _make_session() _setup_deps(session=session) resp = client.get( "/api/dataset-orchestration/sessions/sess-1/exports/validation?format=json" ) assert resp.status_code == 200 data = resp.json() assert data["artifact_type"] == "validation_report" # #endregion test_export_validation_json # #region test_export_validation_markdown [C:2] [TYPE Function] def test_export_validation_markdown(self): session = _make_session() _setup_deps(session=session) resp = client.get( "/api/dataset-orchestration/sessions/sess-1/exports/validation?format=markdown" ) assert resp.status_code == 200 # #endregion test_export_validation_markdown # ── batch_approve_semantic_fields ───────────────────────────── class TestBatchApproveSemantic: """POST /api/dataset-orchestration/sessions/{session_id}/fields/semantic/approve-batch""" # #region test_batch_approve_semantic [C:2] [TYPE Function] def test_batch_approve_semantic(self): field1 = _make_field(field_id="field-1") field2 = _make_field(field_id="field-2") session = _make_session(semantic_fields=[field1, field2], version=1) repo = MagicMock() repo.list_sessions_for_user.return_value = [session] repo.load_session_detail.return_value = session repo.require_session_version = MagicMock(return_value=session) repo.commit_session_mutation = MagicMock() repo.db = MagicMock() repo.event_logger = MagicMock(spec=SessionEventLogger) repo.event_logger.log_for_session.return_value = SimpleNamespace( session_event_id="evt-0" ) _setup_deps(repository=repo, session=session) resp = client.post( "/api/dataset-orchestration/sessions/sess-1/fields/semantic/approve-batch", json={ "items": [ {"field_id": "field-1", "candidate_id": "cand-1", "lock_field": True}, {"field_id": "field-2", "candidate_id": "cand-2", "lock_field": False}, ] }, headers={"X-Session-Version": "1"}, ) assert resp.status_code == 200 data = resp.json() assert isinstance(data, list) # #endregion test_batch_approve_semantic # ── update_execution_mapping ────────────────────────────────── class TestUpdateExecutionMapping: """PATCH /api/dataset-orchestration/sessions/{session_id}/mappings/{mapping_id}""" # #region test_update_execution_mapping [C:2] [TYPE Function] def test_update_execution_mapping(self): mapping = _make_mapping(mapping_id="map-1") session = _make_session(execution_mappings=[mapping], version=1, readiness_state=ReadinessState.MAPPING_REVIEW_NEEDED) repo = MagicMock() repo.list_sessions_for_user.return_value = [session] repo.load_session_detail.return_value = session repo.require_session_version = MagicMock(return_value=session) repo.commit_session_mutation = MagicMock() repo.db = MagicMock() repo.event_logger = MagicMock(spec=SessionEventLogger) repo.event_logger.log_for_session.return_value = SimpleNamespace( session_event_id="evt-0" ) _setup_deps(repository=repo, session=session) resp = client.patch( "/api/dataset-orchestration/sessions/sess-1/mappings/map-1", json={ "effective_value": "new_value", "mapping_method": "manual_override", "transformation_note": "Updated by user", }, headers={"X-Session-Version": "1"}, ) assert resp.status_code == 200 data = resp.json() assert data["mapping_id"] == "map-1" # #endregion test_update_execution_mapping # ── approve_execution_mapping ───────────────────────────────── class TestApproveExecutionMapping: """POST /api/dataset-orchestration/sessions/{session_id}/mappings/{mapping_id}/approve""" # #region test_approve_mapping_with_note [C:2] [TYPE Function] def test_approve_mapping_with_note(self): mapping = _make_mapping(mapping_id="map-1") session = _make_session(execution_mappings=[mapping], version=1, readiness_state=ReadinessState.MAPPING_REVIEW_NEEDED) repo = MagicMock() repo.list_sessions_for_user.return_value = [session] repo.load_session_detail.return_value = session repo.require_session_version = MagicMock(return_value=session) repo.commit_session_mutation = MagicMock() repo.db = MagicMock() repo.event_logger = MagicMock(spec=SessionEventLogger) repo.event_logger.log_for_session.return_value = SimpleNamespace( session_event_id="evt-0" ) _setup_deps(repository=repo, session=session) resp = client.post( "/api/dataset-orchestration/sessions/sess-1/mappings/map-1/approve", json={"approval_note": "Looks good"}, headers={"X-Session-Version": "1"}, ) assert resp.status_code == 200 # #endregion test_approve_mapping_with_note # ── approve_batch_execution_mappings ────────────────────────── class TestBatchApproveMappings: """POST /api/dataset-orchestration/sessions/{session_id}/mappings/approve-batch""" # #region test_batch_approve_mappings [C:2] [TYPE Function] def test_batch_approve_mappings(self): mapping1 = _make_mapping(mapping_id="map-1") mapping2 = _make_mapping(mapping_id="map-2") session = _make_session(execution_mappings=[mapping1, mapping2], version=1, readiness_state=ReadinessState.MAPPING_REVIEW_NEEDED) repo = MagicMock() repo.list_sessions_for_user.return_value = [session] repo.load_session_detail.return_value = session repo.require_session_version = MagicMock(return_value=session) repo.commit_session_mutation = MagicMock() repo.db = MagicMock() repo.event_logger = MagicMock(spec=SessionEventLogger) repo.event_logger.log_for_session.return_value = SimpleNamespace( session_event_id="evt-0" ) _setup_deps(repository=repo, session=session) resp = client.post( "/api/dataset-orchestration/sessions/sess-1/mappings/approve-batch", json={"mapping_ids": ["map-1", "map-2"], "approval_note": "Batch approved"}, headers={"X-Session-Version": "1"}, ) assert resp.status_code == 200 data = resp.json() assert isinstance(data, list) assert len(data) == 2 # #endregion test_batch_approve_mappings # ── record_field_feedback ───────────────────────────────────── class TestRecordFieldFeedback: """POST /api/dataset-orchestration/sessions/{session_id}/fields/{field_id}/feedback""" # #region test_field_feedback_thumbs_up [C:2] [TYPE Function] def test_field_feedback_thumbs_up(self): field = _make_field(field_id="field-1") session = _make_session(semantic_fields=[field], version=1) repo = MagicMock() repo.list_sessions_for_user.return_value = [session] repo.load_session_detail.return_value = session repo.require_session_version = MagicMock(return_value=session) repo.commit_session_mutation = MagicMock() repo.db = MagicMock() repo.event_logger = MagicMock(spec=SessionEventLogger) repo.event_logger.log_for_session.return_value = SimpleNamespace( session_event_id="evt-0" ) _setup_deps(repository=repo, session=session) resp = client.post( "/api/dataset-orchestration/sessions/sess-1/fields/field-1/feedback", json={"feedback": "thumbs_up"}, headers={"X-Session-Version": "1"}, ) assert resp.status_code == 200 data = resp.json() assert data["feedback"] == "thumbs_up" assert data["target_id"] == "field-1" # #endregion test_field_feedback_thumbs_up # ── record_clarification_feedback ───────────────────────────── class TestRecordClarificationFeedback: """POST /api/dataset-orchestration/sessions/{session_id}/clarification/questions/{question_id}/feedback""" # #region test_clarification_feedback_success [C:2] [TYPE Function] def test_clarification_feedback_success(self): answer = _make_answer() question = _make_question(question_id="q-1", answer=answer) cs = _make_clarification_session(questions=[question]) session = _make_session(clarification_sessions=[cs], version=1) repo = MagicMock() repo.list_sessions_for_user.return_value = [session] repo.load_session_detail.return_value = session repo.require_session_version = MagicMock(return_value=session) repo.commit_session_mutation = MagicMock() repo.db = MagicMock() repo.event_logger = MagicMock(spec=SessionEventLogger) repo.event_logger.log_for_session.return_value = SimpleNamespace( session_event_id="evt-0" ) _setup_deps(repository=repo, session=session) resp = client.post( "/api/dataset-orchestration/sessions/sess-1/clarification/questions/q-1/feedback", json={"feedback": "thumbs_down"}, headers={"X-Session-Version": "1"}, ) assert resp.status_code == 200 data = resp.json() assert data["feedback"] == "thumbs_down" # #endregion test_clarification_feedback_success # #endregion Test.DatasetReview.SessionLifecycle