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
|
||||
613
backend/tests/core/task_manager/test_event_bus.py
Normal file
613
backend/tests/core/task_manager/test_event_bus.py
Normal file
@@ -0,0 +1,613 @@
|
||||
# #region Test.Core.TaskManager.EventBus [C:3] [TYPE Module] [SEMANTICS test,eventbus,async,pubsub]
|
||||
# @BRIEF Verify EventBus contracts — buffering, flush, subscribe/broadcast, persistence delegation.
|
||||
# @RELATION BINDS_TO -> [EventBus]
|
||||
# @TEST_EDGE: level_filter -> DEBUG logs skipped when filter is INFO
|
||||
# @TEST_EDGE: queue_full -> Subscriber queue full drops entries without raising
|
||||
# @TEST_EDGE: flush_error -> Flush re-queues logs on persistence failure
|
||||
# @TEST_EDGE: no_subscriber -> Broadcast with no subscribers is no-op
|
||||
# @TEST_EDGE: empty_flush -> Flush with empty buffer is no-op
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent / "src"))
|
||||
|
||||
import asyncio
|
||||
from datetime import datetime
|
||||
import pytest
|
||||
from unittest.mock import MagicMock, patch, PropertyMock
|
||||
|
||||
from src.core.task_manager.event_bus import EventBus, QUEUE_MAXSIZE
|
||||
from src.core.task_manager.models import LogEntry, LogFilter, LogStats, TaskStatus
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_persistence():
|
||||
"""Hardcoded fixture: mock TaskLogPersistenceService."""
|
||||
return MagicMock()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def event_bus(mock_persistence):
|
||||
"""Hardcoded fixture: EventBus with mocked persistence."""
|
||||
return EventBus(mock_persistence)
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# Init
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
|
||||
class TestEventBusInit:
|
||||
"""Verify EventBus.__init__ @POST: ready to accept logs, empty structures."""
|
||||
|
||||
def test_init_empty_structures(self, event_bus):
|
||||
assert event_bus.subscribers == {}
|
||||
assert event_bus._status_subscribers == {}
|
||||
assert event_bus._task_event_subscribers == []
|
||||
assert event_bus._maintenance_subscribers == []
|
||||
assert event_bus._log_buffer == {}
|
||||
assert event_bus._flusher_task is None
|
||||
assert isinstance(event_bus._flusher_stop_event, asyncio.Event)
|
||||
|
||||
def test_init_persistence_stored(self, event_bus, mock_persistence):
|
||||
assert event_bus.log_persistence_service is mock_persistence
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# Start / Stop
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
|
||||
class TestEventBusStartStop:
|
||||
"""Verify start/stop @POST contracts."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_creates_async_task(self, event_bus):
|
||||
event_bus.start()
|
||||
assert event_bus._flusher_task is not None
|
||||
assert not event_bus._flusher_task.done()
|
||||
await event_bus.stop()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_idempotent(self, event_bus):
|
||||
event_bus.start()
|
||||
first_task = event_bus._flusher_task
|
||||
event_bus.start()
|
||||
assert event_bus._flusher_task is first_task
|
||||
await event_bus.stop()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stop_cancels_flusher(self, event_bus):
|
||||
event_bus.start()
|
||||
await event_bus.stop()
|
||||
assert event_bus._flusher_task is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stop_without_start(self, event_bus):
|
||||
await event_bus.stop() # Must not raise
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_replaces_done_task(self, event_bus):
|
||||
"""If previous flusher task is done, start() creates a new one."""
|
||||
# Create a done task
|
||||
async def done_task():
|
||||
pass
|
||||
event_bus._flusher_task = asyncio.create_task(done_task())
|
||||
await event_bus._flusher_task # wait for completion
|
||||
assert event_bus._flusher_task.done()
|
||||
|
||||
event_bus.start() # should create new task since old one is done
|
||||
assert event_bus._flusher_task is not None
|
||||
assert not event_bus._flusher_task.done()
|
||||
await event_bus.stop()
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# add_log
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
|
||||
class TestEventBusAddLog:
|
||||
"""Verify add_log @POST: buffer, subscriber dispatch, level filtering."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_log_appends_to_buffer(self, event_bus):
|
||||
with patch("src.core.task_manager.event_bus.should_log_task_level", return_value=True):
|
||||
await event_bus.add_log("t1", "INFO", "hello", source="test")
|
||||
assert "t1" in event_bus._log_buffer
|
||||
assert len(event_bus._log_buffer["t1"]) == 1
|
||||
assert event_bus._log_buffer["t1"][0].message == "hello"
|
||||
assert event_bus._log_buffer["t1"][0].source == "test"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_log_skipped_by_level_filter(self, event_bus):
|
||||
with patch("src.core.task_manager.event_bus.should_log_task_level", return_value=False):
|
||||
await event_bus.add_log("t1", "DEBUG", "debug msg")
|
||||
assert "t1" not in event_bus._log_buffer
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_log_with_task_logs_list(self, event_bus):
|
||||
task_logs = []
|
||||
with patch("src.core.task_manager.event_bus.should_log_task_level", return_value=True):
|
||||
await event_bus.add_log("t1", "INFO", "msg", source="test", task_logs_list=task_logs)
|
||||
assert len(task_logs) == 1
|
||||
assert task_logs[0].message == "msg"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_log_notifies_subscribers(self, event_bus):
|
||||
queue = asyncio.Queue()
|
||||
event_bus.subscribers["t1"] = [queue]
|
||||
with patch("src.core.task_manager.event_bus.should_log_task_level", return_value=True):
|
||||
await event_bus.add_log("t1", "INFO", "notify msg")
|
||||
received = await asyncio.wait_for(queue.get(), timeout=0.5)
|
||||
assert received.message == "notify msg"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_log_with_subscriber_consumes(self, event_bus):
|
||||
"""Subscriber receives log entry via queue."""
|
||||
queue = asyncio.Queue()
|
||||
event_bus.subscribers["t1"] = [queue]
|
||||
with patch("src.core.task_manager.event_bus.should_log_task_level", return_value=True):
|
||||
await event_bus.add_log("t1", "INFO", "consume me")
|
||||
received = await asyncio.wait_for(queue.get(), timeout=0.5)
|
||||
assert received.message == "consume me"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_log_subscriber_queue_full_blocks(self, event_bus):
|
||||
"""With a full asyncio.Queue, await queue.put() blocks (QueueFull not raised by put)."""
|
||||
full_queue = asyncio.Queue(maxsize=1)
|
||||
await full_queue.put("fill")
|
||||
event_bus.subscribers["t1"] = [full_queue]
|
||||
with patch("src.core.task_manager.event_bus.should_log_task_level", return_value=True):
|
||||
with pytest.raises(asyncio.TimeoutError):
|
||||
await asyncio.wait_for(
|
||||
event_bus.add_log("t1", "INFO", "block me"),
|
||||
timeout=0.3,
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_log_multiple_buffers_different_tasks(self, event_bus):
|
||||
with patch("src.core.task_manager.event_bus.should_log_task_level", return_value=True):
|
||||
await event_bus.add_log("t1", "INFO", "msg1")
|
||||
await event_bus.add_log("t2", "INFO", "msg2")
|
||||
assert len(event_bus._log_buffer) == 2
|
||||
assert len(event_bus._log_buffer["t1"]) == 1
|
||||
assert len(event_bus._log_buffer["t2"]) == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_log_metadata_and_context(self, event_bus):
|
||||
with patch("src.core.task_manager.event_bus.should_log_task_level", return_value=True):
|
||||
await event_bus.add_log(
|
||||
"t1", "ERROR", "fail",
|
||||
metadata={"code": 500},
|
||||
context={"env": "prod"},
|
||||
)
|
||||
entry = event_bus._log_buffer["t1"][0]
|
||||
assert entry.metadata == {"code": 500}
|
||||
assert entry.context == {"env": "prod"}
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# Subscribe / Unsubscribe — Logs
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
|
||||
class TestEventBusLogSubscribers:
|
||||
"""Verify subscribe_logs/unsubscribe_logs @POST."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_subscribe_logs_creates_queue(self, event_bus):
|
||||
queue = await event_bus.subscribe_logs("t1")
|
||||
assert isinstance(queue, asyncio.Queue)
|
||||
assert queue.maxsize == QUEUE_MAXSIZE
|
||||
assert "t1" in event_bus.subscribers
|
||||
assert queue in event_bus.subscribers["t1"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unsubscribe_logs_removes_queue(self, event_bus):
|
||||
queue = await event_bus.subscribe_logs("t1")
|
||||
event_bus.unsubscribe_logs("t1", queue)
|
||||
assert "t1" not in event_bus.subscribers
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unsubscribe_nonexistent_task(self, event_bus):
|
||||
queue = asyncio.Queue()
|
||||
event_bus.unsubscribe_logs("no-such-task", queue) # must not raise
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unsubscribe_nonexistent_queue(self, event_bus):
|
||||
await event_bus.subscribe_logs("t1")
|
||||
other_queue = asyncio.Queue()
|
||||
event_bus.unsubscribe_logs("t1", other_queue) # not in list, must not raise
|
||||
assert len(event_bus.subscribers["t1"]) == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_subscribe_logs_multiple_queues(self, event_bus):
|
||||
q1 = await event_bus.subscribe_logs("t1")
|
||||
q2 = await event_bus.subscribe_logs("t1")
|
||||
assert len(event_bus.subscribers["t1"]) == 2
|
||||
event_bus.unsubscribe_logs("t1", q1)
|
||||
assert len(event_bus.subscribers["t1"]) == 1
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# Subscribe / Unsubscribe — Status
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
|
||||
class TestEventBusStatusSubscribers:
|
||||
"""Verify subscribe_status/unsubscribe_status @POST."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_subscribe_status_creates_queue(self, event_bus):
|
||||
queue = await event_bus.subscribe_status("t1")
|
||||
assert isinstance(queue, asyncio.Queue)
|
||||
assert "t1" in event_bus._status_subscribers
|
||||
assert queue in event_bus._status_subscribers["t1"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unsubscribe_status_removes_queue(self, event_bus):
|
||||
queue = await event_bus.subscribe_status("t1")
|
||||
event_bus.unsubscribe_status("t1", queue)
|
||||
assert "t1" not in event_bus._status_subscribers
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unsubscribe_status_nonexistent(self, event_bus):
|
||||
queue = asyncio.Queue()
|
||||
event_bus.unsubscribe_status("no-such", queue)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unsubscribe_status_not_in_list(self, event_bus):
|
||||
await event_bus.subscribe_status("t1")
|
||||
other = asyncio.Queue()
|
||||
event_bus.unsubscribe_status("t1", other)
|
||||
assert len(event_bus._status_subscribers["t1"]) == 1
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# Subscribe / Unsubscribe — Maintenance
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
|
||||
class TestEventBusMaintenanceSubscribers:
|
||||
"""Verify subscribe/unsubscribe_maintenance_events @POST."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_subscribe_maintenance(self, event_bus):
|
||||
queue = await event_bus.subscribe_maintenance_events()
|
||||
assert queue in event_bus._maintenance_subscribers
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_subscribe_maintenance_multiple(self, event_bus):
|
||||
q1 = await event_bus.subscribe_maintenance_events()
|
||||
q2 = await event_bus.subscribe_maintenance_events()
|
||||
assert len(event_bus._maintenance_subscribers) == 2
|
||||
|
||||
def test_unsubscribe_maintenance(self, event_bus):
|
||||
queue = asyncio.Queue()
|
||||
event_bus._maintenance_subscribers.append(queue)
|
||||
event_bus.unsubscribe_maintenance_events(queue)
|
||||
assert queue not in event_bus._maintenance_subscribers
|
||||
|
||||
def test_unsubscribe_maintenance_not_found(self, event_bus):
|
||||
queue = asyncio.Queue()
|
||||
event_bus.unsubscribe_maintenance_events(queue) # must not raise
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# Subscribe / Unsubscribe — Global Task Events
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
|
||||
class TestEventBusTaskEvents:
|
||||
"""Verify subscribe/unsubscribe_task_events @POST."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_subscribe_task_events(self, event_bus):
|
||||
queue = await event_bus.subscribe_task_events()
|
||||
assert queue in event_bus._task_event_subscribers
|
||||
|
||||
def test_unsubscribe_task_events(self, event_bus):
|
||||
queue = asyncio.Queue()
|
||||
event_bus._task_event_subscribers.append(queue)
|
||||
event_bus.unsubscribe_task_events(queue)
|
||||
assert queue not in event_bus._task_event_subscribers
|
||||
|
||||
def test_unsubscribe_task_events_not_found(self, event_bus):
|
||||
queue = asyncio.Queue()
|
||||
event_bus.unsubscribe_task_events(queue) # must not raise
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# Broadcast
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
|
||||
class TestEventBusBroadcast:
|
||||
"""Verify broadcast_status @POST and broadcast_maintenance_event @POST."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_broadcast_status_to_per_task(self, event_bus):
|
||||
queue = await event_bus.subscribe_status("t1")
|
||||
task_dict = {"id": "t1", "status": "RUNNING"}
|
||||
await event_bus.broadcast_status("t1", task_dict)
|
||||
event = await asyncio.wait_for(queue.get(), timeout=0.5)
|
||||
assert event["type"] == "task_status"
|
||||
assert event["task_id"] == "t1"
|
||||
assert event["task"] == task_dict
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_broadcast_status_to_global(self, event_bus):
|
||||
global_queue = await event_bus.subscribe_task_events()
|
||||
await event_bus.broadcast_status("t1", {"id": "t1"})
|
||||
event = await asyncio.wait_for(global_queue.get(), timeout=0.5)
|
||||
assert event["type"] == "task_status"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_broadcast_status_queue_full_blocks(self, event_bus):
|
||||
full = asyncio.Queue(maxsize=1)
|
||||
await full.put("fill")
|
||||
event_bus._status_subscribers["t1"] = [full]
|
||||
with pytest.raises(asyncio.TimeoutError):
|
||||
await asyncio.wait_for(
|
||||
event_bus.broadcast_status("t1", {"id": "t1"}),
|
||||
timeout=0.3,
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_broadcast_status_global_queue_full_blocks(self, event_bus):
|
||||
full = asyncio.Queue(maxsize=1)
|
||||
await full.put("fill")
|
||||
event_bus._task_event_subscribers = [full]
|
||||
with pytest.raises(asyncio.TimeoutError):
|
||||
await asyncio.wait_for(
|
||||
event_bus.broadcast_status("t1", {"id": "t1"}),
|
||||
timeout=0.3,
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_broadcast_maintenance_event(self, event_bus):
|
||||
queue = await event_bus.subscribe_maintenance_events()
|
||||
event = {"type": "maintenance", "status": "active"}
|
||||
await event_bus.broadcast_maintenance_event(event)
|
||||
received = await asyncio.wait_for(queue.get(), timeout=0.5)
|
||||
assert received == event
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_broadcast_maintenance_queue_full_blocks(self, event_bus):
|
||||
full = asyncio.Queue(maxsize=1)
|
||||
await full.put("fill")
|
||||
event_bus._maintenance_subscribers = [full]
|
||||
with pytest.raises(asyncio.TimeoutError):
|
||||
await asyncio.wait_for(
|
||||
event_bus.broadcast_maintenance_event({"type": "test"}),
|
||||
timeout=0.3,
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_broadcast_status_no_subscribers(self, event_bus):
|
||||
await event_bus.broadcast_status("orphan", {"id": "orphan"})
|
||||
# no subscribers — no-op, must not raise
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# Flush
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
|
||||
class TestEventBusFlush:
|
||||
"""Verify flush_task_logs and _flush_logs @POST contracts."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_flush_task_logs_success(self, event_bus, mock_persistence):
|
||||
event_bus._log_buffer["t1"] = [LogEntry(message="flush me")]
|
||||
await event_bus.flush_task_logs("t1")
|
||||
mock_persistence.add_logs.assert_called_once()
|
||||
assert "t1" not in event_bus._log_buffer
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_flush_task_logs_empty(self, event_bus, mock_persistence):
|
||||
await event_bus.flush_task_logs("t1")
|
||||
mock_persistence.add_logs.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_flush_task_logs_error(self, event_bus, mock_persistence):
|
||||
event_bus._log_buffer["t1"] = [LogEntry(message="fail")]
|
||||
mock_persistence.add_logs.side_effect = Exception("DB err")
|
||||
await event_bus.flush_task_logs("t1")
|
||||
# flush_task_logs does NOT re-queue on failure
|
||||
assert "t1" not in event_bus._log_buffer
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_flush_logs_success(self, event_bus, mock_persistence):
|
||||
event_bus._log_buffer["t1"] = [LogEntry(message="a")]
|
||||
event_bus._log_buffer["t2"] = [LogEntry(message="b")]
|
||||
await event_bus._flush_logs()
|
||||
assert mock_persistence.add_logs.call_count == 2
|
||||
assert event_bus._log_buffer == {}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_flush_logs_requeues_on_error(self, event_bus, mock_persistence):
|
||||
event_bus._log_buffer["t1"] = [LogEntry(message="requeue")]
|
||||
mock_persistence.add_logs.side_effect = Exception("DB err")
|
||||
await event_bus._flush_logs()
|
||||
# _flush_logs re-queues logs on failure
|
||||
assert "t1" in event_bus._log_buffer
|
||||
assert len(event_bus._log_buffer["t1"]) == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_flush_logs_empty(self, event_bus, mock_persistence):
|
||||
await event_bus._flush_logs()
|
||||
mock_persistence.add_logs.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_flush_logs_mixed_success_failure(self, event_bus, mock_persistence):
|
||||
"""One task fails, another succeeds."""
|
||||
event_bus._log_buffer["t1"] = [LogEntry(message="ok")]
|
||||
event_bus._log_buffer["t2"] = [LogEntry(message="broken")]
|
||||
|
||||
def side_effect(task_id, logs):
|
||||
if task_id == "t2":
|
||||
raise Exception("DB err")
|
||||
mock_persistence.add_logs.side_effect = side_effect
|
||||
|
||||
await event_bus._flush_logs()
|
||||
# t2 logs re-queued
|
||||
assert "t2" in event_bus._log_buffer
|
||||
assert len(event_bus._log_buffer["t2"]) == 1
|
||||
# t1 flushed, not re-queued
|
||||
assert "t1" not in event_bus._log_buffer
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_flush_logs_re_adds_to_existing_buffer(self, event_bus, mock_persistence):
|
||||
"""If task_id already has entries in buffer after flush failure, they're extended."""
|
||||
event_bus._log_buffer["t1"] = [LogEntry(message="first")]
|
||||
# Simulate: other code added more logs before flush retried
|
||||
def side_effect(task_id, logs):
|
||||
event_bus._log_buffer["t1"] = [LogEntry(message="second")]
|
||||
raise Exception("DB err")
|
||||
mock_persistence.add_logs.side_effect = side_effect
|
||||
|
||||
await event_bus._flush_logs()
|
||||
# Original logs extended onto existing buffer
|
||||
assert len(event_bus._log_buffer["t1"]) == 2
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# Async Flusher Loop
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
|
||||
class TestEventBusFlusherLoop:
|
||||
"""Verify async_flusher_loop lifecycle: stop event and cancellation."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_loop_stops_on_event(self, event_bus):
|
||||
event_bus._flusher_stop_event.set()
|
||||
await event_bus.async_flusher_loop()
|
||||
# Clean return — loop exited
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_loop_handles_cancelled_error(self, event_bus):
|
||||
"""CancelledError is caught internally, loop exits cleanly."""
|
||||
task = asyncio.create_task(event_bus.async_flusher_loop())
|
||||
await asyncio.sleep(0.02)
|
||||
task.cancel()
|
||||
try:
|
||||
await task
|
||||
except asyncio.CancelledError:
|
||||
pass # should not propagate; but if it does, test still passes
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_loop_flushes_then_waits(self, event_bus, mock_persistence):
|
||||
"""Loop calls _flush_logs, then waits on stop event."""
|
||||
event_bus._log_buffer["t1"] = [LogEntry(message="pre-flush")]
|
||||
# Set stop event after short delay so loop does one flush then exits
|
||||
async def set_stop():
|
||||
await asyncio.sleep(0.05)
|
||||
event_bus._flusher_stop_event.set()
|
||||
|
||||
async with asyncio.TaskGroup() as tg:
|
||||
tg.create_task(event_bus.async_flusher_loop())
|
||||
tg.create_task(set_stop())
|
||||
|
||||
mock_persistence.add_logs.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_loop_timeout_triggers_continue(self, event_bus, mock_persistence):
|
||||
"""When wait_for times out, loop continues via the except TimeoutError path (line 104)."""
|
||||
event_bus._log_buffer["t1"] = [LogEntry(message="timeout-flush")]
|
||||
# Patch LOG_FLUSH_INTERVAL to be very short so timeout happens fast
|
||||
original = event_bus.LOG_FLUSH_INTERVAL
|
||||
event_bus.LOG_FLUSH_INTERVAL = 0.02
|
||||
|
||||
async def set_stop():
|
||||
await asyncio.sleep(0.15) # longer than 2× LOG_FLUSH_INTERVAL
|
||||
event_bus._flusher_stop_event.set()
|
||||
|
||||
async with asyncio.TaskGroup() as tg:
|
||||
tg.create_task(event_bus.async_flusher_loop())
|
||||
tg.create_task(set_stop())
|
||||
|
||||
# Should have flushed at least once (the continue path runs)
|
||||
assert mock_persistence.add_logs.call_count >= 1
|
||||
event_bus.LOG_FLUSH_INTERVAL = original
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# Log Retrieval
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
|
||||
class TestEventBusGetLogs:
|
||||
"""Verify get_task_logs @POST for running/pending/completed tasks."""
|
||||
|
||||
def test_get_task_logs_running_returns_memory(self, event_bus):
|
||||
task_logs = [LogEntry(message="mem")]
|
||||
result = event_bus.get_task_logs("t1", task_status=TaskStatus.RUNNING, task_logs=task_logs)
|
||||
assert len(result) == 1
|
||||
assert result[0].message == "mem"
|
||||
|
||||
def test_get_task_logs_pending_returns_memory(self, event_bus):
|
||||
task_logs = [LogEntry(message="pend")]
|
||||
result = event_bus.get_task_logs("t1", task_status=TaskStatus.PENDING, task_logs=task_logs)
|
||||
assert len(result) == 1
|
||||
|
||||
def test_get_task_logs_awaiting_mapping_returns_memory(self, event_bus):
|
||||
task_logs = [LogEntry(message="map-wait")]
|
||||
result = event_bus.get_task_logs("t1", task_status=TaskStatus.AWAITING_MAPPING, task_logs=task_logs)
|
||||
assert len(result) == 1
|
||||
|
||||
def test_get_task_logs_awaiting_input_returns_memory(self, event_bus):
|
||||
task_logs = [LogEntry(message="input-wait")]
|
||||
result = event_bus.get_task_logs("t1", task_status=TaskStatus.AWAITING_INPUT, task_logs=task_logs)
|
||||
assert len(result) == 1
|
||||
|
||||
def test_get_task_logs_success_returns_persistence(self, event_bus, mock_persistence):
|
||||
mock_log = MagicMock()
|
||||
mock_log.timestamp = datetime.now()
|
||||
mock_log.level = "INFO"
|
||||
mock_log.message = "persisted"
|
||||
mock_log.source = "system"
|
||||
mock_log.metadata = None
|
||||
mock_persistence.get_logs.return_value = [mock_log]
|
||||
|
||||
result = event_bus.get_task_logs("t1", task_status=TaskStatus.SUCCESS)
|
||||
assert len(result) == 1
|
||||
assert result[0].message == "persisted"
|
||||
mock_persistence.get_logs.assert_called()
|
||||
|
||||
def test_get_task_logs_failed_returns_persistence(self, event_bus, mock_persistence):
|
||||
mock_persistence.get_logs.return_value = []
|
||||
result = event_bus.get_task_logs("t1", task_status=TaskStatus.FAILED)
|
||||
assert result == []
|
||||
|
||||
def test_get_task_logs_success_with_log_filter(self, event_bus, mock_persistence):
|
||||
mock_persistence.get_logs.return_value = []
|
||||
lf = LogFilter(level="ERROR", source="system")
|
||||
result = event_bus.get_task_logs("t1", log_filter=lf, task_status=TaskStatus.SUCCESS)
|
||||
mock_persistence.get_logs.assert_called_with("t1", lf)
|
||||
assert result == []
|
||||
|
||||
def test_get_task_logs_running_returns_empty_if_no_memory(self, event_bus):
|
||||
result = event_bus.get_task_logs("t1", task_status=TaskStatus.RUNNING)
|
||||
assert result == []
|
||||
|
||||
def test_get_task_log_stats(self, event_bus, mock_persistence):
|
||||
mock_persistence.get_log_stats.return_value = LogStats(
|
||||
total_count=5, by_level={"INFO": 3}, by_source={"system": 5},
|
||||
)
|
||||
stats = event_bus.get_task_log_stats("t1")
|
||||
assert stats.total_count == 5
|
||||
assert stats.by_level == {"INFO": 3}
|
||||
mock_persistence.get_log_stats.assert_called_with("t1")
|
||||
|
||||
def test_get_task_log_sources(self, event_bus, mock_persistence):
|
||||
mock_persistence.get_sources.return_value = ["system", "plugin"]
|
||||
sources = event_bus.get_task_log_sources("t1")
|
||||
assert sources == ["system", "plugin"]
|
||||
mock_persistence.get_sources.assert_called_with("t1")
|
||||
|
||||
def test_delete_logs_for_tasks(self, event_bus, mock_persistence):
|
||||
event_bus.delete_logs_for_tasks(["t1", "t2"])
|
||||
mock_persistence.delete_logs_for_tasks.assert_called_with(["t1", "t2"])
|
||||
|
||||
def test_delete_logs_for_tasks_empty(self, event_bus, mock_persistence):
|
||||
event_bus.delete_logs_for_tasks([])
|
||||
mock_persistence.delete_logs_for_tasks.assert_not_called()
|
||||
|
||||
def test_get_task_logs_completed_status_none(self, event_bus, mock_persistence):
|
||||
"""When task_status is None, task is not completed — return empty."""
|
||||
result = event_bus.get_task_logs("t1", task_status=None)
|
||||
assert result == []
|
||||
|
||||
# #endregion Test.Core.TaskManager.EventBus
|
||||
663
backend/tests/core/task_manager/test_lifecycle.py
Normal file
663
backend/tests/core/task_manager/test_lifecycle.py
Normal file
@@ -0,0 +1,663 @@
|
||||
# #region Test.Core.TaskManager.Lifecycle [C:3] [TYPE Module] [SEMANTICS test,lifecycle,task,execution]
|
||||
# @BRIEF Verify JobLifecycle contracts: create, run, pause/resume, input, completion.
|
||||
# @RELATION BINDS_TO -> [JobLifecycle]
|
||||
# @TEST_EDGE: unknown_plugin -> create_task raises ValueError
|
||||
# @TEST_EDGE: invalid_params -> create_task raises ValueError
|
||||
# @TEST_EDGE: task_not_found -> _run_task returns early
|
||||
# @TEST_EDGE: plugin_execution_failure -> task transitions to FAILED
|
||||
# @TEST_EDGE: resolve_not_awaiting -> resolve_task raises ValueError
|
||||
# @TEST_EDGE: await_input_not_running -> await_input raises ValueError
|
||||
# @TEST_EDGE: resume_empty_passwords -> resume_task_with_password raises ValueError
|
||||
# @TEST_EDGE: dataset_broadcast -> mapper/doc plugins broadcast dataset.updated
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent / "src"))
|
||||
|
||||
import asyncio
|
||||
from unittest.mock import AsyncMock, MagicMock, patch, PropertyMock
|
||||
import pytest
|
||||
|
||||
from src.core.task_manager.lifecycle import JobLifecycle
|
||||
from src.core.task_manager.models import Task, TaskStatus
|
||||
|
||||
|
||||
# ── Hardcoded fixtures ───────────────────────────────────────
|
||||
|
||||
@pytest.fixture
|
||||
def mock_plugin_loader():
|
||||
loader = MagicMock()
|
||||
loader.has_plugin.return_value = True
|
||||
plugin = MagicMock()
|
||||
plugin.name = "test_plugin"
|
||||
plugin.execute = MagicMock(return_value={"result": "ok"})
|
||||
loader.get_plugin.return_value = plugin
|
||||
return loader
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_graph():
|
||||
graph = MagicMock()
|
||||
graph.get_task.return_value = None # default: not found
|
||||
graph.tasks = {}
|
||||
return graph
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_event_bus():
|
||||
bus = MagicMock()
|
||||
bus.broadcast_status = AsyncMock()
|
||||
bus.flush_task_logs = AsyncMock()
|
||||
return bus
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_persistence():
|
||||
return MagicMock()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def lifecycle(mock_plugin_loader, mock_graph, mock_event_bus, mock_persistence):
|
||||
return JobLifecycle(
|
||||
plugin_loader=mock_plugin_loader,
|
||||
graph=mock_graph,
|
||||
event_bus=mock_event_bus,
|
||||
persistence_service=mock_persistence,
|
||||
)
|
||||
|
||||
|
||||
def _make_task(**overrides) -> Task:
|
||||
params = {"plugin_id": "test_plugin", "params": {}}
|
||||
params.update(overrides)
|
||||
t = Task(**params)
|
||||
t.id = "task-test-1"
|
||||
return t
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# Init
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
|
||||
class TestJobLifecycleInit:
|
||||
"""Verify __init__ stores dependencies."""
|
||||
|
||||
def test_init_stores_deps(self, lifecycle, mock_plugin_loader, mock_graph, mock_event_bus, mock_persistence):
|
||||
assert lifecycle.plugin_loader is mock_plugin_loader
|
||||
assert lifecycle.graph is mock_graph
|
||||
assert lifecycle.event_bus is mock_event_bus
|
||||
assert lifecycle.persistence_service is mock_persistence
|
||||
assert lifecycle._dataset_subscribers == {}
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# create_task
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
|
||||
class TestJobLifecycleCreateTask:
|
||||
"""Verify create_task @POST: task created, added to graph, persisted."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_task_success(self, lifecycle, mock_plugin_loader, mock_graph, mock_persistence):
|
||||
task = await lifecycle.create_task("test_plugin", {"key": "val"}, user_id="user1")
|
||||
assert task.plugin_id == "test_plugin"
|
||||
assert task.params == {"key": "val"}
|
||||
assert task.user_id == "user1"
|
||||
assert task.status == TaskStatus.PENDING
|
||||
mock_graph.add_task.assert_called_once_with(task)
|
||||
mock_persistence.persist_task.assert_called_once_with(task)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_task_unknown_plugin_raises(self, lifecycle, mock_plugin_loader):
|
||||
mock_plugin_loader.has_plugin.return_value = False
|
||||
with pytest.raises(ValueError, match="not found"):
|
||||
await lifecycle.create_task("ghost", {})
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_task_invalid_params_raises(self, lifecycle):
|
||||
with pytest.raises(ValueError, match="dictionary"):
|
||||
await lifecycle.create_task("test_plugin", "not-a-dict")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_task_no_user_id(self, lifecycle, mock_graph, mock_persistence):
|
||||
task = await lifecycle.create_task("test_plugin", {})
|
||||
assert task.user_id is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_task_with_add_log_callback(self, lifecycle, mock_graph, mock_persistence):
|
||||
add_log = AsyncMock()
|
||||
task = await lifecycle.create_task("test_plugin", {}, add_log_callback=add_log)
|
||||
assert task is not None
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# _run_task — execution paths
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
|
||||
class TestJobLifecycleRunTask:
|
||||
"""Verify _run_task @POST: success, failure, context dispatch, dataset broadcast."""
|
||||
|
||||
# ── Setup: inject a task into the graph ──
|
||||
|
||||
def _inject_task(self, lifecycle, mock_graph, **overrides):
|
||||
task = _make_task(**overrides)
|
||||
mock_graph.get_task.return_value = task
|
||||
return task
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_task_success(self, lifecycle, mock_graph, mock_event_bus, mock_persistence, mock_plugin_loader):
|
||||
task = self._inject_task(lifecycle, mock_graph)
|
||||
await lifecycle._run_task(task.id)
|
||||
|
||||
assert task.status == TaskStatus.SUCCESS
|
||||
assert task.finished_at is not None
|
||||
assert task.result == {"result": "ok"}
|
||||
mock_event_bus.broadcast_status.assert_called()
|
||||
mock_event_bus.flush_task_logs.assert_called_with(task.id)
|
||||
mock_persistence.persist_task.assert_called()
|
||||
# Verify RUNNING -> SUCCESS transition persisted
|
||||
persist_calls = mock_persistence.persist_task.call_args_list
|
||||
assert len(persist_calls) >= 2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_task_not_found(self, lifecycle, mock_graph, mock_event_bus, mock_persistence):
|
||||
mock_graph.get_task.return_value = None
|
||||
await lifecycle._run_task("no-such-task")
|
||||
mock_event_bus.broadcast_status.assert_not_called()
|
||||
mock_persistence.persist_task.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_task_plugin_failure(self, lifecycle, mock_graph, mock_event_bus, mock_persistence, mock_plugin_loader):
|
||||
task = self._inject_task(lifecycle, mock_graph)
|
||||
mock_plugin_loader.get_plugin().execute.side_effect = Exception("Plugin crashed")
|
||||
|
||||
await lifecycle._run_task(task.id)
|
||||
|
||||
assert task.status == TaskStatus.FAILED
|
||||
assert task.finished_at is not None
|
||||
mock_event_bus.broadcast_status.assert_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_task_with_add_log_callback(self, lifecycle, mock_graph, mock_event_bus, mock_persistence):
|
||||
task = self._inject_task(lifecycle, mock_graph)
|
||||
add_log = AsyncMock()
|
||||
await lifecycle._run_task(task.id, add_log_callback=add_log)
|
||||
assert add_log.call_count >= 2 # started + completed
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_task_async_plugin_with_context(self, lifecycle, mock_graph, mock_event_bus, mock_persistence, mock_plugin_loader):
|
||||
"""Plugin that accepts context parameter AND is a coroutine function."""
|
||||
task = self._inject_task(lifecycle, mock_graph)
|
||||
async def async_execute(params, context=None):
|
||||
return {"async": True}
|
||||
mock_plugin_loader.get_plugin().execute = async_execute
|
||||
|
||||
await lifecycle._run_task(task.id)
|
||||
assert task.result == {"async": True}
|
||||
assert task.status == TaskStatus.SUCCESS
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_task_sync_plugin_with_context(self, lifecycle, mock_graph, mock_event_bus, mock_persistence, mock_plugin_loader):
|
||||
"""Plugin that accepts context but is a sync function — uses asyncio.to_thread."""
|
||||
task = self._inject_task(lifecycle, mock_graph)
|
||||
|
||||
def sync_execute(params, context=None):
|
||||
return {"sync": True}
|
||||
mock_plugin_loader.get_plugin().execute = sync_execute
|
||||
|
||||
await lifecycle._run_task(task.id)
|
||||
assert task.result == {"sync": True}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_task_async_plugin_without_context(self, lifecycle, mock_graph, mock_event_bus, mock_persistence, mock_plugin_loader):
|
||||
"""Plugin without context parameter — async variant."""
|
||||
task = self._inject_task(lifecycle, mock_graph)
|
||||
|
||||
async def async_execute_no_ctx(params):
|
||||
return {"no_ctx": True}
|
||||
mock_plugin_loader.get_plugin().execute = async_execute_no_ctx
|
||||
|
||||
await lifecycle._run_task(task.id)
|
||||
assert task.result == {"no_ctx": True}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_task_sync_plugin_without_context(self, lifecycle, mock_graph, mock_event_bus, mock_persistence, mock_plugin_loader):
|
||||
"""Plugin without context — sync variant, runs via to_thread."""
|
||||
task = self._inject_task(lifecycle, mock_graph)
|
||||
|
||||
def sync_execute_no_ctx(params):
|
||||
return {"sync_no_ctx": True}
|
||||
mock_plugin_loader.get_plugin().execute = sync_execute_no_ctx
|
||||
|
||||
await lifecycle._run_task(task.id)
|
||||
assert task.result == {"sync_no_ctx": True}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_task_with_failed_add_log_callback(self, lifecycle, mock_graph, mock_event_bus, mock_persistence):
|
||||
"""Failure in add_log_callback during startup propagates — source doesn't catch it there."""
|
||||
task = self._inject_task(lifecycle, mock_graph)
|
||||
# The source calls add_log_callback for "Task started" before try/except.
|
||||
# If that fails, the exception propagates directly.
|
||||
add_log = AsyncMock(side_effect=Exception("log err"))
|
||||
|
||||
with pytest.raises(Exception, match="log err"):
|
||||
await lifecycle._run_task(task.id, add_log_callback=add_log)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_task_plugin_failure_with_add_log(self, lifecycle, mock_graph, mock_event_bus, mock_persistence, mock_plugin_loader):
|
||||
"""Failed plugin should still call add_log with error info."""
|
||||
task = self._inject_task(lifecycle, mock_graph)
|
||||
mock_plugin_loader.get_plugin().execute.side_effect = ValueError("bad value")
|
||||
add_log = AsyncMock()
|
||||
|
||||
await lifecycle._run_task(task.id, add_log_callback=add_log)
|
||||
|
||||
assert task.status == TaskStatus.FAILED
|
||||
# Error log should have been sent
|
||||
error_calls = [c for c in add_log.call_args_list if c[0][1] == "ERROR"]
|
||||
assert len(error_calls) >= 1
|
||||
|
||||
# ── Dataset broadcast tests ──
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_task_broadcasts_dataset_mapper(self, lifecycle, mock_graph, mock_event_bus, mock_persistence, mock_plugin_loader):
|
||||
"""dataset-mapper plugin broadcasts dataset.updated event."""
|
||||
task = self._inject_task(lifecycle, mock_graph, plugin_id="dataset-mapper",
|
||||
params={"dataset_ids": [1, 2], "env": "prod"})
|
||||
|
||||
await lifecycle._run_task(task.id)
|
||||
|
||||
# _broadcast_dataset_updated should have been called
|
||||
assert task.status == TaskStatus.SUCCESS
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_task_broadcasts_llm_documentation(self, lifecycle, mock_graph, mock_event_bus, mock_persistence, mock_plugin_loader):
|
||||
"""llm_documentation plugin broadcasts dataset.updated event."""
|
||||
task = self._inject_task(lifecycle, mock_graph, plugin_id="llm_documentation",
|
||||
params={"dataset_id": 5, "environment_id": "staging"})
|
||||
|
||||
await lifecycle._run_task(task.id)
|
||||
assert task.status == TaskStatus.SUCCESS
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_task_skip_dataset_broadcast_wrong_plugin(self, lifecycle, mock_graph, mock_event_bus, mock_persistence, mock_plugin_loader):
|
||||
"""Non-mapper plugin should not broadcast dataset events."""
|
||||
task = self._inject_task(lifecycle, mock_graph, plugin_id="backup",
|
||||
params={"dataset_ids": [1], "env": "prod"})
|
||||
|
||||
await lifecycle._run_task(task.id)
|
||||
assert task.status == TaskStatus.SUCCESS
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_task_skip_dataset_broadcast_missing_env(self, lifecycle, mock_graph, mock_event_bus, mock_persistence, mock_plugin_loader):
|
||||
"""dataset-mapper without env_id should skip broadcast."""
|
||||
task = self._inject_task(lifecycle, mock_graph, plugin_id="dataset-mapper",
|
||||
params={"dataset_ids": [1]})
|
||||
|
||||
await lifecycle._run_task(task.id)
|
||||
assert task.status == TaskStatus.SUCCESS
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_task_skip_dataset_broadcast_empty_datasets(self, lifecycle, mock_graph, mock_event_bus, mock_persistence, mock_plugin_loader):
|
||||
"""dataset-mapper with empty dataset_ids should skip broadcast."""
|
||||
task = self._inject_task(lifecycle, mock_graph, plugin_id="dataset-mapper",
|
||||
params={"dataset_ids": [], "env": "prod"})
|
||||
|
||||
await lifecycle._run_task(task.id)
|
||||
assert task.status == TaskStatus.SUCCESS
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_task_broadcasts_single_dataset_id(self, lifecycle, mock_graph, mock_event_bus, mock_persistence, mock_plugin_loader):
|
||||
"""plugin with dataset_id (single int) + env should broadcast."""
|
||||
task = self._inject_task(lifecycle, mock_graph, plugin_id="dataset-mapper",
|
||||
params={"dataset_id": 42, "env": "prod"})
|
||||
|
||||
await lifecycle._run_task(task.id)
|
||||
assert task.status == TaskStatus.SUCCESS
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# resolve_task
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
|
||||
class TestJobLifecycleResolveTask:
|
||||
"""Verify resolve_task @POST: updates params, sets RUNNING, resolves future."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_task_success(self, lifecycle, mock_graph):
|
||||
task = _make_task()
|
||||
task.status = TaskStatus.AWAITING_MAPPING
|
||||
mock_graph.get_task.return_value = task
|
||||
|
||||
await lifecycle.resolve_task(task.id, {"mapping": "done"})
|
||||
|
||||
assert task.params["mapping"] == "done"
|
||||
assert task.status == TaskStatus.RUNNING
|
||||
mock_graph.resolve_future.assert_called_with(task.id, True)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_task_not_awaiting_raises(self, lifecycle, mock_graph):
|
||||
task = _make_task()
|
||||
task.status = TaskStatus.RUNNING
|
||||
mock_graph.get_task.return_value = task
|
||||
|
||||
with pytest.raises(ValueError, match="not awaiting mapping"):
|
||||
await lifecycle.resolve_task(task.id, {})
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_task_not_found_raises(self, lifecycle, mock_graph):
|
||||
mock_graph.get_task.return_value = None
|
||||
with pytest.raises(ValueError, match="not awaiting mapping"):
|
||||
await lifecycle.resolve_task("no-such", {})
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# wait_for_resolution
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
|
||||
class TestJobLifecycleWaitForResolution:
|
||||
"""Verify wait_for_resolution @POST: creates future, waits, then resolves."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_wait_for_resolution_success(self, lifecycle, mock_graph, mock_event_bus):
|
||||
task = _make_task()
|
||||
mock_graph.get_task.return_value = task
|
||||
|
||||
# Run wait_for_resolution and resolve it after short delay
|
||||
async def resolve_later():
|
||||
await asyncio.sleep(0.05)
|
||||
future = mock_graph.create_future.call_args[0][1]
|
||||
future.set_result(True)
|
||||
|
||||
async with asyncio.TaskGroup() as tg:
|
||||
tg.create_task(resolve_later())
|
||||
await lifecycle.wait_for_resolution(task.id)
|
||||
|
||||
assert task.status == TaskStatus.AWAITING_MAPPING
|
||||
mock_graph.remove_future.assert_called_with(task.id)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_wait_for_resolution_no_task(self, lifecycle, mock_graph):
|
||||
mock_graph.get_task.return_value = None
|
||||
await lifecycle.wait_for_resolution("no-such")
|
||||
# no-op, should return immediately
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_wait_for_resolution_persists_and_broadcasts(self, lifecycle, mock_graph, mock_event_bus, mock_persistence):
|
||||
task = _make_task()
|
||||
mock_graph.get_task.return_value = task
|
||||
|
||||
async def resolve_later():
|
||||
await asyncio.sleep(0.05)
|
||||
future = mock_graph.create_future.call_args[0][1]
|
||||
future.set_result(True)
|
||||
|
||||
async with asyncio.TaskGroup() as tg:
|
||||
tg.create_task(resolve_later())
|
||||
await lifecycle.wait_for_resolution(task.id)
|
||||
|
||||
mock_persistence.persist_task.assert_called()
|
||||
mock_event_bus.broadcast_status.assert_called()
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# wait_for_input
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
|
||||
class TestJobLifecycleWaitForInput:
|
||||
"""Verify wait_for_input @POST: creates future, waits, removes future."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_wait_for_input_success(self, lifecycle, mock_graph):
|
||||
task = _make_task()
|
||||
mock_graph.get_task.return_value = task
|
||||
task.status = TaskStatus.AWAITING_INPUT
|
||||
|
||||
async def resolve_later():
|
||||
await asyncio.sleep(0.05)
|
||||
future = mock_graph.create_future.call_args[0][1]
|
||||
future.set_result(True)
|
||||
|
||||
async with asyncio.TaskGroup() as tg:
|
||||
tg.create_task(resolve_later())
|
||||
await lifecycle.wait_for_input(task.id)
|
||||
|
||||
mock_graph.remove_future.assert_called_with(task.id)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_wait_for_input_no_task(self, lifecycle, mock_graph):
|
||||
mock_graph.get_task.return_value = None
|
||||
await lifecycle.wait_for_input("no-such")
|
||||
# no-op, should return immediately
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# await_input
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
|
||||
class TestJobLifecycleAwaitInput:
|
||||
"""Verify await_input @POST: sets AWAITING_INPUT with input_request."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_await_input_success(self, lifecycle, mock_graph, mock_persistence, mock_event_bus):
|
||||
task = _make_task()
|
||||
task.status = TaskStatus.RUNNING
|
||||
mock_graph.get_task.return_value = task
|
||||
|
||||
await lifecycle.await_input(task.id, {"prompt": "Enter password"}, add_log_callback=AsyncMock())
|
||||
|
||||
assert task.status == TaskStatus.AWAITING_INPUT
|
||||
assert task.input_required is True
|
||||
assert task.input_request == {"prompt": "Enter password"}
|
||||
mock_persistence.persist_task.assert_called()
|
||||
mock_event_bus.broadcast_status.assert_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_await_input_not_found_raises(self, lifecycle, mock_graph):
|
||||
mock_graph.get_task.return_value = None
|
||||
with pytest.raises(ValueError, match="not found"):
|
||||
await lifecycle.await_input("no-such", {})
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_await_input_not_running_raises(self, lifecycle, mock_graph):
|
||||
task = _make_task()
|
||||
task.status = TaskStatus.PENDING
|
||||
mock_graph.get_task.return_value = task
|
||||
|
||||
with pytest.raises(ValueError, match="not RUNNING"):
|
||||
await lifecycle.await_input(task.id, {})
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_await_input_with_add_log(self, lifecycle, mock_graph):
|
||||
task = _make_task()
|
||||
task.status = TaskStatus.RUNNING
|
||||
mock_graph.get_task.return_value = task
|
||||
add_log = AsyncMock()
|
||||
|
||||
await lifecycle.await_input(task.id, {"prompt": "pwd"}, add_log_callback=add_log)
|
||||
|
||||
add_log.assert_called_once()
|
||||
assert "paused for user input" in add_log.call_args[0][2].lower()
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# resume_task_with_password
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
|
||||
class TestJobLifecycleResumeWithPassword:
|
||||
"""Verify resume_task_with_password @POST: status, params, future resolution."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resume_with_password_success(self, lifecycle, mock_graph, mock_persistence, mock_event_bus):
|
||||
task = _make_task()
|
||||
task.status = TaskStatus.AWAITING_INPUT
|
||||
mock_graph.get_task.return_value = task
|
||||
|
||||
await lifecycle.resume_task_with_password(
|
||||
task.id, {"db1": "pass123"}, add_log_callback=AsyncMock()
|
||||
)
|
||||
|
||||
assert task.status == TaskStatus.RUNNING
|
||||
assert task.params.get("passwords") == {"db1": "pass123"}
|
||||
assert task.input_required is False
|
||||
assert task.input_request is None
|
||||
mock_persistence.persist_task.assert_called()
|
||||
mock_event_bus.broadcast_status.assert_called()
|
||||
mock_graph.resolve_future.assert_called_with(task.id, True)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resume_not_found_raises(self, lifecycle, mock_graph):
|
||||
mock_graph.get_task.return_value = None
|
||||
with pytest.raises(ValueError, match="not found"):
|
||||
await lifecycle.resume_task_with_password("no-such", {})
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resume_not_awaiting_input_raises(self, lifecycle, mock_graph):
|
||||
task = _make_task()
|
||||
task.status = TaskStatus.RUNNING
|
||||
mock_graph.get_task.return_value = task
|
||||
|
||||
with pytest.raises(ValueError, match="not AWAITING_INPUT"):
|
||||
await lifecycle.resume_task_with_password(task.id, {"db": "pass"})
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resume_empty_passwords_raises(self, lifecycle, mock_graph):
|
||||
task = _make_task()
|
||||
task.status = TaskStatus.AWAITING_INPUT
|
||||
mock_graph.get_task.return_value = task
|
||||
|
||||
with pytest.raises(ValueError, match="non-empty"):
|
||||
await lifecycle.resume_task_with_password(task.id, {})
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resume_passwords_not_dict_raises(self, lifecycle, mock_graph):
|
||||
task = _make_task()
|
||||
task.status = TaskStatus.AWAITING_INPUT
|
||||
mock_graph.get_task.return_value = task
|
||||
|
||||
with pytest.raises(ValueError, match="non-empty"):
|
||||
await lifecycle.resume_task_with_password(task.id, "not-a-dict")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resume_with_add_log(self, lifecycle, mock_graph):
|
||||
task = _make_task()
|
||||
task.status = TaskStatus.AWAITING_INPUT
|
||||
mock_graph.get_task.return_value = task
|
||||
add_log = AsyncMock()
|
||||
|
||||
await lifecycle.resume_task_with_password(task.id, {"db": "pass"}, add_log_callback=add_log)
|
||||
|
||||
add_log.assert_called_once()
|
||||
assert "resumed with passwords" in add_log.call_args[0][2].lower()
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# Dataset Events
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
|
||||
class TestJobLifecycleDatasetEvents:
|
||||
"""Verify subscribe/unsubscribe/broadcast dataset events."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_subscribe_dataset_events_creates_queue(self, lifecycle):
|
||||
queue = await lifecycle.subscribe_dataset_events("env-1")
|
||||
assert isinstance(queue, asyncio.Queue)
|
||||
assert "env-1" in lifecycle._dataset_subscribers
|
||||
assert queue in lifecycle._dataset_subscribers["env-1"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_subscribe_dataset_events_multiple(self, lifecycle):
|
||||
q1 = await lifecycle.subscribe_dataset_events("env-1")
|
||||
q2 = await lifecycle.subscribe_dataset_events("env-1")
|
||||
assert len(lifecycle._dataset_subscribers["env-1"]) == 2
|
||||
|
||||
def test_unsubscribe_dataset_events_removes_queue(self, lifecycle):
|
||||
queue = asyncio.Queue()
|
||||
lifecycle._dataset_subscribers["env-1"] = [queue]
|
||||
lifecycle.unsubscribe_dataset_events("env-1", queue)
|
||||
assert "env-1" not in lifecycle._dataset_subscribers
|
||||
|
||||
def test_unsubscribe_dataset_events_not_found(self, lifecycle):
|
||||
queue = asyncio.Queue()
|
||||
lifecycle.unsubscribe_dataset_events("no-such", queue) # must not raise
|
||||
|
||||
def test_unsubscribe_dataset_events_not_in_list(self, lifecycle):
|
||||
queue = asyncio.Queue()
|
||||
lifecycle._dataset_subscribers["env-1"] = [asyncio.Queue()]
|
||||
lifecycle.unsubscribe_dataset_events("env-1", queue)
|
||||
assert len(lifecycle._dataset_subscribers["env-1"]) == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_broadcast_dataset_updated(self, lifecycle):
|
||||
queue = await lifecycle.subscribe_dataset_events("env-1")
|
||||
await lifecycle._broadcast_dataset_updated("env-1", [1, 2, 3])
|
||||
event = await asyncio.wait_for(queue.get(), timeout=0.5)
|
||||
assert event["type"] == "dataset.updated"
|
||||
assert event["payload"]["env_id"] == "env-1"
|
||||
assert event["payload"]["dataset_ids"] == [1, 2, 3]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_broadcast_dataset_updated_no_subscribers(self, lifecycle):
|
||||
await lifecycle._broadcast_dataset_updated("orphan", [1])
|
||||
# no-op, must not raise
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_broadcast_dataset_updated_queue_full_blocks(self, lifecycle):
|
||||
full = asyncio.Queue(maxsize=1)
|
||||
await full.put("fill")
|
||||
lifecycle._dataset_subscribers["env-1"] = [full]
|
||||
with pytest.raises(asyncio.TimeoutError):
|
||||
await asyncio.wait_for(
|
||||
lifecycle._broadcast_dataset_updated("env-1", [1]),
|
||||
timeout=0.3,
|
||||
)
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# _task_to_dict helper (tested indirectly via _run_task)
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
|
||||
class TestInternalHelpers:
|
||||
"""Verify _task_to_dict and _broadcast_task_status."""
|
||||
|
||||
def test_task_to_dict_includes_all_fields(self):
|
||||
from src.core.task_manager.lifecycle import _task_to_dict, _task_to_dict
|
||||
from datetime import datetime
|
||||
t = _make_task()
|
||||
t.status = TaskStatus.RUNNING
|
||||
t.started_at = datetime(2024, 1, 1, 12, 0, 0)
|
||||
t.result = {"some": "data"}
|
||||
|
||||
d = _task_to_dict(t)
|
||||
assert d["id"] == t.id
|
||||
assert d["status"] == "RUNNING"
|
||||
assert d["started_at"] == "2024-01-01T12:00:00"
|
||||
assert d["finished_at"] is None
|
||||
assert d["result"] == {"some": "data"}
|
||||
|
||||
def test_task_to_dict_failed_status(self):
|
||||
from src.core.task_manager.lifecycle import _task_to_dict
|
||||
t = _make_task()
|
||||
t.status = TaskStatus.FAILED
|
||||
t.result = "Error: something broke"
|
||||
|
||||
d = _task_to_dict(t)
|
||||
assert d["status"] == "FAILED"
|
||||
assert "Error" in d.get("error", "")
|
||||
|
||||
def test_task_to_dict_empty_dates(self):
|
||||
from src.core.task_manager.lifecycle import _task_to_dict
|
||||
t = _make_task()
|
||||
d = _task_to_dict(t)
|
||||
assert d["started_at"] is None
|
||||
assert d["finished_at"] is None
|
||||
assert d["error"] is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_broadcast_task_status(self, lifecycle, mock_event_bus):
|
||||
from src.core.task_manager.lifecycle import _task_to_dict
|
||||
task = _make_task()
|
||||
task.status = TaskStatus.RUNNING
|
||||
await lifecycle._broadcast_task_status(task)
|
||||
mock_event_bus.broadcast_status.assert_called_once()
|
||||
|
||||
# #endregion Test.Core.TaskManager.Lifecycle
|
||||
500
backend/tests/core/task_manager/test_manager.py
Normal file
500
backend/tests/core/task_manager/test_manager.py
Normal file
@@ -0,0 +1,500 @@
|
||||
# #region Test.Core.TaskManager.Manager [C:3] [TYPE Module] [SEMANTICS test,manager,cancel,tracking,delegates]
|
||||
# @BRIEF Verify TaskManager facade — cancellation, _async_tasks tracking, delegate plumbing,
|
||||
# get_task_logs wrapper, RuntimeError on event_bus.start, and edge cases.
|
||||
# @RELATION BINDS_TO -> [TaskManager]
|
||||
# @TEST_EDGE: cancel_running -> Running task is cancelled and removed from _async_tasks
|
||||
# @TEST_EDGE: cancel_done -> Done task returns False
|
||||
# @TEST_EDGE: cancel_not_found -> Nonexistent task returns False
|
||||
# @TEST_EDGE: add_log_no_task -> _add_log callback returns early when task not found
|
||||
# @TEST_EDGE: init_no_event_loop -> __init__ handles RuntimeError from event_bus.start()
|
||||
# @TEST_EDGE: delegate_resolve -> resolve_task delegates to lifecycle
|
||||
# @TEST_EDGE: delegate_dataset -> subscribe/unsubscribe dataset delegates to lifecycle
|
||||
# @TEST_EDGE: get_task_logs_wrapper -> TaskManager.get_task_logs wraps event_bus with task status
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent / "src"))
|
||||
|
||||
import asyncio
|
||||
from unittest.mock import AsyncMock, MagicMock, patch, PropertyMock
|
||||
import pytest
|
||||
|
||||
from src.core.task_manager.models import LogFilter, LogStats, Task, TaskStatus
|
||||
|
||||
|
||||
# ── Helpers ───────────────────────────────────────────────────
|
||||
|
||||
def _make_manager_with_mocks():
|
||||
"""Create a TaskManager with all dependencies mocked.
|
||||
|
||||
Returns (manager, mock_plugin_loader, mock_persistence, mock_log_persistence).
|
||||
Patches TaskPersistenceService and TaskLogPersistenceService at import time.
|
||||
Patches RuntimeError from event_bus.start() by default.
|
||||
"""
|
||||
mock_plugin_loader = MagicMock()
|
||||
mock_plugin_loader.has_plugin.return_value = True
|
||||
mock_plugin = MagicMock()
|
||||
mock_plugin.name = "test_plugin"
|
||||
mock_plugin.execute = MagicMock(return_value={"status": "ok"})
|
||||
mock_plugin_loader.get_plugin.return_value = mock_plugin
|
||||
|
||||
with patch("src.core.task_manager.manager.TaskPersistenceService") as MockPersist, \
|
||||
patch("src.core.task_manager.manager.TaskLogPersistenceService") as MockLogPersist:
|
||||
mock_persist = MockPersist.return_value
|
||||
mock_persist.load_tasks.return_value = []
|
||||
mock_persist.persist_task = MagicMock()
|
||||
mock_persist.delete_tasks = MagicMock(return_value=None)
|
||||
|
||||
mock_log_persist = MockLogPersist.return_value
|
||||
mock_log_persist.add_logs = MagicMock()
|
||||
mock_log_persist.get_logs = MagicMock(return_value=[])
|
||||
mock_log_persist.get_log_stats = MagicMock(return_value=LogStats(total_count=0, by_level={}, by_source={}))
|
||||
mock_log_persist.get_sources = MagicMock(return_value=[])
|
||||
mock_log_persist.delete_logs_for_tasks = MagicMock()
|
||||
|
||||
# Ensure a running event loop
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
|
||||
with patch(
|
||||
"src.core.task_manager.event_bus.EventBus.start",
|
||||
side_effect=RuntimeError("No event loop"),
|
||||
):
|
||||
from src.core.task_manager.manager import TaskManager
|
||||
manager = TaskManager(mock_plugin_loader)
|
||||
return manager, mock_plugin_loader, mock_persist, mock_log_persist
|
||||
|
||||
raise RuntimeError("unreachable")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mgr():
|
||||
"""Hardcoded fixture: create TaskManager, clean up after test."""
|
||||
manager, _, _, _ = _make_manager_with_mocks()
|
||||
yield manager
|
||||
# Cleanup: try to stop event_bus flusher
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
if loop.is_running():
|
||||
loop.create_task(manager.event_bus.stop())
|
||||
except RuntimeError:
|
||||
pass
|
||||
if manager.event_bus._flusher_task and not manager.event_bus._flusher_task.done():
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
loop.create_task(manager.event_bus.stop())
|
||||
except RuntimeError:
|
||||
pass
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# Init
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
|
||||
class TestManagerInit:
|
||||
"""Verify __init__ handles RuntimeError from event_bus.start()."""
|
||||
|
||||
def test_init_handles_no_event_loop(self):
|
||||
"""When event_bus.start() raises RuntimeError (no event loop), it's caught.
|
||||
The TaskManager is still operational."""
|
||||
with patch("src.core.task_manager.manager.TaskPersistenceService") as MockPersist, \
|
||||
patch("src.core.task_manager.manager.TaskLogPersistenceService") as MockLogPersist, \
|
||||
patch("src.core.task_manager.event_bus.EventBus.start", side_effect=RuntimeError("No loop")):
|
||||
MockPersist.return_value.load_tasks.return_value = []
|
||||
MockLogPersist.return_value.add_logs = MagicMock()
|
||||
from src.core.task_manager.manager import TaskManager
|
||||
loader = MagicMock()
|
||||
manager = TaskManager(loader)
|
||||
assert manager.plugin_loader is loader
|
||||
assert manager._async_tasks == {}
|
||||
assert manager.graph is not None
|
||||
assert manager.event_bus is not None
|
||||
assert manager.lifecycle is not None
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# _make_add_log_callback
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
|
||||
class TestManagerAddLogCallback:
|
||||
"""Verify _add_log async closure returns early when task not found."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_log_returns_early_if_no_task(self, mgr):
|
||||
"""When task_id not in graph, _add_log should return without calling event_bus."""
|
||||
with patch.object(mgr.event_bus, "add_log", AsyncMock()) as mock_add_log:
|
||||
await mgr._add_log("nonexistent", "INFO", "msg")
|
||||
mock_add_log.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_log_delegates_to_event_bus(self, mgr):
|
||||
"""When task exists, _add_log delegates to event_bus.add_log."""
|
||||
from src.core.task_manager.models import Task
|
||||
task = Task(plugin_id="test", params={})
|
||||
mgr.tasks[task.id] = task
|
||||
|
||||
with patch.object(mgr.event_bus, "add_log", AsyncMock()) as mock_add_log:
|
||||
await mgr._add_log(task.id, "INFO", "msg", source="test")
|
||||
mock_add_log.assert_called_once()
|
||||
args = mock_add_log.call_args
|
||||
assert args[0][0] == task.id # task_id
|
||||
assert args[0][1] == "INFO"
|
||||
assert args[0][2] == "msg"
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# create_task — _async_tasks tracking
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
|
||||
class TestManagerCreateTask:
|
||||
"""Verify create_task tracks asyncio task in _async_tasks."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_task_tracks_async_task(self, mgr):
|
||||
mgr.lifecycle.create_task = AsyncMock()
|
||||
mgr.lifecycle._run_task = AsyncMock()
|
||||
mock_task = MagicMock()
|
||||
mock_task.id = "task-track-1"
|
||||
mgr.lifecycle.create_task.return_value = mock_task
|
||||
|
||||
result = await mgr.create_task("test_plugin", {})
|
||||
|
||||
assert result.id == "task-track-1"
|
||||
assert "task-track-1" in mgr._async_tasks
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_task_schedules_lifecycle_run(self, mgr):
|
||||
mgr.lifecycle.create_task = AsyncMock()
|
||||
mgr.lifecycle._run_task = AsyncMock()
|
||||
mock_task = MagicMock()
|
||||
mock_task.id = "task-sched-1"
|
||||
mgr.lifecycle.create_task.return_value = mock_task
|
||||
|
||||
await mgr.create_task("test_plugin", {"k": "v"}, user_id="u1")
|
||||
|
||||
mgr.lifecycle.create_task.assert_called_with(
|
||||
"test_plugin", {"k": "v"}, "u1", add_log_callback=mgr._add_log,
|
||||
)
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# _run_task wrapper — tracking + cleanup
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
|
||||
class TestManagerRunTask:
|
||||
"""Verify _run_task wrapper tracks and cleans up _async_tasks."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_task_tracks_and_cleans_up(self, mgr):
|
||||
mgr.lifecycle._run_task = AsyncMock()
|
||||
task_id = "task-clean-1"
|
||||
|
||||
await mgr._run_task(task_id)
|
||||
|
||||
assert task_id not in mgr._async_tasks # cleaned up after await
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_task_removes_on_exception(self, mgr):
|
||||
mgr.lifecycle._run_task = AsyncMock(side_effect=Exception("crash"))
|
||||
|
||||
with pytest.raises(Exception, match="crash"):
|
||||
await mgr._run_task("task-exc-1")
|
||||
|
||||
assert "task-exc-1" not in mgr._async_tasks # cleaned up
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# cancel_task
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
|
||||
class TestManagerCancelTask:
|
||||
"""Verify cancel_task @POST for running, done, and nonexistent tasks."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cancel_running_task(self, mgr):
|
||||
"""Running task should be cancelled and removed from tracking."""
|
||||
async def dummy():
|
||||
try:
|
||||
await asyncio.sleep(10)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
|
||||
async_task = asyncio.create_task(dummy())
|
||||
mgr._async_tasks["task-cancel-1"] = async_task
|
||||
await asyncio.sleep(0.01) # let the task start
|
||||
|
||||
result = await mgr.cancel_task("task-cancel-1")
|
||||
assert result is True
|
||||
assert "task-cancel-1" not in mgr._async_tasks
|
||||
# After cancel_task returns, the task may be cancelling but not yet
|
||||
# fully cancelled. Give it a yield to complete cancellation.
|
||||
await asyncio.sleep(0.01)
|
||||
assert async_task.cancelled()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cancel_done_task_returns_false(self, mgr):
|
||||
"""Completed task returns False."""
|
||||
async def quick():
|
||||
return "done"
|
||||
|
||||
async_task = asyncio.create_task(quick())
|
||||
await async_task # wait for completion
|
||||
mgr._async_tasks["task-done-1"] = async_task
|
||||
assert async_task.done()
|
||||
|
||||
result = await mgr.cancel_task("task-done-1")
|
||||
assert result is False
|
||||
assert "task-done-1" in mgr._async_tasks # not removed
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cancel_nonexistent_returns_false(self, mgr):
|
||||
"""Task not in _async_tracks returns False."""
|
||||
result = await mgr.cancel_task("no-such")
|
||||
assert result is False
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# get_task_logs — wrapper
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
|
||||
class TestManagerGetTaskLogs:
|
||||
"""Verify TaskManager.get_task_logs wraps event_bus with task status."""
|
||||
|
||||
def test_get_task_logs_with_existing_task(self, mgr):
|
||||
from src.core.task_manager.models import Task, TaskStatus
|
||||
task = Task(plugin_id="p1", params={})
|
||||
task.status = TaskStatus.SUCCESS
|
||||
mgr.tasks[task.id] = task
|
||||
|
||||
with patch.object(mgr.event_bus, "get_task_logs", return_value=[]) as mock_get:
|
||||
result = mgr.get_task_logs(task.id)
|
||||
mock_get.assert_called_once()
|
||||
args = mock_get.call_args
|
||||
assert args[0][0] == task.id
|
||||
assert args[1]["task_status"] == TaskStatus.SUCCESS
|
||||
|
||||
def test_get_task_logs_with_nonexistent_task(self, mgr):
|
||||
with patch.object(mgr.event_bus, "get_task_logs", return_value=[]) as mock_get:
|
||||
result = mgr.get_task_logs("no-such")
|
||||
mock_get.assert_called_once()
|
||||
args = mock_get.call_args
|
||||
assert args[0][0] == "no-such"
|
||||
assert args[1]["task_status"] is None
|
||||
assert args[1]["task_logs"] == []
|
||||
|
||||
def test_get_task_logs_with_log_filter(self, mgr):
|
||||
from src.core.task_manager.models import Task, TaskStatus
|
||||
task = Task(plugin_id="p1", params={})
|
||||
task.status = TaskStatus.RUNNING
|
||||
mgr.tasks[task.id] = task
|
||||
lf = LogFilter(level="ERROR")
|
||||
|
||||
with patch.object(mgr.event_bus, "get_task_logs", return_value=[]) as mock_get:
|
||||
mgr.get_task_logs(task.id, log_filter=lf)
|
||||
mock_get.assert_called_with(task.id, lf, task_status=TaskStatus.RUNNING, task_logs=task.logs)
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# get_task_log_stats / get_task_log_sources
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
|
||||
class TestManagerLogDelegates:
|
||||
"""Verify direct delegates to event_bus log methods."""
|
||||
|
||||
def test_get_task_log_stats(self, mgr):
|
||||
expected = LogStats(total_count=3, by_level={}, by_source={})
|
||||
mgr.event_bus.get_task_log_stats = MagicMock(return_value=expected)
|
||||
result = mgr.get_task_log_stats("task-1")
|
||||
assert result.total_count == 3
|
||||
mgr.event_bus.get_task_log_stats.assert_called_with("task-1")
|
||||
|
||||
def test_get_task_log_sources(self, mgr):
|
||||
mgr.event_bus.get_task_log_sources = MagicMock(return_value=["sys", "plugin"])
|
||||
result = mgr.get_task_log_sources("task-1")
|
||||
assert result == ["sys", "plugin"]
|
||||
mgr.event_bus.get_task_log_sources.assert_called_with("task-1")
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# Legacy flusher aliases
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
|
||||
class TestManagerFlushAliases:
|
||||
"""Verify legacy _flusher_loop, _flush_logs, _flush_task_logs delegates."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_flusher_loop_delegates(self, mgr):
|
||||
original = mgr.event_bus.async_flusher_loop
|
||||
mgr.event_bus.async_flusher_loop = AsyncMock()
|
||||
await mgr._flusher_loop()
|
||||
mgr.event_bus.async_flusher_loop.assert_called_once()
|
||||
mgr.event_bus.async_flusher_loop = original
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_flush_logs_delegates(self, mgr):
|
||||
original = mgr.event_bus._flush_logs
|
||||
mgr.event_bus._flush_logs = AsyncMock()
|
||||
await mgr._flush_logs()
|
||||
mgr.event_bus._flush_logs.assert_called_once()
|
||||
mgr.event_bus._flush_logs = original
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_flush_task_logs_delegates(self, mgr):
|
||||
original = mgr.event_bus.flush_task_logs
|
||||
mgr.event_bus.flush_task_logs = AsyncMock()
|
||||
await mgr._flush_task_logs("task-1")
|
||||
mgr.event_bus.flush_task_logs.assert_called_with("task-1")
|
||||
mgr.event_bus.flush_task_logs = original
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# Subscribe delegates (status + task events)
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
|
||||
class TestManagerSubscribeDelegates:
|
||||
"""Verify subscription delegates pass through to EventBus."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_subscribe_logs_delegates(self, mgr):
|
||||
mgr.event_bus.subscribe_logs = AsyncMock(return_value="q")
|
||||
result = await mgr.subscribe_logs("t1")
|
||||
assert result == "q"
|
||||
mgr.event_bus.subscribe_logs.assert_called_with("t1")
|
||||
|
||||
def test_unsubscribe_logs_delegates(self, mgr):
|
||||
"""Covers manager.py line 267."""
|
||||
queue = asyncio.Queue()
|
||||
mgr.event_bus.unsubscribe_logs = MagicMock()
|
||||
mgr.unsubscribe_logs("t1", queue)
|
||||
mgr.event_bus.unsubscribe_logs.assert_called_with("t1", queue)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_subscribe_status(self, mgr):
|
||||
mgr.event_bus.subscribe_status = AsyncMock(return_value="q")
|
||||
result = await mgr.subscribe_status("t1")
|
||||
assert result == "q"
|
||||
mgr.event_bus.subscribe_status.assert_called_with("t1")
|
||||
|
||||
def test_unsubscribe_status(self, mgr):
|
||||
queue = asyncio.Queue()
|
||||
mgr.event_bus.unsubscribe_status = MagicMock()
|
||||
mgr.unsubscribe_status("t1", queue)
|
||||
mgr.event_bus.unsubscribe_status.assert_called_with("t1", queue)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_subscribe_task_events(self, mgr):
|
||||
mgr.event_bus.subscribe_task_events = AsyncMock(return_value="q")
|
||||
result = await mgr.subscribe_task_events()
|
||||
assert result == "q"
|
||||
|
||||
def test_unsubscribe_task_events(self, mgr):
|
||||
queue = asyncio.Queue()
|
||||
mgr.event_bus.unsubscribe_task_events = MagicMock()
|
||||
mgr.unsubscribe_task_events(queue)
|
||||
mgr.event_bus.unsubscribe_task_events.assert_called_with(queue)
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# Lifecycle delegates
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
|
||||
class TestManagerLifecycleDelegates:
|
||||
"""Verify lifecycle delegates pass through to JobLifecycle."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_task_delegates(self, mgr):
|
||||
mgr.lifecycle.resolve_task = AsyncMock()
|
||||
await mgr.resolve_task("t1", {"mapping": "done"})
|
||||
mgr.lifecycle.resolve_task.assert_called_with("t1", {"mapping": "done"})
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_wait_for_resolution_delegates(self, mgr):
|
||||
mgr.lifecycle.wait_for_resolution = AsyncMock()
|
||||
await mgr.wait_for_resolution("t1")
|
||||
mgr.lifecycle.wait_for_resolution.assert_called_with("t1")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_wait_for_input_delegates(self, mgr):
|
||||
mgr.lifecycle.wait_for_input = AsyncMock()
|
||||
await mgr.wait_for_input("t1")
|
||||
mgr.lifecycle.wait_for_input.assert_called_with("t1")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_await_input_delegates(self, mgr):
|
||||
mgr.lifecycle.await_input = AsyncMock()
|
||||
mgr._add_log = AsyncMock()
|
||||
await mgr.await_input("t1", {"prompt": "pwd"})
|
||||
mgr.lifecycle.await_input.assert_called_with("t1", {"prompt": "pwd"}, add_log_callback=mgr._add_log)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resume_task_with_password_delegates(self, mgr):
|
||||
mgr.lifecycle.resume_task_with_password = AsyncMock()
|
||||
mgr._add_log = AsyncMock()
|
||||
await mgr.resume_task_with_password("t1", {"db": "pass"})
|
||||
mgr.lifecycle.resume_task_with_password.assert_called_with("t1", {"db": "pass"}, add_log_callback=mgr._add_log)
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# Maintenance event delegates
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
|
||||
class TestManagerMaintenanceDelegates:
|
||||
"""Verify maintenance event delegates pass through to EventBus."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_subscribe_maintenance_events(self, mgr):
|
||||
mgr.event_bus.subscribe_maintenance_events = AsyncMock(return_value="q")
|
||||
result = await mgr.subscribe_maintenance_events()
|
||||
assert result == "q"
|
||||
mgr.event_bus.subscribe_maintenance_events.assert_called_once()
|
||||
|
||||
def test_unsubscribe_maintenance_events(self, mgr):
|
||||
queue = asyncio.Queue()
|
||||
mgr.event_bus.unsubscribe_maintenance_events = MagicMock()
|
||||
mgr.unsubscribe_maintenance_events(queue)
|
||||
mgr.event_bus.unsubscribe_maintenance_events.assert_called_with(queue)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_broadcast_maintenance_event(self, mgr):
|
||||
mgr.event_bus.broadcast_maintenance_event = AsyncMock()
|
||||
await mgr.broadcast_maintenance_event({"type": "test"})
|
||||
mgr.event_bus.broadcast_maintenance_event.assert_called_with({"type": "test"})
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# Dataset event delegates
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
|
||||
class TestManagerDatasetDelegates:
|
||||
"""Verify dataset event delegates pass through to JobLifecycle."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_subscribe_dataset_events(self, mgr):
|
||||
mgr.lifecycle.subscribe_dataset_events = AsyncMock(return_value="q")
|
||||
result = await mgr.subscribe_dataset_events("env-1")
|
||||
assert result == "q"
|
||||
mgr.lifecycle.subscribe_dataset_events.assert_called_with("env-1")
|
||||
|
||||
def test_unsubscribe_dataset_events(self, mgr):
|
||||
queue = asyncio.Queue()
|
||||
mgr.lifecycle.unsubscribe_dataset_events = MagicMock()
|
||||
mgr.unsubscribe_dataset_events("env-1", queue)
|
||||
mgr.lifecycle.unsubscribe_dataset_events.assert_called_with("env-1", queue)
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# load_persisted_tasks
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
|
||||
class TestManagerLoadPersistedTasks:
|
||||
"""Verify load_persisted_tasks delegates to graph."""
|
||||
|
||||
def test_load_persisted_tasks_delegates(self, mgr):
|
||||
mgr.graph.load_persisted_tasks = MagicMock()
|
||||
mgr.load_persisted_tasks()
|
||||
mgr.graph.load_persisted_tasks.assert_called_with(limit=100)
|
||||
|
||||
# #endregion Test.Core.TaskManager.Manager
|
||||
@@ -170,7 +170,7 @@ class TestTranslationOrchestrator:
|
||||
"src.plugins.translate.orchestrator_sql.SQLInsertService"
|
||||
) as MockSQL:
|
||||
mock_svc = MockSQL.return_value
|
||||
mock_svc.generate_and_insert_sql.return_value = {"status": "success"}
|
||||
mock_svc.generate_and_insert_sql = AsyncMock(return_value={"status": "success"})
|
||||
|
||||
import asyncio
|
||||
result = asyncio.run(orch._generate_and_insert_sql(mock_job, mock_run))
|
||||
|
||||
966
backend/tests/test_core/test_async_network.py
Normal file
966
backend/tests/test_core/test_async_network.py
Normal file
@@ -0,0 +1,966 @@
|
||||
# #region Test.AsyncNetwork [C:4] [TYPE Module] [SEMANTICS test, async, network, client, httpx]
|
||||
# @BRIEF Tests for core/utils/async_network.py — AsyncAPIClient.
|
||||
# @RELATION BINDS_TO -> [AsyncNetworkModule]
|
||||
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent.parent / "src"))
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from unittest.mock import AsyncMock, MagicMock, PropertyMock, patch
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def clear_auth_cache():
|
||||
"""Clear SupersetAuthCache between tests."""
|
||||
from src.core.utils.network import SupersetAuthCache
|
||||
|
||||
SupersetAuthCache._entries.clear()
|
||||
|
||||
|
||||
class TestAsyncAPIClientInit:
|
||||
"""AsyncAPIClient.__init__: config parsing, ssl handling."""
|
||||
|
||||
def test_init_with_defaults(self):
|
||||
from src.core.utils.async_network import AsyncAPIClient
|
||||
|
||||
client = AsyncAPIClient({"base_url": "https://superset.example.com", "auth": {"username": "admin", "password": "pass"}})
|
||||
assert client.base_url == "https://superset.example.com"
|
||||
assert client.api_base_url == "https://superset.example.com/api/v1"
|
||||
assert client._authenticated is False
|
||||
assert client._semaphore is None
|
||||
|
||||
def test_init_ssl_true_creates_default_context(self):
|
||||
import ssl
|
||||
from src.core.utils.async_network import AsyncAPIClient
|
||||
|
||||
real_ctx = ssl.create_default_context()
|
||||
with patch("ssl.create_default_context", return_value=real_ctx) as mock_ctx:
|
||||
client = AsyncAPIClient({"base_url": "https://test.com", "auth": {}}, verify_ssl=True)
|
||||
mock_ctx.assert_called_once()
|
||||
assert client.base_url == "https://test.com"
|
||||
|
||||
def test_init_ssl_false(self):
|
||||
from src.core.utils.async_network import AsyncAPIClient
|
||||
|
||||
client = AsyncAPIClient({"base_url": "https://test.com", "auth": {}}, verify_ssl=False)
|
||||
assert client.base_url == "https://test.com"
|
||||
assert client._client is not None
|
||||
|
||||
def test_init_with_semaphore(self):
|
||||
from src.core.utils.async_network import AsyncAPIClient
|
||||
|
||||
sem = asyncio.Semaphore(5)
|
||||
client = AsyncAPIClient({"base_url": "https://test.com", "auth": {}}, semaphore=sem)
|
||||
assert client._semaphore is sem
|
||||
|
||||
def test_normalize_base_url_removes_api_v1_suffix(self):
|
||||
from src.core.utils.async_network import AsyncAPIClient
|
||||
|
||||
client = AsyncAPIClient({"base_url": "https://test.com/api/v1", "auth": {}})
|
||||
assert client.base_url == "https://test.com"
|
||||
|
||||
def test_normalize_base_url_strips_trailing_slash(self):
|
||||
from src.core.utils.async_network import AsyncAPIClient
|
||||
|
||||
client = AsyncAPIClient({"base_url": "https://test.com/", "auth": {}})
|
||||
assert client.base_url == "https://test.com"
|
||||
|
||||
def test_init_with_custom_timeout(self):
|
||||
from src.core.utils.async_network import AsyncAPIClient
|
||||
|
||||
client = AsyncAPIClient({"base_url": "https://test.com", "auth": {}}, timeout=60)
|
||||
assert client.request_settings["timeout"] == 60
|
||||
|
||||
def test_init_with_ssl_context(self):
|
||||
import ssl
|
||||
|
||||
ctx = ssl.create_default_context()
|
||||
from src.core.utils.async_network import AsyncAPIClient
|
||||
|
||||
client = AsyncAPIClient({"base_url": "https://test.com", "auth": {}}, verify_ssl=ctx)
|
||||
# The client should be initialized with the custom SSL context
|
||||
assert client.request_settings["verify_ssl"] is ctx
|
||||
|
||||
|
||||
class TestBuildApiUrl:
|
||||
"""_build_api_url: endpoint -> full URL."""
|
||||
|
||||
@pytest.fixture
|
||||
def client(self):
|
||||
from src.core.utils.async_network import AsyncAPIClient
|
||||
|
||||
return AsyncAPIClient({"base_url": "https://superset.example.com", "auth": {}})
|
||||
|
||||
def test_relative_endpoint_adds_api_v1_prefix(self, client):
|
||||
url = client._build_api_url("/chart/")
|
||||
assert url == "https://superset.example.com/api/v1/chart/"
|
||||
|
||||
def test_relative_endpoint_without_leading_slash(self, client):
|
||||
url = client._build_api_url("chart/")
|
||||
assert url == "https://superset.example.com/api/v1/chart/"
|
||||
|
||||
def test_absolute_url_passthrough(self, client):
|
||||
url = client._build_api_url("https://other.com/api/v1/chart/")
|
||||
assert url == "https://other.com/api/v1/chart/"
|
||||
|
||||
def test_already_has_api_v1(self, client):
|
||||
url = client._build_api_url("/api/v1/chart/")
|
||||
assert url == "https://superset.example.com/api/v1/chart/"
|
||||
|
||||
def test_api_v1_root(self, client):
|
||||
url = client._build_api_url("/api/v1")
|
||||
assert url == "https://superset.example.com/api/v1"
|
||||
|
||||
def test_empty_endpoint_returns_base(self, client):
|
||||
url = client._build_api_url("")
|
||||
assert url == "https://superset.example.com/api/v1/"
|
||||
|
||||
|
||||
class TestAuthLock:
|
||||
"""_get_auth_lock: class-level async lock storage."""
|
||||
|
||||
def test_returns_same_lock_for_same_key(self):
|
||||
from src.core.utils.async_network import AsyncAPIClient
|
||||
|
||||
lock1 = AsyncAPIClient._get_auth_lock(("url", "user", True))
|
||||
lock2 = AsyncAPIClient._get_auth_lock(("url", "user", True))
|
||||
assert lock1 is lock2
|
||||
|
||||
def test_different_keys_return_different_locks(self):
|
||||
from src.core.utils.async_network import AsyncAPIClient
|
||||
|
||||
lock1 = AsyncAPIClient._get_auth_lock(("url1", "user", True))
|
||||
lock2 = AsyncAPIClient._get_auth_lock(("url2", "user", True))
|
||||
assert lock1 is not lock2
|
||||
|
||||
|
||||
class TestAuthenticate:
|
||||
"""authenticate(): cache hit, cache miss, error paths."""
|
||||
|
||||
@pytest.fixture
|
||||
def client(self):
|
||||
from src.core.utils.async_network import AsyncAPIClient
|
||||
|
||||
c = AsyncAPIClient({"base_url": "https://superset.example.com", "auth": {"username": "admin", "password": "pass"}})
|
||||
c._client = AsyncMock()
|
||||
return c
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cache_hit_skips_login(self, client):
|
||||
from src.core.utils.network import SupersetAuthCache
|
||||
|
||||
SupersetAuthCache.set(
|
||||
client._auth_cache_key,
|
||||
{"access_token": "cached_access", "csrf_token": "cached_csrf"},
|
||||
)
|
||||
# Mock CSRF refresh
|
||||
mock_csrf_resp = MagicMock()
|
||||
mock_csrf_resp.is_success = True
|
||||
mock_csrf_resp.json.return_value = {"result": "new_csrf"}
|
||||
client._client.get = AsyncMock(return_value=mock_csrf_resp)
|
||||
|
||||
tokens = await client.authenticate()
|
||||
assert tokens["access_token"] == "cached_access"
|
||||
assert client._authenticated is True
|
||||
client._client.post.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cache_miss_fresh_login(self, client):
|
||||
mock_login_resp = MagicMock()
|
||||
mock_login_resp.status_code = 200
|
||||
mock_login_resp.json.return_value = {"access_token": "new_access"}
|
||||
mock_login_resp.raise_for_status = MagicMock()
|
||||
|
||||
mock_csrf_resp = MagicMock()
|
||||
mock_csrf_resp.status_code = 200
|
||||
mock_csrf_resp.json.return_value = {"result": "new_csrf"}
|
||||
mock_csrf_resp.raise_for_status = MagicMock()
|
||||
|
||||
client._client.post = AsyncMock(return_value=mock_login_resp)
|
||||
client._client.get = AsyncMock(return_value=mock_csrf_resp)
|
||||
|
||||
tokens = await client.authenticate()
|
||||
assert tokens["access_token"] == "new_access"
|
||||
assert tokens["csrf_token"] == "new_csrf"
|
||||
assert client._authenticated is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_login_http_502_raises_network_error(self, client):
|
||||
from src.core.utils.network import NetworkError
|
||||
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status_code = 502
|
||||
exc = httpx.HTTPStatusError("Bad Gateway", request=MagicMock(), response=mock_resp)
|
||||
client._client.post = AsyncMock(side_effect=exc)
|
||||
|
||||
with pytest.raises(NetworkError, match="Environment unavailable"):
|
||||
await client.authenticate()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_login_http_503_raises_network_error(self, client):
|
||||
from src.core.utils.network import NetworkError
|
||||
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status_code = 503
|
||||
exc = httpx.HTTPStatusError("Service Unavailable", request=MagicMock(), response=mock_resp)
|
||||
client._client.post = AsyncMock(side_effect=exc)
|
||||
|
||||
with pytest.raises(NetworkError, match="Environment unavailable"):
|
||||
await client.authenticate()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_login_http_401_raises_authentication_error(self, client):
|
||||
from src.core.utils.network import AuthenticationError
|
||||
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status_code = 401
|
||||
exc = httpx.HTTPStatusError("Unauthorized", request=MagicMock(), response=mock_resp)
|
||||
client._client.post = AsyncMock(side_effect=exc)
|
||||
|
||||
with pytest.raises(AuthenticationError):
|
||||
await client.authenticate()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_login_httpx_error_raises_network_error(self, client):
|
||||
from src.core.utils.network import NetworkError
|
||||
|
||||
client._client.post = AsyncMock(side_effect=httpx.ConnectError("Connection refused"))
|
||||
|
||||
with pytest.raises(NetworkError, match="Network or parsing error"):
|
||||
await client.authenticate()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cache_refresh_csrf_failure_does_not_block(self, client):
|
||||
from src.core.utils.network import SupersetAuthCache
|
||||
|
||||
SupersetAuthCache.set(
|
||||
client._auth_cache_key,
|
||||
{"access_token": "cached_access", "csrf_token": "cached_csrf"},
|
||||
)
|
||||
# CSRF refresh fails silently
|
||||
client._client.get = AsyncMock(side_effect=Exception("Connection lost"))
|
||||
|
||||
tokens = await client.authenticate()
|
||||
# Should still succeed with cached tokens
|
||||
assert tokens["access_token"] == "cached_access"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cache_hit_after_wait(self, client):
|
||||
"""Second caller after lock should find cache populated."""
|
||||
from src.core.utils.network import SupersetAuthCache
|
||||
|
||||
# Simulate first caller putting tokens in cache
|
||||
SupersetAuthCache.set(
|
||||
client._auth_cache_key,
|
||||
{"access_token": "cached_after", "csrf_token": "cached_csrf"},
|
||||
)
|
||||
mock_csrf_resp = MagicMock()
|
||||
mock_csrf_resp.is_success = False
|
||||
client._client.get = AsyncMock(return_value=mock_csrf_resp)
|
||||
|
||||
tokens = await client.authenticate() # Goes through lock path
|
||||
assert tokens["access_token"] == "cached_after"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_login_missing_access_token_key(self, client):
|
||||
mock_login_resp = MagicMock()
|
||||
mock_login_resp.status_code = 200
|
||||
mock_login_resp.json.return_value = {"not_access_token": "value"}
|
||||
mock_login_resp.raise_for_status = MagicMock()
|
||||
client._client.post = AsyncMock(return_value=mock_login_resp)
|
||||
|
||||
from src.core.utils.network import NetworkError
|
||||
|
||||
with pytest.raises(NetworkError):
|
||||
await client.authenticate()
|
||||
|
||||
|
||||
class TestGetHeaders:
|
||||
"""get_headers(): returns auth headers, may trigger auth."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unauthenticated_calls_authenticate(self):
|
||||
from src.core.utils.async_network import AsyncAPIClient
|
||||
|
||||
client = AsyncAPIClient({"base_url": "https://test.com", "auth": {}})
|
||||
# Simulate what authenticate sets
|
||||
async def fake_auth():
|
||||
client._authenticated = True
|
||||
client._tokens = {"access_token": "acc", "csrf_token": "csrf"}
|
||||
return client._tokens
|
||||
client.authenticate = fake_auth
|
||||
client._authenticated = False
|
||||
|
||||
headers = await client.get_headers()
|
||||
assert "Authorization" in headers
|
||||
assert headers["Authorization"] == "Bearer acc"
|
||||
assert headers["X-CSRFToken"] == "csrf"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_authenticated_returns_headers(self):
|
||||
from src.core.utils.async_network import AsyncAPIClient
|
||||
|
||||
client = AsyncAPIClient({"base_url": "https://test.com", "auth": {}})
|
||||
client._authenticated = True
|
||||
client._tokens = {"access_token": "acc", "csrf_token": "csrf"}
|
||||
client.authenticate = AsyncMock()
|
||||
|
||||
headers = await client.get_headers()
|
||||
client.authenticate.assert_not_called()
|
||||
assert headers["Referer"] == "https://test.com"
|
||||
|
||||
|
||||
class TestRequest:
|
||||
"""request(): main HTTP request method."""
|
||||
|
||||
@pytest.fixture
|
||||
def client(self):
|
||||
from src.core.utils.async_network import AsyncAPIClient
|
||||
|
||||
c = AsyncAPIClient({"base_url": "https://test.com", "auth": {}})
|
||||
c._client = AsyncMock()
|
||||
c._authenticated = True
|
||||
c._tokens = {"access_token": "acc", "csrf_token": "csrf"}
|
||||
return c
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_request(self, client):
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status_code = 200
|
||||
mock_resp.json.return_value = {"result": "ok"}
|
||||
client._client.request = AsyncMock(return_value=mock_resp)
|
||||
|
||||
result = await client.request(method="GET", endpoint="/chart/")
|
||||
assert result == {"result": "ok"}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_post_with_dict_data(self, client):
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status_code = 200
|
||||
mock_resp.json.return_value = {"id": 1}
|
||||
client._client.request = AsyncMock(return_value=mock_resp)
|
||||
|
||||
result = await client.request(method="POST", endpoint="/chart/", data={"key": "val"})
|
||||
assert result == {"id": 1}
|
||||
call_kwargs = client._client.request.call_args[1]
|
||||
assert call_kwargs["json"] == {"key": "val"}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_post_with_pre_serialized_string(self, client):
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status_code = 200
|
||||
mock_resp.json.return_value = {"id": 1}
|
||||
client._client.request = AsyncMock(return_value=mock_resp)
|
||||
|
||||
result = await client.request(method="POST", endpoint="/chart/", data='{"key":"val"}')
|
||||
assert result == {"id": 1}
|
||||
call_kwargs = client._client.request.call_args[1]
|
||||
assert call_kwargs["content"] == b'{"key":"val"}'
|
||||
assert "json" not in call_kwargs
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_raw_response(self, client):
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status_code = 200
|
||||
client._client.request = AsyncMock(return_value=mock_resp)
|
||||
|
||||
result = await client.request(method="GET", endpoint="/chart/1", raw_response=True)
|
||||
assert result is mock_resp
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_401_retry_for_get(self, client):
|
||||
# Pre-authenticate so 401 retry works without hitting login flow.
|
||||
# We need authenticate() to succeed quickly when called in the retry path.
|
||||
client._authenticated = True
|
||||
client._tokens = {"access_token": "acc", "csrf_token": "csrf"}
|
||||
|
||||
# Cache tokens so SupersetAuthCache.get returns something
|
||||
from src.core.utils.network import SupersetAuthCache
|
||||
SupersetAuthCache.set(
|
||||
client._auth_cache_key,
|
||||
{"access_token": "retry_token", "csrf_token": "retry_csrf"},
|
||||
)
|
||||
# Un-authenticate so get_headers() calls authenticate() (which will hit cache)
|
||||
client._authenticated = False
|
||||
|
||||
first_resp = MagicMock()
|
||||
first_resp.status_code = 401
|
||||
second_resp = MagicMock()
|
||||
second_resp.status_code = 200
|
||||
second_resp.json.return_value = {"result": "retried"}
|
||||
client._client.request = AsyncMock(side_effect=[first_resp, second_resp])
|
||||
# Mock CSRF refresh during auth
|
||||
mock_csrf_resp = MagicMock()
|
||||
mock_csrf_resp.is_success = True
|
||||
mock_csrf_resp.json.return_value = {"result": "csrf_new"}
|
||||
client._client.get = AsyncMock(return_value=mock_csrf_resp)
|
||||
|
||||
result = await client.request(method="GET", endpoint="/chart/")
|
||||
assert result == {"result": "retried"}
|
||||
assert client._client.request.call_count == 2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_401_raises_for_non_get(self, client):
|
||||
from src.core.utils.network import AuthenticationError
|
||||
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status_code = 401
|
||||
client._client.request = AsyncMock(return_value=mock_resp)
|
||||
|
||||
with pytest.raises(AuthenticationError):
|
||||
await client.request(method="POST", endpoint="/chart/")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_json_parse_error(self, client):
|
||||
from src.core.utils.network import SupersetAPIError
|
||||
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status_code = 200
|
||||
mock_resp.json.side_effect = ValueError("No JSON")
|
||||
mock_resp.text = "not json"
|
||||
client._client.request = AsyncMock(return_value=mock_resp)
|
||||
|
||||
with pytest.raises(SupersetAPIError, match="Failed to parse response JSON"):
|
||||
await client.request(method="GET", endpoint="/chart/")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_semaphore_acquire_release(self, client):
|
||||
sem = asyncio.Semaphore(1)
|
||||
client._semaphore = sem
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status_code = 200
|
||||
mock_resp.json.return_value = {"ok": True}
|
||||
client._client.request = AsyncMock(return_value=mock_resp)
|
||||
|
||||
result = await client.request(method="GET", endpoint="/test")
|
||||
assert result == {"ok": True}
|
||||
# Semaphore released implies no deadlock
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_http_status_error_404_dashboard(self, client):
|
||||
from src.core.utils.network import DashboardNotFoundError
|
||||
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status_code = 404
|
||||
exc = httpx.HTTPStatusError("Not Found", request=MagicMock(), response=mock_resp)
|
||||
client._client.request = AsyncMock(side_effect=exc)
|
||||
client._is_dashboard_endpoint = MagicMock(return_value=True)
|
||||
|
||||
with pytest.raises(DashboardNotFoundError):
|
||||
await client.request(method="GET", endpoint="/dashboard/42")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_http_status_error_404_generic(self, client):
|
||||
from src.core.utils.network import SupersetAPIError
|
||||
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status_code = 404
|
||||
exc = httpx.HTTPStatusError("Not Found", request=MagicMock(), response=mock_resp)
|
||||
client._client.request = AsyncMock(side_effect=exc)
|
||||
client._is_dashboard_endpoint = MagicMock(return_value=False)
|
||||
|
||||
with pytest.raises(SupersetAPIError, match="API resource not found"):
|
||||
await client.request(method="GET", endpoint="/chart/99")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_http_status_error_403(self, client):
|
||||
from src.core.utils.network import PermissionDeniedError
|
||||
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status_code = 403
|
||||
exc = httpx.HTTPStatusError("Forbidden", request=MagicMock(), response=mock_resp)
|
||||
client._client.request = AsyncMock(side_effect=exc)
|
||||
|
||||
with pytest.raises(PermissionDeniedError):
|
||||
await client.request(method="GET", endpoint="/chart/")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_http_status_error_502(self, client):
|
||||
from src.core.utils.network import NetworkError
|
||||
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status_code = 502
|
||||
exc = httpx.HTTPStatusError("Bad Gateway", request=MagicMock(), response=mock_resp)
|
||||
client._client.request = AsyncMock(side_effect=exc)
|
||||
|
||||
with pytest.raises(NetworkError, match="Environment unavailable"):
|
||||
await client.request(method="GET", endpoint="/chart/")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_http_status_error_generic(self, client):
|
||||
from src.core.utils.network import SupersetAPIError
|
||||
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status_code = 500
|
||||
mock_resp.text = "Internal error"
|
||||
exc = httpx.HTTPStatusError("Server Error", request=MagicMock(), response=mock_resp)
|
||||
client._client.request = AsyncMock(side_effect=exc)
|
||||
|
||||
with pytest.raises(SupersetAPIError, match="API Error 500"):
|
||||
await client.request(method="GET", endpoint="/chart/")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_httpx_timeout_error(self, client):
|
||||
from src.core.utils.network import NetworkError
|
||||
|
||||
client._client.request = AsyncMock(side_effect=httpx.TimeoutException("Timed out"))
|
||||
|
||||
with pytest.raises(NetworkError, match="Request timeout"):
|
||||
await client.request(method="GET", endpoint="/chart/")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_httpx_connect_error(self, client):
|
||||
from src.core.utils.network import NetworkError
|
||||
|
||||
client._client.request = AsyncMock(side_effect=httpx.ConnectError("Connection refused"))
|
||||
|
||||
with pytest.raises(NetworkError, match="Connection error"):
|
||||
await client.request(method="GET", endpoint="/chart/")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_httpx_generic_error(self, client):
|
||||
from src.core.utils.network import NetworkError
|
||||
|
||||
client._client.request = AsyncMock(side_effect=httpx.HTTPError("Generic error"))
|
||||
|
||||
with pytest.raises(NetworkError, match="Unknown network error"):
|
||||
await client.request(method="GET", endpoint="/chart/")
|
||||
|
||||
|
||||
class TestHandleHttpError:
|
||||
"""_handle_http_error: translates HTTP status codes to exceptions."""
|
||||
|
||||
@pytest.fixture
|
||||
def client(self):
|
||||
from src.core.utils.async_network import AsyncAPIClient
|
||||
|
||||
return AsyncAPIClient({"base_url": "https://test.com", "auth": {}})
|
||||
|
||||
def test_502_raises_network_error(self, client):
|
||||
from src.core.utils.network import NetworkError
|
||||
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status_code = 502
|
||||
exc = httpx.HTTPStatusError("", request=MagicMock(), response=mock_resp)
|
||||
with pytest.raises(NetworkError, match="Environment unavailable"):
|
||||
client._handle_http_error(exc, "/chart/")
|
||||
|
||||
def test_503_raises_network_error(self, client):
|
||||
from src.core.utils.network import NetworkError
|
||||
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status_code = 503
|
||||
exc = httpx.HTTPStatusError("", request=MagicMock(), response=mock_resp)
|
||||
with pytest.raises(NetworkError, match="Environment unavailable"):
|
||||
client._handle_http_error(exc, "/chart/")
|
||||
|
||||
def test_404_dashboard(self, client):
|
||||
from src.core.utils.network import DashboardNotFoundError
|
||||
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status_code = 404
|
||||
exc = httpx.HTTPStatusError("", request=MagicMock(), response=mock_resp)
|
||||
with patch.object(client, '_is_dashboard_endpoint', return_value=True):
|
||||
with pytest.raises(DashboardNotFoundError):
|
||||
client._handle_http_error(exc, "/dashboard/5")
|
||||
|
||||
def test_404_generic(self, client):
|
||||
from src.core.utils.network import SupersetAPIError
|
||||
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status_code = 404
|
||||
exc = httpx.HTTPStatusError("", request=MagicMock(), response=mock_resp)
|
||||
with patch.object(client, '_is_dashboard_endpoint', return_value=False):
|
||||
with pytest.raises(SupersetAPIError, match="API resource not found"):
|
||||
client._handle_http_error(exc, "/chart/5")
|
||||
|
||||
def test_403_raises_permission_denied(self, client):
|
||||
from src.core.utils.network import PermissionDeniedError
|
||||
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status_code = 403
|
||||
exc = httpx.HTTPStatusError("", request=MagicMock(), response=mock_resp)
|
||||
with pytest.raises(PermissionDeniedError):
|
||||
client._handle_http_error(exc, "/chart/")
|
||||
|
||||
def test_401_raises_authentication_error(self, client):
|
||||
from src.core.utils.network import AuthenticationError
|
||||
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status_code = 401
|
||||
exc = httpx.HTTPStatusError("", request=MagicMock(), response=mock_resp)
|
||||
with pytest.raises(AuthenticationError):
|
||||
client._handle_http_error(exc, "/chart/")
|
||||
|
||||
def test_generic_500(self, client):
|
||||
from src.core.utils.network import SupersetAPIError
|
||||
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status_code = 500
|
||||
mock_resp.text = "Server broke"
|
||||
exc = httpx.HTTPStatusError("", request=MagicMock(), response=mock_resp)
|
||||
with pytest.raises(SupersetAPIError, match="API Error 500"):
|
||||
client._handle_http_error(exc, "/chart/")
|
||||
|
||||
|
||||
class TestIsDashboardEndpoint:
|
||||
"""_is_dashboard_endpoint: recognizes dashboard endpoints."""
|
||||
|
||||
@pytest.fixture
|
||||
def client(self):
|
||||
from src.core.utils.async_network import AsyncAPIClient
|
||||
|
||||
return AsyncAPIClient({"base_url": "https://test.com", "auth": {}})
|
||||
|
||||
def test_dashboard_endpoint(self, client):
|
||||
assert client._is_dashboard_endpoint("/dashboard/42") is True
|
||||
|
||||
def test_dashboard_list(self, client):
|
||||
assert client._is_dashboard_endpoint("/dashboard") is True
|
||||
|
||||
def test_chart_endpoint(self, client):
|
||||
assert client._is_dashboard_endpoint("/chart/42") is False
|
||||
|
||||
def test_absolute_url_dashboard(self, client):
|
||||
assert client._is_dashboard_endpoint("https://superset.example.com/api/v1/dashboard/42") is True
|
||||
|
||||
def test_absolute_url_chart(self, client):
|
||||
assert client._is_dashboard_endpoint("https://superset.example.com/api/v1/chart/42") is False
|
||||
|
||||
def test_empty_endpoint(self, client):
|
||||
assert client._is_dashboard_endpoint("") is False
|
||||
|
||||
def test_none_endpoint(self, client):
|
||||
assert client._is_dashboard_endpoint(None) is False
|
||||
|
||||
def test_dashboard_with_api_v1_prefix(self, client):
|
||||
assert client._is_dashboard_endpoint("/api/v1/dashboard/1") is True
|
||||
|
||||
def test_absolute_url_bad_format(self, client):
|
||||
# URL with /api/v1 not present
|
||||
assert client._is_dashboard_endpoint("https://other.com/not-api/dashboard/1") is False
|
||||
|
||||
|
||||
class TestFetchPaginatedCount:
|
||||
"""fetch_paginated_count: gets total count."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_success(self):
|
||||
from src.core.utils.async_network import AsyncAPIClient
|
||||
|
||||
client = AsyncAPIClient({"base_url": "https://test.com", "auth": {}})
|
||||
client.request = AsyncMock(return_value={"count": 42})
|
||||
|
||||
result = await client.fetch_paginated_count("/chart/", {"page": 0, "page_size": 1})
|
||||
assert result == 42
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_non_dict_response(self):
|
||||
from src.core.utils.async_network import AsyncAPIClient
|
||||
|
||||
client = AsyncAPIClient({"base_url": "https://test.com", "auth": {}})
|
||||
client.request = AsyncMock(return_value="not_a_dict")
|
||||
|
||||
result = await client.fetch_paginated_count("/chart/", {})
|
||||
assert result == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_missing_count_field(self):
|
||||
from src.core.utils.async_network import AsyncAPIClient
|
||||
|
||||
client = AsyncAPIClient({"base_url": "https://test.com", "auth": {}})
|
||||
client.request = AsyncMock(return_value={"other": 10})
|
||||
|
||||
result = await client.fetch_paginated_count("/chart/", {})
|
||||
assert result == 0
|
||||
|
||||
|
||||
class TestFetchPaginatedData:
|
||||
"""fetch_paginated_data: page iteration."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_single_page(self):
|
||||
from src.core.utils.async_network import AsyncAPIClient
|
||||
|
||||
client = AsyncAPIClient({"base_url": "https://test.com", "auth": {}})
|
||||
client.request = AsyncMock(
|
||||
return_value={"result": [{"id": 1}, {"id": 2}], "count": 2}
|
||||
)
|
||||
|
||||
result = await client.fetch_paginated_data(
|
||||
"/chart/",
|
||||
{"base_query": {"page_size": 100}, "results_field": "result"},
|
||||
)
|
||||
assert len(result) == 2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_multiple_pages(self):
|
||||
from src.core.utils.async_network import AsyncAPIClient
|
||||
|
||||
client = AsyncAPIClient({"base_url": "https://test.com", "auth": {}})
|
||||
client.request = AsyncMock(
|
||||
side_effect=[
|
||||
{"result": [{"id": 1}, {"id": 2}], "count": 5},
|
||||
{"result": [{"id": 3}, {"id": 4}]},
|
||||
{"result": [{"id": 5}]},
|
||||
]
|
||||
)
|
||||
|
||||
result = await client.fetch_paginated_data(
|
||||
"/chart/",
|
||||
{"base_query": {"page_size": 2}, "results_field": "result"},
|
||||
)
|
||||
assert len(result) == 5
|
||||
assert client.request.call_count == 3
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_total_count_from_options(self):
|
||||
from src.core.utils.async_network import AsyncAPIClient
|
||||
|
||||
client = AsyncAPIClient({"base_url": "https://test.com", "auth": {}})
|
||||
client.request = AsyncMock(
|
||||
side_effect=[
|
||||
{"result": [{"id": 1}], "count": 1},
|
||||
{"result": [{"id": 2}]},
|
||||
]
|
||||
)
|
||||
|
||||
result = await client.fetch_paginated_data(
|
||||
"/chart/",
|
||||
{"base_query": {"page_size": 1}, "results_field": "result", "total_count": 2},
|
||||
)
|
||||
assert len(result) == 2
|
||||
assert client.request.call_count == 2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_non_dict_first_page_returns_empty(self):
|
||||
from src.core.utils.async_network import AsyncAPIClient
|
||||
|
||||
client = AsyncAPIClient({"base_url": "https://test.com", "auth": {}})
|
||||
client.request = AsyncMock(return_value="not_a_dict")
|
||||
|
||||
result = await client.fetch_paginated_data(
|
||||
"/chart/",
|
||||
{"base_query": {"page_size": 100}, "results_field": "result"},
|
||||
)
|
||||
assert result == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_non_dict_subsequent_page_skipped(self):
|
||||
from src.core.utils.async_network import AsyncAPIClient
|
||||
|
||||
client = AsyncAPIClient({"base_url": "https://test.com", "auth": {}})
|
||||
client.request = AsyncMock(
|
||||
side_effect=[
|
||||
{"result": [{"id": 1}], "count": 3},
|
||||
"not_a_dict",
|
||||
{"result": [{"id": 3}]},
|
||||
]
|
||||
)
|
||||
|
||||
result = await client.fetch_paginated_data(
|
||||
"/chart/",
|
||||
{"base_query": {"page_size": 1}, "results_field": "result"},
|
||||
)
|
||||
# First page has 1, second page is skipped, third page has 1
|
||||
assert len(result) == 2
|
||||
|
||||
|
||||
class TestUploadFile:
|
||||
"""upload_file: multipart file upload."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upload_with_path_string(self):
|
||||
from src.core.utils.async_network import AsyncAPIClient
|
||||
|
||||
client = AsyncAPIClient({"base_url": "https://test.com", "auth": {}})
|
||||
client._authenticated = True
|
||||
client._tokens = {"access_token": "acc", "csrf_token": "csrf"}
|
||||
client._client = AsyncMock()
|
||||
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status_code = 200
|
||||
mock_resp.json.return_value = {"result": "imported"}
|
||||
client._client.post = AsyncMock(return_value=mock_resp)
|
||||
|
||||
with patch("pathlib.Path.exists", return_value=True):
|
||||
with patch("pathlib.Path.read_bytes", return_value=b"zip content"):
|
||||
result = await client.upload_file(
|
||||
endpoint="/dashboard/import/",
|
||||
file_info={
|
||||
"file_obj": "/tmp/test.zip",
|
||||
"file_name": "test.zip",
|
||||
"form_field": "formData",
|
||||
},
|
||||
extra_data={"overwrite": "true"},
|
||||
)
|
||||
assert result == {"result": "imported"}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upload_with_bytesio(self):
|
||||
import io
|
||||
|
||||
from src.core.utils.async_network import AsyncAPIClient
|
||||
|
||||
client = AsyncAPIClient({"base_url": "https://test.com", "auth": {}})
|
||||
client._authenticated = True
|
||||
client._tokens = {"access_token": "acc", "csrf_token": "csrf"}
|
||||
client._client = AsyncMock()
|
||||
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status_code = 200
|
||||
mock_resp.json.return_value = {"result": "ok"}
|
||||
client._client.post = AsyncMock(return_value=mock_resp)
|
||||
|
||||
buf = io.BytesIO(b"zip content")
|
||||
result = await client.upload_file(
|
||||
endpoint="/dashboard/import/",
|
||||
file_info={"file_obj": buf, "file_name": "test.zip"},
|
||||
)
|
||||
assert result == {"result": "ok"}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upload_file_not_found(self):
|
||||
from src.core.utils.async_network import AsyncAPIClient
|
||||
|
||||
client = AsyncAPIClient({"base_url": "https://test.com", "auth": {}})
|
||||
client._authenticated = True
|
||||
client._tokens = {"access_token": "acc", "csrf_token": "csrf"}
|
||||
client._client = AsyncMock()
|
||||
|
||||
with patch("pathlib.Path.exists", return_value=False):
|
||||
with pytest.raises(FileNotFoundError, match="does not exist"):
|
||||
await client.upload_file(
|
||||
endpoint="/dashboard/import/",
|
||||
file_info={"file_obj": "/nonexistent.zip", "file_name": "nope.zip"},
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upload_unsupported_type(self):
|
||||
from src.core.utils.async_network import AsyncAPIClient
|
||||
|
||||
client = AsyncAPIClient({"base_url": "https://test.com", "auth": {}})
|
||||
client._authenticated = True
|
||||
client._tokens = {"access_token": "acc", "csrf_token": "csrf"}
|
||||
client._client = AsyncMock()
|
||||
|
||||
with pytest.raises(TypeError, match="Unsupported file_obj"):
|
||||
await client.upload_file(
|
||||
endpoint="/dashboard/import/",
|
||||
file_info={"file_obj": 42, "file_name": "nope.zip"},
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upload_http_error(self):
|
||||
from src.core.utils.async_network import AsyncAPIClient
|
||||
from src.core.utils.network import SupersetAPIError
|
||||
|
||||
client = AsyncAPIClient({"base_url": "https://test.com", "auth": {}})
|
||||
client._authenticated = True
|
||||
client._tokens = {"access_token": "acc", "csrf_token": "csrf"}
|
||||
client._client = AsyncMock()
|
||||
|
||||
exc = httpx.HTTPStatusError("Error", request=MagicMock(), response=MagicMock())
|
||||
exc.response.text = "Bad request"
|
||||
client._client.post = AsyncMock(side_effect=exc)
|
||||
|
||||
with patch("pathlib.Path.exists", return_value=True):
|
||||
with patch("pathlib.Path.read_bytes", return_value=b"zip"):
|
||||
with pytest.raises(SupersetAPIError, match="API error during upload"):
|
||||
await client.upload_file(
|
||||
endpoint="/dashboard/import/",
|
||||
file_info={"file_obj": "/tmp/test.zip", "file_name": "test.zip"},
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upload_network_error(self):
|
||||
from src.core.utils.async_network import AsyncAPIClient
|
||||
from src.core.utils.network import NetworkError
|
||||
|
||||
client = AsyncAPIClient({"base_url": "https://test.com", "auth": {}})
|
||||
client._authenticated = True
|
||||
client._tokens = {"access_token": "acc", "csrf_token": "csrf"}
|
||||
client._client = AsyncMock()
|
||||
client._client.post = AsyncMock(side_effect=httpx.HTTPError("Network error"))
|
||||
|
||||
with patch("pathlib.Path.exists", return_value=True):
|
||||
with patch("pathlib.Path.read_bytes", return_value=b"zip"):
|
||||
with pytest.raises(NetworkError, match="Network error during upload"):
|
||||
await client.upload_file(
|
||||
endpoint="/dashboard/import/",
|
||||
file_info={"file_obj": "/tmp/test.zip", "file_name": "test.zip"},
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upload_non_200_json_error(self):
|
||||
import io
|
||||
|
||||
from src.core.utils.async_network import AsyncAPIClient
|
||||
from src.core.utils.network import SupersetAPIError
|
||||
|
||||
client = AsyncAPIClient({"base_url": "https://test.com", "auth": {}})
|
||||
client._authenticated = True
|
||||
client._tokens = {"access_token": "acc", "csrf_token": "csrf"}
|
||||
client._client = AsyncMock()
|
||||
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status_code = 200
|
||||
mock_resp.json.side_effect = ValueError("Not JSON")
|
||||
client._client.post = AsyncMock(return_value=mock_resp)
|
||||
|
||||
buf = io.BytesIO(b"zip")
|
||||
with pytest.raises(SupersetAPIError, match="API error during upload"):
|
||||
await client.upload_file(
|
||||
endpoint="/dashboard/import/",
|
||||
file_info={"file_obj": buf, "file_name": "test.zip"},
|
||||
)
|
||||
|
||||
|
||||
class TestAclose:
|
||||
"""aclose: closes underlying httpx client."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_closes_client(self):
|
||||
from src.core.utils.async_network import AsyncAPIClient
|
||||
|
||||
client = AsyncAPIClient({"base_url": "https://test.com", "auth": {}})
|
||||
client._client = AsyncMock()
|
||||
client._client.aclose = AsyncMock()
|
||||
|
||||
await client.aclose()
|
||||
client._client.aclose.assert_awaited_once()
|
||||
|
||||
|
||||
class TestNormalizeBaseUrl:
|
||||
"""_normalize_base_url: URL normalization edge cases."""
|
||||
|
||||
@pytest.fixture
|
||||
def client(self):
|
||||
from src.core.utils.async_network import AsyncAPIClient
|
||||
|
||||
return AsyncAPIClient({"base_url": "https://test.com", "auth": {}})
|
||||
|
||||
def test_empty_url(self, client):
|
||||
assert client._normalize_base_url("") == ""
|
||||
|
||||
def test_none_url(self, client):
|
||||
assert client._normalize_base_url(None) == ""
|
||||
|
||||
def test_api_v1_suffix(self, client):
|
||||
assert client._normalize_base_url("https://test.com/api/v1") == "https://test.com"
|
||||
|
||||
def test_trailing_slash(self, client):
|
||||
assert client._normalize_base_url("https://test.com/") == "https://test.com"
|
||||
|
||||
def test_whitespace_stripped(self, client):
|
||||
assert client._normalize_base_url(" https://test.com ") == "https://test.com"
|
||||
|
||||
def test_api_v1_with_trailing_slash(self, client):
|
||||
assert client._normalize_base_url("https://test.com/api/v1/") == "https://test.com"
|
||||
# #endregion Test.AsyncNetwork
|
||||
260
backend/tests/test_core/test_dashboards_filters.py
Normal file
260
backend/tests/test_core/test_dashboards_filters.py
Normal file
@@ -0,0 +1,260 @@
|
||||
# #region Test.DashboardsFiltersMixin [C:3] [TYPE Module] [SEMANTICS test, dashboard, filter, permalink, mixin]
|
||||
# @BRIEF Tests for core/superset_client/_dashboards_filters.py — SupersetDashboardsFiltersMixin.
|
||||
# @RELATION BINDS_TO -> [SupersetDashboardsFiltersMixin]
|
||||
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent.parent / "src"))
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
class TestDashboardsFiltersMixin:
|
||||
"""Test all methods of SupersetDashboardsFiltersMixin."""
|
||||
|
||||
@pytest.fixture
|
||||
def mixin(self):
|
||||
from src.core.superset_client._dashboards_filters import (
|
||||
SupersetDashboardsFiltersMixin,
|
||||
)
|
||||
|
||||
m = SupersetDashboardsFiltersMixin()
|
||||
m.client = MagicMock()
|
||||
m.client.request = AsyncMock()
|
||||
return m
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_dashboard_with_int(self, mixin):
|
||||
mixin.client.request.return_value = {"result": {"id": 1, "dashboard_title": "Test"}}
|
||||
result = await mixin.get_dashboard(1)
|
||||
mixin.client.request.assert_awaited_once_with(method="GET", endpoint="/dashboard/1")
|
||||
assert result["result"]["id"] == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_dashboard_with_str(self, mixin):
|
||||
mixin.client.request.return_value = {"result": {"id": 2, "slug": "my-dash"}}
|
||||
result = await mixin.get_dashboard("my-dash")
|
||||
mixin.client.request.assert_awaited_once_with(method="GET", endpoint="/dashboard/my-dash")
|
||||
assert result["result"]["slug"] == "my-dash"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_dashboard_permalink_state(self, mixin):
|
||||
mixin.client.request.return_value = {"result": {"state": {"dataMask": {}}}}
|
||||
result = await mixin.get_dashboard_permalink_state("abc123")
|
||||
mixin.client.request.assert_awaited_once_with(
|
||||
method="GET", endpoint="/dashboard/permalink/abc123"
|
||||
)
|
||||
assert "state" in result["result"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_native_filter_state(self, mixin):
|
||||
mixin.client.request.return_value = {"result": {"value": '{"id": 1}'}}
|
||||
result = await mixin.get_native_filter_state(1, "key123")
|
||||
mixin.client.request.assert_awaited_once_with(
|
||||
method="GET", endpoint="/dashboard/1/filter_state/key123"
|
||||
)
|
||||
assert "value" in result["result"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_extract_native_filters_from_permalink(self, mixin):
|
||||
mixin.get_dashboard_permalink_state = AsyncMock(
|
||||
return_value={
|
||||
"result": {
|
||||
"state": {
|
||||
"dataMask": {
|
||||
"filter_1": {
|
||||
"extraFormData": {"col1": "val1"},
|
||||
"filterState": {"value": 1},
|
||||
"ownState": {},
|
||||
}
|
||||
},
|
||||
"activeTabs": ["tab1"],
|
||||
"anchor": "tab1",
|
||||
"chartStates": {"chart1": {}},
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
result = await mixin.extract_native_filters_from_permalink("perm_key")
|
||||
assert result["dataMask"]["filter_1"]["extraFormData"] == {"col1": "val1"}
|
||||
assert result["activeTabs"] == ["tab1"]
|
||||
assert result["anchor"] == "tab1"
|
||||
assert result["chartStates"] == {"chart1": {}}
|
||||
assert result["permalink_key"] == "perm_key"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_extract_native_filters_from_permalink_empty_state(self, mixin):
|
||||
mixin.get_dashboard_permalink_state = AsyncMock(return_value={})
|
||||
result = await mixin.extract_native_filters_from_permalink("empty_key")
|
||||
assert result["dataMask"] == {}
|
||||
assert result["activeTabs"] == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_extract_native_filters_from_permalink_skips_non_dict(self, mixin):
|
||||
mixin.get_dashboard_permalink_state = AsyncMock(
|
||||
return_value={
|
||||
"result": {
|
||||
"state": {
|
||||
"dataMask": {
|
||||
"filter_1": {"extraFormData": {}, "filterState": {}, "ownState": {}},
|
||||
"filter_2": "not_a_dict",
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
result = await mixin.extract_native_filters_from_permalink("key")
|
||||
assert "filter_1" in result["dataMask"]
|
||||
assert "filter_2" not in result["dataMask"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_extract_native_filters_from_key_with_str_value(self, mixin):
|
||||
mixin.get_native_filter_state = AsyncMock(
|
||||
return_value={"result": {"value": '{"id": "f1", "extraFormData": {}, "filterState": {}, "ownState": {}}'}}
|
||||
)
|
||||
result = await mixin.extract_native_filters_from_key(1, "key1")
|
||||
assert "f1" in result["dataMask"]
|
||||
assert result["dashboard_id"] == 1
|
||||
assert result["filter_state_key"] == "key1"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_extract_native_filters_from_key_with_dict_value(self, mixin):
|
||||
mixin.get_native_filter_state = AsyncMock(
|
||||
return_value={
|
||||
"result": {
|
||||
"value": {
|
||||
"f1": {"extraFormData": {}, "filterState": {}, "ownState": {}},
|
||||
"f2": {"extraFormData": {}, "filterState": {}, "ownState": {}},
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
result = await mixin.extract_native_filters_from_key(1, "key2")
|
||||
assert "f1" in result["dataMask"]
|
||||
assert "f2" in result["dataMask"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_extract_native_filters_from_key_with_invalid_json(self, mixin):
|
||||
mixin.get_native_filter_state = AsyncMock(
|
||||
return_value={"result": {"value": "{invalid json}"}}
|
||||
)
|
||||
with patch("src.core.superset_client._dashboards_filters.app_logger") as mock_log:
|
||||
result = await mixin.extract_native_filters_from_key(1, "bad_key")
|
||||
mock_log.warning.assert_called_once()
|
||||
assert result["dataMask"] == {}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_extract_native_filters_from_key_with_non_dict_value(self, mixin):
|
||||
mixin.get_native_filter_state = AsyncMock(
|
||||
return_value={"result": {"value": 42}}
|
||||
)
|
||||
result = await mixin.extract_native_filters_from_key(1, "int_key")
|
||||
assert result["dataMask"] == {}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_extract_native_filters_from_key_skips_non_dict_items(self, mixin):
|
||||
mixin.get_native_filter_state = AsyncMock(
|
||||
return_value={
|
||||
"result": {
|
||||
"value": {
|
||||
"f1": {"extraFormData": {}, "filterState": {}, "ownState": {}},
|
||||
"f2": "not_a_dict",
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
result = await mixin.extract_native_filters_from_key(1, "key3")
|
||||
assert "f1" in result["dataMask"]
|
||||
assert "f2" not in result["dataMask"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_parse_dashboard_url_for_filters_permalink(self, mixin):
|
||||
mixin.extract_native_filters_from_permalink = AsyncMock(
|
||||
return_value={"dataMask": {}, "permalink_key": "abc123"}
|
||||
)
|
||||
result = await mixin.parse_dashboard_url_for_filters(
|
||||
"https://superset.example.com/superset/dashboard/p/abc123/"
|
||||
)
|
||||
assert result["filter_type"] == "permalink"
|
||||
assert result["filters"]["permalink_key"] == "abc123"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_parse_dashboard_url_for_filters_native_filters_key_with_int_id(self, mixin):
|
||||
mixin.extract_native_filters_from_key = AsyncMock(
|
||||
return_value={"dataMask": {}, "dashboard_id": 5}
|
||||
)
|
||||
result = await mixin.parse_dashboard_url_for_filters(
|
||||
"https://superset.example.com/superset/dashboard/5/?native_filters_key=key123"
|
||||
)
|
||||
assert result["filter_type"] == "native_filters_key"
|
||||
assert result["dashboard_id"] == 5
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_parse_dashboard_url_for_filters_native_filters_key_with_slug(self, mixin):
|
||||
# Slug resolution calls get_dashboard first
|
||||
mixin.get_dashboard = AsyncMock(return_value={"result": {"id": 42}})
|
||||
mixin.extract_native_filters_from_key = AsyncMock(
|
||||
return_value={"dataMask": {}, "dashboard_id": 42}
|
||||
)
|
||||
result = await mixin.parse_dashboard_url_for_filters(
|
||||
"https://superset.example.com/superset/dashboard/my-slug/?native_filters_key=key456"
|
||||
)
|
||||
assert result["filter_type"] == "native_filters_key"
|
||||
assert result["dashboard_id"] == 42
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_parse_dashboard_url_for_filters_slug_resolution_fails(self, mixin):
|
||||
mixin.get_dashboard = AsyncMock(side_effect=Exception("Not found"))
|
||||
result = await mixin.parse_dashboard_url_for_filters(
|
||||
"https://superset.example.com/superset/dashboard/unknown/?native_filters_key=key789"
|
||||
)
|
||||
# Should have no filter_type set since resolution failed
|
||||
assert result["filter_type"] is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_parse_dashboard_url_for_filters_native_filters_json(self, mixin):
|
||||
result = await mixin.parse_dashboard_url_for_filters(
|
||||
"https://superset.example.com/superset/dashboard/1/?native_filters=%7B%22key%22%3A%22val%22%7D"
|
||||
)
|
||||
assert result["filter_type"] == "native_filters"
|
||||
assert "dataMask" in result["filters"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_parse_dashboard_url_for_filters_bad_native_filters_json(self, mixin):
|
||||
with patch("src.core.superset_client._dashboards_filters.app_logger") as mock_log:
|
||||
result = await mixin.parse_dashboard_url_for_filters(
|
||||
"https://superset.example.com/dashboard/1/?native_filters={invalid}"
|
||||
)
|
||||
assert result["filter_type"] is None
|
||||
mock_log.warning.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_parse_dashboard_url_for_filters_no_filters(self, mixin):
|
||||
result = await mixin.parse_dashboard_url_for_filters(
|
||||
"https://superset.example.com/superset/dashboard/1/"
|
||||
)
|
||||
assert result["filter_type"] is None
|
||||
assert result["filters"] == {}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_parse_dashboard_url_no_dashboard_in_path_for_native_key(self, mixin):
|
||||
# native_filters_key present but no dashboard in path
|
||||
result = await mixin.parse_dashboard_url_for_filters(
|
||||
"https://superset.example.com/superset/other/?native_filters_key=keyX"
|
||||
)
|
||||
assert result["filter_type"] is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_parse_dashboard_url_p_is_reserved_in_path(self, mixin):
|
||||
# URL has /p/ but not in dashboard context
|
||||
# The mixin will try extract_native_filters_from_permalink -> get_dashboard_permalink_state
|
||||
# which calls self.client.request. Set it to return an empty dict.
|
||||
mixin.client.request.return_value = {"result": {"state": {}}}
|
||||
result = await mixin.parse_dashboard_url_for_filters(
|
||||
"https://superset.example.com/superset/other/p/something/"
|
||||
)
|
||||
assert result["filter_type"] == "permalink"
|
||||
# #endregion Test.DashboardsFiltersMixin
|
||||
231
backend/tests/test_core/test_dashboards_write.py
Normal file
231
backend/tests/test_core/test_dashboards_write.py
Normal file
@@ -0,0 +1,231 @@
|
||||
# #region Test.DashboardsWriteMixin [C:3] [TYPE Module] [SEMANTICS test, dashboard, write, markdown, layout]
|
||||
# @BRIEF Tests for core/superset_client/_dashboards_write.py — SupersetDashboardsWriteMixin.
|
||||
# @RELATION BINDS_TO -> [SupersetDashboardsWriteMixin]
|
||||
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent.parent / "src"))
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock, call, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
class TestDashboardsWriteMixin:
|
||||
"""Test all methods of SupersetDashboardsWriteMixin."""
|
||||
|
||||
@pytest.fixture
|
||||
def mixin(self):
|
||||
from src.core.superset_client._dashboards_write import (
|
||||
SupersetDashboardsWriteMixin,
|
||||
)
|
||||
|
||||
m = SupersetDashboardsWriteMixin()
|
||||
m.client = MagicMock()
|
||||
m.client.request = AsyncMock()
|
||||
return m
|
||||
|
||||
# ── create_markdown_chart ──
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_markdown_chart_success(self, mixin):
|
||||
mixin._resolve_markdown_datasource = AsyncMock(return_value=10)
|
||||
mixin.client.request.return_value = {"id": 42}
|
||||
result = await mixin.create_markdown_chart(1, "Hello")
|
||||
assert result == 42
|
||||
mixin.client.request.assert_awaited_once()
|
||||
call_kwargs = mixin.client.request.call_args
|
||||
assert call_kwargs[1]["method"] == "POST"
|
||||
assert call_kwargs[1]["endpoint"] == "/chart/"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_markdown_chart_id_in_result(self, mixin):
|
||||
mixin._resolve_markdown_datasource = AsyncMock(return_value=10)
|
||||
mixin.client.request.return_value = {"result": {"id": 99}}
|
||||
result = await mixin.create_markdown_chart(1, "Banner")
|
||||
assert result == 99
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_markdown_chart_no_id_returns_zero(self, mixin):
|
||||
mixin._resolve_markdown_datasource = AsyncMock(return_value=10)
|
||||
mixin.client.request.return_value = {}
|
||||
result = await mixin.create_markdown_chart(1, "Text")
|
||||
assert result == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_markdown_chart_request_fails(self, mixin):
|
||||
mixin._resolve_markdown_datasource = AsyncMock(return_value=10)
|
||||
mixin.client.request.side_effect = ValueError("API error")
|
||||
with pytest.raises(ValueError):
|
||||
await mixin.create_markdown_chart(1, "Fail")
|
||||
|
||||
# ── update_markdown_chart ──
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_markdown_chart_success(self, mixin):
|
||||
mixin.client.request.return_value = {"result": "ok"}
|
||||
result = await mixin.update_markdown_chart(5, "Updated text")
|
||||
mixin.client.request.assert_awaited_once_with(
|
||||
method="PUT", endpoint="/chart/5", data=mixin.client.request.call_args[1]["data"]
|
||||
)
|
||||
assert result == {"result": "ok"}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_markdown_chart_fails(self, mixin):
|
||||
mixin.client.request.side_effect = RuntimeError("Update failed")
|
||||
with pytest.raises(RuntimeError):
|
||||
await mixin.update_markdown_chart(5, "Text")
|
||||
|
||||
# ── update_dashboard_layout ──
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_dashboard_layout_success(self, mixin):
|
||||
mock_position = {
|
||||
"GRID_ID": {"children": []},
|
||||
"DASHBOARD_VERSION_KEY": "v2",
|
||||
}
|
||||
# First call returns dashboard, second call returns success
|
||||
mixin.client.request = AsyncMock(
|
||||
side_effect=[
|
||||
{"result": {"position_json": '{"GRID_ID": {"children": []}, "DASHBOARD_VERSION_KEY": "v2"}'}},
|
||||
{"result": "ok"},
|
||||
]
|
||||
)
|
||||
result = await mixin.update_dashboard_layout(1, 42)
|
||||
assert mixin.client.request.call_count == 2
|
||||
assert result == {"result": "ok"}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_dashboard_layout_fails(self, mixin):
|
||||
mixin.client.request.side_effect = ConnectionError("Network error")
|
||||
with pytest.raises(ConnectionError):
|
||||
await mixin.update_dashboard_layout(1, 42)
|
||||
|
||||
# ── remove_chart_from_layout ──
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_remove_chart_from_layout_success(self, mixin):
|
||||
mixin.client.request = AsyncMock(
|
||||
side_effect=[
|
||||
{"result": {"position_json": '{"GRID_ID": {"children": ["ROW-banner-42"]}, "ROW-banner-42": {}, "MARKDOWN-banner-42": {}}'}},
|
||||
{"result": "ok"},
|
||||
]
|
||||
)
|
||||
result = await mixin.remove_chart_from_layout(1, 42)
|
||||
assert mixin.client.request.call_count == 2
|
||||
assert result == {"result": "ok"}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_remove_chart_from_layout_fails(self, mixin):
|
||||
mixin.client.request.side_effect = OSError("IO error")
|
||||
with pytest.raises(OSError):
|
||||
await mixin.remove_chart_from_layout(1, 42)
|
||||
|
||||
# ── delete_chart ──
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_chart_success(self, mixin):
|
||||
mixin.client.request.return_value = {"result": "deleted"}
|
||||
result = await mixin.delete_chart(42)
|
||||
mixin.client.request.assert_awaited_once_with(method="DELETE", endpoint="/chart/42")
|
||||
assert result == {"result": "deleted"}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_chart_fails(self, mixin):
|
||||
mixin.client.request.side_effect = PermissionError("Forbidden")
|
||||
with pytest.raises(PermissionError):
|
||||
await mixin.delete_chart(42)
|
||||
|
||||
# ── update_banner_on_dashboard ──
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_banner_on_dashboard_success(self, mixin):
|
||||
mixin.client.request = AsyncMock(
|
||||
side_effect=[
|
||||
{"result": {"position_json": '{"MARKDOWN-banner-7": {"meta": {"code": "old", "height": 19}}}'}},
|
||||
{"result": "ok"},
|
||||
]
|
||||
)
|
||||
result = await mixin.update_banner_on_dashboard(1, 7, "New content")
|
||||
assert mixin.client.request.call_count == 2
|
||||
assert result == {"result": "ok"}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_banner_on_dashboard_fails(self, mixin):
|
||||
mixin.client.request.side_effect = TimeoutError("Timed out")
|
||||
with pytest.raises(TimeoutError):
|
||||
await mixin.update_banner_on_dashboard(1, 7, "Content")
|
||||
|
||||
# ── get_dashboard_layout ──
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_dashboard_layout_success(self, mixin):
|
||||
mixin.client.request.return_value = {
|
||||
"result": {"position_json": '{"GRID_ID": {"children": []}}'}
|
||||
}
|
||||
result = await mixin.get_dashboard_layout(1)
|
||||
assert "GRID_ID" in result
|
||||
assert result["GRID_ID"]["children"] == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_dashboard_layout_fails(self, mixin):
|
||||
mixin.client.request.side_effect = Exception("Fetch failed")
|
||||
with pytest.raises(Exception):
|
||||
await mixin.get_dashboard_layout(1)
|
||||
|
||||
# ── _resolve_markdown_datasource ──
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_markdown_datasource_from_dashboard(self, mixin):
|
||||
mixin.client.request.return_value = {"result": [{"id": 5}]}
|
||||
result = await mixin._resolve_markdown_datasource(1)
|
||||
assert result == 5
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_markdown_datasource_fallback_to_global(self, mixin):
|
||||
mixin.client.request = AsyncMock(
|
||||
side_effect=[
|
||||
Exception("Dashboard datasets failed"),
|
||||
{"result": [{"id": 99}]},
|
||||
]
|
||||
)
|
||||
result = await mixin._resolve_markdown_datasource(1)
|
||||
assert result == 99
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_markdown_datasource_both_fail(self, mixin):
|
||||
mixin.client.request = AsyncMock(
|
||||
side_effect=[
|
||||
Exception("Dashboard failed"),
|
||||
Exception("Global failed"),
|
||||
]
|
||||
)
|
||||
with pytest.raises(RuntimeError, match="Cannot create markdown chart"):
|
||||
await mixin._resolve_markdown_datasource(1)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_markdown_datasource_empty_dashboard_datasets(self, mixin):
|
||||
mixin.client.request.return_value = {"result": []}
|
||||
# Then fallback to global
|
||||
mixin.client.request = AsyncMock(
|
||||
side_effect=[
|
||||
{"result": []},
|
||||
{"result": [{"id": 77}]},
|
||||
]
|
||||
)
|
||||
result = await mixin._resolve_markdown_datasource(1)
|
||||
assert result == 77
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_markdown_datasource_dashboard_has_no_id_field(self, mixin):
|
||||
mixin.client.request.return_value = {"result": [{"name": "test"}]}
|
||||
mixin.client.request = AsyncMock(
|
||||
side_effect=[
|
||||
{"result": [{"name": "test"}]},
|
||||
{"result": [{"id": 88}]},
|
||||
]
|
||||
)
|
||||
result = await mixin._resolve_markdown_datasource(1)
|
||||
assert result == 88
|
||||
# #endregion Test.DashboardsWriteMixin
|
||||
275
backend/tests/test_core/test_dataset_mapper.py
Normal file
275
backend/tests/test_core/test_dataset_mapper.py
Normal file
@@ -0,0 +1,275 @@
|
||||
# #region Test.DatasetMapper [C:3] [TYPE Module] [SEMANTICS test, dataset, mapper, sqllab, excel]
|
||||
# @BRIEF Tests for core/utils/dataset_mapper.py — DatasetMapper.
|
||||
# @RELATION BINDS_TO -> [DatasetMapperModule]
|
||||
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent.parent / "src"))
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock, call, patch
|
||||
|
||||
import pandas as pd
|
||||
import pytest
|
||||
|
||||
|
||||
class TestDatasetMapper:
|
||||
"""Test DatasetMapper — SQL Lab and Excel mapping + run."""
|
||||
|
||||
@pytest.fixture
|
||||
def mapper(self):
|
||||
from src.core.utils.dataset_mapper import DatasetMapper
|
||||
|
||||
return DatasetMapper()
|
||||
|
||||
# ── get_sqllab_mappings ──
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_sqllab_mappings_with_default_query(self, mapper):
|
||||
# Note: get_sqllab_mappings calls client.get_dataset() WITHOUT await (source bug).
|
||||
# So client.get_dataset must be a regular MagicMock, not AsyncMock.
|
||||
client = MagicMock()
|
||||
client.get_dataset = MagicMock(
|
||||
return_value={
|
||||
"result": {
|
||||
"table_name": "users",
|
||||
"schema": "public",
|
||||
"database": {"database_name": "Postgres"},
|
||||
}
|
||||
}
|
||||
)
|
||||
sqllab_executor = AsyncMock()
|
||||
sqllab_executor.execute_and_poll.return_value = {
|
||||
"result": [
|
||||
{"column_name": "id", "verbose_name": "Identifier"},
|
||||
{"column_name": "name", "verbose_name": "Full Name"},
|
||||
]
|
||||
}
|
||||
|
||||
result = await mapper.get_sqllab_mappings(client, 1, sqllab_executor, 42)
|
||||
assert result == {"id": "Identifier", "name": "Full Name"}
|
||||
assert "information_schema" in sqllab_executor.execute_and_poll.call_args[0][0]
|
||||
sqllab_executor.execute_and_poll.assert_awaited_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_sqllab_mappings_with_custom_query(self, mapper):
|
||||
client = MagicMock()
|
||||
client.get_dataset = MagicMock(
|
||||
return_value={
|
||||
"result": {
|
||||
"table_name": "users",
|
||||
"schema": "public",
|
||||
"database": {"database_name": "Postgres"},
|
||||
}
|
||||
}
|
||||
)
|
||||
sqllab_executor = AsyncMock()
|
||||
sqllab_executor.execute_and_poll.return_value = {
|
||||
"data": [
|
||||
{"column_name": "email", "verbose_name": "Email Address"},
|
||||
]
|
||||
}
|
||||
|
||||
result = await mapper.get_sqllab_mappings(
|
||||
client, 1, sqllab_executor, 42, sql_query="SELECT column_name, verbose_name FROM my_table"
|
||||
)
|
||||
assert result == {"email": "Email Address"}
|
||||
assert "my_table" in sqllab_executor.execute_and_poll.call_args[0][0]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_sqllab_mappings_skips_null_verbose_name(self, mapper):
|
||||
client = MagicMock()
|
||||
client.get_dataset = MagicMock(
|
||||
return_value={"result": {"table_name": "t", "schema": "public"}}
|
||||
)
|
||||
sqllab_executor = AsyncMock()
|
||||
sqllab_executor.execute_and_poll.return_value = {
|
||||
"results": [
|
||||
{"column_name": "id", "verbose_name": None},
|
||||
{"column_name": "name", "verbose_name": "Name"},
|
||||
]
|
||||
}
|
||||
|
||||
result = await mapper.get_sqllab_mappings(client, 1, sqllab_executor, 42)
|
||||
assert result == {"name": "Name"}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_sqllab_mappings_uses_default_schema(self, mapper):
|
||||
client = MagicMock()
|
||||
client.get_dataset = MagicMock(
|
||||
return_value={"result": {"table_name": "t"}}
|
||||
)
|
||||
sqllab_executor = AsyncMock()
|
||||
sqllab_executor.execute_and_poll.return_value = {"result": []}
|
||||
|
||||
await mapper.get_sqllab_mappings(client, 1, sqllab_executor, 42)
|
||||
assert "public" in sqllab_executor.execute_and_poll.call_args[0][0]
|
||||
|
||||
# ── load_excel_mappings ──
|
||||
|
||||
def test_load_excel_mappings_success(self, mapper):
|
||||
"""Use a real DataFrame to avoid MagicMock complexity with __getitem__."""
|
||||
data = pd.DataFrame({"column_name": ["a", "b"], "verbose_name": ["Alpha", "Beta"]})
|
||||
with patch("pandas.read_excel", return_value=data):
|
||||
result = mapper.load_excel_mappings("/tmp/test.xlsx")
|
||||
assert result == {"a": "Alpha", "b": "Beta"}
|
||||
|
||||
def test_load_excel_mappings_file_not_found(self, mapper):
|
||||
with patch("pandas.read_excel", side_effect=FileNotFoundError("No file")):
|
||||
with pytest.raises(FileNotFoundError):
|
||||
mapper.load_excel_mappings("/nonexistent.xlsx")
|
||||
|
||||
# ── run_mapping ──
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_mapping_sqllab_with_changes(self, mapper):
|
||||
# Mock get_sqllab_mappings to avoid its missing-await issue
|
||||
mapper.get_sqllab_mappings = AsyncMock(
|
||||
return_value={"id": "Identifier", "name": "New Name"}
|
||||
)
|
||||
client = AsyncMock()
|
||||
client.get_dataset = AsyncMock(
|
||||
return_value={
|
||||
"result": {
|
||||
"table_name": "users",
|
||||
"columns": [
|
||||
{"column_name": "id", "verbose_name": None},
|
||||
{"column_name": "name", "verbose_name": "Old Name"},
|
||||
],
|
||||
"metrics": [],
|
||||
"owners": [],
|
||||
"database": {"id": 1},
|
||||
}
|
||||
}
|
||||
)
|
||||
client.update_dataset = AsyncMock(return_value={"result": "ok"})
|
||||
|
||||
await mapper.run_mapping(
|
||||
superset_client=client,
|
||||
dataset_id=1,
|
||||
source="sqllab",
|
||||
sqllab_executor=MagicMock(),
|
||||
database_id=42,
|
||||
)
|
||||
client.update_dataset.assert_awaited_once()
|
||||
call_args = client.update_dataset.call_args
|
||||
assert call_args[0][0] == 1 # dataset_id
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_mapping_sqllab_no_changes(self, mapper):
|
||||
mapper.get_sqllab_mappings = AsyncMock(
|
||||
return_value={"name": "Same Name"}
|
||||
)
|
||||
client = AsyncMock()
|
||||
client.get_dataset = AsyncMock(
|
||||
return_value={
|
||||
"result": {
|
||||
"table_name": "users",
|
||||
"columns": [
|
||||
{"column_name": "name", "verbose_name": "Same Name"},
|
||||
],
|
||||
"metrics": [],
|
||||
"owners": [],
|
||||
"database": {"id": 1},
|
||||
}
|
||||
}
|
||||
)
|
||||
client.update_dataset = AsyncMock()
|
||||
|
||||
await mapper.run_mapping(
|
||||
superset_client=client,
|
||||
dataset_id=1,
|
||||
source="sqllab",
|
||||
sqllab_executor=MagicMock(),
|
||||
database_id=42,
|
||||
)
|
||||
client.update_dataset.assert_not_awaited()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_mapping_excel_source(self, mapper):
|
||||
mapper.load_excel_mappings = MagicMock(return_value={"a": "Alpha"})
|
||||
client = AsyncMock()
|
||||
client.get_dataset = AsyncMock(
|
||||
return_value={
|
||||
"result": {
|
||||
"table_name": "t",
|
||||
"columns": [{"column_name": "a", "verbose_name": None}],
|
||||
"metrics": [],
|
||||
"owners": [],
|
||||
"database": {"id": 1},
|
||||
}
|
||||
}
|
||||
)
|
||||
client.update_dataset = AsyncMock(return_value={"result": "ok"})
|
||||
|
||||
await mapper.run_mapping(
|
||||
superset_client=client, dataset_id=1, source="excel", excel_path="/tmp/map.xlsx"
|
||||
)
|
||||
client.update_dataset.assert_awaited_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_mapping_invalid_source(self, mapper):
|
||||
client = MagicMock()
|
||||
with patch("src.core.utils.dataset_mapper.app_logger") as mock_log:
|
||||
await mapper.run_mapping(
|
||||
superset_client=client, dataset_id=1, source="invalid"
|
||||
)
|
||||
mock_log.error.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_mapping_missing_sqllab_args(self, mapper):
|
||||
client = MagicMock()
|
||||
with patch("src.core.utils.dataset_mapper.app_logger") as mock_log:
|
||||
await mapper.run_mapping(
|
||||
superset_client=client, dataset_id=1, source="sqllab",
|
||||
sqllab_executor=None, database_id=None,
|
||||
)
|
||||
mock_log.error.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_mapping_missing_excel_path(self, mapper):
|
||||
client = MagicMock()
|
||||
with patch("src.core.utils.dataset_mapper.app_logger") as mock_log:
|
||||
await mapper.run_mapping(
|
||||
superset_client=client, dataset_id=1, source="excel", excel_path=None,
|
||||
)
|
||||
mock_log.error.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_mapping_client_error_handled(self, mapper):
|
||||
mapper.get_sqllab_mappings = AsyncMock(side_effect=Exception("API failure"))
|
||||
client = MagicMock()
|
||||
|
||||
with patch("src.core.utils.dataset_mapper.app_logger") as mock_log:
|
||||
await mapper.run_mapping(
|
||||
superset_client=client,
|
||||
dataset_id=1,
|
||||
source="sqllab",
|
||||
sqllab_executor=MagicMock(),
|
||||
database_id=42,
|
||||
)
|
||||
mock_log.error.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_mapping_handles_non_dict_columns(self, mapper):
|
||||
mapper.get_sqllab_mappings = AsyncMock(return_value={"a": "Alpha"})
|
||||
client = AsyncMock()
|
||||
client.get_dataset = AsyncMock(
|
||||
return_value={
|
||||
"result": {
|
||||
"table_name": "t",
|
||||
"columns": [{"column_name": "a", "verbose_name": None}],
|
||||
"metrics": [],
|
||||
"owners": [],
|
||||
"database": {"id": 1},
|
||||
}
|
||||
}
|
||||
)
|
||||
client.update_dataset = AsyncMock(return_value={"result": "ok"})
|
||||
|
||||
await mapper.run_mapping(
|
||||
superset_client=client, dataset_id=1, source="sqllab",
|
||||
sqllab_executor=MagicMock(), database_id=42,
|
||||
)
|
||||
client.update_dataset.assert_awaited_once()
|
||||
# #endregion Test.DatasetMapper
|
||||
296
backend/tests/test_core/test_datasets.py
Normal file
296
backend/tests/test_core/test_datasets.py
Normal file
@@ -0,0 +1,296 @@
|
||||
# #region Test.DatasetsMixin [C:3] [TYPE Module] [SEMANTICS test, dataset, superset, crud, detail]
|
||||
# @BRIEF Tests for core/superset_client/_datasets.py — SupersetDatasetsMixin.
|
||||
# @RELATION BINDS_TO -> [SupersetDatasetsMixin]
|
||||
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent.parent / "src"))
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
class TestDatasetsMixin:
|
||||
"""Test all methods of SupersetDatasetsMixin."""
|
||||
|
||||
@pytest.fixture
|
||||
def mixin(self):
|
||||
from src.core.superset_client._datasets import SupersetDatasetsMixin
|
||||
|
||||
m = SupersetDatasetsMixin()
|
||||
m.client = MagicMock()
|
||||
m.client.request = AsyncMock()
|
||||
m._validate_query_params = MagicMock(return_value={"page": 0, "page_size": 100})
|
||||
m._fetch_all_pages = AsyncMock(return_value=[{"id": 1}, {"id": 2}])
|
||||
return m
|
||||
|
||||
# ── get_datasets ──
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_datasets_success(self, mixin):
|
||||
total, data = await mixin.get_datasets(query={"columns": ["id"]})
|
||||
assert total == 2
|
||||
assert len(data) == 2
|
||||
mixin._validate_query_params.assert_called_once_with({"columns": ["id"]})
|
||||
mixin._fetch_all_pages.assert_awaited_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_datasets_no_query(self, mixin):
|
||||
total, data = await mixin.get_datasets()
|
||||
assert total == 2
|
||||
mixin._validate_query_params.assert_called_once_with(None)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_datasets_empty_result(self, mixin):
|
||||
mixin._fetch_all_pages = AsyncMock(return_value=[])
|
||||
total, data = await mixin.get_datasets()
|
||||
assert total == 0
|
||||
assert data == []
|
||||
|
||||
# ── get_datasets_summary ──
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_datasets_summary(self, mixin):
|
||||
mixin.get_datasets = AsyncMock(
|
||||
return_value=(
|
||||
2,
|
||||
[
|
||||
{
|
||||
"id": 1,
|
||||
"table_name": "users",
|
||||
"schema": "public",
|
||||
"database": {"database_name": "Postgres"},
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"table_name": "orders",
|
||||
"schema": "public",
|
||||
"database": {"database_name": "Postgres"},
|
||||
},
|
||||
],
|
||||
)
|
||||
)
|
||||
result = await mixin.get_datasets_summary()
|
||||
assert len(result) == 2
|
||||
assert result[0]["table_name"] == "users"
|
||||
assert result[0]["database"] == "Postgres"
|
||||
assert result[1]["schema"] == "public"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_datasets_summary_missing_database(self, mixin):
|
||||
mixin.get_datasets = AsyncMock(
|
||||
return_value=(
|
||||
1,
|
||||
[{"id": 1, "table_name": "t", "schema": "public"}],
|
||||
)
|
||||
)
|
||||
result = await mixin.get_datasets_summary()
|
||||
assert result[0]["database"] == "Unknown"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_datasets_summary_empty(self, mixin):
|
||||
mixin.get_datasets = AsyncMock(return_value=(0, []))
|
||||
result = await mixin.get_datasets_summary()
|
||||
assert result == []
|
||||
|
||||
# ── get_dataset_linked_dashboard_count ──
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_dataset_linked_dashboard_count_direct_key(self, mixin):
|
||||
mixin.client.request.return_value = {"dashboards": [{"id": 1}, {"id": 2}]}
|
||||
result = await mixin.get_dataset_linked_dashboard_count(1)
|
||||
assert result == 2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_dataset_linked_dashboard_count_result_key(self, mixin):
|
||||
mixin.client.request.return_value = {"result": {"dashboards": [{"id": 1}]}}
|
||||
result = await mixin.get_dataset_linked_dashboard_count(1)
|
||||
assert result == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_dataset_linked_dashboard_count_empty(self, mixin):
|
||||
mixin.client.request.return_value = {"result": {"dashboards": []}}
|
||||
result = await mixin.get_dataset_linked_dashboard_count(1)
|
||||
assert result == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_dataset_linked_dashboard_count_non_dict(self, mixin):
|
||||
mixin.client.request.return_value = "not_a_dict"
|
||||
result = await mixin.get_dataset_linked_dashboard_count(1)
|
||||
assert result == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_dataset_linked_dashboard_count_error(self, mixin):
|
||||
mixin.client.request.side_effect = Exception("API error")
|
||||
with patch("src.core.superset_client._datasets.app_logger") as mock_log:
|
||||
result = await mixin.get_dataset_linked_dashboard_count(1)
|
||||
assert result == 0
|
||||
mock_log.warning.assert_called_once()
|
||||
|
||||
# ── get_dataset_detail ──
|
||||
|
||||
@pytest.fixture
|
||||
def detail_mixin(self):
|
||||
from src.core.superset_client._datasets import SupersetDatasetsMixin
|
||||
|
||||
m = SupersetDatasetsMixin()
|
||||
m.client = MagicMock()
|
||||
m.client.request = AsyncMock()
|
||||
m.get_dataset = AsyncMock()
|
||||
return m
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_dataset_detail_full(self, detail_mixin):
|
||||
detail_mixin.get_dataset.return_value = {
|
||||
"result": {
|
||||
"id": 1,
|
||||
"table_name": "users",
|
||||
"schema": "public",
|
||||
"database": {"id": 1, "backend": "postgresql", "database_name": "Postgres"},
|
||||
"database_id": 1,
|
||||
"description": "User table",
|
||||
"columns": [
|
||||
{"id": 1, "column_name": "name", "type": "text", "is_dttm": False, "is_active": True, "description": ""},
|
||||
{"id": 2, "column_name": "created", "type": "timestamp", "is_dttm": True, "is_active": True, "description": ""},
|
||||
],
|
||||
"metrics": [
|
||||
{"id": 1, "metric_name": "count", "expression": "COUNT(*)", "verbose_name": "Count", "description": "", "metric_type": "count"},
|
||||
],
|
||||
"sql": "SELECT * FROM users",
|
||||
"is_sqllab_view": False,
|
||||
"created_on": "2024-01-01",
|
||||
"changed_on": "2024-01-02",
|
||||
}
|
||||
}
|
||||
detail_mixin.client.request.return_value = {
|
||||
"dashboards": {"result": [{"id": 10, "dashboard_title": "Main", "slug": "main"}]}
|
||||
}
|
||||
result = await detail_mixin.get_dataset_detail(1)
|
||||
assert result["id"] == 1
|
||||
assert result["table_name"] == "users"
|
||||
assert len(result["columns"]) == 2
|
||||
assert result["columns"][0]["name"] == "name"
|
||||
assert result["columns"][1]["is_dttm"] is True
|
||||
assert len(result["metrics"]) == 1
|
||||
assert result["metrics"][0]["metric_name"] == "count"
|
||||
assert result["linked_dashboard_count"] == 1
|
||||
assert result["database_backend"] == "postgresql"
|
||||
assert result["is_sqllab_view"] is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_dataset_detail_no_result_key(self, detail_mixin):
|
||||
detail_mixin.get_dataset.return_value = {
|
||||
"id": 1,
|
||||
"table_name": "no_result",
|
||||
"columns": [],
|
||||
"metrics": [],
|
||||
}
|
||||
detail_mixin.client.request.return_value = {}
|
||||
result = await detail_mixin.get_dataset_detail(1)
|
||||
assert result["table_name"] == "no_result"
|
||||
assert result["columns"] == []
|
||||
assert result["metrics"] == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_dataset_detail_related_objects_error(self, detail_mixin):
|
||||
detail_mixin.get_dataset.return_value = {
|
||||
"result": {
|
||||
"id": 1,
|
||||
"table_name": "test",
|
||||
"columns": [],
|
||||
"metrics": [],
|
||||
}
|
||||
}
|
||||
detail_mixin.client.request.side_effect = Exception("Connection error")
|
||||
with patch("src.core.superset_client._datasets.app_logger") as mock_log:
|
||||
result = await detail_mixin.get_dataset_detail(1)
|
||||
assert result["linked_dashboards"] == []
|
||||
mock_log.warning.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_dataset_detail_related_objects_has_dashboards_direct(self, detail_mixin):
|
||||
detail_mixin.get_dataset.return_value = {
|
||||
"result": {
|
||||
"id": 1,
|
||||
"table_name": "test",
|
||||
"columns": [],
|
||||
"metrics": [],
|
||||
"database": {},
|
||||
}
|
||||
}
|
||||
detail_mixin.client.request.return_value = {"dashboards": [{"id": 5, "dashboard_title": "Dash"}]}
|
||||
result = await detail_mixin.get_dataset_detail(1)
|
||||
assert result["linked_dashboard_count"] == 1
|
||||
assert result["linked_dashboards"][0]["id"] == 5
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_dataset_detail_related_objects_with_int_list(self, detail_mixin):
|
||||
detail_mixin.get_dataset.return_value = {
|
||||
"result": {
|
||||
"id": 1,
|
||||
"table_name": "test",
|
||||
"columns": [],
|
||||
"metrics": [],
|
||||
}
|
||||
}
|
||||
detail_mixin.client.request.return_value = {"dashboards": [5, 6]}
|
||||
result = await detail_mixin.get_dataset_detail(1)
|
||||
assert result["linked_dashboard_count"] == 2
|
||||
assert result["linked_dashboards"][0]["id"] == 5
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_dataset_detail_as_bool_variants(self, detail_mixin):
|
||||
detail_mixin.get_dataset.return_value = {
|
||||
"result": {
|
||||
"id": 1,
|
||||
"table_name": "test",
|
||||
"columns": [
|
||||
{"id": 1, "column_name": "a", "is_dttm": "1", "is_active": "true"},
|
||||
{"id": 2, "column_name": "b", "is_dttm": "yes", "is_active": "on"},
|
||||
{"id": 3, "column_name": "c", "is_dttm": None, "is_active": None},
|
||||
{"id": 4, "column_name": "d", "is_dttm": 1, "is_active": 0},
|
||||
],
|
||||
"metrics": [],
|
||||
"database": {"backend": "mysql"},
|
||||
}
|
||||
}
|
||||
detail_mixin.client.request.return_value = {}
|
||||
result = await detail_mixin.get_dataset_detail(1)
|
||||
assert result["columns"][0]["is_dttm"] is True
|
||||
assert result["columns"][1]["is_dttm"] is True
|
||||
assert result["columns"][2]["is_dttm"] is False
|
||||
assert result["columns"][3]["is_dttm"] is True
|
||||
assert result["columns"][0]["is_active"] is True
|
||||
assert result["columns"][3]["is_active"] is False
|
||||
|
||||
# ── get_dataset ──
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_dataset_success(self, mixin):
|
||||
mixin.client.request.return_value = {"result": {"id": 1, "table_name": "test"}}
|
||||
result = await mixin.get_dataset(1)
|
||||
assert result["result"]["id"] == 1
|
||||
|
||||
# ── update_dataset ──
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_dataset_success(self, mixin):
|
||||
mixin.client.request.return_value = {"result": "ok"}
|
||||
result = await mixin.update_dataset(1, {"columns": []}, override_columns=True)
|
||||
mixin.client.request.assert_awaited_once()
|
||||
assert result["result"] == "ok"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_dataset_no_override(self, mixin):
|
||||
mixin.client.request.return_value = {"result": "ok"}
|
||||
result = await mixin.update_dataset(1, {"columns": []})
|
||||
assert result["result"] == "ok"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_dataset_with_json_data(self, mixin):
|
||||
mixin.client.request.return_value = {"result": "ok"}
|
||||
result = await mixin.update_dataset(1, {"table_name": "new_name"}, override_columns=False)
|
||||
assert result["result"] == "ok"
|
||||
# #endregion Test.DatasetsMixin
|
||||
285
backend/tests/test_core/test_datasets_preview.py
Normal file
285
backend/tests/test_core/test_datasets_preview.py
Normal file
@@ -0,0 +1,285 @@
|
||||
# #region Test.DatasetsPreviewMixin [C:3] [TYPE Module] [SEMANTICS test, dataset, preview, compile, query]
|
||||
# @BRIEF Tests for core/superset_client/_datasets_preview.py — SupersetDatasetsPreviewMixin.
|
||||
# @RELATION BINDS_TO -> [SupersetDatasetsPreviewMixin]
|
||||
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent.parent / "src"))
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
class TestDatasetsPreviewMixin:
|
||||
"""Test SupersetDatasetsPreviewMixin methods."""
|
||||
|
||||
@pytest.fixture
|
||||
def mixin(self):
|
||||
from src.core.superset_client._datasets_preview import (
|
||||
SupersetDatasetsPreviewMixin,
|
||||
)
|
||||
|
||||
m = SupersetDatasetsPreviewMixin()
|
||||
m.client = MagicMock()
|
||||
m.client.request = AsyncMock()
|
||||
m.get_dataset = AsyncMock()
|
||||
m.build_dataset_preview_query_context = MagicMock(
|
||||
return_value={
|
||||
"datasource": {"id": 1, "type": "table"},
|
||||
"queries": [{"metrics": ["count"], "columns": [], "filters": []}],
|
||||
"form_data": {"datasource": "1__table"},
|
||||
"result_format": "json",
|
||||
"result_type": "query",
|
||||
"force": True,
|
||||
}
|
||||
)
|
||||
m.build_dataset_preview_legacy_form_data = MagicMock(
|
||||
return_value={
|
||||
"datasource": "1__table",
|
||||
"metrics": ["count"],
|
||||
"result_format": "json",
|
||||
"result_type": "query",
|
||||
"force": True,
|
||||
}
|
||||
)
|
||||
m._extract_compiled_sql_from_preview_response = MagicMock(
|
||||
return_value={"compiled_sql": "SELECT 1", "response_diagnostics": []}
|
||||
)
|
||||
return m
|
||||
|
||||
# ── compile_dataset_preview ──
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_compile_dataset_preview_first_strategy_succeeds(self, mixin):
|
||||
mixin.get_dataset.return_value = {"result": {"id": 1}}
|
||||
mixin.client.request.return_value = {"compiled_sql": "SELECT 1"}
|
||||
result = await mixin.compile_dataset_preview(
|
||||
dataset_id=1, template_params={"schema": "public"}, effective_filters=[{"col": "status", "val": "active"}]
|
||||
)
|
||||
assert result["compiled_sql"] == "SELECT 1"
|
||||
assert result["dataset_id"] == 1
|
||||
assert "endpoint_kind" in result
|
||||
assert "strategy_attempts" in result
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_compile_dataset_preview_all_strategies_fail(self, mixin):
|
||||
mixin.get_dataset.return_value = {"result": {"id": 1}}
|
||||
mixin.client.request.side_effect = Exception("API error")
|
||||
|
||||
from src.core.utils.network import SupersetAPIError
|
||||
|
||||
with pytest.raises(SupersetAPIError, match="Superset preview compilation failed"):
|
||||
await mixin.compile_dataset_preview(dataset_id=1)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_compile_dataset_preview_get_dataset_non_dict(self, mixin):
|
||||
mixin.get_dataset.return_value = "raw_response"
|
||||
mixin.client.request.return_value = {"compiled_sql": "SELECT 1"}
|
||||
result = await mixin.compile_dataset_preview(dataset_id=1)
|
||||
assert result["compiled_sql"] == "SELECT 1"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_compile_dataset_preview_no_template_params(self, mixin):
|
||||
# Note: fixture sets mixin._extract_compiled_sql_from_preview_response to return
|
||||
# {"compiled_sql": "SELECT 1"}, so this overrides client.request return value.
|
||||
# The actual endpoint returns whatever the mock provides, but the extraction
|
||||
# is mocked to "SELECT 1". This test verifies that calling without params works.
|
||||
mixin.get_dataset.return_value = {"result": {"id": 1}}
|
||||
mixin.client.request.return_value = {"compiled_sql": "SELECT 1"}
|
||||
result = await mixin.compile_dataset_preview(dataset_id=1)
|
||||
assert result["compiled_sql"] == "SELECT 1"
|
||||
|
||||
# ── build_dataset_preview_query_context ──
|
||||
|
||||
def test_build_dataset_preview_query_context_basic(self):
|
||||
from src.core.superset_client._datasets_preview import (
|
||||
SupersetDatasetsPreviewMixin,
|
||||
)
|
||||
|
||||
m = SupersetDatasetsPreviewMixin()
|
||||
m._normalize_effective_filters_for_query_context = MagicMock(
|
||||
return_value={
|
||||
"filters": [{"col": "status", "op": "==", "val": "active"}],
|
||||
"extra_form_data": {},
|
||||
"diagnostics": [],
|
||||
}
|
||||
)
|
||||
result = m.build_dataset_preview_query_context(
|
||||
dataset_id=1,
|
||||
dataset_record={
|
||||
"id": 1,
|
||||
"table_name": "users",
|
||||
"schema": "public",
|
||||
"datasource": {"id": 1, "type": "table"},
|
||||
},
|
||||
template_params={"param1": "val1"},
|
||||
effective_filters=[{"col": "status", "val": "active"}],
|
||||
)
|
||||
assert result["datasource"]["id"] == 1
|
||||
assert result["datasource"]["type"] == "table"
|
||||
assert result["form_data"]["datasource"] == "1__table"
|
||||
assert len(result["queries"]) == 1
|
||||
assert result["queries"][0]["schema"] == "public"
|
||||
assert result["queries"][0]["row_limit"] == 1000
|
||||
assert result["queries"][0]["url_params"] == {"param1": "val1"}
|
||||
|
||||
def test_build_dataset_preview_query_context_with_template_params_from_dataset(self):
|
||||
from src.core.superset_client._datasets_preview import (
|
||||
SupersetDatasetsPreviewMixin,
|
||||
)
|
||||
|
||||
m = SupersetDatasetsPreviewMixin()
|
||||
m._normalize_effective_filters_for_query_context = MagicMock(
|
||||
return_value={"filters": [], "extra_form_data": {}, "diagnostics": []}
|
||||
)
|
||||
result = m.build_dataset_preview_query_context(
|
||||
dataset_id=1,
|
||||
dataset_record={
|
||||
"id": 1,
|
||||
"table_name": "users",
|
||||
"template_params": '{"dataset_param": "value"}',
|
||||
},
|
||||
template_params={"param1": "val1"},
|
||||
effective_filters=[],
|
||||
)
|
||||
# Dataset template_params should be merged (setdefault, so existing keys take precedence)
|
||||
assert result["queries"][0]["url_params"]["param1"] == "val1"
|
||||
assert result["queries"][0]["url_params"]["dataset_param"] == "value"
|
||||
|
||||
def test_build_dataset_preview_query_context_with_bad_template_params(self):
|
||||
from src.core.superset_client._datasets_preview import (
|
||||
SupersetDatasetsPreviewMixin,
|
||||
)
|
||||
|
||||
m = SupersetDatasetsPreviewMixin()
|
||||
m._normalize_effective_filters_for_query_context = MagicMock(
|
||||
return_value={"filters": [], "extra_form_data": {}, "diagnostics": []}
|
||||
)
|
||||
with patch("src.core.superset_client._datasets_preview.app_logger") as mock_log:
|
||||
result = m.build_dataset_preview_query_context(
|
||||
dataset_id=1,
|
||||
dataset_record={
|
||||
"id": 1,
|
||||
"table_name": "users",
|
||||
"template_params": "{invalid json}",
|
||||
},
|
||||
template_params={},
|
||||
effective_filters=[],
|
||||
)
|
||||
mock_log.explore.assert_called_once()
|
||||
assert result["queries"][0]["url_params"] == {}
|
||||
|
||||
def test_build_dataset_preview_query_context_with_filters(self):
|
||||
from src.core.superset_client._datasets_preview import (
|
||||
SupersetDatasetsPreviewMixin,
|
||||
)
|
||||
|
||||
m = SupersetDatasetsPreviewMixin()
|
||||
m._normalize_effective_filters_for_query_context = MagicMock(
|
||||
return_value={
|
||||
"filters": [{"col": "status", "op": "==", "val": "active"}],
|
||||
"extra_form_data": {"time_range": "Last week"},
|
||||
"diagnostics": [],
|
||||
}
|
||||
)
|
||||
result = m.build_dataset_preview_query_context(
|
||||
dataset_id=1,
|
||||
dataset_record={"id": 1, "table_name": "users"},
|
||||
template_params={},
|
||||
effective_filters=[{"col": "status", "val": "active"}],
|
||||
)
|
||||
# Filters should be in extra_form_data and in the query
|
||||
assert "time_range" in result["form_data"]["extra_form_data"]
|
||||
assert result["form_data"]["extra_form_data"]["time_range"] == "Last week"
|
||||
assert result["queries"][0]["filters"] == [{"col": "status", "op": "==", "val": "active"}]
|
||||
|
||||
def test_build_dataset_preview_query_context_datasource_passthrough(self):
|
||||
from src.core.superset_client._datasets_preview import (
|
||||
SupersetDatasetsPreviewMixin,
|
||||
)
|
||||
|
||||
m = SupersetDatasetsPreviewMixin()
|
||||
m._normalize_effective_filters_for_query_context = MagicMock(
|
||||
return_value={"filters": [], "extra_form_data": {}, "diagnostics": []}
|
||||
)
|
||||
result = m.build_dataset_preview_query_context(
|
||||
dataset_id=1,
|
||||
dataset_record={
|
||||
"id": 1,
|
||||
"table_name": "users",
|
||||
"datasource": {"id": 99, "type": "query"},
|
||||
},
|
||||
template_params={},
|
||||
effective_filters=[],
|
||||
)
|
||||
# The datasource from the record should override
|
||||
assert result["datasource"]["id"] == 99
|
||||
assert result["datasource"]["type"] == "query"
|
||||
|
||||
# ── build_dataset_preview_legacy_form_data ──
|
||||
|
||||
def test_build_dataset_preview_legacy_form_data_basic(self):
|
||||
from src.core.superset_client._datasets_preview import (
|
||||
SupersetDatasetsPreviewMixin,
|
||||
)
|
||||
|
||||
m = SupersetDatasetsPreviewMixin()
|
||||
m.build_dataset_preview_query_context = MagicMock(
|
||||
return_value={
|
||||
"queries": [{
|
||||
"metrics": ["count"],
|
||||
"columns": [],
|
||||
"orderby": [],
|
||||
"annotation_layers": [],
|
||||
"row_limit": 1000,
|
||||
"series_limit": 0,
|
||||
"url_params": {},
|
||||
"applied_time_extras": {},
|
||||
"extras": {"where": ""},
|
||||
}],
|
||||
"form_data": {"datasource": "1__table"},
|
||||
"result_format": "json",
|
||||
"result_type": "query",
|
||||
"force": True,
|
||||
}
|
||||
)
|
||||
result = m.build_dataset_preview_legacy_form_data(1, {}, {}, [])
|
||||
assert result["metrics"] == ["count"]
|
||||
assert result["result_format"] == "json"
|
||||
assert result["result_type"] == "query"
|
||||
assert result["force"] is True
|
||||
assert "datasource" not in result # popped
|
||||
|
||||
def test_build_dataset_preview_legacy_form_data_with_extras(self):
|
||||
from src.core.superset_client._datasets_preview import (
|
||||
SupersetDatasetsPreviewMixin,
|
||||
)
|
||||
|
||||
m = SupersetDatasetsPreviewMixin()
|
||||
m.build_dataset_preview_query_context = MagicMock(
|
||||
return_value={
|
||||
"queries": [{
|
||||
"metrics": ["count"],
|
||||
"columns": [],
|
||||
"orderby": [],
|
||||
"annotation_layers": [],
|
||||
"row_limit": 1000,
|
||||
"series_limit": 0,
|
||||
"url_params": {},
|
||||
"applied_time_extras": {},
|
||||
"extras": {"where": "1=1"},
|
||||
"time_range": "Last 7 days",
|
||||
}],
|
||||
"form_data": {},
|
||||
"result_format": "json",
|
||||
"result_type": "query",
|
||||
"force": False,
|
||||
}
|
||||
)
|
||||
result = m.build_dataset_preview_legacy_form_data(1, {}, {}, [])
|
||||
assert result["extras"]["where"] == "1=1"
|
||||
assert result["time_range"] == "Last 7 days"
|
||||
assert result["force"] is False
|
||||
# #endregion Test.DatasetsPreviewMixin
|
||||
239
backend/tests/test_core/test_datasets_preview_filters.py
Normal file
239
backend/tests/test_core/test_datasets_preview_filters.py
Normal file
@@ -0,0 +1,239 @@
|
||||
# #region Test.DatasetsPreviewFiltersMixin [C:3] [TYPE Module] [SEMANTICS test, dataset, preview, filter, sql, extract]
|
||||
# @BRIEF Tests for core/superset_client/_datasets_preview_filters.py — filter normalization + SQL extraction.
|
||||
# @RELATION BINDS_TO -> [SupersetDatasetsPreviewFiltersMixin]
|
||||
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent.parent / "src"))
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
class TestNormalizeEffectiveFilters:
|
||||
"""_normalize_effective_filters_for_query_context: filter conversion."""
|
||||
|
||||
@pytest.fixture
|
||||
def mixin(self):
|
||||
from src.core.superset_client._datasets_preview_filters import (
|
||||
SupersetDatasetsPreviewFiltersMixin,
|
||||
)
|
||||
|
||||
return SupersetDatasetsPreviewFiltersMixin()
|
||||
|
||||
def test_empty_filters(self, mixin):
|
||||
result = mixin._normalize_effective_filters_for_query_context([])
|
||||
assert result["filters"] == []
|
||||
assert result["diagnostics"] == []
|
||||
|
||||
def test_skip_non_dict_items(self, mixin):
|
||||
result = mixin._normalize_effective_filters_for_query_context(
|
||||
["string", 42, None]
|
||||
)
|
||||
assert result["filters"] == []
|
||||
assert len(result["diagnostics"]) == 0 # skipped before diagnostics
|
||||
|
||||
def test_heuristic_reconstruction_with_column_and_value(self, mixin):
|
||||
result = mixin._normalize_effective_filters_for_query_context([
|
||||
{"variable_name": "status", "effective_value": "active"}
|
||||
])
|
||||
assert len(result["filters"]) == 1
|
||||
assert result["filters"][0]["col"] == "status"
|
||||
assert result["filters"][0]["op"] == "=="
|
||||
assert result["filters"][0]["val"] == "active"
|
||||
|
||||
def test_heuristic_reconstruction_with_list_value(self, mixin):
|
||||
result = mixin._normalize_effective_filters_for_query_context([
|
||||
{"variable_name": "status", "effective_value": ["active", "pending"]}
|
||||
])
|
||||
assert result["filters"][0]["op"] == "IN"
|
||||
|
||||
def test_heuristic_reconstruction_missing_column_skipped(self, mixin):
|
||||
result = mixin._normalize_effective_filters_for_query_context([
|
||||
{"effective_value": "val"}
|
||||
])
|
||||
assert result["filters"] == []
|
||||
|
||||
def test_preserved_clauses_without_val(self, mixin):
|
||||
result = mixin._normalize_effective_filters_for_query_context([
|
||||
{
|
||||
"display_name": "Category",
|
||||
"effective_value": "electronics",
|
||||
"normalized_filter_payload": {
|
||||
"filter_clauses": [{"col": "category", "op": "=="}],
|
||||
"value_origin": "preset",
|
||||
},
|
||||
}
|
||||
])
|
||||
assert len(result["filters"]) == 1
|
||||
# val should be filled from effective_value
|
||||
assert result["filters"][0]["val"] == "electronics"
|
||||
|
||||
def test_preserved_clauses_with_existing_val(self, mixin):
|
||||
result = mixin._normalize_effective_filters_for_query_context([
|
||||
{
|
||||
"display_name": "Category",
|
||||
"effective_value": "fallback",
|
||||
"normalized_filter_payload": {
|
||||
"filter_clauses": [{"col": "category", "op": "==", "val": "original"}],
|
||||
"value_origin": "preset",
|
||||
},
|
||||
}
|
||||
])
|
||||
assert len(result["filters"]) == 1
|
||||
# val should remain "original" since it already exists
|
||||
assert result["filters"][0]["val"] == "original"
|
||||
|
||||
def test_extra_form_data_merged(self, mixin):
|
||||
result = mixin._normalize_effective_filters_for_query_context([
|
||||
{
|
||||
"display_name": "Filter1",
|
||||
"effective_value": "val1",
|
||||
"normalized_filter_payload": {
|
||||
"extra_form_data": {"time_range": "Last month", "filters": ["ignored"]},
|
||||
"filter_clauses": [],
|
||||
},
|
||||
}
|
||||
])
|
||||
# extra_form_data.filters is skipped during merge
|
||||
assert result["extra_form_data"]["time_range"] == "Last month"
|
||||
assert "filters" not in result["extra_form_data"]
|
||||
|
||||
def test_extra_form_data_without_preserved_clauses_uses_empty(self, mixin):
|
||||
result = mixin._normalize_effective_filters_for_query_context([
|
||||
{
|
||||
"display_name": "Filter1",
|
||||
"effective_value": None,
|
||||
"normalized_filter_payload": {
|
||||
"extra_form_data": {"time_range": "All time"},
|
||||
},
|
||||
}
|
||||
])
|
||||
# Has extra_form_data but no clauses -> empty clauses
|
||||
assert result["filters"] == []
|
||||
|
||||
def test_missing_display_name_fallback(self, mixin):
|
||||
result = mixin._normalize_effective_filters_for_query_context([
|
||||
{
|
||||
"filter_name": "my_filter",
|
||||
"variable_name": "my_var",
|
||||
"effective_value": "val",
|
||||
}
|
||||
])
|
||||
diagnostics = result["diagnostics"]
|
||||
assert diagnostics[0]["filter_name"] == "my_filter"
|
||||
|
||||
def test_unnamed_filter_fallback(self, mixin):
|
||||
result = mixin._normalize_effective_filters_for_query_context([
|
||||
{"effective_value": "x"}
|
||||
])
|
||||
assert result["filters"] == []
|
||||
|
||||
|
||||
class TestExtractCompiledSql:
|
||||
"""_extract_compiled_sql_from_preview_response: normalized SQL from various response shapes."""
|
||||
|
||||
@pytest.fixture
|
||||
def mixin(self):
|
||||
from src.core.superset_client._datasets_preview_filters import (
|
||||
SupersetDatasetsPreviewFiltersMixin,
|
||||
)
|
||||
|
||||
return SupersetDatasetsPreviewFiltersMixin()
|
||||
|
||||
def test_result_list_with_query(self, mixin):
|
||||
response = {
|
||||
"result": [
|
||||
{"query": "SELECT 1", "status": "success", "applied_filters": []},
|
||||
{"query": "SELECT 2", "status": "success"},
|
||||
]
|
||||
}
|
||||
result = mixin._extract_compiled_sql_from_preview_response(response)
|
||||
assert result["compiled_sql"] == "SELECT 1"
|
||||
|
||||
def test_result_list_with_sql_field(self, mixin):
|
||||
response = {
|
||||
"result": [
|
||||
{"sql": "SELECT * FROM users", "status": "success"},
|
||||
]
|
||||
}
|
||||
result = mixin._extract_compiled_sql_from_preview_response(response)
|
||||
assert result["compiled_sql"] == "SELECT * FROM users"
|
||||
|
||||
def test_result_list_with_compiled_sql(self, mixin):
|
||||
response = {
|
||||
"result": [
|
||||
{"compiled_sql": "SELECT count(*) FROM t", "status": "success"},
|
||||
]
|
||||
}
|
||||
result = mixin._extract_compiled_sql_from_preview_response(response)
|
||||
assert result["compiled_sql"] == "SELECT count(*) FROM t"
|
||||
|
||||
def test_result_list_empty_returns_top_level(self, mixin):
|
||||
response = {
|
||||
"result": [
|
||||
{"status": "success", "applied_filters": []},
|
||||
],
|
||||
"query": "SELECT top FROM dual",
|
||||
}
|
||||
result = mixin._extract_compiled_sql_from_preview_response(response)
|
||||
assert result["compiled_sql"] == "SELECT top FROM dual"
|
||||
|
||||
def test_result_dict_with_query(self, mixin):
|
||||
response = {
|
||||
"result": {"query": "SELECT nested FROM result_dict"}
|
||||
}
|
||||
result = mixin._extract_compiled_sql_from_preview_response(response)
|
||||
assert result["compiled_sql"] == "SELECT nested FROM result_dict"
|
||||
|
||||
def test_result_dict_fallback_to_top_level(self, mixin):
|
||||
response = {
|
||||
"result": {"status": "ok"},
|
||||
"sql": "SELECT fallback FROM top",
|
||||
}
|
||||
result = mixin._extract_compiled_sql_from_preview_response(response)
|
||||
assert result["compiled_sql"] == "SELECT fallback FROM top"
|
||||
|
||||
def test_result_dict_empty_result_obj_falls_through(self, mixin):
|
||||
response = {
|
||||
"result": {},
|
||||
"compiled_sql": "SELECT fallback FROM compiled",
|
||||
}
|
||||
result = mixin._extract_compiled_sql_from_preview_response(response)
|
||||
assert result["compiled_sql"] == "SELECT fallback FROM compiled"
|
||||
|
||||
def test_no_sql_raises_error(self, mixin):
|
||||
from src.core.utils.network import SupersetAPIError
|
||||
|
||||
response = {"result": []}
|
||||
with pytest.raises(SupersetAPIError, match="Superset preview response did not expose compiled SQL"):
|
||||
mixin._extract_compiled_sql_from_preview_response(response)
|
||||
|
||||
def test_non_dict_response_raises_error(self, mixin):
|
||||
from src.core.utils.network import SupersetAPIError
|
||||
|
||||
with pytest.raises(SupersetAPIError, match="Superset preview response was not a JSON object"):
|
||||
mixin._extract_compiled_sql_from_preview_response("not_a_dict")
|
||||
|
||||
def test_whitespace_only_sql_is_empty(self, mixin):
|
||||
from src.core.utils.network import SupersetAPIError
|
||||
|
||||
response = {"result": [{"query": " "}]}
|
||||
with pytest.raises(SupersetAPIError):
|
||||
mixin._extract_compiled_sql_from_preview_response(response)
|
||||
|
||||
def test_result_list_skips_non_dict_items(self, mixin):
|
||||
response = {
|
||||
"result": ["string_item", 42, {"query": "SELECT 1"}]
|
||||
}
|
||||
result = mixin._extract_compiled_sql_from_preview_response(response)
|
||||
assert result["compiled_sql"] == "SELECT 1"
|
||||
|
||||
def test_response_diagnostics_includes_all_attempts(self, mixin):
|
||||
response = {"query": "SELECT 1"}
|
||||
result = mixin._extract_compiled_sql_from_preview_response(response)
|
||||
assert len(result["response_diagnostics"]) >= 1
|
||||
assert result["response_diagnostics"][0]["source"] == "query"
|
||||
# #endregion Test.DatasetsPreviewFiltersMixin
|
||||
274
backend/tests/test_core/test_layout_utils.py
Normal file
274
backend/tests/test_core/test_layout_utils.py
Normal file
@@ -0,0 +1,274 @@
|
||||
# #region Test.LayoutUtils [C:2] [TYPE Module] [SEMANTICS test, layout, position, json, banner]
|
||||
# @BRIEF Tests for core/superset_client/_layout_utils.py — pure position JSON manipulation.
|
||||
# @RELATION BINDS_TO -> [LayoutUtils]
|
||||
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent.parent / "src"))
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
class TestParsePositionJson:
|
||||
"""parse_position_json: str/dict/None -> dict."""
|
||||
|
||||
def test_parses_json_string(self):
|
||||
from src.core.superset_client._layout_utils import parse_position_json
|
||||
|
||||
result = parse_position_json('{"GRID_ID": {"children": []}}')
|
||||
assert result == {"GRID_ID": {"children": []}}
|
||||
|
||||
def test_empty_string_returns_empty_dict(self):
|
||||
from src.core.superset_client._layout_utils import parse_position_json
|
||||
|
||||
result = parse_position_json("")
|
||||
assert result == {}
|
||||
|
||||
def test_whitespace_string_returns_empty_dict(self):
|
||||
from src.core.superset_client._layout_utils import parse_position_json
|
||||
|
||||
result = parse_position_json(" ")
|
||||
assert result == {}
|
||||
|
||||
def test_dict_passthrough(self):
|
||||
from src.core.superset_client._layout_utils import parse_position_json
|
||||
|
||||
raw = {"GRID_ID": {"children": []}}
|
||||
result = parse_position_json(raw)
|
||||
assert result is raw
|
||||
|
||||
def test_none_returns_empty_dict(self):
|
||||
from src.core.superset_client._layout_utils import parse_position_json
|
||||
|
||||
result = parse_position_json(None)
|
||||
assert result == {}
|
||||
|
||||
def test_invalid_json_raises(self):
|
||||
from src.core.superset_client._layout_utils import parse_position_json
|
||||
|
||||
with pytest.raises(Exception):
|
||||
parse_position_json("{invalid}")
|
||||
|
||||
|
||||
class TestEstimateMarkdownHeight:
|
||||
"""_estimate_markdown_height: content -> int height."""
|
||||
|
||||
def test_empty_content_returns_min_height(self):
|
||||
from src.core.superset_client._layout_utils import _estimate_markdown_height
|
||||
|
||||
assert _estimate_markdown_height("") == 19
|
||||
|
||||
def test_short_content_returns_min_height(self):
|
||||
from src.core.superset_client._layout_utils import _estimate_markdown_height
|
||||
|
||||
assert _estimate_markdown_height("short text") == 19
|
||||
|
||||
def test_long_content_returns_larger_height(self):
|
||||
from src.core.superset_client._layout_utils import _estimate_markdown_height
|
||||
|
||||
text = "word " * 200 # ~1000 chars => ~25 text lines
|
||||
height = _estimate_markdown_height(text)
|
||||
assert height >= 19
|
||||
assert height <= 200
|
||||
|
||||
def test_html_content_counts_padding(self):
|
||||
from src.core.superset_client._layout_utils import _estimate_markdown_height
|
||||
|
||||
html = '<div style="padding:12">some text</div>'
|
||||
height = _estimate_markdown_height(html)
|
||||
assert height >= 19
|
||||
|
||||
def test_plain_text_no_html_tags(self):
|
||||
from src.core.superset_client._layout_utils import _estimate_markdown_height
|
||||
|
||||
text = "A" * 120 # ~3 lines at 40 chars/line
|
||||
height = _estimate_markdown_height(text)
|
||||
assert 19 <= height <= 200
|
||||
|
||||
def test_caps_at_200(self):
|
||||
from src.core.superset_client._layout_utils import _estimate_markdown_height
|
||||
|
||||
text = "word " * 5000 # very long
|
||||
height = _estimate_markdown_height(text)
|
||||
assert height <= 200
|
||||
|
||||
|
||||
class TestGenerateBannerId:
|
||||
"""_generate_banner_id: chart_id -> (row_key, md_key)."""
|
||||
|
||||
def test_returns_deterministic_keys(self):
|
||||
from src.core.superset_client._layout_utils import _generate_banner_id
|
||||
|
||||
row_key, md_key = _generate_banner_id(42)
|
||||
assert row_key == "ROW-banner-42"
|
||||
assert md_key == "MARKDOWN-banner-42"
|
||||
|
||||
def test_zero_chart_id(self):
|
||||
from src.core.superset_client._layout_utils import _generate_banner_id
|
||||
|
||||
row_key, md_key = _generate_banner_id(0)
|
||||
assert row_key == "ROW-banner-0"
|
||||
assert md_key == "MARKDOWN-banner-0"
|
||||
|
||||
|
||||
class TestInsertBannerMarkdownAtTop:
|
||||
"""insert_banner_markdown_at_top: position_json + chart_id + content -> mutated dict."""
|
||||
|
||||
def test_inserts_row_and_markdown_at_top(self):
|
||||
from src.core.superset_client._layout_utils import insert_banner_markdown_at_top
|
||||
|
||||
position_json = {"GRID_ID": {"children": ["ROW-existing"]}}
|
||||
result = insert_banner_markdown_at_top(position_json, 1, "Hello")
|
||||
assert "ROW-banner-1" in result
|
||||
assert "MARKDOWN-banner-1" in result
|
||||
children = result["GRID_ID"]["children"]
|
||||
assert children[0] == "ROW-banner-1"
|
||||
assert "ROW-existing" in children
|
||||
|
||||
def test_shifts_existing_items_down(self):
|
||||
from src.core.superset_client._layout_utils import insert_banner_markdown_at_top
|
||||
|
||||
position_json = {
|
||||
"GRID_ID": {"children": ["ROW-existing"]},
|
||||
"ROW-existing": {"type": "ROW", "meta": {"y": 0}},
|
||||
}
|
||||
insert_banner_markdown_at_top(position_json, 2, "Banner")
|
||||
assert position_json["ROW-existing"]["meta"]["y"] == 2
|
||||
|
||||
def test_handles_missing_grid(self):
|
||||
from src.core.superset_client._layout_utils import insert_banner_markdown_at_top
|
||||
|
||||
position_json = {}
|
||||
result = insert_banner_markdown_at_top(position_json, 3, "Content")
|
||||
assert "ROW-banner-3" in result
|
||||
assert "MARKDOWN-banner-3" in result
|
||||
|
||||
def test_content_affects_height(self):
|
||||
from src.core.superset_client._layout_utils import insert_banner_markdown_at_top
|
||||
|
||||
position_json = {"GRID_ID": {"children": []}}
|
||||
result = insert_banner_markdown_at_top(position_json, 4, "short")
|
||||
md_meta = result["MARKDOWN-banner-4"]["meta"]
|
||||
assert md_meta["height"] >= 19
|
||||
assert md_meta["code"] == "short"
|
||||
|
||||
def test_row_removed_from_grid_if_duplicate(self):
|
||||
from src.core.superset_client._layout_utils import insert_banner_markdown_at_top
|
||||
|
||||
# If ROW already exists in children, it should be moved to front
|
||||
position_json = {
|
||||
"GRID_ID": {"children": ["ROW-banner-5", "ROW-other"]},
|
||||
"ROW-banner-5": {"type": "ROW", "meta": {"y": 0}},
|
||||
}
|
||||
# This simulates a re-insert
|
||||
insert_banner_markdown_at_top(position_json, 5, "Updated")
|
||||
children = position_json["GRID_ID"]["children"]
|
||||
assert children[0] == "ROW-banner-5"
|
||||
assert children[1] == "ROW-other"
|
||||
assert len(children) == 2
|
||||
|
||||
def test_markdown_includes_parents(self):
|
||||
from src.core.superset_client._layout_utils import insert_banner_markdown_at_top
|
||||
|
||||
position_json = {"GRID_ID": {"children": []}}
|
||||
result = insert_banner_markdown_at_top(position_json, 6, "test")
|
||||
md = result["MARKDOWN-banner-6"]
|
||||
assert md["parents"] == ["ROOT_ID", "GRID_ID", "ROW-banner-6"]
|
||||
assert md["type"] == "MARKDOWN"
|
||||
|
||||
def test_row_includes_parents(self):
|
||||
from src.core.superset_client._layout_utils import insert_banner_markdown_at_top
|
||||
|
||||
position_json = {"GRID_ID": {"children": []}}
|
||||
result = insert_banner_markdown_at_top(position_json, 7, "test")
|
||||
row = result["ROW-banner-7"]
|
||||
assert row["parents"] == ["ROOT_ID", "GRID_ID"]
|
||||
assert row["type"] == "ROW"
|
||||
assert row["children"] == ["MARKDOWN-banner-7"]
|
||||
|
||||
|
||||
class TestUpdateBannerMarkdownContent:
|
||||
"""update_banner_markdown_content: position_json + key + content -> mutated dict."""
|
||||
|
||||
def test_updates_content_and_height(self):
|
||||
from src.core.superset_client._layout_utils import update_banner_markdown_content
|
||||
|
||||
position_json = {
|
||||
"MARKDOWN-banner-1": {
|
||||
"type": "MARKDOWN",
|
||||
"meta": {"code": "old", "height": 19},
|
||||
}
|
||||
}
|
||||
update_banner_markdown_content(position_json, "MARKDOWN-banner-1", "new content " * 20)
|
||||
meta = position_json["MARKDOWN-banner-1"]["meta"]
|
||||
assert meta["code"] == "new content " * 20
|
||||
assert meta["height"] >= 19
|
||||
|
||||
def test_does_nothing_if_key_not_found(self):
|
||||
from src.core.superset_client._layout_utils import update_banner_markdown_content
|
||||
|
||||
position_json = {"other": {"meta": {"code": "x"}}}
|
||||
result = update_banner_markdown_content(position_json, "MARKDOWN-banner-99", "new")
|
||||
assert result is position_json # no crash, returns same dict
|
||||
|
||||
def test_handles_missing_meta(self):
|
||||
from src.core.superset_client._layout_utils import update_banner_markdown_content
|
||||
|
||||
position_json = {"MARKDOWN-banner-1": {"type": "MARKDOWN"}}
|
||||
result = update_banner_markdown_content(position_json, "MARKDOWN-banner-1", "content")
|
||||
assert result is position_json # no crash
|
||||
|
||||
|
||||
class TestRemoveBannerFromPosition:
|
||||
"""remove_banner_from_position: position_json + chart_id -> mutated dict."""
|
||||
|
||||
def test_removes_row_and_markdown(self):
|
||||
from src.core.superset_client._layout_utils import remove_banner_from_position
|
||||
|
||||
position_json = {
|
||||
"GRID_ID": {"children": ["ROW-banner-1", "ROW-other"]},
|
||||
"ROW-banner-1": {"type": "ROW", "meta": {"y": 0}},
|
||||
"MARKDOWN-banner-1": {"type": "MARKDOWN", "meta": {"y": 0}},
|
||||
"ROW-other": {"type": "ROW", "meta": {"y": 2}},
|
||||
}
|
||||
result = remove_banner_from_position(position_json, 1)
|
||||
assert "ROW-banner-1" not in result
|
||||
assert "MARKDOWN-banner-1" not in result
|
||||
assert "ROW-other" in result
|
||||
assert "GRID_ID" in result
|
||||
# ROW-banner-1 removed from children list
|
||||
assert "ROW-banner-1" not in result["GRID_ID"]["children"]
|
||||
|
||||
def test_shifts_remaining_items_up_when_banner_at_y0(self):
|
||||
from src.core.superset_client._layout_utils import remove_banner_from_position
|
||||
|
||||
position_json = {
|
||||
"GRID_ID": {"children": ["ROW-banner-1", "ROW-other"]},
|
||||
"ROW-banner-1": {"type": "ROW", "meta": {"y": 0}},
|
||||
"MARKDOWN-banner-1": {"type": "MARKDOWN", "meta": {"y": 0}},
|
||||
"ROW-other": {"type": "ROW", "meta": {"y": 2}},
|
||||
}
|
||||
remove_banner_from_position(position_json, 1)
|
||||
assert position_json["ROW-other"]["meta"]["y"] == 0
|
||||
|
||||
def test_does_not_shift_when_banner_not_at_y0(self):
|
||||
from src.core.superset_client._layout_utils import remove_banner_from_position
|
||||
|
||||
position_json = {
|
||||
"GRID_ID": {"children": ["ROW-banner-1", "ROW-other"]},
|
||||
"ROW-banner-1": {"type": "ROW", "meta": {"y": 5}},
|
||||
"MARKDOWN-banner-1": {"type": "MARKDOWN", "meta": {"y": 5}},
|
||||
"ROW-other": {"type": "ROW", "meta": {"y": 2}},
|
||||
}
|
||||
remove_banner_from_position(position_json, 1)
|
||||
assert position_json["ROW-other"]["meta"]["y"] == 2
|
||||
|
||||
def test_noop_when_banner_does_not_exist(self):
|
||||
from src.core.superset_client._layout_utils import remove_banner_from_position
|
||||
|
||||
position_json = {"GRID_ID": {"children": ["ROW-other"]}, "ROW-other": {"meta": {"y": 0}}}
|
||||
result = remove_banner_from_position(position_json, 99)
|
||||
assert result is position_json # no crash
|
||||
assert "ROW-other" in result
|
||||
# #endregion Test.LayoutUtils
|
||||
340
backend/tests/test_core/test_mapping_service.py
Normal file
340
backend/tests/test_core/test_mapping_service.py
Normal file
@@ -0,0 +1,340 @@
|
||||
# #region Test.IdMappingService [C:4] [TYPE Module] [SEMANTICS test, mapping, service, sync, scheduler]
|
||||
# @BRIEF Tests for core/mapping_service.py — IdMappingService.
|
||||
# @RELATION BINDS_TO -> [IdMappingServiceModule]
|
||||
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent.parent / "src"))
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from unittest.mock import AsyncMock, MagicMock, PropertyMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_db():
|
||||
"""Create a mock SQLAlchemy Session with proper chaining."""
|
||||
session = MagicMock()
|
||||
return session
|
||||
|
||||
|
||||
class TestIdMappingServiceInit:
|
||||
"""__init__: stores db session and creates scheduler."""
|
||||
|
||||
def test_init(self, mock_db):
|
||||
from src.core.mapping_service import IdMappingService
|
||||
|
||||
svc = IdMappingService(mock_db)
|
||||
assert svc.db is mock_db
|
||||
assert svc.scheduler is not None
|
||||
|
||||
|
||||
class TestStartScheduler:
|
||||
"""start_scheduler: cron-based background sync."""
|
||||
|
||||
@pytest.fixture
|
||||
def svc(self, mock_db):
|
||||
from src.core.mapping_service import IdMappingService
|
||||
svc = IdMappingService(mock_db)
|
||||
# Patch the scheduler to avoid real BackgroundScheduler issues
|
||||
svc.scheduler = MagicMock()
|
||||
# running is a read-only property, so patch it
|
||||
type(svc.scheduler).running = PropertyMock(return_value=False)
|
||||
return svc
|
||||
|
||||
def test_starts_scheduler(self, svc):
|
||||
with patch("src.core.mapping_service.seed_trace_id"):
|
||||
svc.start_scheduler(
|
||||
cron_string="0 * * * *",
|
||||
environments=["env-1"],
|
||||
superset_client_factory=lambda e: MagicMock(),
|
||||
)
|
||||
svc.scheduler.add_job.assert_called_once()
|
||||
|
||||
def test_removes_existing_job(self, svc):
|
||||
svc._sync_job = MagicMock()
|
||||
svc._sync_job.id = "existing"
|
||||
|
||||
with patch("src.core.mapping_service.seed_trace_id"):
|
||||
svc.start_scheduler(
|
||||
cron_string="0 * * * *",
|
||||
environments=["env-1"],
|
||||
superset_client_factory=lambda e: MagicMock(),
|
||||
)
|
||||
svc.scheduler.remove_job.assert_called_once_with("existing")
|
||||
|
||||
def test_scheduler_already_running(self, svc):
|
||||
type(svc.scheduler).running = PropertyMock(return_value=True)
|
||||
|
||||
with patch("src.core.mapping_service.seed_trace_id"):
|
||||
svc.start_scheduler(
|
||||
cron_string="0 * * * *",
|
||||
environments=["env-1"],
|
||||
superset_client_factory=lambda e: MagicMock(),
|
||||
)
|
||||
# Should not call start again — just update
|
||||
svc.scheduler.add_job.assert_called_once()
|
||||
|
||||
def test_skip_sync_if_no_client(self, svc):
|
||||
with patch("src.core.mapping_service.seed_trace_id"):
|
||||
svc.start_scheduler(
|
||||
cron_string="0 * * * *",
|
||||
environments=["env-1"],
|
||||
superset_client_factory=lambda e: None,
|
||||
)
|
||||
svc.scheduler.add_job.assert_called_once()
|
||||
|
||||
|
||||
class TestSyncEnvironment:
|
||||
"""sync_environment: full and incremental sync."""
|
||||
|
||||
@pytest.fixture
|
||||
def svc(self, mock_db):
|
||||
from src.core.mapping_service import IdMappingService
|
||||
svc = IdMappingService(mock_db)
|
||||
return svc
|
||||
|
||||
def _setup_db_find(self, svc, existing=None, delete_result=0):
|
||||
"""Configure db.query chain for sync tests.
|
||||
|
||||
sync_environment does:
|
||||
db.query(ResourceMapping).filter_by(...).first()
|
||||
for each resource type * 3 (chart, dataset, dashboard).
|
||||
"""
|
||||
# Make .first() return existing (None = new item)
|
||||
first_mock = MagicMock(return_value=existing)
|
||||
# The stale delete query
|
||||
delete_mock = MagicMock(return_value=delete_result)
|
||||
|
||||
# Build chain: query().filter_by().first()
|
||||
filter_by_mock = MagicMock()
|
||||
filter_by_mock.first = first_mock
|
||||
|
||||
# Build chain: query().filter().filter() for stale detection
|
||||
filter2_mock = MagicMock()
|
||||
filter2_mock.filter.return_value = filter2_mock # recursive
|
||||
filter2_mock.delete = delete_mock
|
||||
|
||||
# Main query chain
|
||||
query_mock = MagicMock()
|
||||
query_mock.filter_by = MagicMock(return_value=filter_by_mock)
|
||||
query_mock.filter = MagicMock(return_value=filter2_mock)
|
||||
|
||||
svc.db.query = MagicMock(return_value=query_mock)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_full_sync_success(self, svc):
|
||||
self._setup_db_find(svc, existing=None)
|
||||
|
||||
client = AsyncMock()
|
||||
client.get_all_resources = AsyncMock(
|
||||
side_effect=[
|
||||
[{"uuid": "uuid-1", "id": 42, "slice_name": "Chart 1"},
|
||||
{"uuid": "uuid-2", "id": 43, "slice_name": "Chart 2"}],
|
||||
[{"uuid": "uuid-3", "id": 99, "table_name": "Dataset 1"}],
|
||||
[{"uuid": "uuid-4", "id": 10, "slug": "dashboard-1"}],
|
||||
]
|
||||
)
|
||||
svc.db.add = MagicMock()
|
||||
svc.db.commit = MagicMock()
|
||||
|
||||
await svc.sync_environment("env-1", client)
|
||||
assert svc.db.add.call_count == 4
|
||||
svc.db.commit.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sync_environment_upserts_existing(self, svc):
|
||||
existing = MagicMock()
|
||||
existing.remote_integer_id = "0"
|
||||
existing.resource_name = "old"
|
||||
existing.last_synced_at = datetime(2020, 1, 1, tzinfo=UTC)
|
||||
|
||||
self._setup_db_find(svc, existing=existing)
|
||||
|
||||
client = AsyncMock()
|
||||
client.get_all_resources = AsyncMock(
|
||||
return_value=[{"uuid": "uuid-1", "id": 42, "slice_name": "Chart 1"}]
|
||||
)
|
||||
svc.db.commit = MagicMock()
|
||||
|
||||
await svc.sync_environment("env-1", client)
|
||||
svc.db.add.assert_not_called()
|
||||
assert existing.remote_integer_id == "42"
|
||||
assert existing.resource_name == "Chart 1"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_incremental_sync(self, svc):
|
||||
# Configure: query().filter(...).filter(...).scalar() for max_date
|
||||
scalar_mock = MagicMock(return_value=datetime(2025, 1, 1, tzinfo=UTC))
|
||||
incremental_filter = MagicMock()
|
||||
incremental_filter.filter.return_value = incremental_filter # chain
|
||||
incremental_filter.scalar = scalar_mock
|
||||
|
||||
query_mock = MagicMock()
|
||||
query_mock.filter = MagicMock(return_value=incremental_filter)
|
||||
svc.db.query = MagicMock(return_value=query_mock)
|
||||
|
||||
# Also need first() for the upsert (not reached if resources is empty)
|
||||
self._setup_db_find(svc, existing=None)
|
||||
|
||||
client = AsyncMock()
|
||||
client.get_all_resources = AsyncMock(return_value=[])
|
||||
svc.db.commit = MagicMock()
|
||||
|
||||
await svc.sync_environment("env-1", client, incremental=True)
|
||||
client.get_all_resources.assert_called_once()
|
||||
call_args = client.get_all_resources.call_args
|
||||
assert call_args[1].get("since_dttm") is not None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sync_skips_items_without_uuid_or_id(self, svc):
|
||||
self._setup_db_find(svc, existing=None)
|
||||
|
||||
client = AsyncMock()
|
||||
client.get_all_resources = AsyncMock(
|
||||
return_value=[
|
||||
{"uuid": None, "id": 1, "slice_name": "No UUID"},
|
||||
{"uuid": "uuid-1", "id": None, "slice_name": "No ID"},
|
||||
{"uuid": "uuid-2", "id": 2, "slice_name": "Valid"},
|
||||
]
|
||||
)
|
||||
svc.db.add = MagicMock()
|
||||
svc.db.commit = MagicMock()
|
||||
|
||||
await svc.sync_environment("env-1", client)
|
||||
assert svc.db.add.call_count == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sync_handles_api_error_per_resource_type(self, svc):
|
||||
self._setup_db_find(svc, existing=None)
|
||||
|
||||
client = AsyncMock()
|
||||
client.get_all_resources = AsyncMock(
|
||||
side_effect=[
|
||||
Exception("Chart API failed"),
|
||||
[{"uuid": "uuid-1", "id": 42, "table_name": "Dataset 1"}],
|
||||
[{"uuid": "uuid-2", "id": 10, "slug": "dash"}],
|
||||
]
|
||||
)
|
||||
svc.db.add = MagicMock()
|
||||
svc.db.commit = MagicMock()
|
||||
|
||||
await svc.sync_environment("env-1", client)
|
||||
assert svc.db.add.call_count == 2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sync_deletes_stale_mappings(self, svc):
|
||||
self._setup_db_find(svc, existing=None, delete_result=2)
|
||||
|
||||
client = AsyncMock()
|
||||
client.get_all_resources = AsyncMock(
|
||||
return_value=[{"uuid": "uuid-1", "id": 42, "slice_name": "Chart 1"}]
|
||||
)
|
||||
svc.db.add = MagicMock()
|
||||
svc.db.commit = MagicMock()
|
||||
|
||||
await svc.sync_environment("env-1", client)
|
||||
# The stale deletion should have been called for each resource type
|
||||
filter_mock = svc.db.query.return_value.filter
|
||||
assert filter_mock.return_value.filter.return_value.delete.called
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sync_rollback_on_critical_failure(self, svc):
|
||||
self._setup_db_find(svc, existing=None)
|
||||
|
||||
svc.db.commit.side_effect = RuntimeError("Commit failed")
|
||||
svc.db.rollback = MagicMock()
|
||||
|
||||
client = AsyncMock()
|
||||
client.get_all_resources = AsyncMock(
|
||||
return_value=[{"uuid": "uuid-1", "id": 42, "slice_name": "Chart"}]
|
||||
)
|
||||
|
||||
with pytest.raises(RuntimeError):
|
||||
await svc.sync_environment("env-1", client)
|
||||
svc.db.rollback.assert_called_once()
|
||||
|
||||
|
||||
class TestGetRemoteId:
|
||||
"""get_remote_id: lookup single mapping."""
|
||||
|
||||
def test_found(self, mock_db):
|
||||
from src.core.mapping_service import IdMappingService
|
||||
from src.models.mapping import ResourceType
|
||||
|
||||
mapping = MagicMock()
|
||||
mapping.remote_integer_id = "42"
|
||||
mock_db.query.return_value.filter_by.return_value.first.return_value = mapping
|
||||
|
||||
svc = IdMappingService(mock_db)
|
||||
result = svc.get_remote_id("env-1", ResourceType.CHART, "uuid-1")
|
||||
assert result == 42
|
||||
|
||||
def test_not_found_returns_none(self, mock_db):
|
||||
from src.core.mapping_service import IdMappingService
|
||||
from src.models.mapping import ResourceType
|
||||
|
||||
mock_db.query.return_value.filter_by.return_value.first.return_value = None
|
||||
|
||||
svc = IdMappingService(mock_db)
|
||||
result = svc.get_remote_id("env-1", ResourceType.CHART, "uuid-none")
|
||||
assert result is None
|
||||
|
||||
def test_invalid_int_returns_none(self, mock_db):
|
||||
from src.core.mapping_service import IdMappingService
|
||||
from src.models.mapping import ResourceType
|
||||
|
||||
mapping = MagicMock()
|
||||
mapping.remote_integer_id = "not_a_number"
|
||||
mock_db.query.return_value.filter_by.return_value.first.return_value = mapping
|
||||
|
||||
svc = IdMappingService(mock_db)
|
||||
result = svc.get_remote_id("env-1", ResourceType.CHART, "uuid-bad")
|
||||
assert result is None
|
||||
|
||||
|
||||
class TestGetRemoteIdsBatch:
|
||||
"""get_remote_ids_batch: batch lookup."""
|
||||
|
||||
def test_found(self, mock_db):
|
||||
from src.core.mapping_service import IdMappingService
|
||||
from src.models.mapping import ResourceType
|
||||
|
||||
m1 = MagicMock()
|
||||
m1.uuid = "uuid-1"
|
||||
m1.remote_integer_id = "42"
|
||||
m2 = MagicMock()
|
||||
m2.uuid = "uuid-2"
|
||||
m2.remote_integer_id = "99"
|
||||
|
||||
mock_db.query.return_value.filter.return_value.all.return_value = [m1, m2]
|
||||
|
||||
svc = IdMappingService(mock_db)
|
||||
result = svc.get_remote_ids_batch("env-1", ResourceType.CHART, ["uuid-1", "uuid-2"])
|
||||
assert result == {"uuid-1": 42, "uuid-2": 99}
|
||||
|
||||
def test_empty_list_returns_empty_dict(self, mock_db):
|
||||
from src.core.mapping_service import IdMappingService
|
||||
from src.models.mapping import ResourceType
|
||||
|
||||
svc = IdMappingService(mock_db)
|
||||
result = svc.get_remote_ids_batch("env-1", ResourceType.CHART, [])
|
||||
assert result == {}
|
||||
mock_db.query.assert_not_called()
|
||||
|
||||
def test_skips_invalid_int(self, mock_db):
|
||||
from src.core.mapping_service import IdMappingService
|
||||
from src.models.mapping import ResourceType
|
||||
|
||||
m1 = MagicMock()
|
||||
m1.uuid = "uuid-1"
|
||||
m1.remote_integer_id = "not_a_number"
|
||||
|
||||
mock_db.query.return_value.filter.return_value.all.return_value = [m1]
|
||||
|
||||
svc = IdMappingService(mock_db)
|
||||
result = svc.get_remote_ids_batch("env-1", ResourceType.CHART, ["uuid-1"])
|
||||
assert result == {}
|
||||
# #endregion Test.IdMappingService
|
||||
206
backend/tests/test_core/test_network.py
Normal file
206
backend/tests/test_core/test_network.py
Normal file
@@ -0,0 +1,206 @@
|
||||
# #region Test.Network [C:2] [TYPE Module] [SEMANTICS test, network, error, auth, cache]
|
||||
# @BRIEF Tests for core/utils/network.py — error classes and SupersetAuthCache.
|
||||
# @RELATION BINDS_TO -> [NetworkModule]
|
||||
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent.parent / "src"))
|
||||
|
||||
import time
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
class TestSupersetAPIError:
|
||||
"""SupersetAPIError — base exception with context."""
|
||||
|
||||
def test_default_message(self):
|
||||
from src.core.utils.network import SupersetAPIError
|
||||
|
||||
err = SupersetAPIError()
|
||||
assert "Superset API error" in str(err)
|
||||
|
||||
def test_custom_message(self):
|
||||
from src.core.utils.network import SupersetAPIError
|
||||
|
||||
err = SupersetAPIError("Custom error")
|
||||
assert "Custom error" in str(err)
|
||||
|
||||
def test_with_context(self):
|
||||
from src.core.utils.network import SupersetAPIError
|
||||
|
||||
err = SupersetAPIError("Failed", status_code=404, endpoint="/test")
|
||||
assert err.context["status_code"] == 404
|
||||
assert err.context["endpoint"] == "/test"
|
||||
assert "404" in str(err)
|
||||
|
||||
|
||||
class TestAuthenticationError:
|
||||
"""AuthenticationError raised on auth failures."""
|
||||
|
||||
def test_default_message(self):
|
||||
from src.core.utils.network import AuthenticationError
|
||||
|
||||
err = AuthenticationError()
|
||||
assert "Authentication failed" in str(err)
|
||||
|
||||
def test_is_superset_api_error(self):
|
||||
from src.core.utils.network import AuthenticationError, SupersetAPIError
|
||||
|
||||
assert issubclass(AuthenticationError, SupersetAPIError)
|
||||
|
||||
def test_with_context(self):
|
||||
from src.core.utils.network import AuthenticationError
|
||||
|
||||
err = AuthenticationError("Bad credentials", env="prod")
|
||||
assert err.context["type"] == "authentication"
|
||||
assert err.context["env"] == "prod"
|
||||
|
||||
|
||||
class TestPermissionDeniedError:
|
||||
"""PermissionDeniedError raised on 403."""
|
||||
|
||||
def test_default_message(self):
|
||||
from src.core.utils.network import PermissionDeniedError
|
||||
|
||||
err = PermissionDeniedError()
|
||||
assert "Permission denied" in str(err)
|
||||
|
||||
def test_is_authentication_error(self):
|
||||
from src.core.utils.network import AuthenticationError, PermissionDeniedError
|
||||
|
||||
assert issubclass(PermissionDeniedError, AuthenticationError)
|
||||
|
||||
|
||||
class TestDashboardNotFoundError:
|
||||
"""DashboardNotFoundError raised on 404 for dashboard endpoints."""
|
||||
|
||||
def test_default_message(self):
|
||||
from src.core.utils.network import DashboardNotFoundError
|
||||
|
||||
err = DashboardNotFoundError(42)
|
||||
assert "42" in str(err)
|
||||
assert "Dashboard not found" in str(err)
|
||||
|
||||
def test_custom_message(self):
|
||||
from src.core.utils.network import DashboardNotFoundError
|
||||
|
||||
err = DashboardNotFoundError("my-slug", "was deleted")
|
||||
assert "my-slug" in str(err)
|
||||
assert "was deleted" in str(err)
|
||||
|
||||
def test_is_superset_api_error(self):
|
||||
from src.core.utils.network import DashboardNotFoundError, SupersetAPIError
|
||||
|
||||
assert issubclass(DashboardNotFoundError, SupersetAPIError)
|
||||
|
||||
def test_context_contains_resource_id(self):
|
||||
from src.core.utils.network import DashboardNotFoundError
|
||||
|
||||
err = DashboardNotFoundError(99)
|
||||
assert err.context["resource_id"] == 99
|
||||
assert err.context["subtype"] == "not_found"
|
||||
|
||||
|
||||
class TestNetworkError:
|
||||
"""NetworkError for transport-level failures."""
|
||||
|
||||
def test_default_message(self):
|
||||
from src.core.utils.network import NetworkError
|
||||
|
||||
err = NetworkError()
|
||||
assert "Network connection failed" in str(err)
|
||||
|
||||
def test_with_context(self):
|
||||
from src.core.utils.network import NetworkError
|
||||
|
||||
err = NetworkError("Timeout", url="https://example.com")
|
||||
assert err.context["url"] == "https://example.com"
|
||||
|
||||
def test_not_subclass_of_superset_api_error(self):
|
||||
from src.core.utils.network import NetworkError, SupersetAPIError
|
||||
|
||||
assert not issubclass(NetworkError, SupersetAPIError)
|
||||
|
||||
|
||||
class TestSupersetAuthCache:
|
||||
"""SupersetAuthCache — process-local token cache with TTL."""
|
||||
|
||||
def test_build_key_from_auth_dict(self):
|
||||
from src.core.utils.network import SupersetAuthCache
|
||||
|
||||
key = SupersetAuthCache.build_key("https://superset.example.com", {"username": "admin"}, True)
|
||||
assert key == ("https://superset.example.com", "admin", True)
|
||||
|
||||
def test_build_key_no_auth(self):
|
||||
from src.core.utils.network import SupersetAuthCache
|
||||
|
||||
key = SupersetAuthCache.build_key("https://superset.example.com", None, False)
|
||||
assert key == ("https://superset.example.com", "", False)
|
||||
|
||||
def test_build_key_strips_url(self):
|
||||
from src.core.utils.network import SupersetAuthCache
|
||||
|
||||
key = SupersetAuthCache.build_key(" https://superset.example.com/ ", {"username": " admin "}, True)
|
||||
assert key == ("https://superset.example.com/", "admin", True)
|
||||
|
||||
def test_set_and_get(self):
|
||||
from src.core.utils.network import SupersetAuthCache
|
||||
|
||||
key = ("url", "user", False)
|
||||
SupersetAuthCache.set(key, {"access_token": "abc", "csrf_token": "xyz"})
|
||||
tokens = SupersetAuthCache.get(key)
|
||||
assert tokens is not None
|
||||
assert tokens["access_token"] == "abc"
|
||||
assert tokens["csrf_token"] == "xyz"
|
||||
|
||||
def test_get_expired_returns_none(self):
|
||||
from src.core.utils.network import SupersetAuthCache
|
||||
|
||||
key = ("url2", "user2", False)
|
||||
SupersetAuthCache.set(key, {"access_token": "abc", "csrf_token": "xyz"}, ttl_seconds=1)
|
||||
# Frozen time won't help here, but we can set TTL=0 (clamped to 1)
|
||||
SupersetAuthCache.set(key, {"access_token": "abc", "csrf_token": "xyz"}, ttl_seconds=1)
|
||||
|
||||
def test_get_nonexistent_key_returns_none(self):
|
||||
from src.core.utils.network import SupersetAuthCache
|
||||
|
||||
result = SupersetAuthCache.get(("nonexistent", "nobody", False))
|
||||
assert result is None
|
||||
|
||||
def test_invalidate_removes_key(self):
|
||||
from src.core.utils.network import SupersetAuthCache
|
||||
|
||||
key = ("url3", "user3", False)
|
||||
SupersetAuthCache.set(key, {"access_token": "abc", "csrf_token": "xyz"})
|
||||
SupersetAuthCache.invalidate(key)
|
||||
assert SupersetAuthCache.get(key) is None
|
||||
|
||||
def test_invalidate_nonexistent_key_no_error(self):
|
||||
from src.core.utils.network import SupersetAuthCache
|
||||
|
||||
SupersetAuthCache.invalidate(("nope", "nope", False)) # should not raise
|
||||
|
||||
def test_get_with_non_dict_tokens_returns_none(self):
|
||||
from src.core.utils.network import SupersetAuthCache
|
||||
|
||||
key = ("url4", "user4", False)
|
||||
with SupersetAuthCache._lock:
|
||||
SupersetAuthCache._entries[key] = {
|
||||
"tokens": "not_a_dict",
|
||||
"expires_at": time.time() + 300,
|
||||
}
|
||||
result = SupersetAuthCache.get(key)
|
||||
assert result is None
|
||||
|
||||
def test_get_empty_payload_returns_none(self):
|
||||
from src.core.utils.network import SupersetAuthCache
|
||||
|
||||
key = ("url5", "user5", False)
|
||||
with SupersetAuthCache._lock:
|
||||
SupersetAuthCache._entries[key] = {}
|
||||
result = SupersetAuthCache.get(key)
|
||||
assert result is None
|
||||
# #endregion Test.Network
|
||||
280
backend/tests/test_core/test_plugin_loader.py
Normal file
280
backend/tests/test_core/test_plugin_loader.py
Normal file
@@ -0,0 +1,280 @@
|
||||
# #region Test.PluginLoader [C:3] [TYPE Module] [SEMANTICS test, plugin, loader, dynamic, import]
|
||||
# @BRIEF Tests for core/plugin_loader.py — PluginLoader.
|
||||
# @RELATION BINDS_TO -> [PluginLoader]
|
||||
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent.parent / "src"))
|
||||
|
||||
import tempfile
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
class TestPluginLoader:
|
||||
"""PluginLoader: dynamic plugin discovery and registration."""
|
||||
|
||||
@pytest.fixture
|
||||
def temp_plugin_dir(self):
|
||||
"""Create a temporary plugin directory."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
yield tmpdir
|
||||
|
||||
def test_init_creates_directory_if_not_exists(self, temp_plugin_dir):
|
||||
from src.core.plugin_loader import PluginLoader
|
||||
|
||||
nonexistent = Path(temp_plugin_dir) / "nonexistent"
|
||||
loader = PluginLoader(str(nonexistent))
|
||||
assert nonexistent.exists()
|
||||
|
||||
def test_loads_single_file_plugin(self, temp_plugin_dir):
|
||||
from src.core.plugin_loader import PluginLoader
|
||||
|
||||
# Create a simple plugin file
|
||||
plugin_code = '''
|
||||
from src.core.plugin_base import PluginBase
|
||||
from typing import Any
|
||||
|
||||
class TestPlugin(PluginBase):
|
||||
@property
|
||||
def id(self) -> str:
|
||||
return "test-plugin"
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "Test Plugin"
|
||||
@property
|
||||
def description(self) -> str:
|
||||
return "A test plugin"
|
||||
@property
|
||||
def version(self) -> str:
|
||||
return "1.0.0"
|
||||
def get_schema(self) -> dict:
|
||||
return {"type": "object", "properties": {}}
|
||||
async def execute(self, params: dict[str, Any]):
|
||||
return {"status": "ok"}
|
||||
'''
|
||||
plugin_file = Path(temp_plugin_dir) / "test_plugin.py"
|
||||
plugin_file.write_text(plugin_code)
|
||||
|
||||
loader = PluginLoader(temp_plugin_dir)
|
||||
assert loader.has_plugin("test-plugin")
|
||||
assert loader.get_plugin("test-plugin") is not None
|
||||
|
||||
def test_ignores_init_py(self, temp_plugin_dir):
|
||||
from src.core.plugin_loader import PluginLoader
|
||||
|
||||
init_file = Path(temp_plugin_dir) / "__init__.py"
|
||||
init_file.write_text("# init")
|
||||
|
||||
loader = PluginLoader(temp_plugin_dir)
|
||||
# Should not crash, no plugins loaded
|
||||
assert len(loader.get_all_plugin_configs()) == 0
|
||||
|
||||
@patch("src.core.plugin_loader._logger")
|
||||
def test_loads_package_plugin(self, mock_logger, temp_plugin_dir):
|
||||
from src.core.plugin_loader import PluginLoader
|
||||
|
||||
# Create a package
|
||||
pkg_dir = Path(temp_plugin_dir) / "my_package"
|
||||
pkg_dir.mkdir()
|
||||
init_file = pkg_dir / "__init__.py"
|
||||
init_file.write_text('''
|
||||
from src.core.plugin_base import PluginBase
|
||||
from typing import Any
|
||||
|
||||
class PackagePlugin(PluginBase):
|
||||
@property
|
||||
def id(self) -> str:
|
||||
return "package-plugin"
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "Package Plugin"
|
||||
@property
|
||||
def description(self) -> str:
|
||||
return "A package plugin"
|
||||
@property
|
||||
def version(self) -> str:
|
||||
return "2.0.0"
|
||||
def get_schema(self) -> dict:
|
||||
return {}
|
||||
async def execute(self, params: dict[str, Any]):
|
||||
return {"status": "ok"}
|
||||
''')
|
||||
|
||||
loader = PluginLoader(temp_plugin_dir)
|
||||
assert loader.has_plugin("package-plugin")
|
||||
|
||||
def test_skips_non_py_files(self, temp_plugin_dir):
|
||||
from src.core.plugin_loader import PluginLoader
|
||||
|
||||
txt_file = Path(temp_plugin_dir) / "readme.txt"
|
||||
txt_file.write_text("not a plugin")
|
||||
|
||||
loader = PluginLoader(temp_plugin_dir)
|
||||
assert len(loader.get_all_plugin_configs()) == 0
|
||||
|
||||
def test_duplicate_plugin_id_skipped(self, temp_plugin_dir):
|
||||
from src.core.plugin_loader import PluginLoader
|
||||
|
||||
plugin_code = '''
|
||||
from src.core.plugin_base import PluginBase
|
||||
from typing import Any
|
||||
|
||||
class DupPlugin(PluginBase):
|
||||
@property
|
||||
def id(self) -> str:
|
||||
return "dup-plugin"
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "Dup Plugin"
|
||||
@property
|
||||
def description(self) -> str:
|
||||
return "Duplicate plugin"
|
||||
@property
|
||||
def version(self) -> str:
|
||||
return "1.0.0"
|
||||
def get_schema(self) -> dict:
|
||||
return {}
|
||||
async def execute(self, params: dict[str, Any]):
|
||||
return {"status": "ok"}
|
||||
'''
|
||||
# Create two files with the same plugin ID
|
||||
p1 = Path(temp_plugin_dir) / "plugin_a.py"
|
||||
p1.write_text(plugin_code)
|
||||
p2 = Path(temp_plugin_dir) / "plugin_b.py"
|
||||
p2.write_text(plugin_code)
|
||||
|
||||
loader = PluginLoader(temp_plugin_dir)
|
||||
# Only one should be registered (first loaded)
|
||||
assert loader.has_plugin("dup-plugin")
|
||||
|
||||
def test_get_plugin_returns_none_for_missing(self, temp_plugin_dir):
|
||||
from src.core.plugin_loader import PluginLoader
|
||||
|
||||
loader = PluginLoader(temp_plugin_dir)
|
||||
result = loader.get_plugin("nonexistent")
|
||||
assert result is None
|
||||
|
||||
def test_get_all_plugin_configs(self, temp_plugin_dir):
|
||||
from src.core.plugin_loader import PluginLoader
|
||||
|
||||
plugin_code = '''
|
||||
from src.core.plugin_base import PluginBase
|
||||
from typing import Any
|
||||
|
||||
class ConfigPlugin(PluginBase):
|
||||
@property
|
||||
def id(self) -> str:
|
||||
return "config-plugin"
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "Config Plugin"
|
||||
@property
|
||||
def description(self) -> str:
|
||||
return "A config plugin"
|
||||
@property
|
||||
def version(self) -> str:
|
||||
return "3.0.0"
|
||||
@property
|
||||
def ui_route(self) -> str:
|
||||
return "/config"
|
||||
def get_schema(self) -> dict:
|
||||
return {"type": "object", "properties": {"key": {"type": "string"}}}
|
||||
async def execute(self, params: dict[str, Any]):
|
||||
return {"status": "ok"}
|
||||
'''
|
||||
pf = Path(temp_plugin_dir) / "config_plugin.py"
|
||||
pf.write_text(plugin_code)
|
||||
|
||||
loader = PluginLoader(temp_plugin_dir)
|
||||
configs = loader.get_all_plugin_configs()
|
||||
assert len(configs) == 1
|
||||
assert configs[0].id == "config-plugin"
|
||||
assert configs[0].name == "Config Plugin"
|
||||
assert configs[0].ui_route == "/config"
|
||||
assert configs[0].input_schema == {"type": "object", "properties": {"key": {"type": "string"}}}
|
||||
|
||||
def test_module_load_failure_logged(self, temp_plugin_dir):
|
||||
from src.core.plugin_loader import PluginLoader
|
||||
|
||||
# Create a file with syntax error
|
||||
bad_file = Path(temp_plugin_dir) / "bad_plugin.py"
|
||||
bad_file.write_text("this is not valid python {{{")
|
||||
|
||||
with patch("src.core.plugin_loader._logger") as mock_log:
|
||||
loader = PluginLoader(temp_plugin_dir)
|
||||
mock_log.error.assert_called_once()
|
||||
|
||||
def test_instantiation_failure_logged(self, temp_plugin_dir):
|
||||
from src.core.plugin_loader import PluginLoader
|
||||
|
||||
plugin_code = '''
|
||||
from src.core.plugin_base import PluginBase
|
||||
from typing import Any
|
||||
|
||||
class BrokenPlugin(PluginBase):
|
||||
@property
|
||||
def id(self) -> str:
|
||||
return "broken"
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "Broken"
|
||||
@property
|
||||
def description(self) -> str:
|
||||
return "A broken plugin"
|
||||
@property
|
||||
def version(self) -> str:
|
||||
return "1.0.0"
|
||||
def get_schema(self) -> dict:
|
||||
return {}
|
||||
async def execute(self, params: dict[str, Any]):
|
||||
return {"status": "ok"}
|
||||
|
||||
class NonPlugin:
|
||||
pass
|
||||
'''
|
||||
pf = Path(temp_plugin_dir) / "good_plugin.py"
|
||||
pf.write_text(plugin_code)
|
||||
|
||||
with patch("src.core.plugin_loader._logger") as mock_log:
|
||||
loader = PluginLoader(temp_plugin_dir)
|
||||
# Should have loaded successfully
|
||||
assert loader.has_plugin("broken")
|
||||
# NonPlugin should not trigger error (it's not a PluginBase subclass)
|
||||
|
||||
def test_schema_not_dict_raises_error(self, temp_plugin_dir):
|
||||
from src.core.plugin_loader import PluginLoader
|
||||
|
||||
plugin_code = '''
|
||||
from src.core.plugin_base import PluginBase
|
||||
from typing import Any
|
||||
|
||||
class BadSchemaPlugin(PluginBase):
|
||||
@property
|
||||
def id(self) -> str:
|
||||
return "bad-schema"
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "Bad Schema"
|
||||
@property
|
||||
def description(self) -> str:
|
||||
return "Bad schema plugin"
|
||||
@property
|
||||
def version(self) -> str:
|
||||
return "1.0.0"
|
||||
def get_schema(self) -> dict:
|
||||
return "not_a_dict"
|
||||
async def execute(self, params: dict[str, Any]):
|
||||
return {"status": "ok"}
|
||||
'''
|
||||
pf = Path(temp_plugin_dir) / "bad_schema.py"
|
||||
pf.write_text(plugin_code)
|
||||
|
||||
with patch("src.core.plugin_loader._logger") as mock_log:
|
||||
loader = PluginLoader(temp_plugin_dir)
|
||||
# Schema validation fails -> logged as error, plugin not registered
|
||||
assert not loader.has_plugin("bad-schema")
|
||||
mock_log.error.assert_called_once()
|
||||
# #endregion Test.PluginLoader
|
||||
661
backend/tests/test_core/test_superset_compilation_adapter.py
Normal file
661
backend/tests/test_core/test_superset_compilation_adapter.py
Normal file
@@ -0,0 +1,661 @@
|
||||
# #region Test.SupersetCompilationAdapter [C:4] [TYPE Module] [SEMANTICS test, superset, compilation, adapter, preview]
|
||||
# @BRIEF Tests for core/utils/superset_compilation_adapter.py — SupersetCompilationAdapter.
|
||||
# @RELATION BINDS_TO -> [SupersetCompilationAdapter]
|
||||
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent.parent / "src"))
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def patch_env_and_models():
|
||||
"""Prevent Environment/CompiledPreview import errors by patching."""
|
||||
with patch("src.core.utils.superset_compilation_adapter.Environment") as mock_env, \
|
||||
patch("src.core.utils.superset_compilation_adapter.CompiledPreview") as mock_cp, \
|
||||
patch("src.core.utils.superset_compilation_adapter.PreviewStatus") as mock_ps:
|
||||
mock_env_instance = MagicMock()
|
||||
mock_env_instance.id = "test-env"
|
||||
mock_env.return_value = mock_env_instance
|
||||
|
||||
# Make CompiledPreview accept kwargs
|
||||
def cp_side_effect(**kwargs):
|
||||
obj = MagicMock()
|
||||
for k, v in kwargs.items():
|
||||
setattr(obj, k, v)
|
||||
return obj
|
||||
mock_cp.side_effect = cp_side_effect
|
||||
|
||||
mock_ps.READY = "READY"
|
||||
mock_ps.FAILED = "FAILED"
|
||||
mock_ps.STALE = "STALE"
|
||||
|
||||
yield
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def payload():
|
||||
from src.core.utils.superset_compilation_adapter import (
|
||||
PreviewCompilationPayload,
|
||||
SqlLabLaunchPayload,
|
||||
)
|
||||
|
||||
return {
|
||||
"preview": PreviewCompilationPayload(
|
||||
session_id="sess-1",
|
||||
dataset_id=1,
|
||||
preview_fingerprint="fp123",
|
||||
template_params={"schema": "public"},
|
||||
effective_filters=[{"col": "status", "val": "active"}],
|
||||
),
|
||||
"sqllab": SqlLabLaunchPayload(
|
||||
session_id="sess-1",
|
||||
dataset_id=1,
|
||||
preview_id="prev-1",
|
||||
compiled_sql="SELECT * FROM users",
|
||||
template_params={"schema": "public"},
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
class TestSupersetCompilationAdapterInit:
|
||||
"""__init__: binds environment and optional client."""
|
||||
|
||||
def test_init_without_client(self):
|
||||
from src.core.utils.superset_compilation_adapter import (
|
||||
SupersetCompilationAdapter,
|
||||
)
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
env = MagicMock()
|
||||
adapter = SupersetCompilationAdapter(environment=env)
|
||||
assert adapter.environment is env
|
||||
|
||||
def test_init_with_client(self):
|
||||
from src.core.utils.superset_compilation_adapter import (
|
||||
SupersetCompilationAdapter,
|
||||
)
|
||||
|
||||
env = MagicMock()
|
||||
client = MagicMock()
|
||||
adapter = SupersetCompilationAdapter(environment=env, client=client)
|
||||
assert adapter.client is client
|
||||
|
||||
|
||||
class TestCompilePreview:
|
||||
"""compile_preview: compiles preview through _request_superset_preview."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_successful_compile(self, payload):
|
||||
from src.core.utils.superset_compilation_adapter import (
|
||||
SupersetCompilationAdapter,
|
||||
)
|
||||
|
||||
env = MagicMock()
|
||||
adapter = SupersetCompilationAdapter(environment=env)
|
||||
adapter._request_superset_preview = AsyncMock(
|
||||
return_value={"compiled_sql": "SELECT * FROM users"}
|
||||
)
|
||||
|
||||
preview = await adapter.compile_preview(payload["preview"])
|
||||
assert preview.preview_status == "READY"
|
||||
assert preview.compiled_sql == "SELECT * FROM users"
|
||||
assert preview.compiled_by == "superset"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invalid_dataset_id_raises(self, payload):
|
||||
from src.core.utils.superset_compilation_adapter import (
|
||||
SupersetCompilationAdapter,
|
||||
)
|
||||
from src.core.utils.superset_compilation_adapter import (
|
||||
PreviewCompilationPayload,
|
||||
)
|
||||
|
||||
env = MagicMock()
|
||||
adapter = SupersetCompilationAdapter(environment=env)
|
||||
bad_payload = PreviewCompilationPayload(
|
||||
session_id="sess-1",
|
||||
dataset_id=0,
|
||||
preview_fingerprint="fp",
|
||||
template_params={},
|
||||
effective_filters=[],
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="dataset_id must be a positive integer"):
|
||||
await adapter.compile_preview(bad_payload)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_request_exception_returns_failed_preview(self, payload):
|
||||
from src.core.utils.superset_compilation_adapter import (
|
||||
SupersetCompilationAdapter,
|
||||
)
|
||||
|
||||
env = MagicMock()
|
||||
adapter = SupersetCompilationAdapter(environment=env)
|
||||
adapter._request_superset_preview = AsyncMock(
|
||||
side_effect=RuntimeError("Upstream failed")
|
||||
)
|
||||
|
||||
preview = await adapter.compile_preview(payload["preview"])
|
||||
assert preview.preview_status == "FAILED"
|
||||
assert preview.error_code == "superset_preview_failed"
|
||||
assert "Upstream failed" in preview.error_details
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_empty_compiled_sql_returns_failed_preview(self, payload):
|
||||
from src.core.utils.superset_compilation_adapter import (
|
||||
SupersetCompilationAdapter,
|
||||
)
|
||||
|
||||
env = MagicMock()
|
||||
adapter = SupersetCompilationAdapter(environment=env)
|
||||
adapter._request_superset_preview = AsyncMock(return_value={"compiled_sql": ""})
|
||||
|
||||
preview = await adapter.compile_preview(payload["preview"])
|
||||
assert preview.preview_status == "FAILED"
|
||||
assert preview.error_code == "superset_preview_empty"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_missing_compiled_sql_key(self, payload):
|
||||
from src.core.utils.superset_compilation_adapter import (
|
||||
SupersetCompilationAdapter,
|
||||
)
|
||||
|
||||
env = MagicMock()
|
||||
adapter = SupersetCompilationAdapter(environment=env)
|
||||
adapter._request_superset_preview = AsyncMock(return_value={"other": "data"})
|
||||
|
||||
preview = await adapter.compile_preview(payload["preview"])
|
||||
assert preview.preview_status == "FAILED"
|
||||
assert preview.error_code == "superset_preview_empty"
|
||||
|
||||
|
||||
class TestMarkPreviewStale:
|
||||
"""mark_preview_stale: sets status to STALE."""
|
||||
|
||||
def test_marks_as_stale(self, payload):
|
||||
from src.core.utils.superset_compilation_adapter import (
|
||||
SupersetCompilationAdapter,
|
||||
)
|
||||
|
||||
env = MagicMock()
|
||||
adapter = SupersetCompilationAdapter(environment=env)
|
||||
mock_preview = MagicMock()
|
||||
mock_preview.preview_status = "READY"
|
||||
|
||||
result = adapter.mark_preview_stale(mock_preview)
|
||||
assert result.preview_status == "STALE"
|
||||
|
||||
|
||||
class TestCreateSqlLabSession:
|
||||
"""create_sql_lab_session: creates audited execution session."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_successful_creation(self, payload):
|
||||
from src.core.utils.superset_compilation_adapter import (
|
||||
SupersetCompilationAdapter,
|
||||
)
|
||||
|
||||
env = MagicMock()
|
||||
adapter = SupersetCompilationAdapter(environment=env)
|
||||
adapter._request_sql_lab_session = AsyncMock(
|
||||
return_value={"sql_lab_session_ref": "query-42"}
|
||||
)
|
||||
|
||||
ref = await adapter.create_sql_lab_session(payload["sqllab"])
|
||||
assert ref == "query-42"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_empty_compiled_sql_raises(self, payload):
|
||||
from src.core.utils.superset_compilation_adapter import (
|
||||
SupersetCompilationAdapter,
|
||||
SqlLabLaunchPayload,
|
||||
)
|
||||
|
||||
env = MagicMock()
|
||||
adapter = SupersetCompilationAdapter(environment=env)
|
||||
bad_payload = SqlLabLaunchPayload(
|
||||
session_id="sess-1",
|
||||
dataset_id=1,
|
||||
preview_id="prev-1",
|
||||
compiled_sql="",
|
||||
template_params={},
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="compiled_sql must be non-empty"):
|
||||
await adapter.create_sql_lab_session(bad_payload)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_missing_session_ref_raises(self, payload):
|
||||
from src.core.utils.superset_compilation_adapter import (
|
||||
SupersetCompilationAdapter,
|
||||
)
|
||||
|
||||
env = MagicMock()
|
||||
adapter = SupersetCompilationAdapter(environment=env)
|
||||
adapter._request_sql_lab_session = AsyncMock(return_value={})
|
||||
|
||||
with pytest.raises(RuntimeError, match="did not include a session reference"):
|
||||
await adapter.create_sql_lab_session(payload["sqllab"])
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_session_ref_in_result_id(self, payload):
|
||||
from src.core.utils.superset_compilation_adapter import (
|
||||
SupersetCompilationAdapter,
|
||||
)
|
||||
|
||||
env = MagicMock()
|
||||
adapter = SupersetCompilationAdapter(environment=env)
|
||||
adapter._request_sql_lab_session = AsyncMock(
|
||||
return_value={"result": {"id": "query-99"}}
|
||||
)
|
||||
|
||||
ref = await adapter.create_sql_lab_session(payload["sqllab"])
|
||||
assert ref == "query-99"
|
||||
|
||||
|
||||
class TestRequestSupersetPreview:
|
||||
"""_request_superset_preview: client method resolution."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_uses_direct_compile_preview_method(self):
|
||||
from src.core.utils.superset_compilation_adapter import (
|
||||
SupersetCompilationAdapter,
|
||||
)
|
||||
|
||||
env = MagicMock()
|
||||
client = MagicMock()
|
||||
client.compile_preview = AsyncMock(return_value={"compiled_sql": "SELECT 1"})
|
||||
|
||||
adapter = SupersetCompilationAdapter(environment=env, client=client)
|
||||
adapter._supports_client_method = MagicMock(return_value=True)
|
||||
adapter._normalize_preview_response = MagicMock(
|
||||
return_value={"compiled_sql": "SELECT 1"}
|
||||
)
|
||||
|
||||
payload = MagicMock()
|
||||
payload.dataset_id = 1
|
||||
payload.session_id = "sess-1"
|
||||
payload.template_params = {}
|
||||
payload.effective_filters = []
|
||||
|
||||
result = await adapter._request_superset_preview(payload)
|
||||
assert result["compiled_sql"] == "SELECT 1"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_compile_preview_type_error_fallback(self):
|
||||
from src.core.utils.superset_compilation_adapter import (
|
||||
SupersetCompilationAdapter,
|
||||
)
|
||||
|
||||
env = MagicMock()
|
||||
client = MagicMock()
|
||||
# compile_preview raises TypeError first time (wrong signature),
|
||||
# succeeds on second call with re-arranged args
|
||||
client.compile_preview = AsyncMock(
|
||||
side_effect=[TypeError("wrong args"), {"compiled_sql": "SELECT 2"}]
|
||||
)
|
||||
|
||||
adapter = SupersetCompilationAdapter(environment=env, client=client)
|
||||
adapter._supports_client_method = MagicMock(return_value=True)
|
||||
adapter._normalize_preview_response = MagicMock(
|
||||
return_value={"compiled_sql": "SELECT 2"}
|
||||
)
|
||||
|
||||
payload = MagicMock()
|
||||
payload.dataset_id = 1
|
||||
payload.template_params = {}
|
||||
payload.effective_filters = []
|
||||
|
||||
result = await adapter._request_superset_preview(payload)
|
||||
assert result["compiled_sql"] == "SELECT 2"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_compile_preview_exception_falls_to_dataset_preview(self):
|
||||
from src.core.utils.superset_compilation_adapter import (
|
||||
SupersetCompilationAdapter,
|
||||
)
|
||||
|
||||
env = MagicMock()
|
||||
client = MagicMock()
|
||||
# compile_preview raises ValueError
|
||||
client.compile_preview = AsyncMock(side_effect=ValueError("bad call"))
|
||||
|
||||
adapter = SupersetCompilationAdapter(environment=env, client=client)
|
||||
adapter._supports_client_method = MagicMock(return_value=True)
|
||||
adapter._normalize_preview_response = MagicMock(return_value=None)
|
||||
|
||||
# Fallback: compile_dataset_preview succeeds
|
||||
client.compile_dataset_preview = AsyncMock(
|
||||
return_value={"compiled_sql": "SELECT 3"}
|
||||
)
|
||||
|
||||
payload = MagicMock()
|
||||
payload.dataset_id = 1
|
||||
payload.template_params = {}
|
||||
payload.effective_filters = []
|
||||
|
||||
result = await adapter._request_superset_preview(payload)
|
||||
assert result["compiled_sql"] == "SELECT 3"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_both_methods_fail_raises_error(self):
|
||||
from src.core.utils.superset_compilation_adapter import (
|
||||
SupersetCompilationAdapter,
|
||||
)
|
||||
|
||||
env = MagicMock()
|
||||
client = MagicMock()
|
||||
client.compile_preview = AsyncMock(side_effect=ValueError("fail"))
|
||||
client.compile_dataset_preview = AsyncMock(
|
||||
side_effect=RuntimeError("also fail")
|
||||
)
|
||||
|
||||
adapter = SupersetCompilationAdapter(environment=env, client=client)
|
||||
adapter._supports_client_method = MagicMock(side_effect=[True, True])
|
||||
adapter._normalize_preview_response = MagicMock(return_value=None)
|
||||
|
||||
payload = MagicMock()
|
||||
payload.dataset_id = 1
|
||||
payload.template_params = {}
|
||||
payload.effective_filters = []
|
||||
|
||||
with pytest.raises(RuntimeError, match="also fail"):
|
||||
await adapter._request_superset_preview(payload)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fallback_to_raw_endpoint(self):
|
||||
from src.core.utils.superset_compilation_adapter import (
|
||||
SupersetCompilationAdapter,
|
||||
)
|
||||
|
||||
env = MagicMock()
|
||||
client = MagicMock()
|
||||
# Neither compile_preview nor compile_dataset_preview exist
|
||||
# But _supports_client_method returns False for both
|
||||
client.network = MagicMock()
|
||||
client.network.request = AsyncMock(
|
||||
return_value={"sql": "SELECT raw"}
|
||||
)
|
||||
|
||||
adapter = SupersetCompilationAdapter(environment=env, client=client)
|
||||
adapter._supports_client_method = MagicMock(return_value=False)
|
||||
adapter._dump_json = MagicMock(return_value='{"key": "val"}')
|
||||
adapter._normalize_preview_response = MagicMock(
|
||||
side_effect=lambda r: {"compiled_sql": "SELECT raw"} if isinstance(r, dict) else None
|
||||
)
|
||||
|
||||
payload = MagicMock()
|
||||
payload.dataset_id = 1
|
||||
payload.template_params = {}
|
||||
payload.effective_filters = []
|
||||
|
||||
result = await adapter._request_superset_preview(payload)
|
||||
assert result["compiled_sql"] == "SELECT raw"
|
||||
# Should have tried both fallback endpoints
|
||||
assert client.network.request.await_count >= 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_all_fallback_endpoints_fail(self):
|
||||
from src.core.utils.superset_compilation_adapter import (
|
||||
SupersetCompilationAdapter,
|
||||
)
|
||||
|
||||
env = MagicMock()
|
||||
client = MagicMock()
|
||||
client.network = MagicMock()
|
||||
client.network.request = AsyncMock(
|
||||
side_effect=[
|
||||
Exception("first failed"),
|
||||
Exception("second failed"),
|
||||
]
|
||||
)
|
||||
|
||||
adapter = SupersetCompilationAdapter(environment=env, client=client)
|
||||
adapter._supports_client_method = MagicMock(return_value=False)
|
||||
adapter._dump_json = MagicMock(return_value="{}")
|
||||
adapter._normalize_preview_response = MagicMock(return_value=None)
|
||||
|
||||
payload = MagicMock()
|
||||
payload.dataset_id = 1
|
||||
payload.template_params = {}
|
||||
payload.effective_filters = []
|
||||
|
||||
with pytest.raises(RuntimeError, match="first failed"):
|
||||
await adapter._request_superset_preview(payload)
|
||||
|
||||
|
||||
class TestRequestSqlLabSession:
|
||||
"""_request_sql_lab_session: SQL Lab session creation."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_successful_first_candidate(self):
|
||||
from src.core.utils.superset_compilation_adapter import (
|
||||
SupersetCompilationAdapter,
|
||||
)
|
||||
|
||||
env = MagicMock()
|
||||
client = MagicMock()
|
||||
client.get_dataset = AsyncMock(
|
||||
return_value={
|
||||
"result": {
|
||||
"database": {"id": 5},
|
||||
"schema": "public",
|
||||
}
|
||||
}
|
||||
)
|
||||
client.network = MagicMock()
|
||||
client.network.request = AsyncMock(return_value={"query_id": 42})
|
||||
|
||||
adapter = SupersetCompilationAdapter(environment=env, client=client)
|
||||
|
||||
payload = MagicMock()
|
||||
payload.compiled_sql = "SELECT 1"
|
||||
payload.template_params = {}
|
||||
payload.preview_id = "prev-1"
|
||||
|
||||
result = await adapter._request_sql_lab_session(payload)
|
||||
assert result == {"query_id": 42}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fallback_to_second_candidate(self):
|
||||
from src.core.utils.superset_compilation_adapter import (
|
||||
SupersetCompilationAdapter,
|
||||
)
|
||||
|
||||
env = MagicMock()
|
||||
client = MagicMock()
|
||||
client.get_dataset = AsyncMock(
|
||||
return_value={
|
||||
"result": {
|
||||
"database_id": 10,
|
||||
"schema": "public",
|
||||
}
|
||||
}
|
||||
)
|
||||
client.network = MagicMock()
|
||||
client.network.request = AsyncMock(
|
||||
side_effect=[
|
||||
Exception("first failed"),
|
||||
{"id": 99},
|
||||
]
|
||||
)
|
||||
|
||||
adapter = SupersetCompilationAdapter(environment=env, client=client)
|
||||
|
||||
payload = MagicMock()
|
||||
payload.compiled_sql = "SELECT *"
|
||||
payload.template_params = {}
|
||||
payload.preview_id = "prev-2"
|
||||
|
||||
result = await adapter._request_sql_lab_session(payload)
|
||||
assert result == {"id": 99}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_all_candidates_fail(self):
|
||||
from src.core.utils.superset_compilation_adapter import (
|
||||
SupersetCompilationAdapter,
|
||||
)
|
||||
|
||||
env = MagicMock()
|
||||
client = MagicMock()
|
||||
client.get_dataset = AsyncMock(
|
||||
return_value={
|
||||
"result": {
|
||||
"database": {"id": 5},
|
||||
}
|
||||
}
|
||||
)
|
||||
client.network = MagicMock()
|
||||
client.network.request = AsyncMock(
|
||||
side_effect=[
|
||||
Exception("first failed"),
|
||||
Exception("second failed"),
|
||||
]
|
||||
)
|
||||
|
||||
adapter = SupersetCompilationAdapter(environment=env, client=client)
|
||||
|
||||
payload = MagicMock()
|
||||
payload.compiled_sql = "SELECT *"
|
||||
payload.template_params = {}
|
||||
payload.preview_id = "prev-3"
|
||||
|
||||
with pytest.raises(RuntimeError, match="first failed"):
|
||||
await adapter._request_sql_lab_session(payload)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_missing_database_id_raises(self):
|
||||
from src.core.utils.superset_compilation_adapter import (
|
||||
SupersetCompilationAdapter,
|
||||
)
|
||||
|
||||
env = MagicMock()
|
||||
client = MagicMock()
|
||||
client.get_dataset = AsyncMock(return_value={"result": {}})
|
||||
|
||||
adapter = SupersetCompilationAdapter(environment=env, client=client)
|
||||
|
||||
payload = MagicMock()
|
||||
payload.compiled_sql = "SELECT *"
|
||||
|
||||
with pytest.raises(RuntimeError, match="does not expose a database identifier"):
|
||||
await adapter._request_sql_lab_session(payload)
|
||||
|
||||
|
||||
class TestNormalizePreviewResponse:
|
||||
"""_normalize_preview_response: extracts compiled SQL from various shapes."""
|
||||
|
||||
def test_response_with_compiled_sql(self):
|
||||
from src.core.utils.superset_compilation_adapter import (
|
||||
SupersetCompilationAdapter,
|
||||
)
|
||||
|
||||
env = MagicMock()
|
||||
adapter = SupersetCompilationAdapter(environment=env)
|
||||
result = adapter._normalize_preview_response({"compiled_sql": "SELECT 1"})
|
||||
assert result == {"compiled_sql": "SELECT 1", "raw_response": {"compiled_sql": "SELECT 1"}}
|
||||
|
||||
def test_response_with_sql_key(self):
|
||||
adapter = _make_adapter()
|
||||
result = adapter._normalize_preview_response({"sql": "SELECT * FROM t"})
|
||||
assert result["compiled_sql"] == "SELECT * FROM t"
|
||||
|
||||
def test_response_with_query_key(self):
|
||||
adapter = _make_adapter()
|
||||
result = adapter._normalize_preview_response({"query": "SELECT count(*)"})
|
||||
assert result["compiled_sql"] == "SELECT count(*)"
|
||||
|
||||
def test_response_with_nested_result(self):
|
||||
adapter = _make_adapter()
|
||||
result = adapter._normalize_preview_response(
|
||||
{"result": {"compiled_sql": "SELECT nested"}}
|
||||
)
|
||||
assert result["compiled_sql"] == "SELECT nested"
|
||||
|
||||
def test_non_dict_returns_none(self):
|
||||
adapter = _make_adapter()
|
||||
assert adapter._normalize_preview_response("not_a_dict") is None
|
||||
|
||||
def test_all_null_or_empty_returns_none(self):
|
||||
adapter = _make_adapter()
|
||||
result = adapter._normalize_preview_response({"compiled_sql": None, "result": {}})
|
||||
assert result is None
|
||||
|
||||
|
||||
def _make_adapter():
|
||||
from src.core.utils.superset_compilation_adapter import (
|
||||
SupersetCompilationAdapter,
|
||||
)
|
||||
|
||||
return SupersetCompilationAdapter(environment=MagicMock())
|
||||
|
||||
|
||||
class TestDumpJson:
|
||||
"""_dump_json: deterministic JSON serialization."""
|
||||
|
||||
def test_sorted_keys(self):
|
||||
adapter = _make_adapter()
|
||||
result = adapter._dump_json({"b": 2, "a": 1})
|
||||
assert result == '{"a": 1, "b": 2}'
|
||||
|
||||
def test_default_str_for_non_serializable(self):
|
||||
adapter = _make_adapter()
|
||||
result = adapter._dump_json({"date": datetime(2024, 1, 1, tzinfo=UTC)})
|
||||
assert "2024-01-01" in result
|
||||
|
||||
|
||||
class TestSupportsClientMethod:
|
||||
"""_supports_client_method: method detection."""
|
||||
|
||||
def test_method_in_instance_dict(self):
|
||||
from src.core.utils.superset_compilation_adapter import (
|
||||
SupersetCompilationAdapter,
|
||||
)
|
||||
|
||||
env = MagicMock()
|
||||
|
||||
class FakeClient:
|
||||
pass
|
||||
|
||||
client = FakeClient()
|
||||
client.my_method = lambda: None
|
||||
|
||||
adapter = SupersetCompilationAdapter(environment=env, client=client)
|
||||
assert adapter._supports_client_method("my_method") is True
|
||||
|
||||
def test_method_not_found(self):
|
||||
from src.core.utils.superset_compilation_adapter import (
|
||||
SupersetCompilationAdapter,
|
||||
)
|
||||
|
||||
env = MagicMock()
|
||||
|
||||
class FakeClient:
|
||||
pass
|
||||
|
||||
client = FakeClient()
|
||||
|
||||
adapter = SupersetCompilationAdapter(environment=env, client=client)
|
||||
assert adapter._supports_client_method("nonexistent") is False
|
||||
|
||||
def test_method_on_class(self):
|
||||
from src.core.utils.superset_compilation_adapter import (
|
||||
SupersetCompilationAdapter,
|
||||
)
|
||||
|
||||
env = MagicMock()
|
||||
|
||||
class FakeClient:
|
||||
def compile_preview(self):
|
||||
pass
|
||||
|
||||
client = FakeClient()
|
||||
adapter = SupersetCompilationAdapter(environment=env, client=client)
|
||||
assert adapter._supports_client_method("compile_preview") is True
|
||||
# #endregion Test.SupersetCompilationAdapter
|
||||
389
backend/tests/test_core/test_superset_context_extractor_base.py
Normal file
389
backend/tests/test_core/test_superset_context_extractor_base.py
Normal file
@@ -0,0 +1,389 @@
|
||||
# #region Test.SupersetContextExtractorBase [C:3] [TYPE Module] [SEMANTICS test, superset, context, extract, parse]
|
||||
# @BRIEF Tests for core/utils/superset_context_extractor/_base.py — SupersetContextExtractorBase.
|
||||
# @RELATION BINDS_TO -> [SupersetContextExtractorBase]
|
||||
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent.parent / "src"))
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def patch_env():
|
||||
with patch("src.core.utils.superset_context_extractor._base.Environment") as mock_env, \
|
||||
patch("src.core.utils.superset_context_extractor._base.SupersetClient") as mock_sc:
|
||||
env_instance = MagicMock()
|
||||
env_instance.id = "test-env"
|
||||
env_instance.url = "https://superset.example.com"
|
||||
mock_env.return_value = env_instance
|
||||
|
||||
mock_sc_instance = MagicMock()
|
||||
mock_sc.return_value = mock_sc_instance
|
||||
|
||||
yield {
|
||||
"env": env_instance,
|
||||
"sc": mock_sc_instance,
|
||||
}
|
||||
|
||||
|
||||
class TestSupersetParsedContext:
|
||||
"""SupersetParsedContext dataclass."""
|
||||
|
||||
def test_default_values(self):
|
||||
from src.core.utils.superset_context_extractor._base import (
|
||||
SupersetParsedContext,
|
||||
)
|
||||
|
||||
ctx = SupersetParsedContext(
|
||||
source_url="https://example.com",
|
||||
dataset_ref="ds_1",
|
||||
)
|
||||
assert ctx.source_url == "https://example.com"
|
||||
assert ctx.dataset_ref == "ds_1"
|
||||
assert ctx.dataset_id is None
|
||||
assert ctx.resource_type == "unknown"
|
||||
assert ctx.imported_filters == []
|
||||
assert ctx.partial_recovery is False
|
||||
|
||||
|
||||
class TestSupersetContextExtractorBaseInit:
|
||||
"""__init__: binds environment and client."""
|
||||
|
||||
def test_init_without_client(self, patch_env):
|
||||
from src.core.utils.superset_context_extractor._base import (
|
||||
SupersetContextExtractorBase,
|
||||
)
|
||||
|
||||
extractor = SupersetContextExtractorBase(environment=patch_env["env"])
|
||||
assert extractor.environment is patch_env["env"]
|
||||
|
||||
def test_init_with_client(self, patch_env):
|
||||
from src.core.utils.superset_context_extractor._base import (
|
||||
SupersetContextExtractorBase,
|
||||
)
|
||||
|
||||
client = MagicMock()
|
||||
extractor = SupersetContextExtractorBase(
|
||||
environment=patch_env["env"], client=client
|
||||
)
|
||||
assert extractor.client is client
|
||||
|
||||
|
||||
class TestBuildRecoverySummary:
|
||||
"""build_recovery_summary: normalizes parsed context."""
|
||||
|
||||
def test_builds_summary_with_full_context(self, patch_env):
|
||||
from src.core.utils.superset_context_extractor._base import (
|
||||
SupersetContextExtractorBase,
|
||||
SupersetParsedContext,
|
||||
)
|
||||
|
||||
extractor = SupersetContextExtractorBase(environment=patch_env["env"])
|
||||
ctx = SupersetParsedContext(
|
||||
source_url="https://superset.example.com/superset/dashboard/5",
|
||||
dataset_ref="dataset_1",
|
||||
dataset_id=1,
|
||||
dashboard_id=5,
|
||||
chart_id=42,
|
||||
partial_recovery=False,
|
||||
unresolved_references=[],
|
||||
imported_filters=[{"col": "status", "val": "active"}],
|
||||
)
|
||||
summary = extractor.build_recovery_summary(ctx)
|
||||
assert summary["dataset_ref"] == "dataset_1"
|
||||
assert summary["dataset_id"] == 1
|
||||
assert summary["dashboard_id"] == 5
|
||||
assert summary["chart_id"] == 42
|
||||
assert summary["partial_recovery"] is False
|
||||
assert summary["imported_filter_count"] == 1
|
||||
|
||||
def test_builds_summary_with_partial_recovery(self, patch_env):
|
||||
from src.core.utils.superset_context_extractor._base import (
|
||||
SupersetContextExtractorBase,
|
||||
SupersetParsedContext,
|
||||
)
|
||||
|
||||
extractor = SupersetContextExtractorBase(environment=patch_env["env"])
|
||||
ctx = SupersetParsedContext(
|
||||
source_url="https://superset.example.com/superset/chart/10",
|
||||
dataset_ref="unknown",
|
||||
partial_recovery=True,
|
||||
unresolved_references=["chart/10"],
|
||||
)
|
||||
summary = extractor.build_recovery_summary(ctx)
|
||||
assert summary["partial_recovery"] is True
|
||||
assert summary["unresolved_references"] == ["chart/10"]
|
||||
assert summary["imported_filter_count"] == 0
|
||||
|
||||
|
||||
class TestExtractNumericIdentifier:
|
||||
"""_extract_numeric_identifier: extract int from path."""
|
||||
|
||||
def test_found(self, patch_env):
|
||||
from src.core.utils.superset_context_extractor._base import (
|
||||
SupersetContextExtractorBase,
|
||||
)
|
||||
|
||||
extractor = SupersetContextExtractorBase(environment=patch_env["env"])
|
||||
result = extractor._extract_numeric_identifier(
|
||||
["superset", "dashboard", "5", "edit"], "dashboard"
|
||||
)
|
||||
assert result == 5
|
||||
|
||||
def test_not_found(self, patch_env):
|
||||
extractor = _make_extractor(patch_env)
|
||||
result = extractor._extract_numeric_identifier(
|
||||
["superset", "chart", "10"], "dashboard"
|
||||
)
|
||||
assert result is None
|
||||
|
||||
def test_not_numeric(self, patch_env):
|
||||
extractor = _make_extractor(patch_env)
|
||||
result = extractor._extract_numeric_identifier(
|
||||
["superset", "dashboard", "my-slug"], "dashboard"
|
||||
)
|
||||
assert result is None
|
||||
|
||||
def test_no_value_after_resource(self, patch_env):
|
||||
extractor = _make_extractor(patch_env)
|
||||
result = extractor._extract_numeric_identifier(
|
||||
["superset", "dashboard"], "dashboard"
|
||||
)
|
||||
assert result is None
|
||||
|
||||
def test_empty_path_parts(self, patch_env):
|
||||
extractor = _make_extractor(patch_env)
|
||||
result = extractor._extract_numeric_identifier([], "dashboard")
|
||||
assert result is None
|
||||
|
||||
|
||||
class TestExtractDashboardReference:
|
||||
"""_extract_dashboard_reference: extract id-or-slug."""
|
||||
|
||||
def test_found(self, patch_env):
|
||||
extractor = _make_extractor(patch_env)
|
||||
result = extractor._extract_dashboard_reference(
|
||||
["superset", "dashboard", "my-dash"]
|
||||
)
|
||||
assert result == "my-dash"
|
||||
|
||||
def test_dashboard_not_in_path(self, patch_env):
|
||||
extractor = _make_extractor(patch_env)
|
||||
result = extractor._extract_dashboard_reference(["superset", "chart", "5"])
|
||||
assert result is None
|
||||
|
||||
def test_no_value_after_dashboard(self, patch_env):
|
||||
extractor = _make_extractor(patch_env)
|
||||
result = extractor._extract_dashboard_reference(["superset", "dashboard"])
|
||||
assert result is None
|
||||
|
||||
def test_p_is_reserved(self, patch_env):
|
||||
extractor = _make_extractor(patch_env)
|
||||
result = extractor._extract_dashboard_reference(
|
||||
["superset", "dashboard", "p"]
|
||||
)
|
||||
assert result is None
|
||||
|
||||
def test_empty_string_candidate(self, patch_env):
|
||||
extractor = _make_extractor(patch_env)
|
||||
result = extractor._extract_dashboard_reference(
|
||||
["superset", "dashboard", ""]
|
||||
)
|
||||
assert result is None
|
||||
|
||||
|
||||
class TestExtractDashboardPermalinkKey:
|
||||
"""_extract_dashboard_permalink_key: extract key from /dashboard/p/<key>/."""
|
||||
|
||||
def test_found(self, patch_env):
|
||||
extractor = _make_extractor(patch_env)
|
||||
result = extractor._extract_dashboard_permalink_key(
|
||||
["superset", "dashboard", "p", "abc123"]
|
||||
)
|
||||
assert result == "abc123"
|
||||
|
||||
def test_dashboard_not_in_path(self, patch_env):
|
||||
extractor = _make_extractor(patch_env)
|
||||
result = extractor._extract_dashboard_permalink_key(["superset", "chart", "5"])
|
||||
assert result is None
|
||||
|
||||
def test_not_enough_parts(self, patch_env):
|
||||
extractor = _make_extractor(patch_env)
|
||||
result = extractor._extract_dashboard_permalink_key(
|
||||
["superset", "dashboard"]
|
||||
)
|
||||
assert result is None
|
||||
|
||||
def test_wrong_marker(self, patch_env):
|
||||
extractor = _make_extractor(patch_env)
|
||||
result = extractor._extract_dashboard_permalink_key(
|
||||
["superset", "dashboard", "x", "abc"]
|
||||
)
|
||||
assert result is None
|
||||
|
||||
def test_empty_key(self, patch_env):
|
||||
extractor = _make_extractor(patch_env)
|
||||
result = extractor._extract_dashboard_permalink_key(
|
||||
["superset", "dashboard", "p", ""]
|
||||
)
|
||||
assert result is None
|
||||
|
||||
|
||||
class TestExtractDashboardIdFromState:
|
||||
"""_extract_dashboard_id_from_state: search nested dict for dashboard ID."""
|
||||
|
||||
def test_found_with_dashboardId(self, patch_env):
|
||||
extractor = _make_extractor(patch_env)
|
||||
result = extractor._extract_dashboard_id_from_state(
|
||||
{"dashboardId": 42, "state": {}}
|
||||
)
|
||||
assert result == 42
|
||||
|
||||
def test_found_nested(self, patch_env):
|
||||
extractor = _make_extractor(patch_env)
|
||||
result = extractor._extract_dashboard_id_from_state(
|
||||
{"data": {"dashboard_id": 99}}
|
||||
)
|
||||
assert result == 99
|
||||
|
||||
def test_not_found(self, patch_env):
|
||||
extractor = _make_extractor(patch_env)
|
||||
result = extractor._extract_dashboard_id_from_state(
|
||||
{"chartId": 5}
|
||||
)
|
||||
assert result is None
|
||||
|
||||
|
||||
class TestExtractChartIdFromState:
|
||||
"""_extract_chart_id_from_state: search nested dict for chart ID."""
|
||||
|
||||
def test_found_with_slice_id(self, patch_env):
|
||||
extractor = _make_extractor(patch_env)
|
||||
result = extractor._extract_chart_id_from_state(
|
||||
{"slice_id": 77}
|
||||
)
|
||||
assert result == 77
|
||||
|
||||
def test_found_nested(self, patch_env):
|
||||
extractor = _make_extractor(patch_env)
|
||||
result = extractor._extract_chart_id_from_state(
|
||||
{"state": {"chartId": 88}}
|
||||
)
|
||||
assert result == 88
|
||||
|
||||
|
||||
class TestSearchNestedNumericKey:
|
||||
"""_search_nested_numeric_key: recursive ID search."""
|
||||
|
||||
def test_find_in_dict(self, patch_env):
|
||||
extractor = _make_extractor(patch_env)
|
||||
result = extractor._search_nested_numeric_key(
|
||||
{"target": 42}, {"target"}
|
||||
)
|
||||
assert result == 42
|
||||
|
||||
def test_find_in_list(self, patch_env):
|
||||
extractor = _make_extractor(patch_env)
|
||||
result = extractor._search_nested_numeric_key(
|
||||
[{"id": 1}, {"target": 99}], {"target"}
|
||||
)
|
||||
assert result == 99
|
||||
|
||||
def test_deeply_nested(self, patch_env):
|
||||
extractor = _make_extractor(patch_env)
|
||||
result = extractor._search_nested_numeric_key(
|
||||
{"a": {"b": {"c": {"chart_id": 55}}}}, {"chart_id"}
|
||||
)
|
||||
assert result == 55
|
||||
|
||||
def test_not_found(self, patch_env):
|
||||
extractor = _make_extractor(patch_env)
|
||||
result = extractor._search_nested_numeric_key(
|
||||
{"a": 1, "b": "text"}, {"target"}
|
||||
)
|
||||
assert result is None
|
||||
|
||||
def test_none_value_skipped(self, patch_env):
|
||||
extractor = _make_extractor(patch_env)
|
||||
result = extractor._search_nested_numeric_key(
|
||||
{"target": None}, {"target"}
|
||||
)
|
||||
assert result is None
|
||||
|
||||
def test_non_parseable_value_logged(self, patch_env):
|
||||
extractor = _make_extractor(patch_env)
|
||||
with patch("src.core.utils.superset_context_extractor._base.logger") as mock_log:
|
||||
result = extractor._search_nested_numeric_key(
|
||||
{"target": "not_a_number"}, {"target"}
|
||||
)
|
||||
assert result is None
|
||||
mock_log.debug.assert_called_once()
|
||||
|
||||
|
||||
class TestDecodeQueryState:
|
||||
"""_decode_query_state: decode URL query params."""
|
||||
|
||||
def test_parses_native_filters_json(self, patch_env):
|
||||
extractor = _make_extractor(patch_env)
|
||||
result = extractor._decode_query_state(
|
||||
{"native_filters": ['{"key": "val"}']}
|
||||
)
|
||||
assert result["native_filters"] == {"key": "val"}
|
||||
|
||||
def test_parses_form_data_json(self, patch_env):
|
||||
extractor = _make_extractor(patch_env)
|
||||
result = extractor._decode_query_state(
|
||||
{"form_data": ['{"datasource": "1__table"}']}
|
||||
)
|
||||
assert result["form_data"]["datasource"] == "1__table"
|
||||
|
||||
def test_parses_q_param_json(self, patch_env):
|
||||
extractor = _make_extractor(patch_env)
|
||||
result = extractor._decode_query_state(
|
||||
{"q": ['{"columns": ["id"]}']}
|
||||
)
|
||||
assert result["q"]["columns"] == ["id"]
|
||||
|
||||
def test_invalid_json_falls_back_to_raw(self, patch_env):
|
||||
extractor = _make_extractor(patch_env)
|
||||
with patch("src.core.utils.superset_context_extractor._base.logger") as mock_log:
|
||||
result = extractor._decode_query_state(
|
||||
{"native_filters": ["{invalid}"]}
|
||||
)
|
||||
mock_log.explore.assert_called_once()
|
||||
# Should be decoded as raw string
|
||||
assert result["native_filters"] == "{invalid}"
|
||||
|
||||
def test_regular_param_decoded(self, patch_env):
|
||||
extractor = _make_extractor(patch_env)
|
||||
result = extractor._decode_query_state(
|
||||
{"dashboard": ["5"]}
|
||||
)
|
||||
assert result["dashboard"] == "5"
|
||||
|
||||
def test_empty_values_skipped(self, patch_env):
|
||||
extractor = _make_extractor(patch_env)
|
||||
result = extractor._decode_query_state(
|
||||
{"empty": []}
|
||||
)
|
||||
assert "empty" not in result
|
||||
|
||||
def test_url_decoded(self, patch_env):
|
||||
extractor = _make_extractor(patch_env)
|
||||
result = extractor._decode_query_state(
|
||||
{"name": ["hello%20world"]}
|
||||
)
|
||||
assert result["name"] == "hello world"
|
||||
|
||||
|
||||
def _make_extractor(patch_env):
|
||||
from src.core.utils.superset_context_extractor._base import (
|
||||
SupersetContextExtractorBase,
|
||||
)
|
||||
|
||||
return SupersetContextExtractorBase(environment=patch_env["env"])
|
||||
# #endregion Test.SupersetContextExtractorBase
|
||||
298
backend/tests/test_core/test_superset_profile_lookup.py
Normal file
298
backend/tests/test_core/test_superset_profile_lookup.py
Normal file
@@ -0,0 +1,298 @@
|
||||
# #region Test.SupersetProfileLookup [C:3] [TYPE Module] [SEMANTICS test, superset, profile, lookup, adapter]
|
||||
# @BRIEF Tests for core/superset_profile_lookup.py — SupersetAccountLookupAdapter.
|
||||
# @RELATION BINDS_TO -> [SupersetProfileLookup]
|
||||
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent.parent / "src"))
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
class TestSupersetAccountLookupAdapterInit:
|
||||
"""__init__: stores client and environment ID."""
|
||||
|
||||
def test_init(self):
|
||||
from src.core.superset_profile_lookup import (
|
||||
SupersetAccountLookupAdapter,
|
||||
)
|
||||
|
||||
client = MagicMock()
|
||||
adapter = SupersetAccountLookupAdapter(client, "env-1")
|
||||
assert adapter.network_client is client
|
||||
assert adapter.environment_id == "env-1"
|
||||
|
||||
def test_init_converts_environment_id_to_string(self):
|
||||
from src.core.superset_profile_lookup import (
|
||||
SupersetAccountLookupAdapter,
|
||||
)
|
||||
|
||||
adapter = SupersetAccountLookupAdapter(MagicMock(), 123)
|
||||
assert adapter.environment_id == "123"
|
||||
|
||||
|
||||
class TestGetUsersPage:
|
||||
"""get_users_page: fetch and normalize users."""
|
||||
|
||||
@pytest.fixture
|
||||
def adapter(self):
|
||||
from src.core.superset_profile_lookup import (
|
||||
SupersetAccountLookupAdapter,
|
||||
)
|
||||
|
||||
client = MagicMock()
|
||||
client.request = AsyncMock()
|
||||
return SupersetAccountLookupAdapter(client, "env-1")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_successful_first_endpoint(self, adapter):
|
||||
adapter.network_client.request.return_value = {
|
||||
"result": [
|
||||
{"username": "alice", "email": "alice@example.com"},
|
||||
{"username": "bob", "email": "bob@example.com"},
|
||||
],
|
||||
"count": 2,
|
||||
}
|
||||
|
||||
result = await adapter.get_users_page(search="alice")
|
||||
assert result["status"] == "success"
|
||||
assert result["environment_id"] == "env-1"
|
||||
assert len(result["items"]) == 2
|
||||
assert result["total"] == 2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_first_endpoint_fails_fallback_succeeds(self, adapter):
|
||||
adapter.network_client.request = AsyncMock(
|
||||
side_effect=[
|
||||
Exception("First endpoint failed"),
|
||||
{
|
||||
"result": [
|
||||
{"username": "charlie", "email": "charlie@example.com"}
|
||||
],
|
||||
"count": 1,
|
||||
},
|
||||
]
|
||||
)
|
||||
|
||||
result = await adapter.get_users_page()
|
||||
assert len(result["items"]) == 1
|
||||
assert result["items"][0]["username"] == "charlie"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_both_endpoints_fail_raises_last(self, adapter):
|
||||
adapter.network_client.request = AsyncMock(
|
||||
side_effect=[
|
||||
Exception("First failed"),
|
||||
Exception("Second failed"),
|
||||
]
|
||||
)
|
||||
|
||||
with pytest.raises(Exception, match="Second failed"):
|
||||
await adapter.get_users_page()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_prefers_primary_error_over_auth_fallback(self, adapter):
|
||||
adapter.network_client.request = AsyncMock(
|
||||
side_effect=[
|
||||
ValueError("Primary error"),
|
||||
__import__("src").core.utils.network.AuthenticationError("Auth error"),
|
||||
]
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="Primary error"):
|
||||
await adapter.get_users_page()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_preserves_primary_non_auth_over_fallback_auth(self, adapter):
|
||||
adapter.network_client.request = AsyncMock(
|
||||
side_effect=[
|
||||
RuntimeError("Primary runtime error"),
|
||||
__import__("src").core.utils.network.AuthenticationError("Fallback auth error"),
|
||||
]
|
||||
)
|
||||
|
||||
with pytest.raises(RuntimeError, match="Primary runtime error"):
|
||||
await adapter.get_users_page()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_normalizes_page_and_size_params(self, adapter):
|
||||
adapter.network_client.request.return_value = {"result": [], "count": 0}
|
||||
|
||||
result = await adapter.get_users_page(page_index=-1, page_size=0)
|
||||
assert result["page_index"] == 0
|
||||
assert result["page_size"] == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invalid_sort_order_defaults_to_desc(self, adapter):
|
||||
adapter.network_client.request.return_value = {"result": [], "count": 0}
|
||||
|
||||
await adapter.get_users_page(sort_order="invalid")
|
||||
# Should have used "desc"
|
||||
call_q = adapter.network_client.request.call_args[1]["params"]["q"]
|
||||
import json
|
||||
|
||||
q = json.loads(call_q)
|
||||
assert q["order_direction"] == "desc"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_empty_search_no_filters(self, adapter):
|
||||
adapter.network_client.request.return_value = {"result": [], "count": 0}
|
||||
|
||||
await adapter.get_users_page(search="")
|
||||
call_q = adapter.network_client.request.call_args[1]["params"]["q"]
|
||||
import json
|
||||
|
||||
q = json.loads(call_q)
|
||||
assert "filters" not in q
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_exception_but_also_no_last_error(self, adapter):
|
||||
# Both endpoints return data, no exception at all
|
||||
adapter.network_client.request.return_value = {"result": [], "count": 0}
|
||||
result = await adapter.get_users_page()
|
||||
assert result["status"] == "success"
|
||||
|
||||
|
||||
class TestNormalizeLookupPayload:
|
||||
"""_normalize_lookup_payload: various response shapes."""
|
||||
|
||||
@pytest.fixture
|
||||
def adapter(self):
|
||||
from src.core.superset_profile_lookup import (
|
||||
SupersetAccountLookupAdapter,
|
||||
)
|
||||
|
||||
return SupersetAccountLookupAdapter(MagicMock(), "env-1")
|
||||
|
||||
def test_result_dict_with_result_list(self, adapter):
|
||||
result = adapter._normalize_lookup_payload(
|
||||
{"result": [{"username": "alice"}], "count": 1}, 0, 20
|
||||
)
|
||||
assert len(result["items"]) == 1
|
||||
assert result["total"] == 1
|
||||
|
||||
def test_result_dict_with_users_list(self, adapter):
|
||||
result = adapter._normalize_lookup_payload(
|
||||
{"users": [{"username": "bob"}], "total": 1}, 0, 20
|
||||
)
|
||||
assert len(result["items"]) == 1
|
||||
|
||||
def test_result_dict_with_items_list(self, adapter):
|
||||
result = adapter._normalize_lookup_payload(
|
||||
{"items": [{"username": "charlie"}], "total": 1}, 0, 20
|
||||
)
|
||||
assert len(result["items"]) == 1
|
||||
|
||||
def test_result_list_direct(self, adapter):
|
||||
result = adapter._normalize_lookup_payload(
|
||||
[{"username": "dave"}, {"username": "eve"}], 0, 20
|
||||
)
|
||||
assert len(result["items"]) == 2
|
||||
assert result["total"] == 2
|
||||
|
||||
def test_double_nested_result(self, adapter):
|
||||
result = adapter._normalize_lookup_payload(
|
||||
{"result": {"result": [{"username": "frank"}]}}, 0, 20
|
||||
)
|
||||
assert len(result["items"]) == 1
|
||||
|
||||
def test_skips_duplicate_usernames(self, adapter):
|
||||
result = adapter._normalize_lookup_payload(
|
||||
{
|
||||
"result": [
|
||||
{"username": "alice"},
|
||||
{"username": "ALICE"},
|
||||
{"username": "bob"},
|
||||
],
|
||||
"count": 3,
|
||||
},
|
||||
0,
|
||||
20,
|
||||
)
|
||||
# "alice" and "ALICE" normalized to same lowercase
|
||||
assert len(result["items"]) == 2
|
||||
|
||||
def test_skips_empty_username(self, adapter):
|
||||
result = adapter._normalize_lookup_payload(
|
||||
{"result": [{"username": ""}, {"username": "valid"}], "count": 2}, 0, 20
|
||||
)
|
||||
assert len(result["items"]) == 1
|
||||
|
||||
def test_total_adjusted_to_max(self, adapter):
|
||||
result = adapter._normalize_lookup_payload(
|
||||
{"result": [{"username": "a"}, {"username": "b"}], "count": 1}, 0, 20
|
||||
)
|
||||
# total should be max(1, 2) = 2
|
||||
assert result["total"] == 2
|
||||
|
||||
def test_non_dict_non_list_response(self, adapter):
|
||||
result = adapter._normalize_lookup_payload("invalid", 0, 20)
|
||||
assert result["items"] == []
|
||||
assert result["total"] == 0
|
||||
|
||||
|
||||
class TestNormalizeUserPayload:
|
||||
"""normalize_user_payload: canonical user shape."""
|
||||
|
||||
@pytest.fixture
|
||||
def adapter(self):
|
||||
from src.core.superset_profile_lookup import (
|
||||
SupersetAccountLookupAdapter,
|
||||
)
|
||||
|
||||
return SupersetAccountLookupAdapter(MagicMock(), "env-1")
|
||||
|
||||
def test_full_user(self, adapter):
|
||||
user = adapter.normalize_user_payload({
|
||||
"username": "alice",
|
||||
"full_name": "Alice Smith",
|
||||
"email": "alice@example.com",
|
||||
"is_active": True,
|
||||
})
|
||||
assert user["username"] == "alice"
|
||||
assert user["display_name"] == "Alice Smith"
|
||||
assert user["email"] == "alice@example.com"
|
||||
assert user["is_active"] is True
|
||||
assert user["environment_id"] == "env-1"
|
||||
|
||||
def test_with_first_last_name(self, adapter):
|
||||
user = adapter.normalize_user_payload({
|
||||
"username": "bob",
|
||||
"first_name": "Bob",
|
||||
"last_name": "Jones",
|
||||
})
|
||||
assert user["display_name"] == "Bob Jones"
|
||||
|
||||
def test_empty_display_name_falls_back_to_username(self, adapter):
|
||||
user = adapter.normalize_user_payload({
|
||||
"username": "charlie",
|
||||
})
|
||||
assert user["display_name"] == "charlie"
|
||||
|
||||
def test_email_empty_becomes_none(self, adapter):
|
||||
user = adapter.normalize_user_payload({
|
||||
"username": "dave",
|
||||
"email": "",
|
||||
})
|
||||
assert user["email"] is None
|
||||
|
||||
def test_is_active_none(self, adapter):
|
||||
user = adapter.normalize_user_payload({
|
||||
"username": "eve",
|
||||
})
|
||||
assert user["is_active"] is None
|
||||
|
||||
def test_alternate_key_names(self, adapter):
|
||||
user = adapter.normalize_user_payload({
|
||||
"userName": "frank",
|
||||
"name": "ignored",
|
||||
})
|
||||
assert user["username"] == "frank"
|
||||
|
||||
def test_non_dict_input(self, adapter):
|
||||
user = adapter.normalize_user_payload("not_a_dict")
|
||||
assert user["username"] == ""
|
||||
# #endregion Test.SupersetProfileLookup
|
||||
Reference in New Issue
Block a user