test: add 7 more test modules — assistant cmd parser, history, dispatch, admin routes, dataset review, maintenance + semantic resolver
This commit is contained in:
230
backend/tests/api/test_assistant_admin_routes.py
Normal file
230
backend/tests/api/test_assistant_admin_routes.py
Normal file
@@ -0,0 +1,230 @@
|
||||
# #region Test.Assistant.AdminRoutes [C:2] [TYPE Module] [SEMANTICS test,assistant,admin,route,audit,conversation]
|
||||
# @BRIEF Tests for _admin_routes.py — admin route handlers for assistant.
|
||||
# @RELATION BINDS_TO -> [AssistantAdminRoutes]
|
||||
# @TEST_EDGE: list_conversations -> Returns empty (deprecated)
|
||||
# @TEST_EDGE: delete_conversation -> Deletes from cache and DB
|
||||
# @TEST_EDGE: delete_conversation_not_found -> 404 on missing
|
||||
# @TEST_EDGE: get_history -> Returns paginated messages
|
||||
# @TEST_EDGE: get_assistant_audit -> Returns audit records
|
||||
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from unittest.mock import MagicMock, patch
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clean_global_state():
|
||||
"""Clean global in-memory stores before each test."""
|
||||
from src.api.routes.assistant._schemas import ASSISTANT_AUDIT, CONVERSATIONS, USER_ACTIVE_CONVERSATION
|
||||
ASSISTANT_AUDIT.clear()
|
||||
CONVERSATIONS.clear()
|
||||
USER_ACTIVE_CONVERSATION.clear()
|
||||
|
||||
|
||||
class TestListConversations:
|
||||
"""list_conversations — deprecated endpoint."""
|
||||
|
||||
def test_returns_empty(self):
|
||||
from src.api.routes.assistant._admin_routes import list_conversations
|
||||
|
||||
result = __import__("asyncio").run(list_conversations(
|
||||
page=1, page_size=20, include_archived=False, archived_only=False,
|
||||
search=None, current_user=None, db=MagicMock(),
|
||||
))
|
||||
assert result["items"] == []
|
||||
assert result["total"] == 0
|
||||
assert result["page"] == 1
|
||||
|
||||
|
||||
class TestDeleteConversation:
|
||||
"""delete_conversation — conversation deletion."""
|
||||
|
||||
def test_delete_success(self):
|
||||
from src.api.routes.assistant._schemas import CONVERSATIONS
|
||||
from src.api.routes.assistant._admin_routes import delete_conversation
|
||||
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=[])
|
||||
db = MagicMock()
|
||||
|
||||
# Add to in-memory cache
|
||||
CONVERSATIONS[("user-1", "conv-1")] = [{"role": "user", "text": "hello"}]
|
||||
|
||||
# Build query chain where .delete() returns int
|
||||
q = MagicMock()
|
||||
q.filter.return_value = q
|
||||
q.delete.return_value = 5
|
||||
db.query.return_value = q
|
||||
|
||||
result = __import__("asyncio").run(delete_conversation(
|
||||
"conv-1", current_user=user, db=db,
|
||||
))
|
||||
assert result["status"] == "success"
|
||||
assert result["deleted"] == 5
|
||||
assert ("user-1", "conv-1") not in CONVERSATIONS
|
||||
|
||||
def test_delete_not_found_raises_404(self):
|
||||
from src.api.routes.assistant._admin_routes import delete_conversation
|
||||
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=[])
|
||||
db = MagicMock()
|
||||
|
||||
q = MagicMock()
|
||||
q.filter.return_value = q
|
||||
q.delete.return_value = 0
|
||||
db.query.return_value = q
|
||||
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
__import__("asyncio").run(delete_conversation(
|
||||
"conv-nonexistent", current_user=user, db=db,
|
||||
))
|
||||
assert exc.value.status_code == 404
|
||||
|
||||
def test_delete_unknown_user_no_cache_entry(self):
|
||||
from src.api.routes.assistant._admin_routes import delete_conversation
|
||||
from src.schemas.auth import User
|
||||
|
||||
user = User(id="user-other", username="test", email="test@x.com", is_active=True,
|
||||
auth_source="internal", created_at=datetime.now(UTC), last_login=datetime.now(UTC), roles=[])
|
||||
db = MagicMock()
|
||||
|
||||
q = MagicMock()
|
||||
q.filter.return_value = q
|
||||
q.delete.return_value = 0
|
||||
db.query.return_value = q
|
||||
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
__import__("asyncio").run(delete_conversation(
|
||||
"conv-1", current_user=user, db=db,
|
||||
))
|
||||
assert exc.value.status_code == 404
|
||||
|
||||
|
||||
class TestGetHistory:
|
||||
"""get_history — conversation history retrieval."""
|
||||
|
||||
def _make_mock_row(self, msg_id="msg-1", conv_id="conv-1", role="user", text="Hello",
|
||||
state="sent", task_id=None, confirmation_id=None, payload=None):
|
||||
from src.models.assistant import AssistantMessageRecord
|
||||
row = MagicMock(spec=AssistantMessageRecord)
|
||||
row.id = msg_id
|
||||
row.user_id = "user-1"
|
||||
row.conversation_id = conv_id
|
||||
row.role = role
|
||||
row.text = text
|
||||
row.state = state
|
||||
row.task_id = task_id
|
||||
row.confirmation_id = confirmation_id
|
||||
row.payload = payload
|
||||
row.created_at = datetime.now(UTC)
|
||||
return row
|
||||
|
||||
def test_get_history_basic(self):
|
||||
from src.api.routes.assistant._admin_routes import get_history
|
||||
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=[])
|
||||
db = MagicMock()
|
||||
|
||||
# Build query mock chain that returns real values
|
||||
q = MagicMock()
|
||||
# .filter().filter() -> q
|
||||
q.filter.return_value = q
|
||||
q.count.return_value = 1
|
||||
q.order_by.return_value = q
|
||||
q.offset.return_value = q
|
||||
q.limit.return_value = q
|
||||
q.all.return_value = [self._make_mock_row()]
|
||||
db.query.return_value = q
|
||||
|
||||
with patch("src.api.routes.assistant._admin_routes._resolve_or_create_conversation") as mock_resolve, \
|
||||
patch("src.api.routes.assistant._admin_routes._cleanup_history_ttl") as mock_cleanup:
|
||||
mock_resolve.return_value = "conv-1"
|
||||
|
||||
result = __import__("asyncio").run(get_history(
|
||||
page=1, page_size=20, conversation_id="conv-1",
|
||||
from_latest=False, current_user=user, db=db,
|
||||
))
|
||||
assert result["total"] == 1
|
||||
assert len(result["items"]) == 1
|
||||
assert result["items"][0]["message_id"] == "msg-1"
|
||||
assert result["conversation_id"] == "conv-1"
|
||||
|
||||
def test_get_history_from_latest(self):
|
||||
from src.api.routes.assistant._admin_routes import get_history
|
||||
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=[])
|
||||
db = MagicMock()
|
||||
|
||||
q = MagicMock()
|
||||
q.filter.return_value = q
|
||||
q.count.return_value = 1
|
||||
q.order_by.return_value = q
|
||||
q.offset.return_value = q
|
||||
q.limit.return_value = q
|
||||
q.all.return_value = [self._make_mock_row(role="assistant", text="Response", state="success", task_id="task-1")]
|
||||
db.query.return_value = q
|
||||
|
||||
with patch("src.api.routes.assistant._admin_routes._resolve_or_create_conversation") as mock_resolve, \
|
||||
patch("src.api.routes.assistant._admin_routes._cleanup_history_ttl") as mock_cleanup:
|
||||
mock_resolve.return_value = "conv-1"
|
||||
|
||||
result = __import__("asyncio").run(get_history(
|
||||
page=1, page_size=20, conversation_id="conv-1",
|
||||
from_latest=True, current_user=user, db=db,
|
||||
))
|
||||
assert result["total"] == 1
|
||||
assert result["from_latest"] is True
|
||||
|
||||
|
||||
class TestGetAssistantAudit:
|
||||
"""get_assistant_audit — audit record retrieval."""
|
||||
|
||||
def _make_mock_row(self):
|
||||
from src.models.assistant import AssistantAuditRecord
|
||||
row = MagicMock(spec=AssistantAuditRecord)
|
||||
row.id = "audit-1"
|
||||
row.user_id = "user-1"
|
||||
row.conversation_id = "conv-1"
|
||||
row.decision = "executed"
|
||||
row.task_id = "task-1"
|
||||
row.message = "test command"
|
||||
row.payload = {"key": "val"}
|
||||
row.created_at = datetime.now(UTC)
|
||||
return row
|
||||
|
||||
def test_get_audit(self):
|
||||
from src.api.routes.assistant._admin_routes import get_assistant_audit
|
||||
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=[])
|
||||
db = MagicMock()
|
||||
|
||||
q = MagicMock()
|
||||
q.filter.return_value = q
|
||||
q.order_by.return_value = q
|
||||
q.limit.return_value = q
|
||||
q.all.return_value = [self._make_mock_row()]
|
||||
db.query.return_value = q
|
||||
|
||||
result = __import__("asyncio").run(get_assistant_audit(
|
||||
limit=50, current_user=user, db=db, _=None,
|
||||
))
|
||||
assert result["total"] == 1
|
||||
assert len(result["items"]) == 1
|
||||
assert result["items"][0]["id"] == "audit-1"
|
||||
assert result["memory_total"] == 0
|
||||
# #endregion Test.Assistant.AdminRoutes
|
||||
367
backend/tests/api/test_assistant_command_parser.py
Normal file
367
backend/tests/api/test_assistant_command_parser.py
Normal file
@@ -0,0 +1,367 @@
|
||||
# #region Test.Assistant.CommandParser [C:2] [TYPE Module] [SEMANTICS test,assistant,command,parser,nlu]
|
||||
# @BRIEF Tests for _command_parser.py — deterministic RU/EN command parser.
|
||||
# @RELATION BINDS_TO -> [AssistantCommandParser]
|
||||
# @TEST_EDGE: help_variants -> All help phrases map to show_capabilities
|
||||
# @TEST_EDGE: status_variants -> Status commands get_task_status with/without task_id
|
||||
# @TEST_EDGE: branch_create -> Branch create with dashboard_id
|
||||
# @TEST_EDGE: commit_variants -> Commit with quoted message
|
||||
# @TEST_EDGE: deploy_variants -> Deploy with env, production detection
|
||||
# @TEST_EDGE: migrate_variants -> Migration with src/tgt/dry_run/replace_db
|
||||
# @TEST_EDGE: backup_variants -> Backup with env
|
||||
# @TEST_EDGE: health_variants -> Health check with env
|
||||
# @TEST_EDGE: validation_variants -> LLM validation with provider
|
||||
# @TEST_EDGE: documentation_variants -> LLM documentation with dataset_id
|
||||
# @TEST_EDGE: fallback -> Unknown commands return clarify intent
|
||||
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
import pytest
|
||||
|
||||
|
||||
def _make_config_manager(envs=None):
|
||||
cm = MagicMock()
|
||||
envs = envs or []
|
||||
cm.get_environments = MagicMock(return_value=envs)
|
||||
return cm
|
||||
|
||||
|
||||
def _make_env(id_, name_, 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
|
||||
|
||||
|
||||
class TestParseCommandHelp:
|
||||
"""_parse_command — help/capabilities variants."""
|
||||
|
||||
@pytest.mark.parametrize("msg", [
|
||||
"что ты умеешь",
|
||||
"что умеешь",
|
||||
"что ты можешь",
|
||||
"help",
|
||||
"помощь",
|
||||
"доступные команды",
|
||||
"какие команды",
|
||||
])
|
||||
def test_help_variants(self, msg):
|
||||
from src.api.routes.assistant._command_parser import _parse_command
|
||||
cm = _make_config_manager()
|
||||
result = _parse_command(msg, cm)
|
||||
assert result["domain"] == "assistant"
|
||||
assert result["operation"] == "show_capabilities"
|
||||
assert result["entities"] == {}
|
||||
assert result["confidence"] == 0.98
|
||||
assert result["risk_level"] == "safe"
|
||||
assert result["requires_confirmation"] is False
|
||||
|
||||
|
||||
class TestParseCommandStatus:
|
||||
"""_parse_command — status/task status variants."""
|
||||
|
||||
@pytest.mark.parametrize("msg,expected_conf", [
|
||||
("статус", 0.66),
|
||||
("status", 0.66),
|
||||
("state", 0.66),
|
||||
("проверь задачу", 0.66),
|
||||
("статус task-abc123", 0.92),
|
||||
("status task-abc-def", 0.92),
|
||||
])
|
||||
def test_status_variants(self, msg, expected_conf):
|
||||
from src.api.routes.assistant._command_parser import _parse_command
|
||||
cm = _make_config_manager()
|
||||
result = _parse_command(msg, cm)
|
||||
assert result["domain"] == "status"
|
||||
assert result["operation"] == "get_task_status"
|
||||
assert result["confidence"] == expected_conf
|
||||
assert result["risk_level"] == "safe"
|
||||
|
||||
|
||||
class TestParseCommandBranch:
|
||||
"""_parse_command — branch create variants."""
|
||||
|
||||
@pytest.mark.parametrize("msg,expected_branch,expected_conf", [
|
||||
("создай ветку feature/test", "feature/test", 0.7),
|
||||
("create branch feature/abc", "feature/abc", 0.7),
|
||||
("сделай ветку fix/123 для дашборд 42", "fix/123", 0.95),
|
||||
("create branch main", "main", 0.7),
|
||||
])
|
||||
def test_branch_create(self, msg, expected_branch, expected_conf):
|
||||
from src.api.routes.assistant._command_parser import _parse_command
|
||||
cm = _make_config_manager()
|
||||
result = _parse_command(msg, cm)
|
||||
assert result["domain"] == "git"
|
||||
assert result["operation"] == "create_branch"
|
||||
assert result["risk_level"] == "guarded"
|
||||
assert result["confidence"] == expected_conf
|
||||
|
||||
|
||||
class TestParseCommandCommit:
|
||||
"""_parse_command — commit variants."""
|
||||
|
||||
def test_commit_with_message(self):
|
||||
from src.api.routes.assistant._command_parser import _parse_command
|
||||
cm = _make_config_manager()
|
||||
result = _parse_command('commit "update dashboard filters"', cm)
|
||||
assert result["operation"] == "commit_changes"
|
||||
assert result["entities"]["message"] == "update dashboard filters"
|
||||
|
||||
def test_commit_without_quotes(self):
|
||||
from src.api.routes.assistant._command_parser import _parse_command
|
||||
cm = _make_config_manager()
|
||||
result = _parse_command("commit", cm)
|
||||
assert result["operation"] == "commit_changes"
|
||||
assert result["entities"]["message"] == "assistant: update dashboard changes"
|
||||
|
||||
|
||||
class TestParseCommandDeploy:
|
||||
"""_parse_command — deploy variants with production detection."""
|
||||
|
||||
def test_deploy_to_dev(self):
|
||||
from src.api.routes.assistant._command_parser import _parse_command
|
||||
envs = [_make_env("dev", "DEV", stage="DEV")]
|
||||
cm = _make_config_manager(envs)
|
||||
result = _parse_command("деплой дашборд 42 в dev", cm)
|
||||
assert result["operation"] == "deploy_dashboard"
|
||||
assert result["risk_level"] == "guarded"
|
||||
assert result["requires_confirmation"] is False
|
||||
|
||||
def test_deploy_to_production(self):
|
||||
from src.api.routes.assistant._command_parser import _parse_command
|
||||
prod = _make_env("prod", "PROD", stage="PROD", is_production=True)
|
||||
cm = _make_config_manager([prod])
|
||||
result = _parse_command("deploy dashboard 42 в prod", cm)
|
||||
assert result["operation"] == "deploy_dashboard"
|
||||
assert result["risk_level"] == "dangerous"
|
||||
assert result["requires_confirmation"] is True
|
||||
|
||||
def test_deploy_without_env(self):
|
||||
from src.api.routes.assistant._command_parser import _parse_command
|
||||
cm = _make_config_manager()
|
||||
result = _parse_command("разверн дашборд 42", cm)
|
||||
assert result["operation"] == "deploy_dashboard"
|
||||
assert result["confidence"] == 0.7
|
||||
|
||||
|
||||
class TestParseCommandMigration:
|
||||
"""_parse_command — migration variants."""
|
||||
|
||||
def test_migration_with_src_tgt(self):
|
||||
from src.api.routes.assistant._command_parser import _parse_command
|
||||
envs = [_make_env("prod", "PROD", stage="PROD", is_production=True)]
|
||||
cm = _make_config_manager(envs)
|
||||
result = _parse_command("миграц дашборд 42 с dev на prod", cm)
|
||||
assert result["operation"] == "execute_migration"
|
||||
assert result["entities"]["source_env"] == "dev"
|
||||
assert result["entities"]["target_env"] == "prod"
|
||||
assert result["risk_level"] == "dangerous" # because tgt=prod
|
||||
|
||||
def test_migration_dry_run(self):
|
||||
from src.api.routes.assistant._command_parser import _parse_command
|
||||
cm = _make_config_manager()
|
||||
result = _parse_command("migrate dashboard 42 from dev to staging --dry-run", cm)
|
||||
assert result["entities"]["dry_run"] is True
|
||||
assert result["entities"]["replace_db_config"] is False
|
||||
assert result["entities"]["fix_cross_filters"] is True
|
||||
|
||||
def test_migration_replace_db(self):
|
||||
from src.api.routes.assistant._command_parser import _parse_command
|
||||
cm = _make_config_manager()
|
||||
result = _parse_command("миграция дашборд 42 с dev на qa --replace-db-config --no-fix-cross-filters", cm)
|
||||
assert result["entities"]["replace_db_config"] is True
|
||||
assert result["entities"]["fix_cross_filters"] is False
|
||||
|
||||
def test_migration_without_dashboard(self):
|
||||
from src.api.routes.assistant._command_parser import _parse_command
|
||||
cm = _make_config_manager()
|
||||
result = _parse_command("migrate from dev to staging", cm)
|
||||
assert result["confidence"] == 0.72
|
||||
|
||||
|
||||
class TestParseCommandBackup:
|
||||
"""_parse_command — backup variants."""
|
||||
|
||||
@pytest.mark.parametrize("msg,expected_env", [
|
||||
("бэкап в dev", "dev"),
|
||||
("backup for prod", "prod"),
|
||||
("резерв из staging", "staging"),
|
||||
("бэкап", None),
|
||||
])
|
||||
def test_backup(self, msg, expected_env):
|
||||
from src.api.routes.assistant._command_parser import _parse_command
|
||||
cm = _make_config_manager()
|
||||
result = _parse_command(msg, cm)
|
||||
assert result["operation"] == "run_backup"
|
||||
assert result["entities"].get("environment") == expected_env
|
||||
assert result["risk_level"] == "guarded"
|
||||
|
||||
|
||||
class TestParseCommandHealth:
|
||||
"""_parse_command — health check variants."""
|
||||
|
||||
@pytest.mark.parametrize("msg,expected_env", [
|
||||
("здоровье в dev", "dev"),
|
||||
("health for prod", "prod"),
|
||||
("ошибки", None),
|
||||
("failing env staging", "staging"),
|
||||
("проблемы", None),
|
||||
])
|
||||
def test_health(self, msg, expected_env):
|
||||
from src.api.routes.assistant._command_parser import _parse_command
|
||||
cm = _make_config_manager()
|
||||
result = _parse_command(msg, cm)
|
||||
assert result["domain"] == "health"
|
||||
assert result["operation"] == "get_health_summary"
|
||||
assert result["entities"].get("environment") == expected_env
|
||||
assert result["risk_level"] == "safe"
|
||||
|
||||
|
||||
class TestParseCommandValidation:
|
||||
"""_parse_command — LLM validation variants."""
|
||||
|
||||
def test_validation_with_provider(self):
|
||||
from src.api.routes.assistant._command_parser import _parse_command
|
||||
cm = _make_config_manager()
|
||||
result = _parse_command("валидац дашборд 42 для env dev провайдер openai", cm)
|
||||
assert result["operation"] == "run_llm_validation"
|
||||
assert result["entities"]["environment"] == "dev"
|
||||
assert result["entities"]["provider"] == "openai"
|
||||
|
||||
def test_validation_without_env(self):
|
||||
from src.api.routes.assistant._command_parser import _parse_command
|
||||
cm = _make_config_manager()
|
||||
# "проверь" alone without a dashboard id -> low confidence
|
||||
result = _parse_command("проверь", cm)
|
||||
assert result["operation"] == "run_llm_validation"
|
||||
assert result["confidence"] == 0.64
|
||||
|
||||
|
||||
class TestParseCommandDocumentation:
|
||||
"""_parse_command — LLM documentation variants."""
|
||||
|
||||
def test_documentation_with_dataset(self):
|
||||
from src.api.routes.assistant._command_parser import _parse_command
|
||||
cm = _make_config_manager()
|
||||
result = _parse_command("документац датасет 123 env prod провайдер anthropic", cm)
|
||||
assert result["operation"] == "run_llm_documentation"
|
||||
assert result["entities"]["dataset_id"] == 123
|
||||
assert result["entities"]["environment"] == "prod"
|
||||
assert result["entities"]["provider"] == "anthropic"
|
||||
|
||||
def test_documentation_without_dataset(self):
|
||||
from src.api.routes.assistant._command_parser import _parse_command
|
||||
cm = _make_config_manager()
|
||||
result = _parse_command("generate docs", cm)
|
||||
assert result["operation"] == "run_llm_documentation"
|
||||
assert result["confidence"] == 0.64
|
||||
|
||||
|
||||
class TestParseCommandSearchDashboards:
|
||||
"""_parse_command — search/list dashboards."""
|
||||
|
||||
@pytest.mark.parametrize("msg,expected_env", [
|
||||
("список дашборд в dev", "dev"),
|
||||
("покажи дашборд в prod", "prod"),
|
||||
("найди дашборд test в staging", "staging"),
|
||||
("list dashboard в dev", "dev"),
|
||||
("search dashboard covid в dev", "dev"),
|
||||
("all dashboards", None),
|
||||
("все дашборд", None),
|
||||
])
|
||||
def test_search_dashboards(self, msg, expected_env):
|
||||
from src.api.routes.assistant._command_parser import _parse_command
|
||||
cm = _make_config_manager()
|
||||
result = _parse_command(msg, cm)
|
||||
assert result["domain"] == "dashboards"
|
||||
assert result["operation"] == "search_dashboards"
|
||||
assert result["entities"].get("environment") == expected_env
|
||||
|
||||
|
||||
class TestParseCommandEnvironments:
|
||||
"""_parse_command — list environments."""
|
||||
|
||||
@pytest.mark.parametrize("msg", [
|
||||
"окружени",
|
||||
"environment",
|
||||
"среды",
|
||||
"сред",
|
||||
])
|
||||
def test_list_environments(self, msg):
|
||||
from src.api.routes.assistant._command_parser import _parse_command
|
||||
cm = _make_config_manager()
|
||||
result = _parse_command(msg, cm)
|
||||
assert result["domain"] == "environments"
|
||||
assert result["operation"] == "list_environments"
|
||||
assert result["risk_level"] == "safe"
|
||||
|
||||
|
||||
class TestParseCommandMaintenance:
|
||||
"""_parse_command — maintenance/events."""
|
||||
|
||||
@pytest.mark.parametrize("msg", [
|
||||
"обслуживан",
|
||||
"maintenance",
|
||||
"события",
|
||||
])
|
||||
def test_list_maintenance(self, msg):
|
||||
from src.api.routes.assistant._command_parser import _parse_command
|
||||
cm = _make_config_manager()
|
||||
result = _parse_command(msg, cm)
|
||||
assert result["domain"] == "maintenance"
|
||||
assert result["operation"] == "list_maintenance_events"
|
||||
|
||||
|
||||
class TestParseCommandLlmProviders:
|
||||
"""_parse_command — LLM provider listing/status."""
|
||||
|
||||
def test_list_providers(self):
|
||||
from src.api.routes.assistant._command_parser import _parse_command
|
||||
cm = _make_config_manager()
|
||||
result = _parse_command("llm провайдер", cm)
|
||||
assert result["domain"] == "llm"
|
||||
assert result["operation"] == "list_llm_providers"
|
||||
|
||||
def test_llm_status_unreachable_via_parser(self):
|
||||
"""The deterministic parser cannot deliver get_llm_status because all
|
||||
keywords matching line 99 are intercepted by earlier checks (status,
|
||||
validation). This is a known source-code quirk; LLM status routing
|
||||
happens through the LLM planner path instead."""
|
||||
from src.api.routes.assistant._command_parser import _parse_command
|
||||
cm = _make_config_manager()
|
||||
result = _parse_command("llm провер", cm)
|
||||
# Line 74 (провер in validation) catches it first
|
||||
assert result["operation"] == "run_llm_validation"
|
||||
|
||||
|
||||
class TestParseCommandFallback:
|
||||
"""_parse_command — unknown commands return clarify intent."""
|
||||
|
||||
@pytest.mark.parametrize("msg", [
|
||||
"",
|
||||
"hello world",
|
||||
"какой сегодня день",
|
||||
"random gibberish xyz",
|
||||
"12345",
|
||||
])
|
||||
def test_fallback_to_clarify(self, msg):
|
||||
from src.api.routes.assistant._command_parser import _parse_command
|
||||
cm = _make_config_manager()
|
||||
result = _parse_command(msg, cm)
|
||||
assert result["domain"] == "unknown"
|
||||
assert result["operation"] == "clarify"
|
||||
assert result["confidence"] == 0.3
|
||||
assert result["risk_level"] == "safe"
|
||||
assert "domain" in result
|
||||
assert "operation" in result
|
||||
assert "entities" in result
|
||||
assert "confidence" in result
|
||||
assert "risk_level" in result
|
||||
assert "requires_confirmation" in result
|
||||
# #endregion Test.Assistant.CommandParser
|
||||
726
backend/tests/api/test_assistant_dataset_review.py
Normal file
726
backend/tests/api/test_assistant_dataset_review.py
Normal file
@@ -0,0 +1,726 @@
|
||||
# #region Test.Assistant.DatasetReview [C:2] [TYPE Module] [SEMANTICS test,assistant,dataset,review,context,intent]
|
||||
# @BRIEF Tests for _dataset_review.py and _dataset_review_dispatch.py — context loading, intent planning, dispatch.
|
||||
# @RELATION BINDS_TO -> [AssistantDatasetReview]
|
||||
# @RELATION BINDS_TO -> [AssistantDatasetReviewDispatch]
|
||||
# @TEST_EDGE: serialize_context -> Session context serialization with all fields
|
||||
# @TEST_EDGE: extract_target -> Target extraction from messages
|
||||
# @TEST_EDGE: match_field -> Field matching by id/name/verbose name
|
||||
# @TEST_EDGE: extract_quoted_segment -> Quoted segment extraction
|
||||
# @TEST_EDGE: plan_intent_approve -> Approve mappings intent
|
||||
# @TEST_EDGE: plan_intent_preview -> Generate SQL preview intent
|
||||
# @TEST_EDGE: plan_intent_semantic -> Set field semantics intent
|
||||
# @TEST_EDGE: plan_intent_context -> Answer context intent
|
||||
# @TEST_EDGE: dispatch_answer_context -> Context-only dispatch
|
||||
# @TEST_EDGE: dispatch_approve_mappings -> Mapping approval dispatch
|
||||
# @TEST_EDGE: dispatch_set_field -> Field semantic update dispatch
|
||||
# @TEST_EDGE: dispatch_generate_preview -> SQL preview generation dispatch
|
||||
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from unittest.mock import MagicMock, AsyncMock, patch, PropertyMock
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
|
||||
# ── Shared helpers ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _make_session_mock(**overrides):
|
||||
"""Create a mock DatasetReviewSession with realistic defaults."""
|
||||
from src.models.dataset_review import ReadinessState, RecommendedAction, SessionStatus, SessionPhase
|
||||
|
||||
s = MagicMock()
|
||||
s.session_id = overrides.get("session_id", "sess-1")
|
||||
s.version = overrides.get("version", 5)
|
||||
s.dataset_ref = overrides.get("dataset_ref", "dataset_123")
|
||||
s.dataset_id = overrides.get("dataset_id", 123)
|
||||
s.environment_id = overrides.get("environment_id", "env-1")
|
||||
s.user_id = overrides.get("user_id", "user-1")
|
||||
|
||||
rs = overrides.get("readiness_state", ReadinessState.REVIEW_READY)
|
||||
s.readiness_state = rs if isinstance(rs, MagicMock) else MagicMock(value=rs.value if hasattr(rs, 'value') else rs)
|
||||
s.readiness_state.value = rs.value if hasattr(rs, 'value') else rs
|
||||
|
||||
ra = overrides.get("recommended_action", RecommendedAction.REVIEW_DOCUMENTATION)
|
||||
s.recommended_action = ra if isinstance(ra, MagicMock) else MagicMock(value=ra.value if hasattr(ra, 'value') else ra)
|
||||
s.recommended_action.value = ra.value if hasattr(ra, 'value') else ra
|
||||
|
||||
st = overrides.get("status", SessionStatus.ACTIVE)
|
||||
s.status = st if isinstance(st, MagicMock) else MagicMock(value=st.value if hasattr(st, 'value') else st)
|
||||
s.status.value = st.value if hasattr(st, 'value') else st
|
||||
|
||||
cp = overrides.get("current_phase", SessionPhase.REVIEW)
|
||||
s.current_phase = cp if isinstance(cp, MagicMock) else MagicMock(value=cp.value if hasattr(cp, 'value') else cp)
|
||||
s.current_phase.value = cp.value if hasattr(cp, 'value') else cp
|
||||
|
||||
s.findings = overrides.get("findings", [])
|
||||
s.imported_filters = overrides.get("imported_filters", [])
|
||||
s.execution_mappings = overrides.get("execution_mappings", [])
|
||||
s.semantic_fields = overrides.get("semantic_fields", [])
|
||||
s.previews = overrides.get("previews", [])
|
||||
s.last_activity_at = overrides.get("last_activity_at", datetime.now(UTC))
|
||||
return s
|
||||
|
||||
|
||||
def _make_finding_mock(finding_id="f-1", code="TEST", severity="WARNING", message="Test finding", resolution_state="OPEN"):
|
||||
f = MagicMock()
|
||||
f.finding_id = finding_id
|
||||
f.code = code
|
||||
f.severity = MagicMock(value=severity)
|
||||
f.message = message
|
||||
f.resolution_state = MagicMock(value=resolution_state)
|
||||
return f
|
||||
|
||||
|
||||
def _make_filter_mock(filter_id="fl-1", filter_name="region", display_name="Region", raw_value="US", normalized_value="USA", source="SUPERSET_URL", confidence_state="HIGH", requires_confirmation=False, recovery_status="RECOVERED", notes=None):
|
||||
fl = MagicMock()
|
||||
fl.filter_id = filter_id
|
||||
fl.filter_name = filter_name
|
||||
fl.display_name = display_name
|
||||
fl.raw_value = raw_value
|
||||
fl.raw_value_masked = False
|
||||
fl.normalized_value = normalized_value
|
||||
fl.source = MagicMock(value=source)
|
||||
fl.confidence_state = MagicMock(value=confidence_state)
|
||||
fl.requires_confirmation = requires_confirmation
|
||||
fl.recovery_status = MagicMock(value=recovery_status)
|
||||
fl.notes = notes
|
||||
return fl
|
||||
|
||||
|
||||
def _make_mapping_mock(mapping_id="m-1", filter_id="fl-1", variable_id="v-1", mapping_method="DIRECT_MATCH", effective_value="US", approval_state="PENDING", requires_explicit_approval=True):
|
||||
m = MagicMock()
|
||||
m.mapping_id = mapping_id
|
||||
m.filter_id = filter_id
|
||||
m.variable_id = variable_id
|
||||
m.mapping_method = MagicMock(value=mapping_method)
|
||||
m.effective_value = effective_value
|
||||
m.approval_state = MagicMock(value=approval_state)
|
||||
m.requires_explicit_approval = requires_explicit_approval
|
||||
return m
|
||||
|
||||
|
||||
def _make_field_mock(field_id="fld-1", field_name="region", verbose_name="Region", description="The region field", display_format="text", provenance="DICTIONARY_EXACT", is_locked=False, needs_review=False, has_conflict=False):
|
||||
f = MagicMock()
|
||||
f.field_id = field_id
|
||||
f.field_name = field_name
|
||||
f.verbose_name = verbose_name
|
||||
f.description = description
|
||||
f.display_format = display_format
|
||||
f.provenance = MagicMock(value=provenance)
|
||||
f.is_locked = is_locked
|
||||
f.needs_review = needs_review
|
||||
f.has_conflict = has_conflict
|
||||
return f
|
||||
|
||||
|
||||
class TestSerializeDatasetReviewContext:
|
||||
"""_serialize_dataset_review_context — context serialization."""
|
||||
|
||||
def test_basic_context(self):
|
||||
from src.api.routes.assistant._dataset_review import _serialize_dataset_review_context
|
||||
|
||||
session = _make_session_mock()
|
||||
ctx = _serialize_dataset_review_context(session)
|
||||
assert ctx["session_id"] == "sess-1"
|
||||
assert ctx["version"] == 5
|
||||
assert ctx["dataset_ref"] == "dataset_123"
|
||||
assert ctx["status"] == "active"
|
||||
|
||||
def test_context_with_findings_and_filters(self):
|
||||
from src.api.routes.assistant._dataset_review import _serialize_dataset_review_context
|
||||
|
||||
session = _make_session_mock(findings=[_make_finding_mock()], imported_filters=[_make_filter_mock()], execution_mappings=[_make_mapping_mock()])
|
||||
ctx = _serialize_dataset_review_context(session)
|
||||
assert len(ctx["findings"]) == 1
|
||||
assert ctx["findings"][0]["finding_id"] == "f-1"
|
||||
assert len(ctx["imported_filters"]) == 1
|
||||
assert ctx["imported_filters"][0]["filter_name"] == "region"
|
||||
assert len(ctx["mappings"]) == 1
|
||||
assert ctx["mappings"][0]["mapping_id"] == "m-1"
|
||||
|
||||
def test_context_with_semantic_fields(self):
|
||||
from src.api.routes.assistant._dataset_review import _serialize_dataset_review_context
|
||||
|
||||
session = _make_session_mock(semantic_fields=[_make_field_mock()])
|
||||
ctx = _serialize_dataset_review_context(session)
|
||||
assert len(ctx["semantic_fields"]) == 1
|
||||
assert ctx["semantic_fields"][0]["field_name"] == "region"
|
||||
|
||||
def test_context_with_preview(self):
|
||||
from src.api.routes.assistant._dataset_review import _serialize_dataset_review_context
|
||||
|
||||
preview_mock = MagicMock()
|
||||
preview_mock.preview_id = "pv-1"
|
||||
preview_mock.preview_status = MagicMock(value="READY")
|
||||
preview_mock.preview_fingerprint = "fp-abc"
|
||||
session = _make_session_mock(previews=[preview_mock])
|
||||
ctx = _serialize_dataset_review_context(session)
|
||||
assert ctx["preview"]["preview_id"] == "pv-1"
|
||||
|
||||
def test_context_no_previews(self):
|
||||
from src.api.routes.assistant._dataset_review import _serialize_dataset_review_context
|
||||
session = _make_session_mock(previews=[])
|
||||
ctx = _serialize_dataset_review_context(session)
|
||||
assert ctx["preview"] is None
|
||||
|
||||
def test_context_without_some_attrs(self):
|
||||
"""Handle missing attributes gracefully."""
|
||||
from src.api.routes.assistant._dataset_review import _serialize_dataset_review_context
|
||||
s = MagicMock()
|
||||
s.session_id = "sess-1"
|
||||
s.version = 0
|
||||
s.dataset_ref = "ds"
|
||||
s.dataset_id = None
|
||||
s.environment_id = "env-1"
|
||||
# Some attributes may be missing
|
||||
del s.readiness_state
|
||||
s.recommended_action = MagicMock(value="NONE")
|
||||
s.status = MagicMock(value="ACTIVE")
|
||||
s.current_phase = MagicMock(value="REVIEW")
|
||||
s.findings = []
|
||||
s.imported_filters = []
|
||||
s.execution_mappings = []
|
||||
s.semantic_fields = []
|
||||
s.previews = []
|
||||
ctx = _serialize_dataset_review_context(s)
|
||||
# Should handle gracefully or raise AttributeError
|
||||
assert ctx["session_id"] == "sess-1"
|
||||
|
||||
|
||||
class TestLoadDatasetReviewContext:
|
||||
"""_load_dataset_review_context — context loading from repository."""
|
||||
|
||||
def test_load_with_valid_session(self):
|
||||
from src.api.routes.assistant._dataset_review import _load_dataset_review_context
|
||||
from src.schemas.auth import User
|
||||
|
||||
db = MagicMock()
|
||||
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=[])
|
||||
|
||||
session = _make_session_mock(user_id="user-1")
|
||||
with patch("src.api.routes.assistant._dataset_review.DatasetReviewSessionRepository") as mock_repo:
|
||||
mock_repo.return_value.load_session_detail.return_value = session
|
||||
result = _load_dataset_review_context("sess-1", user, db)
|
||||
assert result is not None
|
||||
assert result["session_id"] == "sess-1"
|
||||
|
||||
def test_load_with_none_session_id(self):
|
||||
from src.api.routes.assistant._dataset_review import _load_dataset_review_context
|
||||
from src.schemas.auth import User
|
||||
|
||||
db = MagicMock()
|
||||
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=[])
|
||||
result = _load_dataset_review_context(None, user, db)
|
||||
assert result is None
|
||||
|
||||
def test_load_with_wrong_user_raises_404(self):
|
||||
from src.api.routes.assistant._dataset_review import _load_dataset_review_context
|
||||
from src.schemas.auth import User
|
||||
|
||||
db = MagicMock()
|
||||
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=[])
|
||||
|
||||
# Session belongs to different user
|
||||
session = _make_session_mock(user_id="user-2")
|
||||
with patch("src.api.routes.assistant._dataset_review.DatasetReviewSessionRepository") as mock_repo:
|
||||
mock_repo.return_value.load_session_detail.return_value = session
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
_load_dataset_review_context("sess-1", user, db)
|
||||
assert exc.value.status_code == 404
|
||||
|
||||
def test_load_with_missing_session_raises_404(self):
|
||||
from src.api.routes.assistant._dataset_review import _load_dataset_review_context
|
||||
from src.schemas.auth import User
|
||||
|
||||
db = MagicMock()
|
||||
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=[])
|
||||
|
||||
with patch("src.api.routes.assistant._dataset_review.DatasetReviewSessionRepository") as mock_repo:
|
||||
mock_repo.return_value.load_session_detail.return_value = None
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
_load_dataset_review_context("sess-1", user, db)
|
||||
assert exc.value.status_code == 404
|
||||
|
||||
|
||||
class TestExtractDatasetReviewTarget:
|
||||
"""_extract_dataset_review_target — target extraction."""
|
||||
|
||||
@pytest.mark.parametrize("msg,expected_kind,expected_id", [
|
||||
("target:field=abc123", "field", "abc123"),
|
||||
("focus:field=xyz789", "field", "xyz789"),
|
||||
("target:mapping=map_1", "mapping", "map_1"),
|
||||
("target:finding=fnd_1", "finding", "fnd_1"),
|
||||
("target:filter=flt_1", "filter", "flt_1"),
|
||||
("focus = finding=abc", "finding", "abc"),
|
||||
("target = field = def", "field", "def"),
|
||||
("no target here", None, None),
|
||||
("", None, None),
|
||||
])
|
||||
def test_extract(self, msg, expected_kind, expected_id):
|
||||
from src.api.routes.assistant._dataset_review import _extract_dataset_review_target
|
||||
kind, tid = _extract_dataset_review_target(msg)
|
||||
assert kind == expected_kind
|
||||
assert tid == expected_id
|
||||
|
||||
|
||||
class TestMatchDatasetReviewField:
|
||||
"""_match_dataset_review_field — field matching."""
|
||||
|
||||
def test_match_by_id(self):
|
||||
from src.api.routes.assistant._dataset_review import _match_dataset_review_field
|
||||
|
||||
ctx = {"semantic_fields": [{"field_id": "fld-1", "field_name": "region", "verbose_name": "Region"}]}
|
||||
result = _match_dataset_review_field(ctx, "target:field=fld-1")
|
||||
assert result is not None
|
||||
assert result["field_id"] == "fld-1"
|
||||
|
||||
def test_match_by_field_name(self):
|
||||
from src.api.routes.assistant._dataset_review import _match_dataset_review_field
|
||||
|
||||
ctx = {"semantic_fields": [{"field_id": "fld-1", "field_name": "region", "verbose_name": "Region"}]}
|
||||
result = _match_dataset_review_field(ctx, "please update region field")
|
||||
assert result is not None
|
||||
assert result["field_name"] == "region"
|
||||
|
||||
def test_match_by_verbose_name(self):
|
||||
from src.api.routes.assistant._dataset_review import _match_dataset_review_field
|
||||
|
||||
ctx = {"semantic_fields": [{"field_id": "fld-1", "field_name": "region", "verbose_name": "Region Name"}]}
|
||||
result = _match_dataset_review_field(ctx, "change Region Name")
|
||||
assert result is not None
|
||||
assert result["verbose_name"] == "Region Name"
|
||||
|
||||
def test_no_match(self):
|
||||
from src.api.routes.assistant._dataset_review import _match_dataset_review_field
|
||||
|
||||
ctx = {"semantic_fields": [{"field_id": "fld-1", "field_name": "region", "verbose_name": "Region"}]}
|
||||
result = _match_dataset_review_field(ctx, "something completely different")
|
||||
assert result is None
|
||||
|
||||
def test_empty_fields(self):
|
||||
from src.api.routes.assistant._dataset_review import _match_dataset_review_field
|
||||
|
||||
ctx = {"semantic_fields": []}
|
||||
result = _match_dataset_review_field(ctx, "region")
|
||||
assert result is None
|
||||
|
||||
|
||||
class TestExtractQuotedSegment:
|
||||
"""_extract_quoted_segment — quoted command segment extraction."""
|
||||
|
||||
@pytest.mark.parametrize("msg,label,expected", [
|
||||
('verbose_name="My Label"', "verbose_name", "My Label"),
|
||||
("verbose_name='My Label'", "verbose_name", "My Label"),
|
||||
('description="Some desc"', "description", "Some desc"),
|
||||
('format="text"', "display_format", "text"),
|
||||
('verbose_name: "Label"', "verbose_name", "Label"),
|
||||
('verbose_name = "Label"', "verbose_name", "Label"),
|
||||
("no label here", "verbose_name", None),
|
||||
])
|
||||
def test_extract(self, msg, label, expected):
|
||||
from src.api.routes.assistant._dataset_review import _extract_quoted_segment
|
||||
result = _extract_quoted_segment(msg, label)
|
||||
assert result == expected
|
||||
|
||||
|
||||
class TestPlanDatasetReviewIntent:
|
||||
"""_plan_dataset_review_intent — intent planning from message."""
|
||||
|
||||
@pytest.fixture
|
||||
def context(self):
|
||||
return {
|
||||
"session_id": "sess-1",
|
||||
"version": 5,
|
||||
"readiness_state": "REVIEW_READY",
|
||||
"recommended_action": "REVIEW_DOCUMENTATION",
|
||||
"mappings": [
|
||||
{"mapping_id": "m-1", "requires_explicit_approval": True, "approval_state": "PENDING"},
|
||||
{"mapping_id": "m-2", "requires_explicit_approval": True, "approval_state": "PENDING"},
|
||||
],
|
||||
"findings": [{"code": "TEST"}],
|
||||
"imported_filters": [{"filter_name": "region"}],
|
||||
"preview": {"preview_status": "missing"},
|
||||
"semantic_fields": [],
|
||||
}
|
||||
|
||||
def test_approve_mappings(self, context):
|
||||
from src.api.routes.assistant._dataset_review import _plan_dataset_review_intent
|
||||
result = _plan_dataset_review_intent("approve mappings", context)
|
||||
assert result is not None
|
||||
assert result["operation"] == "dataset_review_approve_mappings"
|
||||
assert result["entities"]["mapping_ids"] == ["m-1", "m-2"]
|
||||
assert result["requires_confirmation"] is True
|
||||
|
||||
def test_approve_mappings_russian(self, context):
|
||||
from src.api.routes.assistant._dataset_review import _plan_dataset_review_intent
|
||||
result = _plan_dataset_review_intent("подтверди маппинг", context)
|
||||
assert result is not None
|
||||
assert result["operation"] == "dataset_review_approve_mappings"
|
||||
|
||||
def test_generate_sql_preview(self, context):
|
||||
from src.api.routes.assistant._dataset_review import _plan_dataset_review_intent
|
||||
result = _plan_dataset_review_intent("generate sql preview", context)
|
||||
assert result is not None
|
||||
assert result["operation"] == "dataset_review_generate_sql_preview"
|
||||
|
||||
def test_generate_preview_russian(self, context):
|
||||
from src.api.routes.assistant._dataset_review import _plan_dataset_review_intent
|
||||
result = _plan_dataset_review_intent("сгенерируй превью", context)
|
||||
assert result is not None
|
||||
assert result["operation"] == "dataset_review_generate_sql_preview"
|
||||
|
||||
def test_set_field_semantics_with_target(self, context):
|
||||
from src.api.routes.assistant._dataset_review import _plan_dataset_review_intent
|
||||
ctx_with_field = dict(context)
|
||||
ctx_with_field["semantic_fields"] = [
|
||||
{"field_id": "fld-1", "field_name": "region", "verbose_name": "Region", "candidates": []}
|
||||
]
|
||||
result = _plan_dataset_review_intent('set field semantics target:field=fld-1 verbose_name="Country"', ctx_with_field)
|
||||
assert result is not None
|
||||
assert result["operation"] == "dataset_review_set_field_semantics"
|
||||
assert result["entities"]["verbose_name"] == "Country"
|
||||
|
||||
def test_set_field_semantics_no_field_returns_none(self, context):
|
||||
from src.api.routes.assistant._dataset_review import _plan_dataset_review_intent
|
||||
# No matching field in message and no target
|
||||
result = _plan_dataset_review_intent("set field semantics", context)
|
||||
assert result is None
|
||||
|
||||
def test_set_field_semantics_with_lock(self, context):
|
||||
from src.api.routes.assistant._dataset_review import _plan_dataset_review_intent
|
||||
ctx_with_field = dict(context)
|
||||
ctx_with_field["semantic_fields"] = [
|
||||
{"field_id": "fld-1", "field_name": "region", "verbose_name": "Region", "candidates": []}
|
||||
]
|
||||
result = _plan_dataset_review_intent('apply field semantics target:field=fld-1 lock it verbose_name="Country"', ctx_with_field)
|
||||
assert result is not None
|
||||
assert result["entities"]["lock_field"] is True
|
||||
|
||||
def test_answer_context(self, context):
|
||||
from src.api.routes.assistant._dataset_review import _plan_dataset_review_intent
|
||||
result = _plan_dataset_review_intent("what are the filters?", context)
|
||||
assert result is not None
|
||||
assert result["operation"] == "dataset_review_answer_context"
|
||||
assert "readiness_state" in result["entities"]["summary"]
|
||||
assert result["requires_confirmation"] is False
|
||||
|
||||
def test_answer_context_russian(self, context):
|
||||
from src.api.routes.assistant._dataset_review import _plan_dataset_review_intent
|
||||
result = _plan_dataset_review_intent("фильтр", context)
|
||||
assert result is not None
|
||||
assert result["operation"] == "dataset_review_answer_context"
|
||||
|
||||
def test_unrelated_message_returns_none(self, context):
|
||||
from src.api.routes.assistant._dataset_review import _plan_dataset_review_intent
|
||||
result = _plan_dataset_review_intent("hello world", context)
|
||||
assert result is None
|
||||
|
||||
|
||||
class TestDispatchDatasetReviewIntent:
|
||||
"""_dispatch_dataset_review_intent — intent dispatch."""
|
||||
|
||||
@pytest.fixture
|
||||
def 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=[])
|
||||
|
||||
@pytest.fixture
|
||||
def config_manager(self):
|
||||
cm = MagicMock()
|
||||
cm.get_environments = MagicMock(return_value=[])
|
||||
return cm
|
||||
|
||||
def test_answer_context(self, user, config_manager):
|
||||
from src.api.routes.assistant._dataset_review_dispatch import _dispatch_dataset_review_intent
|
||||
|
||||
db = MagicMock()
|
||||
intent = {
|
||||
"operation": "dataset_review_answer_context",
|
||||
"entities": {
|
||||
"dataset_review_session_id": "sess-1",
|
||||
"session_version": 5,
|
||||
"summary": "Session sess-1: readiness=READY",
|
||||
},
|
||||
}
|
||||
text, task_id, actions = (
|
||||
__import__("asyncio").run(_dispatch_dataset_review_intent(intent, user, config_manager, db))
|
||||
)
|
||||
assert "READY" in text
|
||||
assert task_id is None
|
||||
|
||||
def test_missing_session_id_raises_422(self, user, config_manager):
|
||||
from src.api.routes.assistant._dataset_review_dispatch import _dispatch_dataset_review_intent
|
||||
|
||||
db = MagicMock()
|
||||
intent = {"operation": "test", "entities": {}}
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
__import__("asyncio").run(_dispatch_dataset_review_intent(intent, user, config_manager, db))
|
||||
assert exc.value.status_code == 422
|
||||
|
||||
def test_unsupported_operation_raises_400(self, user, config_manager):
|
||||
from src.api.routes.assistant._dataset_review_dispatch import _dispatch_dataset_review_intent
|
||||
|
||||
db = MagicMock()
|
||||
intent = {
|
||||
"operation": "dataset_review_unknown",
|
||||
"entities": {"dataset_review_session_id": "sess-1", "session_version": 5},
|
||||
}
|
||||
|
||||
session = _make_session_mock()
|
||||
repository_cls = "src.api.routes.assistant._dataset_review_dispatch.DatasetReviewSessionRepository"
|
||||
with patch(repository_cls) as mock_repo_cls:
|
||||
mock_repo = MagicMock()
|
||||
mock_repo_cls.return_value = mock_repo
|
||||
mock_repo.load_session_detail.return_value = session
|
||||
mock_repo.require_session_version.return_value = None
|
||||
mock_repo.bump_session_version.return_value = None
|
||||
mock_repo.db = MagicMock()
|
||||
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
__import__("asyncio").run(_dispatch_dataset_review_intent(intent, user, config_manager, db))
|
||||
assert exc.value.status_code == 400
|
||||
|
||||
def test_approve_mappings_success(self, user, config_manager):
|
||||
from src.api.routes.assistant._dataset_review_dispatch import _dispatch_dataset_review_intent
|
||||
from src.models.dataset_review import ApprovalState, ReadinessState
|
||||
|
||||
db = MagicMock()
|
||||
mapping = _make_mapping_mock(mapping_id="m-1", filter_id="fl-1", variable_id="v-1",
|
||||
approval_state=ApprovalState.PENDING.value,
|
||||
requires_explicit_approval=True)
|
||||
|
||||
session = _make_session_mock(execution_mappings=[mapping],
|
||||
readiness_state=ReadinessState.MAPPING_REVIEW_NEEDED,
|
||||
user_id="user-1")
|
||||
|
||||
intent = {
|
||||
"operation": "dataset_review_approve_mappings",
|
||||
"entities": {
|
||||
"dataset_review_session_id": "sess-1",
|
||||
"session_version": 5,
|
||||
"mapping_ids": ["m-1"],
|
||||
},
|
||||
}
|
||||
|
||||
repository_cls = "src.api.routes.assistant._dataset_review_dispatch.DatasetReviewSessionRepository"
|
||||
with patch(repository_cls) as mock_repo_cls:
|
||||
mock_repo = MagicMock()
|
||||
mock_repo_cls.return_value = mock_repo
|
||||
mock_repo.load_session_detail.return_value = session
|
||||
mock_repo.require_session_version.return_value = None
|
||||
mock_repo.bump_session_version.return_value = None
|
||||
mock_repo.db = MagicMock()
|
||||
mock_repo.event_logger = MagicMock()
|
||||
|
||||
text, task_id, actions = (
|
||||
__import__("asyncio").run(_dispatch_dataset_review_intent(intent, user, config_manager, db))
|
||||
)
|
||||
assert "Approved" in text
|
||||
assert "1 mapping" in text
|
||||
|
||||
def test_approve_mappings_no_pending_raises_409(self, user, config_manager):
|
||||
from src.api.routes.assistant._dataset_review_dispatch import _dispatch_dataset_review_intent
|
||||
|
||||
db = MagicMock()
|
||||
intent = {
|
||||
"operation": "dataset_review_approve_mappings",
|
||||
"entities": {
|
||||
"dataset_review_session_id": "sess-1",
|
||||
"session_version": 5,
|
||||
"mapping_ids": [],
|
||||
},
|
||||
}
|
||||
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
__import__("asyncio").run(_dispatch_dataset_review_intent(intent, user, config_manager, db))
|
||||
assert exc.value.status_code == 409
|
||||
assert "No pending mappings" in exc.value.detail
|
||||
|
||||
def test_approve_mappings_no_match_raises_409(self, user, config_manager):
|
||||
from src.api.routes.assistant._dataset_review_dispatch import _dispatch_dataset_review_intent
|
||||
from src.models.dataset_review import ApprovalState
|
||||
|
||||
db = MagicMock()
|
||||
session = _make_session_mock(execution_mappings=[], user_id="user-1")
|
||||
|
||||
intent = {
|
||||
"operation": "dataset_review_approve_mappings",
|
||||
"entities": {
|
||||
"dataset_review_session_id": "sess-1",
|
||||
"session_version": 5,
|
||||
"mapping_ids": ["m-nonexistent"],
|
||||
},
|
||||
}
|
||||
|
||||
repository_cls = "src.api.routes.assistant._dataset_review_dispatch.DatasetReviewSessionRepository"
|
||||
with patch(repository_cls) as mock_repo_cls:
|
||||
mock_repo = MagicMock()
|
||||
mock_repo_cls.return_value = mock_repo
|
||||
mock_repo.load_session_detail.return_value = session
|
||||
mock_repo.require_session_version.return_value = None
|
||||
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
__import__("asyncio").run(_dispatch_dataset_review_intent(intent, user, config_manager, db))
|
||||
assert exc.value.status_code == 409
|
||||
|
||||
def test_set_field_semantics_success(self, user, config_manager):
|
||||
from src.api.routes.assistant._dataset_review_dispatch import _dispatch_dataset_review_intent
|
||||
|
||||
field = _make_field_mock()
|
||||
session = _make_session_mock(semantic_fields=[field], user_id="user-1")
|
||||
|
||||
intent = {
|
||||
"operation": "dataset_review_set_field_semantics",
|
||||
"entities": {
|
||||
"dataset_review_session_id": "sess-1",
|
||||
"session_version": 5,
|
||||
"field_id": "fld-1",
|
||||
"verbose_name": "Country",
|
||||
},
|
||||
}
|
||||
|
||||
db = MagicMock()
|
||||
repository_cls = "src.api.routes.assistant._dataset_review_dispatch.DatasetReviewSessionRepository"
|
||||
with patch(repository_cls) as mock_repo_cls:
|
||||
mock_repo = MagicMock()
|
||||
mock_repo_cls.return_value = mock_repo
|
||||
mock_repo.load_session_detail.return_value = session
|
||||
mock_repo.require_session_version.return_value = None
|
||||
mock_repo.bump_session_version.return_value = None
|
||||
mock_repo.db = MagicMock()
|
||||
mock_repo.event_logger = MagicMock()
|
||||
|
||||
with patch("src.api.routes.assistant._dataset_review_dispatch._update_semantic_field_state") as mock_update:
|
||||
mock_update.return_value = None
|
||||
text, task_id, actions = (
|
||||
__import__("asyncio").run(_dispatch_dataset_review_intent(intent, user, config_manager, db))
|
||||
)
|
||||
assert "Updated semantic field" in text
|
||||
|
||||
def test_set_field_semantics_missing_field_raises_422(self, user, config_manager):
|
||||
from src.api.routes.assistant._dataset_review_dispatch import _dispatch_dataset_review_intent
|
||||
|
||||
session = _make_session_mock(semantic_fields=[], user_id="user-1")
|
||||
intent = {
|
||||
"operation": "dataset_review_set_field_semantics",
|
||||
"entities": {
|
||||
"dataset_review_session_id": "sess-1",
|
||||
"session_version": 5,
|
||||
"field_id": "",
|
||||
},
|
||||
}
|
||||
|
||||
db = MagicMock()
|
||||
repository_cls = "src.api.routes.assistant._dataset_review_dispatch.DatasetReviewSessionRepository"
|
||||
with patch(repository_cls) as mock_repo_cls:
|
||||
mock_repo = MagicMock()
|
||||
mock_repo_cls.return_value = mock_repo
|
||||
mock_repo.load_session_detail.return_value = session
|
||||
mock_repo.require_session_version.return_value = None
|
||||
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
__import__("asyncio").run(_dispatch_dataset_review_intent(intent, user, config_manager, db))
|
||||
assert exc.value.status_code == 422
|
||||
|
||||
def test_session_not_found_raises_404(self, user, config_manager):
|
||||
from src.api.routes.assistant._dataset_review_dispatch import _dispatch_dataset_review_intent
|
||||
|
||||
db = MagicMock()
|
||||
intent = {
|
||||
"operation": "dataset_review_approve_mappings",
|
||||
"entities": {"dataset_review_session_id": "sess-1", "session_version": 5, "mapping_ids": ["m-1"]},
|
||||
}
|
||||
|
||||
repository_cls = "src.api.routes.assistant._dataset_review_dispatch.DatasetReviewSessionRepository"
|
||||
with patch(repository_cls) as mock_repo_cls:
|
||||
mock_repo = MagicMock()
|
||||
mock_repo_cls.return_value = mock_repo
|
||||
mock_repo.load_session_detail.return_value = None
|
||||
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
__import__("asyncio").run(_dispatch_dataset_review_intent(intent, user, config_manager, db))
|
||||
assert exc.value.status_code == 404
|
||||
|
||||
def test_version_conflict_raises_409(self, user, config_manager):
|
||||
from src.api.routes.assistant._dataset_review_dispatch import _dispatch_dataset_review_intent, DatasetReviewSessionVersionConflictError
|
||||
|
||||
db = MagicMock()
|
||||
intent = {
|
||||
"operation": "dataset_review_approve_mappings",
|
||||
"entities": {"dataset_review_session_id": "sess-1", "session_version": 4, "mapping_ids": ["m-1"]},
|
||||
}
|
||||
|
||||
session = _make_session_mock(user_id="user-1")
|
||||
repository_cls = "src.api.routes.assistant._dataset_review_dispatch.DatasetReviewSessionRepository"
|
||||
with patch(repository_cls) as mock_repo_cls:
|
||||
mock_repo = MagicMock()
|
||||
mock_repo_cls.return_value = mock_repo
|
||||
mock_repo.load_session_detail.return_value = session
|
||||
mock_repo.require_session_version.side_effect = DatasetReviewSessionVersionConflictError(
|
||||
session_id="sess-1", expected_version=4, actual_version=5
|
||||
)
|
||||
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
__import__("asyncio").run(_dispatch_dataset_review_intent(intent, user, config_manager, db))
|
||||
assert exc.value.status_code == 409
|
||||
assert "session_version_conflict" in exc.value.detail["error_code"]
|
||||
|
||||
def test_generate_sql_preview_success(self, user, config_manager):
|
||||
from src.api.routes.assistant._dataset_review_dispatch import _dispatch_dataset_review_intent
|
||||
|
||||
session = _make_session_mock(user_id="user-1")
|
||||
db = MagicMock()
|
||||
|
||||
intent = {
|
||||
"operation": "dataset_review_generate_sql_preview",
|
||||
"entities": {
|
||||
"dataset_review_session_id": "sess-1",
|
||||
"session_version": 5,
|
||||
},
|
||||
}
|
||||
|
||||
repository_cls = "src.api.routes.assistant._dataset_review_dispatch.DatasetReviewSessionRepository"
|
||||
orchestrator_cls = "src.api.routes.assistant._dataset_review_dispatch.DatasetReviewOrchestrator"
|
||||
with patch(repository_cls) as mock_repo_cls, patch(orchestrator_cls) as mock_orch_cls:
|
||||
mock_repo = MagicMock()
|
||||
mock_repo_cls.return_value = mock_repo
|
||||
mock_repo.load_session_detail.return_value = session
|
||||
mock_repo.require_session_version.return_value = None
|
||||
mock_repo.bump_session_version.return_value = None
|
||||
mock_repo.db = MagicMock()
|
||||
mock_repo.event_logger = MagicMock()
|
||||
|
||||
mock_orch = MagicMock()
|
||||
mock_orch_cls.return_value = mock_orch
|
||||
preview_result = MagicMock()
|
||||
preview_result.preview = MagicMock()
|
||||
preview_result.preview.preview_status = "READY"
|
||||
mock_orch.prepare_launch_preview.return_value = preview_result
|
||||
|
||||
text, task_id, actions = (
|
||||
__import__("asyncio").run(_dispatch_dataset_review_intent(intent, user, config_manager, db))
|
||||
)
|
||||
assert "READY" in text
|
||||
|
||||
|
||||
class TestDatasetReviewConflictHttpException:
|
||||
"""_dataset_review_conflict_http_exception — version conflict error mapping."""
|
||||
|
||||
def test_conflict_exception(self):
|
||||
from src.api.routes.assistant._dataset_review_dispatch import (
|
||||
_dataset_review_conflict_http_exception,
|
||||
DatasetReviewSessionVersionConflictError,
|
||||
)
|
||||
exc = DatasetReviewSessionVersionConflictError(
|
||||
session_id="sess-1", expected_version=4, actual_version=5
|
||||
)
|
||||
http_exc = _dataset_review_conflict_http_exception(exc)
|
||||
assert http_exc.status_code == 409
|
||||
assert http_exc.detail["error_code"] == "session_version_conflict"
|
||||
assert http_exc.detail["session_id"] == "sess-1"
|
||||
# #endregion Test.Assistant.DatasetReview
|
||||
140
backend/tests/api/test_assistant_dispatch.py
Normal file
140
backend/tests/api/test_assistant_dispatch.py
Normal file
@@ -0,0 +1,140 @@
|
||||
# #region Test.Assistant.Dispatch [C:2] [TYPE Module] [SEMANTICS test,assistant,dispatch,confirmation,clarification]
|
||||
# @BRIEF Tests for _dispatch.py — clarification text, confirmation summary, and dispatch wrapper.
|
||||
# @RELATION BINDS_TO -> [AssistantDispatch]
|
||||
# @TEST_EDGE: clarification_text -> Missing parameter guidance per operation
|
||||
# @TEST_EDGE: confirmation_summary -> Confirmation prompts for supported ops
|
||||
# @TEST_EDGE: confirmation_summary_unknown -> Fallback for unknown ops
|
||||
# @TEST_EDGE: confirmation_summary_migration -> Migration summary with flags and dry-run
|
||||
# @TEST_EDGE: dispatch_intent -> Backward-compat wrapper calls registry
|
||||
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
import pytest
|
||||
|
||||
|
||||
class TestClarificationTextForIntent:
|
||||
"""_clarification_text_for_intent — per-operation guidance texts."""
|
||||
|
||||
@pytest.mark.parametrize("operation,expected_keyword", [
|
||||
("run_llm_validation", "LLM-валидации"),
|
||||
("run_llm_documentation", "документации"),
|
||||
("create_branch", "ветки"),
|
||||
("commit_changes", "коммита"),
|
||||
("deploy_dashboard", "дашборд"),
|
||||
("execute_migration", "source_env"),
|
||||
("run_backup", "окружение"),
|
||||
("unknown_op", "default"),
|
||||
])
|
||||
def test_clarification_text(self, operation, expected_keyword):
|
||||
from src.api.routes.assistant._dispatch import _clarification_text_for_intent
|
||||
|
||||
intent = {"operation": operation}
|
||||
detail = "default clarification needed"
|
||||
text = _clarification_text_for_intent(intent, detail)
|
||||
if expected_keyword == "default":
|
||||
assert text == detail
|
||||
else:
|
||||
assert expected_keyword in text
|
||||
|
||||
|
||||
class TestAsyncConfirmationSummary:
|
||||
"""_async_confirmation_summary — confirmation prompt generation."""
|
||||
|
||||
@pytest.fixture
|
||||
def config_manager(self):
|
||||
cm = MagicMock()
|
||||
cm.get_environments = MagicMock(return_value=[])
|
||||
return cm
|
||||
|
||||
def _make_intent(self, operation, entities=None):
|
||||
return {"operation": operation, "entities": entities or {}}
|
||||
|
||||
@pytest.mark.parametrize("operation,expected_title_word", [
|
||||
("create_branch", "ветки"),
|
||||
("commit_changes", "Коммит"),
|
||||
("deploy_dashboard", "Деплой"),
|
||||
("execute_migration", "Миграция"),
|
||||
("run_backup", "Бэкап"),
|
||||
("run_llm_validation", "LLM-валидация"),
|
||||
("run_llm_documentation", "документации"),
|
||||
("start_maintenance", "Обслуживание"),
|
||||
("end_maintenance", "Завершение"),
|
||||
])
|
||||
def test_known_operations(self, operation, expected_title_word, config_manager):
|
||||
from src.api.routes.assistant._dispatch import _async_confirmation_summary
|
||||
|
||||
db = MagicMock()
|
||||
intent = self._make_intent(operation, {
|
||||
"dashboard_id": "42",
|
||||
"branch_name": "feature/test",
|
||||
"environment": "dev",
|
||||
"source_env": "dev",
|
||||
"target_env": "prod",
|
||||
"tables": ["users", "orders"],
|
||||
"dataset_id": "123",
|
||||
})
|
||||
text = __import__("asyncio").run(_async_confirmation_summary(intent, config_manager, db))
|
||||
assert expected_title_word in text
|
||||
|
||||
def test_unknown_operation(self, config_manager):
|
||||
from src.api.routes.assistant._dispatch import _async_confirmation_summary
|
||||
|
||||
db = MagicMock()
|
||||
intent = self._make_intent("unknown_operation")
|
||||
text = __import__("asyncio").run(_async_confirmation_summary(intent, config_manager, db))
|
||||
assert "Подтвердите" in text
|
||||
|
||||
def test_migration_with_flags(self, config_manager):
|
||||
from src.api.routes.assistant._dispatch import _async_confirmation_summary
|
||||
|
||||
db = MagicMock()
|
||||
intent = self._make_intent("execute_migration", {
|
||||
"dashboard_id": "42",
|
||||
"source_env": "dev",
|
||||
"target_env": "staging",
|
||||
"replace_db_config": True,
|
||||
"fix_cross_filters": True,
|
||||
"dry_run": False,
|
||||
})
|
||||
text = __import__("asyncio").run(_async_confirmation_summary(intent, config_manager, db))
|
||||
assert "маппинг" in text # should mention db mapping
|
||||
assert "cross" in text or "кросс" in text
|
||||
|
||||
def test_migration_with_tables_list(self, config_manager):
|
||||
from src.api.routes.assistant._dispatch import _async_confirmation_summary
|
||||
|
||||
db = MagicMock()
|
||||
intent = self._make_intent("start_maintenance", {
|
||||
"tables": ["users", "orders", "products", "reviews", "logs", "extra"],
|
||||
})
|
||||
text = __import__("asyncio").run(_async_confirmation_summary(intent, config_manager, db))
|
||||
assert "users" in text
|
||||
|
||||
|
||||
class TestDispatchIntent:
|
||||
"""_dispatch_intent — backward-compat wrapper."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dispatches_to_registry(self):
|
||||
from src.api.routes.assistant._dispatch import _dispatch_intent
|
||||
from src.schemas.auth import User
|
||||
from datetime import UTC, datetime
|
||||
|
||||
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 = {"operation": "run_backup", "entities": {"environment": "dev"}}
|
||||
|
||||
with patch("src.api.routes.assistant._dispatch._registry_dispatch",
|
||||
new=AsyncMock(return_value=("Backup started", "task-42", []))) as mock_dispatch:
|
||||
text, task_id, actions = await _dispatch_intent(intent, user, task_manager, config_manager, db)
|
||||
assert text == "Backup started"
|
||||
assert task_id == "task-42"
|
||||
mock_dispatch.assert_called_once_with("run_backup", intent, user, task_manager, config_manager, db)
|
||||
# #endregion Test.Assistant.Dispatch
|
||||
444
backend/tests/api/test_assistant_history.py
Normal file
444
backend/tests/api/test_assistant_history.py
Normal file
@@ -0,0 +1,444 @@
|
||||
# #region Test.Assistant.History [C:2] [TYPE Module] [SEMANTICS test,assistant,history,audit,confirmation]
|
||||
# @BRIEF Tests for _history.py — conversation history, audit trail, confirmation persistence helpers.
|
||||
# @RELATION BINDS_TO -> [AssistantHistory]
|
||||
# @TEST_EDGE: append_history -> In-memory history appends correctly
|
||||
# @TEST_EDGE: persist_message -> DB persistence with rollback on failure
|
||||
# @TEST_EDGE: audit -> In-memory audit recording
|
||||
# @TEST_EDGE: persist_audit -> DB audit persistence
|
||||
# @TEST_EDGE: persist_confirmation -> Confirmation token persistence
|
||||
# @TEST_EDGE: update_confirmation_state -> State transitions
|
||||
# @TEST_EDGE: load_confirmation_from_db -> Confirmation loading
|
||||
# @TEST_EDGE: ensure_conversation -> Conversation ID resolution
|
||||
# @TEST_EDGE: resolve_or_create_conversation -> DB-backed conversation resolution
|
||||
# @TEST_EDGE: cleanup_history_ttl -> TTL-based cleanup
|
||||
# @TEST_EDGE: is_conversation_archived -> Archive detection
|
||||
# @TEST_EDGE: coerce_query_bool -> Boolean coercion
|
||||
|
||||
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 MagicMock, patch
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
class TestAppendHistory:
|
||||
"""_append_history — in-memory conversation buffer."""
|
||||
|
||||
def test_append_new_conversation(self):
|
||||
from datetime import UTC, datetime
|
||||
from src.api.routes.assistant._schemas import CONVERSATIONS
|
||||
from src.api.routes.assistant._history import _append_history
|
||||
|
||||
CONVERSATIONS.clear()
|
||||
_append_history("user-1", "conv-1", "user", "Hello")
|
||||
key = ("user-1", "conv-1")
|
||||
assert key in CONVERSATIONS
|
||||
assert len(CONVERSATIONS[key]) == 1
|
||||
assert CONVERSATIONS[key][0]["role"] == "user"
|
||||
assert CONVERSATIONS[key][0]["text"] == "Hello"
|
||||
|
||||
def test_append_existing_conversation(self):
|
||||
from src.api.routes.assistant._schemas import CONVERSATIONS
|
||||
from src.api.routes.assistant._history import _append_history
|
||||
|
||||
CONVERSATIONS.clear()
|
||||
_append_history("user-1", "conv-1", "user", "First")
|
||||
_append_history("user-1", "conv-1", "assistant", "Response")
|
||||
key = ("user-1", "conv-1")
|
||||
assert len(CONVERSATIONS[key]) == 2
|
||||
assert CONVERSATIONS[key][0]["role"] == "user"
|
||||
assert CONVERSATIONS[key][1]["role"] == "assistant"
|
||||
|
||||
def test_append_with_state_and_task(self):
|
||||
from src.api.routes.assistant._schemas import CONVERSATIONS
|
||||
from src.api.routes.assistant._history import _append_history
|
||||
|
||||
CONVERSATIONS.clear()
|
||||
_append_history("user-1", "conv-1", "assistant", "Done", state="success", task_id="task-42")
|
||||
key = ("user-1", "conv-1")
|
||||
assert CONVERSATIONS[key][0]["state"] == "success"
|
||||
assert CONVERSATIONS[key][0]["task_id"] == "task-42"
|
||||
|
||||
def test_message_id_is_uuid(self):
|
||||
from src.api.routes.assistant._schemas import CONVERSATIONS
|
||||
from src.api.routes.assistant._history import _append_history
|
||||
|
||||
CONVERSATIONS.clear()
|
||||
_append_history("user-1", "conv-1", "user", "test")
|
||||
mid = CONVERSATIONS[("user-1", "conv-1")][0]["message_id"]
|
||||
assert uuid.UUID(mid)
|
||||
|
||||
|
||||
class TestPersistMessage:
|
||||
"""_persist_message — DB persistence."""
|
||||
|
||||
def test_persist_success(self):
|
||||
from src.api.routes.assistant._history import _persist_message
|
||||
|
||||
db = MagicMock()
|
||||
_persist_message(db, "user-1", "conv-1", "user", "Hello", state="active")
|
||||
db.add.assert_called_once()
|
||||
db.commit.assert_called_once()
|
||||
|
||||
def test_persist_with_metadata(self):
|
||||
from src.api.routes.assistant._history import _persist_message
|
||||
|
||||
db = MagicMock()
|
||||
_persist_message(db, "user-1", "conv-1", "user", "test", metadata={"key": "val"})
|
||||
# Verify the payload field was set
|
||||
added = db.add.call_args[0][0]
|
||||
assert added.payload == {"key": "val"}
|
||||
|
||||
def test_persist_failure_rollback(self):
|
||||
from src.api.routes.assistant._history import _persist_message
|
||||
|
||||
db = MagicMock()
|
||||
db.commit.side_effect = Exception("DB error")
|
||||
# Should not raise — just log warning
|
||||
_persist_message(db, "user-1", "conv-1", "user", "Hello")
|
||||
db.rollback.assert_called_once()
|
||||
|
||||
|
||||
class TestAudit:
|
||||
"""_audit — in-memory audit recording."""
|
||||
|
||||
def test_audit_new_user(self):
|
||||
from src.api.routes.assistant._schemas import ASSISTANT_AUDIT
|
||||
from src.api.routes.assistant._history import _audit
|
||||
|
||||
ASSISTANT_AUDIT.clear()
|
||||
_audit("user-1", {"decision": "executed", "task_id": "task-42"})
|
||||
assert "user-1" in ASSISTANT_AUDIT
|
||||
assert len(ASSISTANT_AUDIT["user-1"]) == 1
|
||||
entry = ASSISTANT_AUDIT["user-1"][0]
|
||||
assert entry["decision"] == "executed"
|
||||
assert "created_at" in entry
|
||||
|
||||
def test_audit_append(self):
|
||||
from src.api.routes.assistant._schemas import ASSISTANT_AUDIT
|
||||
from src.api.routes.assistant._history import _audit
|
||||
|
||||
ASSISTANT_AUDIT.clear()
|
||||
_audit("user-1", {"decision": "first"})
|
||||
_audit("user-1", {"decision": "second"})
|
||||
assert len(ASSISTANT_AUDIT["user-1"]) == 2
|
||||
|
||||
|
||||
class TestPersistAudit:
|
||||
"""_persist_audit — DB audit persistence."""
|
||||
|
||||
def test_persist_audit_success(self):
|
||||
from src.api.routes.assistant._history import _persist_audit
|
||||
|
||||
db = MagicMock()
|
||||
_persist_audit(db, "user-1", {"decision": "executed"}, "conv-1")
|
||||
db.add.assert_called_once()
|
||||
db.commit.assert_called_once()
|
||||
|
||||
def test_persist_audit_failure(self):
|
||||
from src.api.routes.assistant._history import _persist_audit
|
||||
|
||||
db = MagicMock()
|
||||
db.commit.side_effect = Exception("DB error")
|
||||
_persist_audit(db, "user-1", {"decision": "executed"}, "conv-1")
|
||||
db.rollback.assert_called_once()
|
||||
|
||||
|
||||
class TestPersistConfirmation:
|
||||
"""_persist_confirmation — Confirmation token DB persistence."""
|
||||
|
||||
def test_persist_confirmation_success(self):
|
||||
from src.api.routes.assistant._history import _persist_confirmation
|
||||
from src.api.routes.assistant._schemas import ConfirmationRecord
|
||||
|
||||
db = MagicMock()
|
||||
record = ConfirmationRecord(
|
||||
id="conf-1",
|
||||
user_id="user-1",
|
||||
conversation_id="conv-1",
|
||||
state="pending",
|
||||
intent={"op": "test"},
|
||||
dispatch={},
|
||||
expires_at=datetime.now(UTC) + timedelta(minutes=5),
|
||||
created_at=datetime.now(UTC),
|
||||
)
|
||||
_persist_confirmation(db, record)
|
||||
db.merge.assert_called_once()
|
||||
db.commit.assert_called_once()
|
||||
|
||||
def test_persist_confirmation_failure(self):
|
||||
from src.api.routes.assistant._history import _persist_confirmation
|
||||
from src.api.routes.assistant._schemas import ConfirmationRecord
|
||||
|
||||
db = MagicMock()
|
||||
db.commit.side_effect = Exception("DB error")
|
||||
record = ConfirmationRecord(
|
||||
id="conf-1",
|
||||
user_id="user-1",
|
||||
conversation_id="conv-1",
|
||||
state="pending",
|
||||
intent={"op": "test"},
|
||||
dispatch={},
|
||||
expires_at=datetime.now(UTC) + timedelta(minutes=5),
|
||||
created_at=datetime.now(UTC),
|
||||
)
|
||||
_persist_confirmation(db, record)
|
||||
db.rollback.assert_called_once()
|
||||
|
||||
|
||||
class TestUpdateConfirmationState:
|
||||
"""_update_confirmation_state — confirmation lifecycle."""
|
||||
|
||||
def test_update_to_consumed(self):
|
||||
from src.api.routes.assistant._history import _update_confirmation_state
|
||||
from src.models.assistant import AssistantConfirmationRecord
|
||||
|
||||
db = MagicMock()
|
||||
row = MagicMock(spec=AssistantConfirmationRecord)
|
||||
row.state = "pending"
|
||||
row.consumed_at = None
|
||||
db.query.return_value.filter.return_value.first.return_value = row
|
||||
|
||||
_update_confirmation_state(db, "conf-1", "consumed")
|
||||
assert row.state == "consumed"
|
||||
assert row.consumed_at is not None
|
||||
db.commit.assert_called_once()
|
||||
|
||||
def test_update_not_found(self):
|
||||
from src.api.routes.assistant._history import _update_confirmation_state
|
||||
|
||||
db = MagicMock()
|
||||
db.query.return_value.filter.return_value.first.return_value = None
|
||||
# Should not raise
|
||||
_update_confirmation_state(db, "conf-nonexistent", "consumed")
|
||||
db.commit.assert_not_called()
|
||||
|
||||
def test_update_failure_rollback(self):
|
||||
from src.api.routes.assistant._history import _update_confirmation_state
|
||||
from src.models.assistant import AssistantConfirmationRecord
|
||||
|
||||
db = MagicMock()
|
||||
row = MagicMock(spec=AssistantConfirmationRecord)
|
||||
row.state = "pending"
|
||||
db.query.return_value.filter.return_value.first.return_value = row
|
||||
db.commit.side_effect = Exception("DB error")
|
||||
|
||||
_update_confirmation_state(db, "conf-1", "consumed")
|
||||
db.rollback.assert_called_once()
|
||||
|
||||
|
||||
class TestLoadConfirmationFromDb:
|
||||
"""_load_confirmation_from_db — confirmation loading."""
|
||||
|
||||
def test_load_found(self):
|
||||
from src.api.routes.assistant._history import _load_confirmation_from_db
|
||||
from src.models.assistant import AssistantConfirmationRecord
|
||||
|
||||
db = MagicMock()
|
||||
mock_row = MagicMock(spec=AssistantConfirmationRecord)
|
||||
mock_row.id = "conf-1"
|
||||
mock_row.user_id = "user-1"
|
||||
mock_row.conversation_id = "conv-1"
|
||||
mock_row.intent = {"op": "test"}
|
||||
mock_row.dispatch = {}
|
||||
mock_row.state = "pending"
|
||||
mock_row.expires_at = datetime.now(UTC) + timedelta(hours=1)
|
||||
mock_row.created_at = datetime.now(UTC)
|
||||
db.query.return_value.filter.return_value.first.return_value = mock_row
|
||||
|
||||
record = _load_confirmation_from_db(db, "conf-1")
|
||||
assert record is not None
|
||||
assert record.id == "conf-1"
|
||||
assert record.user_id == "user-1"
|
||||
assert record.intent == {"op": "test"}
|
||||
|
||||
def test_load_not_found(self):
|
||||
from src.api.routes.assistant._history import _load_confirmation_from_db
|
||||
|
||||
db = MagicMock()
|
||||
db.query.return_value.filter.return_value.first.return_value = None
|
||||
record = _load_confirmation_from_db(db, "conf-nonexistent")
|
||||
assert record is None
|
||||
|
||||
|
||||
class TestEnsureConversation:
|
||||
"""_ensure_conversation — conversation ID resolution."""
|
||||
|
||||
def test_with_explicit_id(self):
|
||||
from src.api.routes.assistant._schemas import USER_ACTIVE_CONVERSATION
|
||||
from src.api.routes.assistant._history import _ensure_conversation
|
||||
|
||||
USER_ACTIVE_CONVERSATION.clear()
|
||||
result = _ensure_conversation("user-1", "conv-1")
|
||||
assert result == "conv-1"
|
||||
assert USER_ACTIVE_CONVERSATION.get("user-1") == "conv-1"
|
||||
|
||||
def test_with_active_conversation(self):
|
||||
from src.api.routes.assistant._schemas import USER_ACTIVE_CONVERSATION
|
||||
from src.api.routes.assistant._history import _ensure_conversation
|
||||
|
||||
USER_ACTIVE_CONVERSATION.clear()
|
||||
USER_ACTIVE_CONVERSATION["user-1"] = "existing-conv"
|
||||
result = _ensure_conversation("user-1", None)
|
||||
assert result == "existing-conv"
|
||||
|
||||
def test_creates_new(self):
|
||||
from src.api.routes.assistant._schemas import USER_ACTIVE_CONVERSATION
|
||||
from src.api.routes.assistant._history import _ensure_conversation
|
||||
|
||||
USER_ACTIVE_CONVERSATION.clear()
|
||||
result = _ensure_conversation("user-1", None)
|
||||
assert result is not None
|
||||
assert uuid.UUID(result)
|
||||
assert USER_ACTIVE_CONVERSATION.get("user-1") == result
|
||||
|
||||
|
||||
class TestResolveOrCreateConversation:
|
||||
"""_resolve_or_create_conversation — DB-backed conversation resolution."""
|
||||
|
||||
def test_with_explicit_id(self):
|
||||
from src.api.routes.assistant._schemas import USER_ACTIVE_CONVERSATION
|
||||
from src.api.routes.assistant._history import _resolve_or_create_conversation
|
||||
|
||||
USER_ACTIVE_CONVERSATION.clear()
|
||||
db = MagicMock()
|
||||
result = _resolve_or_create_conversation("user-1", "conv-1", db)
|
||||
assert result == "conv-1"
|
||||
|
||||
def test_with_active_memory(self):
|
||||
from src.api.routes.assistant._schemas import USER_ACTIVE_CONVERSATION
|
||||
from src.api.routes.assistant._history import _resolve_or_create_conversation
|
||||
|
||||
USER_ACTIVE_CONVERSATION.clear()
|
||||
USER_ACTIVE_CONVERSATION["user-1"] = "mem-conv"
|
||||
db = MagicMock()
|
||||
result = _resolve_or_create_conversation("user-1", None, db)
|
||||
assert result == "mem-conv"
|
||||
|
||||
def test_creates_new_without_db_hit(self):
|
||||
from src.api.routes.assistant._schemas import USER_ACTIVE_CONVERSATION
|
||||
from src.api.routes.assistant._history import _resolve_or_create_conversation
|
||||
|
||||
USER_ACTIVE_CONVERSATION.clear()
|
||||
db = MagicMock()
|
||||
db.query.return_value.filter.return_value.order_by.return_value.first.return_value = None
|
||||
result = _resolve_or_create_conversation("user-1", None, db)
|
||||
assert result is not None
|
||||
assert uuid.UUID(result)
|
||||
|
||||
|
||||
class TestCleanupHistoryTtl:
|
||||
"""_cleanup_history_ttl — TTL-based history cleanup."""
|
||||
|
||||
def test_cleanup_deletes_expired_db_rows(self):
|
||||
from src.api.routes.assistant._history import _cleanup_history_ttl
|
||||
|
||||
db = MagicMock()
|
||||
db.query.return_value.filter.return_value = MagicMock()
|
||||
db.query.return_value.filter.return_value.filter.return_value.delete.return_value = 5
|
||||
|
||||
_cleanup_history_ttl(db, "user-1")
|
||||
db.commit.assert_called_once()
|
||||
|
||||
def test_cleanup_failure_rollback(self):
|
||||
from src.api.routes.assistant._history import _cleanup_history_ttl
|
||||
|
||||
db = MagicMock()
|
||||
# The second filter (for TTL) raises
|
||||
q = MagicMock()
|
||||
q.filter.return_value = q # chain .filter().filter()
|
||||
q.delete.side_effect = Exception("DB error")
|
||||
db.query.return_value = q
|
||||
|
||||
_cleanup_history_ttl(db, "user-1")
|
||||
db.rollback.assert_called_once()
|
||||
|
||||
def test_cleanup_clears_stale_in_memory(self):
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from src.api.routes.assistant._schemas import CONVERSATIONS
|
||||
from src.api.routes.assistant._history import _cleanup_history_ttl
|
||||
|
||||
CONVERSATIONS.clear()
|
||||
old_time = datetime.now() - timedelta(days=400)
|
||||
# Add a stale entry
|
||||
CONVERSATIONS[("user-1", "old-conv")] = [
|
||||
{"created_at": old_time, "role": "user", "text": "old"}
|
||||
]
|
||||
# Add a recent entry
|
||||
CONVERSATIONS[("user-1", "new-conv")] = [
|
||||
{"created_at": datetime.now(UTC), "role": "user", "text": "new"}
|
||||
]
|
||||
|
||||
db = MagicMock()
|
||||
db.query.return_value.filter.return_value = MagicMock()
|
||||
db.query.return_value.filter.return_value.filter.return_value.delete.return_value = 0
|
||||
db.query.return_value.filter.return_value.filter.return_value = db.query.return_value.filter.return_value
|
||||
|
||||
_cleanup_history_ttl(db, "user-1")
|
||||
|
||||
# Old conversation should be removed
|
||||
assert ("user-1", "old-conv") not in CONVERSATIONS
|
||||
# New conversation should be kept
|
||||
assert ("user-1", "new-conv") in CONVERSATIONS
|
||||
|
||||
|
||||
class TestIsConversationArchived:
|
||||
"""_is_conversation_archived — archive detection."""
|
||||
|
||||
def test_updated_at_none(self):
|
||||
from src.api.routes.assistant._history import _is_conversation_archived
|
||||
assert _is_conversation_archived(None) is False
|
||||
|
||||
def test_recent_conversation(self):
|
||||
from datetime import datetime
|
||||
from src.api.routes.assistant._history import _is_conversation_archived
|
||||
recent = datetime.now()
|
||||
assert _is_conversation_archived(recent) is False
|
||||
|
||||
def test_old_conversation(self):
|
||||
from datetime import datetime, timedelta
|
||||
from src.api.routes.assistant._history import _is_conversation_archived
|
||||
old = datetime.now() - timedelta(days=100)
|
||||
assert _is_conversation_archived(old) is True
|
||||
|
||||
def test_aware_datetime(self):
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from src.api.routes.assistant._history import _is_conversation_archived
|
||||
old = datetime.now(UTC) - timedelta(days=100)
|
||||
assert _is_conversation_archived(old) is True
|
||||
|
||||
|
||||
class TestCoerceQueryBool:
|
||||
"""_coerce_query_bool — boolean normalization."""
|
||||
|
||||
def test_bool_values(self):
|
||||
from src.api.routes.assistant._history import _coerce_query_bool
|
||||
assert _coerce_query_bool(True) is True
|
||||
assert _coerce_query_bool(False) is False
|
||||
|
||||
@pytest.mark.parametrize("val,expected", [
|
||||
("1", True),
|
||||
("true", True),
|
||||
("yes", True),
|
||||
("on", True),
|
||||
("0", False),
|
||||
("false", False),
|
||||
("no", False),
|
||||
("off", False),
|
||||
("", False),
|
||||
])
|
||||
def test_string_values(self, val, expected):
|
||||
from src.api.routes.assistant._history import _coerce_query_bool
|
||||
assert _coerce_query_bool(val) is expected
|
||||
|
||||
def test_non_bool_non_string(self):
|
||||
from src.api.routes.assistant._history import _coerce_query_bool
|
||||
assert _coerce_query_bool(None) is False
|
||||
assert _coerce_query_bool(42) is False
|
||||
assert _coerce_query_bool([]) is False
|
||||
# #endregion Test.Assistant.History
|
||||
356
backend/tests/api/test_assistant_llm_planner_intent.py
Normal file
356
backend/tests/api/test_assistant_llm_planner_intent.py
Normal file
@@ -0,0 +1,356 @@
|
||||
# #region Test.Assistant.LlmPlannerIntent [C:2] [TYPE Module] [SEMANTICS test,assistant,llm,planner,intent,authorization]
|
||||
# @BRIEF Tests for _llm_planner_intent.py — LLM-based intent planning and authorization.
|
||||
# @RELATION BINDS_TO -> [AssistantLlmPlannerIntent]
|
||||
# @TEST_EDGE: plan_intent_empty_tools -> Returns None when tools list empty
|
||||
# @TEST_EDGE: plan_intent_no_provider -> Returns None when no LLM provider
|
||||
# @TEST_EDGE: plan_intent_llm_failure -> Returns None on LLM error
|
||||
# @TEST_EDGE: plan_intent_clarify -> Returns clarify intent for ambiguous input
|
||||
# @TEST_EDGE: plan_intent_valid_operation -> Valid operation mapped correctly
|
||||
# @TEST_EDGE: plan_intent_production_env -> Production env sets dangerous risk
|
||||
# @TEST_EDGE: authorize_intent -> Permission checks pass or raise 403
|
||||
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
|
||||
class TestPlanIntentWithLlm:
|
||||
"""_plan_intent_with_llm — LLM-based intent planning."""
|
||||
|
||||
@pytest.fixture
|
||||
def db(self):
|
||||
return MagicMock()
|
||||
|
||||
@pytest.fixture
|
||||
def config_manager(self):
|
||||
cm = MagicMock()
|
||||
cm.get_config.return_value.settings.llm = {
|
||||
"assistant_planner_provider": "openai",
|
||||
"assistant_planner_model": "gpt-4",
|
||||
}
|
||||
cm.get_environments.return_value = []
|
||||
return cm
|
||||
|
||||
def test_empty_tools_returns_none(self, db, config_manager):
|
||||
from src.api.routes.assistant._llm_planner_intent import _plan_intent_with_llm
|
||||
|
||||
result = __import__("asyncio").run(_plan_intent_with_llm("test", [], db, config_manager))
|
||||
assert result is None
|
||||
|
||||
def test_no_provider_returns_none(self, db, config_manager):
|
||||
from src.api.routes.assistant._llm_planner_intent import _plan_intent_with_llm
|
||||
|
||||
tools = [{"operation": "run_backup", "domain": "backup"}]
|
||||
with patch("src.api.routes.assistant._llm_planner_intent.LLMProviderService") as mock_svc:
|
||||
mock_svc.return_value.get_all_providers.return_value = []
|
||||
result = __import__("asyncio").run(
|
||||
_plan_intent_with_llm("test", tools, db, config_manager)
|
||||
)
|
||||
assert result is None
|
||||
|
||||
def test_no_api_key_returns_none(self, db, config_manager):
|
||||
from src.api.routes.assistant._llm_planner_intent import _plan_intent_with_llm
|
||||
|
||||
tools = [{"operation": "run_backup", "domain": "backup"}]
|
||||
provider_mock = MagicMock()
|
||||
provider_mock.id = "p-1"
|
||||
provider_mock.provider_type = "openai"
|
||||
provider_mock.base_url = None
|
||||
provider_mock.default_model = "gpt-4"
|
||||
|
||||
with patch("src.api.routes.assistant._llm_planner_intent.LLMProviderService") as mock_svc_cls:
|
||||
mock_svc = MagicMock()
|
||||
mock_svc_cls.return_value = mock_svc
|
||||
mock_svc.get_all_providers.return_value = [provider_mock]
|
||||
mock_svc.get_decrypted_api_key.return_value = None
|
||||
|
||||
with patch("src.api.routes.assistant._llm_planner_intent._resolve_provider_id") as mock_resolve:
|
||||
mock_resolve.return_value = "p-1"
|
||||
result = __import__("asyncio").run(
|
||||
_plan_intent_with_llm("test", tools, db, config_manager)
|
||||
)
|
||||
assert result is None
|
||||
|
||||
def test_llm_failure_returns_none(self, db, config_manager):
|
||||
from src.api.routes.assistant._llm_planner_intent import _plan_intent_with_llm
|
||||
|
||||
tools = [{"operation": "run_backup", "domain": "backup"}]
|
||||
provider_mock = MagicMock()
|
||||
provider_mock.id = "p-1"
|
||||
provider_mock.provider_type = "openai"
|
||||
provider_mock.base_url = None
|
||||
provider_mock.default_model = "gpt-4"
|
||||
|
||||
with patch("src.api.routes.assistant._llm_planner_intent.LLMProviderService") as mock_svc_cls, \
|
||||
patch("src.api.routes.assistant._llm_planner_intent.LLMClient") as mock_client_cls:
|
||||
mock_svc = MagicMock()
|
||||
mock_svc_cls.return_value = mock_svc
|
||||
mock_svc.get_all_providers.return_value = [provider_mock]
|
||||
mock_svc.get_decrypted_api_key.return_value = "sk-test"
|
||||
|
||||
mock_client = AsyncMock()
|
||||
mock_client_cls.return_value = mock_client
|
||||
mock_client.get_json_completion.side_effect = Exception("LLM unavailable")
|
||||
|
||||
with patch("src.api.routes.assistant._llm_planner_intent._resolve_provider_id") as mock_resolve:
|
||||
mock_resolve.return_value = "p-1"
|
||||
result = __import__("asyncio").run(
|
||||
_plan_intent_with_llm("test", tools, db, config_manager)
|
||||
)
|
||||
assert result is None
|
||||
|
||||
def test_clarify_response(self, db, config_manager):
|
||||
from src.api.routes.assistant._llm_planner_intent import _plan_intent_with_llm
|
||||
|
||||
tools = [{"operation": "run_backup", "domain": "backup"}]
|
||||
provider_mock = MagicMock()
|
||||
provider_mock.id = "p-1"
|
||||
provider_mock.provider_type = "openai"
|
||||
provider_mock.base_url = None
|
||||
provider_mock.default_model = "gpt-4"
|
||||
|
||||
with patch("src.api.routes.assistant._llm_planner_intent.LLMProviderService") as mock_svc_cls, \
|
||||
patch("src.api.routes.assistant._llm_planner_intent.LLMClient") as mock_client_cls:
|
||||
mock_svc = MagicMock()
|
||||
mock_svc_cls.return_value = mock_svc
|
||||
mock_svc.get_all_providers.return_value = [provider_mock]
|
||||
mock_svc.get_decrypted_api_key.return_value = "sk-test"
|
||||
|
||||
mock_client = AsyncMock()
|
||||
mock_client_cls.return_value = mock_client
|
||||
mock_client.get_json_completion.return_value = {
|
||||
"operation": "clarify",
|
||||
"domain": "unknown",
|
||||
"confidence": 0.3,
|
||||
"clarification": "Что именно вы хотите сделать?",
|
||||
"entities": {},
|
||||
}
|
||||
|
||||
with patch("src.api.routes.assistant._llm_planner_intent._resolve_provider_id") as mock_resolve:
|
||||
mock_resolve.return_value = "p-1"
|
||||
result = __import__("asyncio").run(
|
||||
_plan_intent_with_llm("test", tools, db, config_manager)
|
||||
)
|
||||
assert result is not None
|
||||
assert result["operation"] == "clarify"
|
||||
assert "Что именно" in result.get("clarification", "")
|
||||
|
||||
def test_invalid_operation_returns_none(self, db, config_manager):
|
||||
from src.api.routes.assistant._llm_planner_intent import _plan_intent_with_llm
|
||||
|
||||
tools = [{"operation": "run_backup", "domain": "backup"}]
|
||||
provider_mock = MagicMock()
|
||||
provider_mock.id = "p-1"
|
||||
provider_mock.provider_type = "openai"
|
||||
provider_mock.base_url = None
|
||||
provider_mock.default_model = "gpt-4"
|
||||
|
||||
with patch("src.api.routes.assistant._llm_planner_intent.LLMProviderService") as mock_svc_cls, \
|
||||
patch("src.api.routes.assistant._llm_planner_intent.LLMClient") as mock_client_cls:
|
||||
mock_svc = MagicMock()
|
||||
mock_svc_cls.return_value = mock_svc
|
||||
mock_svc.get_all_providers.return_value = [provider_mock]
|
||||
mock_svc.get_decrypted_api_key.return_value = "sk-test"
|
||||
|
||||
mock_client = AsyncMock()
|
||||
mock_client_cls.return_value = mock_client
|
||||
mock_client.get_json_completion.return_value = {
|
||||
"operation": "nonexistent_op",
|
||||
"domain": "backup",
|
||||
"confidence": 0.9,
|
||||
"entities": {},
|
||||
}
|
||||
|
||||
with patch("src.api.routes.assistant._llm_planner_intent._resolve_provider_id") as mock_resolve:
|
||||
mock_resolve.return_value = "p-1"
|
||||
result = __import__("asyncio").run(
|
||||
_plan_intent_with_llm("test", tools, db, config_manager)
|
||||
)
|
||||
assert result is None
|
||||
|
||||
def test_non_dict_response_returns_none(self, db, config_manager):
|
||||
from src.api.routes.assistant._llm_planner_intent import _plan_intent_with_llm
|
||||
|
||||
tools = [{"operation": "run_backup", "domain": "backup"}]
|
||||
provider_mock = MagicMock()
|
||||
provider_mock.id = "p-1"
|
||||
provider_mock.provider_type = "openai"
|
||||
provider_mock.base_url = None
|
||||
provider_mock.default_model = "gpt-4"
|
||||
|
||||
with patch("src.api.routes.assistant._llm_planner_intent.LLMProviderService") as mock_svc_cls, \
|
||||
patch("src.api.routes.assistant._llm_planner_intent.LLMClient") as mock_client_cls:
|
||||
mock_svc = MagicMock()
|
||||
mock_svc_cls.return_value = mock_svc
|
||||
mock_svc.get_all_providers.return_value = [provider_mock]
|
||||
mock_svc.get_decrypted_api_key.return_value = "sk-test"
|
||||
|
||||
mock_client = AsyncMock()
|
||||
mock_client_cls.return_value = mock_client
|
||||
mock_client.get_json_completion.return_value = "not a dict"
|
||||
|
||||
with patch("src.api.routes.assistant._llm_planner_intent._resolve_provider_id") as mock_resolve:
|
||||
mock_resolve.return_value = "p-1"
|
||||
result = __import__("asyncio").run(
|
||||
_plan_intent_with_llm("test", tools, db, config_manager)
|
||||
)
|
||||
assert result is None
|
||||
|
||||
def test_valid_operation_with_defaults(self, db, config_manager):
|
||||
from src.api.routes.assistant._llm_planner_intent import _plan_intent_with_llm
|
||||
|
||||
tools = [{
|
||||
"operation": "run_backup",
|
||||
"domain": "backup",
|
||||
"risk_level": "guarded",
|
||||
"requires_confirmation": False,
|
||||
"defaults": {"environment": "dev"},
|
||||
}]
|
||||
provider_mock = MagicMock()
|
||||
provider_mock.id = "p-1"
|
||||
provider_mock.provider_type = "openai"
|
||||
provider_mock.base_url = None
|
||||
provider_mock.default_model = "gpt-4"
|
||||
|
||||
with patch("src.api.routes.assistant._llm_planner_intent.LLMProviderService") as mock_svc_cls, \
|
||||
patch("src.api.routes.assistant._llm_planner_intent.LLMClient") as mock_client_cls:
|
||||
mock_svc = MagicMock()
|
||||
mock_svc_cls.return_value = mock_svc
|
||||
mock_svc.get_all_providers.return_value = [provider_mock]
|
||||
mock_svc.get_decrypted_api_key.return_value = "sk-test"
|
||||
|
||||
mock_client = AsyncMock()
|
||||
mock_client_cls.return_value = mock_client
|
||||
mock_client.get_json_completion.return_value = {
|
||||
"operation": "run_backup",
|
||||
"domain": "backup",
|
||||
"confidence": 0.9,
|
||||
"entities": {},
|
||||
"risk_level": "guarded",
|
||||
"requires_confirmation": False,
|
||||
}
|
||||
|
||||
with patch("src.api.routes.assistant._llm_planner_intent._resolve_provider_id") as mock_resolve, \
|
||||
patch("src.api.routes.assistant._llm_planner_intent._coerce_intent_entities") as mock_coerce, \
|
||||
patch("src.api.routes.assistant._llm_planner_intent.normalize_llm_settings") as mock_norm:
|
||||
mock_resolve.return_value = "p-1"
|
||||
mock_coerce.return_value = {
|
||||
"domain": "backup",
|
||||
"operation": "run_backup",
|
||||
"entities": {},
|
||||
"confidence": 0.9,
|
||||
"risk_level": "guarded",
|
||||
"requires_confirmation": False,
|
||||
}
|
||||
mock_norm.return_value = {
|
||||
"assistant_planner_provider": "openai",
|
||||
"assistant_planner_model": "gpt-4",
|
||||
}
|
||||
result = __import__("asyncio").run(
|
||||
_plan_intent_with_llm("test", tools, db, config_manager)
|
||||
)
|
||||
assert result is not None
|
||||
assert result["operation"] == "run_backup"
|
||||
# Default should be applied
|
||||
assert result["entities"].get("environment") == "dev"
|
||||
|
||||
def test_production_deploy_sets_dangerous(self, db, config_manager):
|
||||
from src.api.routes.assistant._llm_planner_intent import _plan_intent_with_llm
|
||||
|
||||
# Environments must be JSON-serializable dicts
|
||||
config_manager.get_environments.return_value = [
|
||||
type('Env', (), {'id': 'prod', 'name': 'PROD'})(),
|
||||
]
|
||||
tools = [{
|
||||
"operation": "deploy_dashboard",
|
||||
"domain": "git",
|
||||
"risk_level": "guarded",
|
||||
"requires_confirmation": False,
|
||||
}]
|
||||
provider_mock = MagicMock()
|
||||
provider_mock.id = "p-1"
|
||||
provider_mock.provider_type = "openai"
|
||||
provider_mock.base_url = None
|
||||
provider_mock.default_model = "gpt-4"
|
||||
|
||||
with patch("src.api.routes.assistant._llm_planner_intent.LLMProviderService") as mock_svc_cls, \
|
||||
patch("src.api.routes.assistant._llm_planner_intent.LLMClient") as mock_client_cls:
|
||||
mock_svc = MagicMock()
|
||||
mock_svc_cls.return_value = mock_svc
|
||||
mock_svc.get_all_providers.return_value = [provider_mock]
|
||||
mock_svc.get_decrypted_api_key.return_value = "sk-test"
|
||||
|
||||
mock_client = AsyncMock()
|
||||
mock_client_cls.return_value = mock_client
|
||||
mock_client.get_json_completion.return_value = {
|
||||
"operation": "deploy_dashboard",
|
||||
"domain": "git",
|
||||
"confidence": 0.9,
|
||||
"entities": {"environment": "prod"},
|
||||
"risk_level": "guarded",
|
||||
"requires_confirmation": False,
|
||||
}
|
||||
|
||||
with patch("src.api.routes.assistant._llm_planner_intent._resolve_provider_id") as mock_resolve, \
|
||||
patch("src.api.routes.assistant._llm_planner_intent._coerce_intent_entities") as mock_coerce, \
|
||||
patch("src.api.routes.assistant._llm_planner_intent._is_production_env") as mock_is_prod, \
|
||||
patch("src.api.routes.assistant._llm_planner_intent.normalize_llm_settings") as mock_norm:
|
||||
mock_resolve.return_value = "p-1"
|
||||
mock_coerce.return_value = {
|
||||
"domain": "git",
|
||||
"operation": "deploy_dashboard",
|
||||
"entities": {"environment": "prod"},
|
||||
"confidence": 0.9,
|
||||
"risk_level": "guarded",
|
||||
"requires_confirmation": False,
|
||||
}
|
||||
mock_is_prod.return_value = True
|
||||
mock_norm.return_value = {
|
||||
"assistant_planner_provider": "openai",
|
||||
"assistant_planner_model": "gpt-4",
|
||||
}
|
||||
result = __import__("asyncio").run(
|
||||
_plan_intent_with_llm("test", tools, db, config_manager)
|
||||
)
|
||||
assert result is not None
|
||||
assert result["risk_level"] == "dangerous"
|
||||
assert result["requires_confirmation"] is True
|
||||
|
||||
|
||||
class TestAuthorizeIntent:
|
||||
"""_authorize_intent — permission checks."""
|
||||
|
||||
def test_authorized(self):
|
||||
from src.api.routes.assistant._llm_planner_intent import _authorize_intent
|
||||
from src.schemas.auth import User
|
||||
from datetime import UTC, datetime
|
||||
|
||||
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=[])
|
||||
intent = {"operation": "run_backup"}
|
||||
|
||||
with patch("src.api.routes.assistant._llm_planner_intent.get_permission_checks") as mock_checks, \
|
||||
patch("src.api.routes.assistant._llm_planner_intent._check_any_permission") as mock_check:
|
||||
mock_checks.return_value = {"run_backup": [("backup", "WRITE")]}
|
||||
_authorize_intent(intent, user)
|
||||
mock_check.assert_called_once_with(user, [("backup", "WRITE")])
|
||||
|
||||
def test_no_checks_skips(self):
|
||||
from src.api.routes.assistant._llm_planner_intent import _authorize_intent
|
||||
from src.schemas.auth import User
|
||||
from datetime import UTC, datetime
|
||||
|
||||
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=[])
|
||||
intent = {"operation": "show_capabilities"}
|
||||
|
||||
with patch("src.api.routes.assistant._llm_planner_intent.get_permission_checks") as mock_checks:
|
||||
mock_checks.return_value = {} # no checks for this op
|
||||
# Should not raise
|
||||
_authorize_intent(intent, user)
|
||||
# #endregion Test.Assistant.LlmPlannerIntent
|
||||
384
backend/tests/api/test_assistant_tool_maintenance.py
Normal file
384
backend/tests/api/test_assistant_tool_maintenance.py
Normal file
@@ -0,0 +1,384 @@
|
||||
# #region Test.Assistant.ToolMaintenance [C:2] [TYPE Module] [SEMANTICS test,assistant,maintenance,events]
|
||||
# @BRIEF Tests for _tool_maintenance.py — maintenance handlers.
|
||||
# @RELATION BINDS_TO -> [AssistantToolMaintenance]
|
||||
# @TEST_EDGE: list_maintenance_events -> Lists active and completed events
|
||||
# @TEST_EDGE: list_maintenance_no_events -> Empty maintenance response
|
||||
# @TEST_EDGE: start_maintenance -> Start with tables and start_time
|
||||
# @TEST_EDGE: start_maintenance_missing_params -> 422 on missing params
|
||||
# @TEST_EDGE: start_maintenance_invalid_time -> 422 on bad datetime
|
||||
# @TEST_EDGE: start_maintenance_too_many_tables -> 422 on >100 tables
|
||||
# @TEST_EDGE: end_maintenance_by_id -> End specific event
|
||||
# @TEST_EDGE: end_maintenance_not_found -> 404 on missing event
|
||||
# @TEST_EDGE: end_maintenance_all_active -> End all active events
|
||||
|
||||
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
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
|
||||
def _make_maintenance_event(event_id="evt-1", status="ACTIVE", tables=None, environment_id="env-1"):
|
||||
ev = MagicMock()
|
||||
ev.id = event_id
|
||||
ev.status = status
|
||||
ev.tables = tables or ["users"]
|
||||
ev.environment_id = environment_id
|
||||
ev.created_at = datetime.now(UTC)
|
||||
ev.end_time = None
|
||||
return ev
|
||||
|
||||
|
||||
class TestHandleListMaintenanceEvents:
|
||||
"""handle_list_maintenance_events — listing events."""
|
||||
|
||||
@pytest.fixture
|
||||
def intent(self):
|
||||
return {"entities": {}}
|
||||
|
||||
@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=[])
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_lists_active_and_completed(self, intent, current_user):
|
||||
from src.api.routes.assistant._tool_maintenance import handle_list_maintenance_events
|
||||
from src.models.maintenance import MaintenanceEventStatus
|
||||
|
||||
task_manager = MagicMock()
|
||||
task_manager.create_task = AsyncMock(return_value=MagicMock(id="task-99"))
|
||||
config_manager = MagicMock()
|
||||
db = MagicMock()
|
||||
|
||||
active_events = [_make_maintenance_event("evt-1", MaintenanceEventStatus.ACTIVE)]
|
||||
completed_events = [_make_maintenance_event("evt-2", MaintenanceEventStatus.COMPLETED)]
|
||||
|
||||
# Mock the queries
|
||||
db.query.return_value.filter.return_value = MagicMock()
|
||||
db.query.return_value.filter.return_value.filter.return_value.all.return_value = []
|
||||
|
||||
# Separate mocks for each query
|
||||
def query_side_effect(model):
|
||||
q = MagicMock()
|
||||
if model.__name__ == "MaintenanceEvent":
|
||||
# Active events
|
||||
q.filter.return_value = MagicMock()
|
||||
q.filter.return_value.filter.return_value.all.side_effect = [
|
||||
[], # expired
|
||||
active_events, # active
|
||||
]
|
||||
q.filter.return_value.filter.return_value.order_by.return_value.limit.return_value.all.return_value = completed_events
|
||||
q.filter.return_value.filter.return_value.order_by.return_value.all.return_value = active_events
|
||||
q.filter.return_value.in_.return_value = q.filter.return_value
|
||||
q.filter.return_value.isnot.return_value = q.filter.return_value
|
||||
return q
|
||||
|
||||
db.query.side_effect = query_side_effect
|
||||
|
||||
# Mock dashboard state count
|
||||
db.query.return_value.filter.return_value.filter.return_value = MagicMock()
|
||||
db.query.return_value.filter.return_value.filter.return_value.count.return_value = 3
|
||||
|
||||
text, task_id, actions = await handle_list_maintenance_events(
|
||||
intent, current_user, task_manager, config_manager, db
|
||||
)
|
||||
assert "Активные" in text or "Завершённые" in text
|
||||
assert actions is not None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_events(self, intent, current_user):
|
||||
from src.api.routes.assistant._tool_maintenance import handle_list_maintenance_events
|
||||
|
||||
task_manager = MagicMock()
|
||||
task_manager.create_task = AsyncMock(return_value=MagicMock(id="task-99"))
|
||||
config_manager = MagicMock()
|
||||
db = MagicMock()
|
||||
|
||||
def query_side_effect(model):
|
||||
q = MagicMock()
|
||||
if model.__name__ == "MaintenanceEvent":
|
||||
q.filter.return_value = MagicMock()
|
||||
q.filter.return_value.filter.return_value.all.return_value = []
|
||||
q.filter.return_value.filter.return_value.order_by.return_value.all.return_value = []
|
||||
q.filter.return_value.filter.return_value.order_by.return_value.limit.return_value.all.return_value = []
|
||||
else:
|
||||
q.filter.return_value.filter.return_value.count.return_value = 0
|
||||
return q
|
||||
|
||||
db.query.side_effect = query_side_effect
|
||||
|
||||
text, task_id, actions = await handle_list_maintenance_events(
|
||||
intent, current_user, task_manager, config_manager, db
|
||||
)
|
||||
assert "Нет событий" in text
|
||||
|
||||
|
||||
class TestHandleStartMaintenance:
|
||||
"""handle_start_maintenance — starting maintenance events."""
|
||||
|
||||
@pytest.fixture
|
||||
def intent(self):
|
||||
return {"entities": {}}
|
||||
|
||||
@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=[])
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_success(self, intent, current_user):
|
||||
from src.api.routes.assistant._tool_maintenance import handle_start_maintenance
|
||||
|
||||
task_manager = MagicMock()
|
||||
task_manager.create_task = AsyncMock(return_value=MagicMock(id="task-42"))
|
||||
config_manager = MagicMock()
|
||||
db = MagicMock()
|
||||
|
||||
intent["entities"] = {
|
||||
"tables": ["users", "orders"],
|
||||
"start_time": "2025-06-15T10:00",
|
||||
"end_time": "2025-06-15T12:00",
|
||||
"message": "Scheduled maintenance",
|
||||
}
|
||||
|
||||
text, task_id, actions = await handle_start_maintenance(
|
||||
intent, current_user, task_manager, config_manager, db
|
||||
)
|
||||
assert "Обслуживание запущено" in text
|
||||
assert task_id == "task-42"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_without_end_time(self, intent, current_user):
|
||||
from src.api.routes.assistant._tool_maintenance import handle_start_maintenance
|
||||
|
||||
task_manager = MagicMock()
|
||||
task_manager.create_task = AsyncMock(return_value=MagicMock(id="task-43"))
|
||||
config_manager = MagicMock()
|
||||
db = MagicMock()
|
||||
|
||||
intent["entities"] = {
|
||||
"tables": ["users"],
|
||||
"start_time": "2025-06-15T10:00",
|
||||
}
|
||||
|
||||
text, task_id, actions = await handle_start_maintenance(
|
||||
intent, current_user, task_manager, config_manager, db
|
||||
)
|
||||
assert "Обслуживание запущено" in text
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_missing_params_raises_422(self, intent, current_user):
|
||||
from src.api.routes.assistant._tool_maintenance import handle_start_maintenance
|
||||
|
||||
task_manager = MagicMock()
|
||||
config_manager = MagicMock()
|
||||
db = MagicMock()
|
||||
|
||||
intent["entities"] = {}
|
||||
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await handle_start_maintenance(
|
||||
intent, current_user, task_manager, config_manager, db
|
||||
)
|
||||
assert exc.value.status_code == 422
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_missing_tables_raises_422(self, intent, current_user):
|
||||
from src.api.routes.assistant._tool_maintenance import handle_start_maintenance
|
||||
|
||||
task_manager = MagicMock()
|
||||
config_manager = MagicMock()
|
||||
db = MagicMock()
|
||||
|
||||
intent["entities"] = {"start_time": "2025-06-15T10:00"}
|
||||
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await handle_start_maintenance(
|
||||
intent, current_user, task_manager, config_manager, db
|
||||
)
|
||||
assert exc.value.status_code == 422
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_invalid_time_raises_422(self, intent, current_user):
|
||||
from src.api.routes.assistant._tool_maintenance import handle_start_maintenance
|
||||
|
||||
task_manager = MagicMock()
|
||||
config_manager = MagicMock()
|
||||
db = MagicMock()
|
||||
|
||||
intent["entities"] = {
|
||||
"tables": ["users"],
|
||||
"start_time": "not-a-date",
|
||||
}
|
||||
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await handle_start_maintenance(
|
||||
intent, current_user, task_manager, config_manager, db
|
||||
)
|
||||
assert exc.value.status_code == 422
|
||||
assert "start_time" in exc.value.detail.lower()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_invalid_end_time_raises_422(self, intent, current_user):
|
||||
from src.api.routes.assistant._tool_maintenance import handle_start_maintenance
|
||||
|
||||
task_manager = MagicMock()
|
||||
config_manager = MagicMock()
|
||||
db = MagicMock()
|
||||
|
||||
intent["entities"] = {
|
||||
"tables": ["users"],
|
||||
"start_time": "2025-06-15T10:00",
|
||||
"end_time": "not-a-date",
|
||||
}
|
||||
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await handle_start_maintenance(
|
||||
intent, current_user, task_manager, config_manager, db
|
||||
)
|
||||
assert exc.value.status_code == 422
|
||||
assert "end_time" in exc.value.detail.lower()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_too_many_tables_raises_422(self, intent, current_user):
|
||||
from src.api.routes.assistant._tool_maintenance import handle_start_maintenance
|
||||
|
||||
task_manager = MagicMock()
|
||||
config_manager = MagicMock()
|
||||
db = MagicMock()
|
||||
|
||||
intent["entities"] = {
|
||||
"tables": [f"table_{i}" for i in range(101)],
|
||||
"start_time": "2025-06-15T10:00",
|
||||
}
|
||||
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await handle_start_maintenance(
|
||||
intent, current_user, task_manager, config_manager, db
|
||||
)
|
||||
assert exc.value.status_code == 422
|
||||
assert "100" in exc.value.detail
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_with_tables_as_string(self, intent, current_user):
|
||||
from src.api.routes.assistant._tool_maintenance import handle_start_maintenance
|
||||
|
||||
task_manager = MagicMock()
|
||||
task_manager.create_task = AsyncMock(return_value=MagicMock(id="task-44"))
|
||||
config_manager = MagicMock()
|
||||
db = MagicMock()
|
||||
|
||||
intent["entities"] = {
|
||||
"tables": "users,orders,products",
|
||||
"start_time": "2025-06-15T10:00",
|
||||
}
|
||||
|
||||
text, task_id, actions = await handle_start_maintenance(
|
||||
intent, current_user, task_manager, config_manager, db
|
||||
)
|
||||
assert "Обслуживание запущено" in text
|
||||
# Should have split the string
|
||||
task_params = task_manager.create_task.call_args[1]["params"]
|
||||
assert task_params["tables"] == ["users", "orders", "products"]
|
||||
|
||||
|
||||
class TestHandleEndMaintenance:
|
||||
"""handle_end_maintenance — ending maintenance events."""
|
||||
|
||||
@pytest.fixture
|
||||
def intent(self):
|
||||
return {"entities": {}}
|
||||
|
||||
@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=[])
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_end_by_id(self, intent, current_user):
|
||||
from src.api.routes.assistant._tool_maintenance import handle_end_maintenance
|
||||
|
||||
task_manager = MagicMock()
|
||||
task_manager.create_task = AsyncMock(return_value=MagicMock(id="task-45"))
|
||||
config_manager = MagicMock()
|
||||
db = MagicMock()
|
||||
|
||||
event = _make_maintenance_event("evt-1")
|
||||
db.query.return_value.filter.return_value.first.return_value = event
|
||||
|
||||
intent["entities"] = {"event_id": "evt-1"}
|
||||
|
||||
text, task_id, actions = await handle_end_maintenance(
|
||||
intent, current_user, task_manager, config_manager, db
|
||||
)
|
||||
assert "Завершение" in text
|
||||
assert task_id == "task-45"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_end_not_found_raises_404(self, intent, current_user):
|
||||
from src.api.routes.assistant._tool_maintenance import handle_end_maintenance
|
||||
|
||||
task_manager = MagicMock()
|
||||
config_manager = MagicMock()
|
||||
db = MagicMock()
|
||||
|
||||
db.query.return_value.filter.return_value.first.return_value = None
|
||||
|
||||
intent["entities"] = {"event_id": "evt-nonexistent"}
|
||||
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await handle_end_maintenance(
|
||||
intent, current_user, task_manager, config_manager, db
|
||||
)
|
||||
assert exc.value.status_code == 404
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_end_all_active_success(self, intent, current_user):
|
||||
from src.api.routes.assistant._tool_maintenance import handle_end_maintenance
|
||||
from src.models.maintenance import MaintenanceEventStatus
|
||||
|
||||
task_manager = MagicMock()
|
||||
task_manager.create_task = AsyncMock(return_value=MagicMock(id="task-46"))
|
||||
config_manager = MagicMock()
|
||||
db = MagicMock()
|
||||
|
||||
active_events = [
|
||||
_make_maintenance_event("evt-1", MaintenanceEventStatus.ACTIVE),
|
||||
_make_maintenance_event("evt-2", MaintenanceEventStatus.PARTIAL),
|
||||
]
|
||||
db.query.return_value.filter.return_value.filter.return_value.all.return_value = active_events
|
||||
|
||||
intent["entities"] = {} # No event_id -> end all
|
||||
|
||||
text, task_id, actions = await handle_end_maintenance(
|
||||
intent, current_user, task_manager, config_manager, db
|
||||
)
|
||||
assert "Завершение" in text
|
||||
assert "2" in text # Should mention 2 events
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_end_all_no_active(self, intent, current_user):
|
||||
from src.api.routes.assistant._tool_maintenance import handle_end_maintenance
|
||||
|
||||
task_manager = MagicMock()
|
||||
config_manager = MagicMock()
|
||||
db = MagicMock()
|
||||
|
||||
db.query.return_value.filter.return_value.filter.return_value.all.return_value = []
|
||||
|
||||
intent["entities"] = {} # No event_id
|
||||
|
||||
text, task_id, actions = await handle_end_maintenance(
|
||||
intent, current_user, task_manager, config_manager, db
|
||||
)
|
||||
assert "Нет активных" in text
|
||||
assert task_id is None
|
||||
# #endregion Test.Assistant.ToolMaintenance
|
||||
Reference in New Issue
Block a user