Files
ss-tools/backend/tests/api/test_dataset_review_routes_extended.py

872 lines
33 KiB
Python

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