ForcePilot/backend/test/unit/channels/test_googlechat_modules.py
Kris 3264900bc9 test: 新增多渠道单元测试用例并配置测试环境变量
新增了Twitch、Telegram、Discord、Slack、Mattermost、WeChat、Zalo等多渠道的单元测试用例,覆盖了令牌处理、速率限制、消息去重、会话解析、格式转换、安全策略等模块
同时在测试配置中添加了测试用的OpenAI API密钥环境变量
2026-05-12 00:56:47 +08:00

634 lines
22 KiB
Python

"""Unit tests for Google Chat adapter modules.
Tests cover: formatter, normalizer, audit, streaming, cards,
mentions, threads, slash_commands, session, pubsub_ modules.
"""
from __future__ import annotations
import asyncio
import json
from unittest.mock import MagicMock
from yuxi.channels.models import (
Attachment,
ChannelIdentity,
ChannelResponse,
ChannelType,
ChatType,
EventType,
MessageType,
)
# ============================================================
# formatter.py tests
# ============================================================
class TestFormatter:
def test_basic_text_format(self):
from yuxi.channels.adapters.googlechat.formatter import format_outbound
resp = ChannelResponse(
identity=ChannelIdentity(
channel_id="googlechat",
channel_type=ChannelType.GOOGLE_CHAT,
channel_user_id="user1",
channel_chat_id="spaces/ABC",
),
content="hello world",
)
result = format_outbound(resp)
assert result["text"] == "hello world"
def test_empty_content_defaults_to_space(self):
from yuxi.channels.adapters.googlechat.formatter import format_outbound
resp = ChannelResponse(
identity=ChannelIdentity(
channel_id="googlechat",
channel_type=ChannelType.GOOGLE_CHAT,
channel_user_id="user1",
channel_chat_id="spaces/ABC",
),
content="",
)
result = format_outbound(resp)
assert result["text"] == " "
def test_card_in_metadata(self):
from yuxi.channels.adapters.googlechat.formatter import format_outbound
resp = ChannelResponse(
identity=ChannelIdentity(
channel_id="googlechat",
channel_type=ChannelType.GOOGLE_CHAT,
channel_user_id="user1",
channel_chat_id="spaces/ABC",
),
content="check this card",
metadata={"card": {"cards_v2": [{"card": {"header": {"title": "Test"}}}]}},
)
result = format_outbound(resp)
assert "cards_v2" in result
assert result["cards_v2"][0]["card"]["header"]["title"] == "Test"
def test_image_attachment(self):
from yuxi.channels.adapters.googlechat.formatter import format_outbound
resp = ChannelResponse(
identity=ChannelIdentity(
channel_id="googlechat",
channel_type=ChannelType.GOOGLE_CHAT,
channel_user_id="user1",
channel_chat_id="spaces/ABC",
),
content="image",
attachments=[Attachment(type="image", url="https://example.com/img.png")],
)
result = format_outbound(resp)
assert "cards_v2" in result
def test_thread_reply(self):
from yuxi.channels.adapters.googlechat.formatter import format_outbound
resp = ChannelResponse(
identity=ChannelIdentity(
channel_id="googlechat",
channel_type=ChannelType.GOOGLE_CHAT,
channel_user_id="user1",
channel_chat_id="spaces/ABC",
),
content="reply",
metadata={"thread_key": "thread-123"},
)
result = format_outbound(resp)
assert result["messageReplyOption"] == "REPLY_MESSAGE_FALLBACK_TO_NEW_THREAD"
assert result["thread"]["threadKey"] == "thread-123"
def test_truncate_long_text(self):
from yuxi.channels.adapters.googlechat.formatter import format_outbound
long_content = "A" * 40000
resp = ChannelResponse(
identity=ChannelIdentity(
channel_id="googlechat",
channel_type=ChannelType.GOOGLE_CHAT,
channel_user_id="user1",
channel_chat_id="spaces/ABC",
),
content=long_content,
)
result = format_outbound(resp)
assert len(result["text"].encode("utf-8")) <= 32000
assert result["text"].endswith("...")
def test_truncate_short_text_unchanged(self):
from yuxi.channels.adapters.googlechat.formatter import format_outbound
resp = ChannelResponse(
identity=ChannelIdentity(
channel_id="googlechat",
channel_type=ChannelType.GOOGLE_CHAT,
channel_user_id="user1",
channel_chat_id="spaces/ABC",
),
content="short",
)
result = format_outbound(resp)
assert result["text"] == "short"
# ============================================================
# normalizer.py tests
# ============================================================
class TestNormalizer:
def test_basic_text_message(self):
from yuxi.channels.adapters.googlechat.normalizer import normalize_inbound
raw = {
"event": {
"type": "MESSAGE",
"message": {"text": "hello", "name": "spaces/ABC/messages/xyz"},
"space": {"name": "spaces/ABC", "spaceType": "SPACE"},
"user": {"name": "users/123"},
}
}
result = normalize_inbound("googlechat", ChannelType.GOOGLE_CHAT, raw)
assert result.content == "hello"
assert result.event_type == EventType.MESSAGE_RECEIVED
assert result.message_type == MessageType.TEXT
def test_added_to_space_event(self):
from yuxi.channels.adapters.googlechat.normalizer import normalize_inbound
raw = {
"event": {
"type": "ADDED_TO_SPACE",
"space": {"name": "spaces/ABC", "spaceType": "SPACE"},
"user": {"name": "users/123"},
}
}
result = normalize_inbound("googlechat", ChannelType.GOOGLE_CHAT, raw)
assert result.event_type == EventType.BOT_ADDED
def test_card_clicked_event(self):
from yuxi.channels.adapters.googlechat.normalizer import normalize_inbound
raw = {
"event": {
"type": "CARD_CLICKED",
"space": {"name": "spaces/ABC", "spaceType": "SPACE"},
"user": {"name": "users/123"},
},
"action": {"function": "approve", "parameters": [{"key": "id", "value": "1"}]},
}
result = normalize_inbound("googlechat", ChannelType.GOOGLE_CHAT, raw)
assert result.event_type == EventType.CARD_ACTION
def test_thread_message(self):
from yuxi.channels.adapters.googlechat.normalizer import normalize_inbound
raw = {
"event": {
"type": "MESSAGE",
"message": {
"text": "reply",
"name": "spaces/ABC/messages/xyz",
"thread": {"threadKey": "thread-1"},
},
"space": {"name": "spaces/ABC", "spaceType": "SPACE"},
"user": {"name": "users/123"},
}
}
result = normalize_inbound("googlechat", ChannelType.GOOGLE_CHAT, raw)
assert result.chat_type == ChatType.THREAD
assert "threads/thread-1" in result.identity.channel_chat_id
def test_direct_message(self):
from yuxi.channels.adapters.googlechat.normalizer import normalize_inbound
raw = {
"event": {
"type": "MESSAGE",
"message": {"text": "hi"},
"space": {"name": "spaces/ABC", "spaceType": "DIRECT_MESSAGE"},
"user": {"name": "users/123"},
}
}
result = normalize_inbound("googlechat", ChannelType.GOOGLE_CHAT, raw)
assert result.chat_type == ChatType.DIRECT
def test_image_message_type(self):
from yuxi.channels.adapters.googlechat.normalizer import normalize_inbound
raw = {
"event": {
"type": "MESSAGE",
"message": {
"text": "",
"attachment": [{"contentType": "image/png", "name": "att/1"}],
},
"space": {"name": "spaces/ABC", "spaceType": "SPACE"},
"user": {"name": "users/123"},
}
}
result = normalize_inbound("googlechat", ChannelType.GOOGLE_CHAT, raw)
assert result.message_type == MessageType.IMAGE
def test_file_message_type(self):
from yuxi.channels.adapters.googlechat.normalizer import normalize_inbound
raw = {
"event": {
"type": "MESSAGE",
"message": {
"text": "",
"attachment": [{"contentType": "application/pdf", "name": "att/1"}],
},
"space": {"name": "spaces/ABC", "spaceType": "SPACE"},
"user": {"name": "users/123"},
}
}
result = normalize_inbound("googlechat", ChannelType.GOOGLE_CHAT, raw)
assert result.message_type == MessageType.FILE
def test_extract_attachments(self):
from yuxi.channels.adapters.googlechat.normalizer import extract_attachments
msg = {
"attachment": [
{"name": "att/1", "contentName": "photo.png", "contentType": "image/png"},
{"name": "att/2", "contentName": "doc.pdf", "contentType": "application/pdf"},
]
}
result = extract_attachments(msg)
assert len(result) == 2
assert result[0].type == "image"
assert result[0].filename == "photo.png"
assert result[1].type == "file"
# ============================================================
# audit.py tests
# ============================================================
class TestAudit:
def test_check_membership_true(self):
from yuxi.channels.adapters.googlechat.audit import check_space_membership
service = MagicMock()
service.spaces().members().get().execute.return_value = {"name": "member1"}
result = asyncio.run(check_space_membership(service, "spaces/ABC", "users/123"))
assert result is True
def test_check_membership_false_on_error(self):
from yuxi.channels.adapters.googlechat.audit import check_space_membership
service = MagicMock()
service.spaces().members().get().execute.side_effect = Exception("not found")
result = asyncio.run(check_space_membership(service, "spaces/ABC", "users/999"))
assert result is False
def test_list_members_success(self):
from yuxi.channels.adapters.googlechat.audit import list_space_members
service = MagicMock()
service.spaces().members().list().execute.return_value = {"memberships": [{"name": "m1"}, {"name": "m2"}]}
result = asyncio.run(list_space_members(service, "spaces/ABC"))
assert len(result) == 2
def test_get_member_role_success(self):
from yuxi.channels.adapters.googlechat.audit import get_member_role
service = MagicMock()
service.spaces().members().get().execute.return_value = {"role": "ROLE_MANAGER"}
result = asyncio.run(get_member_role(service, "spaces/ABC", "users/123"))
assert result == "ROLE_MANAGER"
def test_get_member_role_none_on_error(self):
from yuxi.channels.adapters.googlechat.audit import get_member_role
service = MagicMock()
service.spaces().members().get().execute.side_effect = Exception("error")
result = asyncio.run(get_member_role(service, "spaces/ABC", "users/999"))
assert result is None
# ============================================================
# streaming.py tests
# ============================================================
class TestStreaming:
def test_register_message(self):
from yuxi.channels.adapters.googlechat.streaming import StreamManager
mgr = StreamManager()
mgr.register_message("chat1", "msg1", "hello")
assert mgr.has_pending("chat1") is True
assert mgr.get_message_name("chat1") == "msg1"
assert mgr.get_accumulated_text("chat1") == "hello"
def test_append_text(self):
from yuxi.channels.adapters.googlechat.streaming import StreamManager
mgr = StreamManager()
mgr.register_message("chat1", "msg1", "hello")
mgr.append_text("chat1", " world")
assert mgr.get_accumulated_text("chat1") == "hello world"
def test_clear(self):
from yuxi.channels.adapters.googlechat.streaming import StreamManager
mgr = StreamManager()
mgr.register_message("chat1", "msg1", "hello")
mgr.register_message("chat2", "msg2", "hi")
mgr.clear()
assert mgr.pending_count == 0
def test_pending_count(self):
from yuxi.channels.adapters.googlechat.streaming import StreamManager
mgr = StreamManager()
assert mgr.pending_count == 0
mgr.register_message("chat1", "msg1", "hello")
assert mgr.pending_count == 1
mgr.register_message("chat2", "msg2", "hi")
assert mgr.pending_count == 2
def test_should_update_after_register(self):
from yuxi.channels.adapters.googlechat.streaming import StreamManager
mgr = StreamManager()
mgr.register_message("chat1", "msg1", "hello")
mgr._last_update["chat1"] = 0
assert mgr.should_update("chat1") is True
def test_should_not_update_immediately_after_mark(self):
from yuxi.channels.adapters.googlechat.streaming import StreamManager
mgr = StreamManager()
mgr.register_message("chat1", "msg1", "hello")
mgr.mark_update("chat1")
assert mgr.should_update("chat1") is False
def test_finalize_clears_state(self):
from yuxi.channels.adapters.googlechat.streaming import StreamManager
mgr = StreamManager()
mgr.register_message("chat1", "msg1", "hello")
service = MagicMock()
service.spaces().messages().update().execute.return_value = {"name": "msg1"}
result = asyncio.run(mgr.finalize(service, "chat1"))
assert result.success is True
assert mgr.pending_count == 0
def test_send_update_without_finished_appends_indicator(self):
from yuxi.channels.adapters.googlechat.streaming import StreamManager
mgr = StreamManager()
mgr.register_message("chat1", "msg1", "hello")
service = MagicMock()
service.spaces().messages().update().execute.return_value = {"name": "msg1"}
result = asyncio.run(mgr.send_update(service, "chat1", finished=False))
assert result.success is True
assert mgr.pending_count == 1
def test_send_update_finished_clears_state(self):
from yuxi.channels.adapters.googlechat.streaming import StreamManager
mgr = StreamManager()
mgr.register_message("chat1", "msg1", "hello")
service = MagicMock()
service.spaces().messages().update().execute.return_value = {"name": "msg1"}
result = asyncio.run(mgr.send_update(service, "chat1", finished=True))
assert result.success is True
assert mgr.pending_count == 0
# ============================================================
# cards.py tests
# ============================================================
class TestCards:
def test_build_approval_card(self):
from yuxi.channels.adapters.googlechat.cards import build_approval_card
card = build_approval_card("Approve?", "action_1")
assert "cards_v2" in card
assert card["cards_v2"][0]["card"]["header"]["title"] == "Approve?"
def test_build_poll_card(self):
from yuxi.channels.adapters.googlechat.cards import build_poll_card
card = build_poll_card("Favorite?", ["A", "B", "C"], "poll_1")
assert "cards_v2" in card
sections = card["cards_v2"][0]["card"]["sections"]
assert len(sections) >= 2
def test_build_info_card(self):
from yuxi.channels.adapters.googlechat.cards import build_info_card
card = build_info_card("Info", {"key1": "val1", "key2": "val2"})
assert "cards_v2" in card
def test_build_form_card(self):
from yuxi.channels.adapters.googlechat.cards import build_form_card
fields = [{"label": "Name", "name": "name", "input_type": "SINGLE_LINE"}]
card = build_form_card("Form", fields, "submit_action")
assert "cards_v2" in card
def test_build_exec_approval_card(self):
from yuxi.channels.adapters.googlechat.cards import build_exec_approval_card
card = build_exec_approval_card(
"Execute?",
"exec_1",
detail_fields={"Action": "Delete", "Target": "file.txt"},
)
assert "cards_v2" in card
# ============================================================
# mentions.py tests
# ============================================================
class TestMentions:
def test_parse_user_mentions(self):
from yuxi.channels.adapters.googlechat.mentions import parse_mentions
msg = {
"annotations": [
{"type": "USER_MENTION", "userMention": {"user": {"name": "users/123"}, "type": "MENTION"}},
{"type": "USER_MENTION", "userMention": {"user": {"name": "users/456"}, "type": "MENTION"}},
]
}
result = parse_mentions(msg)
assert len(result.mentioned_user_ids) == 2
assert "users/123" in result.mentioned_user_ids
assert result.is_bot_mentioned is False
def test_parse_bot_mention(self):
from yuxi.channels.adapters.googlechat.mentions import parse_mentions
msg = {
"annotations": [
{"type": "USER_MENTION", "userMention": {"user": {"name": "users/bot1"}, "type": "BOT"}},
]
}
result = parse_mentions(msg)
assert result.is_bot_mentioned is True
def test_no_annotations(self):
from yuxi.channels.adapters.googlechat.mentions import parse_mentions
result = parse_mentions({})
assert result.mentioned_user_ids == []
assert result.is_bot_mentioned is False
# ============================================================
# threads.py tests
# ============================================================
class TestThreads:
def test_parse_thread_key(self):
from yuxi.channels.adapters.googlechat.threads import parse_thread_key
msg = {"thread": {"threadKey": "key-123"}}
assert parse_thread_key(msg) == "key-123"
def test_parse_thread_key_none(self):
from yuxi.channels.adapters.googlechat.threads import parse_thread_key
assert parse_thread_key({}) is None
def test_is_thread_message(self):
from yuxi.channels.adapters.googlechat.threads import is_thread_message
assert is_thread_message({"thread": {"threadKey": "key-1"}}) is True
assert is_thread_message({}) is False
def test_extract_thread_metadata(self):
from yuxi.channels.adapters.googlechat.threads import extract_thread_metadata
meta = extract_thread_metadata({"thread": {"threadKey": "key-1", "name": "spaces/ABC/threads/key-1"}})
assert meta["thread_key"] == "key-1"
assert meta["is_thread_root"] is False
def test_extract_thread_metadata_root(self):
from yuxi.channels.adapters.googlechat.threads import extract_thread_metadata
meta = extract_thread_metadata({})
assert meta["is_thread_root"] is True
# ============================================================
# slash_commands.py tests
# ============================================================
class TestSlashCommands:
def test_extract_valid_command(self):
from yuxi.channels.adapters.googlechat.slash_commands import extract_command
cmd, args = extract_command("/help")
assert cmd == "/help"
assert args == ""
def test_extract_command_with_args(self):
from yuxi.channels.adapters.googlechat.slash_commands import extract_command
cmd, args = extract_command("/reset now")
assert cmd == "/reset"
assert args == "now"
def test_unknown_command(self):
from yuxi.channels.adapters.googlechat.slash_commands import extract_command
cmd, args = extract_command("/unknown")
assert cmd is None
assert args == "/unknown"
def test_non_command_text(self):
from yuxi.channels.adapters.googlechat.slash_commands import extract_command
cmd, args = extract_command("hello world")
assert cmd is None
assert args == "hello world"
def test_get_command_help(self):
from yuxi.channels.adapters.googlechat.slash_commands import get_command_help
help_text = get_command_help()
assert "/help" in help_text
assert "/reset" in help_text
# ============================================================
# session.py tests
# ============================================================
class TestSession:
def test_build_thread_id_for_space(self):
from yuxi.channels.adapters.googlechat.session import build_thread_id
tid = build_thread_id("agent1", "spaces/ABC", "user@example.com")
assert tid == "agent:agent1:googlechat:space:ABC"
def test_build_thread_id_for_dm(self):
from yuxi.channels.adapters.googlechat.session import build_thread_id
tid = build_thread_id("main", "non-space-id", "user@example.com")
assert "googlechat" in tid
assert "user@example.com" in tid
# ============================================================
# pubsub_.py tests
# ============================================================
class TestPubsub:
def test_decode_pubsub_push(self):
from yuxi.channels.adapters.googlechat.pubsub_ import decode_pubsub_push
import base64
payload = {"event": {"type": "MESSAGE"}}
encoded = base64.b64encode(json.dumps(payload).encode()).decode()
body = {"message": {"data": encoded}}
result = decode_pubsub_push(body)
assert result["event"]["type"] == "MESSAGE"
def test_decode_empty_data_returns_none(self):
from yuxi.channels.adapters.googlechat.pubsub_ import decode_pubsub_push
result = decode_pubsub_push({"message": {}})
assert result is None
def test_decode_invalid_base64_returns_none(self):
from yuxi.channels.adapters.googlechat.pubsub_ import decode_pubsub_push
result = decode_pubsub_push({"message": {"data": "!!!invalid!!!"}})
assert result is None