test: 5 final agents — fix 40+ failures, llm_analysis 80%+, git_plugin 90%+, routes 90-98%, services 98-100%, core 90-100%. Coverage: real 87%, target 95%+

This commit is contained in:
2026-06-15 19:31:56 +03:00
parent 51d90f58c1
commit 010edfcfdc
20 changed files with 5275 additions and 231 deletions

View File

@@ -126,6 +126,56 @@ class TestHandleListMaintenanceEvents:
)
assert "Нет событий" in text
@pytest.mark.asyncio
async def test_with_expired_events(self, intent, current_user):
"""Expired events trigger end-task creation."""
from src.api.routes.assistant._tool_maintenance import handle_list_maintenance_events
task_manager = MagicMock()
task_manager.create_task = AsyncMock(return_value=MagicMock(id="task-exp-1"))
config_manager = MagicMock()
db = MagicMock()
expired_events = [
_make_maintenance_event("evt-exp-1", "ACTIVE"),
_make_maintenance_event("evt-exp-2", "PARTIAL"),
]
active_events = [_make_maintenance_event("evt-act-1", "ACTIVE")]
completed_events = [_make_maintenance_event("evt-cmp-1", "COMPLETED")]
class CountMock:
"""Mock that returns a specific count value."""
def __init__(self, val):
self._val = val
def count(self):
return self._val
def query_side_effect(model):
q = MagicMock()
if model.__name__ == "MaintenanceEvent":
# First .filter(...).all() → expired events
# Then .filter(...).order_by().all() → active
# Then .filter(...).order_by().limit().all() → completed
f1 = MagicMock()
f1.all.side_effect = [expired_events, active_events, completed_events]
q.filter.return_value = f1
# For the order_by path used in active/completed:
f1.order_by.return_value.all.return_value = active_events
f1.order_by.return_value.limit.return_value.all.return_value = completed_events
elif model.__name__ == "MaintenanceDashboardState":
q.filter.return_value.filter.return_value.count.return_value = 2
return q
db.query.side_effect = query_side_effect
text, task_id, actions = await handle_list_maintenance_events(
intent, current_user, task_manager, config_manager, db
)
assert "Активные" in text
assert "Завершённые" in text
# Should have created tasks for expired events
assert task_manager.create_task.call_count >= 2
class TestHandleStartMaintenance:
"""handle_start_maintenance — starting maintenance events."""

View File

@@ -0,0 +1,558 @@
# #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.CANDIDATE)
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.AI_SUGGESTED
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.bump_session_version = 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.bump_session_version = 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.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.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.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.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.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.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.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

View File

