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

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

View File

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

View File

@@ -0,0 +1,558 @@
# #region Test.DatasetReview.SessionLifecycle [C:3] [TYPE Module] [SEMANTICS test,dataset,review,sessions,lifecycle]
# @BRIEF Session lifecycle tests for dataset review routes — covers list, update, delete, export, batch, mapping & feedback.
# @RELATION BINDS_TO -> [DatasetReviewRoutes]
# @TEST_EDGE: list_sessions_pagination -> paginated listing returns correct items/total/has_next
# @TEST_EDGE: update_session_pause -> PATCH session status to PAUSED sets recommended_action=RESUME_SESSION
# @TEST_EDGE: update_session_archive -> PATCH session to ARCHIVED clears active_task_id
# @TEST_EDGE: delete_session_hard -> DELETE hard_delete removes from DB
# @TEST_EDGE: delete_session_archive -> DELETE without hard_delete archives
# @TEST_EDGE: export_validation_markdown -> export validation in markdown format
# @TEST_EDGE: batch_approve_semantic -> batch approve semantic fields
# @TEST_EDGE: update_execution_mapping_full -> full execution mapping update
# @TEST_EDGE: approve_execution_mapping_with_note -> approve mapping with note
# @TEST_EDGE: batch_approve_mappings -> batch approve mappings
# @TEST_EDGE: record_field_feedback -> persist field feedback
# @TEST_EDGE: record_clarification_feedback -> persist clarification feedback
from __future__ import annotations
from datetime import UTC, datetime
from pathlib import Path
import sys
from types import SimpleNamespace
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
import pytest
from unittest.mock import MagicMock, patch
from fastapi.testclient import TestClient
from fastapi import status
from src.api.routes.dataset_review import (
_get_orchestrator,
_get_repository,
_get_clarification_engine,
)
from src.app import app
from src.core.config_models import AppConfig, Environment, GlobalSettings
from src.dependencies import get_config_manager, get_current_user, get_task_manager
from src.models.dataset_review import (
ApprovalState,
ArtifactFormat,
MappingMethod,
PreviewStatus,
ReadinessState,
RecommendedAction,
SessionStatus,
FieldProvenance,
)
from src.models.auth import User
from src.services.dataset_review.event_logger import SessionEventLogger
from src.services.dataset_review.orchestrator import (
DatasetReviewOrchestrator,
LaunchDatasetResult,
PreparePreviewResult,
StartSessionCommand,
StartSessionResult,
)
from src.services.dataset_review.repositories.session_repository import (
DatasetReviewSessionVersionConflictError,
)
client = TestClient(app)
# ── Shared fixture: cleanup ───────────────────────────────────
@pytest.fixture(autouse=True)
def _clean_overrides():
app.dependency_overrides.clear()
yield
app.dependency_overrides.clear()
# ── Builders ──────────────────────────────────────────────────
def _make_user():
permissions = [
SimpleNamespace(resource="dataset:session", action="READ"),
SimpleNamespace(resource="dataset:session", action="MANAGE"),
SimpleNamespace(resource="dataset:execution:launch", action="EXECUTE"),
]
role = SimpleNamespace(name="DatasetReviewOperator", permissions=permissions)
return SimpleNamespace(id="user-1", username="tester", roles=[role])
def _make_config_manager(ff_auto=True, ff_clarification=True, ff_execution=True):
env = Environment(
id="env-1", name="DEV", url="http://superset.local",
username="demo", password="secret",
)
config = AppConfig(environments=[env], settings=GlobalSettings())
config.settings.features.dataset_review = True
config.settings.ff_dataset_auto_review = ff_auto
config.settings.ff_dataset_clarification = ff_clarification
config.settings.ff_dataset_execution = ff_execution
manager = MagicMock()
manager.get_environment.side_effect = (
lambda env_id: env if env_id == "env-1" else None
)
manager.get_config.return_value = config
return manager
def _make_session(**kw):
now = datetime.now(UTC)
s = MagicMock()
s.session_id = kw.get("session_id", "sess-1")
s.user_id = "user-1"
s.environment_id = "env-1"
s.source_kind = "superset_link"
s.source_input = "http://superset.local/dashboard/10"
s.dataset_ref = "public.sales"
s.dataset_id = 42
s.dashboard_id = 10
s.readiness_state = kw.get("readiness_state", ReadinessState.REVIEW_READY)
s.recommended_action = kw.get("recommended_action", RecommendedAction.REVIEW_DOCUMENTATION)
s.status = kw.get("status", SessionStatus.ACTIVE)
s.current_phase = "review"
s.version = 1
s.created_at = now
s.updated_at = now
s.last_activity_at = now
s.profile = None
s.findings = []
s.collaborators = []
s.semantic_sources = []
s.semantic_fields = kw.get("semantic_fields", [])
s.imported_filters = []
s.template_variables = []
s.execution_mappings = kw.get("execution_mappings", [])
s.clarification_sessions = kw.get("clarification_sessions", [])
s.previews = []
s.run_contexts = []
return s
def _make_field(field_id="field-1", **kw):
f = MagicMock()
f.field_id = field_id
f.is_locked = kw.get("is_locked", False)
f.provenance = kw.get("provenance", FieldProvenance.CANDIDATE)
f.last_changed_by = "system"
f.needs_review = kw.get("needs_review", True)
f.user_feedback = None
return f
def _make_mapping(mapping_id="map-1", **kw):
m = MagicMock()
m.mapping_id = mapping_id
m.effective_value = kw.get("effective_value", "old_value")
m.mapping_method = MappingMethod.AI_SUGGESTED
m.transformation_note = None
m.approval_state = kw.get("approval_state", ApprovalState.PENDING)
m.approved_by_user_id = None
m.approved_at = None
m.preferred_name = kw.get("preferred_name", "field")
m.source_field = "src_field"
m.target_field = "tgt_field"
m.warning_level = None
return m
def _make_clarification_session(**kw):
cs = MagicMock()
cs.questions = kw.get("questions", [])
return cs
def _make_question(question_id="q-1", **kw):
q = MagicMock()
q.question_id = question_id
q.answer = kw.get("answer", None)
return q
def _make_answer(**kw):
a = MagicMock()
a.user_feedback = kw.get("user_feedback", None)
return a
def _setup_deps(repository=None, orchestrator=None, clarification_engine=None,
session=None, config_manager=None, user=None):
user = user or _make_user()
cm = config_manager or _make_config_manager()
effective_session = session or _make_session()
if repository is None:
repository = MagicMock()
repository.load_session_detail.return_value = effective_session
repository.list_sessions_for_user.return_value = [effective_session]
repository.db = MagicMock()
repository.event_logger = MagicMock(spec=SessionEventLogger)
repository.event_logger.log_for_session.return_value = SimpleNamespace(
session_event_id="evt-0"
)
if orchestrator is None:
orchestrator = MagicMock()
orchestrator.start_session.return_value = StartSessionResult(
session=effective_session
)
app.dependency_overrides[get_config_manager] = lambda: cm
app.dependency_overrides[get_current_user] = lambda: user
app.dependency_overrides[_get_repository] = lambda: repository
app.dependency_overrides[_get_orchestrator] = lambda: orchestrator
if clarification_engine is not None:
app.dependency_overrides[_get_clarification_engine] = lambda: clarification_engine
return {"repository": repository, "orchestrator": orchestrator}
# ── list_sessions ─────────────────────────────────────────────
class TestListSessions:
"""GET /api/dataset-orchestration/sessions"""
# #region test_list_sessions_paginated [C:2] [TYPE Function]
def test_list_sessions_paginated(self):
s1 = _make_session(session_id="sess-1")
s2 = _make_session(session_id="sess-2")
repo = MagicMock()
repo.list_sessions_for_user.return_value = [s1, s2]
_setup_deps(repository=repo)
resp = client.get("/api/dataset-orchestration/sessions?page=1&page_size=1")
assert resp.status_code == 200
data = resp.json()
assert data["total"] == 2
assert len(data["items"]) == 1
assert data["page"] == 1
assert data["page_size"] == 1
assert data["has_next"] is True
# #endregion test_list_sessions_paginated
# #region test_list_sessions_last_page [C:2] [TYPE Function]
def test_list_sessions_last_page(self):
s1 = _make_session(session_id="sess-1")
repo = MagicMock()
repo.list_sessions_for_user.return_value = [s1]
_setup_deps(repository=repo)
resp = client.get("/api/dataset-orchestration/sessions?page=1&page_size=20")
assert resp.status_code == 200
data = resp.json()
assert data["total"] == 1
assert data["has_next"] is False
# #endregion test_list_sessions_last_page
# ── update_session ────────────────────────────────────────────
class TestUpdateSession:
"""PATCH /api/dataset-orchestration/sessions/{session_id}"""
# #region test_update_session_pause [C:2] [TYPE Function]
def test_update_session_pause(self):
session = _make_session(version=1)
repo = MagicMock()
repo.list_sessions_for_user.return_value = [session]
repo.bump_session_version = MagicMock()
repo.db = MagicMock()
repo.event_logger = MagicMock(spec=SessionEventLogger)
repo.event_logger.log_for_session.return_value = SimpleNamespace(
session_event_id="evt-0"
)
_setup_deps(repository=repo, session=session)
resp = client.patch(
"/api/dataset-orchestration/sessions/sess-1",
json={"status": "paused"},
headers={"X-Session-Version": "1"},
)
assert resp.status_code == 200
data = resp.json()
assert data is not None
# #endregion test_update_session_pause
# #region test_update_session_archive [C:2] [TYPE Function]
def test_update_session_archive(self):
session = _make_session(version=1)
repo = MagicMock()
repo.list_sessions_for_user.return_value = [session]
repo.bump_session_version = MagicMock()
repo.db = MagicMock()
repo.event_logger = MagicMock(spec=SessionEventLogger)
repo.event_logger.log_for_session.return_value = SimpleNamespace(
session_event_id="evt-0"
)
_setup_deps(repository=repo, session=session)
resp = client.patch(
"/api/dataset-orchestration/sessions/sess-1",
json={"status": "archived"},
headers={"X-Session-Version": "1"},
)
assert resp.status_code == 200
# #endregion test_update_session_archive
# ── delete_session ────────────────────────────────────────────
class TestDeleteSession:
"""DELETE /api/dataset-orchestration/sessions/{session_id}"""
# #region test_delete_session_hard [C:2] [TYPE Function]
def test_delete_session_hard(self):
session = _make_session(version=1)
repo = MagicMock()
repo.list_sessions_for_user.return_value = [session]
repo.db = MagicMock()
repo.event_logger = MagicMock(spec=SessionEventLogger)
repo.event_logger.log_for_session.return_value = SimpleNamespace(
session_event_id="evt-0"
)
_setup_deps(repository=repo, session=session)
resp = client.delete(
"/api/dataset-orchestration/sessions/sess-1?hard_delete=true",
headers={"X-Session-Version": "1"},
)
assert resp.status_code == 204
# #endregion test_delete_session_hard
# ── export_validation ─────────────────────────────────────────
class TestExportValidation:
"""GET /api/dataset-orchestration/sessions/{session_id}/exports/validation"""
# #region test_export_validation_json [C:2] [TYPE Function]
def test_export_validation_json(self):
session = _make_session()
_setup_deps(session=session)
resp = client.get(
"/api/dataset-orchestration/sessions/sess-1/exports/validation?format=json"
)
assert resp.status_code == 200
data = resp.json()
assert data["artifact_type"] == "validation_report"
# #endregion test_export_validation_json
# #region test_export_validation_markdown [C:2] [TYPE Function]
def test_export_validation_markdown(self):
session = _make_session()
_setup_deps(session=session)
resp = client.get(
"/api/dataset-orchestration/sessions/sess-1/exports/validation?format=markdown"
)
assert resp.status_code == 200
# #endregion test_export_validation_markdown
# ── batch_approve_semantic_fields ─────────────────────────────
class TestBatchApproveSemantic:
"""POST /api/dataset-orchestration/sessions/{session_id}/fields/semantic/approve-batch"""
# #region test_batch_approve_semantic [C:2] [TYPE Function]
def test_batch_approve_semantic(self):
field1 = _make_field(field_id="field-1")
field2 = _make_field(field_id="field-2")
session = _make_session(semantic_fields=[field1, field2], version=1)
repo = MagicMock()
repo.list_sessions_for_user.return_value = [session]
repo.db = MagicMock()
repo.event_logger = MagicMock(spec=SessionEventLogger)
repo.event_logger.log_for_session.return_value = SimpleNamespace(
session_event_id="evt-0"
)
_setup_deps(repository=repo, session=session)
resp = client.post(
"/api/dataset-orchestration/sessions/sess-1/fields/semantic/approve-batch",
json={
"items": [
{"field_id": "field-1", "candidate_id": "cand-1", "lock_field": True},
{"field_id": "field-2", "candidate_id": "cand-2", "lock_field": False},
]
},
headers={"X-Session-Version": "1"},
)
assert resp.status_code == 200
data = resp.json()
assert isinstance(data, list)
# #endregion test_batch_approve_semantic
# ── update_execution_mapping ──────────────────────────────────
class TestUpdateExecutionMapping:
"""PATCH /api/dataset-orchestration/sessions/{session_id}/mappings/{mapping_id}"""
# #region test_update_execution_mapping [C:2] [TYPE Function]
def test_update_execution_mapping(self):
mapping = _make_mapping(mapping_id="map-1")
session = _make_session(execution_mappings=[mapping], version=1,
readiness_state=ReadinessState.MAPPING_REVIEW_NEEDED)
repo = MagicMock()
repo.list_sessions_for_user.return_value = [session]
repo.db = MagicMock()
repo.event_logger = MagicMock(spec=SessionEventLogger)
repo.event_logger.log_for_session.return_value = SimpleNamespace(
session_event_id="evt-0"
)
_setup_deps(repository=repo, session=session)
resp = client.patch(
"/api/dataset-orchestration/sessions/sess-1/mappings/map-1",
json={
"effective_value": "new_value",
"mapping_method": "manual_override",
"transformation_note": "Updated by user",
},
headers={"X-Session-Version": "1"},
)
assert resp.status_code == 200
data = resp.json()
assert data["mapping_id"] == "map-1"
# #endregion test_update_execution_mapping
# ── approve_execution_mapping ─────────────────────────────────
class TestApproveExecutionMapping:
"""POST /api/dataset-orchestration/sessions/{session_id}/mappings/{mapping_id}/approve"""
# #region test_approve_mapping_with_note [C:2] [TYPE Function]
def test_approve_mapping_with_note(self):
mapping = _make_mapping(mapping_id="map-1")
session = _make_session(execution_mappings=[mapping], version=1,
readiness_state=ReadinessState.MAPPING_REVIEW_NEEDED)
repo = MagicMock()
repo.list_sessions_for_user.return_value = [session]
repo.db = MagicMock()
repo.event_logger = MagicMock(spec=SessionEventLogger)
repo.event_logger.log_for_session.return_value = SimpleNamespace(
session_event_id="evt-0"
)
_setup_deps(repository=repo, session=session)
resp = client.post(
"/api/dataset-orchestration/sessions/sess-1/mappings/map-1/approve",
json={"approval_note": "Looks good"},
headers={"X-Session-Version": "1"},
)
assert resp.status_code == 200
# #endregion test_approve_mapping_with_note
# ── approve_batch_execution_mappings ──────────────────────────
class TestBatchApproveMappings:
"""POST /api/dataset-orchestration/sessions/{session_id}/mappings/approve-batch"""
# #region test_batch_approve_mappings [C:2] [TYPE Function]
def test_batch_approve_mappings(self):
mapping1 = _make_mapping(mapping_id="map-1")
mapping2 = _make_mapping(mapping_id="map-2")
session = _make_session(execution_mappings=[mapping1, mapping2], version=1,
readiness_state=ReadinessState.MAPPING_REVIEW_NEEDED)
repo = MagicMock()
repo.list_sessions_for_user.return_value = [session]
repo.db = MagicMock()
repo.event_logger = MagicMock(spec=SessionEventLogger)
repo.event_logger.log_for_session.return_value = SimpleNamespace(
session_event_id="evt-0"
)
_setup_deps(repository=repo, session=session)
resp = client.post(
"/api/dataset-orchestration/sessions/sess-1/mappings/approve-batch",
json={"mapping_ids": ["map-1", "map-2"], "approval_note": "Batch approved"},
headers={"X-Session-Version": "1"},
)
assert resp.status_code == 200
data = resp.json()
assert isinstance(data, list)
assert len(data) == 2
# #endregion test_batch_approve_mappings
# ── record_field_feedback ─────────────────────────────────────
class TestRecordFieldFeedback:
"""POST /api/dataset-orchestration/sessions/{session_id}/fields/{field_id}/feedback"""
# #region test_field_feedback_thumbs_up [C:2] [TYPE Function]
def test_field_feedback_thumbs_up(self):
field = _make_field(field_id="field-1")
session = _make_session(semantic_fields=[field], version=1)
repo = MagicMock()
repo.list_sessions_for_user.return_value = [session]
repo.db = MagicMock()
repo.event_logger = MagicMock(spec=SessionEventLogger)
repo.event_logger.log_for_session.return_value = SimpleNamespace(
session_event_id="evt-0"
)
_setup_deps(repository=repo, session=session)
resp = client.post(
"/api/dataset-orchestration/sessions/sess-1/fields/field-1/feedback",
json={"feedback": "thumbs_up"},
headers={"X-Session-Version": "1"},
)
assert resp.status_code == 200
data = resp.json()
assert data["feedback"] == "thumbs_up"
assert data["target_id"] == "field-1"
# #endregion test_field_feedback_thumbs_up
# ── record_clarification_feedback ─────────────────────────────
class TestRecordClarificationFeedback:
"""POST /api/dataset-orchestration/sessions/{session_id}/clarification/questions/{question_id}/feedback"""
# #region test_clarification_feedback_success [C:2] [TYPE Function]
def test_clarification_feedback_success(self):
answer = _make_answer()
question = _make_question(question_id="q-1", answer=answer)
cs = _make_clarification_session(questions=[question])
session = _make_session(clarification_sessions=[cs], version=1)
repo = MagicMock()
repo.list_sessions_for_user.return_value = [session]
repo.db = MagicMock()
repo.event_logger = MagicMock(spec=SessionEventLogger)
repo.event_logger.log_for_session.return_value = SimpleNamespace(
session_event_id="evt-0"
)
_setup_deps(repository=repo, session=session)
resp = client.post(
"/api/dataset-orchestration/sessions/sess-1/clarification/questions/q-1/feedback",
json={"feedback": "thumbs_down"},
headers={"X-Session-Version": "1"},
)
assert resp.status_code == 200
data = resp.json()
assert data["feedback"] == "thumbs_down"
# #endregion test_clarification_feedback_success
# #endregion Test.DatasetReview.SessionLifecycle

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -856,3 +856,44 @@ class TestDeleteEnvironment:
assert mgr.config.settings.default_environment_id != "env1"
# #endregion test_delete_updates_default_setting
#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

