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:
@@ -126,6 +126,56 @@ class TestHandleListMaintenanceEvents:
|
|||||||
)
|
)
|
||||||
assert "Нет событий" in text
|
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:
|
class TestHandleStartMaintenance:
|
||||||
"""handle_start_maintenance — starting maintenance events."""
|
"""handle_start_maintenance — starting maintenance events."""
|
||||||
|
|||||||
558
backend/tests/api/test_dataset_review_routes_sessions.py
Normal file
558
backend/tests/api/test_dataset_review_routes_sessions.py
Normal 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
|
||||||
@@ -396,4 +396,127 @@ class TestGenerateCommitMessage:
|
|||||||
resp = client.post("/repositories/42/generate-message")
|
resp = client.post("/repositories/42/generate-message")
|
||||||
assert resp.status_code == 400
|
assert resp.status_code == 400
|
||||||
assert "No active LLM provider" in resp.text
|
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
|
# #endregion Test.Api.GitRepoOperationsRoutes
|
||||||
|
|||||||
258
backend/tests/api/test_llm_edge.py
Normal file
258
backend/tests/api/test_llm_edge.py
Normal 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
|
||||||
362
backend/tests/api/test_maintenance_routes_edge.py
Normal file
362
backend/tests/api/test_maintenance_routes_edge.py
Normal 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
|
||||||
@@ -145,6 +145,17 @@ class TestDeleteFile:
|
|||||||
class TestDownloadFile:
|
class TestDownloadFile:
|
||||||
"""GET /api/storage/download/{category}/{path}"""
|
"""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):
|
def test_not_found(self):
|
||||||
client, mock_loader = _make_client()
|
client, mock_loader = _make_client()
|
||||||
mock_plugin = MagicMock()
|
mock_plugin = MagicMock()
|
||||||
@@ -171,6 +182,33 @@ class TestDownloadFile:
|
|||||||
class TestGetFileByPath:
|
class TestGetFileByPath:
|
||||||
"""GET /api/storage/file"""
|
"""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):
|
def test_missing_path(self):
|
||||||
client, mock_loader = _make_client()
|
client, mock_loader = _make_client()
|
||||||
mock_plugin = MagicMock()
|
mock_plugin = MagicMock()
|
||||||
|
|||||||
240
backend/tests/api/test_validation_tasks_edge.py
Normal file
240
backend/tests/api/test_validation_tasks_edge.py
Normal 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
|
||||||
@@ -856,3 +856,44 @@ class TestDeleteEnvironment:
|
|||||||
assert mgr.config.settings.default_environment_id != "env1"
|
assert mgr.config.settings.default_environment_id != "env1"
|
||||||
# #endregion test_delete_updates_default_setting
|
# #endregion test_delete_updates_default_setting
|
||||||
#endregion Test.Core.ConfigManager
|
#endregion Test.Core.ConfigManager
|
||||||
|
|
||||||
|
|
||||||
|
# #region TestGlobalSettingsValidators [C:2] [TYPE Module]
|
||||||
|
# @BRIEF Direct tests for GlobalSettings field validators.
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
class TestAppTimezoneValidator:
|
||||||
|
# #region test_valid_timezone [C:1] [TYPE Function]
|
||||||
|
def test_valid_timezone(self):
|
||||||
|
gs = GlobalSettings(app_timezone="Europe/Moscow")
|
||||||
|
assert gs.app_timezone == "Europe/Moscow"
|
||||||
|
# #endregion test_valid_timezone
|
||||||
|
|
||||||
|
# #region test_invalid_timezone [C:1] [TYPE Function]
|
||||||
|
def test_invalid_timezone(self):
|
||||||
|
with pytest.raises(ValueError, match="Invalid IANA timezone"):
|
||||||
|
GlobalSettings(app_timezone="Not/A/Timezone")
|
||||||
|
# #endregion test_invalid_timezone
|
||||||
|
|
||||||
|
|
||||||
|
class TestAllowedLanguagesValidator:
|
||||||
|
# #region test_valid_languages [C:1] [TYPE Function]
|
||||||
|
def test_valid_languages(self):
|
||||||
|
gs = GlobalSettings(allowed_languages=["en", "fr", "de", "zh-CN", "pt-BR"])
|
||||||
|
assert "en" in gs.allowed_languages
|
||||||
|
assert "zh-CN" in gs.allowed_languages
|
||||||
|
# #endregion test_valid_languages
|
||||||
|
|
||||||
|
# #region test_empty_language_rejected [C:1] [TYPE Function]
|
||||||
|
def test_empty_language_rejected(self):
|
||||||
|
with pytest.raises(ValueError, match="Empty language code"):
|
||||||
|
GlobalSettings(allowed_languages=["en", "", "fr"])
|
||||||
|
# #endregion test_empty_language_rejected
|
||||||
|
|
||||||
|
# #region test_invalid_bcp47_rejected [C:1] [TYPE Function]
|
||||||
|
def test_invalid_bcp47_rejected(self):
|
||||||
|
with pytest.raises(ValueError, match="Invalid BCP-47"):
|
||||||
|
GlobalSettings(allowed_languages=["en", "123"])
|
||||||
|
# #endregion test_invalid_bcp47_rejected
|
||||||
|
# #endregion TestGlobalSettingsValidators
|
||||||
|
|||||||
@@ -1,23 +1,27 @@
|
|||||||
# #region Test.GitPlugin [C:3] [TYPE Module] [SEMANTICS test, git, plugin, sync, deploy]
|
# #region Test.GitPlugin [C:3] [TYPE Module] [SEMANTICS test, git, plugin, sync, deploy, helpers, coverage]
|
||||||
# @BRIEF Verify GitPlugin contracts — helpers, execute, _handle_sync, _handle_deploy, _get_env.
|
# @BRIEF Comprehensive tests covering GitPluginModule — helpers, execute, _handle_sync, _handle_deploy, _get_env, edge cases.
|
||||||
# @RELATION BINDS_TO -> [GitPluginModule]
|
# @RELATION BINDS_TO -> [GitPluginModule]
|
||||||
# @TEST_EDGE: empty_zip -> Raises ValueError
|
# @TEST_EDGE: empty_zip -> Raises ValueError
|
||||||
# @TEST_EDGE: env_not_found -> Raises ValueError
|
# @TEST_EDGE: env_not_found -> Raises ValueError
|
||||||
# @TEST_EDGE: unknown_operation -> Raises ValueError
|
# @TEST_EDGE: unknown_operation -> Raises ValueError
|
||||||
# @TEST_EDGE: backup_restore_on_failure -> Backup restored on unzip error
|
# @TEST_EDGE: backup_restore_on_failure -> Backup restored on unzip error
|
||||||
|
# @TEST_EDGE: deploy_missing_env_id -> Raises ValueError
|
||||||
|
# @TEST_EDGE: init_fallback -> Falls back to new ConfigManager when import fails
|
||||||
|
import asyncio
|
||||||
import io
|
import io
|
||||||
import os
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
import tempfile
|
import tempfile
|
||||||
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import zipfile
|
import zipfile
|
||||||
import time
|
|
||||||
import pytest
|
|
||||||
from unittest.mock import AsyncMock, MagicMock, patch, PropertyMock
|
from unittest.mock import AsyncMock, MagicMock, patch, PropertyMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
from src.plugins.git_plugin import (
|
from src.plugins.git_plugin import (
|
||||||
_create_sync_backup, _delete_managed_files, _extract_zip_to_repo,
|
_create_sync_backup, _delete_managed_files, _extract_zip_to_repo,
|
||||||
_restore_sync_backup, _pack_deploy_zip,
|
_restore_sync_backup, _pack_deploy_zip, GitPlugin,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -49,10 +53,18 @@ def _create_zip(files: dict[str, str], root_folder: str = "export") -> bytes:
|
|||||||
return buf.getvalue()
|
return buf.getvalue()
|
||||||
|
|
||||||
|
|
||||||
# ── Helper Tests ──
|
def _make_plugin():
|
||||||
|
"""Helper: create GitPlugin with dependencies patched so config_manager is a mock."""
|
||||||
|
mock_cm = MagicMock()
|
||||||
|
with patch('src.dependencies.config_manager', mock_cm):
|
||||||
|
plugin = GitPlugin()
|
||||||
|
return plugin, mock_cm
|
||||||
|
|
||||||
|
|
||||||
|
# ── Sync Helper Tests ──
|
||||||
|
|
||||||
class TestSyncHelpers:
|
class TestSyncHelpers:
|
||||||
"""Verify _sync_helpers functions."""
|
"""Verify _sync_helpers functions comprehensively."""
|
||||||
|
|
||||||
def test_create_and_restore_backup(self, temp_repo, temp_backup):
|
def test_create_and_restore_backup(self, temp_repo, temp_backup):
|
||||||
"""Happy: create backup, then restore it."""
|
"""Happy: create backup, then restore it."""
|
||||||
@@ -88,6 +100,11 @@ class TestSyncHelpers:
|
|||||||
_create_sync_backup(temp_repo, temp_backup, ["nonexistent"], ["no.yaml"])
|
_create_sync_backup(temp_repo, temp_backup, ["nonexistent"], ["no.yaml"])
|
||||||
# No crash is success
|
# No crash is success
|
||||||
|
|
||||||
|
def test_backup_non_existent_file_skipped(self, temp_repo, temp_backup):
|
||||||
|
"""Edge: non-existent file path skipped during backup."""
|
||||||
|
_create_sync_backup(temp_repo, temp_backup, [], ["no_such_file.yaml"])
|
||||||
|
# No crash
|
||||||
|
|
||||||
def test_delete_managed_files(self, temp_repo):
|
def test_delete_managed_files(self, temp_repo):
|
||||||
"""Happy: delete managed files."""
|
"""Happy: delete managed files."""
|
||||||
(temp_repo / "dashboards").mkdir(parents=True)
|
(temp_repo / "dashboards").mkdir(parents=True)
|
||||||
@@ -103,6 +120,16 @@ class TestSyncHelpers:
|
|||||||
_delete_managed_files(temp_repo, ["does-not-exist"], ["no.yaml"])
|
_delete_managed_files(temp_repo, ["does-not-exist"], ["no.yaml"])
|
||||||
# No crash
|
# No crash
|
||||||
|
|
||||||
|
def test_delete_file_only(self, temp_repo):
|
||||||
|
"""Edge: delete only files, not dirs."""
|
||||||
|
(temp_repo / "some_dir").mkdir(parents=True)
|
||||||
|
(temp_repo / "some_dir" / "file.txt").write_text("data")
|
||||||
|
(temp_repo / "metadata.yaml").write_text("version: 1")
|
||||||
|
|
||||||
|
_delete_managed_files(temp_repo, [], ["metadata.yaml"])
|
||||||
|
assert not (temp_repo / "metadata.yaml").exists()
|
||||||
|
assert (temp_repo / "some_dir").exists() # dirs untouched
|
||||||
|
|
||||||
def test_extract_zip_to_repo(self, temp_repo):
|
def test_extract_zip_to_repo(self, temp_repo):
|
||||||
"""Happy: extract zip to repository."""
|
"""Happy: extract zip to repository."""
|
||||||
zip_bytes = _create_zip({
|
zip_bytes = _create_zip({
|
||||||
@@ -118,11 +145,19 @@ class TestSyncHelpers:
|
|||||||
"""Negative: empty zip raises ValueError."""
|
"""Negative: empty zip raises ValueError."""
|
||||||
buf = io.BytesIO()
|
buf = io.BytesIO()
|
||||||
with zipfile.ZipFile(buf, "w") as zf:
|
with zipfile.ZipFile(buf, "w") as zf:
|
||||||
pass
|
pass # empty zip
|
||||||
buf.seek(0)
|
buf.seek(0)
|
||||||
with pytest.raises(ValueError, match="empty"):
|
with pytest.raises(ValueError, match="empty"):
|
||||||
_extract_zip_to_repo(buf.getvalue(), temp_repo)
|
_extract_zip_to_repo(buf.getvalue(), temp_repo)
|
||||||
|
|
||||||
|
def test_extract_zip_nested_dirs(self, temp_repo):
|
||||||
|
"""Edge: extract zip with nested directory structure."""
|
||||||
|
zip_bytes = _create_zip({
|
||||||
|
"dashboards/sub/child.json": '{"nested": true}',
|
||||||
|
})
|
||||||
|
_extract_zip_to_repo(zip_bytes, temp_repo)
|
||||||
|
assert (temp_repo / "dashboards" / "sub" / "child.json").exists()
|
||||||
|
|
||||||
def test_restore_backup_non_existent(self, temp_repo, temp_backup):
|
def test_restore_backup_non_existent(self, temp_repo, temp_backup):
|
||||||
"""Edge: restore when backup dirs don't exist."""
|
"""Edge: restore when backup dirs don't exist."""
|
||||||
_restore_sync_backup(temp_repo, temp_backup, ["missing"], ["no.yaml"])
|
_restore_sync_backup(temp_repo, temp_backup, ["missing"], ["no.yaml"])
|
||||||
@@ -138,16 +173,24 @@ class TestSyncHelpers:
|
|||||||
_restore_sync_backup(temp_repo, temp_backup, ["dashboards"], [])
|
_restore_sync_backup(temp_repo, temp_backup, ["dashboards"], [])
|
||||||
assert (temp_repo / "dashboards" / "dash1.json").read_text() == "new"
|
assert (temp_repo / "dashboards" / "dash1.json").read_text() == "new"
|
||||||
|
|
||||||
|
def test_restore_backup_dir_with_file_in_subdir(self, temp_repo, temp_backup):
|
||||||
|
"""Edge: restore creates parent dirs for files."""
|
||||||
|
(temp_backup / "subdir").mkdir(parents=True)
|
||||||
|
(temp_backup / "subdir" / "deep.txt").write_text("deep")
|
||||||
|
_restore_sync_backup(temp_repo, temp_backup, [], ["subdir/deep.txt"])
|
||||||
|
assert (temp_repo / "subdir" / "deep.txt").exists()
|
||||||
|
assert (temp_repo / "subdir" / "deep.txt").read_text() == "deep"
|
||||||
|
|
||||||
|
|
||||||
class TestDeployHelpers:
|
class TestDeployHelpers:
|
||||||
"""Verify _deploy_helpers functions."""
|
"""Verify _deploy_helpers functions comprehensively."""
|
||||||
|
|
||||||
def test_pack_deploy_zip(self, temp_repo):
|
def test_pack_deploy_zip(self, temp_repo):
|
||||||
"""Happy: pack repo files into zip."""
|
"""Happy: pack repo files into zip."""
|
||||||
(temp_repo / "dashboards").mkdir(parents=True)
|
(temp_repo / "dashboards").mkdir(parents=True)
|
||||||
(temp_repo / "dashboards" / "dash1.json").write_text('{"id": 1}')
|
(temp_repo / "dashboards" / "dash1.json").write_text('{"id": 1}')
|
||||||
(temp_repo / "charts" / "chart1.json").mkdir(parents=True)
|
(temp_repo / "charts").mkdir(parents=True)
|
||||||
(temp_repo / "charts" / "chart1.json" / ".gitkeep").write_text("")
|
(temp_repo / "charts" / "chart1.json").write_text('{"id": 2}')
|
||||||
|
|
||||||
buf = io.BytesIO()
|
buf = io.BytesIO()
|
||||||
_pack_deploy_zip(temp_repo, "export", buf)
|
_pack_deploy_zip(temp_repo, "export", buf)
|
||||||
@@ -155,8 +198,7 @@ class TestDeployHelpers:
|
|||||||
with zipfile.ZipFile(buf) as zf:
|
with zipfile.ZipFile(buf) as zf:
|
||||||
names = zf.namelist()
|
names = zf.namelist()
|
||||||
assert any("dash1.json" in n for n in names)
|
assert any("dash1.json" in n for n in names)
|
||||||
# .git dirs should be excluded
|
assert any("chart1.json" in n for n in names)
|
||||||
assert not any(".git" in n for n in names)
|
|
||||||
|
|
||||||
def test_pack_zip_skips_git(self, temp_repo):
|
def test_pack_zip_skips_git(self, temp_repo):
|
||||||
"""Edge: .git directories excluded from zip."""
|
"""Edge: .git directories excluded from zip."""
|
||||||
@@ -173,16 +215,102 @@ class TestDeployHelpers:
|
|||||||
assert not any(n.startswith(".git") for n in names)
|
assert not any(n.startswith(".git") for n in names)
|
||||||
assert any("dash1.json" in n for n in names)
|
assert any("dash1.json" in n for n in names)
|
||||||
|
|
||||||
|
def test_pack_zip_empty_repo(self, temp_repo):
|
||||||
|
"""Edge: empty repo results in zip with root only."""
|
||||||
|
buf = io.BytesIO()
|
||||||
|
_pack_deploy_zip(temp_repo, "export", buf)
|
||||||
|
buf.seek(0)
|
||||||
|
with zipfile.ZipFile(buf) as zf:
|
||||||
|
names = zf.namelist()
|
||||||
|
assert len(names) == 0
|
||||||
|
|
||||||
|
def test_pack_zip_skips_existing_zips(self, temp_repo):
|
||||||
|
"""Edge: .zip files excluded from deploy zip."""
|
||||||
|
(temp_repo / "dashboards").mkdir(parents=True)
|
||||||
|
(temp_repo / "dashboards" / "dash1.json").write_text("{}")
|
||||||
|
(temp_repo / "deploy_1.zip").write_text("fake zip content")
|
||||||
|
|
||||||
|
buf = io.BytesIO()
|
||||||
|
_pack_deploy_zip(temp_repo, "export", buf)
|
||||||
|
buf.seek(0)
|
||||||
|
with zipfile.ZipFile(buf) as zf:
|
||||||
|
names = zf.namelist()
|
||||||
|
assert not any(n.endswith(".zip") for n in names)
|
||||||
|
assert any("dash1.json" in n for n in names)
|
||||||
|
|
||||||
|
def test_pack_zip_skips_git_files(self, temp_repo):
|
||||||
|
"""Edge: files named .git excluded."""
|
||||||
|
(temp_repo / "dashboards").mkdir(parents=True)
|
||||||
|
(temp_repo / "dashboards" / "dash1.json").write_text("{}")
|
||||||
|
(temp_repo / ".git").write_text("file named .git")
|
||||||
|
|
||||||
|
buf = io.BytesIO()
|
||||||
|
_pack_deploy_zip(temp_repo, "export", buf)
|
||||||
|
buf.seek(0)
|
||||||
|
with zipfile.ZipFile(buf) as zf:
|
||||||
|
names = zf.namelist()
|
||||||
|
assert not any(".git" in n for n in names)
|
||||||
|
|
||||||
|
|
||||||
# ── GitPlugin Class Tests ──
|
# ── GitPlugin Class Tests ──
|
||||||
|
|
||||||
class TestGitPlugin:
|
class TestGitPluginInit:
|
||||||
"""Verify GitPlugin methods."""
|
"""Verify GitPlugin.__init__ behaviors."""
|
||||||
|
|
||||||
|
def test_init_with_dependencies(self):
|
||||||
|
"""Happy: init fetches config_manager from src.dependencies."""
|
||||||
|
mock_cm = MagicMock()
|
||||||
|
with patch('src.dependencies.config_manager', mock_cm):
|
||||||
|
plugin = GitPlugin()
|
||||||
|
assert plugin.config_manager is mock_cm
|
||||||
|
assert plugin.git_service is not None
|
||||||
|
|
||||||
|
def test_init_fallback_config(self):
|
||||||
|
"""Negative: when import fails, falls back to new ConfigManager."""
|
||||||
|
import sys
|
||||||
|
import types
|
||||||
|
saved = sys.modules.pop('src.dependencies', None)
|
||||||
|
try:
|
||||||
|
fake = types.ModuleType('src.dependencies')
|
||||||
|
fake.__package__ = 'src'
|
||||||
|
sys.modules['src.dependencies'] = fake
|
||||||
|
with patch('src.plugins.git_plugin.ConfigManager') as MockCM:
|
||||||
|
instance = MagicMock()
|
||||||
|
MockCM.return_value = instance
|
||||||
|
plugin = GitPlugin()
|
||||||
|
assert plugin.config_manager is instance
|
||||||
|
finally:
|
||||||
|
if saved:
|
||||||
|
sys.modules['src.dependencies'] = saved
|
||||||
|
else:
|
||||||
|
sys.modules.pop('src.dependencies', None)
|
||||||
|
|
||||||
|
def test_init_retry_on_exception(self):
|
||||||
|
"""Edge: other exception during import also triggers fallback."""
|
||||||
|
import sys
|
||||||
|
import types
|
||||||
|
saved = sys.modules.pop('src.dependencies', None)
|
||||||
|
try:
|
||||||
|
fake = types.ModuleType('src.dependencies')
|
||||||
|
fake.__package__ = 'src'
|
||||||
|
sys.modules['src.dependencies'] = fake
|
||||||
|
with patch('src.plugins.git_plugin.ConfigManager') as MockCM:
|
||||||
|
instance = MagicMock()
|
||||||
|
MockCM.return_value = instance
|
||||||
|
plugin = GitPlugin()
|
||||||
|
assert plugin.config_manager is instance
|
||||||
|
finally:
|
||||||
|
if saved:
|
||||||
|
sys.modules['src.dependencies'] = saved
|
||||||
|
else:
|
||||||
|
sys.modules.pop('src.dependencies', None)
|
||||||
|
|
||||||
|
|
||||||
|
class TestGitPluginProperties:
|
||||||
|
"""Verify static properties."""
|
||||||
|
|
||||||
def test_properties(self):
|
def test_properties(self):
|
||||||
"""Verify static properties."""
|
plugin, _ = _make_plugin()
|
||||||
from src.plugins.git_plugin import GitPlugin
|
|
||||||
plugin = GitPlugin()
|
|
||||||
assert plugin.id == "git-integration"
|
assert plugin.id == "git-integration"
|
||||||
assert plugin.name == "Git Integration"
|
assert plugin.name == "Git Integration"
|
||||||
assert plugin.description == "Version control for Superset dashboards"
|
assert plugin.description == "Version control for Superset dashboards"
|
||||||
@@ -190,9 +318,7 @@ class TestGitPlugin:
|
|||||||
assert plugin.ui_route == "/git"
|
assert plugin.ui_route == "/git"
|
||||||
|
|
||||||
def test_get_schema(self):
|
def test_get_schema(self):
|
||||||
"""Verify get_schema returns correct JSON schema."""
|
plugin, _ = _make_plugin()
|
||||||
from src.plugins.git_plugin import GitPlugin
|
|
||||||
plugin = GitPlugin()
|
|
||||||
schema = plugin.get_schema()
|
schema = plugin.get_schema()
|
||||||
assert schema["type"] == "object"
|
assert schema["type"] == "object"
|
||||||
assert "operation" in schema["properties"]
|
assert "operation" in schema["properties"]
|
||||||
@@ -201,168 +327,401 @@ class TestGitPlugin:
|
|||||||
assert "operation" in schema["required"]
|
assert "operation" in schema["required"]
|
||||||
|
|
||||||
def test_initialize(self):
|
def test_initialize(self):
|
||||||
"""Verify initialize doesn't crash."""
|
plugin, _ = _make_plugin()
|
||||||
from src.plugins.git_plugin import GitPlugin
|
|
||||||
plugin = GitPlugin()
|
|
||||||
import asyncio
|
|
||||||
asyncio.run(plugin.initialize()) # should not raise
|
asyncio.run(plugin.initialize()) # should not raise
|
||||||
|
|
||||||
def test_execute_unknown_operation(self):
|
|
||||||
"""Negative: unknown operation raises ValueError."""
|
class TestGitPluginExecute:
|
||||||
from src.plugins.git_plugin import GitPlugin
|
"""Verify GitPlugin.execute."""
|
||||||
plugin = GitPlugin()
|
|
||||||
import asyncio
|
def test_unknown_operation_raises(self):
|
||||||
|
plugin, _ = _make_plugin()
|
||||||
with pytest.raises(ValueError, match="Unknown operation"):
|
with pytest.raises(ValueError, match="Unknown operation"):
|
||||||
asyncio.run(plugin.execute({"operation": "unknown", "dashboard_id": 1}))
|
asyncio.run(plugin.execute({"operation": "unknown", "dashboard_id": 1}))
|
||||||
|
|
||||||
def test_execute_history(self):
|
def test_history_operation(self):
|
||||||
"""Happy: history operation returns success."""
|
plugin, _ = _make_plugin()
|
||||||
from src.plugins.git_plugin import GitPlugin
|
|
||||||
plugin = GitPlugin()
|
|
||||||
import asyncio
|
|
||||||
result = asyncio.run(plugin.execute({"operation": "history", "dashboard_id": 1}))
|
result = asyncio.run(plugin.execute({"operation": "history", "dashboard_id": 1}))
|
||||||
assert result["status"] == "success"
|
assert result["status"] == "success"
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_execute_sync(self):
|
async def test_sync_operation(self):
|
||||||
"""Happy: sync operation dispatches to _handle_sync."""
|
plugin, _ = _make_plugin()
|
||||||
from src.plugins.git_plugin import GitPlugin
|
|
||||||
|
|
||||||
with patch.object(GitPlugin, '_handle_sync', new=AsyncMock(return_value={"status": "success"})):
|
with patch.object(GitPlugin, '_handle_sync', new=AsyncMock(return_value={"status": "success"})):
|
||||||
plugin = GitPlugin()
|
|
||||||
result = await plugin.execute({"operation": "sync", "dashboard_id": 1})
|
result = await plugin.execute({"operation": "sync", "dashboard_id": 1})
|
||||||
assert result["status"] == "success"
|
assert result["status"] == "success"
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_execute_deploy(self):
|
async def test_deploy_operation(self):
|
||||||
"""Happy: deploy operation dispatches to _handle_deploy."""
|
plugin, _ = _make_plugin()
|
||||||
from src.plugins.git_plugin import GitPlugin
|
|
||||||
|
|
||||||
with patch.object(GitPlugin, '_handle_deploy', new=AsyncMock(return_value={"status": "success"})):
|
with patch.object(GitPlugin, '_handle_deploy', new=AsyncMock(return_value={"status": "success"})):
|
||||||
plugin = GitPlugin()
|
|
||||||
result = await plugin.execute({"operation": "deploy", "dashboard_id": 1, "environment_id": "env-1"})
|
result = await plugin.execute({"operation": "deploy", "dashboard_id": 1, "environment_id": "env-1"})
|
||||||
assert result["status"] == "success"
|
assert result["status"] == "success"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_execute_with_task_context(self):
|
||||||
|
"""Edge: execute with TaskContext logging."""
|
||||||
|
plugin, _ = _make_plugin()
|
||||||
|
mock_ctx = MagicMock()
|
||||||
|
mock_ctx.logger = MagicMock()
|
||||||
|
mock_ctx.logger.with_source.return_value = MagicMock()
|
||||||
|
|
||||||
class TestGetEnv:
|
with patch.object(GitPlugin, '_handle_sync', new=AsyncMock(return_value={"status": "success"})):
|
||||||
"""Verify _get_env method."""
|
result = await plugin.execute(
|
||||||
|
{"operation": "sync", "dashboard_id": 1, "source_env_id": "src-env"},
|
||||||
|
context=mock_ctx,
|
||||||
|
)
|
||||||
|
assert result["status"] == "success"
|
||||||
|
|
||||||
def test_get_env_from_config(self):
|
def test_execute_no_operation_key(self):
|
||||||
"""Happy: get env from ConfigManager."""
|
"""Negative: missing operation key returns None branch."""
|
||||||
from src.plugins.git_plugin import GitPlugin
|
plugin, _ = _make_plugin()
|
||||||
plugin = GitPlugin()
|
with pytest.raises(ValueError, match="Unknown operation"):
|
||||||
|
asyncio.run(plugin.execute({"dashboard_id": 1}))
|
||||||
|
|
||||||
|
|
||||||
|
def _mock_log():
|
||||||
|
"""Create a mock log object for _handle_sync/_handle_deploy."""
|
||||||
|
log = MagicMock()
|
||||||
|
log.with_source.return_value = log
|
||||||
|
log.info = MagicMock()
|
||||||
|
log.error = MagicMock()
|
||||||
|
log.warning = MagicMock()
|
||||||
|
log.debug = MagicMock()
|
||||||
|
return log
|
||||||
|
|
||||||
|
|
||||||
|
class TestHandleSync:
|
||||||
|
"""Verify _handle_sync method."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_handle_sync_success(self):
|
||||||
|
"""Happy: full sync flow succeeds."""
|
||||||
|
plugin, mock_cm = _make_plugin()
|
||||||
|
log = _mock_log()
|
||||||
|
|
||||||
|
# Mock dependencies
|
||||||
|
mock_env = MagicMock()
|
||||||
|
mock_env.name = "Test Env"
|
||||||
|
mock_cm.get_environment.return_value = mock_env
|
||||||
|
|
||||||
|
with patch('src.plugins.git_plugin.run_blocking') as mock_run_blocking:
|
||||||
|
# Mock git repo
|
||||||
|
tmpdir = tempfile.mkdtemp()
|
||||||
|
mock_repo = MagicMock()
|
||||||
|
mock_repo.working_dir = tmpdir
|
||||||
|
mock_repo.git = MagicMock()
|
||||||
|
mock_repo.git.add = MagicMock()
|
||||||
|
|
||||||
|
# Setup run_blocking side effects based on kind
|
||||||
|
async def run_blocking_side(kind='file', fn=None, **kwargs):
|
||||||
|
if kind == 'git' and fn == plugin.git_service.get_repo:
|
||||||
|
return mock_repo
|
||||||
|
if kind == 'git' and fn == mock_repo.git.add:
|
||||||
|
return None
|
||||||
|
if fn.__name__ == '_create_sync_backup':
|
||||||
|
return None
|
||||||
|
if fn.__name__ == '_delete_managed_files':
|
||||||
|
return None
|
||||||
|
if fn.__name__ == '_extract_zip_to_repo':
|
||||||
|
return None
|
||||||
|
if fn.__name__ == 'rmtree':
|
||||||
|
return None
|
||||||
|
return None
|
||||||
|
|
||||||
|
mock_run_blocking.side_effect = run_blocking_side
|
||||||
|
|
||||||
|
# Mock SupersetClient
|
||||||
|
with patch('src.plugins.git_plugin.SupersetClient') as MockSupersetClient:
|
||||||
|
mock_client = MagicMock()
|
||||||
|
MockSupersetClient.return_value = mock_client
|
||||||
|
mock_client.authenticate = AsyncMock()
|
||||||
|
mock_client.export_dashboard = AsyncMock(return_value=(b"zip data", "20240101"))
|
||||||
|
|
||||||
|
result = await plugin._handle_sync(1, log=log, git_log=log, superset_log=log)
|
||||||
|
|
||||||
|
assert result["status"] == "success"
|
||||||
|
assert "synced" in result["message"]
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_handle_sync_backup_restore_on_failure(self):
|
||||||
|
"""Negative: on unzip failure, backup is restored and error re-raised."""
|
||||||
|
plugin, mock_cm = _make_plugin()
|
||||||
|
log = _mock_log()
|
||||||
|
|
||||||
mock_env = MagicMock()
|
mock_env = MagicMock()
|
||||||
mock_env.name = "Test Env"
|
mock_env.name = "Test Env"
|
||||||
mock_env.id = "env-1"
|
mock_cm.get_environment.return_value = mock_env
|
||||||
|
|
||||||
with patch.object(plugin.config_manager, 'get_environment', return_value=mock_env):
|
with patch('src.plugins.git_plugin.run_blocking') as mock_run_blocking:
|
||||||
result = plugin._get_env("env-1")
|
mock_repo = MagicMock()
|
||||||
assert result.name == "Test Env"
|
mock_repo.working_dir = tempfile.mkdtemp()
|
||||||
|
mock_repo.git = MagicMock()
|
||||||
|
|
||||||
|
async def run_blocking_side(kind='file', fn=None, **kwargs):
|
||||||
|
if kind == 'git' and fn == plugin.git_service.get_repo:
|
||||||
|
return mock_repo
|
||||||
|
if fn.__name__ == '_extract_zip_to_repo':
|
||||||
|
raise ValueError("Unzip failed")
|
||||||
|
if fn.__name__ == '_restore_sync_backup':
|
||||||
|
return None
|
||||||
|
return None
|
||||||
|
|
||||||
|
mock_run_blocking.side_effect = run_blocking_side
|
||||||
|
|
||||||
|
with patch('src.plugins.git_plugin.SupersetClient') as MockSupersetClient:
|
||||||
|
mock_client = MagicMock()
|
||||||
|
MockSupersetClient.return_value = mock_client
|
||||||
|
mock_client.authenticate = AsyncMock()
|
||||||
|
mock_client.export_dashboard = AsyncMock(return_value=(b"zip", "ts"))
|
||||||
|
|
||||||
|
# backup_dir needs to exist for restore branch
|
||||||
|
with patch('pathlib.Path.exists', return_value=True):
|
||||||
|
with pytest.raises(ValueError, match="Unzip failed"):
|
||||||
|
await plugin._handle_sync(1, log=log, git_log=log, superset_log=log)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_handle_sync_dashboard_id_string(self):
|
||||||
|
"""Edge: sync with string dashboard_id."""
|
||||||
|
plugin, mock_cm = _make_plugin()
|
||||||
|
log = _mock_log()
|
||||||
|
mock_env = MagicMock()
|
||||||
|
mock_env.name = "Test"
|
||||||
|
mock_cm.get_environment.return_value = mock_env
|
||||||
|
|
||||||
|
with patch('src.plugins.git_plugin.run_blocking') as mock_run_blocking:
|
||||||
|
mock_repo = MagicMock()
|
||||||
|
mock_repo.working_dir = tempfile.mkdtemp()
|
||||||
|
mock_repo.git = MagicMock()
|
||||||
|
|
||||||
|
async def rb_side(kind='file', fn=None, **kwargs):
|
||||||
|
if kind == 'git' and fn == plugin.git_service.get_repo:
|
||||||
|
return mock_repo
|
||||||
|
return None
|
||||||
|
|
||||||
|
mock_run_blocking.side_effect = rb_side
|
||||||
|
|
||||||
|
with patch('src.plugins.git_plugin.SupersetClient') as MockSupersetClient:
|
||||||
|
mock_client = MagicMock()
|
||||||
|
MockSupersetClient.return_value = mock_client
|
||||||
|
mock_client.authenticate = AsyncMock()
|
||||||
|
mock_client.export_dashboard = AsyncMock(return_value=(b"zip", "ts"))
|
||||||
|
|
||||||
|
result = await plugin._handle_sync(1, log=log, git_log=log, superset_log=log)
|
||||||
|
assert result["status"] == "success"
|
||||||
|
|
||||||
|
|
||||||
|
class TestHandleDeploy:
|
||||||
|
"""Verify _handle_deploy method."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_handle_deploy_success(self):
|
||||||
|
"""Happy: full deploy flow succeeds."""
|
||||||
|
plugin, mock_cm = _make_plugin()
|
||||||
|
log = _mock_log()
|
||||||
|
|
||||||
|
mock_env = MagicMock()
|
||||||
|
mock_env.name = "Target Env"
|
||||||
|
mock_cm.get_environment.return_value = mock_env
|
||||||
|
|
||||||
|
with patch('src.plugins.git_plugin.run_blocking') as mock_run_blocking:
|
||||||
|
mock_repo = MagicMock()
|
||||||
|
mock_repo.working_dir = tempfile.mkdtemp()
|
||||||
|
mock_repo.git = MagicMock()
|
||||||
|
|
||||||
|
async def rb_side(kind='file', fn=None, **kwargs):
|
||||||
|
if kind == 'git' and fn == plugin.git_service.get_repo:
|
||||||
|
return mock_repo
|
||||||
|
if fn.__name__ == '_pack_deploy_zip':
|
||||||
|
return None
|
||||||
|
if fn == Path.write_bytes:
|
||||||
|
return None
|
||||||
|
if fn == Path.exists:
|
||||||
|
return True
|
||||||
|
if fn == os.remove:
|
||||||
|
return None
|
||||||
|
return None
|
||||||
|
|
||||||
|
mock_run_blocking.side_effect = rb_side
|
||||||
|
|
||||||
|
with patch('src.plugins.git_plugin.SupersetClient') as MockSupersetClient:
|
||||||
|
mock_client = MagicMock()
|
||||||
|
MockSupersetClient.return_value = mock_client
|
||||||
|
mock_client.authenticate = AsyncMock()
|
||||||
|
mock_client.import_dashboard = AsyncMock(return_value={"import": "ok"})
|
||||||
|
|
||||||
|
result = await plugin._handle_deploy(1, "env-1", log=log, git_log=log, superset_log=log)
|
||||||
|
assert result["status"] == "success"
|
||||||
|
assert "deployed" in result["message"].lower()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_handle_deploy_missing_env_id(self):
|
||||||
|
"""Negative: empty env_id raises ValueError."""
|
||||||
|
plugin, _ = _make_plugin()
|
||||||
|
log = _mock_log()
|
||||||
|
with pytest.raises(ValueError, match="Target environment ID required"):
|
||||||
|
await plugin._handle_deploy(1, None, log=log, git_log=log, superset_log=log)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_handle_deploy_env_not_found(self):
|
||||||
|
"""Negative: env_id not in config manager."""
|
||||||
|
plugin, mock_cm = _make_plugin()
|
||||||
|
log = _mock_log()
|
||||||
|
mock_cm.get_environment.return_value = None
|
||||||
|
|
||||||
|
with patch('src.plugins.git_plugin.run_blocking') as mock_run_blocking:
|
||||||
|
mock_repo = MagicMock()
|
||||||
|
mock_repo.working_dir = tempfile.mkdtemp()
|
||||||
|
|
||||||
|
async def rb_side(kind='file', fn=None, **kwargs):
|
||||||
|
if kind == 'git' and fn == plugin.git_service.get_repo:
|
||||||
|
return mock_repo
|
||||||
|
return None
|
||||||
|
|
||||||
|
mock_run_blocking.side_effect = rb_side
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="not found"):
|
||||||
|
await plugin._handle_deploy(1, "nonexistent-env", log=log, git_log=log, superset_log=log)
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetEnv:
|
||||||
|
"""Verify _get_env method — all paths."""
|
||||||
|
|
||||||
|
def test_get_env_from_config(self):
|
||||||
|
"""Happy: get env from ConfigManager."""
|
||||||
|
plugin, mock_cm = _make_plugin()
|
||||||
|
mock_env = MagicMock()
|
||||||
|
mock_env.name = "Test Env"
|
||||||
|
mock_env.id = "env-1"
|
||||||
|
mock_cm.get_environment.return_value = mock_env
|
||||||
|
|
||||||
|
result = plugin._get_env("env-1")
|
||||||
|
assert result.name == "Test Env"
|
||||||
|
|
||||||
def test_get_env_not_found_raises(self):
|
def test_get_env_not_found_raises(self):
|
||||||
"""Negative: env not found in config or DB when env_id given."""
|
"""Negative: env not found in config or DB when env_id given."""
|
||||||
from src.plugins.git_plugin import GitPlugin
|
from src.plugins.git_plugin import GitPlugin as RealGitPlugin
|
||||||
|
# We need a fresh plugin with properly mocked dependencies
|
||||||
|
mock_cm = MagicMock()
|
||||||
|
mock_cm.get_environment.return_value = None
|
||||||
|
with patch('src.dependencies.config_manager', mock_cm):
|
||||||
|
plugin = RealGitPlugin()
|
||||||
|
|
||||||
|
with patch('src.core.database.SessionLocal') as MockSession:
|
||||||
|
mock_db = MagicMock()
|
||||||
|
MockSession.return_value = mock_db
|
||||||
|
# DB query returns None too
|
||||||
|
db_filter = MagicMock()
|
||||||
|
db_filter.first.return_value = None
|
||||||
|
mock_db.query.return_value.filter.return_value = db_filter
|
||||||
|
|
||||||
# We need to test the fallback path. The method will try config first,
|
|
||||||
# then DB, then raise.
|
|
||||||
# Mock ConfigManager.get_environment to return None
|
|
||||||
# Mock SessionLocal to return a mock that also returns None
|
|
||||||
with patch.object(GitPlugin, '_get_env') as mock_get_env:
|
|
||||||
mock_get_env.side_effect = ValueError("Environment 'bad-env' not found")
|
|
||||||
plugin = GitPlugin()
|
|
||||||
with pytest.raises(ValueError, match="not found"):
|
with pytest.raises(ValueError, match="not found"):
|
||||||
plugin._get_env("bad-env")
|
plugin._get_env("bad-env")
|
||||||
|
|
||||||
def test_get_env_db_fallback(self):
|
def test_get_env_db_fallback(self):
|
||||||
"""Happy: get env from DB when not in config."""
|
"""Happy: get env from DB when not in config."""
|
||||||
from src.plugins.git_plugin import GitPlugin
|
plugin, mock_cm = _make_plugin()
|
||||||
|
mock_cm.get_environment.return_value = None
|
||||||
|
|
||||||
plugin = GitPlugin()
|
with patch('src.core.database.SessionLocal') as MockSession:
|
||||||
mock_env = MagicMock()
|
mock_db = MagicMock()
|
||||||
mock_env.name = "DB Env"
|
MockSession.return_value = mock_db
|
||||||
|
mock_db_env = MagicMock()
|
||||||
|
mock_db_env.id = "db-env-1"
|
||||||
|
mock_db_env.name = "DB Env"
|
||||||
|
mock_db_env.superset_url = "https://superset.example.com"
|
||||||
|
mock_db_env.superset_token = "token123"
|
||||||
|
|
||||||
# Mock ConfigManager.get_environment to return None
|
db_filter = MagicMock()
|
||||||
with patch.object(plugin.config_manager, 'get_environment', return_value=None):
|
db_filter.first.return_value = mock_db_env
|
||||||
# Mock DB session and query
|
mock_db.query.return_value.filter.return_value = db_filter
|
||||||
with patch('src.plugins.git_plugin.SessionLocal') as MockSession:
|
|
||||||
mock_db = MagicMock()
|
|
||||||
MockSession.return_value.__enter__.return_value = mock_db
|
|
||||||
MockSession.return_value.__exit__.return_value = None
|
|
||||||
mock_db_env = MagicMock()
|
|
||||||
mock_db_env.id = "db-env-1"
|
|
||||||
mock_db_env.name = "DB Env"
|
|
||||||
mock_db_env.superset_url = "https://superset.example.com"
|
|
||||||
mock_db_env.superset_token = "token123"
|
|
||||||
|
|
||||||
db_query = MagicMock()
|
with patch('src.core.config_models.Environment') as MockEnv:
|
||||||
db_query.first.return_value = mock_db_env
|
MockEnv.return_value.name = "DB Env"
|
||||||
mock_db.query.return_value.filter.return_value = db_query
|
result = plugin._get_env("db-env-1")
|
||||||
|
assert result.name == "DB Env"
|
||||||
with patch('src.plugins.git_plugin.Environment') as MockEnv:
|
|
||||||
MockEnv.return_value.name = "DB Env"
|
|
||||||
result = plugin._get_env("db-env-1")
|
|
||||||
assert result.name == "DB Env"
|
|
||||||
|
|
||||||
def test_get_env_first_active_db(self):
|
def test_get_env_first_active_db(self):
|
||||||
"""Happy: get first active env from DB when no env_id."""
|
"""Happy: get first active env from DB when no env_id."""
|
||||||
from src.plugins.git_plugin import GitPlugin
|
plugin, mock_cm = _make_plugin()
|
||||||
|
mock_cm.get_environment.return_value = None
|
||||||
|
|
||||||
plugin = GitPlugin()
|
with patch('src.core.database.SessionLocal') as MockSession:
|
||||||
with patch.object(plugin.config_manager, 'get_environment', return_value=None):
|
mock_db = MagicMock()
|
||||||
with patch('src.plugins.git_plugin.SessionLocal') as MockSession:
|
MockSession.return_value = mock_db
|
||||||
mock_db = MagicMock()
|
mock_db_env = MagicMock()
|
||||||
MockSession.return_value = mock_db
|
mock_db_env.id = "active-1"
|
||||||
mock_db_env = MagicMock()
|
mock_db_env.name = "Active Env"
|
||||||
mock_db_env.id = "active-1"
|
mock_db_env.superset_url = "https://example.com"
|
||||||
mock_db_env.name = "Active Env"
|
mock_db_env.superset_token = "token"
|
||||||
mock_db_env.superset_url = "https://example.com"
|
|
||||||
mock_db_env.superset_token = "token"
|
|
||||||
# First: filter by is_active, then first()
|
|
||||||
db_filter = MagicMock()
|
|
||||||
db_filter.first.side_effect = [mock_db_env] # active found
|
|
||||||
mock_db.query.return_value.filter.return_value = db_filter
|
|
||||||
|
|
||||||
with patch('src.plugins.git_plugin.Environment'):
|
# First filter (is_active) returns the mock
|
||||||
result = plugin._get_env(None)
|
# Second filter (first env) never reached
|
||||||
assert result is not None
|
db_filter = MagicMock()
|
||||||
|
db_filter.first.return_value = mock_db_env
|
||||||
|
mock_db.query.return_value.filter.return_value = db_filter
|
||||||
|
|
||||||
|
with patch('src.core.config_models.Environment'):
|
||||||
|
result = plugin._get_env(None)
|
||||||
|
assert result is not None
|
||||||
|
|
||||||
def test_get_env_fallback_to_first_config(self):
|
def test_get_env_fallback_to_first_config(self):
|
||||||
"""Happy: fallback to first env from config when no env_id and no DB env."""
|
"""Happy: fallback to first env from config when no env_id and no DB env."""
|
||||||
from src.plugins.git_plugin import GitPlugin
|
plugin, mock_cm = _make_plugin()
|
||||||
|
mock_cm.get_environment.return_value = None
|
||||||
|
|
||||||
plugin = GitPlugin()
|
mock_env_list = [MagicMock(), MagicMock()]
|
||||||
mock_env_list = [MagicMock(name="First Env"), MagicMock(name="Second Env")]
|
|
||||||
mock_env_list[0].name = "First Env"
|
mock_env_list[0].name = "First Env"
|
||||||
|
mock_cm.get_environments.return_value = mock_env_list
|
||||||
|
|
||||||
with patch.object(plugin.config_manager, 'get_environment', return_value=None):
|
with patch('src.core.database.SessionLocal') as MockSession:
|
||||||
with patch.object(plugin.config_manager, 'get_environments', return_value=mock_env_list):
|
mock_db = MagicMock()
|
||||||
with patch('src.plugins.git_plugin.SessionLocal') as MockSession:
|
MockSession.return_value = mock_db
|
||||||
mock_db = MagicMock()
|
db_filter = MagicMock()
|
||||||
MockSession.return_value = mock_db
|
db_filter.first.side_effect = [None, None] # no active, no first
|
||||||
db_filter = MagicMock()
|
mock_db.query.return_value.filter.return_value = db_filter
|
||||||
db_filter.first.side_effect = [None, None] # no active, no first
|
mock_db.query.return_value.first.return_value = None # plain .first() returns None
|
||||||
mock_db.query.return_value.filter.return_value = db_filter
|
|
||||||
mock_db.query.return_value = MagicMock()
|
|
||||||
mock_db.query.return_value.filter.return_value = db_filter
|
|
||||||
|
|
||||||
result = plugin._get_env(None)
|
result = plugin._get_env(None)
|
||||||
assert result.name == "First Env"
|
assert result.name == "First Env"
|
||||||
|
|
||||||
def test_get_env_no_envs_configured(self):
|
def test_get_env_no_envs_configured(self):
|
||||||
"""Negative: no environments at all raises ValueError."""
|
"""Negative: no environments at all raises ValueError."""
|
||||||
from src.plugins.git_plugin import GitPlugin
|
plugin, mock_cm = _make_plugin()
|
||||||
|
mock_cm.get_environment.return_value = None
|
||||||
|
mock_cm.get_environments.return_value = []
|
||||||
|
|
||||||
plugin = GitPlugin()
|
with patch('src.core.database.SessionLocal') as MockSession:
|
||||||
with patch.object(plugin.config_manager, 'get_environment', return_value=None):
|
mock_db = MagicMock()
|
||||||
with patch.object(plugin.config_manager, 'get_environments', return_value=[]):
|
MockSession.return_value = mock_db
|
||||||
with patch('src.plugins.git_plugin.SessionLocal') as MockSession:
|
db_filter = MagicMock()
|
||||||
mock_db = MagicMock()
|
db_filter.first.side_effect = [None, None]
|
||||||
MockSession.return_value = mock_db
|
mock_db.query.return_value.filter.return_value = db_filter
|
||||||
db_filter = MagicMock()
|
mock_db.query.return_value.first.return_value = None # plain .first() returns None
|
||||||
db_filter.first.side_effect = [None, None]
|
|
||||||
mock_db.query.return_value.filter.return_value = db_filter
|
|
||||||
|
|
||||||
with pytest.raises(ValueError, match="No environments configured"):
|
with pytest.raises(ValueError, match="No environments configured"):
|
||||||
plugin._get_env(None)
|
plugin._get_env(None)
|
||||||
|
|
||||||
|
def test_get_env_cm_config_id_present(self):
|
||||||
|
"""Edge: env_id given but not found in config, but found in DB."""
|
||||||
|
plugin, mock_cm = _make_plugin()
|
||||||
|
mock_cm.get_environment.return_value = None
|
||||||
|
|
||||||
|
with patch('src.core.database.SessionLocal') as MockSession:
|
||||||
|
mock_db = MagicMock()
|
||||||
|
MockSession.return_value = mock_db
|
||||||
|
mock_db_env = MagicMock()
|
||||||
|
mock_db_env.id = "db-1"
|
||||||
|
mock_db_env.name = "DB Found"
|
||||||
|
mock_db_env.superset_url = "https://example.com"
|
||||||
|
mock_db_env.superset_token = "tok"
|
||||||
|
|
||||||
|
db_filter = MagicMock()
|
||||||
|
db_filter.first.return_value = mock_db_env
|
||||||
|
mock_db.query.return_value.filter.return_value = db_filter
|
||||||
|
|
||||||
|
with patch('src.core.config_models.Environment') as MockEnv:
|
||||||
|
MockEnv.return_value.name = "DB Found"
|
||||||
|
result = plugin._get_env("db-1")
|
||||||
|
assert result.name == "DB Found"
|
||||||
# #endregion Test.GitPlugin
|
# #endregion Test.GitPlugin
|
||||||
|
|||||||
1454
backend/tests/plugins/test_llm_analysis_plugin.py
Normal file
1454
backend/tests/plugins/test_llm_analysis_plugin.py
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -7,7 +7,7 @@ from sqlalchemy import create_engine, event
|
|||||||
from sqlalchemy.orm import Session, sessionmaker
|
from sqlalchemy.orm import Session, sessionmaker
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from src.models.translate import Base, TranslationJob, TranslationRun
|
from src.models.translate import Base, TranslationBatch, TranslationJob, TranslationRun
|
||||||
from src.plugins.translate.events import TranslationEventLog
|
from src.plugins.translate.events import TranslationEventLog
|
||||||
|
|
||||||
# Shared IDs for FK references
|
# Shared IDs for FK references
|
||||||
@@ -36,12 +36,16 @@ def db_session():
|
|||||||
Base.metadata.drop_all(bind=engine)
|
Base.metadata.drop_all(bind=engine)
|
||||||
|
|
||||||
|
|
||||||
|
BATCH_ID = "batch-1"
|
||||||
|
|
||||||
@pytest.fixture(scope="function")
|
@pytest.fixture(scope="function")
|
||||||
def db_with_run(db_session):
|
def db_with_run(db_session):
|
||||||
"""Create a TranslationRun in addition to the base job, with RUN_STARTED event."""
|
"""Create a TranslationRun and TranslationBatch in addition to the base job."""
|
||||||
run = TranslationRun(id=RUN_ID, job_id=JOB_ID, status="RUNNING",
|
run = TranslationRun(id=RUN_ID, job_id=JOB_ID, status="RUNNING",
|
||||||
started_at=datetime.now(UTC))
|
started_at=datetime.now(UTC))
|
||||||
db_session.add(run)
|
db_session.add(run)
|
||||||
|
batch = TranslationBatch(id=BATCH_ID, run_id=RUN_ID, batch_index=0, status="PENDING")
|
||||||
|
db_session.add(batch)
|
||||||
db_session.commit()
|
db_session.commit()
|
||||||
# Log RUN_STARTED event so downstream events can be logged
|
# Log RUN_STARTED event so downstream events can be logged
|
||||||
event_log = TranslationEventLog(db_session)
|
event_log = TranslationEventLog(db_session)
|
||||||
|
|||||||
@@ -144,6 +144,23 @@ class TestCancelRun:
|
|||||||
result = cancel_run(session, event_log, "test_user", run_id)
|
result = cancel_run(session, event_log, "test_user", run_id)
|
||||||
assert result is not None
|
assert result is not None
|
||||||
|
|
||||||
|
def test_cancel_run_fallback_no_run_found(self):
|
||||||
|
"""_fallback_cancel_request with nonexistent run raises ValueError."""
|
||||||
|
session, engine = None, None
|
||||||
|
from sqlalchemy import create_engine, event
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
from src.plugins.translate.orchestrator_cancel import _fallback_cancel_request
|
||||||
|
|
||||||
|
from src.models.translate import Base
|
||||||
|
engine = create_engine("sqlite:///:memory:")
|
||||||
|
Base.metadata.create_all(bind=engine)
|
||||||
|
session = sessionmaker(bind=engine)()
|
||||||
|
try:
|
||||||
|
with pytest.raises(ValueError, match="not found"):
|
||||||
|
_fallback_cancel_request(session, "nonexistent-run-id")
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
|
||||||
class TestRetryInsert:
|
class TestRetryInsert:
|
||||||
"""Verify retry_insert function."""
|
"""Verify retry_insert function."""
|
||||||
|
|||||||
@@ -374,4 +374,57 @@ def test_reject_persists_audit_event():
|
|||||||
# #endregion test_reject_persists_audit_event
|
# #endregion test_reject_persists_audit_event
|
||||||
|
|
||||||
|
|
||||||
|
# #region test_approve_rejects_already_approved_status [C:2] [TYPE Function]
|
||||||
|
def test_approve_rejects_already_approved_status():
|
||||||
|
"""Candidate already has APPROVED status -> raise ApprovalGateError."""
|
||||||
|
from src.services.clean_release.approval_service import approve_candidate
|
||||||
|
|
||||||
|
repository, candidate_id, report_id = _seed_candidate_with_report(
|
||||||
|
candidate_id="cand-already-approved",
|
||||||
|
report_id="CCR-already-approved",
|
||||||
|
)
|
||||||
|
candidate = repository.get_candidate(candidate_id)
|
||||||
|
candidate.status = CandidateStatus.APPROVED.value
|
||||||
|
repository.save_candidate(candidate)
|
||||||
|
|
||||||
|
with pytest.raises(ApprovalGateError, match="already approved"):
|
||||||
|
approve_candidate(
|
||||||
|
repository=repository,
|
||||||
|
candidate_id=candidate_id,
|
||||||
|
report_id=report_id,
|
||||||
|
decided_by="approver",
|
||||||
|
)
|
||||||
|
# #endregion test_approve_rejects_already_approved_status
|
||||||
|
|
||||||
|
|
||||||
|
# #region test_approve_handles_transition_exception [C:2] [TYPE Function]
|
||||||
|
def test_approve_handles_transition_exception():
|
||||||
|
"""Non-ApprovalGateError in candidate.transition_to wraps into ApprovalGateError."""
|
||||||
|
from src.services.clean_release.approval_service import approve_candidate
|
||||||
|
|
||||||
|
repository, candidate_id, report_id = _seed_candidate_with_report(
|
||||||
|
candidate_id="cand-transition-err",
|
||||||
|
report_id="CCR-transition-err",
|
||||||
|
)
|
||||||
|
|
||||||
|
candidate = repository.get_candidate(candidate_id)
|
||||||
|
original_transition = candidate.transition_to
|
||||||
|
def broken_transition(_new_status):
|
||||||
|
raise RuntimeError("unexpected internal error")
|
||||||
|
candidate.transition_to = broken_transition
|
||||||
|
repository.save_candidate(candidate)
|
||||||
|
|
||||||
|
with pytest.raises(ApprovalGateError, match="unexpected internal error"):
|
||||||
|
approve_candidate(
|
||||||
|
repository=repository,
|
||||||
|
candidate_id=candidate_id,
|
||||||
|
report_id=report_id,
|
||||||
|
decided_by="approver",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Restore original transition
|
||||||
|
candidate.transition_to = original_transition
|
||||||
|
# #endregion test_approve_handles_transition_exception
|
||||||
|
|
||||||
|
|
||||||
# #endregion TestApprovalService
|
# #endregion TestApprovalService
|
||||||
|
|||||||
@@ -243,3 +243,29 @@ class TestComplianceExecutionServiceRun:
|
|||||||
assert result.stage_runs == stage_runs
|
assert result.stage_runs == stage_runs
|
||||||
assert result.violations == violations
|
assert result.violations == violations
|
||||||
# #endregion test_execute_result_dataclass
|
# #endregion test_execute_result_dataclass
|
||||||
|
|
||||||
|
# #region test_execute_run_stage_raises_exception [C:2] [TYPE Function]
|
||||||
|
@patch("src.services.clean_release.compliance_execution_service.resolve_trusted_policy_snapshots")
|
||||||
|
def test_execute_run_stage_raises_exception(self, mock_resolve):
|
||||||
|
"""Stage that raises an exception triggers the except handler (lines 211-217)."""
|
||||||
|
repo = _make_repo_with_candidate()
|
||||||
|
mock_resolve.return_value = (
|
||||||
|
repo.policies["policy-exe-1"],
|
||||||
|
repo.registries["registry-exe-1"],
|
||||||
|
)
|
||||||
|
config = MagicMock()
|
||||||
|
config.get_config.return_value.settings.clean_release.active_policy_id = "policy-exe-1"
|
||||||
|
config.get_config.return_value.settings.clean_release.active_registry_id = "registry-exe-1"
|
||||||
|
|
||||||
|
class CrashingStage(ComplianceStage):
|
||||||
|
stage_name = ComplianceStageName("DATA_PURITY")
|
||||||
|
def execute(self, context):
|
||||||
|
raise RuntimeError("stage crashed unexpectedly")
|
||||||
|
|
||||||
|
svc = ComplianceExecutionService(repository=repo, config_manager=config, stages=[CrashingStage()])
|
||||||
|
result = svc.execute_run(candidate_id="cand-exe-1", requested_by="tester")
|
||||||
|
|
||||||
|
assert result.run.status == RunStatus.FAILED.value
|
||||||
|
assert result.run.final_status == ComplianceDecision.ERROR.value
|
||||||
|
assert "stage crashed" in result.run.failure_reason
|
||||||
|
# #endregion test_execute_run_stage_raises_exception
|
||||||
|
|||||||
@@ -86,4 +86,42 @@ def test_create_isolated_repository_keeps_mode_data_separate() -> None:
|
|||||||
assert real_repo.get_candidate(demo_candidate_id) is None
|
assert real_repo.get_candidate(demo_candidate_id) is None
|
||||||
# #endregion test_create_isolated_repository_keeps_mode_data_separate
|
# #endregion test_create_isolated_repository_keeps_mode_data_separate
|
||||||
|
|
||||||
|
|
||||||
|
# #region test_build_namespaced_id_rejects_empty_namespace [C:2] [TYPE Function]
|
||||||
|
def test_build_namespaced_id_rejects_empty_namespace() -> None:
|
||||||
|
"""build_namespaced_id with empty namespace raises ValueError."""
|
||||||
|
from src.services.clean_release.demo_data_service import build_namespaced_id
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="namespace must be non-empty"):
|
||||||
|
build_namespaced_id("", "logical-1")
|
||||||
|
with pytest.raises(ValueError, match="namespace must be non-empty"):
|
||||||
|
build_namespaced_id(" ", "logical-1")
|
||||||
|
# #endregion test_build_namespaced_id_rejects_empty_namespace
|
||||||
|
|
||||||
|
|
||||||
|
# #region test_build_namespaced_id_rejects_empty_logical_id [C:2] [TYPE Function]
|
||||||
|
def test_build_namespaced_id_rejects_empty_logical_id() -> None:
|
||||||
|
"""build_namespaced_id with empty logical_id raises ValueError."""
|
||||||
|
from src.services.clean_release.demo_data_service import build_namespaced_id
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="logical_id must be non-empty"):
|
||||||
|
build_namespaced_id("demo", "")
|
||||||
|
with pytest.raises(ValueError, match="logical_id must be non-empty"):
|
||||||
|
build_namespaced_id("demo", " ")
|
||||||
|
# #endregion test_build_namespaced_id_rejects_empty_logical_id
|
||||||
|
|
||||||
|
|
||||||
|
# #region test_resolve_namespace_defaults_to_real [C:2] [TYPE Function]
|
||||||
|
def test_resolve_namespace_defaults_to_real() -> None:
|
||||||
|
"""Non-demo mode defaults to real namespace."""
|
||||||
|
from src.services.clean_release.demo_data_service import resolve_namespace
|
||||||
|
|
||||||
|
assert resolve_namespace("") == "clean-release:real"
|
||||||
|
assert resolve_namespace(None) == "clean-release:real"
|
||||||
|
assert resolve_namespace("production") == "clean-release:real"
|
||||||
|
# #endregion test_resolve_namespace_defaults_to_real
|
||||||
|
|
||||||
|
|
||||||
# #endregion TestDemoModeIsolation
|
# #endregion TestDemoModeIsolation
|
||||||
|
|||||||
141
backend/tests/services/clean_release/test_repository_coverage.py
Normal file
141
backend/tests/services/clean_release/test_repository_coverage.py
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
# #region Test.Repository.Coverage [C:2] [TYPE Module] [SEMANTICS test,clean-release,repository,coverage]
|
||||||
|
# @BRIEF Coverage tests for CleanReleaseRepository — alias methods, clear_history, _run_ref.
|
||||||
|
# @RELATION BINDS_TO -> [RepositoryRelations]
|
||||||
|
# @TEST_EDGE: save_distribution_manifest -> alias delegates to save_manifest
|
||||||
|
# @TEST_EDGE: get_distribution_manifest -> alias delegates to get_manifest
|
||||||
|
# @TEST_EDGE: save_compliance_run -> alias delegates to save_check_run
|
||||||
|
# @TEST_EDGE: get_compliance_run -> alias delegates to get_check_run
|
||||||
|
# @TEST_EDGE: clear_history -> clears runs, reports, violations
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
from src.models.clean_release import (
|
||||||
|
ComplianceRun,
|
||||||
|
ComplianceReport,
|
||||||
|
ComplianceStageRun,
|
||||||
|
ComplianceViolation,
|
||||||
|
DistributionManifest,
|
||||||
|
ReleaseCandidate,
|
||||||
|
)
|
||||||
|
from src.services.clean_release.repository import CleanReleaseRepository
|
||||||
|
|
||||||
|
|
||||||
|
# #region test_save_distribution_manifest_alias [C:1] [TYPE Function]
|
||||||
|
def test_save_distribution_manifest_alias():
|
||||||
|
"""save_distribution_manifest delegates to save_manifest."""
|
||||||
|
repo = CleanReleaseRepository()
|
||||||
|
m = DistributionManifest(
|
||||||
|
id="dm-alias-1", candidate_id="cand-1", manifest_version=1,
|
||||||
|
manifest_digest="d1", artifacts_digest="d1",
|
||||||
|
source_snapshot_ref="ref", content_json={},
|
||||||
|
created_by="tester", created_at=datetime.now(UTC), immutable=True,
|
||||||
|
)
|
||||||
|
result = repo.save_distribution_manifest(m)
|
||||||
|
assert result.id == "dm-alias-1"
|
||||||
|
assert repo.manifests["dm-alias-1"] is m
|
||||||
|
# #endregion test_save_distribution_manifest_alias
|
||||||
|
|
||||||
|
|
||||||
|
# #region test_get_distribution_manifest_alias [C:1] [TYPE Function]
|
||||||
|
def test_get_distribution_manifest_alias():
|
||||||
|
"""get_distribution_manifest delegates to get_manifest."""
|
||||||
|
repo = CleanReleaseRepository()
|
||||||
|
m = DistributionManifest(
|
||||||
|
id="dm-alias-2", candidate_id="cand-1", manifest_version=1,
|
||||||
|
manifest_digest="d2", artifacts_digest="d2",
|
||||||
|
source_snapshot_ref="ref", content_json={},
|
||||||
|
created_by="tester", created_at=datetime.now(UTC), immutable=True,
|
||||||
|
)
|
||||||
|
repo.save_manifest(m)
|
||||||
|
result = repo.get_distribution_manifest("dm-alias-2")
|
||||||
|
assert result is m
|
||||||
|
|
||||||
|
missing = repo.get_distribution_manifest("nonexistent")
|
||||||
|
assert missing is None
|
||||||
|
# #endregion test_get_distribution_manifest_alias
|
||||||
|
|
||||||
|
|
||||||
|
# #region test_save_compliance_run_alias [C:1] [TYPE Function]
|
||||||
|
def test_save_compliance_run_alias():
|
||||||
|
"""save_compliance_run delegates to save_check_run."""
|
||||||
|
repo = CleanReleaseRepository()
|
||||||
|
r = ComplianceRun(
|
||||||
|
id="cr-alias-1", candidate_id="cand-1", manifest_id="m1",
|
||||||
|
manifest_digest="d1", policy_snapshot_id="p1", registry_snapshot_id="reg1",
|
||||||
|
requested_by="tester", requested_at=datetime.now(UTC),
|
||||||
|
status="RUNNING",
|
||||||
|
)
|
||||||
|
result = repo.save_compliance_run(r)
|
||||||
|
assert result.id == "cr-alias-1"
|
||||||
|
assert repo.check_runs["cr-alias-1"] is r
|
||||||
|
# #endregion test_save_compliance_run_alias
|
||||||
|
|
||||||
|
|
||||||
|
# #region test_get_compliance_run_alias [C:1] [TYPE Function]
|
||||||
|
def test_get_compliance_run_alias():
|
||||||
|
"""get_compliance_run delegates to get_check_run."""
|
||||||
|
repo = CleanReleaseRepository()
|
||||||
|
r = ComplianceRun(
|
||||||
|
id="cr-alias-2", candidate_id="cand-1", manifest_id="m1",
|
||||||
|
manifest_digest="d1", policy_snapshot_id="p1", registry_snapshot_id="reg1",
|
||||||
|
requested_by="tester", requested_at=datetime.now(UTC),
|
||||||
|
status="RUNNING",
|
||||||
|
)
|
||||||
|
repo.save_check_run(r)
|
||||||
|
result = repo.get_compliance_run("cr-alias-2")
|
||||||
|
assert result is r
|
||||||
|
|
||||||
|
missing = repo.get_compliance_run("nonexistent")
|
||||||
|
assert missing is None
|
||||||
|
# #endregion test_get_compliance_run_alias
|
||||||
|
|
||||||
|
|
||||||
|
# #region test_clear_history_clears_evidence [C:1] [TYPE Function]
|
||||||
|
def test_clear_history_clears_evidence():
|
||||||
|
"""clear_history removes check_runs, reports, and violations but keeps candidates."""
|
||||||
|
repo = CleanReleaseRepository()
|
||||||
|
|
||||||
|
repo.save_candidate(ReleaseCandidate(
|
||||||
|
id="cand-keep-1", version="1.0.0", source_snapshot_ref="ref",
|
||||||
|
created_by="tester", created_at=datetime.now(UTC), status="DRAFT",
|
||||||
|
))
|
||||||
|
repo.save_check_run(ComplianceRun(
|
||||||
|
id="run-clear-1", candidate_id="cand-keep-1", manifest_id="m1",
|
||||||
|
manifest_digest="d1", policy_snapshot_id="p1", registry_snapshot_id="reg1",
|
||||||
|
requested_by="tester", requested_at=datetime.now(UTC), status="RUNNING",
|
||||||
|
))
|
||||||
|
repo.save_report(ComplianceReport(
|
||||||
|
id="rpt-clear-1", run_id="run-clear-1", candidate_id="cand-keep-1",
|
||||||
|
final_status="PASSED", summary_json={}, generated_at=datetime.now(UTC), immutable=True,
|
||||||
|
))
|
||||||
|
repo.save_violation(ComplianceViolation(
|
||||||
|
id="viol-clear-1", run_id="run-clear-1", stage_name="DATA_PURITY",
|
||||||
|
code="EXT", severity="MAJOR", message="violation",
|
||||||
|
))
|
||||||
|
|
||||||
|
repo.clear_history()
|
||||||
|
|
||||||
|
assert len(repo.check_runs) == 0
|
||||||
|
assert len(repo.reports) == 0
|
||||||
|
assert len(repo.violations) == 0
|
||||||
|
assert len(repo.candidates) == 1 # Candidates are preserved
|
||||||
|
# #endregion test_clear_history_clears_evidence
|
||||||
|
|
||||||
|
|
||||||
|
# #region test_run_ref_extra_helper [C:1] [TYPE Function]
|
||||||
|
def test_run_ref_extra_helper():
|
||||||
|
"""_run_ref extracts run_id from entity."""
|
||||||
|
from src.services.clean_release.repository import CleanReleaseRepository
|
||||||
|
from src.models.clean_release import ComplianceViolation
|
||||||
|
|
||||||
|
repo = CleanReleaseRepository()
|
||||||
|
v = ComplianceViolation(
|
||||||
|
id="viol-runref", run_id="run-123", stage_name="DATA_PURITY",
|
||||||
|
code="EXT", severity="MAJOR", message="test",
|
||||||
|
)
|
||||||
|
ref = repo._run_ref(v)
|
||||||
|
assert ref == "run-123"
|
||||||
|
# #endregion test_run_ref_extra_helper
|
||||||
|
# #endregion Test.Repository.Coverage
|
||||||
@@ -283,7 +283,7 @@ class TestEnsureGitflowMissingMain:
|
|||||||
repo.create_head.assert_any_call("main", repo.head.commit)
|
repo.create_head.assert_any_call("main", repo.head.commit)
|
||||||
|
|
||||||
def test_active_branch_raises_no_checkout(self):
|
def test_active_branch_raises_no_checkout(self):
|
||||||
"""active_branch.name raises → current_branch=None, no checkout attempt."""
|
"""active_branch.name raises → current_branch=None, checkout dev attempted."""
|
||||||
from src.services.git._branch import GitServiceBranchMixin
|
from src.services.git._branch import GitServiceBranchMixin
|
||||||
repo = MagicMock()
|
repo = MagicMock()
|
||||||
main_head = MagicMock()
|
main_head = MagicMock()
|
||||||
@@ -297,7 +297,6 @@ class TestEnsureGitflowMissingMain:
|
|||||||
repo.remote.return_value = origin
|
repo.remote.return_value = origin
|
||||||
svc = TestableGitBranch(repo)
|
svc = TestableGitBranch(repo)
|
||||||
svc._ensure_gitflow_branches(repo, 1)
|
svc._ensure_gitflow_branches(repo, 1)
|
||||||
# If current_branch is None, the != "dev" check is True (None != "dev")
|
# With current_branch=None, None != "dev" is True, so checkout("dev") is called
|
||||||
# and git.checkout("dev") is attempted and fails
|
repo.git.checkout.assert_called_once_with("dev")
|
||||||
repo.git.checkout.assert_not_called()
|
|
||||||
# #endregion Test.Git.Branch.Edge
|
# #endregion Test.Git.Branch.Edge
|
||||||
|
|||||||
@@ -343,5 +343,111 @@ class TestLogPersistence:
|
|||||||
self.service.delete_logs_for_tasks([]) # Should not raise
|
self.service.delete_logs_for_tasks([]) # Should not raise
|
||||||
# #endregion test_delete_logs_for_tasks_empty
|
# #endregion test_delete_logs_for_tasks_empty
|
||||||
|
|
||||||
|
# #region test_add_logs_db_error_rollback [C:2] [TYPE Function]
|
||||||
|
# @PURPOSE: add_logs rollbacks on database error.
|
||||||
|
def test_add_logs_db_error_rollback(self):
|
||||||
|
entry = LogEntry(timestamp=datetime.now(UTC), level="INFO", source="test", message="will fail")
|
||||||
|
|
||||||
|
def broken_session():
|
||||||
|
s = self.TestSessionLocal()
|
||||||
|
s.commit = lambda: (_ for _ in ()).throw(RuntimeError("db dead"))
|
||||||
|
return s
|
||||||
|
with patch("src.core.task_manager.persistence.TasksSessionLocal", broken_session):
|
||||||
|
self.service.add_logs("test-task-1", [entry]) # Should not raise
|
||||||
|
|
||||||
|
from src.models.task import TaskLogRecord
|
||||||
|
session = self.TestSessionLocal()
|
||||||
|
count = session.query(TaskLogRecord).filter_by(task_id="test-task-1").count()
|
||||||
|
session.close()
|
||||||
|
assert count == 0 # rolled back
|
||||||
|
# #endregion test_add_logs_db_error_rollback
|
||||||
|
|
||||||
|
# #region test_get_logs_with_metadata_json [C:2] [TYPE Function]
|
||||||
|
# @PURPOSE: get_logs parses metadata_json field correctly.
|
||||||
|
def test_get_logs_with_metadata_json(self):
|
||||||
|
entry = LogEntry(
|
||||||
|
timestamp=datetime.now(UTC),
|
||||||
|
level="INFO", source="test", message="metadata test",
|
||||||
|
metadata={"dashboard_id": "dash-1", "progress": 50},
|
||||||
|
)
|
||||||
|
with self._patched("add_logs"):
|
||||||
|
self.service.add_logs("test-task-1", [entry])
|
||||||
|
|
||||||
|
with self._patched("get_logs"):
|
||||||
|
logs = self.service.get_logs("test-task-1", LogFilter())
|
||||||
|
assert len(logs) == 1
|
||||||
|
assert logs[0].metadata == {"dashboard_id": "dash-1", "progress": 50}
|
||||||
|
# #endregion test_get_logs_with_metadata_json
|
||||||
|
|
||||||
|
# #region test_get_logs_with_corrupt_metadata_json [C:2] [TYPE Function]
|
||||||
|
# @PURPOSE: get_logs handles corrupt metadata_json gracefully.
|
||||||
|
def test_get_logs_with_corrupt_metadata_json(self):
|
||||||
|
# Manually insert a log with corrupt metadata_json
|
||||||
|
from src.models.task import TaskLogRecord
|
||||||
|
session = self.TestSessionLocal()
|
||||||
|
record = TaskLogRecord(
|
||||||
|
task_id="test-task-1",
|
||||||
|
timestamp=datetime.now(UTC),
|
||||||
|
level="INFO",
|
||||||
|
source="test",
|
||||||
|
message="corrupt metadata",
|
||||||
|
metadata_json="{not-json",
|
||||||
|
)
|
||||||
|
session.add(record)
|
||||||
|
session.commit()
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
with self._patched("get_logs"):
|
||||||
|
logs = self.service.get_logs("test-task-1", LogFilter())
|
||||||
|
assert len(logs) == 1
|
||||||
|
assert logs[0].metadata is None # gracefully handled
|
||||||
|
# #endregion test_get_logs_with_corrupt_metadata_json
|
||||||
|
|
||||||
|
# #region test_delete_logs_for_task_db_error [C:2] [TYPE Function]
|
||||||
|
# @PURPOSE: delete_logs_for_task handles database error gracefully.
|
||||||
|
def test_delete_logs_for_task_db_error(self):
|
||||||
|
entry = LogEntry(timestamp=datetime.now(UTC), level="INFO", source="test", message="test")
|
||||||
|
with self._patched("add_logs"):
|
||||||
|
self.service.add_logs("test-task-1", [entry])
|
||||||
|
|
||||||
|
def broken_session():
|
||||||
|
s = self.TestSessionLocal()
|
||||||
|
s.commit = lambda: (_ for _ in ()).throw(RuntimeError("delete failed"))
|
||||||
|
return s
|
||||||
|
with patch("src.core.task_manager.persistence.TasksSessionLocal", broken_session):
|
||||||
|
self.service.delete_logs_for_task("test-task-1") # Should not raise
|
||||||
|
|
||||||
|
# Logs still exist
|
||||||
|
from src.models.task import TaskLogRecord
|
||||||
|
session = self.TestSessionLocal()
|
||||||
|
count = session.query(TaskLogRecord).filter_by(task_id="test-task-1").count()
|
||||||
|
session.close()
|
||||||
|
assert count == 1
|
||||||
|
# #endregion test_delete_logs_for_task_db_error
|
||||||
|
|
||||||
|
# #region test_delete_logs_for_tasks_db_error [C:2] [TYPE Function]
|
||||||
|
# @PURPOSE: delete_logs_for_tasks handles database error gracefully.
|
||||||
|
def test_delete_logs_for_tasks_db_error(self):
|
||||||
|
for tid in ["multi-1", "multi-2"]:
|
||||||
|
entry = LogEntry(timestamp=datetime.now(UTC), level="INFO", source="test", message="test")
|
||||||
|
with self._patched("add_logs"):
|
||||||
|
self.service.add_logs(tid, [entry])
|
||||||
|
|
||||||
|
def broken_session():
|
||||||
|
s = self.TestSessionLocal()
|
||||||
|
s.commit = lambda: (_ for _ in ()).throw(RuntimeError("batch delete failed"))
|
||||||
|
return s
|
||||||
|
with patch("src.core.task_manager.persistence.TasksSessionLocal", broken_session):
|
||||||
|
self.service.delete_logs_for_tasks(["multi-1", "multi-2"]) # Should not raise
|
||||||
|
|
||||||
|
from src.models.task import TaskLogRecord
|
||||||
|
session = self.TestSessionLocal()
|
||||||
|
remaining = session.query(TaskLogRecord).all()
|
||||||
|
session.close()
|
||||||
|
# All logs that were associated with these task_ids should still exist
|
||||||
|
multi_ids = [r.task_id for r in remaining if r.task_id in ("multi-1", "multi-2")]
|
||||||
|
assert len(multi_ids) == 2
|
||||||
|
# #endregion test_delete_logs_for_tasks_db_error
|
||||||
|
|
||||||
# #endregion TestLogPersistence
|
# #endregion TestLogPersistence
|
||||||
# #endregion test_log_persistence
|
# #endregion test_log_persistence
|
||||||
|
|||||||
@@ -426,5 +426,155 @@ class TestTaskPersistenceService:
|
|||||||
assert record.environment_id == "env-uuid-1"
|
assert record.environment_id == "env-uuid-1"
|
||||||
# #endregion test_persist_task_resolves_environment_slug_to_existing_id
|
# #endregion test_persist_task_resolves_environment_slug_to_existing_id
|
||||||
|
|
||||||
|
# #region test_resolve_environment_id_direct_id_match [C:2] [TYPE Function]
|
||||||
|
# @PURPOSE: _resolve_environment_id returns match on direct primary key lookup.
|
||||||
|
def test_resolve_environment_id_direct_id_match(self):
|
||||||
|
session = self.TestSessionLocal()
|
||||||
|
env = Environment(id="direct-id", name="Some Env", url="https://x", credentials_id="cred-1")
|
||||||
|
session.add(env)
|
||||||
|
session.commit()
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
result = TaskPersistenceService._resolve_environment_id(self.TestSessionLocal(), "direct-id")
|
||||||
|
assert result == "direct-id"
|
||||||
|
# #endregion test_resolve_environment_id_direct_id_match
|
||||||
|
|
||||||
|
# #region test_resolve_environment_id_name_match [C:2] [TYPE Function]
|
||||||
|
# @PURPOSE: _resolve_environment_id returns match on name lookup.
|
||||||
|
def test_resolve_environment_id_name_match(self):
|
||||||
|
session = self.TestSessionLocal()
|
||||||
|
env = Environment(id="env-name-id", name="Production", url="https://prod", credentials_id="cred-1")
|
||||||
|
session.add(env)
|
||||||
|
session.commit()
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
result = TaskPersistenceService._resolve_environment_id(self.TestSessionLocal(), "Production")
|
||||||
|
assert result == "env-name-id"
|
||||||
|
# #endregion test_resolve_environment_id_name_match
|
||||||
|
|
||||||
|
# #region test_resolve_environment_id_empty_token [C:2] [TYPE Function]
|
||||||
|
# @PURPOSE: _resolve_environment_id returns None when normalized token is empty.
|
||||||
|
def test_resolve_environment_id_empty_token(self):
|
||||||
|
# No envs in DB; value "---" normalizes to "" (all non-alnum chars) → returns None
|
||||||
|
result = TaskPersistenceService._resolve_environment_id(self.TestSessionLocal(), "---")
|
||||||
|
assert result is None
|
||||||
|
# #endregion test_resolve_environment_id_empty_token
|
||||||
|
|
||||||
|
# #region test_persist_task_with_list_in_params [C:2] [TYPE Function]
|
||||||
|
# @PURPOSE: json_serializable handles list values in params.
|
||||||
|
def test_persist_task_with_list_in_params(self):
|
||||||
|
task = self._make_task(params={"items": [1, 2, {"nested": "val"}]})
|
||||||
|
with self._patched():
|
||||||
|
self.service.persist_task(task)
|
||||||
|
|
||||||
|
session = self.TestSessionLocal()
|
||||||
|
record = session.query(TaskRecord).filter_by(id="test-uuid-1").first()
|
||||||
|
session.close()
|
||||||
|
assert record is not None
|
||||||
|
assert record.params["items"] == [1, 2, {"nested": "val"}]
|
||||||
|
# #endregion test_persist_task_with_list_in_params
|
||||||
|
|
||||||
|
# #region test_persist_task_with_context_datetime [C:2] [TYPE Function]
|
||||||
|
# @PURPOSE: Log context with datetime values gets json_serializable treatment.
|
||||||
|
def test_persist_task_with_context_datetime(self):
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
task = self._make_task()
|
||||||
|
dt_val = datetime(2024, 7, 4, 12, 0, 0, tzinfo=UTC)
|
||||||
|
task.logs = [
|
||||||
|
LogEntry(message="ctx test", level="INFO", source="test", context={"time": dt_val, "val": 42}),
|
||||||
|
]
|
||||||
|
with self._patched():
|
||||||
|
self.service.persist_task(task)
|
||||||
|
|
||||||
|
session = self.TestSessionLocal()
|
||||||
|
record = session.query(TaskRecord).filter_by(id="test-uuid-1").first()
|
||||||
|
session.close()
|
||||||
|
assert record is not None
|
||||||
|
assert len(record.logs) == 1
|
||||||
|
assert record.logs[0]["context"]["time"] == "2024-07-04T12:00:00+00:00"
|
||||||
|
# #endregion test_persist_task_with_context_datetime
|
||||||
|
|
||||||
|
# #region test_persist_task_db_error_rollback [C:2] [TYPE Function]
|
||||||
|
# @PURPOSE: persist_task rollbacks on database error.
|
||||||
|
def test_persist_task_db_error_rollback(self):
|
||||||
|
task = self._make_task()
|
||||||
|
# Force a DB error by passing params that cause SQLite issue
|
||||||
|
with patch.object(self.TestSessionLocal(), "commit", side_effect=RuntimeError("db down")):
|
||||||
|
# Repatch TasksSessionLocal to use the broken session
|
||||||
|
def broken_session():
|
||||||
|
s = self.TestSessionLocal()
|
||||||
|
s.commit = lambda: (_ for _ in ()).throw(RuntimeError("db down"))
|
||||||
|
return s
|
||||||
|
with patch("src.core.task_manager.persistence.TasksSessionLocal", broken_session):
|
||||||
|
self.service.persist_task(task) # Should not raise
|
||||||
|
|
||||||
|
session = self.TestSessionLocal()
|
||||||
|
record = session.query(TaskRecord).filter_by(id="test-uuid-1").first()
|
||||||
|
session.close()
|
||||||
|
assert record is None # rolled back
|
||||||
|
# #endregion test_persist_task_db_error_rollback
|
||||||
|
|
||||||
|
# #region test_load_tasks_with_non_dict_logs [C:2] [TYPE Function]
|
||||||
|
# @PURPOSE: Load tasks where log entries are not dicts (skipped gracefully).
|
||||||
|
def test_load_tasks_with_non_dict_logs(self):
|
||||||
|
task = self._make_task(status=TaskStatus.SUCCESS)
|
||||||
|
with self._patched():
|
||||||
|
self.service.persist_task(task)
|
||||||
|
|
||||||
|
# Manually inject non-dict log data
|
||||||
|
session = self.TestSessionLocal()
|
||||||
|
record = session.query(TaskRecord).filter_by(id="test-uuid-1").first()
|
||||||
|
record.logs = [42, "not-a-dict"]
|
||||||
|
session.commit()
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
with self._patched():
|
||||||
|
loaded = self.service.load_tasks()
|
||||||
|
assert len(loaded) == 1
|
||||||
|
assert loaded[0].id == "test-uuid-1"
|
||||||
|
assert loaded[0].logs == [] # non-dict entries skipped
|
||||||
|
# #endregion test_load_tasks_with_non_dict_logs
|
||||||
|
|
||||||
|
# #region test_load_tasks_corrupt_record_skipped [C:2] [TYPE Function]
|
||||||
|
# @PURPOSE: Load tasks gracefully skips records that fail reconstruction.
|
||||||
|
def test_load_tasks_corrupt_record_skipped(self):
|
||||||
|
session = self.TestSessionLocal()
|
||||||
|
# Create a record with invalid status value
|
||||||
|
record = TaskRecord(id="corrupt-1", type="test", status="INVALID_STATUS")
|
||||||
|
session.add(record)
|
||||||
|
session.commit()
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
with self._patched():
|
||||||
|
loaded = self.service.load_tasks()
|
||||||
|
# Corrupt record should be skipped, no exception raised
|
||||||
|
ids = [t.id for t in loaded]
|
||||||
|
assert "corrupt-1" not in ids
|
||||||
|
# #endregion test_load_tasks_corrupt_record_skipped
|
||||||
|
|
||||||
|
# #region test_delete_tasks_db_error [C:2] [TYPE Function]
|
||||||
|
# @PURPOSE: delete_tasks handles database error gracefully.
|
||||||
|
def test_delete_tasks_db_error(self):
|
||||||
|
# Pre-create a task
|
||||||
|
task = self._make_task(id="del-err")
|
||||||
|
with self._patched():
|
||||||
|
self.service.persist_task(task)
|
||||||
|
|
||||||
|
# Force error during delete
|
||||||
|
def broken_session():
|
||||||
|
s = self.TestSessionLocal()
|
||||||
|
original_commit = s.commit
|
||||||
|
s.commit = lambda: (_ for _ in ()).throw(RuntimeError("delete failed"))
|
||||||
|
return s
|
||||||
|
with patch("src.core.task_manager.persistence.TasksSessionLocal", broken_session):
|
||||||
|
self.service.delete_tasks(["del-err"]) # Should not raise
|
||||||
|
|
||||||
|
# Task still exists
|
||||||
|
session = self.TestSessionLocal()
|
||||||
|
record = session.query(TaskRecord).filter_by(id="del-err").first()
|
||||||
|
session.close()
|
||||||
|
assert record is not None
|
||||||
|
# #endregion test_delete_tasks_db_error
|
||||||
|
|
||||||
# #endregion TestTaskPersistenceService
|
# #endregion TestTaskPersistenceService
|
||||||
# #endregion test_task_persistence
|
# #endregion test_task_persistence
|
||||||
|
|||||||
Reference in New Issue
Block a user