@@ -396,4 +396,127 @@ class TestGenerateCommitMessage:
resp = client.post("/repositories/42/generate-message")
assert resp.status_code == 400
assert "No active LLM provider" in resp.text
def test_provider_found_via_active_flag(self):
"""generate-commit-message falls back to active provider when bound provider not found."""
mock_gs = MagicMock()
mock_gs.get_diff.side_effect = [{"diff": "changes"}, None]
mock_gs.get_commit_history.return_value = []
mock_provider = MagicMock()
mock_provider.id = "active-provider"
mock_provider.provider_type = "openai"
mock_provider.is_active = True
mock_provider.base_url = None
mock_provider.default_model = "gpt-4"
mock_llm_svc = MagicMock()
mock_llm_svc.get_all_providers.return_value = [mock_provider]
mock_llm_svc.get_decrypted_api_key.return_value = "sk-test"
with (
patch("src.api.routes.git._repo_operations_routes.get_git_service", return_value=mock_gs),
patch("src.api.routes.git._resolve_dashboard_id_from_ref", AsyncMock(return_value=42)),
patch("src.services.llm_provider.LLMProviderService", return_value=mock_llm_svc),
patch("src.plugins.git.llm_extension.GitLLMExtension") as mock_ext_cls,
):
mock_ext = MagicMock()
mock_ext.suggest_commit_message = AsyncMock(return_value="feat: ai message")
mock_ext_cls.return_value = mock_ext
client = _make_client()
resp = client.post("/repositories/42/generate-message")
assert resp.status_code == 200
assert resp.json()["message"] == "feat: ai message"
# ── HTTPException propagation ──
class TestHttpExceptionPropagation:
"""When _resolve_dashboard_id_from_ref raises HTTPException, route must re-raise unchanged."""
HTTPERR = HTTPException(status_code=404, detail="Not found")
def test_commit_changes_http_error(self):
with patch("src.api.routes.git._resolve_dashboard_id_from_ref", AsyncMock(side_effect=self.HTTPERR)):
client = _make_client()
resp = client.post("/repositories/42/commit", json={"message": "x", "files": []})
assert resp.status_code == 404
def test_push_changes_http_error(self):
with patch("src.api.routes.git._resolve_dashboard_id_from_ref", AsyncMock(side_effect=self.HTTPERR)):
client = _make_client()
resp = client.post("/repositories/42/push")
assert resp.status_code == 404
def test_pull_changes_http_error(self):
with patch("src.api.routes.git._resolve_dashboard_id_from_ref", AsyncMock(side_effect=self.HTTPERR)):
client = _make_client()
resp = client.post("/repositories/42/pull")
assert resp.status_code == 404
def test_get_repository_status_http_error(self):
with patch("src.api.routes.git._resolve_dashboard_id_from_ref", AsyncMock(side_effect=self.HTTPERR)):
client = _make_client()
resp = client.get("/repositories/42/status")
assert resp.status_code == 404
def test_get_repository_diff_http_error(self):
with patch("src.api.routes.git._resolve_dashboard_id_from_ref", AsyncMock(side_effect=self.HTTPERR)):
client = _make_client()
resp = client.get("/repositories/42/diff")
assert resp.status_code == 404
def test_get_history_http_error(self):
with patch("src.api.routes.git._resolve_dashboard_id_from_ref", AsyncMock(side_effect=self.HTTPERR)):
client = _make_client()
resp = client.get("/repositories/42/history")
assert resp.status_code == 404
def test_generate_commit_message_http_error(self):
with patch("src.api.routes.git._resolve_dashboard_id_from_ref", AsyncMock(side_effect=self.HTTPERR)):
client = _make_client()
resp = client.post("/repositories/42/generate-message")
assert resp.status_code == 404
# ── get_repository_status_batch generic Exception ──
class TestBatchStatusGenericError:
"""When _resolve_repository_status raises a non-HTTP error, batch should map it to ERROR state."""
def test_generic_exception_mapped_to_error(self):
def side_effect(did):
if did == 2:
raise RuntimeError("unexpected")
return {"is_dirty": False, "has_repo": True}
with patch("src.api.routes.git._repo_operations_routes._resolve_repository_status", AsyncMock(side_effect=side_effect)):
client = _make_client()
resp = client.post("/repositories/status/batch", json={"dashboard_ids": [1, 2, 3]})
assert resp.status_code == 200
statuses = resp.json()["statuses"]
assert statuses["2"]["sync_state"] == "ERROR"
# ── pull_changes diagnostics ──
class TestPullChangesDiagnostics:
"""pull_changes route diagnostics block with DB error."""
def test_diagnostics_error_logged(self):
mock_gs = MagicMock()
mock_db = MagicMock()
# First call raises exception to trigger diagnostics
mock_db.query.return_value.filter.return_value.first.side_effect = RuntimeError("db err")
from src.core.database import get_db
with (
patch("src.api.routes.git._repo_operations_routes.get_git_service", return_value=mock_gs),
patch("src.api.routes.git._resolve_dashboard_id_from_ref", AsyncMock(return_value=42)),
patch("src.api.routes.git._repo_operations_routes._apply_git_identity_from_profile", AsyncMock()),
):
client = _make_client({get_db: lambda: mock_db})
resp = client.post("/repositories/42/pull")
assert resp.status_code == 200
assert resp.json()["status"] == "success"
# #endregion Test.Api.GitRepoOperationsRoutes

View File

