# #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