from __future__ import annotations import asyncio from datetime import UTC, datetime, timedelta from unittest.mock import AsyncMock import pytest from yuxi.channels.adapters.slack.approval import ( ApprovalManager, ApprovalRequest, ApprovalStatus, ) from yuxi.channels.adapters.slack.approval_native import ( ExecApprovalConfig, is_authorized_sender, normalize_approver_id, resolve_origin_target, ) class TestApprovalStatus: def test_values(self): assert ApprovalStatus.PENDING == "pending" assert ApprovalStatus.APPROVED == "approved" assert ApprovalStatus.REJECTED == "rejected" assert ApprovalStatus.EXPIRED == "expired" assert ApprovalStatus.EXECUTED == "executed" assert ApprovalStatus.FAILED == "failed" class TestApprovalRequest: def test_creation(self): req = ApprovalRequest( approval_id="req001", title="Test Approval", detail="Approve this action", command="/do_something", chat_id="C001", message_ts="123.456", created_by="U001", ) assert req.approval_id == "req001" assert req.title == "Test Approval" assert req.detail == "Approve this action" assert req.command == "/do_something" assert req.chat_id == "C001" assert req.message_ts == "123.456" assert req.created_by == "U001" assert req.status == ApprovalStatus.PENDING assert req.approved_by == "" assert req.exec_result == "" assert req.exec_error == "" def test_default_values(self): req = ApprovalRequest( approval_id="req002", title="Test", detail="Desc", command="/cmd", chat_id="C001", message_ts="123.456", ) assert req.created_by == "" assert req.approved_by == "" assert req.status == ApprovalStatus.PENDING assert req.exec_result == "" assert req.exec_error == "" def test_is_pending(self): req = ApprovalRequest("r1", "T", "D", "/c", "C1", "ts") assert req.is_pending is True req.status = ApprovalStatus.APPROVED assert req.is_pending is False def test_is_resolved(self): req = ApprovalRequest("r1", "T", "D", "/c", "C1", "ts") assert req.is_resolved is False req.status = ApprovalStatus.APPROVED assert req.is_resolved is True req.status = ApprovalStatus.REJECTED assert req.is_resolved is True req.status = ApprovalStatus.EXPIRED assert req.is_resolved is True class TestApprovalManager: @pytest.fixture def manager(self): return ApprovalManager(ttl_seconds=600) def test_create_approval(self, manager): req = manager.create_approval("Title", "Detail", "/cmd", "C001", "123.456", "U001") assert req.title == "Title" assert req.status == ApprovalStatus.PENDING assert len(req.approval_id) == 12 assert manager.get_approval(req.approval_id) is req def test_create_approval_unique_ids(self, manager): req1 = manager.create_approval("T1", "D1", "/c1", "C1", "ts") req2 = manager.create_approval("T2", "D2", "/c2", "C2", "ts") assert req1.approval_id != req2.approval_id def test_get_approval_not_found(self, manager): assert manager.get_approval("nonexistent") is None def test_approve(self, manager): req = manager.create_approval("T", "D", "/c", "C1", "ts") updated = manager.approve(req.approval_id, "U001") assert updated is not None assert updated.status == ApprovalStatus.APPROVED assert updated.approved_by == "U001" def test_approve_not_found(self, manager): assert manager.approve("nonexistent") is None def test_approve_already_resolved(self, manager): req = manager.create_approval("T", "D", "/c", "C1", "ts") manager.approve(req.approval_id, "U001") result = manager.approve(req.approval_id, "U002") assert result is None def test_reject(self, manager): req = manager.create_approval("T", "D", "/c", "C1", "ts") updated = manager.reject(req.approval_id) assert updated is not None assert updated.status == ApprovalStatus.REJECTED def test_reject_not_found(self, manager): assert manager.reject("nonexistent") is None def test_mark_executed(self, manager): req = manager.create_approval("T", "D", "/c", "C1", "ts") manager.approve(req.approval_id) updated = manager.mark_executed(req.approval_id, "Execution successful") assert updated is not None assert updated.status == ApprovalStatus.EXECUTED assert updated.exec_result == "Execution successful" def test_mark_executed_not_approved(self, manager): req = manager.create_approval("T", "D", "/c", "C1", "ts") result = manager.mark_executed(req.approval_id) assert result is None def test_mark_failed(self, manager): req = manager.create_approval("T", "D", "/c", "C1", "ts") manager.approve(req.approval_id) updated = manager.mark_failed(req.approval_id, "Something went wrong") assert updated is not None assert updated.status == ApprovalStatus.FAILED assert updated.exec_error == "Something went wrong" def test_mark_failed_not_approved(self, manager): req = manager.create_approval("T", "D", "/c", "C1", "ts") result = manager.mark_failed(req.approval_id) assert result is None @pytest.mark.asyncio async def test_execute_approval_success(self, manager): async def handler(**kwargs): return {"success": True} manager.register_exec_handler("/cmd", handler) req = manager.create_approval("T", "D", "/cmd arg1", "C1", "ts") manager.approve(req.approval_id) result = await manager.execute_approval(req.approval_id) assert result["success"] is True @pytest.mark.asyncio async def test_execute_approval_no_handler(self, manager): req = manager.create_approval("T", "D", "/unknown_cmd", "C1", "ts") manager.approve(req.approval_id) result = await manager.execute_approval(req.approval_id) assert result["success"] is False assert "No exec handler" in result["error"] @pytest.mark.asyncio async def test_execute_approval_not_approved(self, manager): req = manager.create_approval("T", "D", "/cmd", "C1", "ts") result = await manager.execute_approval(req.approval_id) assert result["success"] is False @pytest.mark.asyncio async def test_execute_approval_handler_error(self, manager): async def failing_handler(**kwargs): raise ValueError("boom") manager.register_exec_handler("/cmd", failing_handler) req = manager.create_approval("T", "D", "/cmd", "C1", "ts") manager.approve(req.approval_id) result = await manager.execute_approval(req.approval_id) assert result["success"] is False assert "boom" in result["error"] def test_list_pending_all(self, manager): manager.create_approval("T1", "D1", "/c1", "C1", "ts") manager.create_approval("T2", "D2", "/c2", "C1", "ts") pending = manager.list_pending() assert len(pending) == 2 def test_list_pending_by_chat(self, manager): manager.create_approval("T1", "D1", "/c1", "C1", "ts") manager.create_approval("T2", "D2", "/c2", "C2", "ts") pending = manager.list_pending(chat_id="C1") assert len(pending) == 1 assert pending[0]["title"] == "T1" def test_list_pending_excludes_resolved(self, manager): req = manager.create_approval("T1", "D1", "/c1", "C1", "ts") manager.approve(req.approval_id) pending = manager.list_pending() assert len(pending) == 0 def test_cleanup_expired_resolved(self, manager): manager._ttl_seconds = 0.01 req = manager.create_approval("T", "D", "/c", "C1", "ts") req.created_at = datetime.now(UTC) - timedelta(seconds=10) manager.reject(req.approval_id) manager.create_approval("T2", "D2", "/c2", "C2", "ts") manager._cleanup_expired() assert manager.get_approval(req.approval_id) is None @pytest.mark.asyncio async def test_execute_approval_not_found(self, manager): result = await manager.execute_approval("nonexistent") assert result["success"] is False def test_register_exec_handler_prefix_match(self, manager): async def handler(**kwargs): return {"text": kwargs["command"]} manager.register_exec_handler("/cmd", handler) req = manager.create_approval("T", "D", "/cmd arg1", "C1", "ts") manager.approve(req.approval_id) import asyncio result = asyncio.get_event_loop().run_until_complete(manager.execute_approval(req.approval_id)) assert result["success"] is True class TestNormalizeApproverId: def test_slack_format(self): assert normalize_approver_id("slack:U12345") == "U12345" def test_mention_format(self): assert normalize_approver_id("<@U12345>") == "U12345" def test_plain_id(self): assert normalize_approver_id("U12345") == "U12345" def test_strips_whitespace(self): assert normalize_approver_id(" U12345 ") == "U12345" def test_unknown_format_passthrough(self): assert normalize_approver_id("unknown_id") == "unknown_id" class TestExecApprovalConfig: def test_defaults(self): config = ExecApprovalConfig() assert config.enabled == "auto" assert config.approvers == [] assert config.normalized_approvers == [] def test_with_approvers(self): config = ExecApprovalConfig(approvers=["slack:U001", "U002", "<@U003>"]) assert config.normalized_approvers == ["U001", "U002", "U003"] def test_is_approver_in_list(self): config = ExecApprovalConfig(approvers=["U001", "U002"]) assert config.is_approver("U001") is True assert config.is_approver("U003") is False def test_is_approver_empty_list(self): config = ExecApprovalConfig(approvers=[]) assert config.is_approver("U001") is True def test_is_approver_with_format(self): config = ExecApprovalConfig(approvers=["U001"]) assert config.is_approver("<@U001>") is True assert config.is_approver("slack:U001") is True def test_from_config_none(self): config = ExecApprovalConfig.from_config(None) assert config.enabled == "auto" assert config.approvers == [] def test_from_config_empty(self): config = ExecApprovalConfig.from_config({}) assert config.enabled == "auto" assert config.approvers == [] def test_from_config_with_values(self): config = ExecApprovalConfig.from_config( { "execApprovals": { "enabled": "manual", "approvers": ["U001", "U002"], } } ) assert config.enabled == "manual" assert config.approvers == ["U001", "U002"] class TestResolveOriginTarget: def test_turn_source(self): assert resolve_origin_target("turn", "session", "fallback") == "turn" def test_session_target(self): assert resolve_origin_target("", "session", "fallback") == "session" def test_fallback_target(self): assert resolve_origin_target("", "", "fallback") == "fallback" def test_all_empty(self): assert resolve_origin_target("", "", "") == "" class TestIsAuthorizedSender: def test_authorized(self): config = ExecApprovalConfig(approvers=["U001"]) assert is_authorized_sender("U001", config) is True def test_not_authorized(self): config = ExecApprovalConfig(approvers=["U001"]) assert is_authorized_sender("U002", config) is False def test_empty_approvers_authorizes_all(self): config = ExecApprovalConfig(approvers=[]) assert is_authorized_sender("U001", config) is True