@@ -0,0 +1,258 @@
# #region Test.Api.Llm.Edge [C:3] [TYPE Module] [SEMANTICS test,llm,routes,edge,coverage]
# @BRIEF Edge-case tests for LLM API routes — covers uncovered branches from llm.py.
# @RELATION BINDS_TO -> [LlmRoutes]
# @TEST_EDGE: is_valid_key_placeholder -> _is_valid_runtime_api_key returns False for ********
# @TEST_EDGE: is_valid_key_too_short -> _is_valid_runtime_api_key returns False for short keys
# @TEST_EDGE: fetch_models_invalid_provider_type -> 400 when provider_type is invalid enum
# @TEST_EDGE: test_provider_config_exception -> connection failure captured as success=false
# @TEST_EDGE: probe_max_images_binary_search -> binary search between last_ok and first_fail
# @TEST_EDGE: get_providers_no_decrypted_key -> empty api_key when no stored key
import os
os.environ.setdefault("DATABASE_URL", "sqlite:///:memory:")
os.environ.setdefault("AUTH_DATABASE_URL", "sqlite:///:memory:")
os.environ.setdefault("SECRET_KEY", "test-secret-key-for-tests")
os.environ.setdefault("DEV_MODE", "true")
import sys
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
_src = str(Path(__file__).resolve().parent.parent.parent / "src")
if _src not in sys.path:
sys.path.insert(0, _src)
P = "/api/llm"
def _make_provider(**kw):
p = MagicMock()
p.id = kw.get("id", "prov-1")
p.provider_type = kw.get("provider_type", "openai")
p.name = kw.get("name", "Test Provider")
p.base_url = kw.get("base_url", "https://api.openai.com")
p.api_key = kw.get("api_key", "sk-test")
p.default_model = kw.get("default_model", "gpt-4")
p.is_active = kw.get("is_active", True)
p.is_multimodal = kw.get("is_multimodal", True)
p.max_images = kw.get("max_images", 10)
p.context_window = kw.get("context_window", 128000)
p.max_output_tokens = kw.get("max_output_tokens", 4096)
return p
def _make_client(overrides: dict | None = None) -> TestClient:
from src.api.routes.llm import router
from src.core.database import get_db
from src.dependencies import get_current_user
from src.schemas.auth import User, RoleSchema
app = FastAPI()
app.include_router(router, prefix=P)
mock_user = User(
id="admin-1", username="admin", email="admin@x.com",
auth_source="LOCAL",
created_at=__import__("datetime").datetime.now(),
roles=[RoleSchema(id="r1", name="Admin", description="", permissions=[])],
)
app.dependency_overrides[get_db] = lambda: MagicMock()
app.dependency_overrides[get_current_user] = lambda: mock_user
if overrides:
for dep, fn in overrides.items():
app.dependency_overrides[dep] = fn
return TestClient(app)
# ── _is_valid_runtime_api_key edge cases ──────────────────────
class TestIsValidRuntimeApiKey:
"""Direct test of _is_valid_runtime_api_key edge cases."""
# #region test_valid_key_placeholder [C:2] [TYPE Function]
# @BRIEF "********" returns False.
def test_valid_key_placeholder(self):
from src.api.routes.llm import _is_valid_runtime_api_key
assert _is_valid_runtime_api_key("********") is False
# #endregion test_valid_key_placeholder
# #region test_valid_key_empty_or_none [C:2] [TYPE Function]
# @BRIEF "EMPTY_OR_NONE" returns False.
def test_valid_key_empty_or_none(self):
from src.api.routes.llm import _is_valid_runtime_api_key
assert _is_valid_runtime_api_key("EMPTY_OR_NONE") is False
# #endregion test_valid_key_empty_or_none
# #region test_valid_key_too_short [C:2] [TYPE Function]
# @BRIEF Key shorter than 16 chars returns False.
def test_valid_key_too_short(self):
from src.api.routes.llm import _is_valid_runtime_api_key
assert _is_valid_runtime_api_key("short") is False
# #endregion test_valid_key_too_short
# #region test_valid_key_none [C:2] [TYPE Function]
# @BRIEF None returns False.
def test_valid_key_none(self):
from src.api.routes.llm import _is_valid_runtime_api_key
assert _is_valid_runtime_api_key(None) is False
# #endregion test_valid_key_none
# ── get_providers — no decrypted key ──────────────────────────
class TestGetProvidersEdge:
"""GET /api/llm/providers — edge paths."""
# #region test_get_providers_no_api_key [C:2] [TYPE Function]
# @BRIEF Provider has no stored api_key -> returns masked api_key.
def test_get_providers_no_api_key(self):
mock_db = MagicMock()
mock_svc = MagicMock()
p = _make_provider(api_key="")
mock_svc.get_all_providers.return_value = [p]
from src.core.database import get_db
with patch("src.api.routes.llm.LLMProviderService", return_value=mock_svc):
client = _make_client({get_db: lambda: mock_db})
resp = client.get(f"{P}/providers")
assert resp.status_code == 200
data = resp.json()
assert len(data) == 1
assert data[0]["api_key"] == "" # no key -> empty string
# #endregion test_get_providers_no_api_key
# ── fetch_models — invalid provider_type ──────────────────────
class TestFetchModelsEdge:
"""POST /api/llm/providers/fetch-models — edge paths."""
# #region test_fetch_models_invalid_provider_type [C:2] [TYPE Function]
# @BRIEF Invalid provider_type string -> 400.
def test_fetch_models_invalid_provider_type(self):
client = _make_client()
resp = client.post(f"{P}/providers/fetch-models", json={
"base_url": "https://api.example.com",
"provider_type": "nonexistent_type",
"api_key": "sk-test",
})
assert resp.status_code == 400
assert "Invalid provider_type" in resp.json()["detail"]
# #endregion test_fetch_models_invalid_provider_type
# #region test_fetch_models_masked_key [C:2] [TYPE Function]
# @BRIEF Masked API key treated as empty -> requires provider_id fallback -> 400.
def test_fetch_models_masked_key(self):
client = _make_client()
resp = client.post(f"{P}/providers/fetch-models", json={
"base_url": "https://api.example.com",
"provider_type": "openai",
"api_key": "********",
})
# Masked key is treated as empty, and no provider_id given -> 400
assert resp.status_code == 400
assert "api_key or provider_id" in resp.json()["detail"]
# #endregion test_fetch_models_masked_key
# ── test_provider_config — exception path ─────────────────────
class TestTestProviderConfigEdge:
"""POST /api/llm/providers/test — edge paths."""
# #region test_test_provider_config_exception [C:2] [TYPE Function]
# @BRIEF LLMClient raises -> success=false with error message.
def test_test_provider_config_exception(self):
mock_client = MagicMock()
mock_client.test_runtime_connection = AsyncMock(side_effect=RuntimeError("Connection refused"))
with patch("src.plugins.llm_analysis.service.LLMClient", return_value=mock_client):
client = _make_client()
resp = client.post(f"{P}/providers/test", json={
"provider_type": "openai", "name": "T", "base_url": "https://x.com",
"api_key": "sk-test", "default_model": "gpt-4",
})
assert resp.status_code == 200
data = resp.json()
assert data["success"] is False
assert "Connection refused" in data["error"]
# #endregion test_test_provider_config_exception
# ── probe_max_images — binary search ──────────────────────────
class TestProbeMaxImagesEdge:
"""POST /api/llm/providers/{provider_id}/probe-max-images — binary search path."""
# #region test_probe_max_images_binary_search [C:2] [TYPE Function]
# @BRIEF 2 images OK, 4 images fail -> binary search between 2 and 4.
def test_probe_max_images_binary_search(self):
mock_db = MagicMock()
mock_svc = MagicMock()
mock_svc.get_provider.return_value = _make_provider()
mock_svc.get_decrypted_api_key.return_value = "sk-real-16chars-minimum"
mock_openai = MagicMock()
mock_openai.chat.completions = MagicMock()
# 1, 2 OK; 4, 8, 16, 32, 64 fail with unknown error
call_count = [0]
results = [True, True, False, False, False, False, False]
async def _mock_create(**kwargs):
idx = call_count[0]
call_count[0] += 1
if idx < len(results) and results[idx]:
return MagicMock()
raise RuntimeError("unsupported")
mock_openai.chat.completions.create = _mock_create
from src.core.database import get_db
with (
patch("src.api.routes.llm.LLMProviderService", return_value=mock_svc),
patch("openai.AsyncOpenAI", return_value=mock_openai),
):
client = _make_client({get_db: lambda: mock_db})
resp = client.post(f"{P}/providers/prov-1/probe-max-images")
assert resp.status_code == 200
data = resp.json()
# 2 was last OK, 4 was first fail -> binary search -> lo=2, hi=4 -> 3 tested -> 3 fails -> lo=2
assert data["method"] == "binary_search"
assert data["max_images"] == 2
# #endregion test_probe_max_images_binary_search
# #region test_probe_max_images_all_succeed [C:2] [TYPE Function]
# @BRIEF All probe sizes succeed (1..64 all OK) -> no_limit_detected.
def test_probe_max_images_all_succeed(self):
mock_db = MagicMock()
mock_svc = MagicMock()
mock_svc.get_provider.return_value = _make_provider(default_model="gpt-4")
mock_svc.get_decrypted_api_key.return_value = "sk-real-16chars-minimum"
mock_openai = MagicMock()
mock_openai.chat.completions = MagicMock()
mock_openai.chat.completions.create = AsyncMock(return_value=MagicMock())
from src.core.database import get_db
with (
patch("src.api.routes.llm.LLMProviderService", return_value=mock_svc),
patch("openai.AsyncOpenAI", return_value=mock_openai),
):
client = _make_client({get_db: lambda: mock_db})
resp = client.post(f"{P}/providers/prov-1/probe-max-images")
assert resp.status_code == 200
data = resp.json()
assert data["method"] == "no_limit_detected"
assert data["max_images"] is None
# #endregion test_probe_max_images_all_succeed
# #endregion Test.Api.Llm.Edge

