PhoneWork/tests/test_sdk_migration.py
Yuyao Huang 26746335c4 refactor(commands): rename status command to list and update related references
feat(feishu): update card schema to 2.0 and simplify approval card structure

docs(feishu): add documentation for card json schema 2.0 changes
2026-04-01 14:50:30 +08:00

900 lines
31 KiB
Python

"""Unit tests for the SDK migration (secretary model).
Tests cover:
- SDKSession lifecycle, message buffering, get_progress, approval
- sdk_hooks audit + deny
- SessionManager new methods (send_message, send_and_wait, get_progress, interrupt, approve)
- audit.py new functions (log_tool_use, log_permission_decision)
- bot/commands.py new commands (//stop, //progress, //perm auto)
- orchestrator/tools.py new tools (SessionProgressTool, InterruptConversationTool)
- bot/handler.py text approval fallback
"""
from __future__ import annotations
import asyncio
import json
import time
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock, patch, PropertyMock
import pytest
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
def tmp_audit_dir(tmp_path):
"""Redirect audit logs to a temp directory."""
import agent.audit as audit_mod
original = audit_mod.AUDIT_DIR
audit_mod.AUDIT_DIR = tmp_path / "audit"
yield tmp_path / "audit"
audit_mod.AUDIT_DIR = original
@pytest.fixture
def mock_sdk_client():
"""Create a mock ClaudeSDKClient that yields controllable messages."""
client = AsyncMock()
client.connect = AsyncMock()
client.disconnect = AsyncMock()
client.query = AsyncMock()
client.interrupt = AsyncMock()
client.set_permission_mode = AsyncMock()
return client
@pytest.fixture
def mock_feishu():
"""Mock all Feishu send functions."""
captured = {"texts": [], "cards": [], "markdowns": []}
async def _send_text(rid, rtype, text):
captured["texts"].append(text)
async def _send_card(rid, rtype, card):
captured["cards"].append(card)
async def _send_markdown(rid, rtype, content):
captured["markdowns"].append(content)
with patch("bot.feishu.send_text", side_effect=_send_text), \
patch("bot.feishu.send_card", side_effect=_send_card), \
patch("bot.feishu.send_markdown", side_effect=_send_markdown), \
patch("bot.handler.send_text", side_effect=_send_text), \
patch("bot.handler.send_markdown", side_effect=_send_markdown):
yield captured
# ===========================================================================
# 1. SDKSession unit tests
# ===========================================================================
class TestSDKSessionProgress:
"""Test SDKSession.get_progress() with buffered messages."""
def test_initial_progress_is_idle(self):
from agent.sdk_session import SDKSession
s = SDKSession("c1", "/tmp", "u1")
p = s.get_progress()
assert p.busy is False
assert p.current_prompt == ""
assert p.text_messages == []
assert p.tool_calls == []
def test_progress_after_manual_state_change(self):
from agent.sdk_session import SDKSession
s = SDKSession("c1", "/tmp", "u1")
s._busy = True
s._current_prompt = "write hello.py"
s._started_at = time.time() - 5
s._text_buffer = ["I'll create the file", "Done"]
s._tool_buffer = ["Write(hello.py)", "Read(hello.py)"]
p = s.get_progress()
assert p.busy is True
assert p.current_prompt == "write hello.py"
assert p.elapsed_seconds >= 4
assert len(p.text_messages) == 2
assert len(p.tool_calls) == 2
def test_buffer_limits(self):
from agent.sdk_session import SDKSession
s = SDKSession("c1", "/tmp", "u1")
for i in range(30):
s._text_buffer.append(f"text-{i}")
if len(s._text_buffer) > s.MAX_BUFFER_TEXTS:
s._text_buffer.pop(0)
assert len(s._text_buffer) == s.MAX_BUFFER_TEXTS
assert s._text_buffer[0] == "text-10"
def test_progress_pending_approval(self):
from agent.sdk_session import SDKSession
s = SDKSession("c1", "/tmp", "u1")
s._pending_approval_desc = "Bash: `rm -rf /tmp/test`"
p = s.get_progress()
assert p.pending_approval == "Bash: `rm -rf /tmp/test`"
class TestSDKSessionApproval:
"""Test the approval mechanism."""
@pytest.mark.asyncio
async def test_approve_resolves_future(self):
from agent.sdk_session import SDKSession
s = SDKSession("c1", "/tmp", "u1")
loop = asyncio.get_running_loop()
s._pending_approval = loop.create_future()
await s.approve(True)
assert s._pending_approval.result() is True
@pytest.mark.asyncio
async def test_approve_deny(self):
from agent.sdk_session import SDKSession
s = SDKSession("c1", "/tmp", "u1")
loop = asyncio.get_running_loop()
s._pending_approval = loop.create_future()
await s.approve(False)
assert s._pending_approval.result() is False
@pytest.mark.asyncio
async def test_approve_no_pending_is_noop(self):
from agent.sdk_session import SDKSession
s = SDKSession("c1", "/tmp", "u1")
# Should not raise
await s.approve(True)
class TestSDKSessionClose:
"""Test clean shutdown."""
@pytest.mark.asyncio
async def test_close_without_start(self):
from agent.sdk_session import SDKSession
s = SDKSession("c1", "/tmp", "u1")
# Should not raise
await s.close()
assert s.client is None
@pytest.mark.asyncio
async def test_close_disconnects_client(self, mock_sdk_client):
from agent.sdk_session import SDKSession
s = SDKSession("c1", "/tmp", "u1")
s.client = mock_sdk_client
s._message_loop_task = None
await s.close()
mock_sdk_client.disconnect.assert_awaited_once()
assert s.client is None
class TestSDKSessionSend:
"""Test send() and send_and_wait() with mocked client."""
@pytest.mark.asyncio
async def test_send_returns_immediately(self):
from agent.sdk_session import SDKSession
s = SDKSession("c1", "/tmp", "u1", chat_id="chat1")
# Provide a mock client that has receive_messages yielding nothing
mock_client = AsyncMock()
mock_client.query = AsyncMock()
async def _empty_messages():
return
yield # make it an async generator
mock_client.receive_messages = _empty_messages
s.client = mock_client
result = await s.send("hello")
assert "已开始执行" in result
assert s._busy is True
assert s._current_prompt == "hello"
# Cleanup
if s._message_loop_task:
s._message_loop_task.cancel()
try:
await s._message_loop_task
except asyncio.CancelledError:
pass
class TestSDKSessionFormatSummary:
"""Test _format_tool_summary."""
def test_bash_summary(self):
from agent.sdk_session import SDKSession
s = SDKSession("c1", "/tmp", "u1")
result = s._format_tool_summary("Bash", {"command": "ls -la"})
assert "`ls -la`" in result
def test_edit_summary(self):
from agent.sdk_session import SDKSession
s = SDKSession("c1", "/tmp", "u1")
result = s._format_tool_summary("Edit", {"file_path": "/tmp/test.py"})
assert "test.py" in result
def test_other_summary_truncated(self):
from agent.sdk_session import SDKSession
s = SDKSession("c1", "/tmp", "u1")
result = s._format_tool_summary("CustomTool", {"key": "x" * 500})
assert len(result) <= 200
# ===========================================================================
# 2. sdk_hooks tests
# ===========================================================================
class TestSDKHooks:
@pytest.mark.asyncio
async def test_audit_hook_logs(self, tmp_audit_dir):
from agent.sdk_hooks import audit_hook
input_data = {
"session_id": "test-session",
"tool_name": "Bash",
"tool_input": {"command": "echo hello"},
"tool_response": "hello\n",
}
result = await audit_hook(input_data, "tu-1", {"signal": None})
assert result == {}
# Check JSONL was written
log_file = tmp_audit_dir / "test-session.jsonl"
assert log_file.exists()
entry = json.loads(log_file.read_text().strip())
assert entry["type"] == "tool_use"
assert entry["tool_name"] == "Bash"
@pytest.mark.asyncio
async def test_deny_dangerous_rm_rf(self):
from agent.sdk_hooks import deny_dangerous_hook
input_data = {
"tool_name": "Bash",
"tool_input": {"command": "rm -rf /"},
}
result = await deny_dangerous_hook(input_data, None, {"signal": None})
assert result.get("hookSpecificOutput", {}).get("permissionDecision") == "deny"
@pytest.mark.asyncio
async def test_deny_allows_safe_commands(self):
from agent.sdk_hooks import deny_dangerous_hook
input_data = {
"tool_name": "Bash",
"tool_input": {"command": "ls -la /tmp"},
}
result = await deny_dangerous_hook(input_data, None, {"signal": None})
assert result == {}
@pytest.mark.asyncio
async def test_deny_ignores_non_bash(self):
from agent.sdk_hooks import deny_dangerous_hook
input_data = {
"tool_name": "Edit",
"tool_input": {"file_path": "/etc/passwd"},
}
result = await deny_dangerous_hook(input_data, None, {"signal": None})
assert result == {}
def test_build_hooks_returns_expected_structure(self):
from agent.sdk_hooks import build_hooks
hooks = build_hooks("test-conv")
assert "PostToolUse" in hooks
assert "PreToolUse" in hooks
assert len(hooks["PostToolUse"]) == 1
assert len(hooks["PreToolUse"]) == 1
# ===========================================================================
# 3. manager tests (new methods)
# ===========================================================================
class TestSessionManagerNew:
@pytest.fixture(autouse=True)
def _reset(self):
from agent.manager import manager
manager._sessions.clear()
yield
manager._sessions.clear()
@pytest.mark.asyncio
async def test_create_session_no_cc_timeout(self):
from agent.manager import manager, Session
s = await manager.create("c1", "/tmp/test", owner_id="u1", chat_id="chat1")
assert s.conv_id == "c1"
assert s.chat_id == "chat1"
assert not hasattr(s, "cc_timeout") or "cc_timeout" not in s.to_dict()
@pytest.mark.asyncio
async def test_get_progress_no_session(self):
from agent.manager import manager
result = manager.get_progress("nonexistent")
assert result is None
@pytest.mark.asyncio
async def test_get_progress_no_sdk_session(self):
from agent.manager import manager
await manager.create("c1", "/tmp/test", owner_id="u1")
p = manager.get_progress("c1", user_id="u1")
assert p is not None
assert p.busy is False
@pytest.mark.asyncio
async def test_interrupt_no_sdk_session(self):
from agent.manager import manager
await manager.create("c1", "/tmp/test", owner_id="u1")
result = await manager.interrupt("c1", user_id="u1")
assert result is False
@pytest.mark.asyncio
async def test_approve_no_sdk_session(self):
from agent.manager import manager
await manager.create("c1", "/tmp/test", owner_id="u1")
# Should not raise
await manager.approve("c1", True)
@pytest.mark.asyncio
async def test_close_with_sdk_session(self):
from agent.manager import manager
from agent.sdk_session import SDKSession
await manager.create("c1", "/tmp/test", owner_id="u1")
mock_sdk = MagicMock(spec=SDKSession)
mock_sdk.close = AsyncMock()
manager._sessions["c1"].sdk_session = mock_sdk
result = await manager.close("c1", user_id="u1")
assert result is True
mock_sdk.close.assert_awaited_once()
@pytest.mark.asyncio
async def test_set_permission_mode_with_sdk_session(self):
from agent.manager import manager
from agent.sdk_session import SDKSession
await manager.create("c1", "/tmp/test", owner_id="u1")
mock_sdk = MagicMock(spec=SDKSession)
mock_sdk.set_permission_mode = AsyncMock()
manager._sessions["c1"].sdk_session = mock_sdk
manager.set_permission_mode("c1", "acceptEdits", user_id="u1")
assert manager._sessions["c1"].permission_mode == "acceptEdits"
def test_list_sessions_includes_busy(self):
from agent.manager import manager, Session
from agent.sdk_session import SDKSession
session = Session(conv_id="c1", cwd="/tmp", owner_id="u1")
mock_sdk = MagicMock(spec=SDKSession)
mock_sdk._busy = True
session.sdk_session = mock_sdk
manager._sessions["c1"] = session
result = manager.list_sessions()
assert result[0]["busy"] is True
def test_session_from_dict_strips_old_fields(self):
from agent.manager import Session
old_data = {
"conv_id": "c1",
"cwd": "/tmp",
"owner_id": "u1",
"cc_session_id": "old-uuid",
"started": True,
"cc_timeout": 300.0,
"last_activity": 0.0,
"idle_timeout": 1800,
"permission_mode": "default",
}
s = Session.from_dict(old_data)
assert s.conv_id == "c1"
assert not hasattr(s, "cc_session_id")
def test_session_to_dict_excludes_sdk_session(self):
from agent.manager import Session
s = Session(conv_id="c1", cwd="/tmp")
s.sdk_session = MagicMock()
d = s.to_dict()
assert "sdk_session" not in d
# ===========================================================================
# 4. audit tests (new functions)
# ===========================================================================
class TestAuditNewFunctions:
def test_log_tool_use(self, tmp_audit_dir):
from agent.audit import log_tool_use
log_tool_use(
session_id="s1",
tool_name="Bash",
tool_input={"command": "echo hello"},
tool_response="hello\n",
)
log_file = tmp_audit_dir / "s1.jsonl"
assert log_file.exists()
entry = json.loads(log_file.read_text().strip())
assert entry["type"] == "tool_use"
assert entry["tool_name"] == "Bash"
def test_log_permission_decision_approved(self, tmp_audit_dir):
from agent.audit import log_permission_decision
log_permission_decision(
conv_id="c1",
tool_name="Bash",
tool_input={"command": "rm test.txt"},
approved=True,
)
log_file = tmp_audit_dir / "c1.jsonl"
assert log_file.exists()
entry = json.loads(log_file.read_text().strip())
assert entry["type"] == "permission_decision"
assert entry["approved"] is True
def test_log_permission_decision_denied(self, tmp_audit_dir):
from agent.audit import log_permission_decision
log_permission_decision(
conv_id="c1",
tool_name="Write",
tool_input={"file_path": "/etc/passwd"},
approved=False,
)
log_file = tmp_audit_dir / "c1.jsonl"
entry = json.loads(log_file.read_text().strip())
assert entry["approved"] is False
# ===========================================================================
# 5. bot/commands.py new commands
# ===========================================================================
class TestNewCommands:
@pytest.fixture(autouse=True)
def _reset(self):
from agent.manager import manager
from orchestrator.agent import agent
from orchestrator.tools import set_current_user, set_current_chat
manager._sessions.clear()
agent._active_conv.clear()
agent._passthrough.clear()
set_current_user(None)
set_current_chat(None)
yield
manager._sessions.clear()
agent._active_conv.clear()
@pytest.mark.asyncio
async def test_stop_no_active_session(self):
from bot.commands import handle_command
result = await handle_command("u1", "//stop")
assert "No active session" in result
@pytest.mark.asyncio
async def test_stop_with_session_no_sdk(self):
from bot.commands import handle_command
from agent.manager import manager, Session
from orchestrator.agent import agent
from orchestrator.tools import set_current_user
set_current_user("u1")
session = Session(conv_id="c1", cwd="/tmp", owner_id="u1")
manager._sessions["c1"] = session
agent._active_conv["u1"] = "c1"
result = await handle_command("u1", "//stop")
assert "No active task" in result
@pytest.mark.asyncio
async def test_progress_no_session(self):
from bot.commands import handle_command
result = await handle_command("u1", "//progress")
assert "No active session" in result
@pytest.mark.asyncio
async def test_progress_idle_session(self):
from bot.commands import handle_command
from agent.manager import manager, Session
from orchestrator.agent import agent
from orchestrator.tools import set_current_user
set_current_user("u1")
session = Session(conv_id="c1", cwd="/tmp", owner_id="u1")
manager._sessions["c1"] = session
agent._active_conv["u1"] = "c1"
result = await handle_command("u1", "//progress")
assert "空闲" in result
@pytest.mark.asyncio
async def test_progress_busy_session(self):
from bot.commands import handle_command
from agent.manager import manager, Session
from agent.sdk_session import SDKSession
from orchestrator.agent import agent
from orchestrator.tools import set_current_user
set_current_user("u1")
session = Session(conv_id="c1", cwd="/tmp", owner_id="u1")
sdk = SDKSession("c1", "/tmp", "u1")
sdk._busy = True
sdk._started_at = time.time() - 10
sdk._tool_buffer = ["Bash(echo hello)", "Read(test.py)"]
session.sdk_session = sdk
manager._sessions["c1"] = session
agent._active_conv["u1"] = "c1"
result = await handle_command("u1", "//progress")
assert "执行中" in result
assert "Bash" in result
@pytest.mark.asyncio
async def test_progress_with_pending_approval(self):
from bot.commands import handle_command
from agent.manager import manager, Session
from agent.sdk_session import SDKSession
from orchestrator.agent import agent
from orchestrator.tools import set_current_user
set_current_user("u1")
session = Session(conv_id="c1", cwd="/tmp", owner_id="u1")
sdk = SDKSession("c1", "/tmp", "u1")
sdk._busy = True
sdk._started_at = time.time() - 5
sdk._pending_approval_desc = "Bash: `rm test`"
session.sdk_session = sdk
manager._sessions["c1"] = session
agent._active_conv["u1"] = "c1"
result = await handle_command("u1", "//progress")
assert "审批" in result
@pytest.mark.asyncio
async def test_perm_auto_alias(self):
from bot.commands import handle_command
from agent.manager import manager, Session
from orchestrator.agent import agent
from orchestrator.tools import set_current_user
set_current_user("u1")
session = Session(conv_id="c1", cwd="/tmp", owner_id="u1")
manager._sessions["c1"] = session
agent._active_conv["u1"] = "c1"
result = await handle_command("u1", "//perm auto")
assert "auto" in result
assert manager._sessions["c1"].permission_mode == "dontAsk"
# ===========================================================================
# 6. orchestrator/tools.py new tools
# ===========================================================================
class TestNewTools:
@pytest.fixture(autouse=True)
def _reset(self):
from agent.manager import manager
from orchestrator.tools import set_current_user, set_current_chat
manager._sessions.clear()
set_current_user("u1")
set_current_chat("chat1")
yield
manager._sessions.clear()
set_current_user(None)
set_current_chat(None)
@pytest.mark.asyncio
async def test_session_progress_not_found(self):
from orchestrator.tools import SessionProgressTool
tool = SessionProgressTool()
result = await tool._arun("nonexistent")
data = json.loads(result)
assert "error" in data
@pytest.mark.asyncio
async def test_session_progress_idle(self):
from orchestrator.tools import SessionProgressTool
from agent.manager import manager, Session
session = Session(conv_id="c1", cwd="/tmp", owner_id="u1")
manager._sessions["c1"] = session
tool = SessionProgressTool()
result = await tool._arun("c1")
data = json.loads(result)
assert data["busy"] is False
@pytest.mark.asyncio
async def test_session_progress_busy(self):
from orchestrator.tools import SessionProgressTool
from agent.manager import manager, Session
from agent.sdk_session import SDKSession
session = Session(conv_id="c1", cwd="/tmp", owner_id="u1")
sdk = SDKSession("c1", "/tmp", "u1")
sdk._busy = True
sdk._started_at = time.time() - 30
sdk._current_prompt = "fix the bug"
sdk._tool_buffer = ["Read(main.py)", "Edit(main.py)"]
session.sdk_session = sdk
manager._sessions["c1"] = session
tool = SessionProgressTool()
result = await tool._arun("c1")
data = json.loads(result)
assert data["busy"] is True
assert data["elapsed_seconds"] >= 29
assert "Edit" in str(data["recent_tools"])
@pytest.mark.asyncio
async def test_interrupt_not_found(self):
from orchestrator.tools import InterruptConversationTool
tool = InterruptConversationTool()
result = await tool._arun("nonexistent")
assert "not found" in result
@pytest.mark.asyncio
async def test_interrupt_no_active_task(self):
from orchestrator.tools import InterruptConversationTool
from agent.manager import manager, Session
session = Session(conv_id="c1", cwd="/tmp", owner_id="u1")
manager._sessions["c1"] = session
tool = InterruptConversationTool()
result = await tool._arun("c1")
assert "No active task" in result
# ===========================================================================
# 7. bot/handler.py text approval fallback
# ===========================================================================
class TestTextApprovalFallback:
@pytest.fixture(autouse=True)
def _reset(self):
from agent.manager import manager
from orchestrator.agent import agent
from orchestrator.tools import set_current_chat
manager._sessions.clear()
agent._active_conv.clear()
set_current_chat(None)
yield
manager._sessions.clear()
agent._active_conv.clear()
@pytest.mark.asyncio
async def test_y_resolves_pending_approval(self, mock_feishu):
from agent.manager import manager, Session
from agent.sdk_session import SDKSession
from orchestrator.agent import agent
from orchestrator.tools import set_current_chat
set_current_chat("chat1")
session = Session(conv_id="c1", cwd="/tmp", owner_id="u1")
sdk = SDKSession("c1", "/tmp", "u1", chat_id="chat1")
loop = asyncio.get_running_loop()
sdk._pending_approval = loop.create_future()
session.sdk_session = sdk
manager._sessions["c1"] = session
agent._active_conv["u1"] = "c1"
from bot.handler import _process_message
await _process_message("u1", "chat1", "y")
assert sdk._pending_approval.done()
assert sdk._pending_approval.result() is True
# handler calls send_text which is mocked separately from send_markdown
assert any("批准" in t for t in mock_feishu["texts"])
@pytest.mark.asyncio
async def test_n_denies_pending_approval(self, mock_feishu):
from agent.manager import manager, Session
from agent.sdk_session import SDKSession
from orchestrator.agent import agent
from orchestrator.tools import set_current_chat
set_current_chat("chat1")
session = Session(conv_id="c1", cwd="/tmp", owner_id="u1")
sdk = SDKSession("c1", "/tmp", "u1", chat_id="chat1")
loop = asyncio.get_running_loop()
sdk._pending_approval = loop.create_future()
session.sdk_session = sdk
manager._sessions["c1"] = session
agent._active_conv["u1"] = "c1"
from bot.handler import _process_message
await _process_message("u1", "chat1", "n")
assert sdk._pending_approval.done()
assert sdk._pending_approval.result() is False
assert any("拒绝" in t for t in mock_feishu["texts"])
@pytest.mark.asyncio
async def test_y_without_pending_falls_through(self, mock_feishu):
"""If there's no pending approval, 'y' should not be consumed."""
from agent.manager import manager, Session
from orchestrator.agent import agent
from orchestrator.tools import set_current_chat
set_current_chat("chat1")
session = Session(conv_id="c1", cwd="/tmp", owner_id="u1")
manager._sessions["c1"] = session
agent._active_conv["u1"] = "c1"
from bot.handler import _process_message
# Patch agent.run to avoid actual LLM call
with patch("orchestrator.agent.agent.run", new_callable=AsyncMock, return_value="ok"):
await _process_message("u1", "chat1", "y")
# Should not have consumed as approval
assert not any("批准" in t for t in mock_feishu["markdowns"])
# ===========================================================================
# 8. bot/feishu.py build_approval_card
# ===========================================================================
class TestBuildApprovalCard:
def test_card_structure(self):
from bot.feishu import build_approval_card
card = build_approval_card("c1", "Bash", "`echo hello`", timeout=60)
assert card["schema"] == "2.0"
assert "权限审批" in card["header"]["title"]["content"]
body_elements = card["body"]["elements"]
tags = [e["tag"] for e in body_elements]
# JSON 2.0: no "action" wrapper, no "note" — buttons are direct elements
assert "action" not in tags
assert "note" not in tags
assert "markdown" in tags
assert tags.count("button") == 2
# Buttons carry approve/deny values
buttons = [e for e in body_elements if e["tag"] == "button"]
assert buttons[0]["value"]["action"] == "approve"
assert buttons[0]["value"]["conv_id"] == "c1"
assert buttons[1]["value"]["action"] == "deny"
# ===========================================================================
# 8b. bot/handler.py card callback response
# ===========================================================================
class TestCardCallbackResponse:
"""Test _handle_card_action returns proper callback response per
docs/feishu/card_callback_communication.md."""
def _make_card_event(self, action: str, conv_id: str) -> object:
"""Create a mock CustomizedEvent with card action payload."""
import lark_oapi as lark
mock_event = MagicMock()
payload = json.dumps({
"schema": "2.0",
"header": {
"event_id": "evt_123",
"event_type": "card.action.trigger",
},
"event": {
"operator": {
"open_id": "ou_test_user_123",
},
"action": {
"value": {"action": action, "conv_id": conv_id},
"tag": "button",
},
},
})
# lark.JSON.marshal returns the JSON string
with patch("lark_oapi.JSON.marshal", return_value=payload):
yield payload
def test_approve_returns_toast_and_card(self):
from bot.handler import _handle_card_action
payload = json.dumps({
"event": {
"operator": {"open_id": "ou_test"},
"action": {"value": {"action": "approve", "conv_id": "c1"}, "tag": "button"},
},
})
with patch("lark_oapi.JSON.marshal", return_value=payload), \
patch("bot.handler._main_loop", new=MagicMock()):
result = _handle_card_action(MagicMock())
assert result is not None
# Toast
assert result["toast"]["type"] == "success"
assert "批准" in result["toast"]["content"]
# Updated card
assert result["card"]["type"] == "raw"
card_data = result["card"]["data"]
assert card_data["schema"] == "2.0"
assert card_data["header"]["template"] == "green"
assert "批准" in card_data["body"]["elements"][0]["content"]
def test_deny_returns_warning_toast(self):
from bot.handler import _handle_card_action
payload = json.dumps({
"event": {
"operator": {"open_id": "ou_test"},
"action": {"value": {"action": "deny", "conv_id": "c1"}, "tag": "button"},
},
})
with patch("lark_oapi.JSON.marshal", return_value=payload), \
patch("bot.handler._main_loop", new=MagicMock()):
result = _handle_card_action(MagicMock())
assert result is not None
assert result["toast"]["type"] == "warning"
assert "拒绝" in result["toast"]["content"]
assert result["card"]["data"]["header"]["template"] == "red"
def test_missing_value_returns_none(self):
from bot.handler import _handle_card_action
payload = json.dumps({
"event": {
"action": {"value": {}, "tag": "button"},
},
})
with patch("lark_oapi.JSON.marshal", return_value=payload):
result = _handle_card_action(MagicMock())
assert result is None
# ===========================================================================
# 9. Permission mode constants
# ===========================================================================
class TestPermissionModes:
def test_valid_modes_includes_dontask(self):
from agent.sdk_session import VALID_PERMISSION_MODES
assert "dontAsk" in VALID_PERMISSION_MODES
def test_perm_aliases_has_auto(self):
from bot.commands import _PERM_ALIASES
assert _PERM_ALIASES["auto"] == "dontAsk"
def test_perm_labels_has_dontask(self):
from bot.commands import _PERM_LABELS
assert _PERM_LABELS["dontAsk"] == "auto"
# ===========================================================================
# 10. Config
# ===========================================================================
class TestConfig:
def test_sdk_approval_timeout_exists(self):
from config import SDK_APPROVAL_TIMEOUT
assert isinstance(SDK_APPROVAL_TIMEOUT, int)
assert SDK_APPROVAL_TIMEOUT > 0