fix(feishu): keep approval clicks synchronized with callback card state
Feishu approval clicks need the resolved card to come back from the synchronous callback path itself. Leaving approval resolution to the generic asynchronous card-action flow made button feedback depend on later loop work instead of the callback response the client is waiting for. Change-Id: I574997cbbcaa097fdba759b47367e28d1b56b040 Constraint: Feishu card-action callbacks must acknowledge quickly and reflect final approval state from the callback response path Rejected: Keep approval handling on the generic async card-action route | leaves card state synchronization vulnerable to callback timing and follow-up update ordering Confidence: high Scope-risk: narrow Reversibility: clean Directive: Keep approval callback response construction separate from async queue unblocking unless Feishu callback semantics change Tested: pytest tests/gateway/test_feishu.py tests/gateway/test_feishu_approval_buttons.py tests/gateway/test_approve_deny_commands.py tests/gateway/test_slack_approval_buttons.py tests/gateway/test_telegram_approval_buttons.py -q Not-tested: Live Feishu workspace end-to-end callback rendering
This commit is contained in:
@@ -1,12 +1,11 @@
|
||||
"""Tests for Feishu interactive card approval buttons."""
|
||||
|
||||
import asyncio
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, MagicMock, Mock, patch
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -23,14 +22,14 @@ if _repo not in sys.path:
|
||||
# ---------------------------------------------------------------------------
|
||||
def _ensure_feishu_mocks():
|
||||
"""Provide stubs for lark-oapi / aiohttp.web so the import succeeds."""
|
||||
if "lark_oapi" not in sys.modules:
|
||||
if importlib.util.find_spec("lark_oapi") is None and "lark_oapi" not in sys.modules:
|
||||
mod = MagicMock()
|
||||
for name in (
|
||||
"lark_oapi", "lark_oapi.api.im.v1",
|
||||
"lark_oapi.event", "lark_oapi.event.callback_type",
|
||||
):
|
||||
sys.modules.setdefault(name, mod)
|
||||
if "aiohttp" not in sys.modules:
|
||||
if importlib.util.find_spec("aiohttp") is None and "aiohttp" not in sys.modules:
|
||||
aio = MagicMock()
|
||||
sys.modules.setdefault("aiohttp", aio)
|
||||
sys.modules.setdefault("aiohttp.web", aio.web)
|
||||
@@ -39,6 +38,7 @@ def _ensure_feishu_mocks():
|
||||
_ensure_feishu_mocks()
|
||||
|
||||
from gateway.config import PlatformConfig
|
||||
import gateway.platforms.feishu as feishu_module
|
||||
from gateway.platforms.feishu import FeishuAdapter
|
||||
|
||||
|
||||
@@ -74,6 +74,12 @@ def _make_card_action_data(
|
||||
)
|
||||
|
||||
|
||||
def _close_submitted_coro(coro, _loop):
|
||||
"""Close scheduled coroutines in sync-handler tests to avoid unawaited warnings."""
|
||||
coro.close()
|
||||
return SimpleNamespace(add_done_callback=lambda *_args, **_kwargs: None)
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# send_exec_approval — interactive card with buttons
|
||||
# ===========================================================================
|
||||
@@ -203,14 +209,14 @@ class TestFeishuExecApproval:
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# _handle_card_action_event — approval button clicks
|
||||
# _resolve_approval — approval state pop + gateway resolution
|
||||
# ===========================================================================
|
||||
|
||||
class TestFeishuApprovalCallback:
|
||||
"""Test the approval intercept in _handle_card_action_event."""
|
||||
class TestResolveApproval:
|
||||
"""Test _resolve_approval pops state and calls resolve_gateway_approval."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolves_approval_on_click(self):
|
||||
async def test_resolves_once(self):
|
||||
adapter = _make_adapter()
|
||||
adapter._approval_state[1] = {
|
||||
"session_key": "agent:main:feishu:group:oc_12345",
|
||||
@@ -218,28 +224,14 @@ class TestFeishuApprovalCallback:
|
||||
"chat_id": "oc_12345",
|
||||
}
|
||||
|
||||
data = _make_card_action_data(
|
||||
action_value={"hermes_action": "approve_once", "approval_id": 1},
|
||||
)
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
adapter, "_resolve_sender_profile", new_callable=AsyncMock,
|
||||
return_value={"user_id": "ou_user1", "user_name": "Norbert", "user_id_alt": None},
|
||||
),
|
||||
patch.object(adapter, "_update_approval_card", new_callable=AsyncMock) as mock_update,
|
||||
patch("tools.approval.resolve_gateway_approval", return_value=1) as mock_resolve,
|
||||
):
|
||||
await adapter._handle_card_action_event(data)
|
||||
with patch("tools.approval.resolve_gateway_approval", return_value=1) as mock_resolve:
|
||||
await adapter._resolve_approval(1, "once", "Norbert")
|
||||
|
||||
mock_resolve.assert_called_once_with("agent:main:feishu:group:oc_12345", "once")
|
||||
mock_update.assert_called_once_with("msg_001", "Approved once", "Norbert", "once")
|
||||
|
||||
# State should be cleaned up
|
||||
assert 1 not in adapter._approval_state
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_deny_button(self):
|
||||
async def test_resolves_deny(self):
|
||||
adapter = _make_adapter()
|
||||
adapter._approval_state[2] = {
|
||||
"session_key": "some-session",
|
||||
@@ -247,26 +239,13 @@ class TestFeishuApprovalCallback:
|
||||
"chat_id": "oc_12345",
|
||||
}
|
||||
|
||||
data = _make_card_action_data(
|
||||
action_value={"hermes_action": "deny", "approval_id": 2},
|
||||
token="tok_deny",
|
||||
)
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
adapter, "_resolve_sender_profile", new_callable=AsyncMock,
|
||||
return_value={"user_id": "ou_alice", "user_name": "Alice", "user_id_alt": None},
|
||||
),
|
||||
patch.object(adapter, "_update_approval_card", new_callable=AsyncMock) as mock_update,
|
||||
patch("tools.approval.resolve_gateway_approval", return_value=1) as mock_resolve,
|
||||
):
|
||||
await adapter._handle_card_action_event(data)
|
||||
with patch("tools.approval.resolve_gateway_approval", return_value=1) as mock_resolve:
|
||||
await adapter._resolve_approval(2, "deny", "Alice")
|
||||
|
||||
mock_resolve.assert_called_once_with("some-session", "deny")
|
||||
mock_update.assert_called_once_with("msg_002", "Denied", "Alice", "deny")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_session_approval(self):
|
||||
async def test_resolves_session(self):
|
||||
adapter = _make_adapter()
|
||||
adapter._approval_state[3] = {
|
||||
"session_key": "sess-3",
|
||||
@@ -274,26 +253,13 @@ class TestFeishuApprovalCallback:
|
||||
"chat_id": "oc_99",
|
||||
}
|
||||
|
||||
data = _make_card_action_data(
|
||||
action_value={"hermes_action": "approve_session", "approval_id": 3},
|
||||
token="tok_ses",
|
||||
)
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
adapter, "_resolve_sender_profile", new_callable=AsyncMock,
|
||||
return_value={"user_id": "ou_u", "user_name": "Bob", "user_id_alt": None},
|
||||
),
|
||||
patch.object(adapter, "_update_approval_card", new_callable=AsyncMock) as mock_update,
|
||||
patch("tools.approval.resolve_gateway_approval", return_value=1) as mock_resolve,
|
||||
):
|
||||
await adapter._handle_card_action_event(data)
|
||||
with patch("tools.approval.resolve_gateway_approval", return_value=1) as mock_resolve:
|
||||
await adapter._resolve_approval(3, "session", "Bob")
|
||||
|
||||
mock_resolve.assert_called_once_with("sess-3", "session")
|
||||
mock_update.assert_called_once_with("msg_003", "Approved for session", "Bob", "session")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_always_approval(self):
|
||||
async def test_resolves_always(self):
|
||||
adapter = _make_adapter()
|
||||
adapter._approval_state[4] = {
|
||||
"session_key": "sess-4",
|
||||
@@ -301,42 +267,29 @@ class TestFeishuApprovalCallback:
|
||||
"chat_id": "oc_55",
|
||||
}
|
||||
|
||||
data = _make_card_action_data(
|
||||
action_value={"hermes_action": "approve_always", "approval_id": 4},
|
||||
token="tok_alw",
|
||||
)
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
adapter, "_resolve_sender_profile", new_callable=AsyncMock,
|
||||
return_value={"user_id": "ou_u", "user_name": "Carol", "user_id_alt": None},
|
||||
),
|
||||
patch.object(adapter, "_update_approval_card", new_callable=AsyncMock),
|
||||
patch("tools.approval.resolve_gateway_approval", return_value=1) as mock_resolve,
|
||||
):
|
||||
await adapter._handle_card_action_event(data)
|
||||
with patch("tools.approval.resolve_gateway_approval", return_value=1) as mock_resolve:
|
||||
await adapter._resolve_approval(4, "always", "Carol")
|
||||
|
||||
mock_resolve.assert_called_once_with("sess-4", "always")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_already_resolved_drops_silently(self):
|
||||
adapter = _make_adapter()
|
||||
# No state for approval_id 99 — already resolved
|
||||
|
||||
data = _make_card_action_data(
|
||||
action_value={"hermes_action": "approve_once", "approval_id": 99},
|
||||
token="tok_gone",
|
||||
)
|
||||
|
||||
with patch("tools.approval.resolve_gateway_approval") as mock_resolve:
|
||||
await adapter._handle_card_action_event(data)
|
||||
await adapter._resolve_approval(99, "once", "Nobody")
|
||||
|
||||
# Should NOT resolve — already handled
|
||||
mock_resolve.assert_not_called()
|
||||
|
||||
# ===========================================================================
|
||||
# _handle_card_action_event — non-approval card actions
|
||||
# ===========================================================================
|
||||
|
||||
class TestNonApprovalCardAction:
|
||||
"""Non-approval card actions should still route as synthetic commands."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_non_approval_actions_route_normally(self):
|
||||
"""Non-approval card actions should still become synthetic commands."""
|
||||
async def test_routes_as_synthetic_command(self):
|
||||
adapter = _make_adapter()
|
||||
|
||||
data = _make_card_action_data(
|
||||
@@ -351,82 +304,141 @@ class TestFeishuApprovalCallback:
|
||||
),
|
||||
patch.object(adapter, "get_chat_info", new_callable=AsyncMock, return_value={"name": "Test Chat"}),
|
||||
patch.object(adapter, "_handle_message_with_guards", new_callable=AsyncMock) as mock_handle,
|
||||
patch("tools.approval.resolve_gateway_approval") as mock_resolve,
|
||||
):
|
||||
await adapter._handle_card_action_event(data)
|
||||
|
||||
# Should NOT resolve any approval
|
||||
mock_resolve.assert_not_called()
|
||||
# Should have routed as synthetic command
|
||||
mock_handle.assert_called_once()
|
||||
event = mock_handle.call_args[0][0]
|
||||
assert "/card button" in event.text
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# _update_approval_card — card replacement after resolution
|
||||
# _on_card_action_trigger — inline card response for approval actions
|
||||
# ===========================================================================
|
||||
|
||||
class TestFeishuUpdateApprovalCard:
|
||||
"""Test the card update after approval resolution."""
|
||||
class _FakeCallBackCard:
|
||||
def __init__(self):
|
||||
self.type = None
|
||||
self.data = None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_updates_card_on_approve(self):
|
||||
|
||||
class _FakeP2Response:
|
||||
def __init__(self):
|
||||
self.card = None
|
||||
|
||||
|
||||
@pytest.fixture(autouse=False)
|
||||
def _patch_callback_card_types(monkeypatch):
|
||||
"""Provide real-ish P2CardActionTriggerResponse / CallBackCard for tests."""
|
||||
monkeypatch.setattr(feishu_module, "P2CardActionTriggerResponse", _FakeP2Response)
|
||||
monkeypatch.setattr(feishu_module, "CallBackCard", _FakeCallBackCard)
|
||||
|
||||
|
||||
class TestCardActionCallbackResponse:
|
||||
"""Test that _on_card_action_trigger returns updated card inline."""
|
||||
|
||||
def test_drops_action_when_loop_not_ready(self, _patch_callback_card_types):
|
||||
adapter = _make_adapter()
|
||||
adapter._loop = None
|
||||
data = _make_card_action_data({"hermes_action": "approve_once", "approval_id": 1})
|
||||
|
||||
mock_update = AsyncMock()
|
||||
adapter._client.im.v1.message.update = MagicMock()
|
||||
with patch("asyncio.run_coroutine_threadsafe") as mock_submit:
|
||||
response = adapter._on_card_action_trigger(data)
|
||||
|
||||
with patch("asyncio.to_thread", new_callable=AsyncMock) as mock_thread:
|
||||
await adapter._update_approval_card(
|
||||
"msg_001", "Approved once", "Norbert", "once"
|
||||
)
|
||||
assert response is not None
|
||||
assert response.card is None
|
||||
mock_submit.assert_not_called()
|
||||
|
||||
mock_thread.assert_called_once()
|
||||
# Verify the update request was built
|
||||
call_args = mock_thread.call_args
|
||||
assert call_args[0][0] == adapter._client.im.v1.message.update
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_updates_card_on_deny(self):
|
||||
def test_returns_card_for_approve_action(self, _patch_callback_card_types):
|
||||
adapter = _make_adapter()
|
||||
adapter._loop = MagicMock()
|
||||
adapter._loop.is_closed = MagicMock(return_value=False)
|
||||
data = _make_card_action_data(
|
||||
{"hermes_action": "approve_once", "approval_id": 1},
|
||||
open_id="ou_bob",
|
||||
)
|
||||
adapter._sender_name_cache["ou_bob"] = ("Bob", 9999999999)
|
||||
|
||||
with patch("asyncio.to_thread", new_callable=AsyncMock) as mock_thread:
|
||||
await adapter._update_approval_card(
|
||||
"msg_002", "Denied", "Alice", "deny"
|
||||
)
|
||||
with patch("asyncio.run_coroutine_threadsafe", side_effect=_close_submitted_coro):
|
||||
response = adapter._on_card_action_trigger(data)
|
||||
|
||||
mock_thread.assert_called_once()
|
||||
assert response is not None
|
||||
assert response.card is not None
|
||||
assert response.card.type == "raw"
|
||||
card = response.card.data
|
||||
assert card["header"]["template"] == "green"
|
||||
assert "Approved once" in card["header"]["title"]["content"]
|
||||
assert "Bob" in card["elements"][0]["content"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_skips_update_when_not_connected(self):
|
||||
def test_returns_card_for_deny_action(self, _patch_callback_card_types):
|
||||
adapter = _make_adapter()
|
||||
adapter._client = None
|
||||
adapter._loop = MagicMock()
|
||||
adapter._loop.is_closed = MagicMock(return_value=False)
|
||||
data = _make_card_action_data(
|
||||
{"hermes_action": "deny", "approval_id": 2},
|
||||
)
|
||||
|
||||
with patch("asyncio.to_thread", new_callable=AsyncMock) as mock_thread:
|
||||
await adapter._update_approval_card(
|
||||
"msg_001", "Approved", "Bob", "once"
|
||||
)
|
||||
with patch("asyncio.run_coroutine_threadsafe", side_effect=_close_submitted_coro):
|
||||
response = adapter._on_card_action_trigger(data)
|
||||
|
||||
mock_thread.assert_not_called()
|
||||
assert response.card is not None
|
||||
card = response.card.data
|
||||
assert card["header"]["template"] == "red"
|
||||
assert "Denied" in card["header"]["title"]["content"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_skips_update_when_no_message_id(self):
|
||||
def test_ignores_missing_approval_id(self, _patch_callback_card_types):
|
||||
adapter = _make_adapter()
|
||||
adapter._loop = MagicMock()
|
||||
adapter._loop.is_closed = MagicMock(return_value=False)
|
||||
data = _make_card_action_data({"hermes_action": "approve_once"})
|
||||
|
||||
with patch("asyncio.to_thread", new_callable=AsyncMock) as mock_thread:
|
||||
await adapter._update_approval_card(
|
||||
"", "Approved", "Bob", "once"
|
||||
)
|
||||
with patch("asyncio.run_coroutine_threadsafe") as mock_submit:
|
||||
response = adapter._on_card_action_trigger(data)
|
||||
|
||||
mock_thread.assert_not_called()
|
||||
assert response is not None
|
||||
assert response.card is None
|
||||
mock_submit.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_swallows_update_errors(self):
|
||||
def test_no_card_for_non_approval_action(self, _patch_callback_card_types):
|
||||
adapter = _make_adapter()
|
||||
adapter._loop = MagicMock()
|
||||
adapter._loop.is_closed = MagicMock(return_value=False)
|
||||
data = _make_card_action_data({"some_other": "value"})
|
||||
|
||||
with patch("asyncio.to_thread", new_callable=AsyncMock, side_effect=Exception("API error")):
|
||||
# Should not raise
|
||||
await adapter._update_approval_card(
|
||||
"msg_001", "Approved", "Bob", "once"
|
||||
)
|
||||
with patch("asyncio.run_coroutine_threadsafe", side_effect=_close_submitted_coro):
|
||||
response = adapter._on_card_action_trigger(data)
|
||||
|
||||
assert response is not None
|
||||
assert response.card is None
|
||||
|
||||
def test_falls_back_to_open_id_when_name_not_cached(self, _patch_callback_card_types):
|
||||
adapter = _make_adapter()
|
||||
adapter._loop = MagicMock()
|
||||
adapter._loop.is_closed = MagicMock(return_value=False)
|
||||
data = _make_card_action_data(
|
||||
{"hermes_action": "approve_session", "approval_id": 3},
|
||||
open_id="ou_unknown",
|
||||
)
|
||||
|
||||
with patch("asyncio.run_coroutine_threadsafe", side_effect=_close_submitted_coro):
|
||||
response = adapter._on_card_action_trigger(data)
|
||||
|
||||
card = response.card.data
|
||||
assert "ou_unknown" in card["elements"][0]["content"]
|
||||
|
||||
def test_ignores_expired_cached_name(self, _patch_callback_card_types):
|
||||
adapter = _make_adapter()
|
||||
adapter._loop = MagicMock()
|
||||
adapter._loop.is_closed = MagicMock(return_value=False)
|
||||
data = _make_card_action_data(
|
||||
{"hermes_action": "approve_once", "approval_id": 4},
|
||||
open_id="ou_expired",
|
||||
)
|
||||
adapter._sender_name_cache["ou_expired"] = ("Old Name", 1)
|
||||
|
||||
with patch("asyncio.run_coroutine_threadsafe", side_effect=_close_submitted_coro):
|
||||
response = adapter._on_card_action_trigger(data)
|
||||
|
||||
card = response.card.data
|
||||
assert "Old Name" not in card["elements"][0]["content"]
|
||||
assert "ou_expired" in card["elements"][0]["content"]
|
||||
|
||||
Reference in New Issue
Block a user