View File

@@ -0,0 +1,362 @@
# #region Test.Maintenance.Routes.Edge [C:3] [TYPE Module] [SEMANTICS test,maintenance,routes,edge,coverage]
# @BRIEF Edge-case tests for Maintenance Banner API routes — covers uncovered branches from _routes.py.
# @RELATION BINDS_TO -> [MaintenanceRoutesModule]
# @TEST_EDGE: settings_not_found -> GET /settings returns 404 when no settings row exists
# @TEST_EDGE: settings_upsert -> PUT /settings creates new settings when none exist
# @TEST_EDGE: settings_all_fields -> PUT /settings with all fields updates correctly
# @TEST_EDGE: end_already_completed -> POST /{id}/end returns idempotent success for already-completed
# @TEST_EDGE: start_past_time -> POST /start with start_time too far in past returns 400
# @TEST_EDGE: start_html_message -> POST /start with HTML in message is accepted
# @TEST_EDGE: end_all_with_env_body -> POST /end-all with environment_id in body
# @TEST_EDGE: banners_with_linked_states -> GET /dashboard-banners with linked states/events
from datetime import UTC, datetime, timedelta
from unittest.mock import MagicMock
import pytest
# ── Shared fixtures ────────────────────────────────────────────
@pytest.fixture
def client():
from fastapi.testclient import TestClient
from src.app import app
return TestClient(app)
@pytest.fixture(autouse=True)
def auth_mock(client, mock_db):
from src.app import app
from src.core.auth.api_key import generate_api_key
from src.dependencies import get_current_user
from src.models.api_key import APIKey
raw, prefix, key_hash = generate_api_key()
api_key = APIKey(
key_hash=key_hash, prefix=prefix, name="Test Key",
permissions=["maintenance:start", "maintenance:end", "maintenance:end_all"],
active=True,
)
mock_db.add(api_key)
mock_db.commit()
client.headers.update({"X-API-Key": raw})
mock_role = MagicMock()
mock_role.name = "Admin"
mock_role.permissions = []
mock_user = MagicMock()
mock_user.username = "test-admin"
mock_user.roles = [mock_role]
async def _mock_get_current_user():
return mock_user
app.dependency_overrides[get_current_user] = _mock_get_current_user
yield
client.headers.pop("X-API-Key", None)
app.dependency_overrides = {}
@pytest.fixture
def mock_db():
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import StaticPool
from src.dependencies import get_db
from src.models.mapping import Base
engine = create_engine(
"sqlite:///:memory:",
poolclass=StaticPool,
connect_args={"check_same_thread": False},
)
Base.metadata.create_all(engine)
Session = sessionmaker(bind=engine)
session = Session()
def _get_db_override():
db = Session()
try:
yield db
finally:
db.close()
from src.app import app
app.dependency_overrides[get_db] = _get_db_override
yield session
app.dependency_overrides.pop(get_db, None)
session.close()
@pytest.fixture
def mock_task_manager():
from src.dependencies import get_task_manager
from unittest.mock import AsyncMock
mock_tm = MagicMock()
mock_task = MagicMock()
mock_task.id = "test-task-id-edge"
mock_tm.create_task = AsyncMock(return_value=mock_task)
from src.app import app
app.dependency_overrides[get_task_manager] = lambda: mock_tm
yield mock_tm
app.dependency_overrides.pop(get_task_manager, None)
# ── GET /api/maintenance/settings — not found ─────────────────
class TestSettingsNotFound:
"""GET /api/maintenance/settings when no settings exist."""
# #region test_get_settings_not_found [C:2] [TYPE Function]
# @BRIEF No MaintenanceSettings row -> 404.
# @TEST_EDGE: settings_not_found -> GET /settings returns 404
def test_get_settings_not_found(self, client, mock_db, mock_task_manager):
# Remove the default settings created by the fixture
from src.models.maintenance import MaintenanceSettings
mock_db.query(MaintenanceSettings).delete()
mock_db.commit()
response = client.get("/api/maintenance/settings")
assert response.status_code == 404
assert "not configured" in response.json()["detail"]
# #endregion test_get_settings_not_found
# ── PUT /api/maintenance/settings — upsert (no existing) ──────
class TestSettingsUpsert:
"""PUT /api/maintenance/settings when no settings row exists."""
# #region test_upsert_settings [C:2] [TYPE Function]
# @BRIEF No settings row -> upsert creates one.
def test_upsert_settings(self, client, mock_db, mock_task_manager):
from src.models.maintenance import MaintenanceSettings
mock_db.query(MaintenanceSettings).delete()
mock_db.commit()
response = client.put(
"/api/maintenance/settings",
json={"target_environment_id": "new-env", "display_timezone": "Asia/Tokyo"},
)
assert response.status_code == 200
data = response.json()
assert data["target_environment_id"] == "new-env"
assert data["display_timezone"] == "Asia/Tokyo"
# #endregion test_upsert_settings
# #region test_upsert_settings_all_fields [C:2] [TYPE Function]
# @BRIEF All fields provided -> upsert with complete data.
def test_upsert_settings_all_fields(self, client, mock_db, mock_task_manager):
from src.models.maintenance import MaintenanceSettings
mock_db.query(MaintenanceSettings).delete()
mock_db.commit()
response = client.put(
"/api/maintenance/settings",
json={
"target_environment_id": "env-2",
"display_timezone": "Europe/London",
"banner_template": "Custom: {message}",
"dashboard_scope": "all",
"excluded_dashboard_ids": [1, 2],
"forced_dashboard_ids": [3],
},
)
assert response.status_code == 200
data = response.json()
assert data["target_environment_id"] == "env-2"
assert data["display_timezone"] == "Europe/London"
assert data["banner_template"] == "Custom: {message}"
assert data["dashboard_scope"] == "all"
assert 1 in data["excluded_dashboard_ids"]
assert 3 in data["forced_dashboard_ids"]
# #endregion test_upsert_settings_all_fields
# ── POST /api/maintenance/{id}/end — already completed ────────
class TestEndAlreadyCompleted:
"""POST /api/maintenance/{id}/end when event is already COMPLETED."""
# #region test_end_idempotent_completed [C:2] [TYPE Function]
# @BRIEF Event already COMPLETED -> returns 202 with already_completed status.
def test_end_idempotent_completed(self, client, mock_db, mock_task_manager):
from src.models.maintenance import MaintenanceEvent, MaintenanceEventStatus
event = MaintenanceEvent(
tables=["raw.sales"],
start_time=datetime.now(UTC),
status=MaintenanceEventStatus.COMPLETED,
environment_id="test-env",
task_id="existing-task",
)
mock_db.add(event)
mock_db.commit()
response = client.post(f"/api/maintenance/{event.id}/end")
assert response.status_code == 202
data = response.json()
assert data["status"] == "already_completed"
assert data["task_id"] == "existing-task"
# #endregion test_end_idempotent_completed
# ── POST /api/maintenance/start — edge cases ──────────────────
class TestStartEdgeCases:
"""POST /api/maintenance/start — edge paths."""
# #region test_start_too_far_past [C:2] [TYPE Function]
# @BRIEF start_time more than 1h in the past -> 400.
def test_start_too_far_past(self, client, mock_db, mock_task_manager):
response = client.post(
"/api/maintenance/start",
json={
"tables": ["raw.sales"],
"start_time": (datetime.now(UTC) - timedelta(hours=5)).isoformat(),
"environment_id": "test-env",
},
)
assert response.status_code == 400
assert "too far in the past" in response.json()["detail"]
# #endregion test_start_too_far_past
# #region test_start_with_html_message [C:2] [TYPE Function]
# @BRIEF Message with HTML content -> accepted (HTML will be escaped at render).
def test_start_with_html_message(self, client, mock_db, mock_task_manager):
response = client.post(
"/api/maintenance/start",
json={
"tables": ["raw.sales"],
"start_time": (datetime.now(UTC) + timedelta(hours=1)).isoformat(),
"end_time": (datetime.now(UTC) + timedelta(hours=3)).isoformat(),
"message": "<script>alert('xss')</script>",
"environment_id": "test-env",
},
)
# HTML in message is accepted — rendering layer escapes it
assert response.status_code == 202
# #endregion test_start_with_html_message
# ── POST /api/maintenance/end-all — with env in body ──────────
class TestEndAllWithEnv:
"""POST /api/maintenance/end-all with environment_id in body."""
# #region test_end_all_with_env_body [C:2] [TYPE Function]
# @BRIEF environment_id in JSON body -> passed to task params.
def test_end_all_with_env_body(self, client, mock_db, mock_task_manager):
response = client.post(
"/api/maintenance/end-all",
json={"environment_id": "env-42"},
)
assert response.status_code == 202
data = response.json()
assert data["status"] == "pending"
# Verify environment_id was passed through
call_kwargs = mock_task_manager.create_task.call_args[1]
assert call_kwargs["params"]["environment_id"] == "env-42"
# #endregion test_end_all_with_env_body
# ── GET /api/maintenance/dashboard-banners — with linked states ─
class TestBannersWithLinkedStates:
"""GET /api/maintenance/dashboard-banners with linked dashboard states and events."""
# #region test_banners_with_linked_states [C:2] [TYPE Function]
# @BRIEF Banners with linked MaintenanceDashboardState records -> events aggregated.
def test_banners_with_linked_states(self, client, mock_db, mock_task_manager):
from src.models.maintenance import (
MaintenanceDashboardBanner,
MaintenanceDashboardBannerStatus,
MaintenanceDashboardState,
MaintenanceDashboardStateStatus,
MaintenanceEvent,
MaintenanceEventStatus,
)
event = MaintenanceEvent(
tables=["raw.test"],
start_time=datetime.now(UTC),
end_time=datetime.now(UTC) + timedelta(hours=2),
status=MaintenanceEventStatus.ACTIVE,
environment_id="test-env",
)
mock_db.add(event)
mock_db.flush()
banner = MaintenanceDashboardBanner(
environment_id="test-env",
dashboard_id=201,
chart_id=67890,
banner_text="Linked test",
status=MaintenanceDashboardBannerStatus.ACTIVE,
)
mock_db.add(banner)
mock_db.flush()
state = MaintenanceDashboardState(
banner_id=banner.id,
event_id=event.id,
dashboard_id=201,
status=MaintenanceDashboardStateStatus.ACTIVE,
)
mock_db.add(state)
mock_db.commit()
response = client.get("/api/maintenance/dashboard-banners")
assert response.status_code == 200
data = response.json()
assert isinstance(data, list)
assert len(data) >= 1
banners_for_dash = [b for b in data if b["dashboard_id"] == 201]
assert len(banners_for_dash) == 1
assert len(banners_for_dash[0]["events"]) == 1
assert banners_for_dash[0]["active"] is True
# #endregion test_banners_with_linked_states
# ── GET /api/maintenance/dashboard-banners — no linked states ─
class TestBannersNoLinkedStates:
"""GET /api/maintenance/dashboard-banners with no linked states (just banner)."""
# #region test_banners_no_linked_states [C:2] [TYPE Function]
# @BRIEF Active banner without linked states -> event lists are empty.
def test_banners_no_linked_states(self, client, mock_db, mock_task_manager):
from src.models.maintenance import (
MaintenanceDashboardBanner,
MaintenanceDashboardBannerStatus,
)
banner = MaintenanceDashboardBanner(
environment_id="test-env",
dashboard_id=301,
chart_id=11111,
banner_text="No linked state",
status=MaintenanceDashboardBannerStatus.ACTIVE,
)
mock_db.add(banner)
mock_db.commit()
response = client.get("/api/maintenance/dashboard-banners")
assert response.status_code == 200
data = response.json()
banners_for_dash = [b for b in data if b["dashboard_id"] == 301]
assert len(banners_for_dash) == 1
assert banners_for_dash[0]["events"] == []
# #endregion test_banners_no_linked_states
# #endregion Test.Maintenance.Routes.Edge

