test: 6 agents — +52 test files across core, task_manager, translate routes, git/storage/migration routes, dataset_review deps/routes, settings. Fixed 4 failures
This commit is contained in:
354
backend/tests/api/test_agent_conversations.py
Normal file
354
backend/tests/api/test_agent_conversations.py
Normal file
@@ -0,0 +1,354 @@
|
||||
# #region Test.Api.AgentConversations [C:3] [TYPE Module] [SEMANTICS test,agent,conversations,routes]
|
||||
# @BRIEF Tests for agent conversation API routes — list, save, history, delete, active, llm-config.
|
||||
# @RELATION BINDS_TO -> [AgentChat.Api.Conversations]
|
||||
|
||||
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 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.agent_conversations import router, agent_router
|
||||
from src.core.database import get_db
|
||||
from src.dependencies import get_current_user, get_config_manager
|
||||
from src.schemas.auth import User, RoleSchema
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(router)
|
||||
app.include_router(agent_router)
|
||||
|
||||
mock_user = User(
|
||||
id="user-1", username="testuser", email="test@x.com",
|
||||
auth_source="LOCAL",
|
||||
created_at=__import__("datetime").datetime.now(),
|
||||
roles=[RoleSchema(id="r1", name="User", description="", permissions=[])],
|
||||
)
|
||||
|
||||
app.dependency_overrides[get_db] = lambda: MagicMock()
|
||||
app.dependency_overrides[get_current_user] = lambda: mock_user
|
||||
app.dependency_overrides[get_config_manager] = lambda: MagicMock()
|
||||
if overrides:
|
||||
for dep, fn in overrides.items():
|
||||
app.dependency_overrides[dep] = fn
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
# ── list_conversations ──
|
||||
|
||||
class TestListConversations:
|
||||
"""GET /api/assistant/conversations"""
|
||||
|
||||
def test_success(self):
|
||||
mock_db = MagicMock()
|
||||
mock_query = MagicMock()
|
||||
mock_db.query.return_value = mock_query
|
||||
mock_query.filter.return_value = mock_query
|
||||
mock_query.count.return_value = 2
|
||||
mock_conv = MagicMock()
|
||||
mock_conv.id = "conv-1"
|
||||
mock_conv.title = "Test Conversation"
|
||||
mock_conv.updated_at = None
|
||||
mock_conv.messages = [MagicMock()]
|
||||
mock_query.order_by.return_value.offset.return_value.limit.return_value.all.return_value = [mock_conv, mock_conv]
|
||||
|
||||
from src.core.database import get_db
|
||||
client = _make_client({get_db: lambda: mock_db})
|
||||
resp = client.get("/api/assistant/conversations")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert len(data["items"]) == 2
|
||||
assert data["items"][0]["id"] == "conv-1"
|
||||
|
||||
def test_with_search(self):
|
||||
mock_db = MagicMock()
|
||||
mock_query = MagicMock()
|
||||
mock_db.query.return_value = mock_query
|
||||
mock_query.filter.return_value = mock_query
|
||||
mock_query.count.return_value = 0
|
||||
mock_query.order_by.return_value.offset.return_value.limit.return_value.all.return_value = []
|
||||
|
||||
from src.core.database import get_db
|
||||
client = _make_client({get_db: lambda: mock_db})
|
||||
resp = client.get("/api/assistant/conversations?search=test&include_archived=true")
|
||||
assert resp.status_code == 200
|
||||
|
||||
def test_empty(self):
|
||||
mock_db = MagicMock()
|
||||
mock_query = MagicMock()
|
||||
mock_db.query.return_value = mock_query
|
||||
mock_query.filter.return_value = mock_query
|
||||
mock_query.count.return_value = 0
|
||||
mock_query.order_by.return_value.offset.return_value.limit.return_value.all.return_value = []
|
||||
|
||||
from src.core.database import get_db
|
||||
client = _make_client({get_db: lambda: mock_db})
|
||||
resp = client.get("/api/assistant/conversations")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["items"] == []
|
||||
|
||||
|
||||
# ── save_conversation ──
|
||||
|
||||
class TestSaveConversation:
|
||||
"""POST /api/agent/conversations/save"""
|
||||
|
||||
SAVE_PAYLOAD = {
|
||||
"conversation_id": "conv-1",
|
||||
"title": "New Chat",
|
||||
"messages": [
|
||||
{"id": "msg-1", "role": "user", "text": "Hello"},
|
||||
{"id": "msg-2", "role": "assistant", "text": "Hi there", "tool_calls": [{"name": "search"}]},
|
||||
],
|
||||
}
|
||||
|
||||
def test_create_new(self):
|
||||
mock_db = MagicMock()
|
||||
mock_db.query.return_value.filter.return_value.first.return_value = None
|
||||
|
||||
from src.core.database import get_db
|
||||
client = _make_client({get_db: lambda: mock_db})
|
||||
resp = client.post("/api/agent/conversations/save", json=self.SAVE_PAYLOAD)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["saved"] is True
|
||||
assert data["conversation_id"] == "conv-1"
|
||||
|
||||
def test_update_existing(self):
|
||||
existing_conv = MagicMock()
|
||||
mock_db = MagicMock()
|
||||
mock_db.query.return_value.filter.return_value.first.side_effect = [existing_conv, None]
|
||||
|
||||
from src.core.database import get_db
|
||||
client = _make_client({get_db: lambda: mock_db})
|
||||
resp = client.post("/api/agent/conversations/save", json=self.SAVE_PAYLOAD)
|
||||
assert resp.status_code == 200
|
||||
|
||||
def test_skip_empty_message_id(self):
|
||||
mock_db = MagicMock()
|
||||
mock_db.query.return_value.filter.return_value.first.return_value = None
|
||||
|
||||
from src.core.database import get_db
|
||||
client = _make_client({get_db: lambda: mock_db})
|
||||
resp = client.post("/api/agent/conversations/save", json={
|
||||
"conversation_id": "conv-1",
|
||||
"messages": [{"id": "", "role": "user", "text": "skip me"}],
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
|
||||
def test_skip_duplicate_message(self):
|
||||
existing_msg = MagicMock()
|
||||
mock_db = MagicMock()
|
||||
mock_db.query.return_value.filter.return_value.first.side_effect = [None, existing_msg]
|
||||
|
||||
from src.core.database import get_db
|
||||
client = _make_client({get_db: lambda: mock_db})
|
||||
resp = client.post("/api/agent/conversations/save", json=self.SAVE_PAYLOAD)
|
||||
assert resp.status_code == 200
|
||||
|
||||
|
||||
# ── get_history ──
|
||||
|
||||
class TestGetHistory:
|
||||
"""GET /api/assistant/history"""
|
||||
|
||||
def test_success(self):
|
||||
mock_db = MagicMock()
|
||||
mock_conv = MagicMock()
|
||||
mock_conv.id = "conv-1"
|
||||
mock_msg = MagicMock()
|
||||
mock_msg.id = "msg-1"
|
||||
mock_msg.conversation_id = "conv-1"
|
||||
mock_msg.role = "user"
|
||||
mock_msg.text = "Hello"
|
||||
mock_msg.tool_calls = None
|
||||
mock_msg.attachments = None
|
||||
mock_msg.created_at = None
|
||||
mock_conv.messages = [mock_msg]
|
||||
mock_db.query.return_value.filter.return_value.first.return_value = mock_conv
|
||||
|
||||
from src.core.database import get_db
|
||||
client = _make_client({get_db: lambda: mock_db})
|
||||
resp = client.get("/api/assistant/history?conversation_id=conv-1")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert len(data["items"]) == 1
|
||||
assert data["items"][0]["text"] == "Hello"
|
||||
|
||||
def test_not_found(self):
|
||||
mock_db = MagicMock()
|
||||
mock_db.query.return_value.filter.return_value.first.return_value = None
|
||||
|
||||
from src.core.database import get_db
|
||||
client = _make_client({get_db: lambda: mock_db})
|
||||
resp = client.get("/api/assistant/history?conversation_id=unknown")
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
# ── delete_conversation ──
|
||||
|
||||
class TestDeleteConversation:
|
||||
"""DELETE /api/assistant/conversations/{conversation_id}"""
|
||||
|
||||
def test_success(self):
|
||||
mock_db = MagicMock()
|
||||
mock_conv = MagicMock()
|
||||
mock_conv.is_archived = False
|
||||
mock_db.query.return_value.filter.return_value.first.return_value = mock_conv
|
||||
|
||||
from src.core.database import get_db
|
||||
client = _make_client({get_db: lambda: mock_db})
|
||||
resp = client.delete("/api/assistant/conversations/conv-1")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["deleted"] is True
|
||||
assert mock_conv.is_archived is True
|
||||
|
||||
def test_not_found(self):
|
||||
mock_db = MagicMock()
|
||||
mock_db.query.return_value.filter.return_value.first.return_value = None
|
||||
|
||||
from src.core.database import get_db
|
||||
client = _make_client({get_db: lambda: mock_db})
|
||||
resp = client.delete("/api/assistant/conversations/unknown")
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
# ── check_active_session ──
|
||||
|
||||
class TestCheckActiveSession:
|
||||
"""GET /api/agent/conversations/active"""
|
||||
|
||||
def test_returns_false(self):
|
||||
client = _make_client()
|
||||
resp = client.get("/api/agent/conversations/active")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["active"] is False
|
||||
|
||||
|
||||
# ── get_agent_llm_config ──
|
||||
|
||||
class TestGetAgentLlmConfig:
|
||||
"""GET /api/agent/llm-config"""
|
||||
|
||||
def test_success_with_preferred(self):
|
||||
mock_db = MagicMock()
|
||||
mock_config_mgr = MagicMock()
|
||||
mock_config = MagicMock()
|
||||
mock_config.settings.llm = {"assistant_planner_provider": "prov-1"}
|
||||
mock_config_mgr.get_config.return_value = mock_config
|
||||
|
||||
mock_svc = MagicMock()
|
||||
mock_provider = MagicMock()
|
||||
mock_provider.id = "prov-1"
|
||||
mock_provider.provider_type = "openai"
|
||||
mock_provider.base_url = "https://api.openai.com"
|
||||
mock_provider.default_model = "gpt-4"
|
||||
mock_provider.name = "GPT-4 Provider"
|
||||
mock_provider.is_active = True
|
||||
mock_svc.get_all_providers.return_value = [mock_provider]
|
||||
mock_svc.get_decrypted_api_key.return_value = "sk-real-key"
|
||||
|
||||
from src.core.database import get_db
|
||||
from src.dependencies import get_config_manager
|
||||
with patch("src.api.routes.agent_conversations.LLMProviderService", return_value=mock_svc):
|
||||
client = _make_client({
|
||||
get_db: lambda: mock_db,
|
||||
get_config_manager: lambda: mock_config_mgr,
|
||||
})
|
||||
resp = client.get("/api/agent/llm-config")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["configured"] is True
|
||||
assert data["provider_type"] == "openai"
|
||||
|
||||
def test_fallback_to_active(self):
|
||||
mock_db = MagicMock()
|
||||
mock_config_mgr = MagicMock()
|
||||
mock_config = MagicMock()
|
||||
mock_config.settings.llm = {}
|
||||
mock_config_mgr.get_config.return_value = mock_config
|
||||
|
||||
mock_svc = MagicMock()
|
||||
mock_provider = MagicMock()
|
||||
mock_provider.id = "prov-2"
|
||||
mock_provider.provider_type = "anthropic"
|
||||
mock_provider.base_url = ""
|
||||
mock_provider.default_model = "claude-3"
|
||||
mock_provider.name = "Claude"
|
||||
mock_provider.is_active = True
|
||||
mock_svc.get_all_providers.return_value = [mock_provider]
|
||||
mock_svc.get_decrypted_api_key.return_value = "sk-anthropic"
|
||||
|
||||
from src.core.database import get_db
|
||||
from src.dependencies import get_config_manager
|
||||
with patch("src.api.routes.agent_conversations.LLMProviderService", return_value=mock_svc):
|
||||
client = _make_client({
|
||||
get_db: lambda: mock_db,
|
||||
get_config_manager: lambda: mock_config_mgr,
|
||||
})
|
||||
resp = client.get("/api/agent/llm-config")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["configured"] is True
|
||||
|
||||
def test_no_active_provider(self):
|
||||
mock_db = MagicMock()
|
||||
mock_config_mgr = MagicMock()
|
||||
mock_config = MagicMock()
|
||||
mock_config.settings.llm = {}
|
||||
mock_config_mgr.get_config.return_value = mock_config
|
||||
|
||||
mock_svc = MagicMock()
|
||||
mock_svc.get_all_providers.return_value = []
|
||||
|
||||
from src.core.database import get_db
|
||||
from src.dependencies import get_config_manager
|
||||
with patch("src.api.routes.agent_conversations.LLMProviderService", return_value=mock_svc):
|
||||
client = _make_client({
|
||||
get_db: lambda: mock_db,
|
||||
get_config_manager: lambda: mock_config_mgr,
|
||||
})
|
||||
resp = client.get("/api/agent/llm-config")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["configured"] is False
|
||||
assert resp.json()["reason"] == "no_active_provider"
|
||||
|
||||
def test_invalid_api_key(self):
|
||||
mock_db = MagicMock()
|
||||
mock_config_mgr = MagicMock()
|
||||
mock_config = MagicMock()
|
||||
mock_config.settings.llm = {}
|
||||
mock_config_mgr.get_config.return_value = mock_config
|
||||
|
||||
mock_svc = MagicMock()
|
||||
mock_provider = MagicMock()
|
||||
mock_provider.id = "prov-1"
|
||||
mock_provider.is_active = True
|
||||
mock_svc.get_all_providers.return_value = [mock_provider]
|
||||
mock_svc.get_decrypted_api_key.return_value = ""
|
||||
|
||||
from src.core.database import get_db
|
||||
from src.dependencies import get_config_manager
|
||||
with patch("src.api.routes.agent_conversations.LLMProviderService", return_value=mock_svc):
|
||||
client = _make_client({
|
||||
get_db: lambda: mock_db,
|
||||
get_config_manager: lambda: mock_config_mgr,
|
||||
})
|
||||
resp = client.get("/api/agent/llm-config")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["configured"] is False
|
||||
assert resp.json()["reason"] == "invalid_api_key"
|
||||
# #endregion Test.Api.AgentConversations
|
||||
@@ -374,8 +374,9 @@ class TestDispatchDryRunSummary:
|
||||
@pytest.fixture
|
||||
def config_manager(self):
|
||||
cm = MagicMock()
|
||||
env = _make_env("env-dev", "Development")
|
||||
cm.get_environments = MagicMock(return_value=[env])
|
||||
env_dev = _make_env("env-dev", "Development")
|
||||
env_staging = _make_env("env-staging", "Staging")
|
||||
cm.get_environments = MagicMock(return_value=[env_dev, env_staging])
|
||||
return cm
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
1235
backend/tests/api/test_dataset_review_deps_unit.py
Normal file
1235
backend/tests/api/test_dataset_review_deps_unit.py
Normal file
File diff suppressed because it is too large
Load Diff
871
backend/tests/api/test_dataset_review_routes_extended.py
Normal file
871
backend/tests/api/test_dataset_review_routes_extended.py
Normal file
@@ -0,0 +1,871 @@
|
||||
#region Test.DatasetReview.RoutesExtended [C:2] [TYPE Module] [SEMANTICS test,dataset,review,routes,error,edge]
|
||||
# @BRIEF Extended API route tests for dataset_review_pkg/_routes.py — error paths and edge cases.
|
||||
# @RELATION BINDS_TO -> [DatasetReviewRoutes]
|
||||
# @TEST_EDGE: all_flags_disabled -> All endpoints return 404 when dataset_review feature is off
|
||||
# @TEST_EDGE: non_owner_mutation -> Mutation endpoints return 403 for non-owner access
|
||||
# @TEST_EDGE: missing_session_version_header -> 422 when X-Session-Version missing
|
||||
# @TEST_EDGE: preview_blocked -> 409 when preview is blocked by readiness gates
|
||||
# @TEST_EDGE: launch_blocked -> 409 when launch is blocked by readiness gates
|
||||
# @TEST_EDGE: unsupported_export_format -> 400 for non-json/non-markdown export format
|
||||
# @TEST_EDGE: start_session_env_not_found -> 404 when environment not found
|
||||
# @TEST_EDGE: clarification_feedback_no_answer -> 400 when question has no answer yet
|
||||
# @TEST_EDGE: mapping_update_no_effective_value -> 400 when effective_value is null
|
||||
# @TEST_EDGE: preview_session_not_found -> 404 when session not found by orchestrator
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
import sys
|
||||
from types import SimpleNamespace
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
|
||||
|
||||
import pytest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
from fastapi import status
|
||||
|
||||
from src.api.routes.dataset_review import (
|
||||
_get_orchestrator,
|
||||
_get_repository,
|
||||
_get_clarification_engine,
|
||||
)
|
||||
from src.app import app
|
||||
from src.core.config_models import AppConfig, Environment, GlobalSettings
|
||||
from src.dependencies import get_config_manager, get_current_user, get_task_manager
|
||||
from src.models.dataset_review import (
|
||||
ApprovalState,
|
||||
ArtifactFormat,
|
||||
CandidateMatchType,
|
||||
CandidateStatus,
|
||||
ClarificationAnswer,
|
||||
ClarificationOption,
|
||||
ClarificationQuestion,
|
||||
ClarificationSession,
|
||||
ClarificationStatus,
|
||||
CompiledPreview,
|
||||
DatasetReviewSession,
|
||||
ExecutionMapping,
|
||||
FieldKind,
|
||||
FieldProvenance,
|
||||
FindingArea,
|
||||
FindingSeverity,
|
||||
ImportedFilter,
|
||||
LaunchStatus,
|
||||
MappingMethod,
|
||||
MappingWarningLevel,
|
||||
PreviewStatus,
|
||||
QuestionState,
|
||||
ReadinessState,
|
||||
RecommendedAction,
|
||||
ResolutionState,
|
||||
SemanticCandidate,
|
||||
SemanticFieldEntry,
|
||||
SemanticSource,
|
||||
SemanticSourceStatus,
|
||||
SemanticSourceType,
|
||||
SessionPhase,
|
||||
SessionStatus,
|
||||
TemplateVariable,
|
||||
TrustLevel,
|
||||
VariableKind,
|
||||
)
|
||||
from src.models.auth import User
|
||||
from src.services.dataset_review.event_logger import SessionEventLogger
|
||||
from src.services.dataset_review.orchestrator import (
|
||||
DatasetReviewOrchestrator,
|
||||
LaunchDatasetResult,
|
||||
PreparePreviewResult,
|
||||
StartSessionCommand,
|
||||
StartSessionResult,
|
||||
)
|
||||
from src.services.dataset_review.repositories.session_repository import (
|
||||
DatasetReviewSessionVersionConflictError,
|
||||
)
|
||||
|
||||
client = TestClient(app)
|
||||
|
||||
|
||||
# ── Shared fixture: setup mocks ────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clean_overrides():
|
||||
"""Reset dependency overrides before and after each test."""
|
||||
app.dependency_overrides.clear()
|
||||
yield
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
def _make_user(roles=None):
|
||||
"""Build a minimal User-like object with permissions."""
|
||||
if roles is None:
|
||||
permissions = [
|
||||
SimpleNamespace(resource="dataset:session", action="READ"),
|
||||
SimpleNamespace(resource="dataset:session", action="MANAGE"),
|
||||
SimpleNamespace(resource="dataset:execution:launch", action="EXECUTE"),
|
||||
]
|
||||
role = SimpleNamespace(name="DatasetReviewOperator", permissions=permissions)
|
||||
roles = [role]
|
||||
return SimpleNamespace(id="user-1", username="tester", roles=roles)
|
||||
|
||||
|
||||
def _make_config_manager(feature_flag=True, ff_auto=True, ff_clarification=True, ff_execution=True):
|
||||
"""Build a mock config_manager with controllable feature flags."""
|
||||
env = Environment(
|
||||
id="env-1",
|
||||
name="DEV",
|
||||
url="http://superset.local",
|
||||
username="demo",
|
||||
password="secret",
|
||||
)
|
||||
config = AppConfig(environments=[env], settings=GlobalSettings())
|
||||
config.settings.features.dataset_review = feature_flag
|
||||
config.settings.ff_dataset_auto_review = ff_auto
|
||||
config.settings.ff_dataset_clarification = ff_clarification
|
||||
config.settings.ff_dataset_execution = ff_execution
|
||||
manager = MagicMock()
|
||||
manager.get_environment.side_effect = (
|
||||
lambda env_id: env if env_id == "env-1" else None
|
||||
)
|
||||
manager.get_config.return_value = config
|
||||
return manager
|
||||
|
||||
|
||||
def _setup_feature_disabled(flags=("auto",)):
|
||||
"""Override deps so all features are disabled."""
|
||||
cm = _make_config_manager(
|
||||
feature_flag=False,
|
||||
ff_auto="auto" in flags,
|
||||
ff_clarification="clarification" in flags,
|
||||
ff_execution="execution" in flags,
|
||||
)
|
||||
user = _make_user()
|
||||
app.dependency_overrides[get_config_manager] = lambda: cm
|
||||
app.dependency_overrides[get_current_user] = lambda: user
|
||||
|
||||
|
||||
def _setup_happy_deps(session=None, repository=None, orchestrator=None, clarification_engine=None,
|
||||
feature_flag=True, use_launch_permission=False):
|
||||
"""Standard dependency setup with feature flags enabled."""
|
||||
if use_launch_permission:
|
||||
user_roles = [
|
||||
SimpleNamespace(
|
||||
name="DatasetReviewOperator",
|
||||
permissions=[
|
||||
SimpleNamespace(resource="dataset:session", action="READ"),
|
||||
SimpleNamespace(resource="dataset:session", action="MANAGE"),
|
||||
SimpleNamespace(resource="dataset:execution:launch", action="EXECUTE"),
|
||||
],
|
||||
)
|
||||
]
|
||||
else:
|
||||
user_roles = None
|
||||
user = _make_user(roles=user_roles)
|
||||
cm = _make_config_manager(feature_flag=feature_flag)
|
||||
effective_session = session or _make_minimal_session()
|
||||
|
||||
if repository is None:
|
||||
repository = MagicMock()
|
||||
repository.load_session_detail.return_value = effective_session
|
||||
repository.list_sessions_for_user.return_value = [effective_session]
|
||||
repository.require_session_version.side_effect = lambda s, v: s
|
||||
repository.bump_session_version.side_effect = (
|
||||
lambda current: setattr(
|
||||
current, "version", int(getattr(current, "version", 0) or 0) + 1
|
||||
)
|
||||
or getattr(current, "version", 0)
|
||||
)
|
||||
repository.db = MagicMock()
|
||||
repository.event_logger = MagicMock(spec=SessionEventLogger)
|
||||
repository.event_logger.log_for_session.return_value = SimpleNamespace(
|
||||
session_event_id="evt-0"
|
||||
)
|
||||
|
||||
if orchestrator is None:
|
||||
orchestrator = MagicMock()
|
||||
orchestrator.start_session.return_value = StartSessionResult(
|
||||
session=effective_session
|
||||
)
|
||||
|
||||
app.dependency_overrides[get_config_manager] = lambda: cm
|
||||
app.dependency_overrides[get_current_user] = lambda: user
|
||||
if repository is not None:
|
||||
app.dependency_overrides[_get_repository] = lambda: repository
|
||||
if orchestrator is not None:
|
||||
app.dependency_overrides[_get_orchestrator] = lambda: orchestrator
|
||||
if clarification_engine is not None:
|
||||
app.dependency_overrides[_get_clarification_engine] = lambda: clarification_engine
|
||||
|
||||
return {
|
||||
"user": user,
|
||||
"config_manager": cm,
|
||||
"repository": repository,
|
||||
"orchestrator": orchestrator,
|
||||
}
|
||||
|
||||
|
||||
def _make_minimal_session():
|
||||
"""Build a minimal session mock for error path tests."""
|
||||
now = datetime.now(UTC)
|
||||
s = MagicMock(spec=DatasetReviewSession)
|
||||
s.session_id = "sess-1"
|
||||
s.user_id = "user-1"
|
||||
s.environment_id = "env-1"
|
||||
s.source_kind = "superset_link"
|
||||
s.source_input = "http://superset.local/dashboard/10"
|
||||
s.dataset_ref = "public.sales"
|
||||
s.dataset_id = 42
|
||||
s.dashboard_id = 10
|
||||
s.readiness_state = ReadinessState.REVIEW_READY
|
||||
s.recommended_action = RecommendedAction.REVIEW_DOCUMENTATION
|
||||
s.status = SessionStatus.ACTIVE
|
||||
s.current_phase = SessionPhase.REVIEW
|
||||
s.version = 1
|
||||
s.created_at = now
|
||||
s.updated_at = now
|
||||
s.last_activity_at = now
|
||||
s.profile = None
|
||||
s.findings = []
|
||||
s.collaborators = []
|
||||
s.semantic_sources = []
|
||||
s.semantic_fields = []
|
||||
s.imported_filters = []
|
||||
s.template_variables = []
|
||||
s.execution_mappings = []
|
||||
s.clarification_sessions = []
|
||||
s.previews = []
|
||||
s.run_contexts = []
|
||||
return s
|
||||
|
||||
|
||||
# ── Feature-flag disabled → 404 all endpoints ──────────────────────────
|
||||
|
||||
|
||||
FEATURE_DISABLED_ENDPOINTS = [
|
||||
("GET", "/api/dataset-orchestration/sessions"),
|
||||
("POST", "/api/dataset-orchestration/sessions"),
|
||||
("GET", "/api/dataset-orchestration/sessions/sess-1"),
|
||||
("PATCH", "/api/dataset-orchestration/sessions/sess-1"),
|
||||
("DELETE", "/api/dataset-orchestration/sessions/sess-1"),
|
||||
("GET", "/api/dataset-orchestration/sessions/sess-1/exports/documentation"),
|
||||
("GET", "/api/dataset-orchestration/sessions/sess-1/exports/validation"),
|
||||
("GET", "/api/dataset-orchestration/sessions/sess-1/clarification"),
|
||||
("POST", "/api/dataset-orchestration/sessions/sess-1/clarification/resume"),
|
||||
("POST", "/api/dataset-orchestration/sessions/sess-1/clarification/answers"),
|
||||
("PATCH", "/api/dataset-orchestration/sessions/sess-1/fields/field-1/semantic"),
|
||||
("POST", "/api/dataset-orchestration/sessions/sess-1/fields/field-1/lock"),
|
||||
("POST", "/api/dataset-orchestration/sessions/sess-1/fields/field-1/unlock"),
|
||||
("POST", "/api/dataset-orchestration/sessions/sess-1/fields/semantic/approve-batch"),
|
||||
("GET", "/api/dataset-orchestration/sessions/sess-1/mappings"),
|
||||
("PATCH", "/api/dataset-orchestration/sessions/sess-1/mappings/map-1"),
|
||||
("POST", "/api/dataset-orchestration/sessions/sess-1/mappings/map-1/approve"),
|
||||
("POST", "/api/dataset-orchestration/sessions/sess-1/mappings/approve-batch"),
|
||||
("POST", "/api/dataset-orchestration/sessions/sess-1/preview"),
|
||||
("POST", "/api/dataset-orchestration/sessions/sess-1/launch"),
|
||||
("POST", "/api/dataset-orchestration/sessions/sess-1/fields/field-1/feedback"),
|
||||
("POST", "/api/dataset-orchestration/sessions/sess-1/clarification/questions/q-1/feedback"),
|
||||
]
|
||||
|
||||
|
||||
#region test_all_endpoints_return_404_when_feature_disabled [C:2]
|
||||
# @BRIEF Every dataset-orchestration endpoint returns 404 when dataset_review feature is off.
|
||||
@pytest.mark.parametrize("method,path", FEATURE_DISABLED_ENDPOINTS)
|
||||
def test_all_endpoints_return_404_when_feature_disabled(method, path):
|
||||
_setup_feature_disabled()
|
||||
_setup_happy_deps(feature_flag=False)
|
||||
|
||||
kwargs = {}
|
||||
if method in ("POST", "PATCH"):
|
||||
kwargs["json"] = {}
|
||||
kwargs["headers"] = {"X-Session-Version": "0"}
|
||||
|
||||
response = client.request(method, path, **kwargs)
|
||||
assert response.status_code == 404, f"{method} {path} should return 404"
|
||||
assert "Dataset review feature is disabled" in response.json()["detail"]
|
||||
#endregion
|
||||
|
||||
|
||||
# ── Missing X-Session-Version header → 422 ──────────────────────────────
|
||||
|
||||
|
||||
#region test_missing_session_version_header_422 [C:2]
|
||||
# @BRIEF PATCH session returns 422 when X-Session-Version header missing.
|
||||
def test_missing_session_version_header_422():
|
||||
_setup_happy_deps()
|
||||
response = client.patch(
|
||||
"/api/dataset-orchestration/sessions/sess-1",
|
||||
json={"status": "paused"},
|
||||
# No X-Session-Version header
|
||||
)
|
||||
# FastAPI returns 422 for missing required header
|
||||
assert response.status_code == 422
|
||||
#endregion
|
||||
|
||||
|
||||
#region test_missing_session_version_header_all_mutation_endpoints [C:2]
|
||||
# @BRIEF All mutation endpoints return 422 when X-Session-Version missing.
|
||||
@pytest.mark.parametrize("method,path", [
|
||||
("PATCH", "/api/dataset-orchestration/sessions/sess-1"),
|
||||
("DELETE", "/api/dataset-orchestration/sessions/sess-1"),
|
||||
("POST", "/api/dataset-orchestration/sessions/sess-1/clarification/resume"),
|
||||
("POST", "/api/dataset-orchestration/sessions/sess-1/clarification/answers"),
|
||||
("PATCH", "/api/dataset-orchestration/sessions/sess-1/fields/field-1/semantic"),
|
||||
("POST", "/api/dataset-orchestration/sessions/sess-1/fields/field-1/lock"),
|
||||
("POST", "/api/dataset-orchestration/sessions/sess-1/fields/field-1/unlock"),
|
||||
("POST", "/api/dataset-orchestration/sessions/sess-1/fields/semantic/approve-batch"),
|
||||
("PATCH", "/api/dataset-orchestration/sessions/sess-1/mappings/map-1"),
|
||||
("POST", "/api/dataset-orchestration/sessions/sess-1/mappings/map-1/approve"),
|
||||
("POST", "/api/dataset-orchestration/sessions/sess-1/mappings/approve-batch"),
|
||||
("POST", "/api/dataset-orchestration/sessions/sess-1/preview"),
|
||||
("POST", "/api/dataset-orchestration/sessions/sess-1/launch"),
|
||||
("POST", "/api/dataset-orchestration/sessions/sess-1/fields/field-1/feedback"),
|
||||
("POST", "/api/dataset-orchestration/sessions/sess-1/clarification/questions/q-1/feedback"),
|
||||
])
|
||||
def test_missing_session_version_header_422_all(method, path):
|
||||
_setup_happy_deps()
|
||||
kwargs = {"json": {}} if method in ("POST", "PATCH") else {}
|
||||
response = client.request(method, path, **kwargs)
|
||||
assert response.status_code == 422, f"{method} {path} should return 422"
|
||||
#endregion
|
||||
|
||||
|
||||
# ── Non-owner mutation → 403 ────────────────────────────────────────────
|
||||
|
||||
|
||||
def _make_non_owner_session():
|
||||
s = _make_minimal_session()
|
||||
s.user_id = "user-2" # Different from current user (user-1)
|
||||
return s
|
||||
|
||||
|
||||
#region test_patch_session_non_owner_403 [C:2]
|
||||
# @BRIEF PATCH session returns 403 for non-owner.
|
||||
def test_patch_session_non_owner_403():
|
||||
session = _make_non_owner_session()
|
||||
_setup_happy_deps(session=session)
|
||||
response = client.patch(
|
||||
"/api/dataset-orchestration/sessions/sess-1",
|
||||
json={"status": "paused"},
|
||||
headers={"X-Session-Version": "0"},
|
||||
)
|
||||
assert response.status_code == 403
|
||||
assert "Only the owner can mutate" in response.json()["detail"]
|
||||
#endregion
|
||||
|
||||
|
||||
#region test_delete_session_non_owner_403 [C:2]
|
||||
# @BRIEF DELETE session returns 403 for non-owner.
|
||||
def test_delete_session_non_owner_403():
|
||||
session = _make_non_owner_session()
|
||||
_setup_happy_deps(session=session)
|
||||
response = client.delete(
|
||||
"/api/dataset-orchestration/sessions/sess-1",
|
||||
headers={"X-Session-Version": "0"},
|
||||
)
|
||||
assert response.status_code == 403
|
||||
#endregion
|
||||
|
||||
|
||||
#region test_update_field_semantic_non_owner_403 [C:2]
|
||||
# @BRIEF Field semantic update returns 403 for non-owner.
|
||||
def test_update_field_semantic_non_owner_403():
|
||||
session = _make_non_owner_session()
|
||||
_setup_happy_deps(session=session)
|
||||
response = client.patch(
|
||||
"/api/dataset-orchestration/sessions/sess-1/fields/field-1/semantic",
|
||||
json={"candidate_id": "cand-1"},
|
||||
headers={"X-Session-Version": "0"},
|
||||
)
|
||||
assert response.status_code == 403
|
||||
#endregion
|
||||
|
||||
|
||||
# ── Start session error paths ──────────────────────────────────────────
|
||||
|
||||
|
||||
#region test_start_session_environment_not_found_404 [C:2]
|
||||
# @BRIEF POST session returns 404 when environment not found.
|
||||
def test_start_session_environment_not_found_404():
|
||||
orchestrator = MagicMock()
|
||||
orchestrator.start_session.side_effect = ValueError("Environment not found")
|
||||
_setup_happy_deps(orchestrator=orchestrator)
|
||||
response = client.post(
|
||||
"/api/dataset-orchestration/sessions",
|
||||
json={
|
||||
"source_kind": "superset_link",
|
||||
"source_input": "http://superset.local/dashboard/10",
|
||||
"environment_id": "env-999",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 404
|
||||
assert "Environment not found" in response.json()["detail"]
|
||||
#endregion
|
||||
|
||||
|
||||
#region test_start_session_other_error_400 [C:2]
|
||||
# @BRIEF POST session returns 400 for generic ValueError.
|
||||
def test_start_session_other_error_400():
|
||||
orchestrator = MagicMock()
|
||||
orchestrator.start_session.side_effect = ValueError("Invalid input")
|
||||
_setup_happy_deps(orchestrator=orchestrator)
|
||||
response = client.post(
|
||||
"/api/dataset-orchestration/sessions",
|
||||
json={
|
||||
"source_kind": "superset_link",
|
||||
"source_input": "invalid",
|
||||
"environment_id": "env-1",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
assert "Invalid input" in response.json()["detail"]
|
||||
#endregion
|
||||
|
||||
|
||||
# ── Export error paths ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
#region test_export_documentation_unsupported_format_400 [C:2]
|
||||
# @BRIEF GET exports/documentation returns 400 for unsupported format.
|
||||
def test_export_documentation_unsupported_format_400():
|
||||
_setup_happy_deps()
|
||||
response = client.get(
|
||||
"/api/dataset-orchestration/sessions/sess-1/exports/documentation?format=csv"
|
||||
)
|
||||
assert response.status_code == 400
|
||||
assert "Only json and markdown exports are supported" in response.json()["detail"]
|
||||
#endregion
|
||||
|
||||
|
||||
#region test_export_validation_unsupported_format_422 [C:2]
|
||||
# @BRIEF GET exports/validation returns 422 for unsupported format (FastAPI enum validation).
|
||||
def test_export_validation_unsupported_format_422():
|
||||
_setup_happy_deps()
|
||||
response = client.get(
|
||||
"/api/dataset-orchestration/sessions/sess-1/exports/validation?format=xml"
|
||||
)
|
||||
assert response.status_code == 422 # FastAPI validates against ArtifactFormat enum
|
||||
#endregion
|
||||
|
||||
|
||||
# ── Preview error paths ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
#region test_preview_blocked_409 [C:2]
|
||||
# @BRIEF POST preview returns 409 when preview is blocked by readiness gates.
|
||||
def test_preview_blocked_409():
|
||||
session = _make_minimal_session()
|
||||
orchestrator = MagicMock()
|
||||
orchestrator.prepare_launch_preview.side_effect = ValueError(
|
||||
"Preview blocked: unresolved mappings"
|
||||
)
|
||||
_setup_happy_deps(session=session, orchestrator=orchestrator)
|
||||
response = client.post(
|
||||
"/api/dataset-orchestration/sessions/sess-1/preview",
|
||||
headers={"X-Session-Version": "0"},
|
||||
)
|
||||
assert response.status_code == 409
|
||||
assert "Preview blocked" in response.json()["detail"]
|
||||
#endregion
|
||||
|
||||
|
||||
#region test_preview_session_not_found_404 [C:2]
|
||||
# @BRIEF POST preview returns 404 when session not found by orchestrator.
|
||||
def test_preview_session_not_found_404():
|
||||
orchestrator = MagicMock()
|
||||
orchestrator.prepare_launch_preview.side_effect = ValueError("Session not found")
|
||||
_setup_happy_deps(orchestrator=orchestrator)
|
||||
response = client.post(
|
||||
"/api/dataset-orchestration/sessions/sess-1/preview",
|
||||
headers={"X-Session-Version": "0"},
|
||||
)
|
||||
assert response.status_code == 404
|
||||
assert "Session not found" in response.json()["detail"]
|
||||
#endregion
|
||||
|
||||
|
||||
#region test_preview_env_not_found_404 [C:2]
|
||||
# @BRIEF POST preview returns 404 when environment not found.
|
||||
def test_preview_env_not_found_404():
|
||||
orchestrator = MagicMock()
|
||||
orchestrator.prepare_launch_preview.side_effect = ValueError("Environment not found")
|
||||
_setup_happy_deps(orchestrator=orchestrator)
|
||||
response = client.post(
|
||||
"/api/dataset-orchestration/sessions/sess-1/preview",
|
||||
headers={"X-Session-Version": "0"},
|
||||
)
|
||||
assert response.status_code == 404
|
||||
assert "Environment not found" in response.json()["detail"]
|
||||
#endregion
|
||||
|
||||
|
||||
#region test_preview_other_value_error_400 [C:2]
|
||||
# @BRIEF POST preview returns 400 for other ValueError.
|
||||
def test_preview_other_value_error_400():
|
||||
orchestrator = MagicMock()
|
||||
orchestrator.prepare_launch_preview.side_effect = ValueError("Some other error")
|
||||
_setup_happy_deps(orchestrator=orchestrator)
|
||||
response = client.post(
|
||||
"/api/dataset-orchestration/sessions/sess-1/preview",
|
||||
headers={"X-Session-Version": "0"},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
assert "Some other error" in response.json()["detail"]
|
||||
#endregion
|
||||
|
||||
|
||||
#region test_preview_version_conflict_409 [C:2]
|
||||
# @BRIEF POST preview returns 409 when session version conflicts.
|
||||
def test_preview_version_conflict_409():
|
||||
orchestrator = MagicMock()
|
||||
orchestrator.prepare_launch_preview.side_effect = (
|
||||
DatasetReviewSessionVersionConflictError("sess-1", 0, 3)
|
||||
)
|
||||
_setup_happy_deps(orchestrator=orchestrator)
|
||||
response = client.post(
|
||||
"/api/dataset-orchestration/sessions/sess-1/preview",
|
||||
headers={"X-Session-Version": "0"},
|
||||
)
|
||||
assert response.status_code == 409
|
||||
payload = response.json()["detail"]
|
||||
assert payload["error_code"] == "session_version_conflict"
|
||||
#endregion
|
||||
|
||||
|
||||
# ── Launch error paths ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
#region test_launch_blocked_409 [C:2]
|
||||
# @BRIEF POST launch returns 409 when launch is blocked.
|
||||
def test_launch_blocked_409():
|
||||
session = _make_minimal_session()
|
||||
orchestrator = MagicMock()
|
||||
orchestrator.launch_dataset.side_effect = ValueError(
|
||||
"Launch blocked: preview required"
|
||||
)
|
||||
_setup_happy_deps(session=session, orchestrator=orchestrator,
|
||||
use_launch_permission=True)
|
||||
response = client.post(
|
||||
"/api/dataset-orchestration/sessions/sess-1/launch",
|
||||
headers={"X-Session-Version": "0"},
|
||||
)
|
||||
assert response.status_code == 409
|
||||
assert "Launch blocked" in response.json()["detail"]
|
||||
#endregion
|
||||
|
||||
|
||||
#region test_launch_session_not_found_404 [C:2]
|
||||
# @BRIEF POST launch returns 404 when session not found.
|
||||
def test_launch_session_not_found_404():
|
||||
orchestrator = MagicMock()
|
||||
orchestrator.launch_dataset.side_effect = ValueError("Session not found")
|
||||
_setup_happy_deps(orchestrator=orchestrator, use_launch_permission=True)
|
||||
response = client.post(
|
||||
"/api/dataset-orchestration/sessions/sess-1/launch",
|
||||
headers={"X-Session-Version": "0"},
|
||||
)
|
||||
assert response.status_code == 404
|
||||
#endregion
|
||||
|
||||
|
||||
#region test_launch_env_not_found_404 [C:2]
|
||||
# @BRIEF POST launch returns 404 when environment not found.
|
||||
def test_launch_env_not_found_404():
|
||||
orchestrator = MagicMock()
|
||||
orchestrator.launch_dataset.side_effect = ValueError("Environment not found")
|
||||
_setup_happy_deps(orchestrator=orchestrator, use_launch_permission=True)
|
||||
response = client.post(
|
||||
"/api/dataset-orchestration/sessions/sess-1/launch",
|
||||
headers={"X-Session-Version": "0"},
|
||||
)
|
||||
assert response.status_code == 404
|
||||
#endregion
|
||||
|
||||
|
||||
#region test_launch_version_conflict_409 [C:2]
|
||||
# @BRIEF POST launch returns 409 when session version conflicts.
|
||||
def test_launch_version_conflict_409():
|
||||
orchestrator = MagicMock()
|
||||
orchestrator.launch_dataset.side_effect = (
|
||||
DatasetReviewSessionVersionConflictError("sess-1", 0, 3)
|
||||
)
|
||||
_setup_happy_deps(orchestrator=orchestrator, use_launch_permission=True)
|
||||
response = client.post(
|
||||
"/api/dataset-orchestration/sessions/sess-1/launch",
|
||||
headers={"X-Session-Version": "0"},
|
||||
)
|
||||
assert response.status_code == 409
|
||||
payload = response.json()["detail"]
|
||||
assert payload["error_code"] == "session_version_conflict"
|
||||
#endregion
|
||||
|
||||
|
||||
# ── Clarification error paths ──────────────────────────────────────────
|
||||
|
||||
|
||||
#region test_clarification_feedback_no_answer_400 [C:2]
|
||||
# @BRIEF POST clarification feedback returns 400 when question has no answer yet.
|
||||
def test_clarification_feedback_no_answer_400():
|
||||
"""Feedback on unanswered question → 400."""
|
||||
session = _make_minimal_session()
|
||||
q = MagicMock()
|
||||
q.question_id = "q-1"
|
||||
q.answer = None
|
||||
cs = MagicMock()
|
||||
cs.questions = [q]
|
||||
session.clarification_sessions = [cs]
|
||||
|
||||
_setup_happy_deps(session=session)
|
||||
response = client.post(
|
||||
"/api/dataset-orchestration/sessions/sess-1/clarification/questions/q-1/feedback",
|
||||
json={"feedback": "up"},
|
||||
headers={"X-Session-Version": "0"},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
assert "Clarification answer not found" in response.json()["detail"]
|
||||
#endregion
|
||||
|
||||
|
||||
#region test_clarification_feedback_question_not_found_404 [C:2]
|
||||
# @BRIEF POST clarification feedback returns 404 when question not found.
|
||||
def test_clarification_feedback_question_not_found_404():
|
||||
session = _make_minimal_session()
|
||||
cs = MagicMock()
|
||||
cs.questions = [MagicMock(question_id="q-other")]
|
||||
session.clarification_sessions = [cs]
|
||||
|
||||
_setup_happy_deps(session=session)
|
||||
response = client.post(
|
||||
"/api/dataset-orchestration/sessions/sess-1/clarification/questions/q-999/feedback",
|
||||
json={"feedback": "up"},
|
||||
headers={"X-Session-Version": "0"},
|
||||
)
|
||||
assert response.status_code == 404
|
||||
assert "Clarification question not found" in response.json()["detail"]
|
||||
#endregion
|
||||
|
||||
|
||||
#region test_clarification_answer_error_400 [C:2]
|
||||
# @BRIEF POST clarification answer returns 400 when engine raises ValueError.
|
||||
def test_clarification_answer_error_400():
|
||||
session = _make_minimal_session()
|
||||
cs = MagicMock()
|
||||
cs.questions = []
|
||||
session.clarification_sessions = [cs]
|
||||
|
||||
clarification_engine = MagicMock()
|
||||
clarification_engine.record_answer.side_effect = ValueError("Invalid answer kind")
|
||||
|
||||
_setup_happy_deps(session=session, clarification_engine=clarification_engine)
|
||||
response = client.post(
|
||||
"/api/dataset-orchestration/sessions/sess-1/clarification/answers",
|
||||
json={
|
||||
"question_id": "q-1",
|
||||
"answer_kind": "selected",
|
||||
"answer_value": "test",
|
||||
},
|
||||
headers={"X-Session-Version": "0"},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
#endregion
|
||||
|
||||
|
||||
# ── Execution mapping error paths ──────────────────────────────────────
|
||||
|
||||
|
||||
#region test_update_execution_mapping_no_effective_value_400 [C:2]
|
||||
# @BRIEF PATCH mapping returns 400 when effective_value is null.
|
||||
def test_update_execution_mapping_no_effective_value_400():
|
||||
session = _make_minimal_session()
|
||||
mapping = MagicMock()
|
||||
mapping.mapping_id = "map-1"
|
||||
session.execution_mappings = [mapping]
|
||||
|
||||
_setup_happy_deps(session=session)
|
||||
response = client.patch(
|
||||
"/api/dataset-orchestration/sessions/sess-1/mappings/map-1",
|
||||
json={"effective_value": None},
|
||||
headers={"X-Session-Version": "0"},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
assert "effective_value is required" in response.json()["detail"]
|
||||
#endregion
|
||||
|
||||
|
||||
#region test_get_mapping_404 [C:2]
|
||||
# @BRIEF GET mappings returns empty list when session has no mappings.
|
||||
def test_get_mapping_404():
|
||||
session = _make_minimal_session()
|
||||
session.execution_mappings = []
|
||||
_setup_happy_deps(session=session)
|
||||
response = client.get(
|
||||
"/api/dataset-orchestration/sessions/sess-1/mappings",
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["items"] == []
|
||||
#endregion
|
||||
|
||||
|
||||
# ── Session not found for detail endpoint ───────────────────────────────
|
||||
|
||||
|
||||
#region test_get_session_detail_404 [C:2]
|
||||
# @BRIEF GET session detail returns 404 when session not found.
|
||||
def test_get_session_detail_404():
|
||||
repository = MagicMock()
|
||||
repository.load_session_detail.return_value = None
|
||||
_setup_happy_deps(repository=repository)
|
||||
response = client.get("/api/dataset-orchestration/sessions/sess-999")
|
||||
assert response.status_code == 404
|
||||
#endregion
|
||||
|
||||
|
||||
# ── DELETE session hard_delete ──────────────────────────────────────────
|
||||
|
||||
|
||||
#region test_delete_session_hard_delete [C:2]
|
||||
# @BRIEF DELETE session with hard_delete=true deletes from DB.
|
||||
def test_delete_session_hard_delete():
|
||||
session = _make_minimal_session()
|
||||
repository = MagicMock()
|
||||
repository.load_session_detail.return_value = session
|
||||
repository.require_session_version.side_effect = lambda s, v: s
|
||||
repository.db = MagicMock()
|
||||
|
||||
_setup_happy_deps(session=session, repository=repository)
|
||||
response = client.delete(
|
||||
"/api/dataset-orchestration/sessions/sess-1?hard_delete=true",
|
||||
headers={"X-Session-Version": "0"},
|
||||
)
|
||||
assert response.status_code == 204
|
||||
repository.db.delete.assert_called_once_with(session)
|
||||
repository.db.commit.assert_called_once()
|
||||
#endregion
|
||||
|
||||
|
||||
# ── Lock field ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
#region test_lock_field_endpoint [C:2]
|
||||
# @BRIEF POST field lock locks the field and marks provenance.
|
||||
def test_lock_field_endpoint():
|
||||
field = MagicMock()
|
||||
field.field_id = "field-1"
|
||||
field.session_id = "sess-1"
|
||||
field.field_name = "revenue"
|
||||
field.field_kind = FieldKind.METRIC
|
||||
field.verbose_name = None
|
||||
field.description = None
|
||||
field.display_format = None
|
||||
field.provenance = FieldProvenance.UNRESOLVED
|
||||
field.source_id = None
|
||||
field.source_version = None
|
||||
field.confidence_rank = None
|
||||
field.is_locked = False
|
||||
field.has_conflict = False
|
||||
field.needs_review = False
|
||||
field.last_changed_by = None
|
||||
field.user_feedback = None
|
||||
field.created_at = datetime.now(UTC)
|
||||
field.updated_at = datetime.now(UTC)
|
||||
field.candidates = []
|
||||
field.session = MagicMock(version=2)
|
||||
|
||||
session = _make_minimal_session()
|
||||
session.semantic_fields = [field]
|
||||
|
||||
repository = MagicMock()
|
||||
repository.load_session_detail.return_value = session
|
||||
repository.require_session_version.side_effect = lambda s, v: s
|
||||
repository.bump_session_version.side_effect = (
|
||||
lambda current: setattr(current, "version", getattr(current, "version", 0) + 1)
|
||||
or getattr(current, "version", 0)
|
||||
)
|
||||
repository.db = MagicMock()
|
||||
repository.event_logger = MagicMock(spec=SessionEventLogger)
|
||||
repository.event_logger.log_for_session.return_value = SimpleNamespace(
|
||||
session_event_id="evt-1"
|
||||
)
|
||||
|
||||
_setup_happy_deps(session=session, repository=repository)
|
||||
response = client.post(
|
||||
"/api/dataset-orchestration/sessions/sess-1/fields/field-1/lock",
|
||||
headers={"X-Session-Version": "0"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["is_locked"] is True
|
||||
assert response.json()["field_id"] == "field-1"
|
||||
#endregion
|
||||
|
||||
|
||||
# ── Unlock field with non-manual provenance ─────────────────────────────
|
||||
|
||||
|
||||
#region test_unlock_field_non_manual_provenance [C:2]
|
||||
# @BRIEF Unlock field with non-manual provenance doesn't reset to UNRESOLVED.
|
||||
def test_unlock_field_non_manual_provenance():
|
||||
field = MagicMock()
|
||||
field.field_id = "field-1"
|
||||
field.session_id = "sess-1"
|
||||
field.field_name = "revenue"
|
||||
field.field_kind = FieldKind.METRIC
|
||||
field.provenance = FieldProvenance.DICTIONARY_EXACT
|
||||
field.is_locked = True
|
||||
field.needs_review = False
|
||||
field.last_changed_by = "system"
|
||||
field.verbose_name = "Revenue"
|
||||
field.description = "Total revenue"
|
||||
field.display_format = "$,.0f"
|
||||
field.source_id = None
|
||||
field.source_version = None
|
||||
field.confidence_rank = 1
|
||||
field.has_conflict = False
|
||||
field.user_feedback = None
|
||||
field.created_at = datetime.now(UTC)
|
||||
field.updated_at = datetime.now(UTC)
|
||||
field.candidates = []
|
||||
field.session = MagicMock(version=2)
|
||||
|
||||
session = _make_minimal_session()
|
||||
session.semantic_fields = [field]
|
||||
|
||||
repository = MagicMock()
|
||||
repository.load_session_detail.return_value = session
|
||||
repository.require_session_version.side_effect = lambda s, v: s
|
||||
repository.bump_session_version.side_effect = (
|
||||
lambda current: setattr(current, "version", getattr(current, "version", 0) + 1)
|
||||
or getattr(current, "version", 0)
|
||||
)
|
||||
repository.db = MagicMock()
|
||||
repository.event_logger = MagicMock(spec=SessionEventLogger)
|
||||
repository.event_logger.log_for_session.return_value = SimpleNamespace(
|
||||
session_event_id="evt-1"
|
||||
)
|
||||
|
||||
_setup_happy_deps(session=session, repository=repository)
|
||||
response = client.post(
|
||||
"/api/dataset-orchestration/sessions/sess-1/fields/field-1/unlock",
|
||||
headers={"X-Session-Version": "0"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["is_locked"] is False
|
||||
# Non-MANUAL_OVERRIDE provenance stays intact
|
||||
assert response.json()["provenance"] == "dictionary_exact"
|
||||
#endregion
|
||||
|
||||
|
||||
# ── Endpoint not found ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
#region test_endpoint_not_found_404 [C:2]
|
||||
# @BRIEF Returns 404 for unknown dataset-orchestration routes.
|
||||
def test_endpoint_not_found_404():
|
||||
_setup_happy_deps()
|
||||
response = client.get("/api/dataset-orchestration/nonexistent")
|
||||
assert response.status_code == 404
|
||||
#endregion
|
||||
|
||||
|
||||
#endregion Test.DatasetReview.RoutesExtended
|
||||
240
backend/tests/api/test_git_merge_routes.py
Normal file
240
backend/tests/api/test_git_merge_routes.py
Normal file
@@ -0,0 +1,240 @@
|
||||
# #region Test.Api.GitMergeRoutes [C:3] [TYPE Module] [SEMANTICS test,git,merge,routes]
|
||||
# @BRIEF Tests for Git merge API routes — status, conflicts, resolve, abort, continue.
|
||||
# @RELATION BINDS_TO -> [GitMergeRoutes]
|
||||
# @TEST_EDGE: success -> 200 with expected data
|
||||
# @TEST_EDGE: unexpected_error -> 500
|
||||
# @TEST_EDGE: dashboard_not_found -> 404 propagated from resolve_dashboard
|
||||
|
||||
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")
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI, HTTPException
|
||||
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)
|
||||
|
||||
|
||||
MERGE_STATUS = {
|
||||
"has_unfinished_merge": True,
|
||||
"repository_path": "/tmp/repo",
|
||||
"git_dir": "/tmp/repo/.git",
|
||||
"current_branch": "dev",
|
||||
"merge_head": "abc123",
|
||||
"merge_message_preview": "Merge branch 'feature' into dev",
|
||||
"conflicts_count": 2,
|
||||
}
|
||||
|
||||
MERGE_CONFLICTS = [
|
||||
{"file_path": "dashboard.json", "mine": "my content", "theirs": "their content"},
|
||||
{"file_path": "config.yaml", "mine": "my config", "theirs": "their config"},
|
||||
]
|
||||
|
||||
|
||||
def _make_client() -> TestClient:
|
||||
from src.api.routes.git._router import router as parent_router
|
||||
from src.core.database import get_db
|
||||
from src.dependencies import get_current_user, has_permission
|
||||
from src.schemas.auth import User, RoleSchema
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(parent_router)
|
||||
|
||||
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(),
|
||||
get_current_user: lambda: mock_user,
|
||||
}
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
class TestGetMergeStatus:
|
||||
"""GET /repositories/{dashboard_ref}/merge/status"""
|
||||
|
||||
def test_success(self):
|
||||
mock_gs = MagicMock()
|
||||
mock_gs.get_merge_status.return_value = MERGE_STATUS
|
||||
|
||||
with (
|
||||
patch("src.api.routes.git._merge_routes.get_git_service", return_value=mock_gs),
|
||||
patch("src.api.routes.git._resolve_dashboard_id_from_ref", AsyncMock(return_value=42)),
|
||||
):
|
||||
client = _make_client()
|
||||
resp = client.get("/repositories/42/merge/status")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["has_unfinished_merge"] is True
|
||||
assert data["current_branch"] == "dev"
|
||||
|
||||
def test_unexpected_error(self):
|
||||
mock_gs = MagicMock()
|
||||
mock_gs.get_merge_status.side_effect = RuntimeError("boom")
|
||||
|
||||
with (
|
||||
patch("src.api.routes.git._merge_routes.get_git_service", return_value=mock_gs),
|
||||
patch("src.api.routes.git._resolve_dashboard_id_from_ref", AsyncMock(return_value=42)),
|
||||
):
|
||||
client = _make_client()
|
||||
resp = client.get("/repositories/42/merge/status")
|
||||
assert resp.status_code == 500
|
||||
assert "boom" in resp.text
|
||||
|
||||
def test_dashboard_not_found(self):
|
||||
with (
|
||||
patch("src.api.routes.git._resolve_dashboard_id_from_ref", AsyncMock(side_effect=HTTPException(status_code=404, detail="Not found"))),
|
||||
):
|
||||
client = _make_client()
|
||||
resp = client.get("/repositories/bad-ref/merge/status")
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
class TestGetMergeConflicts:
|
||||
"""GET /repositories/{dashboard_ref}/merge/conflicts"""
|
||||
|
||||
def test_success(self):
|
||||
mock_gs = MagicMock()
|
||||
mock_gs.get_merge_conflicts.return_value = MERGE_CONFLICTS
|
||||
|
||||
with (
|
||||
patch("src.api.routes.git._merge_routes.get_git_service", return_value=mock_gs),
|
||||
patch("src.api.routes.git._resolve_dashboard_id_from_ref", AsyncMock(return_value=42)),
|
||||
):
|
||||
client = _make_client()
|
||||
resp = client.get("/repositories/42/merge/conflicts")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert len(data) == 2
|
||||
assert data[0]["file_path"] == "dashboard.json"
|
||||
|
||||
def test_unexpected_error(self):
|
||||
mock_gs = MagicMock()
|
||||
mock_gs.get_merge_conflicts.side_effect = RuntimeError("boom")
|
||||
|
||||
with (
|
||||
patch("src.api.routes.git._merge_routes.get_git_service", return_value=mock_gs),
|
||||
patch("src.api.routes.git._resolve_dashboard_id_from_ref", AsyncMock(return_value=42)),
|
||||
):
|
||||
client = _make_client()
|
||||
resp = client.get("/repositories/42/merge/conflicts")
|
||||
assert resp.status_code == 500
|
||||
|
||||
|
||||
class TestResolveMergeConflicts:
|
||||
"""POST /repositories/{dashboard_ref}/merge/resolve"""
|
||||
|
||||
RESOLVE_PAYLOAD = {
|
||||
"resolutions": [
|
||||
{"file_path": "dashboard.json", "resolution": "mine"},
|
||||
]
|
||||
}
|
||||
|
||||
def test_success(self):
|
||||
mock_gs = MagicMock()
|
||||
mock_gs.resolve_merge_conflicts.return_value = ["dashboard.json"]
|
||||
|
||||
with (
|
||||
patch("src.api.routes.git._merge_routes.get_git_service", return_value=mock_gs),
|
||||
patch("src.api.routes.git._resolve_dashboard_id_from_ref", AsyncMock(return_value=42)),
|
||||
):
|
||||
client = _make_client()
|
||||
resp = client.post("/repositories/42/merge/resolve", json=self.RESOLVE_PAYLOAD)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["status"] == "success"
|
||||
assert data["resolved_files"] == ["dashboard.json"]
|
||||
|
||||
def test_unexpected_error(self):
|
||||
mock_gs = MagicMock()
|
||||
mock_gs.resolve_merge_conflicts.side_effect = RuntimeError("boom")
|
||||
|
||||
with (
|
||||
patch("src.api.routes.git._merge_routes.get_git_service", return_value=mock_gs),
|
||||
patch("src.api.routes.git._resolve_dashboard_id_from_ref", AsyncMock(return_value=42)),
|
||||
):
|
||||
client = _make_client()
|
||||
resp = client.post("/repositories/42/merge/resolve", json=self.RESOLVE_PAYLOAD)
|
||||
assert resp.status_code == 500
|
||||
|
||||
|
||||
class TestAbortMerge:
|
||||
"""POST /repositories/{dashboard_ref}/merge/abort"""
|
||||
|
||||
def test_success(self):
|
||||
mock_gs = MagicMock()
|
||||
mock_gs.abort_merge.return_value = {"status": "aborted"}
|
||||
|
||||
with (
|
||||
patch("src.api.routes.git._merge_routes.get_git_service", return_value=mock_gs),
|
||||
patch("src.api.routes.git._resolve_dashboard_id_from_ref", AsyncMock(return_value=42)),
|
||||
):
|
||||
client = _make_client()
|
||||
resp = client.post("/repositories/42/merge/abort")
|
||||
assert resp.status_code == 200
|
||||
|
||||
def test_unexpected_error(self):
|
||||
mock_gs = MagicMock()
|
||||
mock_gs.abort_merge.side_effect = RuntimeError("boom")
|
||||
|
||||
with (
|
||||
patch("src.api.routes.git._merge_routes.get_git_service", return_value=mock_gs),
|
||||
patch("src.api.routes.git._resolve_dashboard_id_from_ref", AsyncMock(return_value=42)),
|
||||
):
|
||||
client = _make_client()
|
||||
resp = client.post("/repositories/42/merge/abort")
|
||||
assert resp.status_code == 500
|
||||
|
||||
|
||||
class TestContinueMerge:
|
||||
"""POST /repositories/{dashboard_ref}/merge/continue"""
|
||||
|
||||
def test_success(self):
|
||||
mock_gs = MagicMock()
|
||||
mock_gs.continue_merge.return_value = {"status": "merged"}
|
||||
|
||||
with (
|
||||
patch("src.api.routes.git._merge_routes.get_git_service", return_value=mock_gs),
|
||||
patch("src.api.routes.git._resolve_dashboard_id_from_ref", AsyncMock(return_value=42)),
|
||||
):
|
||||
client = _make_client()
|
||||
resp = client.post("/repositories/42/merge/continue", json={"message": "Merge complete"})
|
||||
assert resp.status_code == 200
|
||||
|
||||
def test_success_no_message(self):
|
||||
mock_gs = MagicMock()
|
||||
mock_gs.continue_merge.return_value = {"status": "merged"}
|
||||
|
||||
with (
|
||||
patch("src.api.routes.git._merge_routes.get_git_service", return_value=mock_gs),
|
||||
patch("src.api.routes.git._resolve_dashboard_id_from_ref", AsyncMock(return_value=42)),
|
||||
):
|
||||
client = _make_client()
|
||||
resp = client.post("/repositories/42/merge/continue", json={})
|
||||
assert resp.status_code == 200
|
||||
|
||||
def test_unexpected_error(self):
|
||||
mock_gs = MagicMock()
|
||||
mock_gs.continue_merge.side_effect = RuntimeError("boom")
|
||||
|
||||
with (
|
||||
patch("src.api.routes.git._merge_routes.get_git_service", return_value=mock_gs),
|
||||
patch("src.api.routes.git._resolve_dashboard_id_from_ref", AsyncMock(return_value=42)),
|
||||
):
|
||||
client = _make_client()
|
||||
resp = client.post("/repositories/42/merge/continue", json={"message": "x"})
|
||||
assert resp.status_code == 500
|
||||
# #endregion Test.Api.GitMergeRoutes
|
||||
264
backend/tests/api/test_git_repo_lifecycle_routes.py
Normal file
264
backend/tests/api/test_git_repo_lifecycle_routes.py
Normal file
@@ -0,0 +1,264 @@
|
||||
# #region Test.Api.GitRepoLifecycleRoutes [C:3] [TYPE Module] [SEMANTICS test,git,lifecycle,sync,promote,deploy]
|
||||
# @BRIEF Tests for Git lifecycle API routes — sync, promote, deploy.
|
||||
# @RELATION BINDS_TO -> [GitRepoLifecycleRoutes]
|
||||
|
||||
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, HTTPException
|
||||
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)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_db_repo():
|
||||
repo = MagicMock()
|
||||
repo.dashboard_id = 42
|
||||
repo.config_id = "cfg-1"
|
||||
repo.remote_url = "https://example.com/org/repo.git"
|
||||
repo.local_path = "/tmp/repo"
|
||||
repo.current_branch = "dev"
|
||||
return repo
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_git_config():
|
||||
config = MagicMock()
|
||||
config.id = "cfg-1"
|
||||
config.url = "https://gitea.example.com"
|
||||
config.pat = "pat-123"
|
||||
config.provider = "GITEA"
|
||||
config.default_branch = "main"
|
||||
return config
|
||||
|
||||
|
||||
def _make_client(overrides: dict | None = None) -> TestClient:
|
||||
from src.api.routes.git._router import router as parent_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(parent_router)
|
||||
|
||||
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)
|
||||
|
||||
|
||||
# ── sync_dashboard ──
|
||||
|
||||
class TestSyncDashboard:
|
||||
"""POST /repositories/{dashboard_ref}/sync"""
|
||||
|
||||
def test_success(self):
|
||||
mock_plugin = MagicMock()
|
||||
mock_plugin.execute = AsyncMock(return_value={"status": "synced"})
|
||||
|
||||
with (
|
||||
patch("src.plugins.git_plugin.GitPlugin", return_value=mock_plugin),
|
||||
patch("src.api.routes.git._resolve_dashboard_id_from_ref", AsyncMock(return_value=42)),
|
||||
):
|
||||
client = _make_client()
|
||||
resp = client.post("/repositories/42/sync")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["status"] == "synced"
|
||||
|
||||
def test_success_with_source_env(self):
|
||||
mock_plugin = MagicMock()
|
||||
mock_plugin.execute = AsyncMock(return_value={"status": "synced"})
|
||||
|
||||
with (
|
||||
patch("src.plugins.git_plugin.GitPlugin", return_value=mock_plugin),
|
||||
patch("src.api.routes.git._resolve_dashboard_id_from_ref", AsyncMock(return_value=42)),
|
||||
):
|
||||
client = _make_client()
|
||||
resp = client.post("/repositories/42/sync?source_env_id=staging")
|
||||
assert resp.status_code == 200
|
||||
|
||||
def test_unexpected_error(self):
|
||||
mock_plugin = MagicMock()
|
||||
mock_plugin.execute = AsyncMock(side_effect=RuntimeError("boom"))
|
||||
|
||||
with (
|
||||
patch("src.plugins.git_plugin.GitPlugin", return_value=mock_plugin),
|
||||
patch("src.api.routes.git._resolve_dashboard_id_from_ref", AsyncMock(return_value=42)),
|
||||
):
|
||||
client = _make_client()
|
||||
resp = client.post("/repositories/42/sync")
|
||||
assert resp.status_code == 500
|
||||
|
||||
def test_dashboard_not_found(self):
|
||||
with (
|
||||
patch("src.api.routes.git._resolve_dashboard_id_from_ref", AsyncMock(side_effect=HTTPException(404))),
|
||||
):
|
||||
client = _make_client()
|
||||
resp = client.post("/repositories/bad/42/sync")
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
# ── promote_dashboard ──
|
||||
|
||||
class TestPromoteDashboard:
|
||||
"""POST /repositories/{dashboard_ref}/promote"""
|
||||
|
||||
PROMOTE_PAYLOAD = {"from_branch": "dev", "to_branch": "main"}
|
||||
|
||||
def _setup_mock_db(self, mock_db_repo, mock_git_config):
|
||||
mock_db = MagicMock()
|
||||
mock_db.query.return_value.filter.return_value.first.side_effect = [mock_db_repo, mock_git_config]
|
||||
return mock_db
|
||||
|
||||
def test_success_mr_gitea(self, mock_db_repo, mock_git_config):
|
||||
mock_db = self._setup_mock_db(mock_db_repo, mock_git_config)
|
||||
mock_gs = MagicMock()
|
||||
mock_gs.create_gitea_pull_request = AsyncMock(return_value={
|
||||
"status": "opened", "url": "https://pr.url", "id": 1,
|
||||
})
|
||||
|
||||
from src.core.database import get_db
|
||||
with (
|
||||
patch("src.api.routes.git._repo_lifecycle_routes.get_git_service", return_value=mock_gs),
|
||||
patch("src.api.routes.git._resolve_dashboard_id_from_ref", AsyncMock(return_value=42)),
|
||||
):
|
||||
client = _make_client({get_db: lambda: mock_db})
|
||||
resp = client.post("/repositories/42/promote", json=self.PROMOTE_PAYLOAD)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["mode"] == "mr"
|
||||
assert data["status"] == "opened"
|
||||
assert data["policy_violation"] is False
|
||||
|
||||
def test_success_mr_github(self, mock_db_repo, mock_git_config):
|
||||
mock_git_config.provider = "GITHUB"
|
||||
mock_db = self._setup_mock_db(mock_db_repo, mock_git_config)
|
||||
mock_gs = MagicMock()
|
||||
mock_gs.create_github_pull_request = AsyncMock(return_value={
|
||||
"status": "opened", "url": "https://pr.url", "id": 2,
|
||||
})
|
||||
|
||||
from src.core.database import get_db
|
||||
with (
|
||||
patch("src.api.routes.git._repo_lifecycle_routes.get_git_service", return_value=mock_gs),
|
||||
patch("src.api.routes.git._resolve_dashboard_id_from_ref", AsyncMock(return_value=42)),
|
||||
):
|
||||
client = _make_client({get_db: lambda: mock_db})
|
||||
resp = client.post("/repositories/42/promote", json={**self.PROMOTE_PAYLOAD, "draft": True})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["mode"] == "mr"
|
||||
|
||||
def test_success_mr_gitlab(self, mock_db_repo, mock_git_config):
|
||||
mock_git_config.provider = "GITLAB"
|
||||
mock_db = self._setup_mock_db(mock_db_repo, mock_git_config)
|
||||
mock_gs = MagicMock()
|
||||
mock_gs.create_gitlab_merge_request = AsyncMock(return_value={
|
||||
"status": "opened", "url": "https://pr.url", "id": 3,
|
||||
})
|
||||
|
||||
from src.core.database import get_db
|
||||
with (
|
||||
patch("src.api.routes.git._repo_lifecycle_routes.get_git_service", return_value=mock_gs),
|
||||
patch("src.api.routes.git._resolve_dashboard_id_from_ref", AsyncMock(return_value=42)),
|
||||
):
|
||||
client = _make_client({get_db: lambda: mock_db})
|
||||
resp = client.post("/repositories/42/promote", json={**self.PROMOTE_PAYLOAD, "remove_source_branch": True})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["mode"] == "mr"
|
||||
|
||||
def test_success_direct_mode(self, mock_db_repo, mock_git_config):
|
||||
mock_db = self._setup_mock_db(mock_db_repo, mock_git_config)
|
||||
mock_gs = MagicMock()
|
||||
mock_gs.promote_direct_merge.return_value = {"status": "merged"}
|
||||
|
||||
from src.core.database import get_db
|
||||
with (
|
||||
patch("src.api.routes.git._repo_lifecycle_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_lifecycle_routes._apply_git_identity_from_profile", AsyncMock()),
|
||||
):
|
||||
client = _make_client({get_db: lambda: mock_db})
|
||||
resp = client.post("/repositories/42/promote", json={
|
||||
"from_branch": "dev", "to_branch": "main", "mode": "direct", "reason": "Hotfix",
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["mode"] == "direct"
|
||||
assert data["policy_violation"] is True
|
||||
|
||||
def test_repo_not_initialized(self):
|
||||
mock_db = MagicMock()
|
||||
mock_db.query.return_value.filter.return_value.first.return_value = None
|
||||
|
||||
from src.core.database import get_db
|
||||
with (
|
||||
patch("src.api.routes.git._resolve_dashboard_id_from_ref", AsyncMock(return_value=42)),
|
||||
):
|
||||
client = _make_client({get_db: lambda: mock_db})
|
||||
resp = client.post("/repositories/42/promote", json=self.PROMOTE_PAYLOAD)
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_unsupported_provider(self, mock_db_repo, mock_git_config):
|
||||
mock_git_config.provider = "UNKNOWN"
|
||||
mock_db = self._setup_mock_db(mock_db_repo, mock_git_config)
|
||||
|
||||
from src.core.database import get_db
|
||||
with (
|
||||
patch("src.api.routes.git._resolve_dashboard_id_from_ref", AsyncMock(return_value=42)),
|
||||
):
|
||||
client = _make_client({get_db: lambda: mock_db})
|
||||
resp = client.post("/repositories/42/promote", json=self.PROMOTE_PAYLOAD)
|
||||
assert resp.status_code == 501
|
||||
|
||||
|
||||
# ── deploy_dashboard ──
|
||||
|
||||
class TestDeployDashboard:
|
||||
"""POST /repositories/{dashboard_ref}/deploy"""
|
||||
|
||||
def test_success(self):
|
||||
mock_plugin = MagicMock()
|
||||
mock_plugin.execute = AsyncMock(return_value={"status": "deployed"})
|
||||
|
||||
with (
|
||||
patch("src.plugins.git_plugin.GitPlugin", return_value=mock_plugin),
|
||||
patch("src.api.routes.git._resolve_dashboard_id_from_ref", AsyncMock(return_value=42)),
|
||||
):
|
||||
client = _make_client()
|
||||
resp = client.post("/repositories/42/deploy", json={"environment_id": "prod"})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["status"] == "deployed"
|
||||
|
||||
def test_unexpected_error(self):
|
||||
mock_plugin = MagicMock()
|
||||
mock_plugin.execute = AsyncMock(side_effect=RuntimeError("boom"))
|
||||
|
||||
with (
|
||||
patch("src.plugins.git_plugin.GitPlugin", return_value=mock_plugin),
|
||||
patch("src.api.routes.git._resolve_dashboard_id_from_ref", AsyncMock(return_value=42)),
|
||||
):
|
||||
client = _make_client()
|
||||
resp = client.post("/repositories/42/deploy", json={"environment_id": "prod"})
|
||||
assert resp.status_code == 500
|
||||
# #endregion Test.Api.GitRepoLifecycleRoutes
|
||||
399
backend/tests/api/test_git_repo_operations_routes.py
Normal file
399
backend/tests/api/test_git_repo_operations_routes.py
Normal file
@@ -0,0 +1,399 @@
|
||||
# #region Test.Api.GitRepoOperationsRoutes [C:3] [TYPE Module] [SEMANTICS test,git,operations,commit,push,pull,status,diff,history]
|
||||
# @BRIEF Tests for Git repository operations API routes.
|
||||
# @RELATION BINDS_TO -> [GitRepoOperationsRoutes]
|
||||
|
||||
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, HTTPException
|
||||
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.git._router import router as parent_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(parent_router)
|
||||
|
||||
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)
|
||||
|
||||
|
||||
# ── commit_changes ──
|
||||
|
||||
class TestCommitChanges:
|
||||
"""POST /repositories/{dashboard_ref}/commit"""
|
||||
|
||||
COMMIT_PAYLOAD = {"message": "feat: add dashboard", "files": ["dash.json"]}
|
||||
|
||||
def test_success(self):
|
||||
mock_gs = MagicMock()
|
||||
|
||||
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()
|
||||
resp = client.post("/repositories/42/commit", json=self.COMMIT_PAYLOAD)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["status"] == "success"
|
||||
|
||||
def test_unexpected_error(self):
|
||||
mock_gs = MagicMock()
|
||||
mock_gs.commit_changes.side_effect = RuntimeError("boom")
|
||||
|
||||
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()
|
||||
resp = client.post("/repositories/42/commit", json=self.COMMIT_PAYLOAD)
|
||||
assert resp.status_code == 500
|
||||
|
||||
|
||||
# ── push_changes ──
|
||||
|
||||
class TestPushChanges:
|
||||
"""POST /repositories/{dashboard_ref}/push"""
|
||||
|
||||
def test_success(self):
|
||||
mock_gs = MagicMock()
|
||||
|
||||
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)),
|
||||
):
|
||||
client = _make_client()
|
||||
resp = client.post("/repositories/42/push")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["status"] == "success"
|
||||
|
||||
def test_unexpected_error(self):
|
||||
mock_gs = MagicMock()
|
||||
mock_gs.push_changes.side_effect = RuntimeError("boom")
|
||||
|
||||
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)),
|
||||
):
|
||||
client = _make_client()
|
||||
resp = client.post("/repositories/42/push")
|
||||
assert resp.status_code == 500
|
||||
|
||||
|
||||
# ── pull_changes ──
|
||||
|
||||
class TestPullChanges:
|
||||
"""POST /repositories/{dashboard_ref}/pull"""
|
||||
|
||||
def test_success(self):
|
||||
mock_gs = MagicMock()
|
||||
mock_db = MagicMock()
|
||||
mock_db.query.return_value.filter.return_value.first.return_value = None
|
||||
|
||||
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"
|
||||
|
||||
def test_success_with_repo_binding(self):
|
||||
mock_gs = MagicMock()
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.config_id = "cfg-1"
|
||||
mock_repo.local_path = "/tmp/repo"
|
||||
mock_repo.remote_url = "https://url"
|
||||
mock_config = MagicMock()
|
||||
mock_config.url = "https://gitea.example.com"
|
||||
mock_config.provider = "GITEA"
|
||||
|
||||
mock_db = MagicMock()
|
||||
mock_db.query.return_value.filter.return_value.first.side_effect = [mock_repo, mock_config]
|
||||
|
||||
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
|
||||
|
||||
def test_unexpected_error(self):
|
||||
mock_gs = MagicMock()
|
||||
mock_gs.pull_changes.side_effect = RuntimeError("boom")
|
||||
|
||||
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()
|
||||
resp = client.post("/repositories/42/pull")
|
||||
assert resp.status_code == 500
|
||||
|
||||
|
||||
# ── get_repository_status ──
|
||||
|
||||
class TestGetRepositoryStatus:
|
||||
"""GET /repositories/{dashboard_ref}/status"""
|
||||
|
||||
def test_success(self):
|
||||
with (
|
||||
patch("src.api.routes.git._repo_operations_routes._resolve_repository_status", AsyncMock(return_value={"is_dirty": False, "sync_state": "CLEAN"})),
|
||||
patch("src.api.routes.git._resolve_dashboard_id_from_ref", AsyncMock(return_value=42)),
|
||||
):
|
||||
client = _make_client()
|
||||
resp = client.get("/repositories/42/status")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["is_dirty"] is False
|
||||
assert data["sync_state"] == "CLEAN"
|
||||
|
||||
def test_unexpected_error(self):
|
||||
with (
|
||||
patch("src.api.routes.git._repo_operations_routes._resolve_repository_status", AsyncMock(side_effect=RuntimeError("boom"))),
|
||||
patch("src.api.routes.git._resolve_dashboard_id_from_ref", AsyncMock(return_value=42)),
|
||||
):
|
||||
client = _make_client()
|
||||
resp = client.get("/repositories/42/status")
|
||||
assert resp.status_code == 500
|
||||
|
||||
|
||||
# ── get_repository_status_batch ──
|
||||
|
||||
class TestGetRepositoryStatusBatch:
|
||||
"""POST /repositories/status/batch"""
|
||||
|
||||
def test_success(self):
|
||||
mock_status = {"is_dirty": False, "sync_state": "CLEAN", "has_repo": True}
|
||||
|
||||
with (
|
||||
patch("src.api.routes.git._repo_operations_routes._resolve_repository_status", AsyncMock(return_value=mock_status)),
|
||||
):
|
||||
client = _make_client()
|
||||
resp = client.post("/repositories/status/batch", json={"dashboard_ids": [1, 2, 3]})
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert "statuses" in data
|
||||
assert "1" in data["statuses"]
|
||||
assert len(data["statuses"]) == 3
|
||||
|
||||
def test_with_duplicate_ids(self):
|
||||
mock_status = {"is_dirty": False, "sync_state": "CLEAN", "has_repo": True}
|
||||
|
||||
with (
|
||||
patch("src.api.routes.git._repo_operations_routes._resolve_repository_status", AsyncMock(return_value=mock_status)),
|
||||
):
|
||||
client = _make_client()
|
||||
resp = client.post("/repositories/status/batch", json={"dashboard_ids": [1, 1, 2, 2]})
|
||||
assert resp.status_code == 200
|
||||
assert len(resp.json()["statuses"]) == 2
|
||||
|
||||
def test_with_http_error_on_one(self):
|
||||
def side_effect(did):
|
||||
if did == 1:
|
||||
raise HTTPException(404)
|
||||
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]})
|
||||
assert resp.status_code == 200
|
||||
statuses = resp.json()["statuses"]
|
||||
assert statuses["1"]["sync_state"] == "ERROR"
|
||||
assert statuses["2"]["has_repo"] is True
|
||||
|
||||
def test_truncates_large_batch(self):
|
||||
mock_status = {"is_dirty": False}
|
||||
many_ids = list(range(100))
|
||||
|
||||
with (
|
||||
patch("src.api.routes.git._repo_operations_routes._resolve_repository_status", AsyncMock(return_value=mock_status)),
|
||||
):
|
||||
client = _make_client()
|
||||
resp = client.post("/repositories/status/batch", json={"dashboard_ids": many_ids})
|
||||
assert resp.status_code == 200
|
||||
assert len(resp.json()["statuses"]) == 50
|
||||
|
||||
|
||||
# ── get_repository_diff ──
|
||||
|
||||
class TestGetRepositoryDiff:
|
||||
"""GET /repositories/{dashboard_ref}/diff"""
|
||||
|
||||
def test_success(self):
|
||||
mock_gs = MagicMock()
|
||||
mock_gs.get_diff.return_value = {"diff": "--- a/dash.json\n+++ b/dash.json"}
|
||||
|
||||
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)),
|
||||
):
|
||||
client = _make_client()
|
||||
resp = client.get("/repositories/42/diff")
|
||||
assert resp.status_code == 200
|
||||
assert "diff" in resp.json()
|
||||
|
||||
def test_with_file_filter(self):
|
||||
mock_gs = MagicMock()
|
||||
mock_gs.get_diff.return_value = {"diff": "file diff"}
|
||||
|
||||
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)),
|
||||
):
|
||||
client = _make_client()
|
||||
resp = client.get("/repositories/42/diff?file_path=dash.json&staged=true")
|
||||
assert resp.status_code == 200
|
||||
|
||||
def test_unexpected_error(self):
|
||||
mock_gs = MagicMock()
|
||||
mock_gs.get_diff.side_effect = RuntimeError("boom")
|
||||
|
||||
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)),
|
||||
):
|
||||
client = _make_client()
|
||||
resp = client.get("/repositories/42/diff")
|
||||
assert resp.status_code == 500
|
||||
|
||||
|
||||
# ── get_history ──
|
||||
|
||||
class TestGetHistory:
|
||||
"""GET /repositories/{dashboard_ref}/history"""
|
||||
|
||||
def test_success(self):
|
||||
from datetime import datetime
|
||||
mock_gs = MagicMock()
|
||||
mock_commit = MagicMock()
|
||||
mock_commit.hash = "abc123"
|
||||
mock_commit.author = "test"
|
||||
mock_commit.email = "test@x.com"
|
||||
mock_commit.timestamp = datetime.now()
|
||||
mock_commit.message = "feat: add"
|
||||
mock_commit.files_changed = ["dash.json"]
|
||||
mock_gs.get_commit_history.return_value = [mock_commit]
|
||||
|
||||
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)),
|
||||
):
|
||||
client = _make_client()
|
||||
resp = client.get("/repositories/42/history")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert len(data) == 1
|
||||
assert data[0]["hash"] == "abc123"
|
||||
|
||||
def test_with_limit(self):
|
||||
mock_gs = MagicMock()
|
||||
mock_gs.get_commit_history.return_value = []
|
||||
|
||||
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)),
|
||||
):
|
||||
client = _make_client()
|
||||
resp = client.get("/repositories/42/history?limit=5")
|
||||
assert resp.status_code == 200
|
||||
|
||||
def test_unexpected_error(self):
|
||||
mock_gs = MagicMock()
|
||||
mock_gs.get_commit_history.side_effect = RuntimeError("boom")
|
||||
|
||||
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)),
|
||||
):
|
||||
client = _make_client()
|
||||
resp = client.get("/repositories/42/history")
|
||||
assert resp.status_code == 500
|
||||
|
||||
|
||||
# ── generate_commit_message ──
|
||||
|
||||
class TestGenerateCommitMessage:
|
||||
"""POST /repositories/{dashboard_ref}/generate-message"""
|
||||
|
||||
def test_no_changes(self):
|
||||
mock_gs = MagicMock()
|
||||
mock_gs.get_diff.side_effect = [None, None]
|
||||
|
||||
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)),
|
||||
):
|
||||
client = _make_client()
|
||||
resp = client.post("/repositories/42/generate-message")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["message"] == "No changes detected"
|
||||
|
||||
def test_unexpected_error(self):
|
||||
mock_gs = MagicMock()
|
||||
mock_gs.get_diff.side_effect = RuntimeError("boom")
|
||||
|
||||
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)),
|
||||
):
|
||||
client = _make_client()
|
||||
resp = client.post("/repositories/42/generate-message")
|
||||
assert resp.status_code == 500
|
||||
|
||||
def test_no_active_provider(self):
|
||||
mock_gs = MagicMock()
|
||||
mock_gs.get_diff.side_effect = [{"diff": "changes"}, None]
|
||||
|
||||
mock_llm_svc = MagicMock()
|
||||
mock_llm_svc.get_all_providers.return_value = []
|
||||
|
||||
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),
|
||||
):
|
||||
client = _make_client()
|
||||
resp = client.post("/repositories/42/generate-message")
|
||||
assert resp.status_code == 400
|
||||
assert "No active LLM provider" in resp.text
|
||||
# #endregion Test.Api.GitRepoOperationsRoutes
|
||||
347
backend/tests/api/test_git_repo_routes.py
Normal file
347
backend/tests/api/test_git_repo_routes.py
Normal file
@@ -0,0 +1,347 @@
|
||||
# #region Test.Api.GitRepoRoutes [C:3] [TYPE Module] [SEMANTICS test,git,repo,init,binding,branches,checkout]
|
||||
# @BRIEF Tests for core Git repository API routes — init, binding, branches, checkout, delete.
|
||||
# @RELATION BINDS_TO -> [GitRepoRoutes]
|
||||
|
||||
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, HTTPException
|
||||
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)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_git_config():
|
||||
config = MagicMock()
|
||||
config.id = "cfg-1"
|
||||
config.url = "https://gitea.example.com"
|
||||
config.pat = "pat-123"
|
||||
config.provider = "GITEA"
|
||||
config.default_branch = "main"
|
||||
return config
|
||||
|
||||
|
||||
def _make_client(overrides: dict | None = None) -> TestClient:
|
||||
from src.api.routes.git._router import router as parent_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(parent_router)
|
||||
|
||||
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)
|
||||
|
||||
|
||||
# ── init_repository ──
|
||||
|
||||
|
||||
class TestInitRepository:
|
||||
"""POST /repositories/{dashboard_ref}/init"""
|
||||
|
||||
INIT_PAYLOAD = {"config_id": "cfg-1", "remote_url": "https://example.com/org/repo.git"}
|
||||
|
||||
def test_success_new_repo(self, mock_git_config):
|
||||
mock_db = MagicMock()
|
||||
mock_db.query.return_value.filter.return_value.first.side_effect = [
|
||||
mock_git_config, # GitServerConfig lookup
|
||||
None, # GitRepository lookup (not found → create new)
|
||||
]
|
||||
mock_gs = MagicMock()
|
||||
mock_gs.init_repo = AsyncMock()
|
||||
mock_gs._get_repo_path = AsyncMock(return_value="/tmp/repo")
|
||||
|
||||
from src.core.database import get_db
|
||||
with (
|
||||
patch("src.api.routes.git._repo_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._resolve_repo_key_from_ref", AsyncMock(return_value="my-dash")),
|
||||
):
|
||||
client = _make_client({get_db: lambda: mock_db})
|
||||
resp = client.post("/repositories/42/init", json=self.INIT_PAYLOAD)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["status"] == "success"
|
||||
|
||||
# Verify GitRepository was created
|
||||
mock_db.add.assert_called_once()
|
||||
mock_db.commit.assert_called_once()
|
||||
|
||||
def test_success_existing_repo(self, mock_git_config):
|
||||
existing_repo = MagicMock()
|
||||
existing_repo.dashboard_id = 42
|
||||
|
||||
mock_db = MagicMock()
|
||||
mock_db.query.return_value.filter.return_value.first.side_effect = [
|
||||
mock_git_config,
|
||||
existing_repo,
|
||||
]
|
||||
mock_gs = MagicMock()
|
||||
mock_gs.init_repo = AsyncMock()
|
||||
mock_gs._get_repo_path = AsyncMock(return_value="/tmp/repo")
|
||||
|
||||
from src.core.database import get_db
|
||||
with (
|
||||
patch("src.api.routes.git._repo_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._resolve_repo_key_from_ref", AsyncMock(return_value="my-dash")),
|
||||
):
|
||||
client = _make_client({get_db: lambda: mock_db})
|
||||
resp = client.post("/repositories/42/init", json=self.INIT_PAYLOAD)
|
||||
assert resp.status_code == 200
|
||||
# Should update existing, not add new
|
||||
mock_db.add.assert_not_called()
|
||||
mock_db.commit.assert_called_once()
|
||||
|
||||
def test_config_not_found(self):
|
||||
mock_db = MagicMock()
|
||||
mock_db.query.return_value.filter.return_value.first.return_value = None
|
||||
|
||||
from src.core.database import get_db
|
||||
with (
|
||||
patch("src.api.routes.git._resolve_dashboard_id_from_ref", AsyncMock(return_value=42)),
|
||||
):
|
||||
client = _make_client({get_db: lambda: mock_db})
|
||||
resp = client.post("/repositories/42/init", json=self.INIT_PAYLOAD)
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_init_error_rollback(self, mock_git_config):
|
||||
mock_db = MagicMock()
|
||||
mock_db.query.return_value.filter.return_value.first.side_effect = [
|
||||
mock_git_config,
|
||||
None,
|
||||
]
|
||||
mock_gs = MagicMock()
|
||||
mock_gs.init_repo = AsyncMock(side_effect=RuntimeError("init failed"))
|
||||
|
||||
from src.core.database import get_db
|
||||
with (
|
||||
patch("src.api.routes.git._repo_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._resolve_repo_key_from_ref", AsyncMock(return_value="my-dash")),
|
||||
):
|
||||
client = _make_client({get_db: lambda: mock_db})
|
||||
resp = client.post("/repositories/42/init", json=self.INIT_PAYLOAD)
|
||||
assert resp.status_code == 500
|
||||
mock_db.rollback.assert_called_once()
|
||||
|
||||
|
||||
# ── get_repository_binding ──
|
||||
|
||||
|
||||
class TestGetRepositoryBinding:
|
||||
"""GET /repositories/{dashboard_ref}"""
|
||||
|
||||
def test_success(self, mock_git_config):
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.dashboard_id = 42
|
||||
mock_repo.config_id = "cfg-1"
|
||||
mock_repo.remote_url = "https://example.com/repo.git"
|
||||
mock_repo.local_path = "/tmp/repo"
|
||||
|
||||
mock_db = MagicMock()
|
||||
mock_db.query.return_value.filter.return_value.first.side_effect = [
|
||||
mock_repo,
|
||||
mock_git_config,
|
||||
]
|
||||
|
||||
from src.core.database import get_db
|
||||
with (
|
||||
patch("src.api.routes.git._resolve_dashboard_id_from_ref", AsyncMock(return_value=42)),
|
||||
):
|
||||
client = _make_client({get_db: lambda: mock_db})
|
||||
resp = client.get("/repositories/42")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["dashboard_id"] == 42
|
||||
assert data["provider"] == "GITEA"
|
||||
assert data["remote_url"] == "https://example.com/repo.git"
|
||||
|
||||
def test_not_found(self):
|
||||
mock_db = MagicMock()
|
||||
mock_db.query.return_value.filter.return_value.first.return_value = None
|
||||
|
||||
from src.core.database import get_db
|
||||
with (
|
||||
patch("src.api.routes.git._resolve_dashboard_id_from_ref", AsyncMock(return_value=42)),
|
||||
):
|
||||
client = _make_client({get_db: lambda: mock_db})
|
||||
resp = client.get("/repositories/42")
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_unexpected_error(self, mock_git_config):
|
||||
mock_db = MagicMock()
|
||||
mock_db.query.return_value.filter.return_value.first.side_effect = RuntimeError("db error")
|
||||
|
||||
from src.core.database import get_db
|
||||
with (
|
||||
patch("src.api.routes.git._resolve_dashboard_id_from_ref", AsyncMock(return_value=42)),
|
||||
):
|
||||
client = _make_client({get_db: lambda: mock_db})
|
||||
resp = client.get("/repositories/42")
|
||||
assert resp.status_code == 500
|
||||
|
||||
|
||||
# ── delete_repository ──
|
||||
|
||||
|
||||
class TestDeleteRepository:
|
||||
"""DELETE /repositories/{dashboard_ref}"""
|
||||
|
||||
def test_success(self):
|
||||
mock_gs = MagicMock()
|
||||
mock_gs.delete_repo = AsyncMock()
|
||||
|
||||
with (
|
||||
patch("src.api.routes.git._repo_routes.get_git_service", return_value=mock_gs),
|
||||
patch("src.api.routes.git._resolve_dashboard_id_from_ref", AsyncMock(return_value=42)),
|
||||
):
|
||||
client = _make_client()
|
||||
resp = client.delete("/repositories/42")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["status"] == "success"
|
||||
|
||||
def test_unexpected_error(self):
|
||||
mock_gs = MagicMock()
|
||||
mock_gs.delete_repo = AsyncMock(side_effect=RuntimeError("boom"))
|
||||
|
||||
with (
|
||||
patch("src.api.routes.git._repo_routes.get_git_service", return_value=mock_gs),
|
||||
patch("src.api.routes.git._resolve_dashboard_id_from_ref", AsyncMock(return_value=42)),
|
||||
):
|
||||
client = _make_client()
|
||||
resp = client.delete("/repositories/42")
|
||||
assert resp.status_code == 500
|
||||
|
||||
|
||||
# ── get_branches ──
|
||||
|
||||
|
||||
class TestGetBranches:
|
||||
"""GET /repositories/{dashboard_ref}/branches"""
|
||||
|
||||
def test_success(self):
|
||||
mock_gs = MagicMock()
|
||||
mock_branch = MagicMock()
|
||||
mock_branch.name = "dev"
|
||||
mock_branch.commit_hash = "abc"
|
||||
mock_branch.is_remote = False
|
||||
from datetime import datetime
|
||||
mock_branch.last_updated = datetime.now()
|
||||
mock_gs.list_branches.return_value = [mock_branch]
|
||||
|
||||
with (
|
||||
patch("src.api.routes.git._repo_routes.get_git_service", return_value=mock_gs),
|
||||
patch("src.api.routes.git._resolve_dashboard_id_from_ref", AsyncMock(return_value=42)),
|
||||
):
|
||||
client = _make_client()
|
||||
resp = client.get("/repositories/42/branches")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert len(data) == 1
|
||||
assert data[0]["name"] == "dev"
|
||||
|
||||
def test_unexpected_error(self):
|
||||
mock_gs = MagicMock()
|
||||
mock_gs.list_branches.side_effect = RuntimeError("boom")
|
||||
|
||||
with (
|
||||
patch("src.api.routes.git._repo_routes.get_git_service", return_value=mock_gs),
|
||||
patch("src.api.routes.git._resolve_dashboard_id_from_ref", AsyncMock(return_value=42)),
|
||||
):
|
||||
client = _make_client()
|
||||
resp = client.get("/repositories/42/branches")
|
||||
assert resp.status_code == 500
|
||||
|
||||
|
||||
# ── create_branch ──
|
||||
|
||||
|
||||
class TestCreateBranch:
|
||||
"""POST /repositories/{dashboard_ref}/branches"""
|
||||
|
||||
BRANCH_PAYLOAD = {"name": "feature-x", "from_branch": "dev"}
|
||||
|
||||
def test_success(self):
|
||||
mock_gs = MagicMock()
|
||||
|
||||
with (
|
||||
patch("src.api.routes.git._repo_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_routes._apply_git_identity_from_profile", AsyncMock()),
|
||||
):
|
||||
client = _make_client()
|
||||
resp = client.post("/repositories/42/branches", json=self.BRANCH_PAYLOAD)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["status"] == "success"
|
||||
|
||||
def test_unexpected_error(self):
|
||||
mock_gs = MagicMock()
|
||||
mock_gs.create_branch.side_effect = RuntimeError("boom")
|
||||
|
||||
with (
|
||||
patch("src.api.routes.git._repo_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_routes._apply_git_identity_from_profile", AsyncMock()),
|
||||
):
|
||||
client = _make_client()
|
||||
resp = client.post("/repositories/42/branches", json=self.BRANCH_PAYLOAD)
|
||||
assert resp.status_code == 500
|
||||
|
||||
|
||||
# ── checkout_branch ──
|
||||
|
||||
|
||||
class TestCheckoutBranch:
|
||||
"""POST /repositories/{dashboard_ref}/checkout"""
|
||||
|
||||
CHECKOUT_PAYLOAD = {"name": "main"}
|
||||
|
||||
def test_success(self):
|
||||
mock_gs = MagicMock()
|
||||
|
||||
with (
|
||||
patch("src.api.routes.git._repo_routes.get_git_service", return_value=mock_gs),
|
||||
patch("src.api.routes.git._resolve_dashboard_id_from_ref", AsyncMock(return_value=42)),
|
||||
):
|
||||
client = _make_client()
|
||||
resp = client.post("/repositories/42/checkout", json=self.CHECKOUT_PAYLOAD)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["status"] == "success"
|
||||
|
||||
def test_unexpected_error(self):
|
||||
mock_gs = MagicMock()
|
||||
mock_gs.checkout_branch.side_effect = RuntimeError("boom")
|
||||
|
||||
with (
|
||||
patch("src.api.routes.git._repo_routes.get_git_service", return_value=mock_gs),
|
||||
patch("src.api.routes.git._resolve_dashboard_id_from_ref", AsyncMock(return_value=42)),
|
||||
):
|
||||
client = _make_client()
|
||||
resp = client.post("/repositories/42/checkout", json=self.CHECKOUT_PAYLOAD)
|
||||
assert resp.status_code == 500
|
||||
# #endregion Test.Api.GitRepoRoutes
|
||||
495
backend/tests/api/test_llm.py
Normal file
495
backend/tests/api/test_llm.py
Normal file
@@ -0,0 +1,495 @@
|
||||
# #region Test.Api.Llm [C:3] [TYPE Module] [SEMANTICS test,llm,routes]
|
||||
# @BRIEF Tests for LLM API routes — providers, fetch-models, status, test, probe.
|
||||
# @RELATION BINDS_TO -> [LlmRoutes]
|
||||
|
||||
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="/api/llm")
|
||||
|
||||
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)
|
||||
|
||||
|
||||
class TestGetProviders:
|
||||
"""GET /api/llm/providers"""
|
||||
|
||||
def test_success(self):
|
||||
mock_db = MagicMock()
|
||||
mock_svc = MagicMock()
|
||||
mock_svc.get_all_providers.return_value = [_make_provider()]
|
||||
mock_svc.get_decrypted_api_key.return_value = "sk-real-key"
|
||||
|
||||
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
|
||||
|
||||
def test_empty(self):
|
||||
mock_db = MagicMock()
|
||||
mock_svc = MagicMock()
|
||||
mock_svc.get_all_providers.return_value = []
|
||||
|
||||
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
|
||||
assert resp.json() == []
|
||||
|
||||
|
||||
class TestFetchModels:
|
||||
"""POST /api/llm/providers/fetch-models"""
|
||||
|
||||
def test_success_with_api_key(self):
|
||||
mock_db = MagicMock()
|
||||
mock_client = MagicMock()
|
||||
mock_client.fetch_models = AsyncMock(return_value=["gpt-4"])
|
||||
|
||||
from src.core.database import get_db
|
||||
with patch("src.plugins.llm_analysis.service.LLMClient", return_value=mock_client):
|
||||
client = _make_client({get_db: lambda: mock_db})
|
||||
resp = client.post(f"{P}/providers/fetch-models", json={
|
||||
"base_url": "https://api.openai.com", "provider_type": "openai", "api_key": "sk-test",
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
assert "gpt-4" in resp.json()["models"]
|
||||
|
||||
def test_success_with_provider_id(self):
|
||||
mock_db = MagicMock()
|
||||
mock_svc = MagicMock()
|
||||
mock_svc.get_provider.return_value = _make_provider()
|
||||
mock_svc.get_decrypted_api_key.return_value = "sk-decrypted"
|
||||
mock_client = MagicMock()
|
||||
mock_client.fetch_models = AsyncMock(return_value=["gpt-4"])
|
||||
|
||||
from src.core.database import get_db
|
||||
with (
|
||||
patch("src.api.routes.llm.LLMProviderService", return_value=mock_svc),
|
||||
patch("src.plugins.llm_analysis.service.LLMClient", return_value=mock_client),
|
||||
):
|
||||
client = _make_client({get_db: lambda: mock_db})
|
||||
resp = client.post(f"{P}/providers/fetch-models", json={"provider_id": "prov-1"})
|
||||
assert resp.status_code == 200
|
||||
|
||||
def test_missing_credentials(self):
|
||||
client = _make_client()
|
||||
resp = client.post(f"{P}/providers/fetch-models", json={
|
||||
"base_url": "https://api.openai.com", "provider_type": "openai",
|
||||
})
|
||||
assert resp.status_code == 400
|
||||
|
||||
def test_provider_not_found(self):
|
||||
mock_db = MagicMock()
|
||||
mock_svc = MagicMock()
|
||||
mock_svc.get_provider.return_value = None
|
||||
|
||||
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.post(f"{P}/providers/fetch-models", json={"provider_id": "unknown"})
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_missing_base_url(self):
|
||||
mock_db = MagicMock()
|
||||
mock_svc = MagicMock()
|
||||
mock_svc.get_provider.return_value = _make_provider(base_url="")
|
||||
mock_svc.get_decrypted_api_key.return_value = "sk-decrypted"
|
||||
|
||||
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.post(f"{P}/providers/fetch-models", json={"provider_id": "prov-1"})
|
||||
assert resp.status_code == 400
|
||||
|
||||
def test_api_failure(self):
|
||||
mock_db = MagicMock()
|
||||
mock_client = MagicMock()
|
||||
mock_client.fetch_models = AsyncMock(side_effect=RuntimeError("API error"))
|
||||
|
||||
from src.core.database import get_db
|
||||
with patch("src.plugins.llm_analysis.service.LLMClient", return_value=mock_client):
|
||||
client = _make_client({get_db: lambda: mock_db})
|
||||
resp = client.post(f"{P}/providers/fetch-models", json={
|
||||
"base_url": "https://api.openai.com", "provider_type": "openai", "api_key": "sk-test",
|
||||
})
|
||||
assert resp.status_code == 502
|
||||
|
||||
|
||||
class TestGetLlmStatus:
|
||||
"""GET /api/llm/status"""
|
||||
|
||||
def test_configured(self):
|
||||
mock_db = MagicMock()
|
||||
mock_svc = MagicMock()
|
||||
mock_svc.get_all_providers.return_value = [_make_provider()]
|
||||
mock_svc.get_decrypted_api_key.return_value = "sk-real-valid-key-16chars-min"
|
||||
|
||||
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}/status")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["configured"] is True
|
||||
|
||||
def test_no_providers(self):
|
||||
mock_db = MagicMock()
|
||||
mock_svc = MagicMock()
|
||||
mock_svc.get_all_providers.return_value = []
|
||||
|
||||
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}/status")
|
||||
assert resp.json()["configured"] is False
|
||||
|
||||
def test_no_active_provider(self):
|
||||
mock_db = MagicMock()
|
||||
mock_svc = MagicMock()
|
||||
mock_svc.get_all_providers.return_value = [_make_provider(is_active=False)]
|
||||
|
||||
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}/status")
|
||||
assert resp.json()["reason"] == "no_active_provider"
|
||||
|
||||
def test_invalid_api_key(self):
|
||||
mock_db = MagicMock()
|
||||
mock_svc = MagicMock()
|
||||
mock_svc.get_all_providers.return_value = [_make_provider()]
|
||||
mock_svc.get_decrypted_api_key.return_value = ""
|
||||
|
||||
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}/status")
|
||||
assert resp.json()["reason"] == "invalid_api_key"
|
||||
|
||||
|
||||
class TestCreateProvider:
|
||||
"""POST /api/llm/providers"""
|
||||
|
||||
def test_success(self):
|
||||
mock_db = MagicMock()
|
||||
mock_svc = MagicMock()
|
||||
mock_svc.create_provider.return_value = _make_provider(id="new-prov")
|
||||
|
||||
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.post(f"{P}/providers", json={
|
||||
"provider_type": "openai", "name": "New", "base_url": "https://x.com",
|
||||
"api_key": "sk-new", "default_model": "gpt-4",
|
||||
})
|
||||
assert resp.status_code == 201
|
||||
|
||||
|
||||
class TestUpdateProvider:
|
||||
"""PUT /api/llm/providers/{provider_id}"""
|
||||
|
||||
def test_success(self):
|
||||
mock_db = MagicMock()
|
||||
mock_svc = MagicMock()
|
||||
mock_svc.update_provider.return_value = _make_provider(id="prov-1")
|
||||
mock_svc.get_decrypted_api_key.return_value = "sk-decrypted"
|
||||
|
||||
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.put(f"{P}/providers/prov-1", json={
|
||||
"provider_type": "openai", "name": "Updated", "base_url": "https://x.com",
|
||||
"api_key": "sk-updated", "default_model": "gpt-4",
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
|
||||
def test_not_found(self):
|
||||
mock_db = MagicMock()
|
||||
mock_svc = MagicMock()
|
||||
mock_svc.update_provider.return_value = None
|
||||
|
||||
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.put(f"{P}/providers/unknown", json={
|
||||
"provider_type": "openai", "name": "X", "base_url": "https://x.com",
|
||||
"api_key": "sk-x", "default_model": "gpt-4",
|
||||
})
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
class TestDeleteProvider:
|
||||
"""DELETE /api/llm/providers/{provider_id}"""
|
||||
|
||||
def test_success(self):
|
||||
mock_db = MagicMock()
|
||||
mock_db.query.return_value.filter.return_value.all.return_value = []
|
||||
mock_svc = MagicMock()
|
||||
mock_svc.delete_provider.return_value = True
|
||||
|
||||
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.delete(f"{P}/providers/prov-1")
|
||||
assert resp.status_code == 204
|
||||
|
||||
def test_not_found(self):
|
||||
mock_db = MagicMock()
|
||||
mock_db.query.return_value.filter.return_value.all.return_value = []
|
||||
mock_svc = MagicMock()
|
||||
mock_svc.delete_provider.return_value = False
|
||||
|
||||
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.delete(f"{P}/providers/unknown")
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_blocked_by_active_tasks(self):
|
||||
mock_db = MagicMock()
|
||||
mock_db.query.return_value.filter.return_value.all.return_value = [MagicMock(id="t-1", name="Task")]
|
||||
|
||||
from src.core.database import get_db
|
||||
client = _make_client({get_db: lambda: mock_db})
|
||||
resp = client.delete(f"{P}/providers/prov-1")
|
||||
assert resp.status_code == 409
|
||||
|
||||
|
||||
class TestLlmTestConnection:
|
||||
"""POST /api/llm/providers/{provider_id}/test"""
|
||||
|
||||
def test_success(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-key"
|
||||
mock_client = MagicMock()
|
||||
mock_client.test_runtime_connection = AsyncMock()
|
||||
|
||||
from src.core.database import get_db
|
||||
with (
|
||||
patch("src.api.routes.llm.LLMProviderService", return_value=mock_svc),
|
||||
patch("src.plugins.llm_analysis.service.LLMClient", return_value=mock_client),
|
||||
):
|
||||
client = _make_client({get_db: lambda: mock_db})
|
||||
resp = client.post(f"{P}/providers/prov-1/test")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["success"] is True
|
||||
|
||||
def test_not_found(self):
|
||||
mock_db = MagicMock()
|
||||
mock_svc = MagicMock()
|
||||
mock_svc.get_provider.return_value = None
|
||||
|
||||
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.post(f"{P}/providers/unknown/test")
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_no_api_key(self):
|
||||
mock_db = MagicMock()
|
||||
mock_svc = MagicMock()
|
||||
mock_svc.get_provider.return_value = _make_provider()
|
||||
mock_svc.get_decrypted_api_key.return_value = ""
|
||||
|
||||
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.post(f"{P}/providers/prov-1/test")
|
||||
assert resp.status_code == 500
|
||||
|
||||
def test_connection_failure(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-key"
|
||||
mock_client = MagicMock()
|
||||
mock_client.test_runtime_connection = AsyncMock(side_effect=RuntimeError("fail"))
|
||||
|
||||
from src.core.database import get_db
|
||||
with (
|
||||
patch("src.api.routes.llm.LLMProviderService", return_value=mock_svc),
|
||||
patch("src.plugins.llm_analysis.service.LLMClient", return_value=mock_client),
|
||||
):
|
||||
client = _make_client({get_db: lambda: mock_db})
|
||||
resp = client.post(f"{P}/providers/prov-1/test")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["success"] is False
|
||||
|
||||
|
||||
class TestTestProviderConfig:
|
||||
"""POST /api/llm/providers/test"""
|
||||
|
||||
def test_success(self):
|
||||
mock_client = MagicMock()
|
||||
mock_client.test_runtime_connection = AsyncMock()
|
||||
|
||||
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
|
||||
assert resp.json()["success"] is True
|
||||
|
||||
def test_masked_api_key_rejected(self):
|
||||
client = _make_client()
|
||||
resp = client.post(f"{P}/providers/test", json={
|
||||
"provider_type": "openai", "name": "T", "base_url": "https://x.com",
|
||||
"api_key": "********", "default_model": "gpt-4",
|
||||
})
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
class TestProbeMaxImages:
|
||||
"""POST /api/llm/providers/{provider_id}/probe-max-images"""
|
||||
|
||||
def test_no_limit_detected(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"
|
||||
mock_openai = MagicMock()
|
||||
mock_openai.chat.completions = MagicMock()
|
||||
mock_openai.chat.completions.create = AsyncMock()
|
||||
|
||||
from src.core.database import get_db
|
||||
with (
|
||||
patch("src.api.routes.llm.LLMProviderService", return_value=mock_svc),
|
||||
patch("src.api.routes.llm.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
|
||||
assert resp.json()["method"] == "no_limit_detected"
|
||||
|
||||
def test_provider_not_found(self):
|
||||
mock_db = MagicMock()
|
||||
mock_svc = MagicMock()
|
||||
mock_svc.get_provider.return_value = None
|
||||
|
||||
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.post(f"{P}/providers/unknown/probe-max-images")
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_no_api_key(self):
|
||||
mock_db = MagicMock()
|
||||
mock_svc = MagicMock()
|
||||
mock_svc.get_provider.return_value = _make_provider()
|
||||
mock_svc.get_decrypted_api_key.return_value = ""
|
||||
|
||||
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.post(f"{P}/providers/prov-1/probe-max-images")
|
||||
assert resp.status_code == 400
|
||||
|
||||
def test_no_default_model(self):
|
||||
mock_db = MagicMock()
|
||||
mock_svc = MagicMock()
|
||||
mock_svc.get_provider.return_value = _make_provider(default_model="")
|
||||
mock_svc.get_decrypted_api_key.return_value = "sk-real"
|
||||
|
||||
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.post(f"{P}/providers/prov-1/probe-max-images")
|
||||
assert resp.status_code == 400
|
||||
|
||||
def test_limit_from_error_parse(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"
|
||||
mock_openai = MagicMock()
|
||||
mock_openai.chat.completions = MagicMock()
|
||||
mock_openai.chat.completions.create = AsyncMock(side_effect=RuntimeError("At most 5 image(s) per request"))
|
||||
|
||||
from src.core.database import get_db
|
||||
with (
|
||||
patch("src.api.routes.llm.LLMProviderService", return_value=mock_svc),
|
||||
patch("src.api.routes.llm.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
|
||||
assert resp.json()["max_images"] == 5
|
||||
assert resp.json()["method"] == "parse_error"
|
||||
|
||||
def test_first_image_fails(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"
|
||||
mock_openai = MagicMock()
|
||||
mock_openai.chat.completions = MagicMock()
|
||||
mock_openai.chat.completions.create = AsyncMock(side_effect=RuntimeError("unsupported"))
|
||||
|
||||
from src.core.database import get_db
|
||||
with (
|
||||
patch("src.api.routes.llm.LLMProviderService", return_value=mock_svc),
|
||||
patch("src.api.routes.llm.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
|
||||
assert resp.json()["max_images"] == 0
|
||||
# #endregion Test.Api.Llm
|
||||
192
backend/tests/api/test_mappings.py
Normal file
192
backend/tests/api/test_mappings.py
Normal file
@@ -0,0 +1,192 @@
|
||||
# #region Test.Api.Mappings [C:3] [TYPE Module] [SEMANTICS test,mappings,routes]
|
||||
# @BRIEF Tests for mappings API routes — get, create, suggest.
|
||||
# @RELATION BINDS_TO -> [MappingsApi]
|
||||
|
||||
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)
|
||||
|
||||
|
||||
def _make_mapping(**kw):
|
||||
m = MagicMock()
|
||||
m.id = kw.get("id", "map-1")
|
||||
m.source_env_id = kw.get("source_env_id", "env-1")
|
||||
m.target_env_id = kw.get("target_env_id", "env-2")
|
||||
m.source_db_uuid = kw.get("source_db_uuid", "src-uuid")
|
||||
m.target_db_uuid = kw.get("target_db_uuid", "tgt-uuid")
|
||||
m.source_db_name = kw.get("source_db_name", "Source DB")
|
||||
m.target_db_name = kw.get("target_db_name", "Target DB")
|
||||
m.engine = kw.get("engine", "postgresql")
|
||||
return m
|
||||
|
||||
|
||||
def _make_client(overrides: dict | None = None) -> TestClient:
|
||||
from src.api.routes.mappings import router
|
||||
from src.core.database import get_db
|
||||
from src.dependencies import get_config_manager, get_current_user
|
||||
from src.schemas.auth import User, RoleSchema
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(router)
|
||||
|
||||
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
|
||||
app.dependency_overrides[get_config_manager] = lambda: MagicMock()
|
||||
if overrides:
|
||||
for dep, fn in overrides.items():
|
||||
app.dependency_overrides[dep] = fn
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
# ── get_mappings ──
|
||||
|
||||
class TestGetMappings:
|
||||
"""GET /mappings"""
|
||||
|
||||
def test_success(self):
|
||||
mock_db = MagicMock()
|
||||
mock_db.query.return_value.all.return_value = [
|
||||
_make_mapping(id="m1"),
|
||||
_make_mapping(id="m2"),
|
||||
]
|
||||
|
||||
from src.core.database import get_db
|
||||
client = _make_client({get_db: lambda: mock_db})
|
||||
resp = client.get("/mappings")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert len(data) == 2
|
||||
assert data[0]["id"] == "m1"
|
||||
|
||||
def test_filter_by_source(self):
|
||||
mock_db = MagicMock()
|
||||
mock_filter = MagicMock()
|
||||
mock_db.query.return_value.filter.return_value = mock_filter
|
||||
mock_filter.filter.return_value = mock_filter
|
||||
mock_filter.all.return_value = [_make_mapping(id="m1")]
|
||||
|
||||
from src.core.database import get_db
|
||||
client = _make_client({get_db: lambda: mock_db})
|
||||
resp = client.get("/mappings?source_env_id=env-1")
|
||||
assert resp.status_code == 200
|
||||
|
||||
def test_filter_by_target(self):
|
||||
mock_db = MagicMock()
|
||||
mock_filter = MagicMock()
|
||||
mock_db.query.return_value.filter.return_value = mock_filter
|
||||
mock_filter.filter.return_value = mock_filter
|
||||
mock_filter.all.return_value = []
|
||||
|
||||
from src.core.database import get_db
|
||||
client = _make_client({get_db: lambda: mock_db})
|
||||
resp = client.get("/mappings?target_env_id=env-2")
|
||||
assert resp.status_code == 200
|
||||
|
||||
def test_empty(self):
|
||||
mock_db = MagicMock()
|
||||
mock_db.query.return_value.all.return_value = []
|
||||
|
||||
from src.core.database import get_db
|
||||
client = _make_client({get_db: lambda: mock_db})
|
||||
resp = client.get("/mappings")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json() == []
|
||||
|
||||
|
||||
# ── create_mapping ──
|
||||
|
||||
class TestCreateMapping:
|
||||
"""POST /mappings"""
|
||||
|
||||
CREATE_PAYLOAD = {
|
||||
"source_env_id": "env-1",
|
||||
"target_env_id": "env-2",
|
||||
"source_db_uuid": "uuid-1",
|
||||
"target_db_uuid": "uuid-2",
|
||||
"source_db_name": "Source DB",
|
||||
"target_db_name": "Target DB",
|
||||
"engine": "postgresql",
|
||||
}
|
||||
|
||||
def test_create_new(self):
|
||||
mock_db = MagicMock()
|
||||
mock_db.query.return_value.filter.return_value.first.return_value = None
|
||||
|
||||
from src.core.database import get_db
|
||||
client = _make_client({get_db: lambda: mock_db})
|
||||
resp = client.post("/mappings", json=self.CREATE_PAYLOAD)
|
||||
assert resp.status_code == 200
|
||||
mock_db.add.assert_called_once()
|
||||
mock_db.commit.assert_called_once()
|
||||
|
||||
def test_update_existing(self):
|
||||
existing = _make_mapping()
|
||||
mock_db = MagicMock()
|
||||
mock_db.query.return_value.filter.return_value.first.return_value = existing
|
||||
|
||||
from src.core.database import get_db
|
||||
client = _make_client({get_db: lambda: mock_db})
|
||||
resp = client.post("/mappings", json=self.CREATE_PAYLOAD)
|
||||
assert resp.status_code == 200
|
||||
mock_db.add.assert_not_called()
|
||||
mock_db.commit.assert_called_once()
|
||||
assert existing.target_db_uuid == "uuid-2"
|
||||
assert existing.target_db_name == "Target DB"
|
||||
|
||||
|
||||
# ── suggest_mappings_api ──
|
||||
|
||||
class TestSuggestMappingsApi:
|
||||
"""POST /mappings/suggest"""
|
||||
|
||||
def test_success(self):
|
||||
mock_config = MagicMock()
|
||||
mock_suggestions = [{"source": "db1", "target": "db2", "score": 0.95}]
|
||||
|
||||
from src.dependencies import get_config_manager
|
||||
with patch("src.api.routes.mappings.MappingService") as MockMappingSvc:
|
||||
instance = MagicMock()
|
||||
instance.get_suggestions = MagicMock(return_value=mock_suggestions)
|
||||
MockMappingSvc.return_value = instance
|
||||
client = _make_client({get_config_manager: lambda: mock_config})
|
||||
resp = client.post("/mappings/suggest", json={
|
||||
"source_env_id": "env-1", "target_env_id": "env-2",
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json() == mock_suggestions
|
||||
|
||||
def test_error(self):
|
||||
mock_config = MagicMock()
|
||||
from src.dependencies import get_config_manager
|
||||
with patch("src.api.routes.mappings.MappingService") as MockMappingSvc:
|
||||
instance = MagicMock()
|
||||
instance.get_suggestions = MagicMock(side_effect=RuntimeError("suggestion failed"))
|
||||
MockMappingSvc.return_value = instance
|
||||
client = _make_client({get_config_manager: lambda: mock_config})
|
||||
resp = client.post("/mappings/suggest", json={
|
||||
"source_env_id": "env-1", "target_env_id": "env-2",
|
||||
})
|
||||
assert resp.status_code == 500
|
||||
# #endregion Test.Api.Mappings
|
||||
391
backend/tests/api/test_migration.py
Normal file
391
backend/tests/api/test_migration.py
Normal file
@@ -0,0 +1,391 @@
|
||||
# #region Test.Api.Migration [C:3] [TYPE Module] [SEMANTICS test,migration,routes]
|
||||
# @BRIEF Tests for migration API routes — dashboards, execute, dry-run, settings, mappings, sync.
|
||||
# @RELATION BINDS_TO -> [MigrationApi]
|
||||
|
||||
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)
|
||||
|
||||
|
||||
def _make_env(id_val="env-1", name="Test Env", url="https://superset.example.com"):
|
||||
env = MagicMock()
|
||||
env.id = id_val
|
||||
env.name = name
|
||||
env.url = url
|
||||
return env
|
||||
|
||||
|
||||
def _make_client(overrides: dict | None = None) -> TestClient:
|
||||
from src.api.routes.migration import router
|
||||
from src.core.database import get_db
|
||||
from src.dependencies import get_config_manager, get_current_user, get_task_manager
|
||||
from src.schemas.auth import User, RoleSchema
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(router)
|
||||
|
||||
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
|
||||
app.dependency_overrides[get_config_manager] = 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)
|
||||
|
||||
|
||||
# ── get_dashboards ──
|
||||
|
||||
class TestGetDashboards:
|
||||
"""GET /environments/{env_id}/dashboards"""
|
||||
|
||||
def test_success(self):
|
||||
mock_config = MagicMock()
|
||||
mock_config.get_environments.return_value = [_make_env()]
|
||||
|
||||
dashboards = [{"id": 1, "dashboard_title": "Test Dash"}]
|
||||
mock_client = MagicMock()
|
||||
mock_client.get_dashboards_summary = AsyncMock(return_value=dashboards)
|
||||
|
||||
from src.dependencies import get_config_manager
|
||||
with patch("src.api.routes.migration.AsyncSupersetClient", return_value=mock_client):
|
||||
client = _make_client({get_config_manager: lambda: mock_config})
|
||||
resp = client.get("/environments/env-1/dashboards")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert len(data) == 1
|
||||
assert data[0]["id"] == 1
|
||||
|
||||
def test_environment_not_found(self):
|
||||
mock_config = MagicMock()
|
||||
mock_config.get_environments.return_value = []
|
||||
|
||||
from src.dependencies import get_config_manager
|
||||
client = _make_client({get_config_manager: lambda: mock_config})
|
||||
resp = client.get("/environments/bad-env/dashboards")
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
# ── execute_migration ──
|
||||
|
||||
class TestExecuteMigration:
|
||||
"""POST /migration/execute"""
|
||||
|
||||
SELECTION_PAYLOAD = {
|
||||
"source_env_id": "env-1",
|
||||
"target_env_id": "env-2",
|
||||
"selected_ids": [1, 2, 3],
|
||||
"replace_db_config": True,
|
||||
"fix_cross_filters": False,
|
||||
}
|
||||
|
||||
def test_success(self):
|
||||
mock_config = MagicMock()
|
||||
e1 = _make_env("env-1")
|
||||
e2 = _make_env("env-2")
|
||||
mock_config.get_environments.return_value = [e1, e2]
|
||||
|
||||
mock_tm = MagicMock()
|
||||
mock_tm.create_task = AsyncMock(return_value=MagicMock(id="task-123"))
|
||||
|
||||
from src.dependencies import get_config_manager, get_task_manager
|
||||
client = _make_client({
|
||||
get_config_manager: lambda: mock_config,
|
||||
get_task_manager: lambda: mock_tm,
|
||||
})
|
||||
resp = client.post("/migration/execute", json=self.SELECTION_PAYLOAD)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["task_id"] == "task-123"
|
||||
|
||||
def test_invalid_source_env(self):
|
||||
mock_config = MagicMock()
|
||||
mock_config.get_environments.return_value = [_make_env("env-1")]
|
||||
|
||||
from src.dependencies import get_config_manager
|
||||
client = _make_client({get_config_manager: lambda: mock_config})
|
||||
resp = client.post("/migration/execute", json=self.SELECTION_PAYLOAD)
|
||||
assert resp.status_code == 400
|
||||
|
||||
def test_invalid_target_env(self):
|
||||
mock_config = MagicMock()
|
||||
mock_config.get_environments.return_value = [_make_env("env-2")]
|
||||
|
||||
from src.dependencies import get_config_manager
|
||||
client = _make_client({get_config_manager: lambda: mock_config})
|
||||
resp = client.post("/migration/execute", json=self.SELECTION_PAYLOAD)
|
||||
assert resp.status_code == 400
|
||||
|
||||
def test_task_creation_fails(self):
|
||||
mock_config = MagicMock()
|
||||
e1 = _make_env("env-1")
|
||||
e2 = _make_env("env-2")
|
||||
mock_config.get_environments.return_value = [e1, e2]
|
||||
|
||||
mock_tm = MagicMock()
|
||||
mock_tm.create_task = AsyncMock(side_effect=RuntimeError("task failed"))
|
||||
|
||||
from src.dependencies import get_config_manager, get_task_manager
|
||||
client = _make_client({
|
||||
get_config_manager: lambda: mock_config,
|
||||
get_task_manager: lambda: mock_tm,
|
||||
})
|
||||
resp = client.post("/migration/execute", json=self.SELECTION_PAYLOAD)
|
||||
assert resp.status_code == 500
|
||||
|
||||
|
||||
# ── dry_run_migration ──
|
||||
|
||||
class TestDryRunMigration:
|
||||
"""POST /migration/dry-run"""
|
||||
|
||||
SELECTION_PAYLOAD = {
|
||||
"source_env_id": "env-1",
|
||||
"target_env_id": "env-2",
|
||||
"selected_ids": [1, 2],
|
||||
"replace_db_config": False,
|
||||
"fix_cross_filters": False,
|
||||
}
|
||||
|
||||
def test_success(self):
|
||||
mock_config = MagicMock()
|
||||
e1 = _make_env("env-1")
|
||||
e2 = _make_env("env-2")
|
||||
mock_config.get_environments.return_value = [e1, e2]
|
||||
|
||||
mock_dry_run = MagicMock()
|
||||
mock_dry_run.run = AsyncMock(return_value={"summary": "ok", "risks": []})
|
||||
|
||||
from src.dependencies import get_config_manager
|
||||
with patch("src.api.routes.migration.MigrationDryRunService", return_value=mock_dry_run):
|
||||
client = _make_client({get_config_manager: lambda: mock_config})
|
||||
resp = client.post("/migration/dry-run", json=self.SELECTION_PAYLOAD)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["summary"] == "ok"
|
||||
|
||||
def test_identical_environments(self):
|
||||
mock_config = MagicMock()
|
||||
e1 = _make_env("env-1")
|
||||
mock_config.get_environments.return_value = [e1]
|
||||
|
||||
from src.dependencies import get_config_manager
|
||||
client = _make_client({get_config_manager: lambda: mock_config})
|
||||
resp = client.post("/migration/dry-run", json={
|
||||
"source_env_id": "env-1", "target_env_id": "env-1", "selected_ids": [1],
|
||||
})
|
||||
assert resp.status_code == 400
|
||||
|
||||
def test_invalid_environments(self):
|
||||
mock_config = MagicMock()
|
||||
mock_config.get_environments.return_value = []
|
||||
|
||||
from src.dependencies import get_config_manager
|
||||
client = _make_client({get_config_manager: lambda: mock_config})
|
||||
resp = client.post("/migration/dry-run", json=self.SELECTION_PAYLOAD)
|
||||
assert resp.status_code == 400
|
||||
|
||||
def test_no_dashboards_selected(self):
|
||||
mock_config = MagicMock()
|
||||
e1 = _make_env("env-1")
|
||||
e2 = _make_env("env-2")
|
||||
mock_config.get_environments.return_value = [e1, e2]
|
||||
|
||||
from src.dependencies import get_config_manager
|
||||
client = _make_client({get_config_manager: lambda: mock_config})
|
||||
resp = client.post("/migration/dry-run", json={
|
||||
"source_env_id": "env-1", "target_env_id": "env-2", "selected_ids": [],
|
||||
})
|
||||
assert resp.status_code == 400
|
||||
|
||||
def test_value_error_from_service(self):
|
||||
mock_config = MagicMock()
|
||||
e1 = _make_env("env-1")
|
||||
e2 = _make_env("env-2")
|
||||
mock_config.get_environments.return_value = [e1, e2]
|
||||
|
||||
mock_dry_run = MagicMock()
|
||||
mock_dry_run.run = AsyncMock(side_effect=ValueError("orchestrator error"))
|
||||
|
||||
from src.dependencies import get_config_manager
|
||||
with patch("src.api.routes.migration.MigrationDryRunService", return_value=mock_dry_run):
|
||||
client = _make_client({get_config_manager: lambda: mock_config})
|
||||
resp = client.post("/migration/dry-run", json=self.SELECTION_PAYLOAD)
|
||||
assert resp.status_code == 500
|
||||
|
||||
|
||||
# ── get_migration_settings ──
|
||||
|
||||
class TestGetMigrationSettings:
|
||||
"""GET /migration/settings"""
|
||||
|
||||
def test_success(self):
|
||||
mock_config = MagicMock()
|
||||
mock_cfg = MagicMock()
|
||||
mock_cfg.settings.migration_sync_cron = "0 2 * * *"
|
||||
mock_config.get_config.return_value = mock_cfg
|
||||
|
||||
from src.dependencies import get_config_manager
|
||||
client = _make_client({get_config_manager: lambda: mock_config})
|
||||
resp = client.get("/migration/settings")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["cron"] == "0 2 * * *"
|
||||
|
||||
|
||||
# ── update_migration_settings ──
|
||||
|
||||
class TestUpdateMigrationSettings:
|
||||
"""PUT /migration/settings"""
|
||||
|
||||
def test_success(self):
|
||||
mock_config = MagicMock()
|
||||
mock_cfg = MagicMock()
|
||||
mock_config.get_config.return_value = mock_cfg
|
||||
|
||||
from src.dependencies import get_config_manager
|
||||
client = _make_client({get_config_manager: lambda: mock_config})
|
||||
resp = client.put("/migration/settings", json={"cron": "0 3 * * *"})
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["cron"] == "0 3 * * *"
|
||||
assert data["status"] == "updated"
|
||||
mock_config.save_config.assert_called_once()
|
||||
|
||||
def test_missing_cron_field(self):
|
||||
mock_config = MagicMock()
|
||||
from src.dependencies import get_config_manager
|
||||
client = _make_client({get_config_manager: lambda: mock_config})
|
||||
resp = client.put("/migration/settings", json={})
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
# ── get_resource_mappings ──
|
||||
|
||||
class TestGetResourceMappings:
|
||||
"""GET /migration/mappings-data"""
|
||||
|
||||
def test_success(self):
|
||||
mock_db = MagicMock()
|
||||
mock_query = MagicMock()
|
||||
mock_db.query.return_value = mock_query
|
||||
mock_query.filter.return_value = mock_query
|
||||
mock_query.count.return_value = 2
|
||||
mock_query.order_by.return_value.offset.return_value.limit.return_value.all.return_value = [
|
||||
MagicMock(id=1, environment_id="env-1", resource_type="DATABASE", uuid="u1",
|
||||
remote_integer_id=100, resource_name="DB", last_synced_at=None),
|
||||
MagicMock(id=2, environment_id="env-1", resource_type="DATABASE", uuid="u2",
|
||||
remote_integer_id=101, resource_name="DB2", last_synced_at=None),
|
||||
]
|
||||
|
||||
from src.core.database import get_db
|
||||
client = _make_client({get_db: lambda: mock_db})
|
||||
resp = client.get("/migration/mappings-data")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["total"] == 2
|
||||
assert len(data["items"]) == 2
|
||||
|
||||
def test_with_filters(self):
|
||||
mock_db = MagicMock()
|
||||
mock_query = MagicMock()
|
||||
mock_db.query.return_value = mock_query
|
||||
mock_query.filter.return_value = mock_query
|
||||
mock_query.count.return_value = 1
|
||||
mock_query.order_by.return_value.offset.return_value.limit.return_value.all.return_value = []
|
||||
|
||||
from src.core.database import get_db
|
||||
client = _make_client({get_db: lambda: mock_db})
|
||||
resp = client.get("/migration/mappings-data?env_id=env-1&resource_type=database&search=test&skip=0&limit=10")
|
||||
assert resp.status_code == 200
|
||||
|
||||
|
||||
# ── trigger_sync_now ──
|
||||
|
||||
class TestTriggerSyncNow:
|
||||
"""POST /migration/sync-now"""
|
||||
|
||||
def test_success(self):
|
||||
mock_config = MagicMock()
|
||||
mock_cfg = MagicMock()
|
||||
e1 = _make_env("env-1", "Env 1", "https://s1.example.com")
|
||||
e2 = _make_env("env-2", "Env 2", "https://s2.example.com")
|
||||
mock_cfg.environments = [e1, e2]
|
||||
mock_config.get_config.return_value = mock_cfg
|
||||
|
||||
mock_db = MagicMock()
|
||||
mock_db.query.return_value.filter_by.return_value.first.side_effect = [None, None]
|
||||
|
||||
mock_sync = MagicMock()
|
||||
|
||||
from src.core.database import get_db
|
||||
from src.dependencies import get_config_manager
|
||||
with patch("src.api.routes.migration.IdMappingService", return_value=mock_sync):
|
||||
client = _make_client({
|
||||
get_config_manager: lambda: mock_config,
|
||||
get_db: lambda: mock_db,
|
||||
})
|
||||
resp = client.post("/migration/sync-now")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["status"] == "completed"
|
||||
assert data["synced_count"] == 2
|
||||
|
||||
def test_no_environments(self):
|
||||
mock_config = MagicMock()
|
||||
mock_cfg = MagicMock()
|
||||
mock_cfg.environments = []
|
||||
mock_config.get_config.return_value = mock_cfg
|
||||
|
||||
from src.dependencies import get_config_manager
|
||||
client = _make_client({get_config_manager: lambda: mock_config})
|
||||
resp = client.post("/migration/sync-now")
|
||||
assert resp.status_code == 400
|
||||
|
||||
def test_sync_with_failures(self):
|
||||
mock_config = MagicMock()
|
||||
mock_cfg = MagicMock()
|
||||
e1 = _make_env("env-1")
|
||||
e2 = _make_env("env-2")
|
||||
mock_cfg.environments = [e1, e2]
|
||||
mock_config.get_config.return_value = mock_cfg
|
||||
|
||||
mock_db = MagicMock()
|
||||
mock_db.query.return_value.filter_by.return_value.first.return_value = None
|
||||
|
||||
mock_sync = MagicMock()
|
||||
mock_sync.sync_environment.side_effect = [None, RuntimeError("sync failed")]
|
||||
|
||||
from src.core.database import get_db
|
||||
from src.dependencies import get_config_manager
|
||||
with patch("src.api.routes.migration.IdMappingService", return_value=mock_sync):
|
||||
client = _make_client({
|
||||
get_config_manager: lambda: mock_config,
|
||||
get_db: lambda: mock_db,
|
||||
})
|
||||
resp = client.post("/migration/sync-now")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["synced_count"] == 1
|
||||
assert data["failed_count"] == 1
|
||||
# #endregion Test.Api.Migration
|
||||
@@ -24,7 +24,7 @@ if _src not in sys.path:
|
||||
sys.path.insert(0, _src)
|
||||
|
||||
|
||||
def _make_client(overrides: dict | None = None) -> TestClient:
|
||||
def _make_client(overrides: dict | None = None, raise_server_exceptions: bool = True) -> TestClient:
|
||||
from src.api.routes.reports import router
|
||||
from src.dependencies import get_clean_release_repository, get_task_manager, get_current_user, has_permission
|
||||
from src.schemas.auth import User, RoleSchema
|
||||
@@ -49,7 +49,7 @@ def _make_client(overrides: dict | None = None) -> TestClient:
|
||||
defaults.update(overrides)
|
||||
for dep, mock_fn in defaults.items():
|
||||
app.dependency_overrides[dep] = mock_fn
|
||||
return TestClient(app)
|
||||
return TestClient(app, raise_server_exceptions=raise_server_exceptions)
|
||||
|
||||
|
||||
# ── _parse_csv_enum_list ──
|
||||
@@ -129,7 +129,7 @@ class TestListReports:
|
||||
with patch("src.api.routes.reports.ReportsService") as MockService:
|
||||
instance = MockService.return_value
|
||||
instance.list_reports.side_effect = ValueError("Internal error")
|
||||
client = _make_client()
|
||||
client = _make_client(raise_server_exceptions=False)
|
||||
resp = client.get("/api/reports?page=1&page_size=20")
|
||||
# Exception from service call is outside try-except -> 500
|
||||
assert resp.status_code == 500
|
||||
@@ -155,24 +155,26 @@ class TestGetReportDetail:
|
||||
|
||||
def test_detail_success(self):
|
||||
from src.models.report import ReportDetailView, TaskReport, ReportStatus, TaskType
|
||||
dt = __import__("datetime").datetime.now()
|
||||
with patch("src.api.routes.reports.ReportsService") as MockService:
|
||||
instance = MockService.return_value
|
||||
instance.get_report_detail.return_value = ReportDetailView(
|
||||
task=TaskReport(
|
||||
id="report-1",
|
||||
report=TaskReport(
|
||||
report_id="report-1",
|
||||
task_id="task-1",
|
||||
task_type=TaskType.BACKUP,
|
||||
status=ReportStatus.SUCCESS,
|
||||
title="Backup report",
|
||||
created_at=__import__("datetime").datetime.now(),
|
||||
updated_at=dt,
|
||||
summary="Backup completed successfully",
|
||||
),
|
||||
diagnostics=[],
|
||||
diagnostics=None,
|
||||
next_actions=[],
|
||||
)
|
||||
client = _make_client()
|
||||
resp = client.get("/api/reports/report-1")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["task"]["id"] == "report-1"
|
||||
assert data["report"]["report_id"] == "report-1"
|
||||
|
||||
def test_detail_not_found(self):
|
||||
mock_task_manager = MagicMock()
|
||||
|
||||
707
backend/tests/api/test_settings.py
Normal file
707
backend/tests/api/test_settings.py
Normal file
@@ -0,0 +1,707 @@
|
||||
# #region Test.Api.Settings [C:3] [TYPE Module] [SEMANTICS test,settings,routes]
|
||||
# @BRIEF Tests for settings API routes — get, update, storage, environments, logging, connections, policies, schedules.
|
||||
# @RELATION BINDS_TO -> [SettingsRouter]
|
||||
|
||||
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)
|
||||
|
||||
|
||||
def _make_env(id_val="env-1", name="Test Env", url="https://example.com"):
|
||||
env = MagicMock()
|
||||
env.id = id_val
|
||||
env.name = name
|
||||
env.url = url
|
||||
env.password = "real-password"
|
||||
return env
|
||||
|
||||
|
||||
def _make_client(overrides: dict | None = None) -> TestClient:
|
||||
from src.api.routes.settings import router
|
||||
from src.core.database import get_db
|
||||
from src.dependencies import get_config_manager, get_current_user
|
||||
from src.schemas.auth import User, RoleSchema
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(router, prefix="/api/settings")
|
||||
|
||||
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
|
||||
app.dependency_overrides[get_config_manager] = lambda: MagicMock()
|
||||
if overrides:
|
||||
for dep, fn in overrides.items():
|
||||
app.dependency_overrides[dep] = fn
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
# ── get_settings ──
|
||||
|
||||
class TestGetSettings:
|
||||
"""GET /settings"""
|
||||
|
||||
def test_success(self):
|
||||
mock_config = MagicMock()
|
||||
mock_cfg = MagicMock()
|
||||
mock_cfg.settings.llm = {}
|
||||
mock_cfg.settings.features = MagicMock()
|
||||
mock_cfg.environments = []
|
||||
mock_config.get_config.return_value = mock_cfg
|
||||
|
||||
from src.dependencies import get_config_manager
|
||||
client = _make_client({get_config_manager: lambda: mock_config})
|
||||
resp = client.get("/api/settings")
|
||||
assert resp.status_code == 200
|
||||
|
||||
def test_password_masked(self):
|
||||
mock_config = MagicMock()
|
||||
mock_cfg = MagicMock()
|
||||
mock_cfg.settings.llm = {}
|
||||
mock_cfg.settings.features = MagicMock()
|
||||
env = _make_env()
|
||||
mock_cfg.environments = [env]
|
||||
mock_config.get_config.return_value = mock_cfg
|
||||
|
||||
from src.dependencies import get_config_manager
|
||||
client = _make_client({get_config_manager: lambda: mock_config})
|
||||
resp = client.get("/api/settings")
|
||||
assert resp.status_code == 200
|
||||
assert mock_cfg.environments[0].password == "********"
|
||||
|
||||
|
||||
class TestGetFeatures:
|
||||
"""GET /api/settings/features"""
|
||||
|
||||
def test_success(self):
|
||||
mock_config = MagicMock()
|
||||
mock_cfg = MagicMock()
|
||||
mock_cfg.settings.features.model_dump.return_value = {"translate": True, "migration": False}
|
||||
mock_config.get_config.return_value = mock_cfg
|
||||
|
||||
from src.dependencies import get_config_manager
|
||||
client = _make_client({get_config_manager: lambda: mock_config})
|
||||
resp = client.get("/api/settings/features")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["translate"] is True
|
||||
|
||||
|
||||
# ── get_allowed_languages ──
|
||||
|
||||
class TestGetAllowedLanguages:
|
||||
"""GET /settings/allowed-languages"""
|
||||
|
||||
def test_success(self):
|
||||
mock_config = MagicMock()
|
||||
mock_cfg = MagicMock()
|
||||
mock_cfg.settings.allowed_languages = ["en", "ru"]
|
||||
mock_config.get_config.return_value = mock_cfg
|
||||
|
||||
from src.dependencies import get_config_manager
|
||||
client = _make_client({get_config_manager: lambda: mock_config})
|
||||
resp = client.get("/api/settings/allowed-languages")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json() == ["en", "ru"]
|
||||
|
||||
|
||||
# ── update_global_settings ──
|
||||
|
||||
class TestUpdateGlobalSettings:
|
||||
"""PATCH /settings/global"""
|
||||
|
||||
def test_success(self):
|
||||
mock_config = MagicMock()
|
||||
|
||||
from src.dependencies import get_config_manager
|
||||
client = _make_client({get_config_manager: lambda: mock_config})
|
||||
resp = client.patch("/api/settings/global", json={"app_name": "Test", "log_level": "INFO"})
|
||||
assert resp.status_code == 200
|
||||
|
||||
|
||||
# ── get_storage_settings ──
|
||||
|
||||
class TestGetStorageSettings:
|
||||
"""GET /settings/storage"""
|
||||
|
||||
def test_success(self):
|
||||
mock_config = MagicMock()
|
||||
mock_cfg = MagicMock()
|
||||
mock_cfg.settings.storage = MagicMock()
|
||||
mock_config.get_config.return_value = mock_cfg
|
||||
|
||||
from src.dependencies import get_config_manager
|
||||
client = _make_client({get_config_manager: lambda: mock_config})
|
||||
resp = client.get("/api/settings/storage")
|
||||
assert resp.status_code == 200
|
||||
|
||||
|
||||
# ── update_storage_settings ──
|
||||
|
||||
class TestUpdateStorageSettings:
|
||||
"""PUT /settings/storage"""
|
||||
|
||||
def test_success(self):
|
||||
mock_config = MagicMock()
|
||||
mock_cfg = MagicMock()
|
||||
mock_cfg.settings = MagicMock()
|
||||
mock_cfg.settings.storage = MagicMock()
|
||||
mock_config.get_config.return_value = mock_cfg
|
||||
mock_config.validate_path.return_value = (True, "")
|
||||
|
||||
from src.dependencies import get_config_manager
|
||||
client = _make_client({get_config_manager: lambda: mock_config})
|
||||
resp = client.put("/api/settings/storage", json={
|
||||
"root_path": "/data/storage",
|
||||
"max_file_size_mb": 100,
|
||||
"allowed_extensions": [".txt"],
|
||||
"backup_retention_days": 30,
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
|
||||
def test_invalid_path(self):
|
||||
mock_config = MagicMock()
|
||||
mock_cfg = MagicMock()
|
||||
mock_cfg.settings = MagicMock()
|
||||
mock_config.get_config.return_value = mock_cfg
|
||||
mock_config.validate_path.return_value = (False, "Path is invalid")
|
||||
|
||||
from src.dependencies import get_config_manager
|
||||
client = _make_client({get_config_manager: lambda: mock_config})
|
||||
resp = client.put("/api/settings/storage", json={
|
||||
"root_path": "/invalid",
|
||||
"max_file_size_mb": 100,
|
||||
"allowed_extensions": [],
|
||||
"backup_retention_days": 30,
|
||||
})
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
# ── get_environments ──
|
||||
|
||||
class TestGetEnvironments:
|
||||
"""GET /settings/environments"""
|
||||
|
||||
def test_success(self):
|
||||
mock_config = MagicMock()
|
||||
mock_config.get_environments.return_value = [_make_env()]
|
||||
|
||||
from src.dependencies import get_config_manager
|
||||
client = _make_client({get_config_manager: lambda: mock_config})
|
||||
resp = client.get("/api/settings/environments")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert len(data) == 1
|
||||
assert data[0]["id"] == "env-1"
|
||||
|
||||
|
||||
# ── add_environment ──
|
||||
|
||||
class TestAddEnvironment:
|
||||
"""POST /settings/environments"""
|
||||
|
||||
ENV_PAYLOAD = {
|
||||
"id": "env-new",
|
||||
"name": "New Env",
|
||||
"url": "https://superset.example.com",
|
||||
"password": "secret",
|
||||
}
|
||||
|
||||
def test_success(self):
|
||||
mock_config = MagicMock()
|
||||
|
||||
with patch("src.api.routes.settings.AsyncSupersetClient") as mock_client_cls:
|
||||
mock_client = MagicMock()
|
||||
mock_client.authenticate = AsyncMock()
|
||||
mock_client.get_dashboards_page = AsyncMock(return_value=(0, []))
|
||||
mock_client_cls.return_value = mock_client
|
||||
|
||||
from src.dependencies import get_config_manager
|
||||
client = _make_client({get_config_manager: lambda: mock_config})
|
||||
resp = client.post("/api/settings/environments", json=self.ENV_PAYLOAD)
|
||||
assert resp.status_code == 200
|
||||
mock_config.add_environment.assert_called_once()
|
||||
|
||||
def test_connection_failure(self):
|
||||
mock_config = MagicMock()
|
||||
|
||||
with patch("src.api.routes.settings.AsyncSupersetClient") as mock_client_cls:
|
||||
mock_client = MagicMock()
|
||||
mock_client.authenticate = AsyncMock(side_effect=RuntimeError("Connection refused"))
|
||||
mock_client_cls.return_value = mock_client
|
||||
|
||||
from src.dependencies import get_config_manager
|
||||
client = _make_client({get_config_manager: lambda: mock_config})
|
||||
resp = client.post("/api/settings/environments", json=self.ENV_PAYLOAD)
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
# ── update_environment ──
|
||||
|
||||
class TestUpdateEnvironment:
|
||||
"""PUT /settings/environments/{id}"""
|
||||
|
||||
ENV_PAYLOAD = {
|
||||
"id": "env-1",
|
||||
"name": "Updated Env",
|
||||
"url": "https://superset.example.com",
|
||||
"password": "new-password",
|
||||
}
|
||||
|
||||
def test_success(self):
|
||||
mock_config = MagicMock()
|
||||
mock_config.get_environments.return_value = [_make_env("env-1")]
|
||||
mock_config.update_environment.return_value = True
|
||||
|
||||
with patch("src.api.routes.settings.AsyncSupersetClient") as mock_client_cls:
|
||||
mock_client = MagicMock()
|
||||
mock_client.authenticate = AsyncMock()
|
||||
mock_client.get_dashboards_page = AsyncMock(return_value=(0, []))
|
||||
mock_client_cls.return_value = mock_client
|
||||
|
||||
from src.dependencies import get_config_manager
|
||||
client = _make_client({get_config_manager: lambda: mock_config})
|
||||
resp = client.put("/api/settings/environments/env-1", json=self.ENV_PAYLOAD)
|
||||
assert resp.status_code == 200
|
||||
|
||||
def test_success_masked_password(self):
|
||||
"""When password is '********', use the existing password for validation."""
|
||||
mock_config = MagicMock()
|
||||
old_env = _make_env("env-1")
|
||||
old_env.password = "existing-real-pwd"
|
||||
mock_config.get_environments.return_value = [old_env]
|
||||
mock_config.update_environment.return_value = True
|
||||
|
||||
with patch("src.api.routes.settings.AsyncSupersetClient") as mock_client_cls:
|
||||
mock_client = MagicMock()
|
||||
mock_client.authenticate = AsyncMock()
|
||||
mock_client.get_dashboards_page = AsyncMock(return_value=(0, []))
|
||||
mock_client_cls.return_value = mock_client
|
||||
|
||||
from src.dependencies import get_config_manager
|
||||
client = _make_client({get_config_manager: lambda: mock_config})
|
||||
resp = client.put("/api/settings/environments/env-1", json={
|
||||
"id": "env-1", "name": "Updated", "url": "https://x.com", "password": "********",
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
|
||||
def test_connection_failure(self):
|
||||
mock_config = MagicMock()
|
||||
mock_config.get_environments.return_value = [_make_env("env-1")]
|
||||
|
||||
with patch("src.api.routes.settings.AsyncSupersetClient") as mock_client_cls:
|
||||
mock_client = MagicMock()
|
||||
mock_client.authenticate = AsyncMock(side_effect=RuntimeError("fail"))
|
||||
mock_client_cls.return_value = mock_client
|
||||
|
||||
from src.dependencies import get_config_manager
|
||||
client = _make_client({get_config_manager: lambda: mock_config})
|
||||
resp = client.put("/api/settings/environments/env-1", json=self.ENV_PAYLOAD)
|
||||
assert resp.status_code == 400
|
||||
|
||||
def test_not_found(self):
|
||||
mock_config = MagicMock()
|
||||
mock_config.get_environments.return_value = [_make_env("env-1")]
|
||||
|
||||
with patch("src.api.routes.settings.AsyncSupersetClient") as mock_client_cls:
|
||||
mock_client = MagicMock()
|
||||
mock_client.authenticate = AsyncMock()
|
||||
mock_client.get_dashboards_page = AsyncMock(return_value=(0, []))
|
||||
mock_client_cls.return_value = mock_client
|
||||
|
||||
from src.dependencies import get_config_manager
|
||||
client = _make_client({get_config_manager: lambda: mock_config})
|
||||
resp = client.put("/api/settings/environments/env-999", json=self.ENV_PAYLOAD)
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
# ── delete_environment ──
|
||||
|
||||
class TestDeleteEnvironment:
|
||||
"""DELETE /settings/environments/{id}"""
|
||||
|
||||
def test_success(self):
|
||||
mock_config = MagicMock()
|
||||
|
||||
from src.dependencies import get_config_manager
|
||||
client = _make_client({get_config_manager: lambda: mock_config})
|
||||
resp = client.delete("/api/settings/environments/env-1")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert "deleted" in data["message"]
|
||||
mock_config.delete_environment.assert_called_once_with("env-1")
|
||||
|
||||
|
||||
# ── test_environment_connection ──
|
||||
|
||||
class TestTestEnvironmentConnection:
|
||||
"""POST /settings/environments/{id}/test"""
|
||||
|
||||
def test_success(self):
|
||||
mock_config = MagicMock()
|
||||
mock_config.get_environments.return_value = [_make_env("env-1")]
|
||||
|
||||
with patch("src.api.routes.settings.AsyncSupersetClient") as mock_client_cls:
|
||||
mock_client = MagicMock()
|
||||
mock_client.authenticate = AsyncMock()
|
||||
mock_client.get_dashboards_page = AsyncMock(return_value=(0, []))
|
||||
mock_client_cls.return_value = mock_client
|
||||
|
||||
from src.dependencies import get_config_manager
|
||||
client = _make_client({get_config_manager: lambda: mock_config})
|
||||
resp = client.post("/api/settings/environments/env-1/test")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["status"] == "success"
|
||||
|
||||
def test_not_found(self):
|
||||
mock_config = MagicMock()
|
||||
mock_config.get_environments.return_value = []
|
||||
|
||||
from src.dependencies import get_config_manager
|
||||
client = _make_client({get_config_manager: lambda: mock_config})
|
||||
resp = client.post("/api/settings/environments/env-unknown/test")
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_connection_error(self):
|
||||
mock_config = MagicMock()
|
||||
mock_config.get_environments.return_value = [_make_env("env-1")]
|
||||
|
||||
with patch("src.api.routes.settings.AsyncSupersetClient") as mock_client_cls:
|
||||
mock_client = MagicMock()
|
||||
mock_client.authenticate = AsyncMock(side_effect=RuntimeError("Connection failed"))
|
||||
mock_client_cls.return_value = mock_client
|
||||
|
||||
from src.dependencies import get_config_manager
|
||||
client = _make_client({get_config_manager: lambda: mock_config})
|
||||
resp = client.post("/api/settings/environments/env-1/test")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["status"] == "error"
|
||||
|
||||
|
||||
# ── get_logging_config ──
|
||||
|
||||
class TestGetLoggingConfig:
|
||||
"""GET /settings/logging"""
|
||||
|
||||
def test_success(self):
|
||||
mock_config = MagicMock()
|
||||
mock_cfg = MagicMock()
|
||||
mock_logging = MagicMock()
|
||||
mock_logging.level = "INFO"
|
||||
mock_logging.task_log_level = "DEBUG"
|
||||
mock_logging.enable_belief_state = True
|
||||
mock_cfg.settings.logging = mock_logging
|
||||
mock_config.get_config.return_value = mock_cfg
|
||||
|
||||
from src.dependencies import get_config_manager
|
||||
client = _make_client({get_config_manager: lambda: mock_config})
|
||||
resp = client.get("/api/settings/logging")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["level"] == "INFO"
|
||||
assert data["task_log_level"] == "DEBUG"
|
||||
assert data["enable_belief_state"] is True
|
||||
|
||||
|
||||
# ── update_logging_config ──
|
||||
|
||||
class TestUpdateLoggingConfig:
|
||||
"""PATCH /settings/logging"""
|
||||
|
||||
def test_success(self):
|
||||
mock_config = MagicMock()
|
||||
mock_cfg = MagicMock()
|
||||
mock_cfg.settings = MagicMock()
|
||||
mock_config.get_config.return_value = mock_cfg
|
||||
|
||||
from src.dependencies import get_config_manager
|
||||
client = _make_client({get_config_manager: lambda: mock_config})
|
||||
resp = client.patch("/api/settings/logging", json={
|
||||
"level": "WARNING", "task_log_level": "INFO", "enable_belief_state": False,
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["level"] == "WARNING"
|
||||
assert data["task_log_level"] == "INFO"
|
||||
|
||||
|
||||
# ── get_validation_policies ──
|
||||
|
||||
class TestGetValidationPolicies:
|
||||
"""GET /settings/automation/policies"""
|
||||
|
||||
def test_success(self):
|
||||
mock_db = MagicMock()
|
||||
mock_db.query.return_value.all.return_value = [MagicMock(id="p1")]
|
||||
|
||||
from src.core.database import get_db
|
||||
client = _make_client({get_db: lambda: mock_db})
|
||||
resp = client.get("/api/settings/automation/policies")
|
||||
assert resp.status_code == 200
|
||||
|
||||
|
||||
# ── create_validation_policy ──
|
||||
|
||||
class TestCreateValidationPolicy:
|
||||
"""POST /settings/automation/policies"""
|
||||
|
||||
def test_success(self):
|
||||
mock_db = MagicMock()
|
||||
|
||||
from src.core.database import get_db
|
||||
client = _make_client({get_db: lambda: mock_db})
|
||||
resp = client.post("/api/settings/automation/policies", json={
|
||||
"name": "Test Policy", "environment_id": "env-1", "dashboard_ids": [],
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
mock_db.add.assert_called_once()
|
||||
mock_db.commit.assert_called_once()
|
||||
|
||||
|
||||
# ── update_validation_policy ──
|
||||
|
||||
class TestUpdateValidationPolicy:
|
||||
"""PATCH /settings/automation/policies/{id}"""
|
||||
|
||||
def test_success(self):
|
||||
mock_db = MagicMock()
|
||||
mock_policy = MagicMock()
|
||||
mock_db.query.return_value.filter.return_value.first.return_value = mock_policy
|
||||
|
||||
from src.core.database import get_db
|
||||
client = _make_client({get_db: lambda: mock_db})
|
||||
resp = client.patch("/api/settings/automation/policies/p1", json={"name": "Updated"})
|
||||
assert resp.status_code == 200
|
||||
mock_db.commit.assert_called_once()
|
||||
|
||||
def test_not_found(self):
|
||||
mock_db = MagicMock()
|
||||
mock_db.query.return_value.filter.return_value.first.return_value = None
|
||||
|
||||
from src.core.database import get_db
|
||||
client = _make_client({get_db: lambda: mock_db})
|
||||
resp = client.patch("/api/settings/automation/policies/unknown", json={"name": "X"})
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
# ── delete_validation_policy ──
|
||||
|
||||
class TestDeleteValidationPolicy:
|
||||
"""DELETE /settings/automation/policies/{id}"""
|
||||
|
||||
def test_success(self):
|
||||
mock_db = MagicMock()
|
||||
mock_policy = MagicMock()
|
||||
mock_db.query.return_value.filter.return_value.first.return_value = mock_policy
|
||||
|
||||
from src.core.database import get_db
|
||||
client = _make_client({get_db: lambda: mock_db})
|
||||
resp = client.delete("/api/settings/automation/policies/p1")
|
||||
assert resp.status_code == 200
|
||||
mock_db.delete.assert_called_once()
|
||||
mock_db.commit.assert_called_once()
|
||||
|
||||
def test_not_found(self):
|
||||
mock_db = MagicMock()
|
||||
mock_db.query.return_value.filter.return_value.first.return_value = None
|
||||
|
||||
from src.core.database import get_db
|
||||
client = _make_client({get_db: lambda: mock_db})
|
||||
resp = client.delete("/api/settings/automation/policies/unknown")
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
# ── get_translation_schedules ──
|
||||
|
||||
class TestGetTranslationSchedules:
|
||||
"""GET /settings/automation/translation-schedules"""
|
||||
|
||||
def test_success(self):
|
||||
mock_db = MagicMock()
|
||||
mock_ts = MagicMock()
|
||||
mock_ts.id = "sched-1"
|
||||
mock_ts.job_id = "job-1"
|
||||
mock_ts.cron_expression = "0 * * * *"
|
||||
mock_ts.timezone = "UTC"
|
||||
mock_ts.is_active = True
|
||||
mock_ts.last_run_at = None
|
||||
mock_ts.next_run_at = None
|
||||
mock_ts.created_by = "admin"
|
||||
mock_ts.created_at = None
|
||||
mock_db.query.return_value.outerjoin.return_value.all.return_value = [(mock_ts, "Test Job")]
|
||||
|
||||
from src.core.database import get_db
|
||||
client = _make_client({get_db: lambda: mock_db})
|
||||
resp = client.get("/api/settings/automation/translation-schedules")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert len(data) == 1
|
||||
assert data[0]["schedule_id"] == "sched-1"
|
||||
assert data[0]["job_name"] == "Test Job"
|
||||
|
||||
|
||||
# ── list_connections ──
|
||||
|
||||
class TestListConnections:
|
||||
"""GET /settings/connections"""
|
||||
|
||||
def test_success(self):
|
||||
mock_conn_svc = MagicMock()
|
||||
mock_conn_svc.list_connections.return_value = [{"id": "conn-1", "name": "Test"}]
|
||||
|
||||
from src.dependencies import get_config_manager
|
||||
with patch("src.api.routes.settings._get_connection_service", return_value=mock_conn_svc):
|
||||
client = _make_client({get_config_manager: lambda: MagicMock()})
|
||||
resp = client.get("/api/settings/connections")
|
||||
assert resp.status_code == 200
|
||||
|
||||
|
||||
# ── get_connection ──
|
||||
|
||||
class TestGetConnection:
|
||||
"""GET /settings/connections/{connection_id}"""
|
||||
|
||||
def test_success(self):
|
||||
mock_conn_svc = MagicMock()
|
||||
mock_conn = MagicMock()
|
||||
mock_conn.model_dump.return_value = {"id": "conn-1", "name": "Test", "password": "real-pwd"}
|
||||
mock_conn_svc.get_connection.return_value = mock_conn
|
||||
mock_conn_svc._count_job_references.return_value = 2
|
||||
|
||||
from src.dependencies import get_config_manager
|
||||
with patch("src.api.routes.settings._get_connection_service", return_value=mock_conn_svc):
|
||||
client = _make_client({get_config_manager: lambda: MagicMock()})
|
||||
resp = client.get("/api/settings/connections/conn-1")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["password"] == "********"
|
||||
assert data["used_by"] == 2
|
||||
|
||||
def test_not_found(self):
|
||||
mock_conn_svc = MagicMock()
|
||||
mock_conn_svc.get_connection.return_value = None
|
||||
|
||||
from src.dependencies import get_config_manager
|
||||
with patch("src.api.routes.settings._get_connection_service", return_value=mock_conn_svc):
|
||||
client = _make_client({get_config_manager: lambda: MagicMock()})
|
||||
resp = client.get("/api/settings/connections/unknown")
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
# ── create_connection ──
|
||||
|
||||
class TestCreateConnection:
|
||||
"""POST /settings/connections"""
|
||||
|
||||
def test_success(self):
|
||||
mock_conn_svc = MagicMock()
|
||||
mock_conn_svc.create_connection.return_value = {"id": "conn-new", "name": "New"}
|
||||
|
||||
from src.dependencies import get_config_manager
|
||||
with patch("src.api.routes.settings._get_connection_service", return_value=mock_conn_svc):
|
||||
client = _make_client({get_config_manager: lambda: MagicMock()})
|
||||
resp = client.post("/api/settings/connections", json={
|
||||
"name": "New", "host": "localhost", "port": 5432,
|
||||
"database": "db", "username": "u", "password": "p", "dialect": "postgresql",
|
||||
})
|
||||
assert resp.status_code == 201
|
||||
|
||||
def test_value_error(self):
|
||||
mock_conn_svc = MagicMock()
|
||||
mock_conn_svc.create_connection.side_effect = ValueError("Invalid")
|
||||
|
||||
from src.dependencies import get_config_manager
|
||||
with patch("src.api.routes.settings._get_connection_service", return_value=mock_conn_svc):
|
||||
client = _make_client({get_config_manager: lambda: MagicMock()})
|
||||
resp = client.post("/api/settings/connections", json={"name": "Bad"})
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
# ── update_connection ──
|
||||
|
||||
class TestUpdateConnection:
|
||||
"""PUT /settings/connections/{connection_id}"""
|
||||
|
||||
def test_success(self):
|
||||
mock_conn_svc = MagicMock()
|
||||
mock_conn_svc.update_connection.return_value = {"id": "conn-1", "name": "Updated"}
|
||||
|
||||
from src.dependencies import get_config_manager
|
||||
with patch("src.api.routes.settings._get_connection_service", return_value=mock_conn_svc):
|
||||
client = _make_client({get_config_manager: lambda: MagicMock()})
|
||||
resp = client.put("/api/settings/connections/conn-1", json={"name": "Updated"})
|
||||
assert resp.status_code == 200
|
||||
|
||||
def test_value_error(self):
|
||||
mock_conn_svc = MagicMock()
|
||||
mock_conn_svc.update_connection.side_effect = ValueError("Invalid")
|
||||
|
||||
from src.dependencies import get_config_manager
|
||||
with patch("src.api.routes.settings._get_connection_service", return_value=mock_conn_svc):
|
||||
client = _make_client({get_config_manager: lambda: MagicMock()})
|
||||
resp = client.put("/api/settings/connections/conn-1", json={"name": ""})
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
# ── delete_connection ──
|
||||
|
||||
class TestDeleteConnection:
|
||||
"""DELETE /settings/connections/{connection_id}"""
|
||||
|
||||
def test_success(self):
|
||||
mock_conn_svc = MagicMock()
|
||||
mock_conn_svc.delete_connection.return_value = {"success": True}
|
||||
|
||||
from src.dependencies import get_config_manager
|
||||
with patch("src.api.routes.settings._get_connection_service", return_value=mock_conn_svc):
|
||||
client = _make_client({get_config_manager: lambda: MagicMock()})
|
||||
resp = client.delete("/api/settings/connections/conn-1")
|
||||
assert resp.status_code == 200
|
||||
|
||||
def test_in_use(self):
|
||||
mock_conn_svc = MagicMock()
|
||||
mock_conn_svc.delete_connection.return_value = {
|
||||
"success": False, "blocking_jobs": ["job-1"],
|
||||
}
|
||||
|
||||
from src.dependencies import get_config_manager
|
||||
with patch("src.api.routes.settings._get_connection_service", return_value=mock_conn_svc):
|
||||
client = _make_client({get_config_manager: lambda: MagicMock()})
|
||||
resp = client.delete("/api/settings/connections/conn-1")
|
||||
assert resp.status_code == 409
|
||||
|
||||
|
||||
# ── test_connection_endpoint ──
|
||||
|
||||
class TestTestConnectionEndpoint:
|
||||
"""POST /settings/connections/{connection_id}/test"""
|
||||
|
||||
def test_success(self):
|
||||
mock_conn_svc = MagicMock()
|
||||
mock_conn_svc.test_connection = AsyncMock(return_value={"success": True})
|
||||
|
||||
from src.dependencies import get_config_manager
|
||||
with patch("src.api.routes.settings._get_connection_service", return_value=mock_conn_svc):
|
||||
client = _make_client({get_config_manager: lambda: MagicMock()})
|
||||
resp = client.post("/api/settings/connections/conn-1/test")
|
||||
assert resp.status_code == 200
|
||||
# #endregion Test.Api.Settings
|
||||
197
backend/tests/api/test_storage.py
Normal file
197
backend/tests/api/test_storage.py
Normal file
@@ -0,0 +1,197 @@
|
||||
# #region Test.Api.Storage [C:3] [TYPE Module] [SEMANTICS test,storage,routes]
|
||||
# @BRIEF Tests for storage API routes — list, upload, delete, download, get-by-path.
|
||||
# @RELATION BINDS_TO -> [storage_routes]
|
||||
|
||||
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/storage"
|
||||
|
||||
|
||||
def _make_client(overrides: dict | None = None) -> tuple[TestClient, MagicMock]:
|
||||
from src.api.routes.storage import router
|
||||
from src.dependencies import get_plugin_loader, get_current_user
|
||||
from src.schemas.auth import User, RoleSchema
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(router, prefix="/api/storage")
|
||||
|
||||
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=[])],
|
||||
)
|
||||
|
||||
mock_plugin_loader = MagicMock()
|
||||
|
||||
app.dependency_overrides[get_current_user] = lambda: mock_user
|
||||
app.dependency_overrides[get_plugin_loader] = lambda: mock_plugin_loader
|
||||
if overrides:
|
||||
for dep, fn in overrides.items():
|
||||
app.dependency_overrides[dep] = fn
|
||||
return TestClient(app), mock_plugin_loader
|
||||
|
||||
|
||||
class TestListFiles:
|
||||
"""GET /api/storage/files"""
|
||||
|
||||
def test_success(self):
|
||||
client, mock_loader = _make_client()
|
||||
mock_plugin = MagicMock()
|
||||
mock_plugin.list_files.return_value = [{"name": "file1.txt"}]
|
||||
mock_loader.get_plugin.return_value = mock_plugin
|
||||
resp = client.get(f"{P}/files")
|
||||
assert resp.status_code == 200
|
||||
|
||||
def test_with_filters(self):
|
||||
client, mock_loader = _make_client()
|
||||
mock_plugin = MagicMock()
|
||||
mock_plugin.list_files.return_value = []
|
||||
mock_loader.get_plugin.return_value = mock_plugin
|
||||
resp = client.get(f"{P}/files?category=BACKUPS&path=/test&recursive=true")
|
||||
assert resp.status_code == 200
|
||||
mock_plugin.list_files.assert_called_once_with("BACKUPS", "/test", True)
|
||||
|
||||
def test_plugin_not_loaded(self):
|
||||
client, mock_loader = _make_client()
|
||||
mock_loader.get_plugin.return_value = None
|
||||
resp = client.get(f"{P}/files")
|
||||
assert resp.status_code == 500
|
||||
|
||||
|
||||
class TestUploadFile:
|
||||
"""POST /api/storage/upload"""
|
||||
|
||||
def test_success(self):
|
||||
client, mock_loader = _make_client()
|
||||
mock_plugin = MagicMock()
|
||||
mock_plugin.save_file = AsyncMock(return_value={"name": "test.txt"})
|
||||
mock_loader.get_plugin.return_value = mock_plugin
|
||||
resp = client.post(f"{P}/upload", data={"category": "BACKUPS"}, files={"file": ("test.txt", b"hello")})
|
||||
assert resp.status_code == 201
|
||||
|
||||
def test_value_error(self):
|
||||
client, mock_loader = _make_client()
|
||||
mock_plugin = MagicMock()
|
||||
mock_plugin.save_file = AsyncMock(side_effect=ValueError("bad"))
|
||||
mock_loader.get_plugin.return_value = mock_plugin
|
||||
resp = client.post(f"{P}/upload", data={"category": "BACKUPS"}, files={"file": ("test.txt", b"data")})
|
||||
assert resp.status_code == 400
|
||||
|
||||
def test_plugin_not_loaded(self):
|
||||
client, mock_loader = _make_client()
|
||||
mock_loader.get_plugin.return_value = None
|
||||
resp = client.post(f"{P}/upload", data={"category": "BACKUPS"}, files={"file": ("test.txt", b"data")})
|
||||
assert resp.status_code == 500
|
||||
|
||||
|
||||
class TestDeleteFile:
|
||||
"""DELETE /api/storage/files/{category}/{path}"""
|
||||
|
||||
def test_success(self):
|
||||
client, mock_loader = _make_client()
|
||||
mock_plugin = MagicMock()
|
||||
mock_loader.get_plugin.return_value = mock_plugin
|
||||
resp = client.delete(f"{P}/files/BACKUPS/test.txt")
|
||||
assert resp.status_code == 204
|
||||
|
||||
def test_not_found(self):
|
||||
client, mock_loader = _make_client()
|
||||
mock_plugin = MagicMock()
|
||||
mock_plugin.delete_file.side_effect = FileNotFoundError
|
||||
mock_loader.get_plugin.return_value = mock_plugin
|
||||
resp = client.delete(f"{P}/files/BACKUPS/missing.txt")
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_value_error(self):
|
||||
client, mock_loader = _make_client()
|
||||
mock_plugin = MagicMock()
|
||||
mock_plugin.delete_file.side_effect = ValueError("bad")
|
||||
mock_loader.get_plugin.return_value = mock_plugin
|
||||
resp = client.delete(f"{P}/files/BACKUPS/bad.txt")
|
||||
assert resp.status_code == 400
|
||||
|
||||
def test_plugin_not_loaded(self):
|
||||
client, mock_loader = _make_client()
|
||||
mock_loader.get_plugin.return_value = None
|
||||
resp = client.delete(f"{P}/files/BACKUPS/test.txt")
|
||||
assert resp.status_code == 500
|
||||
|
||||
|
||||
class TestDownloadFile:
|
||||
"""GET /api/storage/download/{category}/{path}"""
|
||||
|
||||
def test_not_found(self):
|
||||
client, mock_loader = _make_client()
|
||||
mock_plugin = MagicMock()
|
||||
mock_plugin.get_file_path.side_effect = FileNotFoundError
|
||||
mock_loader.get_plugin.return_value = mock_plugin
|
||||
resp = client.get(f"{P}/download/BACKUPS/missing.txt")
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_value_error(self):
|
||||
client, mock_loader = _make_client()
|
||||
mock_plugin = MagicMock()
|
||||
mock_plugin.get_file_path.side_effect = ValueError("bad")
|
||||
mock_loader.get_plugin.return_value = mock_plugin
|
||||
resp = client.get(f"{P}/download/BACKUPS/bad.txt")
|
||||
assert resp.status_code == 400
|
||||
|
||||
def test_plugin_not_loaded(self):
|
||||
client, mock_loader = _make_client()
|
||||
mock_loader.get_plugin.return_value = None
|
||||
resp = client.get(f"{P}/download/BACKUPS/test.txt")
|
||||
assert resp.status_code == 500
|
||||
|
||||
|
||||
class TestGetFileByPath:
|
||||
"""GET /api/storage/file"""
|
||||
|
||||
def test_missing_path(self):
|
||||
client, mock_loader = _make_client()
|
||||
mock_plugin = MagicMock()
|
||||
mock_loader.get_plugin.return_value = mock_plugin
|
||||
resp = client.get(f"{P}/file?path=")
|
||||
assert resp.status_code == 400
|
||||
|
||||
def test_file_not_found(self):
|
||||
client, mock_loader = _make_client()
|
||||
mock_plugin = MagicMock()
|
||||
mock_plugin.validate_path.return_value = Path("/missing.txt")
|
||||
mock_loader.get_plugin.return_value = mock_plugin
|
||||
with patch("pathlib.Path.exists", return_value=False):
|
||||
resp = client.get(f"{P}/file?path=missing.txt")
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_value_error_from_validate(self):
|
||||
client, mock_loader = _make_client()
|
||||
mock_plugin = MagicMock()
|
||||
mock_plugin.validate_path.side_effect = ValueError("bad")
|
||||
mock_loader.get_plugin.return_value = mock_plugin
|
||||
resp = client.get(f"{P}/file?path=../../../etc/passwd")
|
||||
assert resp.status_code == 400
|
||||
|
||||
def test_plugin_not_loaded(self):
|
||||
client, mock_loader = _make_client()
|
||||
mock_loader.get_plugin.return_value = None
|
||||
resp = client.get(f"{P}/file?path=test.txt")
|
||||
assert resp.status_code == 500
|
||||
# #endregion Test.Api.Storage
|
||||
331
backend/tests/api/test_tasks.py
Normal file
331
backend/tests/api/test_tasks.py
Normal file
@@ -0,0 +1,331 @@
|
||||
# ... (same header as before)
|
||||
|
||||
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)
|
||||
|
||||
|
||||
def _make_client(overrides: dict | None = None) -> TestClient:
|
||||
from src.api.routes.tasks import router
|
||||
from src.core.database import get_db
|
||||
from src.dependencies import get_config_manager, get_current_user, get_task_manager
|
||||
from src.schemas.auth import User, RoleSchema
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(router, prefix="/api/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_db] = lambda: MagicMock()
|
||||
app.dependency_overrides[get_current_user] = lambda: mock_user
|
||||
app.dependency_overrides[get_config_manager] = 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)
|
||||
|
||||
|
||||
# ── create_task ──
|
||||
|
||||
class TestCreateTask:
|
||||
"""POST /api/tasks"""
|
||||
|
||||
def test_success(self):
|
||||
mock_tm = MagicMock()
|
||||
mock_tm.create_task = AsyncMock(return_value=MagicMock(id="task-1", plugin_id="backup", status="PENDING", params={}, created_at=None))
|
||||
|
||||
from src.dependencies import get_task_manager
|
||||
client = _make_client({get_task_manager: lambda: mock_tm})
|
||||
resp = client.post("/api/tasks", json={"plugin_id": "superset-backup", "params": {"dashboard_id": 42}})
|
||||
assert resp.status_code == 201
|
||||
|
||||
def test_deprecated_llm_validation_returns_400(self):
|
||||
client = _make_client()
|
||||
resp = client.post("/api/tasks", json={
|
||||
"plugin_id": "llm_dashboard_validation",
|
||||
"params": {"dashboard_ids": ["dash-1"], "environment_id": "env-1"},
|
||||
})
|
||||
assert resp.status_code == 400
|
||||
|
||||
def test_value_error(self):
|
||||
mock_tm = MagicMock()
|
||||
mock_tm.create_task = AsyncMock(side_effect=ValueError("Plugin not found"))
|
||||
|
||||
from src.dependencies import get_task_manager
|
||||
client = _make_client({get_task_manager: lambda: mock_tm})
|
||||
resp = client.post("/api/tasks", json={"plugin_id": "unknown", "params": {}})
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
# ── list_tasks ──
|
||||
|
||||
class TestListTasks:
|
||||
"""GET /api/tasks"""
|
||||
|
||||
def test_success(self):
|
||||
mock_tm = MagicMock()
|
||||
mock_tm.get_tasks.return_value = []
|
||||
|
||||
from src.dependencies import get_task_manager
|
||||
client = _make_client({get_task_manager: lambda: mock_tm})
|
||||
resp = client.get("/api/tasks")
|
||||
assert resp.status_code == 200
|
||||
|
||||
def test_with_filters(self):
|
||||
mock_tm = MagicMock()
|
||||
mock_tm.get_tasks.return_value = []
|
||||
|
||||
from src.dependencies import get_task_manager
|
||||
client = _make_client({get_task_manager: lambda: mock_tm})
|
||||
resp = client.get("/api/tasks?limit=5&offset=10&status=RUNNING&completed_only=true")
|
||||
assert resp.status_code == 200
|
||||
|
||||
def test_with_plugin_ids(self):
|
||||
mock_tm = MagicMock()
|
||||
mock_tm.get_tasks.return_value = []
|
||||
|
||||
from src.dependencies import get_task_manager
|
||||
client = _make_client({get_task_manager: lambda: mock_tm})
|
||||
resp = client.get("/api/tasks?plugin_id=backup&plugin_id=migration")
|
||||
assert resp.status_code == 200
|
||||
|
||||
def test_with_task_type(self):
|
||||
mock_tm = MagicMock()
|
||||
mock_tm.get_tasks.return_value = []
|
||||
|
||||
from src.dependencies import get_task_manager
|
||||
client = _make_client({get_task_manager: lambda: mock_tm})
|
||||
resp = client.get("/api/tasks?task_type=llm_validation")
|
||||
assert resp.status_code == 200
|
||||
|
||||
def test_unsupported_task_type(self):
|
||||
client = _make_client()
|
||||
resp = client.get("/api/tasks?task_type=unsupported")
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
# ── get_task ──
|
||||
|
||||
class TestGetTask:
|
||||
"""GET /api/tasks/{task_id}"""
|
||||
|
||||
def test_success(self):
|
||||
mock_tm = MagicMock()
|
||||
mock_tm.get_task.return_value = MagicMock(id="task-123", plugin_id="backup", status="PENDING", params={}, created_at=None)
|
||||
|
||||
from src.dependencies import get_task_manager
|
||||
client = _make_client({get_task_manager: lambda: mock_tm})
|
||||
resp = client.get("/api/tasks/task-123")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["id"] == "task-123"
|
||||
|
||||
def test_not_found(self):
|
||||
mock_tm = MagicMock()
|
||||
mock_tm.get_task.return_value = None
|
||||
|
||||
from src.dependencies import get_task_manager
|
||||
client = _make_client({get_task_manager: lambda: mock_tm})
|
||||
resp = client.get("/api/tasks/unknown")
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
# ── get_task_logs ──
|
||||
|
||||
class TestGetTaskLogs:
|
||||
"""GET /api/tasks/{task_id}/logs"""
|
||||
|
||||
def test_success(self):
|
||||
mock_tm = MagicMock()
|
||||
mock_tm.get_task.return_value = MagicMock(id="task-123")
|
||||
mock_tm.get_task_logs.return_value = [{"level": "INFO", "message": "test"}]
|
||||
|
||||
from src.dependencies import get_task_manager
|
||||
client = _make_client({get_task_manager: lambda: mock_tm})
|
||||
resp = client.get("/api/tasks/task-123/logs")
|
||||
assert resp.status_code == 200
|
||||
|
||||
def test_with_filters(self):
|
||||
mock_tm = MagicMock()
|
||||
mock_tm.get_task.return_value = MagicMock(id="task-123")
|
||||
|
||||
from src.dependencies import get_task_manager
|
||||
client = _make_client({get_task_manager: lambda: mock_tm})
|
||||
resp = client.get("/api/tasks/task-123/logs?level=INFO&source=worker&search=error&offset=0&limit=50")
|
||||
assert resp.status_code == 200
|
||||
|
||||
def test_task_not_found(self):
|
||||
mock_tm = MagicMock()
|
||||
mock_tm.get_task.return_value = None
|
||||
|
||||
from src.dependencies import get_task_manager
|
||||
client = _make_client({get_task_manager: lambda: mock_tm})
|
||||
resp = client.get("/api/tasks/unknown/logs")
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
# ── get_task_log_stats ──
|
||||
|
||||
class TestGetTaskLogStats:
|
||||
"""GET /api/tasks/{task_id}/logs/stats"""
|
||||
|
||||
def test_success_with_logstats(self):
|
||||
from src.core.task_manager.models import LogStats
|
||||
mock_tm = MagicMock()
|
||||
mock_tm.get_task.return_value = MagicMock(id="task-123")
|
||||
mock_tm.get_task_log_stats.return_value = LogStats(total_count=5, by_level={"INFO": 3}, by_source={"worker": 5})
|
||||
|
||||
from src.dependencies import get_task_manager
|
||||
client = _make_client({get_task_manager: lambda: mock_tm})
|
||||
resp = client.get("/api/tasks/task-123/logs/stats")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["total_count"] == 5
|
||||
|
||||
def test_success_with_dict_payload(self):
|
||||
mock_tm = MagicMock()
|
||||
mock_tm.get_task.return_value = MagicMock(id="task-123")
|
||||
mock_tm.get_task_log_stats.return_value = {"total_count": 3, "by_level": {"INFO": 2}, "by_source": {"worker": 3}}
|
||||
|
||||
from src.dependencies import get_task_manager
|
||||
client = _make_client({get_task_manager: lambda: mock_tm})
|
||||
resp = client.get("/api/tasks/task-123/logs/stats")
|
||||
assert resp.status_code == 200
|
||||
|
||||
def test_success_with_flat_dict(self):
|
||||
mock_tm = MagicMock()
|
||||
mock_tm.get_task.return_value = MagicMock(id="task-123")
|
||||
mock_tm.get_task_log_stats.return_value = {"INFO": 3, "ERROR": 1}
|
||||
|
||||
from src.dependencies import get_task_manager
|
||||
client = _make_client({get_task_manager: lambda: mock_tm})
|
||||
resp = client.get("/api/tasks/task-123/logs/stats")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["total_count"] == 4
|
||||
|
||||
def test_task_not_found(self):
|
||||
mock_tm = MagicMock()
|
||||
mock_tm.get_task.return_value = None
|
||||
|
||||
from src.dependencies import get_task_manager
|
||||
client = _make_client({get_task_manager: lambda: mock_tm})
|
||||
resp = client.get("/api/tasks/unknown/logs/stats")
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
# ── get_task_log_sources ──
|
||||
|
||||
class TestGetTaskLogSources:
|
||||
"""GET /api/tasks/{task_id}/logs/sources"""
|
||||
|
||||
def test_success(self):
|
||||
mock_tm = MagicMock()
|
||||
mock_tm.get_task.return_value = MagicMock(id="task-123")
|
||||
mock_tm.get_task_log_sources.return_value = ["worker", "api"]
|
||||
|
||||
from src.dependencies import get_task_manager
|
||||
client = _make_client({get_task_manager: lambda: mock_tm})
|
||||
resp = client.get("/api/tasks/task-123/logs/sources")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json() == ["worker", "api"]
|
||||
|
||||
def test_task_not_found(self):
|
||||
mock_tm = MagicMock()
|
||||
mock_tm.get_task.return_value = None
|
||||
|
||||
from src.dependencies import get_task_manager
|
||||
client = _make_client({get_task_manager: lambda: mock_tm})
|
||||
resp = client.get("/api/tasks/unknown/logs/sources")
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
# ── resolve_task ──
|
||||
|
||||
class TestResolveTask:
|
||||
"""POST /api/tasks/{task_id}/resolve"""
|
||||
|
||||
def test_success(self):
|
||||
mock_tm = MagicMock()
|
||||
mock_tm.resolve_task = AsyncMock()
|
||||
mock_tm.get_task.return_value = MagicMock(id="task-123")
|
||||
|
||||
from src.dependencies import get_task_manager
|
||||
client = _make_client({get_task_manager: lambda: mock_tm})
|
||||
resp = client.post("/api/tasks/task-123/resolve", json={"resolution_params": {"key": "value"}})
|
||||
assert resp.status_code == 200
|
||||
|
||||
def test_value_error(self):
|
||||
mock_tm = MagicMock()
|
||||
mock_tm.resolve_task = AsyncMock(side_effect=ValueError("Cannot resolve"))
|
||||
|
||||
from src.dependencies import get_task_manager
|
||||
client = _make_client({get_task_manager: lambda: mock_tm})
|
||||
resp = client.post("/api/tasks/task-123/resolve", json={"resolution_params": {}})
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
# ── resume_task ──
|
||||
|
||||
class TestResumeTask:
|
||||
"""POST /api/tasks/{task_id}/resume"""
|
||||
|
||||
def test_success(self):
|
||||
mock_tm = MagicMock()
|
||||
mock_tm.resume_task_with_password = MagicMock()
|
||||
mock_tm.get_task.return_value = MagicMock(id="task-123")
|
||||
|
||||
from src.dependencies import get_task_manager
|
||||
client = _make_client({get_task_manager: lambda: mock_tm})
|
||||
resp = client.post("/api/tasks/task-123/resume", json={"passwords": {"db": "pwd"}})
|
||||
assert resp.status_code == 200
|
||||
|
||||
def test_value_error(self):
|
||||
mock_tm = MagicMock()
|
||||
mock_tm.resume_task_with_password = MagicMock(side_effect=ValueError("Cannot resume"))
|
||||
|
||||
from src.dependencies import get_task_manager
|
||||
client = _make_client({get_task_manager: lambda: mock_tm})
|
||||
resp = client.post("/api/tasks/task-123/resume", json={"passwords": {}})
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
# ── clear_tasks ──
|
||||
|
||||
class TestClearTasks:
|
||||
"""DELETE /api/tasks"""
|
||||
|
||||
def test_success(self):
|
||||
mock_tm = MagicMock()
|
||||
|
||||
from src.dependencies import get_task_manager
|
||||
client = _make_client({get_task_manager: lambda: mock_tm})
|
||||
resp = client.delete("/api/tasks")
|
||||
assert resp.status_code == 204
|
||||
|
||||
def test_with_status(self):
|
||||
mock_tm = MagicMock()
|
||||
|
||||
from src.dependencies import get_task_manager
|
||||
client = _make_client({get_task_manager: lambda: mock_tm})
|
||||
resp = client.delete("/api/tasks?status=COMPLETED")
|
||||
assert resp.status_code == 204
|
||||
# #endregion Test.Api.Tasks
|
||||
535
backend/tests/api/test_translate_dictionary_routes.py
Normal file
535
backend/tests/api/test_translate_dictionary_routes.py
Normal file
@@ -0,0 +1,535 @@
|
||||
# #region Test.Api.TranslateDictionaryRoutes [C:3] [TYPE Module] [SEMANTICS test,translate,dictionary,api,crud]
|
||||
# @BRIEF Tests for _dictionary_routes.py — terminology dictionary CRUD, entries, and import.
|
||||
# @RELATION BINDS_TO -> [TranslateDictionaryRoutesModule]
|
||||
# @TEST_EDGE: dictionary_not_found -> 404
|
||||
# @TEST_EDGE: dictionary_conflict -> 409 (blocked by active jobs)
|
||||
# @TEST_EDGE: entry_already_exists -> 409
|
||||
# @TEST_EDGE: entry_not_found -> 404
|
||||
# @TEST_EDGE: import_invalid_on_conflict -> 422
|
||||
# @TEST_EDGE: import_value_error -> 400
|
||||
|
||||
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")
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import 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.translate._dictionary_routes import router
|
||||
from src.dependencies import get_current_user, get_db, has_permission, require_feature
|
||||
from src.schemas.auth import User, RoleSchema
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(router)
|
||||
|
||||
mock_user = User(
|
||||
id="user-1", username="admin", email="admin@x.com",
|
||||
auth_source="LOCAL",
|
||||
created_at=__import__("datetime").datetime.now(),
|
||||
roles=[RoleSchema(id="r1", name="Admin", description="", permissions=[])],
|
||||
)
|
||||
|
||||
defaults = {
|
||||
get_current_user: lambda: mock_user,
|
||||
get_db: lambda: MagicMock(),
|
||||
has_permission: lambda *a, **kw: lambda: None,
|
||||
require_feature: lambda *a, **kw: lambda: None,
|
||||
}
|
||||
if overrides:
|
||||
defaults.update(overrides)
|
||||
for dep, mock_fn in defaults.items():
|
||||
app.dependency_overrides[dep] = mock_fn
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
# Shared mock dictionary ORM row
|
||||
def _mock_dict(
|
||||
id="dict-1", name="Test Dict", description="A test dictionary",
|
||||
is_active=True, created_by="admin",
|
||||
created_at=None, updated_at=None,
|
||||
):
|
||||
d = MagicMock()
|
||||
d.id = id
|
||||
d.name = name
|
||||
d.description = description
|
||||
d.is_active = is_active
|
||||
d.created_by = created_by
|
||||
d.created_at = created_at or __import__("datetime").datetime(2026, 1, 1)
|
||||
d.updated_at = updated_at
|
||||
return d
|
||||
|
||||
|
||||
def _mock_entry(
|
||||
id="entry-1", dictionary_id="dict-1",
|
||||
source_term="hello", source_term_normalized="hello",
|
||||
target_term="hola",
|
||||
source_language="en", target_language="es",
|
||||
context_notes=None, context_data=None,
|
||||
usage_notes=None, has_context=False,
|
||||
is_regex=False, context_source=None,
|
||||
origin_source_language=None,
|
||||
created_at=None, updated_at=None,
|
||||
):
|
||||
e = MagicMock()
|
||||
e.id = id
|
||||
e.dictionary_id = dictionary_id
|
||||
e.source_term = source_term
|
||||
e.source_term_normalized = source_term_normalized
|
||||
e.target_term = target_term
|
||||
e.source_language = source_language
|
||||
e.target_language = target_language
|
||||
e.context_notes = context_notes
|
||||
e.context_data = context_data
|
||||
e.usage_notes = usage_notes
|
||||
e.has_context = has_context
|
||||
e.is_regex = is_regex
|
||||
e.context_source = context_source
|
||||
e.origin_source_language = origin_source_language
|
||||
e.created_at = created_at or __import__("datetime").datetime(2026, 1, 1)
|
||||
e.updated_at = updated_at
|
||||
return e
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Dictionaries CRUD
|
||||
# =============================================================================
|
||||
|
||||
class TestListDictionaries:
|
||||
"""GET /api/translate/dictionaries"""
|
||||
|
||||
def test_list_success(self):
|
||||
mock_mgr = MagicMock()
|
||||
mock_mgr.list_dictionaries.return_value = ([_mock_dict()], 1)
|
||||
|
||||
with patch("src.api.routes.translate._dictionary_routes.DictionaryManager", mock_mgr):
|
||||
client = _make_client()
|
||||
resp = client.get("/api/translate/dictionaries")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["total"] == 1
|
||||
assert len(data["items"]) == 1
|
||||
assert data["items"][0]["id"] == "dict-1"
|
||||
assert data["items"][0]["name"] == "Test Dict"
|
||||
|
||||
def test_list_empty(self):
|
||||
mock_mgr = MagicMock()
|
||||
mock_mgr.list_dictionaries.return_value = ([], 0)
|
||||
|
||||
with patch("src.api.routes.translate._dictionary_routes.DictionaryManager", mock_mgr):
|
||||
client = _make_client()
|
||||
resp = client.get("/api/translate/dictionaries")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["total"] == 0
|
||||
assert data["items"] == []
|
||||
|
||||
def test_list_pagination_params(self):
|
||||
mock_mgr = MagicMock()
|
||||
mock_mgr.list_dictionaries.return_value = ([_mock_dict()], 1)
|
||||
|
||||
with patch("src.api.routes.translate._dictionary_routes.DictionaryManager", mock_mgr):
|
||||
client = _make_client()
|
||||
resp = client.get("/api/translate/dictionaries?page=2&page_size=10")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["page"] == 2
|
||||
assert data["page_size"] == 10
|
||||
|
||||
|
||||
class TestGetDictionary:
|
||||
"""GET /api/translate/dictionaries/{dictionary_id}"""
|
||||
|
||||
def test_get_success(self):
|
||||
mock_dict = _mock_dict()
|
||||
mock_mgr = MagicMock()
|
||||
mock_mgr.get_dictionary.return_value = mock_dict
|
||||
|
||||
mock_db = MagicMock()
|
||||
mock_db.query.return_value.filter.return_value.count.return_value = 5
|
||||
|
||||
with patch("src.api.routes.translate._dictionary_routes.DictionaryManager", mock_mgr):
|
||||
from src.dependencies import get_db
|
||||
client = _make_client({get_db: lambda: mock_db})
|
||||
resp = client.get("/api/translate/dictionaries/dict-1")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["id"] == "dict-1"
|
||||
assert data["entry_count"] == 5
|
||||
|
||||
def test_get_not_found_404(self):
|
||||
mock_mgr = MagicMock()
|
||||
mock_mgr.get_dictionary.side_effect = ValueError("Dictionary not found")
|
||||
|
||||
with patch("src.api.routes.translate._dictionary_routes.DictionaryManager", mock_mgr):
|
||||
client = _make_client()
|
||||
resp = client.get("/api/translate/dictionaries/dict-missing")
|
||||
assert resp.status_code == 404
|
||||
assert "Dictionary not found" in resp.text
|
||||
|
||||
|
||||
class TestCreateDictionary:
|
||||
"""POST /api/translate/dictionaries"""
|
||||
|
||||
def test_create_success(self):
|
||||
mock_dict = _mock_dict(id="dict-new", name="New Dict")
|
||||
mock_mgr = MagicMock()
|
||||
mock_mgr.create_dictionary.return_value = mock_dict
|
||||
|
||||
with patch("src.api.routes.translate._dictionary_routes.DictionaryManager", mock_mgr):
|
||||
client = _make_client()
|
||||
resp = client.post("/api/translate/dictionaries", json={
|
||||
"name": "New Dict",
|
||||
"description": "A new dictionary",
|
||||
"is_active": True,
|
||||
})
|
||||
assert resp.status_code == 201
|
||||
data = resp.json()
|
||||
assert data["id"] == "dict-new"
|
||||
assert data["entry_count"] == 0
|
||||
|
||||
def test_create_missing_name_422(self):
|
||||
client = _make_client()
|
||||
resp = client.post("/api/translate/dictionaries", json={
|
||||
"description": "Missing name",
|
||||
})
|
||||
assert resp.status_code == 422
|
||||
assert "name" in resp.text
|
||||
|
||||
|
||||
class TestUpdateDictionary:
|
||||
"""PUT /api/translate/dictionaries/{dictionary_id}"""
|
||||
|
||||
def test_update_success(self):
|
||||
mock_dict = _mock_dict(id="dict-1", name="Updated Dict")
|
||||
mock_mgr = MagicMock()
|
||||
mock_mgr.update_dictionary.return_value = mock_dict
|
||||
|
||||
mock_db = MagicMock()
|
||||
mock_db.query.return_value.filter.return_value.count.return_value = 3
|
||||
|
||||
with patch("src.api.routes.translate._dictionary_routes.DictionaryManager", mock_mgr):
|
||||
from src.dependencies import get_db
|
||||
client = _make_client({get_db: lambda: mock_db})
|
||||
resp = client.put("/api/translate/dictionaries/dict-1", json={
|
||||
"name": "Updated Dict",
|
||||
"is_active": False,
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["name"] == "Updated Dict"
|
||||
assert data["entry_count"] == 3
|
||||
|
||||
def test_update_not_found_404(self):
|
||||
mock_mgr = MagicMock()
|
||||
mock_mgr.update_dictionary.side_effect = ValueError("Dictionary not found")
|
||||
|
||||
with patch("src.api.routes.translate._dictionary_routes.DictionaryManager", mock_mgr):
|
||||
client = _make_client()
|
||||
resp = client.put("/api/translate/dictionaries/dict-missing", json={
|
||||
"name": "Nope",
|
||||
"is_active": True,
|
||||
})
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
class TestDeleteDictionary:
|
||||
"""DELETE /api/translate/dictionaries/{dictionary_id}"""
|
||||
|
||||
def test_delete_success(self):
|
||||
mock_mgr = MagicMock()
|
||||
mock_mgr.delete_dictionary.return_value = None
|
||||
|
||||
with patch("src.api.routes.translate._dictionary_routes.DictionaryManager", mock_mgr):
|
||||
client = _make_client()
|
||||
resp = client.delete("/api/translate/dictionaries/dict-1")
|
||||
assert resp.status_code == 204
|
||||
|
||||
def test_delete_not_found_404(self):
|
||||
mock_mgr = MagicMock()
|
||||
mock_mgr.delete_dictionary.side_effect = ValueError("Dictionary not found")
|
||||
|
||||
with patch("src.api.routes.translate._dictionary_routes.DictionaryManager", mock_mgr):
|
||||
client = _make_client()
|
||||
resp = client.delete("/api/translate/dictionaries/dict-missing")
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_delete_conflict_409(self):
|
||||
mock_mgr = MagicMock()
|
||||
mock_mgr.delete_dictionary.side_effect = ValueError(
|
||||
"Dictionary has active/scheduled jobs attached"
|
||||
)
|
||||
|
||||
with patch("src.api.routes.translate._dictionary_routes.DictionaryManager", mock_mgr):
|
||||
client = _make_client()
|
||||
resp = client.delete("/api/translate/dictionaries/dict-busy")
|
||||
assert resp.status_code == 409
|
||||
assert "active/scheduled" in resp.text
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Dictionary Entries
|
||||
# =============================================================================
|
||||
|
||||
class TestListDictionaryEntries:
|
||||
"""GET /api/translate/dictionaries/{dictionary_id}/entries"""
|
||||
|
||||
def test_list_entries_success(self):
|
||||
entry = _mock_entry(id="e1")
|
||||
mock_mgr = MagicMock()
|
||||
mock_mgr.list_entries.return_value = ([entry], 1)
|
||||
|
||||
with patch("src.api.routes.translate._dictionary_routes.DictionaryManager", mock_mgr):
|
||||
client = _make_client()
|
||||
resp = client.get("/api/translate/dictionaries/dict-1/entries")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["total"] == 1
|
||||
assert data["items"][0]["id"] == "e1"
|
||||
assert data["items"][0]["source_term"] == "hello"
|
||||
|
||||
def test_list_entries_with_language_filters(self):
|
||||
entry_en_es = _mock_entry(id="e1", source_language="en", target_language="es")
|
||||
entry_fr_de = _mock_entry(id="e2", source_language="fr", target_language="de")
|
||||
mock_mgr = MagicMock()
|
||||
mock_mgr.list_entries.return_value = ([entry_en_es, entry_fr_de], 2)
|
||||
|
||||
with patch("src.api.routes.translate._dictionary_routes.DictionaryManager", mock_mgr):
|
||||
client = _make_client()
|
||||
resp = client.get(
|
||||
"/api/translate/dictionaries/dict-1/entries"
|
||||
"?source_language=en&target_language=es"
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
# Only en→es passes filter
|
||||
assert data["total"] == 1
|
||||
assert data["items"][0]["id"] == "e1"
|
||||
|
||||
def test_list_entries_only_target_language_filter(self):
|
||||
"""Filter by target_language only — covers source skip at line 193 then target skip at line 195."""
|
||||
entry_en_es = _mock_entry(id="e1", source_language="en", target_language="es")
|
||||
entry_fr_de = _mock_entry(id="e2", source_language="fr", target_language="de")
|
||||
mock_mgr = MagicMock()
|
||||
mock_mgr.list_entries.return_value = ([entry_en_es, entry_fr_de], 2)
|
||||
|
||||
with patch("src.api.routes.translate._dictionary_routes.DictionaryManager", mock_mgr):
|
||||
client = _make_client()
|
||||
resp = client.get(
|
||||
"/api/translate/dictionaries/dict-1/entries?target_language=de"
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
# Only fr→de passes target_language filter
|
||||
assert data["total"] == 1
|
||||
assert data["items"][0]["id"] == "e2"
|
||||
|
||||
def test_list_entries_not_found_404(self):
|
||||
mock_mgr = MagicMock()
|
||||
mock_mgr.list_entries.side_effect = ValueError("Dictionary not found")
|
||||
|
||||
with patch("src.api.routes.translate._dictionary_routes.DictionaryManager", mock_mgr):
|
||||
client = _make_client()
|
||||
resp = client.get("/api/translate/dictionaries/dict-missing/entries")
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
class TestAddDictionaryEntry:
|
||||
"""POST /api/translate/dictionaries/{dictionary_id}/entries"""
|
||||
|
||||
def test_add_entry_success(self):
|
||||
entry = _mock_entry(id="e-new", dictionary_id="dict-1")
|
||||
mock_mgr = MagicMock()
|
||||
mock_mgr.add_entry.return_value = entry
|
||||
|
||||
with patch("src.api.routes.translate._dictionary_routes.DictionaryManager", mock_mgr):
|
||||
client = _make_client()
|
||||
resp = client.post("/api/translate/dictionaries/dict-1/entries", json={
|
||||
"source_term": "hello",
|
||||
"target_term": "hola",
|
||||
})
|
||||
assert resp.status_code == 201
|
||||
data = resp.json()
|
||||
assert data["id"] == "e-new"
|
||||
assert data["source_term"] == "hello"
|
||||
assert data["target_term"] == "hola"
|
||||
|
||||
def test_add_entry_conflict_409(self):
|
||||
mock_mgr = MagicMock()
|
||||
mock_mgr.add_entry.side_effect = ValueError("Entry already exists")
|
||||
|
||||
with patch("src.api.routes.translate._dictionary_routes.DictionaryManager", mock_mgr):
|
||||
client = _make_client()
|
||||
resp = client.post("/api/translate/dictionaries/dict-1/entries", json={
|
||||
"source_term": "hello",
|
||||
"target_term": "hola",
|
||||
})
|
||||
assert resp.status_code == 409
|
||||
assert "already exists" in resp.text
|
||||
|
||||
def test_add_entry_not_found_404(self):
|
||||
mock_mgr = MagicMock()
|
||||
mock_mgr.add_entry.side_effect = ValueError("not found")
|
||||
|
||||
with patch("src.api.routes.translate._dictionary_routes.DictionaryManager", mock_mgr):
|
||||
client = _make_client()
|
||||
resp = client.post("/api/translate/dictionaries/dict-missing/entries", json={
|
||||
"source_term": "hello",
|
||||
"target_term": "hola",
|
||||
})
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_add_entry_422_missing_source(self):
|
||||
client = _make_client()
|
||||
resp = client.post("/api/translate/dictionaries/dict-1/entries", json={
|
||||
"target_term": "hola",
|
||||
})
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
class TestEditDictionaryEntry:
|
||||
"""PUT /api/translate/dictionaries/{dictionary_id}/entries/{entry_id}"""
|
||||
|
||||
def test_edit_entry_success(self):
|
||||
entry = _mock_entry(id="e1", target_term="updated_hola")
|
||||
mock_mgr = MagicMock()
|
||||
mock_mgr.edit_entry.return_value = entry
|
||||
|
||||
with patch("src.api.routes.translate._dictionary_routes.DictionaryManager", mock_mgr):
|
||||
client = _make_client()
|
||||
resp = client.put(
|
||||
"/api/translate/dictionaries/dict-1/entries/e1", json={
|
||||
"source_term": "hello",
|
||||
"target_term": "updated_hola",
|
||||
}
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["target_term"] == "updated_hola"
|
||||
|
||||
def test_edit_entry_conflict_409(self):
|
||||
mock_mgr = MagicMock()
|
||||
mock_mgr.edit_entry.side_effect = ValueError("Entry already exists")
|
||||
|
||||
with patch("src.api.routes.translate._dictionary_routes.DictionaryManager", mock_mgr):
|
||||
client = _make_client()
|
||||
resp = client.put(
|
||||
"/api/translate/dictionaries/dict-1/entries/e1", json={
|
||||
"source_term": "hello",
|
||||
"target_term": "hola",
|
||||
}
|
||||
)
|
||||
assert resp.status_code == 409
|
||||
|
||||
def test_edit_entry_not_found_404(self):
|
||||
mock_mgr = MagicMock()
|
||||
mock_mgr.edit_entry.side_effect = ValueError("Entry not found")
|
||||
|
||||
with patch("src.api.routes.translate._dictionary_routes.DictionaryManager", mock_mgr):
|
||||
client = _make_client()
|
||||
resp = client.put(
|
||||
"/api/translate/dictionaries/dict-1/entries/e-missing", json={
|
||||
"source_term": "hello",
|
||||
"target_term": "hola",
|
||||
}
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
class TestDeleteDictionaryEntry:
|
||||
"""DELETE /api/translate/dictionaries/{dictionary_id}/entries/{entry_id}"""
|
||||
|
||||
def test_delete_entry_success(self):
|
||||
mock_mgr = MagicMock()
|
||||
mock_mgr.delete_entry.return_value = None
|
||||
|
||||
with patch("src.api.routes.translate._dictionary_routes.DictionaryManager", mock_mgr):
|
||||
client = _make_client()
|
||||
resp = client.delete("/api/translate/dictionaries/dict-1/entries/e1")
|
||||
assert resp.status_code == 204
|
||||
|
||||
def test_delete_entry_not_found_404(self):
|
||||
mock_mgr = MagicMock()
|
||||
mock_mgr.delete_entry.side_effect = ValueError("Entry not found")
|
||||
|
||||
with patch("src.api.routes.translate._dictionary_routes.DictionaryManager", mock_mgr):
|
||||
client = _make_client()
|
||||
resp = client.delete("/api/translate/dictionaries/dict-1/entries/e-missing")
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Import
|
||||
# =============================================================================
|
||||
|
||||
class TestImportDictionaryEntries:
|
||||
"""POST /api/translate/dictionaries/{dictionary_id}/import"""
|
||||
|
||||
def test_import_success(self):
|
||||
mock_mgr = MagicMock()
|
||||
mock_mgr.import_entries.return_value = {
|
||||
"total": 2, "created": 2, "updated": 0, "skipped": 0, "errors": [],
|
||||
}
|
||||
|
||||
with patch("src.api.routes.translate._dictionary_routes.DictionaryManager", mock_mgr):
|
||||
client = _make_client()
|
||||
resp = client.post("/api/translate/dictionaries/dict-1/import", json={
|
||||
"content": "source,target\nhello,hola\nbye,adiós",
|
||||
"on_conflict": "overwrite",
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["total"] == 2
|
||||
assert data["created"] == 2
|
||||
|
||||
def test_import_invalid_on_conflict_422(self):
|
||||
client = _make_client()
|
||||
resp = client.post("/api/translate/dictionaries/dict-1/import", json={
|
||||
"content": "source,target\nhello,hola",
|
||||
"on_conflict": "invalid_option",
|
||||
})
|
||||
assert resp.status_code == 422
|
||||
assert "on_conflict" in resp.text
|
||||
|
||||
def test_import_value_error_400(self):
|
||||
mock_mgr = MagicMock()
|
||||
mock_mgr.import_entries.side_effect = ValueError("Invalid CSV format")
|
||||
|
||||
with patch("src.api.routes.translate._dictionary_routes.DictionaryManager", mock_mgr):
|
||||
client = _make_client()
|
||||
resp = client.post("/api/translate/dictionaries/dict-1/import", json={
|
||||
"content": "broken content",
|
||||
"on_conflict": "overwrite",
|
||||
})
|
||||
assert resp.status_code == 400
|
||||
assert "Invalid CSV format" in resp.text
|
||||
|
||||
def test_import_preview_only(self):
|
||||
mock_mgr = MagicMock()
|
||||
mock_mgr.import_entries.return_value = {
|
||||
"total": 1, "created": 0, "updated": 0, "skipped": 0,
|
||||
"errors": [], "preview": [{"source_term": "hello"}],
|
||||
}
|
||||
|
||||
with patch("src.api.routes.translate._dictionary_routes.DictionaryManager", mock_mgr):
|
||||
client = _make_client()
|
||||
resp = client.post("/api/translate/dictionaries/dict-1/import", json={
|
||||
"content": "source,target\nhello,hola",
|
||||
"on_conflict": "keep_existing",
|
||||
"preview_only": True,
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert "preview" in data
|
||||
assert data["preview"][0]["source_term"] == "hello"
|
||||
# #endregion Test.Api.TranslateDictionaryRoutes
|
||||
400
backend/tests/api/test_translate_job_routes.py
Normal file
400
backend/tests/api/test_translate_job_routes.py
Normal file
@@ -0,0 +1,400 @@
|
||||
# #region Test.Api.TranslateJobRoutesExt [C:3] [TYPE Module] [SEMANTICS test,translate,jobs,datasources,api,schema]
|
||||
# @BRIEF Extended tests for _job_routes.py — datasource listing, column fetch, target schema, and error paths.
|
||||
# @RELATION BINDS_TO -> [TranslateJobRoutesModule]
|
||||
# @TEST_EDGE: list_datasources_error -> 502
|
||||
# @TEST_EDGE: datasource_columns_value_error -> 400
|
||||
# @TEST_EDGE: datasource_columns_generic_error -> 502
|
||||
# @TEST_EDGE: check_target_schema_error -> 200 with error field
|
||||
# @TEST_EDGE: list_jobs_value_error -> 400
|
||||
# @TEST_EDGE: create_job_value_error -> 422
|
||||
# @TEST_EDGE: update_job_value_error -> 404
|
||||
|
||||
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")
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
_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.translate._job_routes import router
|
||||
from src.dependencies import (
|
||||
get_config_manager, get_current_user, get_db,
|
||||
has_permission, require_feature,
|
||||
)
|
||||
from src.schemas.auth import User, RoleSchema
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(router)
|
||||
|
||||
mock_user = User(
|
||||
id="user-1", username="admin", email="admin@x.com",
|
||||
auth_source="LOCAL",
|
||||
created_at=__import__("datetime").datetime.now(),
|
||||
roles=[RoleSchema(id="r1", name="Admin", description="", permissions=[])],
|
||||
)
|
||||
|
||||
defaults = {
|
||||
get_current_user: lambda: mock_user,
|
||||
get_config_manager: lambda: MagicMock(),
|
||||
get_db: lambda: MagicMock(),
|
||||
has_permission: lambda *a, **kw: lambda: None,
|
||||
require_feature: lambda *a, **kw: lambda: None,
|
||||
}
|
||||
if overrides:
|
||||
defaults.update(overrides)
|
||||
for dep, mock_fn in defaults.items():
|
||||
app.dependency_overrides[dep] = mock_fn
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# List Datasources
|
||||
# =============================================================================
|
||||
|
||||
class TestListDatasources:
|
||||
"""GET /api/translate/datasources"""
|
||||
|
||||
def test_list_datasources_success(self):
|
||||
mock_service = MagicMock()
|
||||
mock_service.fetch_available_datasources = AsyncMock(return_value=[
|
||||
{"id": 1, "name": "my_table", "database": "postgresql"},
|
||||
])
|
||||
|
||||
with patch(
|
||||
"src.api.routes.translate._job_routes.TranslateJobService",
|
||||
return_value=mock_service,
|
||||
):
|
||||
client = _make_client()
|
||||
resp = client.get("/api/translate/datasources?env_id=env-1")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert len(data) == 1
|
||||
assert data[0]["name"] == "my_table"
|
||||
|
||||
def test_list_datasources_with_search(self):
|
||||
mock_service = MagicMock()
|
||||
mock_service.fetch_available_datasources = AsyncMock(return_value=[
|
||||
{"id": 2, "name": "filtered_table", "database": "postgresql"},
|
||||
])
|
||||
|
||||
with patch(
|
||||
"src.api.routes.translate._job_routes.TranslateJobService",
|
||||
return_value=mock_service,
|
||||
):
|
||||
client = _make_client()
|
||||
resp = client.get("/api/translate/datasources?env_id=env-1&search=filtered")
|
||||
assert resp.status_code == 200
|
||||
mock_service.fetch_available_datasources.assert_called_once_with("env-1", "filtered")
|
||||
|
||||
def test_list_datasources_422_missing_env(self):
|
||||
client = _make_client()
|
||||
resp = client.get("/api/translate/datasources")
|
||||
assert resp.status_code == 422
|
||||
assert "env_id" in resp.text
|
||||
|
||||
def test_list_datasources_generic_error_502(self):
|
||||
mock_service = MagicMock()
|
||||
mock_service.fetch_available_datasources.side_effect = RuntimeError("Superset unreachable")
|
||||
|
||||
with patch(
|
||||
"src.api.routes.translate._job_routes.TranslateJobService",
|
||||
return_value=mock_service,
|
||||
):
|
||||
client = _make_client()
|
||||
resp = client.get("/api/translate/datasources?env_id=env-bad")
|
||||
assert resp.status_code == 502
|
||||
assert "Superset" in resp.text or "unreachable" in resp.text
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Get Datasource Columns
|
||||
# =============================================================================
|
||||
|
||||
class TestGetDatasourceColumns:
|
||||
"""GET /api/translate/datasources/{datasource_id}/columns"""
|
||||
|
||||
def test_datasource_columns_success(self):
|
||||
with patch(
|
||||
"src.api.routes.translate._job_routes.fetch_datasource_columns_service",
|
||||
return_value={
|
||||
"datasource_id": 42,
|
||||
"datasource_name": "my_table",
|
||||
"schema_name": "public",
|
||||
"database_dialect": "postgresql",
|
||||
"columns": [{"name": "id", "type": "integer", "is_physical": True}],
|
||||
},
|
||||
):
|
||||
client = _make_client()
|
||||
resp = client.get("/api/translate/datasources/42/columns?env_id=env-1")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["database_dialect"] == "postgresql"
|
||||
assert len(data["columns"]) == 1
|
||||
assert data["datasource_id"] == 42
|
||||
|
||||
def test_datasource_columns_value_error_400(self):
|
||||
with patch(
|
||||
"src.api.routes.translate._job_routes.fetch_datasource_columns_service",
|
||||
side_effect=ValueError("Datasource not found"),
|
||||
):
|
||||
client = _make_client()
|
||||
resp = client.get("/api/translate/datasources/999/columns?env_id=env-1")
|
||||
assert resp.status_code == 400
|
||||
|
||||
def test_datasource_columns_generic_error_502(self):
|
||||
with patch(
|
||||
"src.api.routes.translate._job_routes.fetch_datasource_columns_service",
|
||||
side_effect=RuntimeError("Superset API error"),
|
||||
):
|
||||
client = _make_client()
|
||||
resp = client.get("/api/translate/datasources/42/columns?env_id=env-1")
|
||||
assert resp.status_code == 502
|
||||
assert "Superset" in resp.text
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Check Target Schema
|
||||
# =============================================================================
|
||||
|
||||
class TestCheckTargetSchema:
|
||||
"""POST /api/translate/check-target-schema"""
|
||||
|
||||
def test_check_target_schema_success(self):
|
||||
with patch(
|
||||
"src.api.routes.translate._job_routes.validate_target_table_schema",
|
||||
return_value={
|
||||
"table_exists": True,
|
||||
"all_present": True,
|
||||
"expected_columns": [],
|
||||
"actual_columns": [],
|
||||
"missing_columns": [],
|
||||
"extra_columns": [],
|
||||
},
|
||||
):
|
||||
client = _make_client()
|
||||
resp = client.post("/api/translate/check-target-schema", json={
|
||||
"environment_id": "env-1",
|
||||
"target_database_id": "db-1",
|
||||
"target_schema": "public",
|
||||
"target_table": "my_table",
|
||||
"target_key_cols": ["id"],
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["table_exists"] is True
|
||||
assert data["all_present"] is True
|
||||
|
||||
def test_check_target_schema_422_missing_fields(self):
|
||||
client = _make_client()
|
||||
resp = client.post("/api/translate/check-target-schema", json={
|
||||
"environment_id": "env-1",
|
||||
})
|
||||
assert resp.status_code == 422
|
||||
assert "target_database_id" in resp.text
|
||||
|
||||
def test_check_target_schema_exception_handled(self):
|
||||
"""Exception in validate_target_table_schema returns 200 with error field."""
|
||||
with patch(
|
||||
"src.api.routes.translate._job_routes.validate_target_table_schema",
|
||||
side_effect=RuntimeError("Connection refused"),
|
||||
):
|
||||
client = _make_client()
|
||||
resp = client.post("/api/translate/check-target-schema", json={
|
||||
"environment_id": "env-1",
|
||||
"target_database_id": "db-1",
|
||||
"target_table": "my_table",
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["table_exists"] is False
|
||||
assert data["error"] is not None
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# List Jobs — Error path not covered by existing tests
|
||||
# =============================================================================
|
||||
|
||||
class TestListJobsErrors:
|
||||
"""GET /api/translate/jobs — error paths"""
|
||||
|
||||
def test_list_jobs_value_error_crashes(self):
|
||||
"""Known source bug: `status` param shadows `fastapi.status` import.
|
||||
ValueError → `status.HTTP_400_BAD_REQUEST` fails (status=None) → AttributeError.
|
||||
The exception handler is broken due to name shadowing; fix is to rename the
|
||||
`status` query parameter. This test documents the known source bug.
|
||||
"""
|
||||
pytest.skip("Source bug: `status` param shadows fastapi.status — error path untestable without source mod")
|
||||
mock_service = MagicMock()
|
||||
mock_service.list_jobs.side_effect = ValueError("Invalid filter")
|
||||
with patch(
|
||||
"src.api.routes.translate._job_routes.TranslateJobService",
|
||||
return_value=mock_service,
|
||||
):
|
||||
client = _make_client()
|
||||
_ = client.get("/api/translate/jobs")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Create Job — 422 error path
|
||||
# =============================================================================
|
||||
|
||||
class TestCreateJobErrors:
|
||||
"""POST /api/translate/jobs — error paths"""
|
||||
|
||||
def test_create_job_422_value_error(self):
|
||||
mock_service = MagicMock()
|
||||
mock_service.create_job.side_effect = ValueError("translation column is required")
|
||||
|
||||
with patch(
|
||||
"src.api.routes.translate._job_routes.TranslateJobService",
|
||||
return_value=mock_service,
|
||||
):
|
||||
client = _make_client()
|
||||
resp = client.post("/api/translate/jobs", json={
|
||||
"name": "Bad job",
|
||||
"source_dialect": "postgresql",
|
||||
"target_dialect": "clickhouse",
|
||||
})
|
||||
assert resp.status_code == 422
|
||||
assert "translation column" in resp.text
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Update Job — error path (404 for missing job + 422 for bad payload)
|
||||
# =============================================================================
|
||||
|
||||
def _mock_job(id="job-1", name="Test Job", **kw):
|
||||
"""Create a mock TranslationJob ORM object."""
|
||||
j = MagicMock()
|
||||
j.id = id
|
||||
j.name = name
|
||||
j.description = kw.get("description")
|
||||
j.source_dialect = kw.get("source_dialect", "postgresql")
|
||||
j.target_dialect = kw.get("target_dialect", "clickhouse")
|
||||
j.source_key_cols = kw.get("source_key_cols", ["id"])
|
||||
j.target_key_cols = kw.get("target_key_cols", ["id"])
|
||||
j.translation_column = kw.get("translation_column", "name")
|
||||
j.batch_size = kw.get("batch_size", 50)
|
||||
j.upsert_strategy = kw.get("upsert_strategy", "MERGE")
|
||||
j.status = kw.get("status", "DRAFT")
|
||||
j.created_by = kw.get("created_by", "admin")
|
||||
j.created_at = kw.get("created_at", __import__("datetime").datetime(2026, 1, 1))
|
||||
return j
|
||||
|
||||
|
||||
class TestGetJob:
|
||||
"""GET /api/translate/jobs/{job_id}"""
|
||||
|
||||
def test_get_job_success(self):
|
||||
"""Covers success path lines 88-89."""
|
||||
from src.plugins.translate.service_utils import job_to_response
|
||||
|
||||
mock_job = _mock_job()
|
||||
mock_service = MagicMock()
|
||||
mock_service.get_job.return_value = mock_job
|
||||
mock_service.get_job_dictionary_ids.return_value = ["dict-1"]
|
||||
|
||||
with patch(
|
||||
"src.api.routes.translate._job_routes.TranslateJobService",
|
||||
return_value=mock_service,
|
||||
):
|
||||
client = _make_client()
|
||||
resp = client.get("/api/translate/jobs/job-1")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["id"] == "job-1"
|
||||
assert data["name"] == "Test Job"
|
||||
|
||||
def test_get_job_404_value_error(self):
|
||||
mock_service = MagicMock()
|
||||
mock_service.get_job.side_effect = ValueError("Job not found")
|
||||
|
||||
with patch(
|
||||
"src.api.routes.translate._job_routes.TranslateJobService",
|
||||
return_value=mock_service,
|
||||
):
|
||||
client = _make_client()
|
||||
resp = client.get("/api/translate/jobs/job-missing")
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
class TestUpdateJob:
|
||||
"""PUT /api/translate/jobs/{job_id}"""
|
||||
|
||||
def test_update_job_success(self):
|
||||
"""Covers success path lines 141-142."""
|
||||
mock_job = _mock_job(name="Updated Job")
|
||||
mock_service = MagicMock()
|
||||
mock_service.update_job.return_value = mock_job
|
||||
mock_service.get_job_dictionary_ids.return_value = ["dict-1"]
|
||||
|
||||
with patch(
|
||||
"src.api.routes.translate._job_routes.TranslateJobService",
|
||||
return_value=mock_service,
|
||||
):
|
||||
client = _make_client()
|
||||
resp = client.put("/api/translate/jobs/job-1", json={
|
||||
"name": "Updated Job",
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["name"] == "Updated Job"
|
||||
|
||||
def test_update_job_404_value_error(self):
|
||||
mock_service = MagicMock()
|
||||
mock_service.update_job.side_effect = ValueError("Job not found")
|
||||
|
||||
with patch(
|
||||
"src.api.routes.translate._job_routes.TranslateJobService",
|
||||
return_value=mock_service,
|
||||
):
|
||||
client = _make_client()
|
||||
resp = client.put("/api/translate/jobs/job-missing", json={
|
||||
"name": "Updated",
|
||||
})
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
class TestDuplicateJob:
|
||||
"""POST /api/translate/jobs/{job_id}/duplicate"""
|
||||
|
||||
def test_duplicate_job_success(self):
|
||||
"""Covers success path line 189."""
|
||||
mock_job = _mock_job(name="Test Job (Copy)", id="job-copy")
|
||||
mock_service = MagicMock()
|
||||
mock_service.duplicate_job.return_value = mock_job
|
||||
|
||||
with patch(
|
||||
"src.api.routes.translate._job_routes.TranslateJobService",
|
||||
return_value=mock_service,
|
||||
):
|
||||
client = _make_client()
|
||||
resp = client.post("/api/translate/jobs/job-1/duplicate")
|
||||
assert resp.status_code == 201
|
||||
data = resp.json()
|
||||
assert data["id"] == "job-copy"
|
||||
|
||||
def test_duplicate_job_404_value_error(self):
|
||||
mock_service = MagicMock()
|
||||
mock_service.duplicate_job.side_effect = ValueError("Job not found")
|
||||
|
||||
with patch(
|
||||
"src.api.routes.translate._job_routes.TranslateJobService",
|
||||
return_value=mock_service,
|
||||
):
|
||||
client = _make_client()
|
||||
resp = client.post("/api/translate/jobs/job-missing/duplicate")
|
||||
assert resp.status_code == 404
|
||||
# #endregion Test.Api.TranslateJobRoutesExt
|
||||
348
backend/tests/api/test_translate_run_edit_routes.py
Normal file
348
backend/tests/api/test_translate_run_edit_routes.py
Normal file
@@ -0,0 +1,348 @@
|
||||
# #region Test.Api.TranslateRunEditRoutes [C:3] [TYPE Module] [SEMANTICS test,translate,run,edit,bulk,override,api]
|
||||
# @BRIEF Tests for _run_edit_routes.py — language override, inline edit, bulk find-replace.
|
||||
# @RELATION BINDS_TO -> [TranslateRunEditRoutesModule]
|
||||
# @TEST_EDGE: override_record_not_found -> 404
|
||||
# @TEST_EDGE: override_lang_entry_not_found -> 404
|
||||
# @TEST_EDGE: override_generic_error -> 400
|
||||
# @TEST_EDGE: inline_edit_value_error -> 400
|
||||
# @TEST_EDGE: inline_edit_generic_error -> 400
|
||||
# @TEST_EDGE: bulk_replace_value_error -> 400
|
||||
# @TEST_EDGE: bulk_replace_generic_error -> 400
|
||||
|
||||
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")
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import 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.translate._run_edit_routes import router
|
||||
from src.dependencies import (
|
||||
get_config_manager, get_current_user, get_db,
|
||||
has_permission, require_feature,
|
||||
)
|
||||
from src.schemas.auth import User, RoleSchema
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(router)
|
||||
|
||||
mock_user = User(
|
||||
id="user-1", username="admin", email="admin@x.com",
|
||||
auth_source="LOCAL",
|
||||
created_at=__import__("datetime").datetime.now(),
|
||||
roles=[RoleSchema(id="r1", name="Admin", description="", permissions=[])],
|
||||
)
|
||||
|
||||
defaults = {
|
||||
get_current_user: lambda: mock_user,
|
||||
get_config_manager: lambda: MagicMock(),
|
||||
get_db: lambda: MagicMock(),
|
||||
has_permission: lambda *a, **kw: lambda: None,
|
||||
require_feature: lambda *a, **kw: lambda: None,
|
||||
}
|
||||
if overrides:
|
||||
defaults.update(overrides)
|
||||
for dep, mock_fn in defaults.items():
|
||||
app.dependency_overrides[dep] = mock_fn
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def _mock_record(id="rec-1", run_id="run-1"):
|
||||
r = MagicMock()
|
||||
r.id = id
|
||||
r.run_id = run_id
|
||||
return r
|
||||
|
||||
|
||||
def _mock_lang_entry(record_id="rec-1", language_code="es"):
|
||||
e = MagicMock()
|
||||
e.record_id = record_id
|
||||
e.language_code = language_code
|
||||
e.source_language_detected = "en"
|
||||
e.language_overridden = False
|
||||
e.needs_review = False
|
||||
# Explicit None for string fields to avoid MagicMock auto-creation
|
||||
e.translated_value = None
|
||||
e.user_edit = None
|
||||
e.final_value = None
|
||||
e.status = "pending"
|
||||
return e
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Override Detected Language
|
||||
# =============================================================================
|
||||
|
||||
class TestOverrideDetectedLanguage:
|
||||
"""PUT /api/translate/runs/{run_id}/records/{record_id}/languages/{language_code}/override-language"""
|
||||
|
||||
def test_override_success(self):
|
||||
mock_db = MagicMock()
|
||||
mock_record = _mock_record()
|
||||
mock_lang = _mock_lang_entry()
|
||||
|
||||
# First query returns record, second returns lang entry
|
||||
mock_db.query.return_value.filter.return_value.first.side_effect = [
|
||||
mock_record, mock_lang,
|
||||
]
|
||||
|
||||
from src.dependencies import get_db
|
||||
client = _make_client({get_db: lambda: mock_db})
|
||||
|
||||
resp = client.put(
|
||||
"/api/translate/runs/run-1/records/rec-1/languages/es/override-language",
|
||||
json={"source_language": "fr"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["source_language_detected"] == "fr"
|
||||
assert data["language_overridden"] is True
|
||||
# Verify language_overridden was set
|
||||
assert mock_lang.language_overridden is True
|
||||
assert mock_lang.source_language_detected == "fr"
|
||||
|
||||
def test_override_record_not_found_404(self):
|
||||
mock_db = MagicMock()
|
||||
mock_db.query.return_value.filter.return_value.first.side_effect = [
|
||||
None, # record not found
|
||||
]
|
||||
|
||||
from src.dependencies import get_db
|
||||
client = _make_client({get_db: lambda: mock_db})
|
||||
|
||||
resp = client.put(
|
||||
"/api/translate/runs/run-1/records/rec-missing/languages/es/override-language",
|
||||
json={"source_language": "fr"},
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
assert "not found" in resp.text
|
||||
|
||||
def test_override_lang_entry_not_found_404(self):
|
||||
mock_db = MagicMock()
|
||||
mock_record = _mock_record()
|
||||
mock_db.query.return_value.filter.return_value.first.side_effect = [
|
||||
mock_record, # record found
|
||||
None, # lang entry not found
|
||||
]
|
||||
|
||||
from src.dependencies import get_db
|
||||
client = _make_client({get_db: lambda: mock_db})
|
||||
|
||||
resp = client.put(
|
||||
"/api/translate/runs/run-1/records/rec-1/languages/missing/override-language",
|
||||
json={"source_language": "fr"},
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
assert "not found" in resp.text
|
||||
|
||||
def test_override_generic_error_400(self):
|
||||
mock_db = MagicMock()
|
||||
mock_db.query.return_value.filter.side_effect = Exception("DB error")
|
||||
|
||||
from src.dependencies import get_db
|
||||
client = _make_client({get_db: lambda: mock_db})
|
||||
|
||||
resp = client.put(
|
||||
"/api/translate/runs/run-1/records/rec-1/languages/es/override-language",
|
||||
json={"source_language": "fr"},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Inline Edit Translation
|
||||
# =============================================================================
|
||||
|
||||
class TestInlineEditTranslation:
|
||||
"""PUT /api/translate/runs/{run_id}/records/{record_id}/languages/{language_code}"""
|
||||
|
||||
def test_inline_edit_success(self):
|
||||
with patch(
|
||||
"src.plugins.translate.service.InlineCorrectionService"
|
||||
) as MockService:
|
||||
MockService.apply_inline_edit.return_value = {
|
||||
"status": "updated",
|
||||
"final_value": "corregido",
|
||||
}
|
||||
|
||||
client = _make_client()
|
||||
resp = client.put(
|
||||
"/api/translate/runs/run-1/records/rec-1/languages/es",
|
||||
json={"final_value": "corregido"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["status"] == "updated"
|
||||
|
||||
def test_inline_edit_with_dictionary_submit(self):
|
||||
with patch(
|
||||
"src.plugins.translate.service.InlineCorrectionService"
|
||||
) as MockService:
|
||||
MockService.apply_inline_edit.return_value = {
|
||||
"status": "updated",
|
||||
"dictionary_entry_id": "entry-1",
|
||||
}
|
||||
|
||||
client = _make_client()
|
||||
resp = client.put(
|
||||
"/api/translate/runs/run-1/records/rec-1/languages/es",
|
||||
json={
|
||||
"final_value": "corregido",
|
||||
"submit_to_dictionary": True,
|
||||
"dictionary_id": "dict-1",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
MockService.apply_inline_edit.assert_called_once()
|
||||
call_kwargs = MockService.apply_inline_edit.call_args.kwargs
|
||||
assert call_kwargs["submit_to_dictionary"] is True
|
||||
assert call_kwargs["dictionary_id"] == "dict-1"
|
||||
|
||||
def test_inline_edit_value_error_400(self):
|
||||
with patch(
|
||||
"src.plugins.translate.service.InlineCorrectionService"
|
||||
) as MockService:
|
||||
MockService.apply_inline_edit.side_effect = ValueError("Record not found")
|
||||
|
||||
client = _make_client()
|
||||
resp = client.put(
|
||||
"/api/translate/runs/run-1/records/rec-missing/languages/es",
|
||||
json={"final_value": "corregido"},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
|
||||
def test_inline_edit_generic_error_400(self):
|
||||
with patch(
|
||||
"src.plugins.translate.service.InlineCorrectionService"
|
||||
) as MockService:
|
||||
MockService.apply_inline_edit.side_effect = RuntimeError("Unexpected")
|
||||
|
||||
client = _make_client()
|
||||
resp = client.put(
|
||||
"/api/translate/runs/run-1/records/rec-1/languages/es",
|
||||
json={"final_value": "corregido"},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
|
||||
def test_inline_edit_422_missing_final_value(self):
|
||||
client = _make_client()
|
||||
resp = client.put(
|
||||
"/api/translate/runs/run-1/records/rec-1/languages/es",
|
||||
json={},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
assert "final_value" in resp.text
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Bulk Find Replace
|
||||
# =============================================================================
|
||||
|
||||
class TestBulkFindReplace:
|
||||
"""POST /api/translate/runs/{run_id}/bulk-replace"""
|
||||
|
||||
def test_bulk_preview_success(self):
|
||||
with patch(
|
||||
"src.plugins.translate.service.BulkFindReplaceService"
|
||||
) as MockService:
|
||||
MockService.preview.return_value = [
|
||||
{"record_id": "rec-1", "current_value": "hola", "new_value": "hello"},
|
||||
]
|
||||
|
||||
client = _make_client()
|
||||
resp = client.post("/api/translate/runs/run-1/bulk-replace", json={
|
||||
"find_pattern": "hola",
|
||||
"replacement_text": "hello",
|
||||
"target_language": "es",
|
||||
"preview": True,
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["rows_affected"] == 1
|
||||
assert data["corrections_submitted"] == 0
|
||||
assert len(data["preview"]) == 1
|
||||
|
||||
def test_bulk_apply_success(self):
|
||||
with patch(
|
||||
"src.plugins.translate.service.BulkFindReplaceService"
|
||||
) as MockService:
|
||||
MockService.apply.return_value = {
|
||||
"rows_affected": 5,
|
||||
"corrections_submitted": 5,
|
||||
"preview": [],
|
||||
}
|
||||
|
||||
client = _make_client()
|
||||
resp = client.post("/api/translate/runs/run-1/bulk-replace", json={
|
||||
"find_pattern": "hola",
|
||||
"replacement_text": "hello",
|
||||
"target_language": "es",
|
||||
"preview": False,
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["rows_affected"] == 5
|
||||
assert data["corrections_submitted"] == 5
|
||||
|
||||
def test_bulk_replace_value_error_400(self):
|
||||
with patch(
|
||||
"src.plugins.translate.service.BulkFindReplaceService"
|
||||
) as MockService:
|
||||
MockService.preview.side_effect = ValueError("Run not found")
|
||||
|
||||
client = _make_client()
|
||||
resp = client.post("/api/translate/runs/run-1/bulk-replace", json={
|
||||
"find_pattern": "hola",
|
||||
"replacement_text": "hello",
|
||||
"target_language": "es",
|
||||
"preview": True,
|
||||
})
|
||||
assert resp.status_code == 400
|
||||
|
||||
def test_bulk_replace_generic_error_400(self):
|
||||
with patch(
|
||||
"src.plugins.translate.service.BulkFindReplaceService"
|
||||
) as MockService:
|
||||
MockService.apply.side_effect = RuntimeError("Bulk replace crashed")
|
||||
|
||||
client = _make_client()
|
||||
resp = client.post("/api/translate/runs/run-1/bulk-replace", json={
|
||||
"find_pattern": "hola",
|
||||
"replacement_text": "hello",
|
||||
"target_language": "es",
|
||||
"preview": False,
|
||||
})
|
||||
assert resp.status_code == 400
|
||||
|
||||
def test_bulk_replace_422_missing_fields(self):
|
||||
client = _make_client()
|
||||
resp = client.post("/api/translate/runs/run-1/bulk-replace", json={
|
||||
"target_language": "es",
|
||||
})
|
||||
assert resp.status_code == 422
|
||||
assert "find_pattern" in resp.text
|
||||
|
||||
def test_bulk_replace_invalid_regex_422(self):
|
||||
"""Pattern longer than 500 chars should be rejected."""
|
||||
client = _make_client()
|
||||
resp = client.post("/api/translate/runs/run-1/bulk-replace", json={
|
||||
"find_pattern": "x" * 501,
|
||||
"is_regex": True,
|
||||
"replacement_text": "y",
|
||||
"target_language": "es",
|
||||
"preview": True,
|
||||
})
|
||||
assert resp.status_code == 422
|
||||
# #endregion Test.Api.TranslateRunEditRoutes
|
||||
471
backend/tests/api/test_translate_run_list_routes.py
Normal file
471
backend/tests/api/test_translate_run_list_routes.py
Normal file
@@ -0,0 +1,471 @@
|
||||
# #region Test.Api.TranslateRunListRoutes [C:3] [TYPE Module] [SEMANTICS test,translate,runs,listing,detail,csv,api]
|
||||
# @BRIEF Tests for _run_list_routes.py — run listing, detail, CSV downloads.
|
||||
# @RELATION BINDS_TO -> [TranslateRunListRoutesModule]
|
||||
# @TEST_EDGE: list_runs_exception -> 400
|
||||
# @TEST_EDGE: run_detail_value_error -> 404
|
||||
# @TEST_EDGE: skipped_csv_exception -> 400
|
||||
# @TEST_EDGE: failed_csv_run_not_found -> 404
|
||||
# @TEST_EDGE: failed_csv_no_records -> 404
|
||||
# @TEST_EDGE: failed_csv_exception -> 400
|
||||
|
||||
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")
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import 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.translate._run_list_routes import router
|
||||
from src.dependencies import (
|
||||
get_config_manager, get_current_user, get_db,
|
||||
has_permission, require_feature,
|
||||
)
|
||||
from src.schemas.auth import User, RoleSchema
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(router)
|
||||
|
||||
mock_user = User(
|
||||
id="user-1", username="admin", email="admin@x.com",
|
||||
auth_source="LOCAL",
|
||||
created_at=__import__("datetime").datetime.now(),
|
||||
roles=[RoleSchema(id="r1", name="Admin", description="", permissions=[])],
|
||||
)
|
||||
|
||||
defaults = {
|
||||
get_current_user: lambda: mock_user,
|
||||
get_config_manager: lambda: MagicMock(),
|
||||
get_db: lambda: MagicMock(),
|
||||
has_permission: lambda *a, **kw: lambda: None,
|
||||
require_feature: lambda *a, **kw: lambda: None,
|
||||
}
|
||||
if overrides:
|
||||
defaults.update(overrides)
|
||||
for dep, mock_fn in defaults.items():
|
||||
app.dependency_overrides[dep] = mock_fn
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def _mock_run(
|
||||
id="run-1", job_id="job-1", status="COMPLETED",
|
||||
trigger_type="manual", started_at=None, completed_at=None,
|
||||
language_stats=None, config_snapshot=None,
|
||||
):
|
||||
from datetime import datetime, UTC
|
||||
|
||||
r = MagicMock()
|
||||
r.id = id
|
||||
r.job_id = job_id
|
||||
r.status = status
|
||||
r.trigger_type = trigger_type
|
||||
r.started_at = started_at or datetime(2026, 1, 1, 12, 0, 0, tzinfo=UTC)
|
||||
r.completed_at = completed_at or datetime(2026, 1, 1, 12, 30, 0, tzinfo=UTC)
|
||||
r.error_message = None
|
||||
r.total_records = 100
|
||||
r.successful_records = 95
|
||||
r.failed_records = 3
|
||||
r.skipped_records = 2
|
||||
r.cache_hits = 10
|
||||
r.insert_status = "completed"
|
||||
r.superset_execution_id = None
|
||||
r.config_hash = "abc123"
|
||||
r.dict_snapshot_hash = None
|
||||
r.created_by = "admin"
|
||||
r.created_at = datetime(2026, 1, 1, 12, 0, 0, tzinfo=UTC)
|
||||
r.language_stats = language_stats or []
|
||||
r.config_snapshot = config_snapshot
|
||||
return r
|
||||
|
||||
|
||||
def _mock_lang_stat(language_code="es", **kw):
|
||||
ls = MagicMock()
|
||||
ls.language_code = language_code
|
||||
ls.total_rows = kw.get("total_rows", 100)
|
||||
ls.translated_rows = kw.get("translated_rows", 90)
|
||||
ls.failed_rows = kw.get("failed_rows", 5)
|
||||
ls.skipped_rows = kw.get("skipped_rows", 5)
|
||||
ls.token_count = kw.get("token_count", 1000)
|
||||
ls.estimated_cost = kw.get("estimated_cost", 0.02)
|
||||
return ls
|
||||
|
||||
|
||||
def _mock_record(id="rec-1", run_id="run-1", status="SKIPPED"):
|
||||
rec = MagicMock()
|
||||
rec.id = id
|
||||
rec.run_id = run_id
|
||||
rec.status = status
|
||||
rec.source_sql = "SELECT * FROM table"
|
||||
rec.target_sql = "INSERT INTO table"
|
||||
rec.source_object_type = "table"
|
||||
rec.source_object_id = "42"
|
||||
rec.source_object_name = "my_table"
|
||||
rec.error_message = None
|
||||
rec.created_at = __import__("datetime").datetime(2026, 1, 1)
|
||||
rec.source_data = {"key": "value"}
|
||||
rec.languages = []
|
||||
return rec
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# List Runs
|
||||
# =============================================================================
|
||||
|
||||
class TestListRuns:
|
||||
"""GET /api/translate/runs"""
|
||||
|
||||
def test_list_runs_success(self):
|
||||
mock_db = MagicMock()
|
||||
run = _mock_run()
|
||||
# Build a query chain
|
||||
mock_query = MagicMock()
|
||||
mock_db.query.return_value = mock_query
|
||||
mock_query.filter.return_value = mock_query
|
||||
mock_query.order_by.return_value = mock_query
|
||||
mock_query.offset.return_value = mock_query
|
||||
mock_query.limit.return_value = mock_query
|
||||
mock_query.count.return_value = 1
|
||||
mock_query.all.return_value = [run]
|
||||
|
||||
from src.dependencies import get_db
|
||||
client = _make_client({get_db: lambda: mock_db})
|
||||
resp = client.get("/api/translate/runs")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["total"] == 1
|
||||
assert len(data["items"]) == 1
|
||||
assert data["items"][0]["id"] == "run-1"
|
||||
|
||||
def test_list_runs_with_filters(self):
|
||||
mock_db = MagicMock()
|
||||
run = _mock_run()
|
||||
mock_query = MagicMock()
|
||||
mock_db.query.return_value = mock_query
|
||||
mock_query.filter.return_value = mock_query
|
||||
mock_query.order_by.return_value = mock_query
|
||||
mock_query.offset.return_value = mock_query
|
||||
mock_query.limit.return_value = mock_query
|
||||
mock_query.count.return_value = 1
|
||||
mock_query.all.return_value = [run]
|
||||
|
||||
from src.dependencies import get_db
|
||||
client = _make_client({get_db: lambda: mock_db})
|
||||
resp = client.get(
|
||||
"/api/translate/runs"
|
||||
"?job_id=job-1&run_status=COMPLETED&trigger_type=manual"
|
||||
"&created_by=admin&date_from=2026-01-01T00:00:00"
|
||||
"&date_to=2026-01-31T00:00:00&page=1&page_size=10"
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["total"] == 1
|
||||
|
||||
def test_list_runs_with_language_stats(self):
|
||||
stats = [_mock_lang_stat("es"), _mock_lang_stat("fr")]
|
||||
run = _mock_run(language_stats=stats)
|
||||
mock_db = MagicMock()
|
||||
mock_query = MagicMock()
|
||||
mock_db.query.return_value = mock_query
|
||||
mock_query.filter.return_value = mock_query
|
||||
mock_query.order_by.return_value = mock_query
|
||||
mock_query.offset.return_value = mock_query
|
||||
mock_query.limit.return_value = mock_query
|
||||
mock_query.count.return_value = 1
|
||||
mock_query.all.return_value = [run]
|
||||
|
||||
from src.dependencies import get_db
|
||||
client = _make_client({get_db: lambda: mock_db})
|
||||
resp = client.get("/api/translate/runs")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
item = data["items"][0]
|
||||
assert "es" in item["language_stats"]
|
||||
assert "fr" in item["language_stats"]
|
||||
assert item["target_languages"] == ["es", "fr"]
|
||||
assert item["total_translated"] == 180
|
||||
|
||||
def test_list_runs_empty(self):
|
||||
mock_db = MagicMock()
|
||||
mock_query = MagicMock()
|
||||
mock_db.query.return_value = mock_query
|
||||
mock_query.filter.return_value = mock_query
|
||||
mock_query.order_by.return_value = mock_query
|
||||
mock_query.offset.return_value = mock_query
|
||||
mock_query.limit.return_value = mock_query
|
||||
mock_query.count.return_value = 0
|
||||
mock_query.all.return_value = []
|
||||
|
||||
from src.dependencies import get_db
|
||||
client = _make_client({get_db: lambda: mock_db})
|
||||
resp = client.get("/api/translate/runs")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["total"] == 0
|
||||
assert data["items"] == []
|
||||
|
||||
def test_list_runs_with_invalid_date_from(self):
|
||||
"""Invalid date_from is logged and ignored (debug path)."""
|
||||
mock_db = MagicMock()
|
||||
run = _mock_run()
|
||||
mock_query = MagicMock()
|
||||
mock_db.query.return_value = mock_query
|
||||
mock_query.filter.return_value = mock_query
|
||||
mock_query.order_by.return_value = mock_query
|
||||
mock_query.offset.return_value = mock_query
|
||||
mock_query.limit.return_value = mock_query
|
||||
mock_query.count.return_value = 1
|
||||
mock_query.all.return_value = [run]
|
||||
|
||||
from src.dependencies import get_db
|
||||
client = _make_client({get_db: lambda: mock_db})
|
||||
resp = client.get("/api/translate/runs?date_from=not-a-date")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["total"] == 1
|
||||
|
||||
def test_list_runs_with_invalid_date_to(self):
|
||||
"""Invalid date_to is logged and ignored (debug path)."""
|
||||
mock_db = MagicMock()
|
||||
run = _mock_run()
|
||||
mock_query = MagicMock()
|
||||
mock_db.query.return_value = mock_query
|
||||
mock_query.filter.return_value = mock_query
|
||||
mock_query.order_by.return_value = mock_query
|
||||
mock_query.offset.return_value = mock_query
|
||||
mock_query.limit.return_value = mock_query
|
||||
mock_query.count.return_value = 1
|
||||
mock_query.all.return_value = [run]
|
||||
|
||||
from src.dependencies import get_db
|
||||
client = _make_client({get_db: lambda: mock_db})
|
||||
resp = client.get("/api/translate/runs?date_to=not-a-date")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["total"] == 1
|
||||
|
||||
def test_list_runs_generic_error_400(self):
|
||||
mock_db = MagicMock()
|
||||
mock_db.query.side_effect = Exception("DB connection error")
|
||||
|
||||
from src.dependencies import get_db
|
||||
client = _make_client({get_db: lambda: mock_db})
|
||||
resp = client.get("/api/translate/runs")
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Run Detail
|
||||
# =============================================================================
|
||||
|
||||
class TestGetRunDetail:
|
||||
"""GET /api/translate/runs/{run_id}/detail"""
|
||||
|
||||
def test_detail_success(self):
|
||||
mock_orch = MagicMock()
|
||||
mock_orch.get_run_status.return_value = {"id": "run-1", "status": "COMPLETED"}
|
||||
mock_orch.get_run_records.return_value = {
|
||||
"items": [{"id": "rec-1"}], "total": 1, "page": 1, "page_size": 50,
|
||||
}
|
||||
|
||||
mock_event_log = MagicMock()
|
||||
mock_event_log.query_events.return_value = [{"event": "started"}]
|
||||
mock_event_log.get_run_event_summary.return_value = {"total_events": 1}
|
||||
|
||||
mock_run = _mock_run(config_snapshot={"key": "val"})
|
||||
|
||||
mock_db = MagicMock()
|
||||
mock_db.query.return_value.filter.return_value.first.return_value = mock_run
|
||||
|
||||
from src.dependencies import get_db
|
||||
|
||||
with (
|
||||
patch(
|
||||
"src.api.routes.translate._run_list_routes.TranslationOrchestrator",
|
||||
return_value=mock_orch,
|
||||
),
|
||||
patch(
|
||||
"src.api.routes.translate._run_list_routes.TranslationEventLog",
|
||||
return_value=mock_event_log,
|
||||
),
|
||||
):
|
||||
client = _make_client({get_db: lambda: mock_db})
|
||||
resp = client.get("/api/translate/runs/run-1/detail")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["id"] == "run-1"
|
||||
assert data["config_snapshot"] == {"key": "val"}
|
||||
assert data["events"] == [{"event": "started"}]
|
||||
assert data["event_invariants"] == {"total_events": 1}
|
||||
|
||||
def test_detail_not_found_404(self):
|
||||
mock_orch = MagicMock()
|
||||
mock_orch.get_run_status.side_effect = ValueError("Run not found")
|
||||
|
||||
with patch(
|
||||
"src.api.routes.translate._run_list_routes.TranslationOrchestrator",
|
||||
return_value=mock_orch,
|
||||
):
|
||||
client = _make_client()
|
||||
resp = client.get("/api/translate/runs/run-missing/detail")
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Download Skipped CSV
|
||||
# =============================================================================
|
||||
|
||||
class TestDownloadSkippedCsv:
|
||||
"""GET /api/translate/runs/{run_id}/skipped.csv"""
|
||||
|
||||
def test_skipped_csv_success(self):
|
||||
mock_db = MagicMock()
|
||||
mock_rec = _mock_record()
|
||||
mock_db.query.return_value.filter.return_value.all.return_value = [mock_rec]
|
||||
|
||||
from src.dependencies import get_db
|
||||
client = _make_client({get_db: lambda: mock_db})
|
||||
resp = client.get("/api/translate/runs/run-1/skipped.csv")
|
||||
assert resp.status_code == 200
|
||||
assert "text/csv" in resp.headers["content-type"]
|
||||
assert "skipped_run-1.csv" in resp.headers["content-disposition"]
|
||||
assert "rec-1" in resp.text
|
||||
|
||||
def test_skipped_csv_no_records(self):
|
||||
mock_db = MagicMock()
|
||||
mock_db.query.return_value.filter.return_value.all.return_value = []
|
||||
|
||||
from src.dependencies import get_db
|
||||
client = _make_client({get_db: lambda: mock_db})
|
||||
resp = client.get("/api/translate/runs/run-1/skipped.csv")
|
||||
assert resp.status_code == 200
|
||||
assert "source_object_name" in resp.text # headers still present
|
||||
|
||||
def test_skipped_csv_error_400(self):
|
||||
mock_db = MagicMock()
|
||||
mock_db.query.side_effect = Exception("DB error")
|
||||
|
||||
from src.dependencies import get_db
|
||||
client = _make_client({get_db: lambda: mock_db})
|
||||
resp = client.get("/api/translate/runs/run-1/skipped.csv")
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Download Failed CSV
|
||||
# =============================================================================
|
||||
|
||||
class TestDownloadFailedCsv:
|
||||
"""GET /api/translate/runs/{run_id}/failed.csv"""
|
||||
|
||||
def _setup_mock_db_for_failed_csv(self, mock_run, records, run_first=True):
|
||||
"""Helper: create a mock DB that returns run on first query and records on second."""
|
||||
mock_db = MagicMock()
|
||||
call_count = [0]
|
||||
|
||||
def query_side_effect(model):
|
||||
call_count[0] += 1
|
||||
if call_count[0] == 1:
|
||||
# TranslationRun query
|
||||
q = MagicMock()
|
||||
q.filter.return_value.first.return_value = mock_run
|
||||
return q
|
||||
else:
|
||||
# TranslationRecord query
|
||||
q = MagicMock()
|
||||
q.options.return_value.filter.return_value.order_by.return_value.all.return_value = records
|
||||
return q
|
||||
|
||||
mock_db.query.side_effect = query_side_effect
|
||||
return mock_db
|
||||
|
||||
def test_failed_csv_success(self):
|
||||
mock_run = _mock_run()
|
||||
mock_rec = _mock_record(id="rec-1", status="SUCCESS")
|
||||
mock_rec.languages = []
|
||||
|
||||
mock_db = self._setup_mock_db_for_failed_csv(mock_run, [mock_rec])
|
||||
|
||||
from src.dependencies import get_db
|
||||
client = _make_client({get_db: lambda: mock_db})
|
||||
resp = client.get("/api/translate/runs/run-1/failed.csv")
|
||||
assert resp.status_code == 200
|
||||
assert "text/csv" in resp.headers["content-type"]
|
||||
assert "failed_run-1.csv" in resp.headers["content-disposition"]
|
||||
|
||||
def test_failed_csv_run_not_found_404(self):
|
||||
mock_db = MagicMock()
|
||||
run_query = MagicMock()
|
||||
run_query.filter.return_value.first.return_value = None
|
||||
mock_db.query.return_value = run_query
|
||||
|
||||
from src.dependencies import get_db
|
||||
client = _make_client({get_db: lambda: mock_db})
|
||||
resp = client.get("/api/translate/runs/run-missing/failed.csv")
|
||||
assert resp.status_code == 404
|
||||
assert "Run not found" in resp.text
|
||||
|
||||
def test_failed_csv_no_records_404(self):
|
||||
mock_run = _mock_run()
|
||||
mock_db = self._setup_mock_db_for_failed_csv(mock_run, [])
|
||||
|
||||
from src.dependencies import get_db
|
||||
client = _make_client({get_db: lambda: mock_db})
|
||||
resp = client.get("/api/translate/runs/run-1/failed.csv")
|
||||
assert resp.status_code == 404
|
||||
assert "No successful records found" in resp.text
|
||||
|
||||
def test_failed_csv_with_languages(self):
|
||||
mock_run = _mock_run()
|
||||
|
||||
mock_lang = MagicMock()
|
||||
mock_lang.language_code = "es"
|
||||
mock_lang.final_value = "hola"
|
||||
mock_lang.translated_value = "hola"
|
||||
|
||||
mock_rec = _mock_record(id="rec-1", status="SUCCESS")
|
||||
mock_rec.languages = [mock_lang]
|
||||
mock_rec.source_data = {"col": "val"}
|
||||
|
||||
mock_db = self._setup_mock_db_for_failed_csv(mock_run, [mock_rec])
|
||||
|
||||
from src.dependencies import get_db
|
||||
client = _make_client({get_db: lambda: mock_db})
|
||||
resp = client.get("/api/translate/runs/run-1/failed.csv")
|
||||
assert resp.status_code == 200
|
||||
assert "hola" in resp.text
|
||||
assert "es" in resp.text
|
||||
|
||||
def test_failed_csv_unserializable_source_data(self):
|
||||
"""Covers json.dumps TypeError fallback in failed.csv (lines 322-323)."""
|
||||
mock_run = _mock_run()
|
||||
|
||||
mock_rec = _mock_record(id="rec-1", status="SUCCESS")
|
||||
mock_rec.languages = []
|
||||
mock_rec.source_data = object() # Not JSON-serializable → TypeError fallback
|
||||
|
||||
mock_db = self._setup_mock_db_for_failed_csv(mock_run, [mock_rec])
|
||||
|
||||
from src.dependencies import get_db
|
||||
client = _make_client({get_db: lambda: mock_db})
|
||||
resp = client.get("/api/translate/runs/run-1/failed.csv")
|
||||
assert resp.status_code == 200
|
||||
# Fallback: str(rec.source_data) should contain "object"
|
||||
assert "object" in resp.text
|
||||
|
||||
def test_failed_csv_generic_error_400(self):
|
||||
mock_db = MagicMock()
|
||||
mock_db.query.side_effect = Exception("Unexpected error")
|
||||
|
||||
from src.dependencies import get_db
|
||||
client = _make_client({get_db: lambda: mock_db})
|
||||
resp = client.get("/api/translate/runs/run-1/failed.csv")
|
||||
assert resp.status_code == 400
|
||||
# #endregion Test.Api.TranslateRunListRoutes
|
||||
261
backend/tests/api/test_translate_run_routes.py
Normal file
261
backend/tests/api/test_translate_run_routes.py
Normal file
@@ -0,0 +1,261 @@
|
||||
# #region Test.Api.TranslateRunRoutes [C:3] [TYPE Module] [SEMANTICS test,translate,runs,execution,api]
|
||||
# @BRIEF Tests for _run_routes.py — run execution, retry, cancel.
|
||||
# @RELATION BINDS_TO -> [TranslateRunRoutesModule]
|
||||
# @TEST_EDGE: run_value_error -> 400
|
||||
# @TEST_EDGE: run_generic_error -> 502
|
||||
# @TEST_EDGE: retry_value_error -> 400
|
||||
# @TEST_EDGE: retry_generic_error -> 502
|
||||
|
||||
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")
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
_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.translate._run_routes import router
|
||||
from src.dependencies import (
|
||||
get_config_manager, get_current_user, get_db,
|
||||
has_permission, require_feature,
|
||||
)
|
||||
from src.schemas.auth import User, RoleSchema
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(router)
|
||||
|
||||
mock_user = User(
|
||||
id="user-1", username="admin", email="admin@x.com",
|
||||
auth_source="LOCAL",
|
||||
created_at=__import__("datetime").datetime.now(),
|
||||
roles=[RoleSchema(id="r1", name="Admin", description="", permissions=[])],
|
||||
)
|
||||
|
||||
defaults = {
|
||||
get_current_user: lambda: mock_user,
|
||||
get_config_manager: lambda: MagicMock(),
|
||||
get_db: lambda: MagicMock(),
|
||||
has_permission: lambda *a, **kw: lambda: None,
|
||||
require_feature: lambda *a, **kw: lambda: None,
|
||||
}
|
||||
if overrides:
|
||||
defaults.update(overrides)
|
||||
for dep, mock_fn in defaults.items():
|
||||
app.dependency_overrides[dep] = mock_fn
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def _mock_run(id="run-1", job_id="job-1", status="PENDING"):
|
||||
r = MagicMock()
|
||||
r.id = id
|
||||
r.job_id = job_id
|
||||
r.status = status
|
||||
r.trigger_type = "manual"
|
||||
r.started_at = None
|
||||
r.completed_at = None
|
||||
r.error_message = None
|
||||
r.total_records = 0
|
||||
r.successful_records = 0
|
||||
r.failed_records = 0
|
||||
r.skipped_records = 0
|
||||
r.cache_hits = 0
|
||||
r.insert_status = None
|
||||
r.superset_execution_id = None
|
||||
r.config_snapshot = None
|
||||
r.key_hash = None
|
||||
r.config_hash = None
|
||||
r.dict_snapshot_hash = None
|
||||
r.created_by = "admin"
|
||||
r.created_at = __import__("datetime").datetime(2026, 1, 1)
|
||||
return r
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Run Translation
|
||||
# =============================================================================
|
||||
|
||||
class TestRunTranslation:
|
||||
"""POST /api/translate/jobs/{job_id}/run"""
|
||||
|
||||
def test_run_translation_success(self):
|
||||
mock_orch = MagicMock()
|
||||
mock_orch.start_run.return_value = _mock_run()
|
||||
|
||||
with (
|
||||
patch("src.api.routes.translate._run_routes.TranslationOrchestrator",
|
||||
return_value=mock_orch),
|
||||
patch("src.api.routes.translate._run_routes.asyncio.create_task"),
|
||||
):
|
||||
client = _make_client()
|
||||
resp = client.post("/api/translate/jobs/job-1/run")
|
||||
assert resp.status_code == 201
|
||||
data = resp.json()
|
||||
assert data["id"] == "run-1"
|
||||
assert data["status"] == "PENDING"
|
||||
|
||||
def test_run_translation_full(self):
|
||||
mock_orch = MagicMock()
|
||||
mock_orch.start_run.return_value = _mock_run()
|
||||
|
||||
with (
|
||||
patch("src.api.routes.translate._run_routes.TranslationOrchestrator",
|
||||
return_value=mock_orch),
|
||||
patch("src.api.routes.translate._run_routes.asyncio.create_task"),
|
||||
):
|
||||
client = _make_client()
|
||||
resp = client.post("/api/translate/jobs/job-1/run?full_translation=true")
|
||||
assert resp.status_code == 201
|
||||
# Verify start_run was called with full_translation=True
|
||||
mock_orch.start_run.assert_called_once_with(
|
||||
job_id="job-1", is_scheduled=False, full_translation=True
|
||||
)
|
||||
|
||||
def test_run_translation_value_error_400(self):
|
||||
mock_orch = MagicMock()
|
||||
mock_orch.start_run.side_effect = ValueError("Job not found")
|
||||
|
||||
with (
|
||||
patch("src.api.routes.translate._run_routes.TranslationOrchestrator",
|
||||
return_value=mock_orch),
|
||||
patch("src.api.routes.translate._run_routes.asyncio.create_task"),
|
||||
):
|
||||
client = _make_client()
|
||||
resp = client.post("/api/translate/jobs/job-missing/run")
|
||||
assert resp.status_code == 400
|
||||
assert "Job not found" in resp.text
|
||||
|
||||
def test_run_translation_generic_error_502(self):
|
||||
mock_orch = MagicMock()
|
||||
mock_orch.start_run.side_effect = RuntimeError("Unexpected error")
|
||||
|
||||
with (
|
||||
patch("src.api.routes.translate._run_routes.TranslationOrchestrator",
|
||||
return_value=mock_orch),
|
||||
patch("src.api.routes.translate._run_routes.asyncio.create_task"),
|
||||
):
|
||||
client = _make_client()
|
||||
resp = client.post("/api/translate/jobs/job-1/run")
|
||||
assert resp.status_code == 502
|
||||
assert "Run failed" in resp.text
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Retry Run
|
||||
# =============================================================================
|
||||
|
||||
class TestRetryRun:
|
||||
"""POST /api/translate/runs/{run_id}/retry"""
|
||||
|
||||
def test_retry_success(self):
|
||||
mock_orch = MagicMock()
|
||||
mock_orch.retry_failed_batches = AsyncMock(return_value=_mock_run(status="RUNNING"))
|
||||
|
||||
with patch("src.api.routes.translate._run_routes.TranslationOrchestrator",
|
||||
return_value=mock_orch):
|
||||
client = _make_client()
|
||||
resp = client.post("/api/translate/runs/run-1/retry")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["status"] == "RUNNING"
|
||||
|
||||
def test_retry_value_error_400(self):
|
||||
mock_orch = MagicMock()
|
||||
mock_orch.retry_failed_batches.side_effect = ValueError("Run not retriable")
|
||||
|
||||
with patch("src.api.routes.translate._run_routes.TranslationOrchestrator",
|
||||
return_value=mock_orch):
|
||||
client = _make_client()
|
||||
resp = client.post("/api/translate/runs/run-1/retry")
|
||||
assert resp.status_code == 400
|
||||
|
||||
def test_retry_generic_error_502(self):
|
||||
mock_orch = MagicMock()
|
||||
mock_orch.retry_failed_batches.side_effect = RuntimeError("Internal error")
|
||||
|
||||
with patch("src.api.routes.translate._run_routes.TranslationOrchestrator",
|
||||
return_value=mock_orch):
|
||||
client = _make_client()
|
||||
resp = client.post("/api/translate/runs/run-1/retry")
|
||||
assert resp.status_code == 502
|
||||
assert "Retry failed" in resp.text
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Retry Insert
|
||||
# =============================================================================
|
||||
|
||||
class TestRetryInsert:
|
||||
"""POST /api/translate/runs/{run_id}/retry-insert"""
|
||||
|
||||
def test_retry_insert_success(self):
|
||||
mock_orch = MagicMock()
|
||||
mock_orch.retry_insert = AsyncMock(return_value=_mock_run(status="COMPLETED"))
|
||||
|
||||
with patch("src.api.routes.translate._run_routes.TranslationOrchestrator",
|
||||
return_value=mock_orch):
|
||||
client = _make_client()
|
||||
resp = client.post("/api/translate/runs/run-1/retry-insert")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["status"] == "COMPLETED"
|
||||
|
||||
def test_retry_insert_value_error_400(self):
|
||||
mock_orch = MagicMock()
|
||||
mock_orch.retry_insert.side_effect = ValueError("Insert not needed")
|
||||
|
||||
with patch("src.api.routes.translate._run_routes.TranslationOrchestrator",
|
||||
return_value=mock_orch):
|
||||
client = _make_client()
|
||||
resp = client.post("/api/translate/runs/run-1/retry-insert")
|
||||
assert resp.status_code == 400
|
||||
|
||||
def test_retry_insert_generic_error_502(self):
|
||||
mock_orch = MagicMock()
|
||||
mock_orch.retry_insert.side_effect = RuntimeError("Insert failed")
|
||||
|
||||
with patch("src.api.routes.translate._run_routes.TranslationOrchestrator",
|
||||
return_value=mock_orch):
|
||||
client = _make_client()
|
||||
resp = client.post("/api/translate/runs/run-1/retry-insert")
|
||||
assert resp.status_code == 502
|
||||
assert "Retry insert failed" in resp.text
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Cancel Run
|
||||
# =============================================================================
|
||||
|
||||
class TestCancelRun:
|
||||
"""POST /api/translate/runs/{run_id}/cancel"""
|
||||
|
||||
def test_cancel_success(self):
|
||||
mock_orch = MagicMock()
|
||||
mock_orch.cancel_run.return_value = _mock_run(status="CANCELLED")
|
||||
|
||||
with patch("src.api.routes.translate._run_routes.TranslationOrchestrator",
|
||||
return_value=mock_orch):
|
||||
client = _make_client()
|
||||
resp = client.post("/api/translate/runs/run-1/cancel")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["status"] == "CANCELLED"
|
||||
|
||||
def test_cancel_value_error_400(self):
|
||||
mock_orch = MagicMock()
|
||||
mock_orch.cancel_run.side_effect = ValueError("Run not found")
|
||||
|
||||
with patch("src.api.routes.translate._run_routes.TranslationOrchestrator",
|
||||
return_value=mock_orch):
|
||||
client = _make_client()
|
||||
resp = client.post("/api/translate/runs/run-missing/cancel")
|
||||
assert resp.status_code == 400
|
||||
# #endregion Test.Api.TranslateRunRoutes
|
||||
389
backend/tests/api/test_translate_schedule_routes.py
Normal file
389
backend/tests/api/test_translate_schedule_routes.py
Normal file
@@ -0,0 +1,389 @@
|
||||
# #region Test.Api.TranslateScheduleRoutes [C:3] [TYPE Module] [SEMANTICS test,translate,schedule,api]
|
||||
# @BRIEF Tests for _schedule_routes.py — schedule CRUD, enable/disable, next executions.
|
||||
# @RELATION BINDS_TO -> [TranslateScheduleRoutesModule]
|
||||
# @TEST_EDGE: schedule_not_found -> 404
|
||||
# @TEST_EDGE: schedule_set_value_error -> 400
|
||||
# @TEST_EDGE: schedule_enable_not_found -> 404
|
||||
# @TEST_EDGE: schedule_disable_not_found -> 404
|
||||
# @TEST_EDGE: schedule_delete_not_found -> 404
|
||||
|
||||
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")
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import 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.translate._schedule_routes import router
|
||||
from src.dependencies import (
|
||||
get_config_manager, get_current_user, get_db,
|
||||
has_permission, require_feature,
|
||||
)
|
||||
from src.schemas.auth import User, RoleSchema
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(router)
|
||||
|
||||
mock_user = User(
|
||||
id="user-1", username="admin", email="admin@x.com",
|
||||
auth_source="LOCAL",
|
||||
created_at=__import__("datetime").datetime.now(),
|
||||
roles=[RoleSchema(id="r1", name="Admin", description="", permissions=[])],
|
||||
)
|
||||
|
||||
defaults = {
|
||||
get_current_user: lambda: mock_user,
|
||||
get_config_manager: lambda: MagicMock(),
|
||||
get_db: lambda: MagicMock(),
|
||||
has_permission: lambda *a, **kw: lambda: None,
|
||||
require_feature: lambda *a, **kw: lambda: None,
|
||||
}
|
||||
if overrides:
|
||||
defaults.update(overrides)
|
||||
for dep, mock_fn in defaults.items():
|
||||
app.dependency_overrides[dep] = mock_fn
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def _mock_schedule(
|
||||
id="sched-1", job_id="job-1",
|
||||
cron_expression="0 2 * * *", timezone="UTC",
|
||||
is_active=True, execution_mode="full",
|
||||
last_run_at=None, next_run_at=None,
|
||||
created_by="admin",
|
||||
created_at=None, updated_at=None,
|
||||
):
|
||||
from datetime import datetime, UTC
|
||||
s = MagicMock()
|
||||
s.id = id
|
||||
s.job_id = job_id
|
||||
s.cron_expression = cron_expression
|
||||
s.timezone = timezone
|
||||
s.is_active = is_active
|
||||
s.execution_mode = execution_mode
|
||||
s.last_run_at = last_run_at or datetime(2026, 1, 1, 2, 0, 0, tzinfo=UTC)
|
||||
s.next_run_at = next_run_at or datetime(2026, 1, 2, 2, 0, 0, tzinfo=UTC)
|
||||
s.created_by = created_by
|
||||
s.created_at = created_at or datetime(2026, 1, 1, 0, 0, 0, tzinfo=UTC)
|
||||
s.updated_at = updated_at
|
||||
return s
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Get Schedule
|
||||
# =============================================================================
|
||||
|
||||
class TestGetSchedule:
|
||||
"""GET /api/translate/jobs/{job_id}/schedule"""
|
||||
|
||||
def test_get_schedule_success(self):
|
||||
mock_scheduler = MagicMock()
|
||||
mock_scheduler.get_schedule.return_value = _mock_schedule()
|
||||
|
||||
with patch(
|
||||
"src.api.routes.translate._schedule_routes.TranslationScheduler",
|
||||
return_value=mock_scheduler,
|
||||
):
|
||||
client = _make_client()
|
||||
resp = client.get("/api/translate/jobs/job-1/schedule")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["id"] == "sched-1"
|
||||
assert data["cron_expression"] == "0 2 * * *"
|
||||
assert data["is_active"] is True
|
||||
|
||||
def test_get_schedule_not_found_404(self):
|
||||
mock_scheduler = MagicMock()
|
||||
mock_scheduler.get_schedule.side_effect = ValueError("Schedule not found")
|
||||
|
||||
with patch(
|
||||
"src.api.routes.translate._schedule_routes.TranslationScheduler",
|
||||
return_value=mock_scheduler,
|
||||
):
|
||||
client = _make_client()
|
||||
resp = client.get("/api/translate/jobs/job-missing/schedule")
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Set Schedule
|
||||
# =============================================================================
|
||||
|
||||
class TestSetSchedule:
|
||||
"""PUT /api/translate/jobs/{job_id}/schedule"""
|
||||
|
||||
def test_set_schedule_create(self):
|
||||
mock_scheduler = MagicMock()
|
||||
mock_scheduler.create_schedule.return_value = _mock_schedule()
|
||||
|
||||
mock_db = MagicMock()
|
||||
mock_db.query.return_value.filter.return_value.first.return_value = None
|
||||
|
||||
mock_sched_svc = MagicMock()
|
||||
|
||||
from src.dependencies import get_db
|
||||
|
||||
with (
|
||||
patch(
|
||||
"src.api.routes.translate._schedule_routes.TranslationScheduler",
|
||||
return_value=mock_scheduler,
|
||||
),
|
||||
patch(
|
||||
"src.dependencies.get_scheduler_service",
|
||||
return_value=mock_sched_svc,
|
||||
),
|
||||
):
|
||||
client = _make_client({get_db: lambda: mock_db})
|
||||
resp = client.put("/api/translate/jobs/job-1/schedule", json={
|
||||
"cron_expression": "0 2 * * *",
|
||||
"timezone": "UTC",
|
||||
"is_active": True,
|
||||
"execution_mode": "full",
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["cron_expression"] == "0 2 * * *"
|
||||
mock_scheduler.create_schedule.assert_called_once()
|
||||
mock_sched_svc.add_translation_job.assert_called_once()
|
||||
|
||||
def test_set_schedule_update(self):
|
||||
mock_scheduler = MagicMock()
|
||||
mock_scheduler.update_schedule.return_value = _mock_schedule()
|
||||
|
||||
mock_existing = MagicMock()
|
||||
mock_existing.id = "sched-1"
|
||||
mock_db = MagicMock()
|
||||
mock_db.query.return_value.filter.return_value.first.return_value = mock_existing
|
||||
|
||||
mock_sched_svc = MagicMock()
|
||||
|
||||
from src.dependencies import get_db
|
||||
|
||||
with (
|
||||
patch(
|
||||
"src.api.routes.translate._schedule_routes.TranslationScheduler",
|
||||
return_value=mock_scheduler,
|
||||
),
|
||||
patch(
|
||||
"src.dependencies.get_scheduler_service",
|
||||
return_value=mock_sched_svc,
|
||||
),
|
||||
):
|
||||
client = _make_client({get_db: lambda: mock_db})
|
||||
resp = client.put("/api/translate/jobs/job-1/schedule", json={
|
||||
"cron_expression": "0 3 * * *",
|
||||
"timezone": "Europe/Moscow",
|
||||
"is_active": False,
|
||||
"execution_mode": "new_key_only",
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
mock_scheduler.update_schedule.assert_called_once()
|
||||
mock_sched_svc.add_translation_job.assert_called_once()
|
||||
|
||||
def test_set_schedule_value_error_400(self):
|
||||
mock_scheduler = MagicMock()
|
||||
mock_scheduler.create_schedule.side_effect = ValueError("Invalid cron")
|
||||
|
||||
mock_db = MagicMock()
|
||||
mock_db.query.return_value.filter.return_value.first.return_value = None
|
||||
|
||||
from src.dependencies import get_db
|
||||
|
||||
with patch(
|
||||
"src.api.routes.translate._schedule_routes.TranslationScheduler",
|
||||
return_value=mock_scheduler,
|
||||
):
|
||||
client = _make_client({get_db: lambda: mock_db})
|
||||
resp = client.put("/api/translate/jobs/job-1/schedule", json={
|
||||
"cron_expression": "bad cron",
|
||||
"timezone": "UTC",
|
||||
})
|
||||
assert resp.status_code == 400
|
||||
|
||||
def test_set_schedule_422_missing_cron(self):
|
||||
client = _make_client()
|
||||
resp = client.put("/api/translate/jobs/job-1/schedule", json={
|
||||
"timezone": "UTC",
|
||||
})
|
||||
assert resp.status_code == 422
|
||||
assert "cron_expression" in resp.text
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Enable Schedule
|
||||
# =============================================================================
|
||||
|
||||
class TestEnableSchedule:
|
||||
"""POST /api/translate/jobs/{job_id}/schedule/enable"""
|
||||
|
||||
def test_enable_success(self):
|
||||
mock_scheduler = MagicMock()
|
||||
mock_scheduler.set_schedule_active.return_value = _mock_schedule(is_active=True)
|
||||
mock_sched_svc = MagicMock()
|
||||
|
||||
with (
|
||||
patch(
|
||||
"src.api.routes.translate._schedule_routes.TranslationScheduler",
|
||||
return_value=mock_scheduler,
|
||||
),
|
||||
patch(
|
||||
"src.dependencies.get_scheduler_service",
|
||||
return_value=mock_sched_svc,
|
||||
),
|
||||
):
|
||||
client = _make_client()
|
||||
resp = client.post("/api/translate/jobs/job-1/schedule/enable")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["status"] == "enabled"
|
||||
assert data["job_id"] == "job-1"
|
||||
|
||||
def test_enable_not_found_404(self):
|
||||
mock_scheduler = MagicMock()
|
||||
mock_scheduler.set_schedule_active.side_effect = ValueError("Schedule not found")
|
||||
|
||||
with patch(
|
||||
"src.api.routes.translate._schedule_routes.TranslationScheduler",
|
||||
return_value=mock_scheduler,
|
||||
):
|
||||
client = _make_client()
|
||||
resp = client.post("/api/translate/jobs/job-missing/schedule/enable")
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Disable Schedule
|
||||
# =============================================================================
|
||||
|
||||
class TestDisableSchedule:
|
||||
"""POST /api/translate/jobs/{job_id}/schedule/disable"""
|
||||
|
||||
def test_disable_success(self):
|
||||
mock_scheduler = MagicMock()
|
||||
mock_scheduler.set_schedule_active.return_value = _mock_schedule(is_active=False)
|
||||
mock_sched_svc = MagicMock()
|
||||
|
||||
with (
|
||||
patch(
|
||||
"src.api.routes.translate._schedule_routes.TranslationScheduler",
|
||||
return_value=mock_scheduler,
|
||||
),
|
||||
patch(
|
||||
"src.dependencies.get_scheduler_service",
|
||||
return_value=mock_sched_svc,
|
||||
),
|
||||
):
|
||||
client = _make_client()
|
||||
resp = client.post("/api/translate/jobs/job-1/schedule/disable")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["status"] == "disabled"
|
||||
|
||||
def test_disable_not_found_404(self):
|
||||
mock_scheduler = MagicMock()
|
||||
mock_scheduler.set_schedule_active.side_effect = ValueError("Schedule not found")
|
||||
|
||||
with patch(
|
||||
"src.api.routes.translate._schedule_routes.TranslationScheduler",
|
||||
return_value=mock_scheduler,
|
||||
):
|
||||
client = _make_client()
|
||||
resp = client.post("/api/translate/jobs/job-missing/schedule/disable")
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Delete Schedule
|
||||
# =============================================================================
|
||||
|
||||
class TestDeleteSchedule:
|
||||
"""DELETE /api/translate/jobs/{job_id}/schedule"""
|
||||
|
||||
def test_delete_success(self):
|
||||
mock_scheduler = MagicMock()
|
||||
mock_scheduler.delete_schedule.return_value = None
|
||||
mock_sched_svc = MagicMock()
|
||||
|
||||
with (
|
||||
patch(
|
||||
"src.api.routes.translate._schedule_routes.TranslationScheduler",
|
||||
return_value=mock_scheduler,
|
||||
),
|
||||
patch(
|
||||
"src.dependencies.get_scheduler_service",
|
||||
return_value=mock_sched_svc,
|
||||
),
|
||||
):
|
||||
client = _make_client()
|
||||
resp = client.delete("/api/translate/jobs/job-1/schedule")
|
||||
assert resp.status_code == 204
|
||||
|
||||
def test_delete_not_found_404(self):
|
||||
mock_scheduler = MagicMock()
|
||||
mock_scheduler.delete_schedule.side_effect = ValueError("Schedule not found")
|
||||
|
||||
with patch(
|
||||
"src.api.routes.translate._schedule_routes.TranslationScheduler",
|
||||
return_value=mock_scheduler,
|
||||
):
|
||||
client = _make_client()
|
||||
resp = client.delete("/api/translate/jobs/job-missing/schedule")
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Get Next Executions
|
||||
# =============================================================================
|
||||
|
||||
class TestGetNextExecutions:
|
||||
"""GET /api/translate/jobs/{job_id}/schedule/next-executions"""
|
||||
|
||||
def test_next_executions_success(self):
|
||||
sched = _mock_schedule(cron_expression="0 2 * * *", timezone="UTC")
|
||||
|
||||
mock_scheduler = MagicMock()
|
||||
mock_scheduler.get_schedule.return_value = sched
|
||||
|
||||
# TranslationScheduler is replaced by a MagicMock class.
|
||||
# `get_next_executions` is a static method called on the class itself, not on instance.
|
||||
# Set it on the mock class so TranslationScheduler.get_next_executions() works.
|
||||
mock_sched_class = MagicMock(return_value=mock_scheduler)
|
||||
mock_sched_class.get_next_executions.return_value = [
|
||||
"2026-01-02T02:00:00Z", "2026-01-03T02:00:00Z",
|
||||
]
|
||||
|
||||
with patch(
|
||||
"src.api.routes.translate._schedule_routes.TranslationScheduler",
|
||||
mock_sched_class,
|
||||
):
|
||||
client = _make_client()
|
||||
resp = client.get("/api/translate/jobs/job-1/schedule/next-executions?n=2")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["cron_expression"] == "0 2 * * *"
|
||||
assert len(data["next_executions"]) == 2
|
||||
|
||||
def test_next_executions_not_found_404(self):
|
||||
mock_scheduler = MagicMock()
|
||||
mock_scheduler.get_schedule.side_effect = ValueError("Schedule not found")
|
||||
|
||||
with patch(
|
||||
"src.api.routes.translate._schedule_routes.TranslationScheduler",
|
||||
return_value=mock_scheduler,
|
||||
):
|
||||
client = _make_client()
|
||||
resp = client.get("/api/translate/jobs/job-missing/schedule/next-executions")
|
||||
assert resp.status_code == 404
|
||||
# #endregion Test.Api.TranslateScheduleRoutes
|
||||
472
backend/tests/api/test_validation_tasks_comprehensive.py
Normal file
472
backend/tests/api/test_validation_tasks_comprehensive.py
Normal file
@@ -0,0 +1,472 @@
|
||||
# #region Test.Api.ValidationTasks.Comprehensive [C:3] [TYPE Module] [SEMANTICS test,validation,tasks,comprehensive]
|
||||
# @BRIEF Comprehensive tests for validation task API — CRUD, runs, parse-url, redirect.
|
||||
# @RELATION BINDS_TO -> [ValidationTasksRoutes]
|
||||
|
||||
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)
|
||||
|
||||
|
||||
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)
|
||||
|
||||
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)
|
||||
|
||||
|
||||
# ── list_tasks ──
|
||||
|
||||
class TestListTasks:
|
||||
"""GET /validation-tasks"""
|
||||
|
||||
def test_success(self):
|
||||
mock_svc = MagicMock()
|
||||
mock_svc.list_tasks.return_value = (1, [{"id": "task-1", "name": "Test"}])
|
||||
|
||||
from src.api.routes.validation_tasks import _get_task_service
|
||||
client = _make_client({_get_task_service: lambda: mock_svc})
|
||||
resp = client.get("/validation-tasks")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["total"] == 1
|
||||
assert len(data["items"]) == 1
|
||||
|
||||
def test_with_filters(self):
|
||||
mock_svc = MagicMock()
|
||||
mock_svc.list_tasks.return_value = (0, [])
|
||||
|
||||
from src.api.routes.validation_tasks import _get_task_service
|
||||
client = _make_client({_get_task_service: lambda: mock_svc})
|
||||
resp = client.get("/validation-tasks?is_active=true&environment_id=env-1&search=test")
|
||||
assert resp.status_code == 200
|
||||
|
||||
def test_value_error(self):
|
||||
mock_svc = MagicMock()
|
||||
mock_svc.list_tasks.side_effect = ValueError("Invalid filter")
|
||||
|
||||
from src.api.routes.validation_tasks import _get_task_service
|
||||
client = _make_client({_get_task_service: lambda: mock_svc})
|
||||
resp = client.get("/validation-tasks")
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
# ── create_task ──
|
||||
|
||||
class TestCreateTask:
|
||||
"""POST /validation-tasks"""
|
||||
|
||||
CREATE_PAYLOAD = {
|
||||
"name": "New Task",
|
||||
"environment_id": "env-1",
|
||||
"dashboard_ids": ["dash-1"],
|
||||
"provider_id": "provider-1",
|
||||
}
|
||||
|
||||
def test_success(self):
|
||||
mock_svc = MagicMock()
|
||||
mock_svc.create_task.return_value = {"id": "task-new", "name": "New Task"}
|
||||
mock_sched = 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_sched,
|
||||
})
|
||||
resp = client.post("/validation-tasks", json=self.CREATE_PAYLOAD)
|
||||
assert resp.status_code == 201
|
||||
mock_sched.reload_validation_policy.assert_called_once()
|
||||
|
||||
def test_value_error(self):
|
||||
mock_svc = MagicMock()
|
||||
mock_svc.create_task.side_effect = ValueError("Invalid provider")
|
||||
|
||||
from src.api.routes.validation_tasks import _get_task_service
|
||||
client = _make_client({_get_task_service: lambda: mock_svc})
|
||||
resp = client.post("/validation-tasks", json=self.CREATE_PAYLOAD)
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
# ── get_task ──
|
||||
|
||||
class TestGetTask:
|
||||
"""GET /validation-tasks/{policy_id}"""
|
||||
|
||||
def test_success(self):
|
||||
mock_svc = MagicMock()
|
||||
mock_svc.get_task.return_value = {"id": "task-1", "name": "Test Task"}
|
||||
|
||||
from src.api.routes.validation_tasks import _get_task_service
|
||||
client = _make_client({_get_task_service: lambda: mock_svc})
|
||||
resp = client.get("/validation-tasks/task-1")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["id"] == "task-1"
|
||||
|
||||
def test_not_found(self):
|
||||
mock_svc = MagicMock()
|
||||
mock_svc.get_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.get("/validation-tasks/unknown")
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
# ── update_task ──
|
||||
|
||||
class TestUpdateTask:
|
||||
"""PUT /validation-tasks/{policy_id}"""
|
||||
|
||||
UPDATE_PAYLOAD = {"name": "Updated Task"}
|
||||
|
||||
def test_success(self):
|
||||
mock_svc = MagicMock()
|
||||
mock_svc.update_task.return_value = {"id": "task-1", "name": "Updated Task"}
|
||||
mock_sched = 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_sched,
|
||||
})
|
||||
resp = client.put("/validation-tasks/task-1", json=self.UPDATE_PAYLOAD)
|
||||
assert resp.status_code == 200
|
||||
mock_sched.reload_validation_policy.assert_called_once()
|
||||
|
||||
def test_not_found(self):
|
||||
mock_svc = MagicMock()
|
||||
mock_svc.update_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.put("/validation-tasks/unknown", json=self.UPDATE_PAYLOAD)
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
# ── toggle_task_status ──
|
||||
|
||||
class TestToggleTaskStatus:
|
||||
"""PATCH /validation-tasks/{policy_id}/status"""
|
||||
|
||||
def test_activate(self):
|
||||
mock_svc = MagicMock()
|
||||
mock_svc.update_task.return_value = {"id": "task-1", "is_active": True}
|
||||
mock_sched = 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_sched,
|
||||
})
|
||||
resp = client.patch("/validation-tasks/task-1/status?is_active=true")
|
||||
assert resp.status_code == 200
|
||||
mock_sched.reload_validation_policy.assert_called_once()
|
||||
|
||||
def test_deactivate(self):
|
||||
mock_svc = MagicMock()
|
||||
mock_svc.update_task.return_value = {"id": "task-1", "is_active": False}
|
||||
mock_sched = 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_sched,
|
||||
})
|
||||
resp = client.patch("/validation-tasks/task-1/status?is_active=false")
|
||||
assert resp.status_code == 200
|
||||
mock_sched.remove_validation_job.assert_called_once()
|
||||
|
||||
def test_not_found(self):
|
||||
mock_svc = MagicMock()
|
||||
mock_svc.update_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.patch("/validation-tasks/unknown/status?is_active=true")
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
# ── trigger_run ──
|
||||
|
||||
class TestTriggerRun:
|
||||
"""POST /validation-tasks/{policy_id}/run"""
|
||||
|
||||
def test_success(self):
|
||||
mock_svc = MagicMock()
|
||||
mock_svc.trigger_run = AsyncMock(return_value={"task_id": "t-1", "run_id": "run-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"] == "task-1"
|
||||
assert data["status"] == "PENDING"
|
||||
|
||||
def test_value_error(self):
|
||||
mock_svc = MagicMock()
|
||||
mock_svc.trigger_run = AsyncMock(side_effect=ValueError("Cannot trigger"))
|
||||
|
||||
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 == 422
|
||||
|
||||
|
||||
# ── list_all_runs ──
|
||||
|
||||
class TestListAllRuns:
|
||||
"""GET /validation-tasks/runs/all"""
|
||||
|
||||
def test_success(self):
|
||||
mock_svc = MagicMock()
|
||||
mock_svc.list_all_runs.return_value = (1, [{"id": "run-1"}])
|
||||
|
||||
from src.api.routes.validation_tasks import _get_task_service
|
||||
client = _make_client({_get_task_service: lambda: mock_svc})
|
||||
resp = client.get("/validation-tasks/runs/all")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["total"] == 1
|
||||
|
||||
def test_with_filters(self):
|
||||
mock_svc = MagicMock()
|
||||
mock_svc.list_all_runs.return_value = (0, [])
|
||||
|
||||
from src.api.routes.validation_tasks import _get_task_service
|
||||
client = _make_client({_get_task_service: lambda: mock_svc})
|
||||
resp = client.get("/validation-tasks/runs/all?status=RUNNING&environment_id=env-1&dashboard_id=dash-1")
|
||||
assert resp.status_code == 200
|
||||
|
||||
def test_value_error(self):
|
||||
mock_svc = MagicMock()
|
||||
mock_svc.list_all_runs.side_effect = ValueError("Invalid filter")
|
||||
|
||||
from src.api.routes.validation_tasks import _get_task_service
|
||||
client = _make_client({_get_task_service: lambda: mock_svc})
|
||||
resp = client.get("/validation-tasks/runs/all")
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
# ── list_runs ──
|
||||
|
||||
class TestListRuns:
|
||||
"""GET /validation-tasks/{policy_id}/runs"""
|
||||
|
||||
def test_success(self):
|
||||
mock_svc = MagicMock()
|
||||
mock_svc.get_run_history.return_value = (1, [{
|
||||
"id": "run-1", "policy_id": "task-1", "status": "SUCCESS",
|
||||
"trigger": "manual", "dashboard_count": 2, "pass_count": 1,
|
||||
"warn_count": 0, "fail_count": 0, "unknown_count": 0,
|
||||
}])
|
||||
|
||||
from src.api.routes.validation_tasks import _get_task_service
|
||||
client = _make_client({_get_task_service: lambda: mock_svc})
|
||||
resp = client.get("/validation-tasks/task-1/runs")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["total"] == 1
|
||||
assert data["items"][0]["status"] == "SUCCESS"
|
||||
|
||||
def test_value_error(self):
|
||||
mock_svc = MagicMock()
|
||||
mock_svc.get_run_history.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.get("/validation-tasks/unknown/runs")
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
# ── get_run_detail ──
|
||||
|
||||
class TestGetRunDetail:
|
||||
"""GET /validation-tasks/{policy_id}/runs/{run_id}"""
|
||||
|
||||
def test_success(self):
|
||||
mock_svc = MagicMock()
|
||||
mock_svc.get_run_detail.return_value = {
|
||||
"run": {"id": "run-1", "policy_id": "task-1", "status": "SUCCESS",
|
||||
"trigger": "manual", "dashboard_count": 2, "pass_count": 1,
|
||||
"warn_count": 0, "fail_count": 0, "unknown_count": 0},
|
||||
"records": [{"dashboard_id": "dash-1", "status": "PASS"}],
|
||||
}
|
||||
|
||||
from src.api.routes.validation_tasks import _get_task_service
|
||||
client = _make_client({_get_task_service: lambda: mock_svc})
|
||||
resp = client.get("/validation-tasks/task-1/runs/run-1")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["run"]["status"] == "SUCCESS"
|
||||
assert len(data["records"]) == 1
|
||||
|
||||
def test_not_found(self):
|
||||
mock_svc = MagicMock()
|
||||
mock_svc.get_run_detail.side_effect = ValueError("Run not found")
|
||||
|
||||
from src.api.routes.validation_tasks import _get_task_service
|
||||
client = _make_client({_get_task_service: lambda: mock_svc})
|
||||
resp = client.get("/validation-tasks/task-1/runs/unknown")
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
# ── parse_superset_url ──
|
||||
|
||||
class TestParseSupersetUrl:
|
||||
"""POST /validation-tasks/parse-url"""
|
||||
|
||||
PARSE_PAYLOAD = {
|
||||
"url": "https://superset.example.com/superset/dashboard/1/",
|
||||
"environment_id": "env-1",
|
||||
}
|
||||
|
||||
def test_success_with_dashboard_id(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 = {}
|
||||
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(return_value={"dashboard_title": "My Dashboard"})
|
||||
mock_extractor.client = mock_client
|
||||
|
||||
from src.dependencies import get_config_manager
|
||||
with patch("src.api.routes.validation_tasks.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"] == "My Dashboard"
|
||||
|
||||
def test_success_with_chart_id(self):
|
||||
mock_config = MagicMock()
|
||||
mock_config.get_environment.return_value = MagicMock()
|
||||
|
||||
mock_extractor = MagicMock()
|
||||
mock_parsed = MagicMock()
|
||||
mock_parsed.dashboard_id = None
|
||||
mock_parsed.chart_id = 42
|
||||
mock_parsed.query_state = {}
|
||||
mock_parsed.partial_recovery = None
|
||||
mock_parsed.unresolved_references = None
|
||||
mock_extractor.parse_superset_link = AsyncMock(return_value=mock_parsed)
|
||||
mock_extractor.client = MagicMock()
|
||||
|
||||
from src.dependencies import get_config_manager
|
||||
with patch("src.api.routes.validation_tasks.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
|
||||
assert resp.json()["title"] == "chart:42"
|
||||
|
||||
def test_environment_not_found(self):
|
||||
mock_config = MagicMock()
|
||||
mock_config.get_environment.return_value = None
|
||||
|
||||
from src.dependencies import get_config_manager
|
||||
client = _make_client({get_config_manager: lambda: mock_config})
|
||||
resp = client.post("/validation-tasks/parse-url", json=self.PARSE_PAYLOAD)
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_value_error(self):
|
||||
mock_config = MagicMock()
|
||||
mock_config.get_environment.return_value = MagicMock()
|
||||
|
||||
mock_extractor = MagicMock()
|
||||
mock_extractor.parse_superset_link = AsyncMock(side_effect=ValueError("Invalid URL"))
|
||||
|
||||
from src.dependencies import get_config_manager
|
||||
with patch("src.api.routes.validation_tasks.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 == 422
|
||||
|
||||
def test_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("Network error"))
|
||||
|
||||
from src.dependencies import get_config_manager
|
||||
with patch("src.api.routes.validation_tasks.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
|
||||
|
||||
|
||||
# ── legacy_llm_report_redirect ──
|
||||
|
||||
class TestLegacyLlmReportRedirect:
|
||||
"""GET /validation-tasks/reports/llm/{task_id}"""
|
||||
|
||||
def test_found(self):
|
||||
mock_db = MagicMock()
|
||||
mock_run = MagicMock()
|
||||
mock_run.task_id = "old-task"
|
||||
mock_run.policy_id = "policy-1"
|
||||
mock_run.id = "run-1"
|
||||
mock_db.query.return_value.filter.return_value.first.return_value = mock_run
|
||||
|
||||
from src.core.database import get_db
|
||||
client = _make_client({get_db: lambda: mock_db})
|
||||
resp = client.get("/validation-tasks/reports/llm/old-task")
|
||||
assert resp.status_code == 302
|
||||
assert "/validation-tasks/policy-1/runs/run-1" in resp.headers["location"]
|
||||
|
||||
def test_not_found(self):
|
||||
mock_db = MagicMock()
|
||||
mock_db.query.return_value.filter.return_value.first.return_value = None
|
||||
|
||||
from src.core.database import get_db
|
||||
client = _make_client({get_db: lambda: mock_db})
|
||||
resp = client.get("/validation-tasks/reports/llm/unknown")
|
||||
assert resp.status_code == 404
|
||||
# #endregion Test.Api.ValidationTasks.Comprehensive
|
||||
Reference in New Issue
Block a user