Files
ss-tools/backend/tests/api/test_assistant_edge_cases.py

968 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 = _make_env("env-dev", "Development")
cm.get_environments = MagicMock(return_value=[env])
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