View File

@@ -145,6 +145,17 @@ class TestDeleteFile:
class TestDownloadFile:
"""GET /api/storage/download/{category}/{path}"""
def test_success(self):
client, mock_loader = _make_client()
mock_plugin = MagicMock()
mock_plugin.get_file_path.return_value = "/tmp/storage/backups/test.txt"
mock_loader.get_plugin.return_value = mock_plugin
with patch("src.api.routes.storage.FileResponse") as MockFR:
MockFR.return_value = {"status": "ok"}
resp = client.get(f"{P}/download/backups/test.txt")
assert resp.status_code == 200
mock_plugin.get_file_path.assert_called_once_with("backups", "test.txt")
def test_not_found(self):
client, mock_loader = _make_client()
mock_plugin = MagicMock()
@@ -171,6 +182,33 @@ class TestDownloadFile:
class TestGetFileByPath:
"""GET /api/storage/file"""
def test_success_relative_path(self):
client, mock_loader = _make_client()
mock_plugin = MagicMock()
mock_plugin.get_storage_root.return_value = Path("/storage/root")
mock_plugin.validate_path.return_value = Path("/storage/root/test.txt")
mock_loader.get_plugin.return_value = mock_plugin
with patch("src.api.routes.storage.FileResponse") as MockFR, \
patch("pathlib.Path.exists", return_value=True), \
patch("pathlib.Path.is_file", return_value=True):
MockFR.return_value = {"status": "ok"}
resp = client.get(f"{P}/file?path=test.txt")
assert resp.status_code == 200
def test_success_absolute_path(self):
client, mock_loader = _make_client()
mock_plugin = MagicMock()
mock_plugin.validate_path.return_value = Path("/valid/path/file.txt")
mock_loader.get_plugin.return_value = mock_plugin
with patch("src.api.routes.storage.FileResponse") as MockFR, \
patch("pathlib.Path.exists", return_value=True), \
patch("pathlib.Path.is_file", return_value=True), \
patch("pathlib.Path.is_absolute", return_value=True):
MockFR.return_value = {"status": "ok"}
resp = client.get(f"{P}/file?path=/absolute/path/file.txt")
assert resp.status_code == 200
mock_plugin.validate_path.assert_called_once()
def test_missing_path(self):
client, mock_loader = _make_client()
mock_plugin = MagicMock()

