test: final 6 agents — 172+ translate tests, llm_analysis 89%, git_plugin 100%, migration/deps/routes polished. 85-94% files pushed to 95%+. Bulk: backup/debug/maintenance plugins
This commit is contained in:
744
backend/tests/api/test_dataset_review_routes_comprehensive.py
Normal file
744
backend/tests/api/test_dataset_review_routes_comprehensive.py
Normal file
@@ -0,0 +1,744 @@
|
||||
# #region Test.DatasetReview.Comprehensive [C:3] [TYPE Module] [SEMANTICS test,dataset,review,routes,coverage]
|
||||
# @BRIEF Comprehensive coverage for dataset_review_pkg/_routes.py — happy paths for all endpoints.
|
||||
# @RELATION BINDS_TO -> [DatasetReviewRoutes]
|
||||
# @TEST_EDGE: start_session_success -> POST /sessions returns 201
|
||||
# @TEST_EDGE: get_session_detail -> GET /sessions/{id} returns session detail
|
||||
# @TEST_EDGE: delete_session_soft_archive -> DELETE session without hard_delete archives
|
||||
# @TEST_EDGE: export_documentation_json -> GET exports/documentation returns content
|
||||
# @TEST_EDGE: get_clarification_state -> GET clarification state with active session
|
||||
# @TEST_EDGE: resume_clarification -> POST clarification/resume returns state
|
||||
# @TEST_EDGE: record_clarification_answer -> POST clarification/answers persists answer
|
||||
# @TEST_EDGE: update_field_semantic -> PATCH field semantic applies update
|
||||
# @TEST_EDGE: unlock_field_manual_override -> Unlock resets MANUAL_OVERRIDE provenance
|
||||
# @TEST_EDGE: approve_batch_semantic_multiple -> Batch approve multiple fields
|
||||
# @TEST_EDGE: update_execution_mapping_full -> Full execution mapping update
|
||||
# @TEST_EDGE: approve_execution_mapping -> POST mapping/approve
|
||||
# @TEST_EDGE: approve_batch_execution_mappings -> POST mappings/approve-batch
|
||||
# @TEST_EDGE: trigger_preview_pending -> Preview queued returns 202
|
||||
# @TEST_EDGE: launch_dataset_success -> POST launch returns 201 with redirect_url
|
||||
# @TEST_EDGE: list_execution_mappings -> GET mappings returns list
|
||||
|
||||
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,
|
||||
FieldProvenance,
|
||||
MappingMethod,
|
||||
PreviewStatus,
|
||||
ReadinessState,
|
||||
RecommendedAction,
|
||||
SessionStatus,
|
||||
SessionPhase,
|
||||
)
|
||||
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,
|
||||
RunContextResult,
|
||||
StartSessionCommand,
|
||||
StartSessionResult,
|
||||
)
|
||||
|
||||
client = TestClient(app)
|
||||
|
||||
|
||||
# ── Shared fixture ──
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clean_overrides():
|
||||
app.dependency_overrides.clear()
|
||||
yield
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
# ── Builders ──
|
||||
|
||||
|
||||
def _make_user(extra_permissions=None):
|
||||
perms = [
|
||||
SimpleNamespace(resource="dataset:session", action="READ"),
|
||||
SimpleNamespace(resource="dataset:session", action="MANAGE"),
|
||||
SimpleNamespace(resource="dataset:execution:launch", action="EXECUTE"),
|
||||
]
|
||||
if extra_permissions:
|
||||
perms.extend(extra_permissions)
|
||||
role = SimpleNamespace(name="DatasetReviewOperator", permissions=perms)
|
||||
return SimpleNamespace(id="user-1", username="tester", roles=[role])
|
||||
|
||||
|
||||
def _make_config_manager():
|
||||
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 = True
|
||||
config.settings.ff_dataset_clarification = True
|
||||
config.settings.ff_dataset_execution = True
|
||||
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 = SessionPhase.REVIEW.value
|
||||
s.version = kw.get("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 = kw.get("previews", [])
|
||||
s.run_contexts = []
|
||||
return s
|
||||
|
||||
|
||||
def _make_field(field_id="field-1", **kw):
|
||||
f = MagicMock()
|
||||
f.field_id = field_id
|
||||
f.session_id = "sess-1"
|
||||
f.field_name = kw.get("field_name", "revenue")
|
||||
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
|
||||
f.verbose_name = kw.get("verbose_name", "Revenue")
|
||||
f.description = None
|
||||
f.display_format = None
|
||||
f.source_id = None
|
||||
f.source_version = None
|
||||
f.confidence_rank = None
|
||||
f.has_conflict = False
|
||||
f.created_at = datetime.now(UTC)
|
||||
f.updated_at = datetime.now(UTC)
|
||||
f.candidates = []
|
||||
f.session = MagicMock(version=2)
|
||||
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 = kw.get("transformation_note", None)
|
||||
m.approval_state = kw.get("approval_state", ApprovalState.PENDING)
|
||||
m.approved_by_user_id = kw.get("approved_by_user_id", None)
|
||||
m.approved_at = kw.get("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.session = MagicMock(version=2)
|
||||
return m
|
||||
|
||||
|
||||
def _setup_deps(repository=None, orchestrator=None, clarification_engine=None,
|
||||
session=None):
|
||||
user = _make_user()
|
||||
cm = _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.require_session_version = MagicMock(return_value=effective_session)
|
||||
repository.bump_session_version = MagicMock(return_value=2)
|
||||
repository.commit_session_mutation = MagicMock()
|
||||
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}
|
||||
|
||||
|
||||
# ── start_session ──
|
||||
|
||||
|
||||
class TestStartSession:
|
||||
"""POST /api/dataset-orchestration/sessions"""
|
||||
|
||||
def test_start_session_success(self):
|
||||
"""Happy: session started returns 201 with summary."""
|
||||
s = _make_session(session_id="new-sess-1")
|
||||
orch = MagicMock()
|
||||
orch.start_session.return_value = StartSessionResult(session=s)
|
||||
_setup_deps(orchestrator=orch)
|
||||
|
||||
resp = client.post(
|
||||
"/api/dataset-orchestration/sessions",
|
||||
json={
|
||||
"source_kind": "superset_link",
|
||||
"source_input": "http://superset.local/dashboard/10",
|
||||
"environment_id": "env-1",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
data = resp.json()
|
||||
assert data["session_id"] == "new-sess-1"
|
||||
|
||||
|
||||
# ── get_session_detail ──
|
||||
|
||||
|
||||
class TestGetSessionDetail:
|
||||
"""GET /api/dataset-orchestration/sessions/{session_id}"""
|
||||
|
||||
def test_get_session_detail(self):
|
||||
"""Happy: returns session detail."""
|
||||
s = _make_session()
|
||||
_setup_deps(session=s)
|
||||
|
||||
resp = client.get("/api/dataset-orchestration/sessions/sess-1")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["session_id"] == "sess-1"
|
||||
|
||||
|
||||
# ── delete_session soft archive ──
|
||||
|
||||
|
||||
class TestDeleteSession:
|
||||
"""DELETE /api/dataset-orchestration/sessions/{session_id}"""
|
||||
|
||||
def test_delete_session_soft_archive(self):
|
||||
"""Soft archive: no hard_delete param archives session."""
|
||||
session = _make_session(version=1)
|
||||
repo = MagicMock()
|
||||
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.delete(
|
||||
"/api/dataset-orchestration/sessions/sess-1",
|
||||
headers={"X-Session-Version": "1"},
|
||||
)
|
||||
assert resp.status_code == 204
|
||||
|
||||
|
||||
# ── export_documentation ──
|
||||
|
||||
|
||||
class TestExportDocumentation:
|
||||
"""GET /api/dataset-orchestration/sessions/{session_id}/exports/documentation"""
|
||||
|
||||
def test_export_documentation_json(self):
|
||||
"""Happy: returns documentation export."""
|
||||
session = _make_session()
|
||||
_setup_deps(session=session)
|
||||
|
||||
resp = client.get(
|
||||
"/api/dataset-orchestration/sessions/sess-1/exports/documentation?format=json"
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["artifact_type"] == "documentation"
|
||||
|
||||
def test_export_documentation_markdown(self):
|
||||
"""Happy: markdown documentation export."""
|
||||
session = _make_session()
|
||||
_setup_deps(session=session)
|
||||
|
||||
resp = client.get(
|
||||
"/api/dataset-orchestration/sessions/sess-1/exports/documentation?format=markdown"
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["artifact_type"] == "documentation"
|
||||
|
||||
|
||||
# ── get_clarification_state ──
|
||||
|
||||
|
||||
class TestGetClarificationState:
|
||||
"""GET /api/dataset-orchestration/sessions/{session_id}/clarification"""
|
||||
|
||||
def test_get_clarification_state_with_session(self):
|
||||
"""Happy: returns clarification state with active session."""
|
||||
q = MagicMock()
|
||||
q.question_id = "q-1"
|
||||
q.question_text = "What is this field?"
|
||||
|
||||
cs = MagicMock()
|
||||
cs.clarification_session_id = "cs-1"
|
||||
cs.questions = [q]
|
||||
cs.status = "active"
|
||||
|
||||
session = _make_session(clarification_sessions=[cs])
|
||||
|
||||
clar_engine = MagicMock()
|
||||
clar_engine.build_question_payload = MagicMock(return_value={
|
||||
"question_id": "q-1",
|
||||
"question_text": "What is this field?",
|
||||
})
|
||||
|
||||
_setup_deps(session=session, clarification_engine=clar_engine)
|
||||
|
||||
resp = client.get(
|
||||
"/api/dataset-orchestration/sessions/sess-1/clarification"
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert "clarification_session" in data
|
||||
|
||||
|
||||
# ── resume_clarification ──
|
||||
|
||||
|
||||
class TestResumeClarification:
|
||||
"""POST /api/dataset-orchestration/sessions/{session_id}/clarification/resume"""
|
||||
|
||||
def test_resume_clarification(self):
|
||||
"""Happy: resumes clarification."""
|
||||
q = MagicMock()
|
||||
q.question_id = "q-1"
|
||||
|
||||
cs = MagicMock()
|
||||
cs.clarification_session_id = "cs-1"
|
||||
cs.questions = [q]
|
||||
cs.status = "active"
|
||||
|
||||
session = _make_session(clarification_sessions=[cs], version=1)
|
||||
|
||||
clar_engine = MagicMock()
|
||||
clar_engine.build_question_payload.return_value = {"question_id": "q-1"}
|
||||
|
||||
repo = MagicMock()
|
||||
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(session=session, repository=repo, clarification_engine=clar_engine)
|
||||
|
||||
resp = client.post(
|
||||
"/api/dataset-orchestration/sessions/sess-1/clarification/resume",
|
||||
headers={"X-Session-Version": "1"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert "clarification_session" in data
|
||||
|
||||
|
||||
# ── record_clarification_answer ──
|
||||
|
||||
|
||||
class TestRecordClarificationAnswer:
|
||||
"""POST /api/dataset-orchestration/sessions/{session_id}/clarification/answers"""
|
||||
|
||||
def test_record_clarification_answer(self):
|
||||
"""Happy: records answer and returns updated state."""
|
||||
q = MagicMock()
|
||||
q.question_id = "q-1"
|
||||
|
||||
cs = MagicMock()
|
||||
cs.clarification_session_id = "cs-1"
|
||||
cs.questions = [q]
|
||||
cs.status = "active"
|
||||
|
||||
session = _make_session(clarification_sessions=[cs], version=1)
|
||||
|
||||
clar_engine = MagicMock()
|
||||
answer_result = MagicMock()
|
||||
answer_result.clarification_session = cs
|
||||
answer_result.current_question = None
|
||||
answer_result.session = session
|
||||
answer_result.changed_findings = []
|
||||
clar_engine.record_answer.return_value = answer_result
|
||||
|
||||
repo = MagicMock()
|
||||
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(session=session, repository=repo, clarification_engine=clar_engine)
|
||||
|
||||
resp = client.post(
|
||||
"/api/dataset-orchestration/sessions/sess-1/clarification/answers",
|
||||
json={
|
||||
"question_id": "q-1",
|
||||
"answer_kind": "free_text",
|
||||
"answer_value": "Test answer",
|
||||
},
|
||||
headers={"X-Session-Version": "1"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert "clarification_state" in data
|
||||
assert "session" in data
|
||||
|
||||
|
||||
# ── update_field_semantic ──
|
||||
|
||||
|
||||
class TestUpdateFieldSemantic:
|
||||
"""PATCH /api/dataset-orchestration/sessions/{session_id}/fields/{field_id}/semantic"""
|
||||
|
||||
def test_update_field_semantic(self):
|
||||
"""Happy: applies field semantic update."""
|
||||
field = _make_field(field_id="field-1")
|
||||
session = _make_session(semantic_fields=[field], version=1)
|
||||
repo = MagicMock()
|
||||
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/fields/field-1/semantic",
|
||||
json={"candidate_id": "cand-1", "lock_field": False},
|
||||
headers={"X-Session-Version": "1"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["field_id"] == "field-1"
|
||||
|
||||
|
||||
# ── unlock_field with MANUAL_OVERRIDE provenance ──
|
||||
|
||||
|
||||
class TestUnlockFieldManualOverride:
|
||||
"""POST /api/dataset-orchestration/sessions/{session_id}/fields/{field_id}/unlock"""
|
||||
|
||||
def test_unlock_manual_override_resets_provenance(self):
|
||||
"""Unlock with MANUAL_OVERRIDE resets provenance to UNRESOLVED."""
|
||||
field = _make_field(field_id="field-1", provenance=FieldProvenance.MANUAL_OVERRIDE,
|
||||
is_locked=True)
|
||||
session = _make_session(semantic_fields=[field], version=1)
|
||||
repo = MagicMock()
|
||||
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/unlock",
|
||||
headers={"X-Session-Version": "1"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["is_locked"] is False
|
||||
assert data["provenance"] == "unresolved"
|
||||
|
||||
|
||||
# ── approve_batch_semantic_fields ──
|
||||
|
||||
|
||||
class TestApproveBatchSemantic:
|
||||
"""POST /api/dataset-orchestration/sessions/{session_id}/fields/semantic/approve-batch"""
|
||||
|
||||
def test_approve_multiple_fields(self):
|
||||
"""Happy: batch approve multiple semantic fields."""
|
||||
field1 = _make_field(field_id="f1")
|
||||
field2 = _make_field(field_id="f2")
|
||||
session = _make_session(semantic_fields=[field1, field2], version=1)
|
||||
repo = MagicMock()
|
||||
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": "f1", "candidate_id": "cand-1", "lock_field": False},
|
||||
{"field_id": "f2", "candidate_id": "cand-2", "lock_field": True},
|
||||
]
|
||||
},
|
||||
headers={"X-Session-Version": "1"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert len(data) == 2
|
||||
|
||||
|
||||
# ── list_execution_mappings ──
|
||||
|
||||
|
||||
class TestListExecutionMappings:
|
||||
"""GET /api/dataset-orchestration/sessions/{session_id}/mappings"""
|
||||
|
||||
def test_list_execution_mappings(self):
|
||||
"""Happy: returns execution mappings list."""
|
||||
mapping = _make_mapping(mapping_id="map-1")
|
||||
session = _make_session(execution_mappings=[mapping])
|
||||
_setup_deps(session=session)
|
||||
|
||||
resp = client.get(
|
||||
"/api/dataset-orchestration/sessions/sess-1/mappings"
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert len(data["items"]) == 1
|
||||
|
||||
|
||||
# ── update_execution_mapping ──
|
||||
|
||||
|
||||
class TestUpdateExecutionMapping:
|
||||
"""PATCH /api/dataset-orchestration/sessions/{session_id}/mappings/{mapping_id}"""
|
||||
|
||||
def test_update_execution_mapping_full(self):
|
||||
"""Happy: full execution mapping update."""
|
||||
mapping = _make_mapping(mapping_id="map-1")
|
||||
session = _make_session(execution_mappings=[mapping], version=1,
|
||||
readiness_state=ReadinessState.MAPPING_REVIEW_NEEDED)
|
||||
repo = MagicMock()
|
||||
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_db_value",
|
||||
"mapping_method": "manual_override",
|
||||
"transformation_note": "User approved",
|
||||
},
|
||||
headers={"X-Session-Version": "1"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["mapping_id"] == "map-1"
|
||||
|
||||
|
||||
# ── approve_execution_mapping ──
|
||||
|
||||
|
||||
class TestApproveExecutionMapping:
|
||||
"""POST /api/dataset-orchestration/sessions/{session_id}/mappings/{mapping_id}/approve"""
|
||||
|
||||
def test_approve_execution_mapping(self):
|
||||
"""Happy: approve execution mapping."""
|
||||
mapping = _make_mapping(mapping_id="map-1")
|
||||
session = _make_session(execution_mappings=[mapping], version=1,
|
||||
readiness_state=ReadinessState.MAPPING_REVIEW_NEEDED)
|
||||
repo = MagicMock()
|
||||
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": "Approved by reviewer"},
|
||||
headers={"X-Session-Version": "1"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["mapping_id"] == "map-1"
|
||||
|
||||
|
||||
# ── approve_batch_execution_mappings ──
|
||||
|
||||
|
||||
class TestApproveBatchMappings:
|
||||
"""POST /api/dataset-orchestration/sessions/{session_id}/mappings/approve-batch"""
|
||||
|
||||
def test_approve_batch_execution_mappings(self):
|
||||
"""Happy: batch approve mappings."""
|
||||
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.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 len(data) == 2
|
||||
|
||||
|
||||
# ── trigger_preview_generation (PENDING path) ──
|
||||
|
||||
|
||||
class TestTriggerPreviewPending:
|
||||
"""POST /api/dataset-orchestration/sessions/{session_id}/preview"""
|
||||
|
||||
def test_preview_pending_202(self):
|
||||
"""Preview queued returns 202 Accepted."""
|
||||
session = _make_session(version=1,
|
||||
readiness_state=ReadinessState.COMPILED_PREVIEW_READY)
|
||||
|
||||
orch = MagicMock()
|
||||
preview_result = MagicMock(spec=PreparePreviewResult)
|
||||
preview_result.preview = MagicMock()
|
||||
preview_result.preview.preview_status = PreviewStatus.PENDING
|
||||
preview_result.session = session
|
||||
orch.prepare_launch_preview.return_value = preview_result
|
||||
|
||||
repo = MagicMock()
|
||||
repo.load_session_detail.return_value = session
|
||||
repo.require_session_version = MagicMock(return_value=session)
|
||||
repo.commit_session_mutation = MagicMock()
|
||||
repo.db = MagicMock()
|
||||
|
||||
_setup_deps(repository=repo, orchestrator=orch, session=session)
|
||||
|
||||
resp = client.post(
|
||||
"/api/dataset-orchestration/sessions/sess-1/preview",
|
||||
headers={"X-Session-Version": "1"},
|
||||
)
|
||||
assert resp.status_code == 202
|
||||
data = resp.json()
|
||||
assert "preview_status" in data
|
||||
|
||||
|
||||
# ── launch_dataset ──
|
||||
|
||||
|
||||
class TestLaunchDataset:
|
||||
"""POST /api/dataset-orchestration/sessions/{session_id}/launch"""
|
||||
|
||||
def test_launch_dataset_success(self):
|
||||
"""Happy: launch returns 201 with redirect URL."""
|
||||
session = _make_session(version=1, environment_id="env-1")
|
||||
|
||||
run_ctx = MagicMock(spec=RunContextResult)
|
||||
run_ctx.sql_lab_session_ref = "sql-lab-ref-123"
|
||||
|
||||
launch_result = MagicMock(spec=LaunchDatasetResult)
|
||||
launch_result.run_context = run_ctx
|
||||
launch_result.session = session
|
||||
|
||||
orch = MagicMock()
|
||||
orch.launch_dataset.return_value = launch_result
|
||||
|
||||
repo = MagicMock()
|
||||
repo.load_session_detail.return_value = session
|
||||
repo.require_session_version = MagicMock(return_value=session)
|
||||
repo.commit_session_mutation = MagicMock()
|
||||
repo.db = MagicMock()
|
||||
|
||||
cm = _make_config_manager()
|
||||
|
||||
_setup_deps(repository=repo, orchestrator=orch, session=session)
|
||||
|
||||
resp = client.post(
|
||||
"/api/dataset-orchestration/sessions/sess-1/launch",
|
||||
headers={"X-Session-Version": "1"},
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
data = resp.json()
|
||||
assert "redirect_url" in data
|
||||
@@ -139,7 +139,7 @@ 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.CANDIDATE)
|
||||
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
|
||||
@@ -150,7 +150,7 @@ 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.AI_SUGGESTED
|
||||
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
|
||||
@@ -264,7 +264,10 @@ class TestUpdateSession:
|
||||
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(
|
||||
@@ -287,7 +290,10 @@ class TestUpdateSession:
|
||||
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(
|
||||
@@ -315,6 +321,8 @@ class TestDeleteSession:
|
||||
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(
|
||||
@@ -374,6 +382,9 @@ class TestBatchApproveSemantic:
|
||||
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(
|
||||
@@ -410,6 +421,9 @@ class TestUpdateExecutionMapping:
|
||||
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(
|
||||
@@ -445,6 +459,9 @@ class TestApproveExecutionMapping:
|
||||
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(
|
||||
@@ -475,6 +492,9 @@ class TestBatchApproveMappings:
|
||||
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(
|
||||
@@ -506,6 +526,9 @@ class TestRecordFieldFeedback:
|
||||
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(
|
||||
@@ -539,6 +562,9 @@ class TestRecordClarificationFeedback:
|
||||
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(
|
||||
|
||||
@@ -133,6 +133,15 @@ class TestGetMergeConflicts:
|
||||
resp = client.get("/repositories/42/merge/conflicts")
|
||||
assert resp.status_code == 500
|
||||
|
||||
def test_dashboard_not_found(self):
|
||||
"""HTTPException from _resolve_dashboard_id_from_ref propagates (except HTTPException: raise)."""
|
||||
with (
|
||||
patch("src.api.routes.git._resolve_dashboard_id_from_ref", AsyncMock(side_effect=HTTPException(status_code=404, detail="Not found"))),
|
||||
):
|
||||
client = _make_client()
|
||||
resp = client.get("/repositories/bad-ref/merge/conflicts")
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
class TestResolveMergeConflicts:
|
||||
"""POST /repositories/{dashboard_ref}/merge/resolve"""
|
||||
@@ -170,6 +179,15 @@ class TestResolveMergeConflicts:
|
||||
resp = client.post("/repositories/42/merge/resolve", json=self.RESOLVE_PAYLOAD)
|
||||
assert resp.status_code == 500
|
||||
|
||||
def test_dashboard_not_found(self):
|
||||
"""HTTPException propagates through except HTTPException: raise (line 108)."""
|
||||
with (
|
||||
patch("src.api.routes.git._resolve_dashboard_id_from_ref", AsyncMock(side_effect=HTTPException(status_code=404, detail="Not found"))),
|
||||
):
|
||||
client = _make_client()
|
||||
resp = client.post("/repositories/bad-ref/merge/resolve", json=self.RESOLVE_PAYLOAD)
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
class TestAbortMerge:
|
||||
"""POST /repositories/{dashboard_ref}/merge/abort"""
|
||||
@@ -198,6 +216,15 @@ class TestAbortMerge:
|
||||
resp = client.post("/repositories/42/merge/abort")
|
||||
assert resp.status_code == 500
|
||||
|
||||
def test_dashboard_not_found(self):
|
||||
"""HTTPException propagates through except HTTPException: raise (line 134)."""
|
||||
with (
|
||||
patch("src.api.routes.git._resolve_dashboard_id_from_ref", AsyncMock(side_effect=HTTPException(status_code=404, detail="Not found"))),
|
||||
):
|
||||
client = _make_client()
|
||||
resp = client.post("/repositories/bad-ref/merge/abort")
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
class TestContinueMerge:
|
||||
"""POST /repositories/{dashboard_ref}/merge/continue"""
|
||||
@@ -237,4 +264,13 @@ class TestContinueMerge:
|
||||
client = _make_client()
|
||||
resp = client.post("/repositories/42/merge/continue", json={"message": "x"})
|
||||
assert resp.status_code == 500
|
||||
|
||||
def test_dashboard_not_found(self):
|
||||
"""HTTPException propagates through except HTTPException: raise (line 162)."""
|
||||
with (
|
||||
patch("src.api.routes.git._resolve_dashboard_id_from_ref", AsyncMock(side_effect=HTTPException(status_code=404, detail="Not found"))),
|
||||
):
|
||||
client = _make_client()
|
||||
resp = client.post("/repositories/bad-ref/merge/continue", json={"message": "x"})
|
||||
assert resp.status_code == 404
|
||||
# #endregion Test.Api.GitMergeRoutes
|
||||
|
||||
@@ -231,6 +231,42 @@ class TestPromoteDashboard:
|
||||
resp = client.post("/repositories/42/promote", json=self.PROMOTE_PAYLOAD)
|
||||
assert resp.status_code == 501
|
||||
|
||||
def test_empty_branch_after_strip_raises_400(self, mock_db_repo, mock_git_config):
|
||||
"""Whitespace-only from_branch (passes Pydantic, stripped empty in handler) → 400 (line 102)."""
|
||||
mock_db = self._setup_mock_db(mock_db_repo, mock_git_config)
|
||||
from src.core.database import get_db
|
||||
with (
|
||||
patch("src.api.routes.git._resolve_dashboard_id_from_ref", AsyncMock(return_value=42)),
|
||||
):
|
||||
client = _make_client({get_db: lambda: mock_db})
|
||||
# Pydantic min_length=1 passes " ", but handler strips to ""
|
||||
resp = client.post("/repositories/42/promote", json={"from_branch": " ", "to_branch": "main"})
|
||||
assert resp.status_code == 400
|
||||
|
||||
def test_same_branch_raises_400(self, mock_db_repo, mock_git_config):
|
||||
"""from_branch == to_branch → 400 (line 106)."""
|
||||
mock_db = self._setup_mock_db(mock_db_repo, mock_git_config)
|
||||
from src.core.database import get_db
|
||||
with (
|
||||
patch("src.api.routes.git._resolve_dashboard_id_from_ref", AsyncMock(return_value=42)),
|
||||
):
|
||||
client = _make_client({get_db: lambda: mock_db})
|
||||
resp = client.post("/repositories/42/promote", json={"from_branch": "dev", "to_branch": "dev"})
|
||||
assert resp.status_code == 400
|
||||
|
||||
def test_direct_promote_no_reason_raises_400(self, mock_db_repo, mock_git_config):
|
||||
"""Direct promote without reason → 400 (line 114)."""
|
||||
mock_db = self._setup_mock_db(mock_db_repo, mock_git_config)
|
||||
from src.core.database import get_db
|
||||
with (
|
||||
patch("src.api.routes.git._resolve_dashboard_id_from_ref", AsyncMock(return_value=42)),
|
||||
):
|
||||
client = _make_client({get_db: lambda: mock_db})
|
||||
resp = client.post("/repositories/42/promote", json={
|
||||
"from_branch": "dev", "to_branch": "main", "mode": "direct", "reason": "",
|
||||
})
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
# ── deploy_dashboard ──
|
||||
|
||||
@@ -261,4 +297,13 @@ class TestDeployDashboard:
|
||||
client = _make_client()
|
||||
resp = client.post("/repositories/42/deploy", json={"environment_id": "prod"})
|
||||
assert resp.status_code == 500
|
||||
|
||||
def test_dashboard_not_found(self):
|
||||
"""HTTPException from _resolve_dashboard_id_from_ref propagates (line 220)."""
|
||||
with (
|
||||
patch("src.api.routes.git._resolve_dashboard_id_from_ref", AsyncMock(side_effect=HTTPException(status_code=404, detail="Not found"))),
|
||||
):
|
||||
client = _make_client()
|
||||
resp = client.post("/repositories/bad-ref/deploy", json={"environment_id": "prod"})
|
||||
assert resp.status_code == 404
|
||||
# #endregion Test.Api.GitRepoLifecycleRoutes
|
||||
|
||||
Reference in New Issue
Block a user