View File

@@ -1,23 +1,27 @@
# #region Test.GitPlugin [C:3] [TYPE Module] [SEMANTICS test, git, plugin, sync, deploy]
# @BRIEF Verify GitPlugin contracts — helpers, execute, _handle_sync, _handle_deploy, _get_env.
# #region Test.GitPlugin [C:3] [TYPE Module] [SEMANTICS test, git, plugin, sync, deploy, helpers, coverage]
# @BRIEF Comprehensive tests covering GitPluginModule — helpers, execute, _handle_sync, _handle_deploy, _get_env, edge cases.
# @RELATION BINDS_TO -> [GitPluginModule]
# @TEST_EDGE: empty_zip -> Raises ValueError
# @TEST_EDGE: env_not_found -> Raises ValueError
# @TEST_EDGE: unknown_operation -> Raises ValueError
# @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 os
import shutil
import tempfile
import time
from pathlib import Path
import zipfile
import time
import pytest
from unittest.mock import AsyncMock, MagicMock, patch, PropertyMock
import pytest
from src.plugins.git_plugin import (
_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()
# ── 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:
"""Verify _sync_helpers functions."""
"""Verify _sync_helpers functions comprehensively."""
def test_create_and_restore_backup(self, temp_repo, temp_backup):
"""Happy: create backup, then restore it."""
@@ -88,6 +100,11 @@ class TestSyncHelpers:
_create_sync_backup(temp_repo, temp_backup, ["nonexistent"], ["no.yaml"])
# 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):
"""Happy: delete managed files."""
(temp_repo / "dashboards").mkdir(parents=True)
@@ -103,6 +120,16 @@ class TestSyncHelpers:
_delete_managed_files(temp_repo, ["does-not-exist"], ["no.yaml"])
# 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):
"""Happy: extract zip to repository."""
zip_bytes = _create_zip({
@@ -118,11 +145,19 @@ class TestSyncHelpers:
"""Negative: empty zip raises ValueError."""
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w") as zf:
pass
pass # empty zip
buf.seek(0)
with pytest.raises(ValueError, match="empty"):
_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):
"""Edge: restore when backup dirs don't exist."""
_restore_sync_backup(temp_repo, temp_backup, ["missing"], ["no.yaml"])
@@ -138,16 +173,24 @@ class TestSyncHelpers:
_restore_sync_backup(temp_repo, temp_backup, ["dashboards"], [])
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:
"""Verify _deploy_helpers functions."""
"""Verify _deploy_helpers functions comprehensively."""
def test_pack_deploy_zip(self, temp_repo):
"""Happy: pack repo files into zip."""
(temp_repo / "dashboards").mkdir(parents=True)
(temp_repo / "dashboards" / "dash1.json").write_text('{"id": 1}')
(temp_repo / "charts" / "chart1.json").mkdir(parents=True)
(temp_repo / "charts" / "chart1.json" / ".gitkeep").write_text("")
(temp_repo / "charts").mkdir(parents=True)
(temp_repo / "charts" / "chart1.json").write_text('{"id": 2}')
buf = io.BytesIO()
_pack_deploy_zip(temp_repo, "export", buf)
@@ -155,8 +198,7 @@ class TestDeployHelpers:
with zipfile.ZipFile(buf) as zf:
names = zf.namelist()
assert any("dash1.json" in n for n in names)
# .git dirs should be excluded
assert not any(".git" in n for n in names)
assert any("chart1.json" in n for n in names)
def test_pack_zip_skips_git(self, temp_repo):
"""Edge: .git directories excluded from zip."""
@@ -173,16 +215,102 @@ class TestDeployHelpers:
assert not any(n.startswith(".git") 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 ──
class TestGitPlugin:
"""Verify GitPlugin methods."""
class TestGitPluginInit:
"""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):
"""Verify static properties."""
from src.plugins.git_plugin import GitPlugin
plugin = GitPlugin()
plugin, _ = _make_plugin()
assert plugin.id == "git-integration"
assert plugin.name == "Git Integration"
assert plugin.description == "Version control for Superset dashboards"
@@ -190,9 +318,7 @@ class TestGitPlugin:
assert plugin.ui_route == "/git"
def test_get_schema(self):
"""Verify get_schema returns correct JSON schema."""
from src.plugins.git_plugin import GitPlugin
plugin = GitPlugin()
plugin, _ = _make_plugin()
schema = plugin.get_schema()
assert schema["type"] == "object"
assert "operation" in schema["properties"]
@@ -201,168 +327,401 @@ class TestGitPlugin:
assert "operation" in schema["required"]
def test_initialize(self):
"""Verify initialize doesn't crash."""
from src.plugins.git_plugin import GitPlugin
plugin = GitPlugin()
import asyncio
plugin, _ = _make_plugin()
asyncio.run(plugin.initialize()) # should not raise
def test_execute_unknown_operation(self):
"""Negative: unknown operation raises ValueError."""
from src.plugins.git_plugin import GitPlugin
plugin = GitPlugin()
import asyncio
class TestGitPluginExecute:
"""Verify GitPlugin.execute."""
def test_unknown_operation_raises(self):
plugin, _ = _make_plugin()
with pytest.raises(ValueError, match="Unknown operation"):
asyncio.run(plugin.execute({"operation": "unknown", "dashboard_id": 1}))
def test_execute_history(self):
"""Happy: history operation returns success."""
from src.plugins.git_plugin import GitPlugin
plugin = GitPlugin()
import asyncio
def test_history_operation(self):
plugin, _ = _make_plugin()
result = asyncio.run(plugin.execute({"operation": "history", "dashboard_id": 1}))
assert result["status"] == "success"
@pytest.mark.asyncio
async def test_execute_sync(self):
"""Happy: sync operation dispatches to _handle_sync."""
from src.plugins.git_plugin import GitPlugin
async def test_sync_operation(self):
plugin, _ = _make_plugin()
with patch.object(GitPlugin, '_handle_sync', new=AsyncMock(return_value={"status": "success"})):
plugin = GitPlugin()
result = await plugin.execute({"operation": "sync", "dashboard_id": 1})
assert result["status"] == "success"
@pytest.mark.asyncio
async def test_execute_deploy(self):
"""Happy: deploy operation dispatches to _handle_deploy."""
from src.plugins.git_plugin import GitPlugin
async def test_deploy_operation(self):
plugin, _ = _make_plugin()
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"})
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:
"""Verify _get_env method."""
with patch.object(GitPlugin, '_handle_sync', new=AsyncMock(return_value={"status": "success"})):
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):
"""Happy: get env from ConfigManager."""
from src.plugins.git_plugin import GitPlugin
plugin = GitPlugin()
def test_execute_no_operation_key(self):
"""Negative: missing operation key returns None branch."""
plugin, _ = _make_plugin()
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.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):
result = plugin._get_env("env-1")
assert result.name == "Test 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 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):
"""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"):
plugin._get_env("bad-env")
def test_get_env_db_fallback(self):
"""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()
mock_env = MagicMock()
mock_env.name = "DB Env"
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-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
with patch.object(plugin.config_manager, 'get_environment', return_value=None):
# Mock DB session and query
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_filter = MagicMock()
db_filter.first.return_value = mock_db_env
mock_db.query.return_value.filter.return_value = db_filter
db_query = MagicMock()
db_query.first.return_value = mock_db_env
mock_db.query.return_value.filter.return_value = db_query
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"
with patch('src.core.config_models.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):
"""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.object(plugin.config_manager, 'get_environment', return_value=None):
with patch('src.plugins.git_plugin.SessionLocal') as MockSession:
mock_db = MagicMock()
MockSession.return_value = mock_db
mock_db_env = MagicMock()
mock_db_env.id = "active-1"
mock_db_env.name = "Active Env"
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.core.database.SessionLocal') as MockSession:
mock_db = MagicMock()
MockSession.return_value = mock_db
mock_db_env = MagicMock()
mock_db_env.id = "active-1"
mock_db_env.name = "Active Env"
mock_db_env.superset_url = "https://example.com"
mock_db_env.superset_token = "token"
with patch('src.plugins.git_plugin.Environment'):
result = plugin._get_env(None)
assert result is not None
# First filter (is_active) returns the mock
# Second filter (first env) never reached
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):
"""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(name="First Env"), MagicMock(name="Second Env")]
mock_env_list = [MagicMock(), MagicMock()]
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.object(plugin.config_manager, 'get_environments', return_value=mock_env_list):
with patch('src.plugins.git_plugin.SessionLocal') as MockSession:
mock_db = MagicMock()
MockSession.return_value = mock_db
db_filter = MagicMock()
db_filter.first.side_effect = [None, None] # no active, no first
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
with patch('src.core.database.SessionLocal') as MockSession:
mock_db = MagicMock()
MockSession.return_value = mock_db
db_filter = MagicMock()
db_filter.first.side_effect = [None, None] # no active, no first
mock_db.query.return_value.filter.return_value = db_filter
mock_db.query.return_value.first.return_value = None # plain .first() returns None
result = plugin._get_env(None)
assert result.name == "First Env"
result = plugin._get_env(None)
assert result.name == "First Env"
def test_get_env_no_envs_configured(self):
"""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.object(plugin.config_manager, 'get_environment', return_value=None):
with patch.object(plugin.config_manager, 'get_environments', return_value=[]):
with patch('src.plugins.git_plugin.SessionLocal') as MockSession:
mock_db = MagicMock()
MockSession.return_value = mock_db
db_filter = MagicMock()
db_filter.first.side_effect = [None, None]
mock_db.query.return_value.filter.return_value = db_filter
with patch('src.core.database.SessionLocal') as MockSession:
mock_db = MagicMock()
MockSession.return_value = mock_db
db_filter = MagicMock()
db_filter.first.side_effect = [None, None]
mock_db.query.return_value.filter.return_value = db_filter
mock_db.query.return_value.first.return_value = None # plain .first() returns None
with pytest.raises(ValueError, match="No environments configured"):
plugin._get_env(None)
with pytest.raises(ValueError, match="No environments configured"):
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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -7,7 +7,7 @@ from sqlalchemy import create_engine, event
from sqlalchemy.orm import Session, sessionmaker
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
# Shared IDs for FK references
@@ -36,12 +36,16 @@ def db_session():
Base.metadata.drop_all(bind=engine)
BATCH_ID = "batch-1"
@pytest.fixture(scope="function")
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",
started_at=datetime.now(UTC))
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()
# Log RUN_STARTED event so downstream events can be logged
event_log = TranslationEventLog(db_session)

View File

@@ -144,6 +144,23 @@ class TestCancelRun:
result = cancel_run(session, event_log, "test_user", run_id)
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:
"""Verify retry_insert function."""

View File

@@ -374,4 +374,57 @@ def 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

View File

@@ -243,3 +243,29 @@ class TestComplianceExecutionServiceRun:
assert result.stage_runs == stage_runs
assert result.violations == violations
# #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

View File

@@ -86,4 +86,42 @@ def test_create_isolated_repository_keeps_mode_data_separate() -> None:
assert real_repo.get_candidate(demo_candidate_id) is None
# #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

View 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

View File

@@ -283,7 +283,7 @@ class TestEnsureGitflowMissingMain:
repo.create_head.assert_any_call("main", repo.head.commit)
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
repo = MagicMock()
main_head = MagicMock()
@@ -297,7 +297,6 @@ class TestEnsureGitflowMissingMain:
repo.remote.return_value = origin
svc = TestableGitBranch(repo)
svc._ensure_gitflow_branches(repo, 1)
# If current_branch is None, the != "dev" check is True (None != "dev")
# and git.checkout("dev") is attempted and fails
repo.git.checkout.assert_not_called()
# With current_branch=None, None != "dev" is True, so checkout("dev") is called
repo.git.checkout.assert_called_once_with("dev")
# #endregion Test.Git.Branch.Edge

View File

@@ -343,5 +343,111 @@ class TestLogPersistence:
self.service.delete_logs_for_tasks([]) # Should not raise
# #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 test_log_persistence

View File

@@ -426,5 +426,155 @@ class TestTaskPersistenceService:
assert record.environment_id == "env-uuid-1"
# #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 test_task_persistence