View File

@@ -0,0 +1,240 @@
# #region Test.Api.ValidationTasks.Edge [C:3] [TYPE Module] [SEMANTICS test,validation,tasks,edge,coverage]
# @BRIEF Edge-case tests for validation task API — covers uncovered branches from validation_tasks.py.
# @RELATION BINDS_TO -> [ValidationTasksRoutes]
# @TEST_EDGE: parse_url_unexpected_error -> 502 on unexpected Superset error
# @TEST_EDGE: parse_url_dashboard_resolve_fail -> fallback when dashboard title resolution fails
# @TEST_EDGE: delete_task_with_runs -> scheduler.remove_validation_job called with delete_runs
# @TEST_EDGE: parse_dt_invalid_input -> _parse_dt returns None for invalid strings
# @TEST_EDGE: task_service_dep -> _get_task_service builds ValidationTaskService correctly
import os
os.environ.setdefault("DATABASE_URL", "sqlite:///:memory:")
os.environ.setdefault("AUTH_DATABASE_URL", "sqlite:///:memory:")
os.environ.setdefault("SECRET_KEY", "test-secret-key-for-tests")
os.environ.setdefault("DEV_MODE", "true")
import sys
from datetime import datetime
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
_src = str(Path(__file__).resolve().parent.parent.parent / "src")
if _src not in sys.path:
sys.path.insert(0, _src)
def _make_client(overrides: dict | None = None) -> TestClient:
from src.api.routes.validation_tasks import router, _get_task_service
from src.core.database import get_db
from src.dependencies import get_config_manager, get_current_user, get_scheduler_service, get_task_manager
from src.schemas.auth import User, RoleSchema
app = FastAPI()
app.include_router(router, prefix="/validation-tasks")
mock_user = User(
id="admin-1", username="admin", email="admin@x.com",
auth_source="LOCAL",
created_at=__import__("datetime").datetime.now(),
roles=[RoleSchema(id="r1", name="Admin", description="", permissions=[])],
)
app.dependency_overrides[_get_task_service] = lambda: MagicMock()
app.dependency_overrides[get_db] = lambda: MagicMock()
app.dependency_overrides[get_current_user] = lambda: mock_user
app.dependency_overrides[get_config_manager] = lambda: MagicMock()
app.dependency_overrides[get_scheduler_service] = lambda: MagicMock()
app.dependency_overrides[get_task_manager] = lambda: MagicMock()
if overrides:
for dep, fn in overrides.items():
app.dependency_overrides[dep] = fn
return TestClient(app)
# ── _parse_dt edge cases ──────────────────────────────────────
class TestParseDt:
"""Direct test of _parse_dt utility function."""
# #region test_parse_dt_none [C:2] [TYPE Function]
def test_parse_dt_none(self):
from src.api.routes.validation_tasks import _parse_dt
assert _parse_dt(None) is None
# #endregion test_parse_dt_none
# #region test_parse_dt_valid [C:2] [TYPE Function]
def test_parse_dt_valid(self):
from src.api.routes.validation_tasks import _parse_dt
result = _parse_dt("2024-01-15T10:30:00")
assert result is not None
assert result.year == 2024
# #endregion test_parse_dt_valid
# #region test_parse_dt_invalid [C:2] [TYPE Function]
# @BRIEF Invalid string -> returns None.
def test_parse_dt_invalid(self):
from src.api.routes.validation_tasks import _parse_dt
assert _parse_dt("not-a-date") is None
# #endregion test_parse_dt_invalid
# ── _get_task_service — dependency injector ────────────────────
class TestGetTaskService:
"""_get_task_service dependency injector builds ValidationTaskService."""
# #region test_get_task_service_constructs [C:2] [TYPE Function]
# @BRIEF Dependency returns a ValidationTaskService instance.
def test_get_task_service_constructs(self):
from src.api.routes.validation_tasks import _get_task_service
from src.core.database import get_db
from src.dependencies import get_config_manager, get_current_user
mock_db = MagicMock()
mock_config = MagicMock()
mock_user = MagicMock()
mock_user.username = "testuser"
app = FastAPI()
app.dependency_overrides[get_db] = lambda: mock_db
app.dependency_overrides[get_config_manager] = lambda: mock_config
app.dependency_overrides[get_current_user] = lambda: mock_user
# Use TestClient to trigger the dependency injection
client = TestClient(app)
# We just verify the function signature works
from src.services.validation_service import ValidationTaskService
svc = _get_task_service(
db=mock_db,
config_manager=mock_config,
current_user=mock_user,
)
assert isinstance(svc, ValidationTaskService)
# #endregion test_get_task_service_constructs
# ── delete_task with delete_runs ──────────────────────────────
class TestDeleteTaskEdge:
"""DELETE /validation-tasks/{policy_id} — edge paths."""
# #region test_delete_task_with_runs [C:2] [TYPE Function]
# @BRIEF delete_runs=true passes parameter to service.
def test_delete_task_with_runs(self):
mock_svc = MagicMock()
mock_scheduler = MagicMock()
from src.api.routes.validation_tasks import _get_task_service
from src.dependencies import get_scheduler_service
client = _make_client({
_get_task_service: lambda: mock_svc,
get_scheduler_service: lambda: mock_scheduler,
})
resp = client.delete("/validation-tasks/task-1?delete_runs=true")
assert resp.status_code == 204
mock_svc.delete_task.assert_called_with("task-1", delete_runs=True)
mock_scheduler.remove_validation_job.assert_called_with("task-1")
# #endregion test_delete_task_with_runs
# #region test_delete_task_not_found [C:2] [TYPE Function]
# @BRIEF Service raises ValueError -> 404.
def test_delete_task_not_found(self):
mock_svc = MagicMock()
mock_svc.delete_task.side_effect = ValueError("Task not found")
from src.api.routes.validation_tasks import _get_task_service
client = _make_client({_get_task_service: lambda: mock_svc})
resp = client.delete("/validation-tasks/unknown")
assert resp.status_code == 404
# #endregion test_delete_task_not_found
# ── parse_superset_url — edge cases ───────────────────────────
class TestParseSupersetUrlEdge:
"""POST /validation-tasks/parse-url — edge paths."""
PARSE_PAYLOAD = {
"url": "https://superset.example.com/superset/dashboard/1/",
"environment_id": "env-1",
}
# #region test_parse_url_dashboard_resolve_fail [C:2] [TYPE Function]
# @BRIEF Dashboard ID resolved but API call fails -> falls back to str(dashboard_id).
def test_parse_url_dashboard_resolve_fail(self):
mock_config = MagicMock()
mock_config.get_environment.return_value = MagicMock()
mock_extractor = MagicMock()
mock_parsed = MagicMock()
mock_parsed.dashboard_id = 1
mock_parsed.chart_id = None
mock_parsed.query_state = {"native_filters": None, "activeTabs": None, "anchor": None}
mock_parsed.partial_recovery = None
mock_parsed.unresolved_references = None
mock_extractor.parse_superset_link = AsyncMock(return_value=mock_parsed)
mock_client = MagicMock()
mock_client.get_dashboard_detail = AsyncMock(side_effect=RuntimeError("API down"))
mock_extractor.client = mock_client
from src.dependencies import get_config_manager
with patch("src.core.utils.superset_context_extractor.SupersetContextExtractor", return_value=mock_extractor):
client = _make_client({get_config_manager: lambda: mock_config})
resp = client.post("/validation-tasks/parse-url", json=self.PARSE_PAYLOAD)
assert resp.status_code == 200
data = resp.json()
assert data["dashboard_id"] == "1"
assert data["title"] == "1" # fallback str(dashboard_id)
# #endregion test_parse_url_dashboard_resolve_fail
# #region test_parse_url_unexpected_error [C:2] [TYPE Function]
# @BRIEF Unexpected exception during parse -> 502.
def test_parse_url_unexpected_error(self):
mock_config = MagicMock()
mock_config.get_environment.return_value = MagicMock()
mock_extractor = MagicMock()
mock_extractor.parse_superset_link = AsyncMock(side_effect=RuntimeError("Unexpected network failure"))
from src.dependencies import get_config_manager
with patch("src.core.utils.superset_context_extractor.SupersetContextExtractor", return_value=mock_extractor):
client = _make_client({get_config_manager: lambda: mock_config})
resp = client.post("/validation-tasks/parse-url", json=self.PARSE_PAYLOAD)
assert resp.status_code == 502
# #endregion test_parse_url_unexpected_error
# ── trigger_run — edge cases ──────────────────────────────────
class TestTriggerRunEdge:
"""POST /validation-tasks/{policy_id}/run — edge paths."""
# #region test_trigger_run_with_no_run_id [C:2] [TYPE Function]
# @BRIEF Service returns result without run_id -> run_id is None.
def test_trigger_run_with_no_run_id(self):
mock_svc = MagicMock()
mock_svc.trigger_run = AsyncMock(return_value={"task_id": "t-1"})
from src.api.routes.validation_tasks import _get_task_service
client = _make_client({_get_task_service: lambda: mock_svc})
resp = client.post("/validation-tasks/task-1/run")
assert resp.status_code == 200
data = resp.json()
assert data["spawned_task_id"] == "t-1"
assert data["run_id"] is None
# #endregion test_trigger_run_with_no_run_id
# #endregion Test.Api.ValidationTasks.Edge