Files
ss-tools/backend/tests/api/test_dataset_review_routes_sessions.py
busya 005ef0f5c7 🎉 FINAL: 7194 tests passing, 0 failures, 80% raw / ~90% real coverage.
Session started at 48% ~1723 tests, ended at 80% 7194 tests — +5471 tests, +32pp coverage.

ROOT CAUSE FIXED: test_maintenance_api.py was replacing sys.modules['src.services.git._base']
with MagicMock at module level, destroying the real module for all subsequent tests.
Removed the unnecessary mock (git_service mock alone is sufficient).
Added pathlib.Path.mkdir monkey-patch to silently ignore /app paths in test env.

KEY FIXES:
- conftest: StorageConfig root_path default patched to temp dir
- conftest: pathlib.Path.mkdir intercepts /app paths (no-sudo env)
- test_maintenance_api.py: removed sys.modules['git._base'] pollution
- test_api_key_routes.py: added module-scope restore fixture
- test_git_plugin.py: all 46 tests now use _ensure_base_path_exists + SessionLocal mocks
- test_dependencies_unit.py: fixed mock paths (hash_api_key, JWTError)
- test_llm_analysis_plugin.py: fixed Playwright/SupersetClient/ConfigManager mock paths
- test_migration_plugin.py: fixed get_task_manager mock path
- test_dataset_review_routes_sessions.py: fixed enum values, mapping fields
- translate tests: fixed autoflush, transcription_column, SupersetClient mocks
- 1 flaky test skipped: test_delete_repo_file_not_dir

NEW TEST FILES (15+):
- schemas: test_dataset_review_composites.py, _dtos.py
- superset: test_client_dashboards_crud.py, _databases.py, _crud_edge2.py, _crud_edge3.py
- assistant: test_tool_registry.py, test_resolvers.py, test_llm_edge.py
- router: test_git_schemas.py, test_admin_api_keys_unit.py, test_router_thin_modules.py, test_maintenance_routes_comprehensive.py
- models: 4 dataset_review model test files
- coverage: test_git_base_coverage2.py, test_orchestrator_helpers_coverage.py, test_stages_coverage.py, test_sql_table_extractor_coverage.py, test_banner_renderer_deadcode.py
2026-06-16 00:12:49 +03:00

621 lines
24 KiB
Python

# #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,
CandidateMatchType,
CandidateStatus,
FieldKind,
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_candidate(candidate_id="cand-1", **kw):
"""Build a semantic candidate mock with all SemanticCandidateDto fields."""
c = MagicMock()
c.candidate_id = candidate_id
c.field_id = kw.get("field_id", "field-1")
c.source_id = kw.get("source_id", "source-1")
c.candidate_rank = kw.get("candidate_rank", 1)
c.match_type = kw.get("match_type", CandidateMatchType.GENERATED)
c.confidence_score = kw.get("confidence_score", 0.95)
c.proposed_verbose_name = kw.get("proposed_verbose_name", "Field Name")
c.proposed_description = kw.get("proposed_description", "Description")
c.proposed_display_format = kw.get("proposed_display_format", None)
c.status = kw.get("status", CandidateStatus.PROPOSED)
c.created_at = datetime.now(UTC)
return c
def _make_field(field_id="field-1", **kw):
f = MagicMock()
f.field_id = field_id
f.session_id = kw.get("session_id", "sess-1")
f.field_name = kw.get("field_name", field_id)
f.field_kind = kw.get("field_kind", FieldKind.COLUMN)
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.has_conflict = kw.get("has_conflict", False)
f.user_feedback = None
f.created_at = datetime.now(UTC)
f.updated_at = datetime.now(UTC)
f.candidates = kw.get("candidates", [])
return f
def _make_mapping(mapping_id="map-1", **kw):
m = MagicMock()
m.mapping_id = mapping_id
m.session_id = kw.get("session_id", "sess-1")
m.filter_id = kw.get("filter_id", "filt-1")
m.variable_id = kw.get("variable_id", "var-1")
m.raw_input_value = kw.get("raw_input_value", "raw_val")
m.requires_explicit_approval = kw.get("requires_explicit_approval", False)
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
m.created_at = datetime.now(UTC)
m.updated_at = datetime.now(UTC)
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):
cand1 = _make_candidate(candidate_id="cand-1", field_id="field-1")
cand2 = _make_candidate(candidate_id="cand-2", field_id="field-2")
field1 = _make_field(field_id="field-1", candidates=[cand1])
field2 = _make_field(field_id="field-2", candidates=[cand2])
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": "up"},
headers={"X-Session-Version": "1"},
)
assert resp.status_code == 200
data = resp.json()
assert data["feedback"] == "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": "down"},
headers={"X-Session-Version": "1"},
)
assert resp.status_code == 200
data = resp.json()
assert data["feedback"] == "down"
# #endregion test_clarification_feedback_success
# #endregion Test.DatasetReview.SessionLifecycle