test: add 7 more test modules — assistant cmd parser, history, dispatch, admin routes, dataset review, maintenance + semantic resolver

This commit is contained in:
2026-06-15 16:06:18 +03:00
parent f75c15dbc6
commit fc7e6c3c15
9 changed files with 3241 additions and 0 deletions

View 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

View 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

View 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

View 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

View 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

View 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

View 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