969 lines
42 KiB
Python
969 lines
42 KiB
Python
# #region Test.Assistant.EdgeCases [C:2] [TYPE Module] [SEMANTICS test,assistant,routes,dispatch,resolvers,tools,edge]
|
|
# @BRIEF Edge-case coverage for _routes.py, _dispatch.py, _resolvers.py, and tool files.
|
|
# Covers confirmation/cancel routes, dry-run summary, resolver fallbacks,
|
|
# and tool handler error paths.
|
|
# @RELATION BINDS_TO -> [AssistantRoutes]
|
|
# @RELATION BINDS_TO -> [AssistantDispatch]
|
|
# @RELATION BINDS_TO -> [AssistantResolvers]
|
|
# @RELATION BINDS_TO -> [AssistantToolBackup]
|
|
# @RELATION BINDS_TO -> [AssistantToolMigration]
|
|
# @RELATION BINDS_TO -> [AssistantToolSearchDashboards]
|
|
# @RELATION BINDS_TO -> [AssistantToolTaskStatus]
|
|
# @RELATION BINDS_TO -> [AssistantToolRegistry]
|
|
|
|
from pathlib import Path
|
|
import sys
|
|
|
|
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
|
|
|
|
from datetime import UTC, datetime, timedelta
|
|
from unittest.mock import AsyncMock, MagicMock, patch, PropertyMock
|
|
import pytest
|
|
from fastapi import HTTPException
|
|
|
|
|
|
# ── Shared helpers ─────────────────────────────────────────────────
|
|
|
|
|
|
def _make_env(id_: str, name_: str, stage="DEV", is_production=False, is_default=False):
|
|
env = MagicMock()
|
|
env.id = id_
|
|
env.name = name_
|
|
env.stage = stage
|
|
env.is_production = is_production
|
|
env.is_default = is_default
|
|
return env
|
|
|
|
|
|
# ── _routes.py: confirm_operation and cancel_operation ────────────
|
|
|
|
|
|
class TestAssistantRoutesConfirmation:
|
|
"""confirm_operation and cancel_operation endpoints."""
|
|
|
|
@pytest.fixture
|
|
def current_user(self):
|
|
from src.schemas.auth import User
|
|
return User(id="user-1", username="test", email="test@x.com", is_active=True,
|
|
auth_source="internal", created_at=datetime.now(UTC),
|
|
last_login=datetime.now(UTC), roles=[])
|
|
|
|
# ── confirm_operation ──
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_confirm_found_in_memory(self, current_user):
|
|
"""Confirm from in-memory CONFIRMATIONS store."""
|
|
from src.api.routes.assistant._routes import confirm_operation
|
|
from src.api.routes.assistant._schemas import CONFIRMATIONS, ConfirmationRecord
|
|
|
|
CONFIRMATIONS.clear()
|
|
cid = "cf-test-1"
|
|
CONFIRMATIONS[cid] = ConfirmationRecord(
|
|
id=cid,
|
|
user_id="user-1",
|
|
conversation_id="conv-1",
|
|
intent={"operation": "show_capabilities", "entities": {}},
|
|
dispatch={"intent": {}},
|
|
expires_at=datetime.now(UTC) + timedelta(minutes=5),
|
|
created_at=datetime.now(UTC),
|
|
)
|
|
|
|
with patch("src.api.routes.assistant._routes.dispatch",
|
|
new=AsyncMock(return_value=("done", "task-1", []))) as mock_dispatch, \
|
|
patch("src.api.routes.assistant._routes._append_history"), \
|
|
patch("src.api.routes.assistant._routes._persist_message"), \
|
|
patch("src.api.routes.assistant._routes._audit"), \
|
|
patch("src.api.routes.assistant._routes._persist_audit"), \
|
|
patch("src.api.routes.assistant._routes._update_confirmation_state"):
|
|
|
|
resp = await confirm_operation(
|
|
confirmation_id=cid,
|
|
current_user=current_user,
|
|
task_manager=MagicMock(),
|
|
config_manager=MagicMock(),
|
|
db=MagicMock(),
|
|
)
|
|
assert resp.state in ("success", "started")
|
|
mock_dispatch.assert_awaited_once()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_confirm_not_found_404(self, current_user):
|
|
"""Missing confirmation_id returns 404."""
|
|
from src.api.routes.assistant._routes import confirm_operation
|
|
from src.api.routes.assistant._schemas import CONFIRMATIONS
|
|
|
|
CONFIRMATIONS.clear()
|
|
|
|
with patch("src.api.routes.assistant._routes._load_confirmation_from_db", return_value=None):
|
|
with pytest.raises(HTTPException) as exc:
|
|
await confirm_operation(
|
|
confirmation_id="nonexistent",
|
|
current_user=current_user,
|
|
task_manager=MagicMock(),
|
|
config_manager=MagicMock(),
|
|
db=MagicMock(),
|
|
)
|
|
assert exc.value.status_code == 404
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_confirm_wrong_user_403(self, current_user):
|
|
"""Confirmation belonging to another user returns 403."""
|
|
from src.api.routes.assistant._routes import confirm_operation
|
|
from src.api.routes.assistant._schemas import CONFIRMATIONS, ConfirmationRecord
|
|
|
|
CONFIRMATIONS.clear()
|
|
cid = "cf-wrong-user"
|
|
CONFIRMATIONS[cid] = ConfirmationRecord(
|
|
id=cid, user_id="user-2", conversation_id="conv-1",
|
|
intent={}, dispatch={},
|
|
expires_at=datetime.now(UTC) + timedelta(minutes=5),
|
|
created_at=datetime.now(UTC),
|
|
)
|
|
|
|
with pytest.raises(HTTPException) as exc:
|
|
await confirm_operation(
|
|
confirmation_id=cid, current_user=current_user,
|
|
task_manager=MagicMock(), config_manager=MagicMock(), db=MagicMock(),
|
|
)
|
|
assert exc.value.status_code == 403
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_confirm_already_consumed_400(self, current_user):
|
|
"""Already consumed/expired confirmation returns 400."""
|
|
from src.api.routes.assistant._routes import confirm_operation
|
|
from src.api.routes.assistant._schemas import CONFIRMATIONS, ConfirmationRecord
|
|
|
|
CONFIRMATIONS.clear()
|
|
cid = "cf-consumed"
|
|
CONFIRMATIONS[cid] = ConfirmationRecord(
|
|
id=cid, user_id="user-1", conversation_id="conv-1",
|
|
intent={}, dispatch={}, state="consumed",
|
|
expires_at=datetime.now(UTC) + timedelta(minutes=5),
|
|
created_at=datetime.now(UTC),
|
|
)
|
|
|
|
with pytest.raises(HTTPException) as exc:
|
|
await confirm_operation(
|
|
confirmation_id=cid, current_user=current_user,
|
|
task_manager=MagicMock(), config_manager=MagicMock(), db=MagicMock(),
|
|
)
|
|
assert exc.value.status_code == 400
|
|
assert "already" in str(exc.value.detail)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_confirm_expired_400(self, current_user):
|
|
"""Expired confirmation returns 400."""
|
|
from src.api.routes.assistant._routes import confirm_operation
|
|
from src.api.routes.assistant._schemas import CONFIRMATIONS, ConfirmationRecord
|
|
|
|
CONFIRMATIONS.clear()
|
|
cid = "cf-expired"
|
|
CONFIRMATIONS[cid] = ConfirmationRecord(
|
|
id=cid, user_id="user-1", conversation_id="conv-1",
|
|
intent={}, dispatch={}, state="pending",
|
|
expires_at=datetime.now(UTC) - timedelta(minutes=5),
|
|
created_at=datetime.now(UTC) - timedelta(minutes=10),
|
|
)
|
|
|
|
with patch("src.api.routes.assistant._routes._update_confirmation_state"):
|
|
with pytest.raises(HTTPException) as exc:
|
|
await confirm_operation(
|
|
confirmation_id=cid, current_user=current_user,
|
|
task_manager=MagicMock(), config_manager=MagicMock(), db=MagicMock(),
|
|
)
|
|
assert exc.value.status_code == 400
|
|
assert "expired" in str(exc.value.detail)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_confirm_loaded_from_db(self, current_user):
|
|
"""Confirmation loaded from DB when not in memory."""
|
|
from src.api.routes.assistant._routes import confirm_operation
|
|
from src.api.routes.assistant._schemas import CONFIRMATIONS, ConfirmationRecord
|
|
|
|
CONFIRMATIONS.clear()
|
|
cid = "cf-db-loaded"
|
|
|
|
db_record = ConfirmationRecord(
|
|
id=cid, user_id="user-1", conversation_id="conv-1",
|
|
intent={"operation": "show_capabilities", "entities": {}}, dispatch={},
|
|
expires_at=datetime.now(UTC) + timedelta(minutes=5),
|
|
created_at=datetime.now(UTC),
|
|
)
|
|
|
|
with patch("src.api.routes.assistant._routes._load_confirmation_from_db", return_value=db_record), \
|
|
patch("src.api.routes.assistant._routes.dispatch",
|
|
new=AsyncMock(return_value=("done", None, []))), \
|
|
patch("src.api.routes.assistant._routes._append_history"), \
|
|
patch("src.api.routes.assistant._routes._persist_message"), \
|
|
patch("src.api.routes.assistant._routes._audit"), \
|
|
patch("src.api.routes.assistant._routes._persist_audit"), \
|
|
patch("src.api.routes.assistant._routes._update_confirmation_state"):
|
|
|
|
resp = await confirm_operation(
|
|
confirmation_id=cid, current_user=current_user,
|
|
task_manager=MagicMock(), config_manager=MagicMock(), db=MagicMock(),
|
|
)
|
|
assert resp.state in ("success", "started")
|
|
|
|
# ── cancel_operation ──
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_cancel_success(self, current_user):
|
|
"""Cancel pending confirmation."""
|
|
from src.api.routes.assistant._routes import cancel_operation
|
|
from src.api.routes.assistant._schemas import CONFIRMATIONS, ConfirmationRecord
|
|
|
|
CONFIRMATIONS.clear()
|
|
cid = "cf-cancel-1"
|
|
CONFIRMATIONS[cid] = ConfirmationRecord(
|
|
id=cid, user_id="user-1", conversation_id="conv-1",
|
|
intent={"operation": "deploy_dashboard"}, dispatch={},
|
|
expires_at=datetime.now(UTC) + timedelta(minutes=5),
|
|
created_at=datetime.now(UTC),
|
|
)
|
|
|
|
with patch("src.api.routes.assistant._routes._update_confirmation_state"), \
|
|
patch("src.api.routes.assistant._routes._append_history"), \
|
|
patch("src.api.routes.assistant._routes._persist_message"), \
|
|
patch("src.api.routes.assistant._routes._audit"), \
|
|
patch("src.api.routes.assistant._routes._persist_audit"):
|
|
|
|
resp = await cancel_operation(
|
|
confirmation_id=cid, current_user=current_user, db=MagicMock(),
|
|
)
|
|
assert resp.state == "success"
|
|
assert "отменена" in resp.text
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_cancel_not_found_404(self, current_user):
|
|
"""Cancel nonexistent confirmation."""
|
|
from src.api.routes.assistant._routes import cancel_operation
|
|
from src.api.routes.assistant._schemas import CONFIRMATIONS
|
|
|
|
CONFIRMATIONS.clear()
|
|
|
|
with patch("src.api.routes.assistant._routes._load_confirmation_from_db", return_value=None):
|
|
with pytest.raises(HTTPException) as exc:
|
|
await cancel_operation(
|
|
confirmation_id="nonexistent", current_user=current_user, db=MagicMock(),
|
|
)
|
|
assert exc.value.status_code == 404
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_cancel_wrong_user_403(self, current_user):
|
|
"""Cancel belonging to another user."""
|
|
from src.api.routes.assistant._routes import cancel_operation
|
|
from src.api.routes.assistant._schemas import CONFIRMATIONS, ConfirmationRecord
|
|
|
|
CONFIRMATIONS.clear()
|
|
cid = "cf-cancel-wrong"
|
|
CONFIRMATIONS[cid] = ConfirmationRecord(
|
|
id=cid, user_id="user-2", conversation_id="conv-1",
|
|
intent={}, dispatch={},
|
|
expires_at=datetime.now(UTC) + timedelta(minutes=5),
|
|
created_at=datetime.now(UTC),
|
|
)
|
|
|
|
with pytest.raises(HTTPException) as exc:
|
|
await cancel_operation(
|
|
confirmation_id=cid, current_user=current_user, db=MagicMock(),
|
|
)
|
|
assert exc.value.status_code == 403
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_cancel_already_consumed_400(self, current_user):
|
|
"""Cancel already consumed confirmation."""
|
|
from src.api.routes.assistant._routes import cancel_operation
|
|
from src.api.routes.assistant._schemas import CONFIRMATIONS, ConfirmationRecord
|
|
|
|
CONFIRMATIONS.clear()
|
|
cid = "cf-cancel-consumed"
|
|
CONFIRMATIONS[cid] = ConfirmationRecord(
|
|
id=cid, user_id="user-1", conversation_id="conv-1",
|
|
intent={}, dispatch={}, state="consumed",
|
|
expires_at=datetime.now(UTC) + timedelta(minutes=5),
|
|
created_at=datetime.now(UTC),
|
|
)
|
|
|
|
with pytest.raises(HTTPException) as exc:
|
|
await cancel_operation(
|
|
confirmation_id=cid, current_user=current_user, db=MagicMock(),
|
|
)
|
|
assert exc.value.status_code == 400
|
|
|
|
# ── _routes.py line 150: failed state (non-clarification HTTPException) ──
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_message_failed_state(self):
|
|
"""Non-403, non-clarification HTTPException results in 'failed' state."""
|
|
from src.api.routes.assistant._routes import send_message
|
|
from src.api.routes.assistant._schemas import AssistantMessageRequest, CONFIRMATIONS
|
|
from src.api.routes.assistant._tool_registry import _tools
|
|
|
|
CONFIRMATIONS.clear()
|
|
_tools.clear()
|
|
|
|
req = AssistantMessageRequest(message="do something")
|
|
|
|
with patch("src.api.routes.assistant._routes._resolve_or_create_conversation", return_value="conv-1"), \
|
|
patch("src.api.routes.assistant._routes._append_history"), \
|
|
patch("src.api.routes.assistant._routes._persist_message"), \
|
|
patch("src.api.routes.assistant._routes._build_tool_catalog", return_value=[]), \
|
|
patch("src.api.routes.assistant._routes._plan_intent_with_llm",
|
|
return_value={"domain": "d", "operation": "op", "entities": {}, "confidence": 0.9, "risk_level": "guarded"}), \
|
|
patch("src.api.routes.assistant._routes._authorize_intent"), \
|
|
patch("src.api.routes.assistant._routes.get_safe_ops", return_value={"op"}), \
|
|
patch("src.api.routes.assistant._routes.dispatch",
|
|
side_effect=HTTPException(status_code=500, detail="Internal error")), \
|
|
patch("src.api.routes.assistant._routes._audit"), \
|
|
patch("src.api.routes.assistant._routes._persist_audit"), \
|
|
patch("src.api.routes.assistant._routes._load_dataset_review_context", return_value=None):
|
|
|
|
current_user = MagicMock(id="user-1")
|
|
resp = await send_message(
|
|
req, current_user, MagicMock(), MagicMock(), MagicMock()
|
|
)
|
|
assert resp.state == "failed"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_message_dataset_review_overrides_intent(self):
|
|
"""dataset_review_intent overrides intent when context is active."""
|
|
from src.api.routes.assistant._routes import send_message
|
|
from src.api.routes.assistant._schemas import AssistantMessageRequest, CONFIRMATIONS
|
|
from src.api.routes.assistant._tool_registry import _tools
|
|
|
|
CONFIRMATIONS.clear()
|
|
_tools.clear()
|
|
|
|
req = AssistantMessageRequest(message="show filters", dataset_review_session_id="sess-1")
|
|
|
|
with patch("src.api.routes.assistant._routes._resolve_or_create_conversation", return_value="conv-1"), \
|
|
patch("src.api.routes.assistant._routes._append_history"), \
|
|
patch("src.api.routes.assistant._routes._persist_message"), \
|
|
patch("src.api.routes.assistant._routes._build_tool_catalog", return_value=[]), \
|
|
patch("src.api.routes.assistant._routes._plan_intent_with_llm",
|
|
return_value={"domain": "original", "operation": "orig_op", "entities": {}, "confidence": 0.9}), \
|
|
patch("src.api.routes.assistant._routes._parse_command",
|
|
return_value={"domain": "unknown", "operation": "clarify", "entities": {}, "confidence": 0.3}), \
|
|
patch("src.api.routes.assistant._routes._plan_dataset_review_intent",
|
|
return_value={"domain": "dataset_review", "operation": "dataset_review_answer_context",
|
|
"entities": {"summary": "test"}, "confidence": 0.8, "risk_level": "safe",
|
|
"requires_confirmation": False}), \
|
|
patch("src.api.routes.assistant._routes._authorize_intent"), \
|
|
patch("src.api.routes.assistant._routes.get_safe_ops", return_value={"dataset_review_answer_context"}), \
|
|
patch("src.api.routes.assistant._routes.dispatch",
|
|
new=AsyncMock(return_value=("context answer", None, []))), \
|
|
patch("src.api.routes.assistant._routes._audit"), \
|
|
patch("src.api.routes.assistant._routes._persist_audit"), \
|
|
patch("src.api.routes.assistant._routes._load_dataset_review_context",
|
|
return_value={"session_id": "sess-1"}):
|
|
|
|
current_user = MagicMock(id="user-1")
|
|
resp = await send_message(
|
|
req, current_user, MagicMock(), MagicMock(), MagicMock()
|
|
)
|
|
assert resp.state in ("success", "started")
|
|
|
|
|
|
# ── _dispatch.py: dry-run code path ──────────────────────────────
|
|
|
|
|
|
class TestDispatchDryRunSummary:
|
|
"""_async_confirmation_summary with dry_run enabled."""
|
|
|
|
@pytest.fixture
|
|
def config_manager(self):
|
|
cm = MagicMock()
|
|
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
|
|
async def test_migration_with_dry_run_success(self, config_manager):
|
|
"""Execute migration with dry_run=True triggers dry-run report."""
|
|
from src.api.routes.assistant._dispatch import _async_confirmation_summary
|
|
|
|
db = MagicMock()
|
|
intent = {
|
|
"operation": "execute_migration",
|
|
"entities": {
|
|
"dashboard_id": "42",
|
|
"source_env": "dev",
|
|
"target_env": "staging",
|
|
"replace_db_config": True,
|
|
"fix_cross_filters": True,
|
|
"dry_run": True,
|
|
},
|
|
}
|
|
|
|
with patch("src.api.routes.assistant._dispatch._resolve_dashboard_id_entity",
|
|
new=AsyncMock(return_value=42)), \
|
|
patch("src.api.routes.assistant._dispatch._resolve_env_id") as mock_resolve_env, \
|
|
patch("src.core.migration.dry_run_orchestrator.MigrationDryRunService") as mock_dry_cls, \
|
|
patch("src.core.async_superset_client.AsyncSupersetClient"):
|
|
|
|
mock_resolve_env.side_effect = lambda t, cm: {"dev": "env-dev", "staging": "env-staging", "prod": "env-prod"}.get(t)
|
|
|
|
mock_dry = MagicMock()
|
|
mock_dry_cls.return_value = mock_dry
|
|
mock_dry.run.return_value = {
|
|
"summary": {
|
|
"dashboards": {"create": 1, "update": 2, "delete": 0},
|
|
"charts": {"create": 3, "update": 4, "delete": 1},
|
|
"datasets": {"create": 0, "update": 0, "delete": 0},
|
|
}
|
|
}
|
|
|
|
text = await _async_confirmation_summary(intent, config_manager, db)
|
|
assert "dry-run" in text.lower() or "dry" in text.lower()
|
|
assert "создано" in text
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_migration_dry_run_missing_env_mismatch(self, config_manager):
|
|
"""Dry-run report gracefully handles environment mismatch."""
|
|
from src.api.routes.assistant._dispatch import _async_confirmation_summary
|
|
|
|
db = MagicMock()
|
|
intent = {
|
|
"operation": "execute_migration",
|
|
"entities": {
|
|
"dashboard_id": "42",
|
|
"source_env": "prod",
|
|
"target_env": "prod",
|
|
"dry_run": True,
|
|
},
|
|
}
|
|
|
|
with patch("src.api.routes.assistant._dispatch._resolve_dashboard_id_entity",
|
|
new=AsyncMock(return_value=None)):
|
|
text = await _async_confirmation_summary(intent, config_manager, db)
|
|
assert "Подтвердите" in text or "Выполнить" in text
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_migration_dry_run_exception_handling(self, config_manager):
|
|
"""Dry-run exception is caught gracefully."""
|
|
from src.api.routes.assistant._dispatch import _async_confirmation_summary
|
|
|
|
db = MagicMock()
|
|
intent = {
|
|
"operation": "execute_migration",
|
|
"entities": {
|
|
"dashboard_id": "42",
|
|
"source_env": "dev",
|
|
"target_env": "staging",
|
|
"dry_run": True,
|
|
},
|
|
}
|
|
|
|
with patch("src.api.routes.assistant._dispatch._resolve_dashboard_id_entity",
|
|
new=AsyncMock(side_effect=Exception("Unexpected error"))):
|
|
text = await _async_confirmation_summary(intent, config_manager, db)
|
|
assert "Подтвердите" in text or "Выполнить" in text
|
|
|
|
|
|
# ── _resolvers.py: edge cases ────────────────────────────────────
|
|
|
|
|
|
class TestAssistantResolversExtended:
|
|
"""Edge cases for resolver functions."""
|
|
|
|
def test_is_production_env_no_env_found(self):
|
|
"""_is_production_env returns False when token doesn't match any env."""
|
|
from src.api.routes.assistant._resolvers import _is_production_env
|
|
|
|
config_manager = MagicMock()
|
|
env1 = _make_env("env-dev", "Development")
|
|
config_manager.get_environments = MagicMock(return_value=[env1])
|
|
|
|
# Token matches env id but env name doesn't contain "prod"
|
|
assert _is_production_env("env-dev", config_manager) is False
|
|
|
|
def test_resolve_provider_id_with_bound_provider(self):
|
|
"""_resolve_provider_id uses resolve_bound_provider_id when config_manager and task_key given."""
|
|
from src.api.routes.assistant._resolvers import _resolve_provider_id
|
|
|
|
db = MagicMock()
|
|
config_manager = MagicMock()
|
|
p1 = MagicMock(id="p1", name="OpenAI", is_active=True)
|
|
|
|
with patch("src.api.routes.assistant._resolvers.LLMProviderService") as mock_svc_cls:
|
|
svc = MagicMock()
|
|
mock_svc_cls.return_value = svc
|
|
svc.get_all_providers.return_value = [p1]
|
|
|
|
with patch("src.api.routes.assistant._resolvers.resolve_bound_provider_id",
|
|
return_value="p1"):
|
|
result = _resolve_provider_id(None, db, config_manager, "validation")
|
|
assert result == "p1"
|
|
|
|
def test_resolve_provider_id_bound_provider_not_found(self):
|
|
"""_resolve_provider_id falls back to active when bound provider not found."""
|
|
from src.api.routes.assistant._resolvers import _resolve_provider_id
|
|
|
|
db = MagicMock()
|
|
config_manager = MagicMock()
|
|
p1 = MagicMock(id="p1", name="OpenAI", is_active=True)
|
|
|
|
with patch("src.api.routes.assistant._resolvers.LLMProviderService") as mock_svc_cls:
|
|
svc = MagicMock()
|
|
mock_svc_cls.return_value = svc
|
|
svc.get_all_providers.return_value = [p1]
|
|
|
|
with patch("src.api.routes.assistant._resolvers.resolve_bound_provider_id",
|
|
return_value="nonexistent"):
|
|
result = _resolve_provider_id(None, db, config_manager, "validation")
|
|
assert result == "p1"
|
|
|
|
def test_resolve_provider_id_bound_provider_exception(self):
|
|
"""_resolve_provider_id handles exception from resolve_bound_provider_id."""
|
|
from src.api.routes.assistant._resolvers import _resolve_provider_id
|
|
|
|
db = MagicMock()
|
|
config_manager = MagicMock()
|
|
p1 = MagicMock(id="p1", name="OpenAI", is_active=True)
|
|
|
|
with patch("src.api.routes.assistant._resolvers.LLMProviderService") as mock_svc_cls:
|
|
svc = MagicMock()
|
|
mock_svc_cls.return_value = svc
|
|
svc.get_all_providers.return_value = [p1]
|
|
|
|
with patch("src.api.routes.assistant._resolvers.resolve_bound_provider_id",
|
|
side_effect=Exception("Config error")):
|
|
result = _resolve_provider_id(None, db, config_manager, "validation")
|
|
assert result == "p1"
|
|
|
|
def test_get_default_environment_id_error_in_get_config(self):
|
|
"""_get_default_environment_id handles get_config exception."""
|
|
from src.api.routes.assistant._resolvers import _get_default_environment_id
|
|
|
|
config_manager = MagicMock()
|
|
env1 = _make_env("env-dev", "Dev", is_default=True)
|
|
config_manager.get_environments = MagicMock(return_value=[env1])
|
|
config_manager.get_config.side_effect = Exception("Config error")
|
|
|
|
result = _get_default_environment_id(config_manager)
|
|
assert result == "env-dev" # falls back to explicit default
|
|
|
|
def test_get_default_environment_id_no_explicit_default(self):
|
|
"""_get_default_environment_id falls back to first env."""
|
|
from src.api.routes.assistant._resolvers import _get_default_environment_id
|
|
|
|
config_manager = MagicMock()
|
|
env1 = _make_env("env-dev", "Dev", is_default=False)
|
|
env2 = _make_env("env-prod", "Prod", is_default=False)
|
|
config_manager.get_environments = MagicMock(return_value=[env1, env2])
|
|
|
|
result = _get_default_environment_id(config_manager)
|
|
assert result == "env-dev" # first in list
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_resolve_dashboard_id_by_ref_with_exception(self):
|
|
"""_resolve_dashboard_id_by_ref handles SupersetClient exception."""
|
|
from src.api.routes.assistant._resolvers import _resolve_dashboard_id_by_ref
|
|
|
|
config_manager = MagicMock()
|
|
env = _make_env("env-dev", "Development")
|
|
config_manager.get_environments = MagicMock(return_value=[env])
|
|
|
|
with patch("src.api.routes.assistant._resolvers.SupersetClient") as mock_client_cls:
|
|
client = MagicMock()
|
|
mock_client_cls.return_value = client
|
|
client.get_dashboards = AsyncMock(side_effect=Exception("API error"))
|
|
|
|
result = await _resolve_dashboard_id_by_ref("my-dash", "env-dev", config_manager)
|
|
assert result is None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_resolve_dashboard_id_by_ref_partial_match(self):
|
|
"""_resolve_dashboard_id_by_ref returns single partial match."""
|
|
from src.api.routes.assistant._resolvers import _resolve_dashboard_id_by_ref
|
|
|
|
config_manager = MagicMock()
|
|
env = _make_env("env-dev", "Development")
|
|
config_manager.get_environments = MagicMock(return_value=[env])
|
|
|
|
with patch("src.api.routes.assistant._resolvers.SupersetClient") as mock_client_cls:
|
|
client = MagicMock()
|
|
mock_client_cls.return_value = client
|
|
client.get_dashboards = AsyncMock(return_value=(None, [
|
|
{"id": 10, "dashboard_title": "COVID Dashboard", "slug": "covid"},
|
|
{"id": 20, "dashboard_title": "Sales Dashboard", "slug": "sales"},
|
|
]))
|
|
|
|
result = await _resolve_dashboard_id_by_ref("covid", "env-dev", config_manager)
|
|
assert result == 10
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_resolve_dashboard_id_by_ref_partial_match_by_title(self):
|
|
"""_resolve_dashboard_id_by_ref matches by title substring."""
|
|
from src.api.routes.assistant._resolvers import _resolve_dashboard_id_by_ref
|
|
|
|
config_manager = MagicMock()
|
|
env = _make_env("env-dev", "Development")
|
|
config_manager.get_environments = MagicMock(return_value=[env])
|
|
|
|
with patch("src.api.routes.assistant._resolvers.SupersetClient") as mock_client_cls:
|
|
client = MagicMock()
|
|
mock_client_cls.return_value = client
|
|
client.get_dashboards = AsyncMock(return_value=(None, [
|
|
{"id": 1, "dashboard_title": "COVID Dashboard", "slug": "covid"},
|
|
{"id": 2, "dashboard_title": "COVID Overview", "slug": "covid-overview"},
|
|
]))
|
|
|
|
# Partial match with multiple results -> None
|
|
result = await _resolve_dashboard_id_by_ref("covid", "env-dev", config_manager)
|
|
assert result == 1 # exact slug match "covid"
|
|
|
|
def test_extract_result_deep_links_migration_params_fallback(self):
|
|
"""Deep links fallback to params selected_ids when migrated list is empty."""
|
|
from src.api.routes.assistant._resolvers import _extract_result_deep_links
|
|
|
|
config_manager = MagicMock()
|
|
env = _make_env("env-prod", "Production")
|
|
config_manager.get_environments = MagicMock(return_value=[env])
|
|
|
|
task = MagicMock(
|
|
plugin_id="superset-migration",
|
|
params={"selected_ids": [123], "target_env_id": "env-prod"},
|
|
result={"migrated_dashboards": None},
|
|
)
|
|
actions = _extract_result_deep_links(task, config_manager)
|
|
assert len(actions) >= 1
|
|
|
|
def test_extract_result_deep_links_backup_params_fallback(self):
|
|
"""Deep links fallback to params dashboard_ids when result list is empty."""
|
|
from src.api.routes.assistant._resolvers import _extract_result_deep_links
|
|
|
|
config_manager = MagicMock()
|
|
env = _make_env("env-dev", "Dev")
|
|
config_manager.get_environments = MagicMock(return_value=[env])
|
|
|
|
task = MagicMock(
|
|
plugin_id="superset-backup",
|
|
params={"dashboard_ids": [456], "environment_id": "env-dev"},
|
|
result={"dashboards": None},
|
|
)
|
|
actions = _extract_result_deep_links(task, config_manager)
|
|
assert len(actions) >= 1
|
|
|
|
def test_extract_result_deep_links_backup_env_from_result(self):
|
|
"""Backup deep links resolves environment from result when not in params."""
|
|
from src.api.routes.assistant._resolvers import _extract_result_deep_links
|
|
|
|
config_manager = MagicMock()
|
|
env = _make_env("env-dev", "Dev")
|
|
config_manager.get_environments = MagicMock(return_value=[env])
|
|
|
|
task = MagicMock(
|
|
plugin_id="superset-backup",
|
|
params={},
|
|
result={"dashboards": [{"id": 456}], "environment": "dev"},
|
|
)
|
|
with patch("src.api.routes.assistant._resolvers._resolve_env_id", return_value="env-dev"):
|
|
actions = _extract_result_deep_links(task, config_manager)
|
|
assert len(actions) >= 1
|
|
|
|
|
|
# ── _tool_backup.py: dashboard_id resolution failure ────────────
|
|
|
|
|
|
class TestToolBackupEdge:
|
|
"""Edge cases for backup tool."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_run_backup_dashboard_resolution_failure(self):
|
|
"""Backup with dashboard_ref that fails resolution raises 422."""
|
|
from src.api.routes.assistant._tool_backup import handle_run_backup
|
|
from src.schemas.auth import User
|
|
|
|
user = User(id="user-1", username="test", email="test@x.com", is_active=True,
|
|
auth_source="internal", created_at=datetime.now(UTC),
|
|
last_login=datetime.now(UTC), roles=[])
|
|
task_manager = MagicMock()
|
|
config_manager = MagicMock()
|
|
db = MagicMock()
|
|
|
|
intent = {
|
|
"entities": {
|
|
"environment": "dev",
|
|
"dashboard_ref": "nonexistent",
|
|
}
|
|
}
|
|
|
|
with patch("src.api.routes.assistant._tool_backup._check_any_permission"), \
|
|
patch("src.api.routes.assistant._tool_backup._resolve_env_id", return_value="env-dev"), \
|
|
patch("src.api.routes.assistant._tool_backup._resolve_dashboard_id_entity",
|
|
new=AsyncMock(return_value=None)):
|
|
|
|
with pytest.raises(HTTPException) as exc:
|
|
await handle_run_backup(intent, user, task_manager, config_manager, db)
|
|
assert exc.value.status_code == 422
|
|
|
|
|
|
# ── _tool_migration.py: dashboard_ref path ───────────────────────
|
|
|
|
|
|
class TestToolMigrationEdge:
|
|
"""Edge cases for migration tool."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_execute_migration_with_dashboard_ref(self):
|
|
"""Migration with dashboard_ref uses regex path."""
|
|
from src.api.routes.assistant._tool_migration import handle_execute_migration
|
|
from src.schemas.auth import User
|
|
|
|
user = User(id="user-1", username="test", email="test@x.com", is_active=True,
|
|
auth_source="internal", created_at=datetime.now(UTC),
|
|
last_login=datetime.now(UTC), roles=[])
|
|
task_manager = MagicMock()
|
|
task_manager.create_task = AsyncMock(return_value=MagicMock(id="task-55"))
|
|
config_manager = MagicMock()
|
|
db = MagicMock()
|
|
|
|
intent = {
|
|
"entities": {
|
|
"source_env": "dev",
|
|
"target_env": "staging",
|
|
"dashboard_ref": "my-dashboard-slug",
|
|
}
|
|
}
|
|
|
|
with patch("src.api.routes.assistant._tool_migration._check_any_permission"), \
|
|
patch("src.api.routes.assistant._tool_migration._resolve_dashboard_id_entity",
|
|
new=AsyncMock(return_value=None)), \
|
|
patch("src.api.routes.assistant._tool_migration._resolve_env_id",
|
|
side_effect=lambda t, cm: {"dev": "env-dev", "staging": "env-staging"}.get(t)):
|
|
|
|
text, task_id, actions = await handle_execute_migration(
|
|
intent, user, task_manager, config_manager, db
|
|
)
|
|
assert "Миграция запущена" in text
|
|
assert task_id == "task-55"
|
|
# Verify dashboard_regex was used
|
|
call_kwargs = task_manager.create_task.call_args[1]
|
|
assert "dashboard_regex" in call_kwargs["params"]
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_execute_migration_missing_both_dashboard_refs(self):
|
|
"""Migration without dashboard_id AND dashboard_ref raises 422."""
|
|
from src.api.routes.assistant._tool_migration import handle_execute_migration
|
|
from src.schemas.auth import User
|
|
|
|
user = User(id="user-1", username="test", email="test@x.com", is_active=True,
|
|
auth_source="internal", created_at=datetime.now(UTC),
|
|
last_login=datetime.now(UTC), roles=[])
|
|
task_manager = MagicMock()
|
|
config_manager = MagicMock()
|
|
db = MagicMock()
|
|
|
|
intent = {
|
|
"entities": {
|
|
"source_env": "dev",
|
|
"target_env": "staging",
|
|
}
|
|
}
|
|
|
|
with patch("src.api.routes.assistant._tool_migration._check_any_permission"), \
|
|
patch("src.api.routes.assistant._tool_migration._resolve_env_id",
|
|
return_value="env-dev"):
|
|
|
|
with pytest.raises(HTTPException) as exc:
|
|
await handle_execute_migration(intent, user, task_manager, config_manager, db)
|
|
assert exc.value.status_code == 422
|
|
|
|
|
|
# ── _tool_search_dashboards.py: no results and pagination ────────
|
|
|
|
|
|
class TestToolSearchDashboardsEdge:
|
|
"""Edge cases for search dashboards tool."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_search_with_no_results(self):
|
|
"""Search with no matching dashboards returns 'not found' message."""
|
|
from src.api.routes.assistant._tool_search_dashboards import handle_search_dashboards
|
|
|
|
config_manager = MagicMock()
|
|
env = _make_env("env-dev", "Development")
|
|
config_manager.get_environments = MagicMock(return_value=[env])
|
|
db = MagicMock()
|
|
|
|
intent = {"entities": {"environment": "dev", "search": "nonexistent"}}
|
|
|
|
with patch("src.api.routes.assistant._tool_search_dashboards._resolve_env_id",
|
|
return_value="env-dev"), \
|
|
patch("src.api.routes.assistant._tool_search_dashboards.SupersetClient") as mock_cls:
|
|
|
|
client = MagicMock()
|
|
mock_cls.return_value = client
|
|
client.get_dashboards_summary = AsyncMock(return_value=[
|
|
{"id": 1, "title": "Dashboard One", "slug": "dash-one"},
|
|
])
|
|
|
|
text, task_id, actions = await handle_search_dashboards(
|
|
intent, MagicMock(id="user-1"), MagicMock(), config_manager, db
|
|
)
|
|
assert "не найдены" in text
|
|
assert task_id is None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_search_empty_env_no_dashboards(self):
|
|
"""Environment with no dashboards at all."""
|
|
from src.api.routes.assistant._tool_search_dashboards import handle_search_dashboards
|
|
|
|
config_manager = MagicMock()
|
|
env = _make_env("env-dev", "Development")
|
|
config_manager.get_environments = MagicMock(return_value=[env])
|
|
db = MagicMock()
|
|
|
|
intent = {"entities": {"environment": "dev"}}
|
|
|
|
with patch("src.api.routes.assistant._tool_search_dashboards._resolve_env_id",
|
|
return_value="env-dev"), \
|
|
patch("src.api.routes.assistant._tool_search_dashboards.SupersetClient") as mock_cls:
|
|
|
|
client = MagicMock()
|
|
mock_cls.return_value = client
|
|
client.get_dashboards_summary = AsyncMock(return_value=[])
|
|
|
|
text, task_id, actions = await handle_search_dashboards(
|
|
intent, MagicMock(id="user-1"), MagicMock(), config_manager, db
|
|
)
|
|
assert "нет дашбордов" in text
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_search_with_pagination_tail(self):
|
|
"""Pagination '... and more' indicator."""
|
|
from src.api.routes.assistant._tool_search_dashboards import handle_search_dashboards
|
|
|
|
config_manager = MagicMock()
|
|
env = _make_env("env-dev", "Development")
|
|
config_manager.get_environments = MagicMock(return_value=[env])
|
|
db = MagicMock()
|
|
|
|
dashboards = [{"id": i, "title": f"Dashboard {i}", "slug": f"dash-{i}"} for i in range(1, 25)]
|
|
|
|
intent = {"entities": {"environment": "dev", "page": 1, "page_size": 20}}
|
|
|
|
with patch("src.api.routes.assistant._tool_search_dashboards._resolve_env_id",
|
|
return_value="env-dev"), \
|
|
patch("src.api.routes.assistant._tool_search_dashboards.SupersetClient") as mock_cls:
|
|
|
|
client = MagicMock()
|
|
mock_cls.return_value = client
|
|
client.get_dashboards_summary = AsyncMock(return_value=dashboards)
|
|
|
|
text, task_id, actions = await handle_search_dashboards(
|
|
intent, MagicMock(id="user-1"), MagicMock(), config_manager, db
|
|
)
|
|
assert "и ещё" in text
|
|
|
|
|
|
# ── _tool_registry.py: get_catalog filtered and defaults ─────────
|
|
|
|
|
|
class TestToolRegistryEdge:
|
|
"""Edge cases for tool registry."""
|
|
|
|
def _clear_registry(self):
|
|
import src.api.routes.assistant._tool_registry as reg
|
|
reg._tools.clear()
|
|
|
|
def test_get_catalog_filters_by_permission(self):
|
|
"""get_catalog skips tools user doesn't have permission for."""
|
|
from src.api.routes.assistant._tool_registry import assistant_tool, get_catalog, _tools
|
|
from src.schemas.auth import User
|
|
|
|
self._clear_registry()
|
|
|
|
@assistant_tool(operation="admin_op", domain="admin", description="Admin only",
|
|
permission_checks=[("admin", "ACCESS")])
|
|
async def admin_handler(i, u, t, c, d): return ("", None, [])
|
|
|
|
@assistant_tool(operation="public_op", domain="public", description="Public",
|
|
risk_level="safe")
|
|
async def public_handler(i, u, t, c, d): return ("", None, [])
|
|
|
|
user = User(id="u1", username="test", email="t@t.com", is_active=True,
|
|
auth_source="internal", created_at=datetime.now(UTC),
|
|
last_login=datetime.now(UTC), roles=[])
|
|
|
|
with patch("src.api.routes.assistant._tool_registry._has_any_permission",
|
|
return_value=False):
|
|
catalog = get_catalog(user, MagicMock(), MagicMock())
|
|
ops = [e["operation"] for e in catalog]
|
|
assert "admin_op" not in ops
|
|
assert "public_op" in ops
|
|
|
|
def test_get_catalog_with_defaults(self):
|
|
"""get_catalog includes defaults when tool has them."""
|
|
from src.api.routes.assistant._tool_registry import assistant_tool, get_catalog, _tools
|
|
|
|
self._clear_registry()
|
|
|
|
@assistant_tool(operation="with_defaults", domain="t", description="t",
|
|
defaults={"environment": "dev"})
|
|
async def h(i, u, t, c, d): return ("", None, [])
|
|
|
|
catalog = get_catalog(MagicMock(), MagicMock(), MagicMock())
|
|
entry = next(e for e in catalog if e["operation"] == "with_defaults")
|
|
assert "defaults" in entry
|
|
assert entry["defaults"]["environment"] == "dev"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_dispatch_dataset_review_ops(self):
|
|
"""dispatch routes dataset_review ops through sub-dispatcher."""
|
|
from src.api.routes.assistant._tool_registry import dispatch
|
|
|
|
with patch(
|
|
"src.api.routes.assistant._dataset_review_dispatch._dispatch_dataset_review_intent",
|
|
new=AsyncMock(return_value=("review result", None, [])),
|
|
) as mock_sub:
|
|
text, tid, acts = await dispatch(
|
|
"dataset_review_answer_context", {}, MagicMock(), MagicMock(), MagicMock(), MagicMock()
|
|
)
|
|
assert text == "review result"
|
|
mock_sub.assert_awaited_once()
|
|
|
|
|
|
# ── _tool_task_status.py: completed task with deep links ─────────
|
|
|
|
|
|
class TestToolTaskStatusEdge:
|
|
"""Edge cases for task status tool."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_task_status_by_id_with_deep_links(self):
|
|
"""Task status with SUCCESS status adds deep link actions."""
|
|
from src.api.routes.assistant._tool_task_status import handle_get_task_status
|
|
from src.api.routes.assistant._schemas import AssistantAction
|
|
from src.schemas.auth import User
|
|
|
|
user = User(id="user-1", username="test", email="test@x.com", is_active=True,
|
|
auth_source="internal", created_at=datetime.now(UTC),
|
|
last_login=datetime.now(UTC), roles=[])
|
|
task_manager = MagicMock()
|
|
config_manager = MagicMock()
|
|
db = MagicMock()
|
|
|
|
with patch("src.api.routes.assistant._tool_task_status._check_any_permission"), \
|
|
patch("src.api.routes.assistant._tool_task_status._build_task_observability_summary",
|
|
return_value="Detailed summary"), \
|
|
patch("src.api.routes.assistant._tool_task_status._extract_result_deep_links",
|
|
return_value=[
|
|
AssistantAction(type="open_route", label="Open Dashboard", target="/dashboards/42")
|
|
]):
|
|
|
|
task = MagicMock(id="task-42", status="SUCCESS", user_id="user-1")
|
|
task_manager.get_task.return_value = task
|
|
|
|
intent = {"entities": {"task_id": "task-42"}}
|
|
text, task_id, actions = await handle_get_task_status(
|
|
intent, user, task_manager, config_manager, db
|
|
)
|
|
assert "task-42" in text
|
|
assert len(actions) > 1 # open_task + deep links
|
|
assert task_id == "task-42"
|
|
# #endregion Test.Assistant.EdgeCases
|