ForcePilot/backend/test/unit/channels/test_googlechat.py
Kris 69fe97a90d test: 批量修复并新增单元测试用例
1. 移除Telegram格式化测试中未使用的导入项
2. 修复Teams测试用例,添加monkeypatch参数并配置通配符开关
3. 更新钉钉适配器测试,替换弃用的流属性检查
4. 修正Twitch规范化测试,更新ROOMSTATE测试逻辑
5. 重构会话映射测试,完善数据库执行结果模拟
6. 格式化Slack块构建测试的长参数调用
7. 修复LINE适配器测试,更新能力断言和异步锁使用
8. 修正Slack会话解析测试,修复聊天类型判断错误
9. 更新能力测试,补充缺失的字段检查
10. 修复Matrix适配器测试,修正位置参数和配置校验逻辑
11. 为飞书分析模块测试添加跳过标记
12. 新增微信能力、限流、链接格式、会话路由等模块的单元测试
13. 修复Twitch适配器导入路径和测试断言
14. 新增Discord Webhook、Nextcloud Talk、Signal多账户等模块的单元测试
15. 修复Manager阶段测试的导入路径
16. 新增iMessage异常和命令处理的单元测试
17. 新增Nostr健康检查和相关模块的单元测试
18. 新增Signal守护进程和SSE重连相关测试
2026-05-13 16:43:01 +08:00

2141 lines
78 KiB
Python

from __future__ import annotations
import asyncio
import json
import time
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from yuxi.channels.adapters.googlechat.approval_auth import (
build_exec_approval_context,
is_fully_approved,
normalize_approver_id,
record_approval_decision,
resolve_approver_ids,
)
from yuxi.channels.adapters.googlechat.auth import (
GoogleChatCertCache,
get_cert_cache,
verify_project_number_token,
)
from yuxi.channels.adapters.googlechat.cards import (
build_approval_card,
build_exec_approval_card,
build_form_card,
build_info_card,
build_poll_card,
build_selection_card,
)
from yuxi.channels.adapters.googlechat.directory import list_groups, list_peers, normalize_id
from yuxi.channels.adapters.googlechat.doctor import run_diagnostics
from yuxi.channels.adapters.googlechat.formatter import (
chunk_text_for_outbound,
format_outbound,
resolve_reply_to_mode,
sanitize_text,
)
from yuxi.channels.adapters.googlechat.mentions import parse_mentions
from yuxi.channels.adapters.googlechat.msg_cache import (
SentMessageCache,
track_sent_message,
update_sent_message_status,
)
from yuxi.channels.adapters.googlechat.normalizer import (
detect_message_edit,
extract_attachments,
is_bot_message,
is_forwarded_message,
map_event_type,
map_msg_type,
normalize_inbound,
resolve_route_envelope,
)
from yuxi.channels.adapters.googlechat.policy import (
DmPolicy,
GoogleChatPolicy,
GroupPolicy,
)
from yuxi.channels.adapters.googlechat.proxy import (
build_google_auth_request,
build_proxies_dict,
resolve_proxy_config,
resolve_tls_config,
)
from yuxi.channels.adapters.googlechat.secret_contract import (
get_required_secrets,
get_secret_contracts,
get_sensitive_fields,
is_sensitive_field,
register_secret_contract,
)
from yuxi.channels.adapters.googlechat.session import build_thread_id
from yuxi.channels.adapters.googlechat.setup import (
SetupWizard,
get_setup_guide,
wizard_validate_service_account,
)
from yuxi.channels.adapters.googlechat.slash_commands import extract_command, get_command_help
from yuxi.channels.adapters.googlechat.ssrf import (
apply_ssrf_guard,
sanitize_google_auth_init,
sanitize_request_kwargs,
)
from yuxi.channels.adapters.googlechat.streaming import StreamManager
from yuxi.channels.adapters.googlechat.target import (
detect_deprecated_target,
is_googlechat_space_target,
is_googlechat_user_target,
normalize_googlechat_target,
resolve_targets,
strip_message_suffix,
)
from yuxi.channels.adapters.googlechat.threads import (
extract_thread_metadata,
is_thread_message,
parse_thread_key,
)
from yuxi.channels.models import (
Attachment,
ChannelIdentity,
ChannelMessage,
ChannelResponse,
ChannelType,
ChatType,
EventType,
MessageType,
MentionsInfo,
)
# ============================================================================
# normalizer.py tests
# ============================================================================
class TestMapEventType:
def test_message_event(self):
assert map_event_type("MESSAGE") == EventType.MESSAGE_RECEIVED
def test_message_update_event(self):
assert map_event_type("MESSAGE_UPDATE") == EventType.MESSAGE_UPDATED
def test_message_delete_event(self):
assert map_event_type("MESSAGE_DELETE") == EventType.MESSAGE_DELETED
def test_added_to_space_event(self):
assert map_event_type("ADDED_TO_SPACE") == EventType.BOT_ADDED
def test_removed_from_space_event(self):
assert map_event_type("REMOVED_FROM_SPACE") == EventType.BOT_REMOVED
def test_card_clicked_event(self):
assert map_event_type("CARD_CLICKED") == EventType.CARD_ACTION
def test_unknown_event_defaults_to_message_received(self):
assert map_event_type("UNKNOWN_EVENT") == EventType.MESSAGE_RECEIVED
def test_empty_event_string(self):
assert map_event_type("") == EventType.MESSAGE_RECEIVED
class TestIsBotMessage:
def test_bot_sender(self):
assert is_bot_message({"sender": {"type": "BOT"}}) is True
def test_human_sender(self):
assert is_bot_message({"sender": {"type": "HUMAN"}}) is False
def test_no_sender_field(self):
assert is_bot_message({}) is False
def test_sender_without_type(self):
assert is_bot_message({"sender": {"name": "test"}}) is False
class TestIsForwardedMessage:
def test_has_retention_settings(self):
assert is_forwarded_message({"retentionSettings": {"state": "PERMANENT"}}) is True
def test_no_retention_no_times(self):
assert is_forwarded_message({}) is False
def test_empty_dict(self):
assert is_forwarded_message({}) is False
class TestDetectMessageEdit:
def test_different_create_and_update_times(self):
assert detect_message_edit({"createTime": "2024-01-01T00:00:00Z", "lastUpdateTime": "2024-01-02T00:00:00Z"}) is True
def test_same_times_not_edited(self):
assert detect_message_edit({"createTime": "2024-01-01T00:00:00Z", "lastUpdateTime": "2024-01-01T00:00:00Z"}) is False
def test_no_times(self):
assert detect_message_edit({}) is False
def test_only_create_time(self):
assert detect_message_edit({"createTime": "2024-01-01T00:00:00Z"}) is False
def test_only_update_time(self):
assert detect_message_edit({"lastUpdateTime": "2024-01-01T00:00:00Z"}) is False
class TestResolveRouteEnvelope:
def test_direct_chat(self):
result = resolve_route_envelope(ChatType.DIRECT, "spaces/dm-123", "users/user@example.com")
assert result["peer_kind"] == "direct"
assert result["peer_id"] == "users/user@example.com"
assert result["peer_key"] == "direct:user@example.com"
route = result["route"]
assert route["content"]["type"] == "direct"
def test_direct_chat_user_with_users_prefix(self):
result = resolve_route_envelope(ChatType.DIRECT, "spaces/dm-123", "users/user@example.com")
assert result["peer_key"] == "direct:user@example.com"
def test_direct_chat_user_without_prefix(self):
result = resolve_route_envelope(ChatType.DIRECT, "spaces/dm-123", "user@example.com")
assert result["peer_key"] == "direct:user@example.com"
def test_group_chat(self):
result = resolve_route_envelope(ChatType.SPACE, "spaces/ABC123", "")
assert result["peer_kind"] == "group"
assert result["peer_id"] == "spaces/ABC123"
assert result["peer_key"] == "space:ABC123"
route = result["route"]
assert route["content"]["type"] == "space"
def test_group_chat_space_without_prefix(self):
result = resolve_route_envelope(ChatType.SPACE, "ABC123", "")
assert result["peer_id"] == "ABC123"
assert result["peer_key"] == "space:ABC123"
class TestExtractAttachments:
def test_image_attachment(self):
msg = {"attachment": [{"name": "img1", "contentName": "photo.png", "contentType": "image/png"}]}
atts = extract_attachments(msg)
assert len(atts) == 1
assert atts[0].type == "image"
assert atts[0].mime_type == "image/png"
assert atts[0].filename == "photo.png"
def test_video_attachment(self):
msg = {"attachment": [{"name": "video1", "contentName": "clip.mp4", "contentType": "video/mp4"}]}
atts = extract_attachments(msg)
assert len(atts) == 1
assert atts[0].type == "video"
assert atts[0].mime_type == "video/mp4"
def test_audio_attachment(self):
msg = {"attachment": [{"name": "audio1", "contentName": "note.mp3", "contentType": "audio/mp3"}]}
atts = extract_attachments(msg)
assert len(atts) == 1
assert atts[0].type == "audio"
def test_file_attachment(self):
msg = {"attachment": [{"name": "file1", "contentName": "doc.pdf", "contentType": "application/pdf"}]}
atts = extract_attachments(msg)
assert len(atts) == 1
assert atts[0].type == "file"
def test_attachments_key_variant(self):
msg = {"attachments": [{"name": "file2", "contentName": "data.csv", "contentType": "text/csv"}]}
atts = extract_attachments(msg)
assert len(atts) == 1
assert atts[0].type == "file"
def test_no_attachments(self):
msg = {}
atts = extract_attachments(msg)
assert atts == []
def test_empty_attachments_list(self):
msg = {"attachment": []}
atts = extract_attachments(msg)
assert atts == []
def test_multiple_mixed_attachments(self):
msg = {
"attachment": [
{"name": "img", "contentName": "a.png", "contentType": "image/png"},
{"name": "doc", "contentName": "b.pdf", "contentType": "application/pdf"},
{"name": "vid", "contentName": "c.mp4", "contentType": "video/mp4"},
]
}
atts = extract_attachments(msg)
assert len(atts) == 3
assert atts[0].type == "image"
assert atts[1].type == "file"
assert atts[2].type == "video"
def test_file_id_encoded(self):
msg = {"attachment": [{"name": "spaces/ABC/messages/xyz/attachments/foo", "contentType": "image/png"}]}
atts = extract_attachments(msg)
assert len(atts) == 1
decoded = json.loads(atts[0].file_id)
assert decoded["name"] == "spaces/ABC/messages/xyz/attachments/foo"
class TestMapMsgType:
def test_card_action_event(self):
assert map_msg_type({}, EventType.CARD_ACTION) == MessageType.CARD
def test_image_content_type(self):
assert map_msg_type({"attachment": [{"contentType": "image/png"}]}, EventType.MESSAGE_RECEIVED) == MessageType.IMAGE
def test_video_content_type(self):
assert map_msg_type({"attachment": [{"contentType": "video/mp4"}]}, EventType.MESSAGE_RECEIVED) == MessageType.VIDEO
def test_audio_content_type(self):
assert map_msg_type({"attachment": [{"contentType": "audio/mp3"}]}, EventType.MESSAGE_RECEIVED) == MessageType.AUDIO
def test_file_content_type(self):
assert map_msg_type({"attachment": [{"contentType": "application/pdf"}]}, EventType.MESSAGE_RECEIVED) == MessageType.FILE
def test_cards_v2_message(self):
assert map_msg_type({"cardsV2": [{}], "text": ""}, EventType.MESSAGE_RECEIVED) == MessageType.CARD
def test_cards_v2_snake_case(self):
assert map_msg_type({"cards_v2": [{}], "text": ""}, EventType.MESSAGE_RECEIVED) == MessageType.CARD
def test_text_message(self):
assert map_msg_type({"text": "hello"}, EventType.MESSAGE_RECEIVED) == MessageType.TEXT
def test_empty_message_defaults_to_text(self):
assert map_msg_type({}, EventType.MESSAGE_RECEIVED) == MessageType.TEXT
class TestNormalizeInbound:
def _sample_payload(self, **overrides):
payload = {
"event": {
"type": "MESSAGE",
"eventTime": "2024-01-01T00:00:00Z",
"message": {
"name": "spaces/ABC/messages/xyz",
"sender": {"name": "users/user1@example.com", "type": "HUMAN"},
"text": "Hello world",
"thread": {"threadKey": ""},
},
"space": {"name": "spaces/ABC", "spaceType": "SPACE"},
"user": {"name": "users/user1@example.com"},
},
}
for key, value in overrides.items():
if key in ("message", "space", "user"):
payload["event"][key].update(value)
elif key == "event_type":
payload["event"]["type"] = value
else:
payload[key] = value
return payload
def test_dm_message(self):
payload = self._sample_payload(
space={"name": "spaces/DM123", "spaceType": "DIRECT_MESSAGE"}
)
result = normalize_inbound("test-ch", ChannelType.GOOGLE_CHAT, payload)
assert result.identity.channel_id == "test-ch"
assert result.identity.channel_type == ChannelType.GOOGLE_CHAT
assert result.identity.channel_user_id == "users/user1@example.com"
assert result.identity.channel_chat_id == "spaces/DM123"
assert result.identity.channel_message_id == "spaces/ABC/messages/xyz"
assert result.chat_type == ChatType.DIRECT
assert result.event_type == EventType.MESSAGE_RECEIVED
assert result.content == "Hello world"
assert result.message_type == MessageType.TEXT
def test_space_message(self):
payload = self._sample_payload()
result = normalize_inbound("test-ch", ChannelType.GOOGLE_CHAT, payload)
assert result.chat_type == ChatType.SPACE
def test_thread_message(self):
payload = self._sample_payload(
message={"thread": {"threadKey": "thread-001"}}
)
result = normalize_inbound("test-ch", ChannelType.GOOGLE_CHAT, payload)
assert result.chat_type == ChatType.THREAD
assert result.metadata["thread_key"] == "thread-001"
def test_card_action_event(self):
payload = self._sample_payload(
event_type="CARD_CLICKED",
action={"actionMethodName": "test_action"},
)
result = normalize_inbound("test-ch", ChannelType.GOOGLE_CHAT, payload)
assert result.event_type == EventType.CARD_ACTION
assert result.message_type == MessageType.CARD
def test_added_to_space(self):
payload = self._sample_payload(event_type="ADDED_TO_SPACE")
result = normalize_inbound("test-ch", ChannelType.GOOGLE_CHAT, payload)
assert result.event_type == EventType.BOT_ADDED
def test_removed_from_space(self):
payload = self._sample_payload(event_type="REMOVED_FROM_SPACE")
result = normalize_inbound("test-ch", ChannelType.GOOGLE_CHAT, payload)
assert result.event_type == EventType.BOT_REMOVED
def test_message_update(self):
payload = self._sample_payload(event_type="MESSAGE_UPDATE")
result = normalize_inbound("test-ch", ChannelType.GOOGLE_CHAT, payload)
assert result.event_type == EventType.MESSAGE_UPDATED
def test_slash_command(self):
payload = self._sample_payload(
message={"text": "/help"},
)
result = normalize_inbound("test-ch", ChannelType.GOOGLE_CHAT, payload)
assert result.message_type == MessageType.COMMAND
assert result.metadata["slash_command"] == "/help"
def test_slash_command_with_args(self):
payload = self._sample_payload(
message={"text": "/reset confirm"},
)
result = normalize_inbound("test-ch", ChannelType.GOOGLE_CHAT, payload)
assert result.metadata["slash_command"] == "/reset"
assert result.metadata["slash_args"] == "confirm"
def test_unknown_slash_command_treated_as_text(self):
payload = self._sample_payload(
message={"text": "/unknown_cmd"},
)
result = normalize_inbound("test-ch", ChannelType.GOOGLE_CHAT, payload)
assert result.message_type == MessageType.TEXT
assert result.metadata["slash_command"] is None
def test_empty_content(self):
payload = self._sample_payload(
message={"text": ""},
)
result = normalize_inbound("test-ch", ChannelType.GOOGLE_CHAT, payload)
assert result.content == ""
def test_metadata_contains_space_info(self):
payload = self._sample_payload()
result = normalize_inbound("test-ch", ChannelType.GOOGLE_CHAT, payload)
assert result.metadata["space_name"] == "spaces/ABC"
assert result.metadata["space_type"] == "SPACE"
assert result.metadata["sender_name"] == "users/user1@example.com"
assert result.metadata["sender_type"] == "HUMAN"
def test_edited_message(self):
payload = self._sample_payload(
message={"createTime": "2024-01-01T00:00:00Z", "lastUpdateTime": "2024-01-02T00:00:00Z"}
)
result = normalize_inbound("test-ch", ChannelType.GOOGLE_CHAT, payload)
assert result.metadata["is_edited"] is True
# ============================================================================
# mentions.py tests
# ============================================================================
class TestParseMentions:
def test_no_annotations(self):
result = parse_mentions({})
assert result.mentioned_user_ids == []
assert result.is_bot_mentioned is False
def test_user_mention(self):
msg = {
"annotations": [
{"type": "USER_MENTION", "userMention": {"user": {"name": "users/user1"}, "type": "HUMAN"}}
]
}
result = parse_mentions(msg)
assert "users/user1" in result.mentioned_user_ids
assert result.is_bot_mentioned is False
def test_bot_mention_by_type(self):
msg = {
"annotations": [
{"type": "USER_MENTION", "userMention": {"type": "BOT"}}
]
}
result = parse_mentions(msg)
assert result.is_bot_mentioned is True
def test_bot_mention_by_name(self):
msg = {
"annotations": [
{"type": "USER_MENTION", "userMention": {"user": {"name": "users/bot@example.com"}, "type": "HUMAN"}}
]
}
result = parse_mentions(msg, bot_user="users/bot@example.com")
assert result.is_bot_mentioned is True
def test_multiple_mentions(self):
msg = {
"annotations": [
{"type": "USER_MENTION", "userMention": {"user": {"name": "users/user1"}}},
{"type": "USER_MENTION", "userMention": {"user": {"name": "users/user2"}}},
{"type": "SLASH_COMMAND", "slashCommand": {}},
]
}
result = parse_mentions(msg)
assert len(result.mentioned_user_ids) == 2
assert "users/user1" in result.mentioned_user_ids
assert "users/user2" in result.mentioned_user_ids
def test_empty_annotations_list(self):
result = parse_mentions({"annotations": []})
assert result.mentioned_user_ids == []
def test_non_user_mention_annotation(self):
msg = {
"annotations": [
{"type": "SLASH_COMMAND", "slashCommand": {}},
]
}
result = parse_mentions(msg)
assert result.mentioned_user_ids == []
assert result.is_bot_mentioned is False
# ============================================================================
# formatter.py tests
# ============================================================================
class TestSanitizeText:
def test_null_bytes_removed(self):
assert sanitize_text("hel\x00lo") == "hello"
def test_control_chars_removed(self):
assert sanitize_text("hello\x01\x08\x0b\x0c\x0e") == "hello"
def test_trailing_whitespace_stripped(self):
assert sanitize_text(" hello \n ") == " hello"
def test_normal_text_unchanged(self):
assert sanitize_text("Hello World 你好") == "Hello World 你好"
def test_empty_string(self):
assert sanitize_text("") == ""
def test_only_control_chars(self):
assert sanitize_text("\x00\x01\x02") == ""
class TestChunkTextForOutbound:
def test_empty_text(self):
assert chunk_text_for_outbound("") == []
def test_short_text(self):
result = chunk_text_for_outbound("Hello", 4000)
assert result == ["Hello"]
def test_text_at_limit(self):
text = "x" * 4000
result = chunk_text_for_outbound(text, 4000)
assert len(result) == 1
assert result[0] == text
def test_text_over_limit_chunked(self):
text = "A" * 8000
result = chunk_text_for_outbound(text, 2000)
assert len(result) >= 4
total = sum(len(c) for c in result)
assert total >= len(text) - 100
def test_long_text_with_newlines(self):
text = "Line 1\n\nLine 2\nLine 3" * 1000
result = chunk_text_for_outbound(text, 4000)
assert len(result) >= 1
for chunk in result:
assert len(chunk) <= 4000
def test_all_chunks_within_limit(self):
text = "B" * 10000
result = chunk_text_for_outbound(text, 3000)
for chunk in result:
assert len(chunk) <= 3000
def test_chunk_with_custom_limit(self):
text = "C" * 500
result = chunk_text_for_outbound(text, 200)
assert len(result) >= 3
for chunk in result:
assert len(chunk) <= 200
def test_no_empty_chunks(self):
text = "D" * 500
result = chunk_text_for_outbound(text, 200)
for chunk in result:
assert len(chunk) > 0
def test_code_block_preserved(self):
text = "Before\n```\ncode block here\n```\nAfter" * 100
result = chunk_text_for_outbound(text, 4000)
assert len(result) >= 1
def test_whitespace_only_treated_as_empty(self):
assert chunk_text_for_outbound(" ") == [" "]
class TestFormatOutbound:
def _make_response(self, **overrides):
defaults = {
"identity": ChannelIdentity(
channel_id="gchat",
channel_type=ChannelType.GOOGLE_CHAT,
channel_user_id="users/user1",
channel_chat_id="spaces/ABC",
),
"content": "Hello",
}
defaults.update(overrides)
return ChannelResponse(**defaults)
def test_text_output(self):
payload = format_outbound(self._make_response(content="Hello World"))
assert payload["text"] == "Hello World"
def test_empty_content(self):
payload = format_outbound(self._make_response(content=""))
assert payload["text"] in (" ", "")
def test_image_attachment(self):
resp = self._make_response(
attachments=[Attachment(type="image", url="https://example.com/img.png", filename="photo.png")]
)
payload = format_outbound(resp)
assert "cards_v2" in payload
assert len(payload["cards_v2"]) == 1
widget = payload["cards_v2"][0]["card"]["sections"][0]["widgets"][0]
assert widget["image"]["imageUrl"] == "https://example.com/img.png"
assert widget["image"]["altText"] == "photo.png"
def test_video_attachment(self):
resp = self._make_response(
attachments=[Attachment(type="video", url="https://example.com/vid.mp4")]
)
payload = format_outbound(resp)
assert "cards_v2" in payload
widget = payload["cards_v2"][0]["card"]["sections"][0]["widgets"][0]
assert widget["image"]["imageUrl"] == "https://example.com/vid.mp4"
def test_thread_key(self):
resp = self._make_response(metadata={"thread_key": "thread-001"})
payload = format_outbound(resp)
assert payload["messageReplyOption"] == "REPLY_MESSAGE_FALLBACK_TO_NEW_THREAD"
assert payload["thread"]["threadKey"] == "thread-001"
def test_silent_notification(self):
resp = self._make_response(metadata={"silent": True})
payload = format_outbound(resp)
assert payload["disableNotification"] is True
def test_prebuilt_card(self):
resp = self._make_response(
metadata={"card": {"cards_v2": [{"card_id": "card1", "card": {}}]}}
)
payload = format_outbound(resp)
assert "cards_v2" in payload
assert payload["cards_v2"][0]["card_id"] == "card1"
def test_approval_card_type(self):
resp = self._make_response(
content="Approve this",
metadata={"card_type": "approval", "card_params": {"title": "Approval Request"}},
)
payload = format_outbound(resp)
assert "cards_v2" in payload
def test_poll_card_type(self):
resp = self._make_response(
content="Poll question",
metadata={
"card_type": "poll",
"card_params": {"options": ["Yes", "No"]},
},
)
payload = format_outbound(resp)
assert "cards_v2" in payload
def test_info_card_type(self):
resp = self._make_response(
metadata={
"card_type": "info",
"card_params": {"fields": {"Key": "Value"}},
},
)
payload = format_outbound(resp)
assert "cards_v2" in payload
def test_form_card_type(self):
resp = self._make_response(
metadata={
"card_type": "form",
"card_params": {"fields": [{"name": "email", "label": "Email"}]},
},
)
payload = format_outbound(resp)
assert "cards_v2" in payload
def test_selection_card_type(self):
resp = self._make_response(
metadata={
"card_type": "selection",
"card_params": {"options": [{"text": "Opt A", "value": "a"}]},
},
)
payload = format_outbound(resp)
assert "cards_v2" in payload
def test_exec_approval_card_type(self):
resp = self._make_response(
metadata={
"card_type": "exec_approval",
"card_params": {"title": "Execute?"},
},
)
payload = format_outbound(resp)
assert "cards_v2" in payload
def test_long_content_truncated(self):
long_text = "A" * 5000
resp = self._make_response(content=long_text)
payload = format_outbound(resp)
assert len(payload["text"]) <= 4003
assert payload["text"].endswith("...")
def test_no_metadata_no_extras(self):
payload = format_outbound(self._make_response())
assert "cards_v2" not in payload
assert "thread" not in payload
class TestResolveReplyToMode:
def test_empty_config(self):
assert resolve_reply_to_mode(None) == "off"
assert resolve_reply_to_mode({}) == "off"
def test_valid_modes(self):
assert resolve_reply_to_mode({"replyToMode": "first"}) == "first"
assert resolve_reply_to_mode({"replyToMode": "all"}) == "all"
assert resolve_reply_to_mode({"replyToMode": "off"}) == "off"
def test_snake_case_key(self):
assert resolve_reply_to_mode({"reply_to_mode": "first"}) == "first"
def test_invalid_mode_defaults(self):
assert resolve_reply_to_mode({"replyToMode": "invalid"}) == "off"
def test_case_insensitive(self):
assert resolve_reply_to_mode({"replyToMode": "FIRST"}) == "first"
def test_whitespace_trimmed(self):
assert resolve_reply_to_mode({"replyToMode": " all "}) == "all"
# ============================================================================
# policy.py tests
# ============================================================================
class TestDmPolicy:
def test_enum_values(self):
assert DmPolicy.OPEN == "open"
assert DmPolicy.PAIRING == "pairing"
assert DmPolicy.ALLOWLIST == "allowlist"
assert DmPolicy.DISABLED == "disabled"
class TestGroupPolicy:
def test_enum_values(self):
assert GroupPolicy.OPEN == "open"
assert GroupPolicy.ALLOWLIST == "allowlist"
assert GroupPolicy.DISABLED == "disabled"
class TestGoogleChatPolicy:
def test_from_config_empty(self):
policy = GoogleChatPolicy.from_config(None)
assert policy.dm_policy == DmPolicy.OPEN
assert policy.group_policy == GroupPolicy.OPEN
assert policy.allow_from == []
assert policy.require_mention is False
def test_from_config_with_values(self):
policy = GoogleChatPolicy.from_config({
"dmPolicy": "allowlist",
"groupPolicy": "disabled",
"requireMention": True,
"allowFrom": ["users/a@example.com"],
})
assert policy.dm_policy == DmPolicy.ALLOWLIST
assert policy.group_policy == GroupPolicy.DISABLED
assert policy.require_mention is True
assert "users/a@example.com" in policy.allow_from
def test_from_config_snake_case_keys(self):
policy = GoogleChatPolicy.from_config({
"dm_policy": "pairing",
"group_policy": "allowlist",
"allow_from": ["users/b@example.com"],
"group_allow_from": ["spaces/ABC"],
})
assert policy.dm_policy == DmPolicy.PAIRING
assert policy.group_policy == GroupPolicy.ALLOWLIST
assert "users/b@example.com" in policy.allow_from
assert "spaces/ABC" in policy.group_allow_from
def test_from_config_comma_separated_allow_from(self):
policy = GoogleChatPolicy.from_config({
"allowFrom": "users/a@x.com, users/b@x.com",
})
assert len(policy.allow_from) == 2
def test_from_config_invalid_dm_policy_defaults_to_open(self):
policy = GoogleChatPolicy.from_config({"dmPolicy": "invalid"})
assert policy.dm_policy == DmPolicy.OPEN
def test_from_config_invalid_group_policy_defaults_to_open(self):
policy = GoogleChatPolicy.from_config({"groupPolicy": "invalid"})
assert policy.group_policy == GroupPolicy.OPEN
def test_check_dm_access_open(self):
policy = GoogleChatPolicy(dm_policy=DmPolicy.OPEN)
assert policy.check_dm_access("any_user") is True
def test_check_dm_access_disabled(self):
policy = GoogleChatPolicy(dm_policy=DmPolicy.DISABLED)
assert policy.check_dm_access("any_user") is False
def test_check_dm_access_pairing(self):
policy = GoogleChatPolicy(dm_policy=DmPolicy.PAIRING)
assert policy.check_dm_access("any_user") is True
def test_check_dm_access_allowlist_match(self):
policy = GoogleChatPolicy(dm_policy=DmPolicy.ALLOWLIST, allow_from=["users/a@x.com"])
assert policy.check_dm_access("users/a@x.com") is True
def test_check_dm_access_allowlist_no_match(self):
policy = GoogleChatPolicy(dm_policy=DmPolicy.ALLOWLIST, allow_from=["users/a@x.com"])
assert policy.check_dm_access("users/b@x.com") is False
def test_check_dm_access_allowlist_wildcard(self):
policy = GoogleChatPolicy(dm_policy=DmPolicy.ALLOWLIST, allow_from=["*"])
assert policy.check_dm_access("any_user") is True
def test_check_dm_access_empty_allowlist(self):
policy = GoogleChatPolicy(dm_policy=DmPolicy.ALLOWLIST, allow_from=[])
assert policy.check_dm_access("any_user") is False
def test_check_group_access_open(self):
policy = GoogleChatPolicy(group_policy=GroupPolicy.OPEN)
assert policy.check_group_access("spaces/ABC") is True
def test_check_group_access_disabled(self):
policy = GoogleChatPolicy(group_policy=GroupPolicy.DISABLED)
assert policy.check_group_access("spaces/ABC") is False
def test_check_group_access_allowlist_match(self):
policy = GoogleChatPolicy(group_policy=GroupPolicy.ALLOWLIST, group_allow_from=["spaces/ABC"])
assert policy.check_group_access("spaces/ABC") is True
def test_check_group_access_allowlist_no_match(self):
policy = GoogleChatPolicy(group_policy=GroupPolicy.ALLOWLIST, group_allow_from=["spaces/XYZ"])
assert policy.check_group_access("spaces/ABC") is False
def test_check_group_per_group_disabled(self):
policy = GoogleChatPolicy(
group_policy=GroupPolicy.OPEN,
groups={"spaces/ABC": MagicMock(enabled=False)},
)
policy.groups["spaces/ABC"].enabled = False
assert policy.check_group_access("spaces/ABC") is False
def test_check_inbound_direct_open(self):
policy = GoogleChatPolicy(dm_policy=DmPolicy.OPEN)
msg = ChannelMessage(
identity=ChannelIdentity(channel_id="gc", channel_type=ChannelType.GOOGLE_CHAT, channel_user_id="users/u1", channel_chat_id="spaces/dm"),
chat_type=ChatType.DIRECT,
content="hi",
)
assert policy.check_inbound(msg) is True
def test_check_inbound_direct_disabled(self):
policy = GoogleChatPolicy(dm_policy=DmPolicy.DISABLED)
msg = ChannelMessage(
identity=ChannelIdentity(channel_id="gc", channel_type=ChannelType.GOOGLE_CHAT, channel_user_id="users/u1", channel_chat_id="spaces/dm"),
chat_type=ChatType.DIRECT,
content="hi",
)
assert policy.check_inbound(msg) is False
def test_check_inbound_group_require_mention_not_mentioned(self):
policy = GoogleChatPolicy(require_mention=True)
msg = ChannelMessage(
identity=ChannelIdentity(channel_id="gc", channel_type=ChannelType.GOOGLE_CHAT, channel_user_id="users/u1", channel_chat_id="spaces/ABC"),
chat_type=ChatType.SPACE,
content="Hello",
mentions=MentionsInfo(mentioned_user_ids=[], is_bot_mentioned=False),
metadata={"space_name": "spaces/ABC"},
)
assert policy.check_inbound(msg) is False
def test_check_inbound_group_require_mention_mentioned(self):
policy = GoogleChatPolicy(require_mention=True)
msg = ChannelMessage(
identity=ChannelIdentity(channel_id="gc", channel_type=ChannelType.GOOGLE_CHAT, channel_user_id="users/u1", channel_chat_id="spaces/ABC"),
chat_type=ChatType.SPACE,
content="Hello @bot",
mentions=MentionsInfo(mentioned_user_ids=["users/bot"], is_bot_mentioned=True),
metadata={"space_name": "spaces/ABC"},
)
assert policy.check_inbound(msg) is True
def test_check_inbound_direct_ignores_require_mention(self):
policy = GoogleChatPolicy(require_mention=True, dm_policy=DmPolicy.OPEN)
msg = ChannelMessage(
identity=ChannelIdentity(channel_id="gc", channel_type=ChannelType.GOOGLE_CHAT, channel_user_id="users/u1", channel_chat_id="spaces/dm"),
chat_type=ChatType.DIRECT,
content="Hello",
mentions=MentionsInfo(mentioned_user_ids=[], is_bot_mentioned=False),
)
assert policy.check_inbound(msg) is True
def test_per_group_users_whitelist(self):
from yuxi.channels.adapters.googlechat.policy import PerGroupConfig
policy = GoogleChatPolicy(
group_policy=GroupPolicy.OPEN,
groups={"spaces/ABC": PerGroupConfig(users=["users/allowed_user"])},
)
msg = ChannelMessage(
identity=ChannelIdentity(channel_id="gc", channel_type=ChannelType.GOOGLE_CHAT, channel_user_id="users/allowed_user", channel_chat_id="spaces/ABC"),
chat_type=ChatType.SPACE,
content="Hello",
metadata={"space_name": "spaces/ABC"},
)
assert policy.check_inbound(msg) is True
def test_per_group_users_whitelist_blocked(self):
from yuxi.channels.adapters.googlechat.policy import PerGroupConfig
policy = GoogleChatPolicy(
group_policy=GroupPolicy.OPEN,
groups={"spaces/ABC": PerGroupConfig(users=["users/allowed_user"])},
)
msg = ChannelMessage(
identity=ChannelIdentity(channel_id="gc", channel_type=ChannelType.GOOGLE_CHAT, channel_user_id="users/blocked_user", channel_chat_id="spaces/ABC"),
chat_type=ChatType.SPACE,
content="Hello",
metadata={"space_name": "spaces/ABC"},
)
assert policy.check_inbound(msg) is False
def test_get_per_group_system_prompt(self):
from yuxi.channels.adapters.googlechat.policy import PerGroupConfig
policy = GoogleChatPolicy(
groups={"spaces/ABC": PerGroupConfig(system_prompt="Custom prompt")},
)
assert policy.get_per_group_system_prompt("spaces/ABC") == "Custom prompt"
assert policy.get_per_group_system_prompt("spaces/XYZ") == ""
# ============================================================================
# slash_commands.py tests
# ============================================================================
class TestExtractCommand:
def test_help_command(self):
cmd, args = extract_command("/help")
assert cmd == "/help"
assert args == ""
def test_reset_command_with_args(self):
cmd, args = extract_command("/reset confirm")
assert cmd == "/reset"
assert args == "confirm"
def test_status_command(self):
cmd, args = extract_command("/status")
assert cmd == "/status"
def test_history_command(self):
cmd, args = extract_command("/history")
assert cmd == "/history"
def test_context_command(self):
cmd, args = extract_command("/context")
assert cmd == "/context"
def test_summary_command(self):
cmd, args = extract_command("/summary")
assert cmd == "/summary"
def test_not_a_command(self):
cmd, args = extract_command("Hello world")
assert cmd is None
assert args == "Hello world"
def test_unknown_command(self):
cmd, args = extract_command("/unknown_cmd with args")
assert cmd is None
assert args == "/unknown_cmd with args"
def test_empty_string(self):
cmd, args = extract_command("")
assert cmd is None
assert args == ""
def test_case_insensitive(self):
cmd, args = extract_command("/HELP")
assert cmd == "/help"
def test_command_with_trailing_whitespace(self):
cmd, args = extract_command(" /help ")
assert cmd == "/help"
class TestGetCommandHelp:
def test_returns_non_empty_string(self):
help_text = get_command_help()
assert len(help_text) > 0
assert "可用命令" in help_text
def test_includes_help_command(self):
help_text = get_command_help()
assert "/help" in help_text
def test_includes_reset_command(self):
help_text = get_command_help()
assert "/reset" in help_text
def test_includes_status_command(self):
help_text = get_command_help()
assert "/status" in help_text
# ============================================================================
# target.py tests
# ============================================================================
class TestStripMessageSuffix:
def test_strips_messages_suffix(self):
result, was_stripped = strip_message_suffix("spaces/ABC/messages/xyz123")
assert result == "spaces/ABC"
assert was_stripped is True
def test_no_strip_when_no_suffix(self):
result, was_stripped = strip_message_suffix("spaces/ABC")
assert result == "spaces/ABC"
assert was_stripped is False
def test_empty_string(self):
result, was_stripped = strip_message_suffix("")
assert result == ""
assert was_stripped is False
class TestDetectDeprecatedTarget:
def test_valid_spaces_prefix(self):
assert detect_deprecated_target("spaces/ABC123") is None
def test_valid_users_prefix(self):
assert detect_deprecated_target("users/user@example.com") is None
def test_deprecated_email_target(self):
result = detect_deprecated_target("user@example.com")
assert result is not None
assert "user@example.com" in result
def test_with_channel_prefix_spaces(self):
assert detect_deprecated_target("googlechat:spaces/ABC") is None
def test_with_channel_prefix_users(self):
assert detect_deprecated_target("gchat:users/user@x.com") is None
class TestNormalizeGooglechatTarget:
def test_empty_target(self):
result = normalize_googlechat_target("")
assert result["type"] == "unknown"
def test_space_target(self):
result = normalize_googlechat_target("spaces/ABC123")
assert result["type"] == "space"
assert result["space_id"] == "ABC123"
assert result["space_name"] == "spaces/ABC123"
def test_thread_target(self):
result = normalize_googlechat_target("spaces/ABC123/threads/thread001")
assert result["type"] == "thread"
assert result["space_id"] == "ABC123"
assert result["thread_key"] == "thread001"
def test_user_target(self):
result = normalize_googlechat_target("users/user@example.com")
assert result["type"] == "user"
assert result["user_id"] == "user@example.com"
assert result["user_name"] == "users/user@example.com"
def test_user_colon_prefix(self):
result = normalize_googlechat_target("user:user@example.com")
assert result["type"] == "user"
assert result["user_id"] == "user@example.com"
def test_email_conversion(self):
result = normalize_googlechat_target("user@example.com")
assert result["type"] == "user"
assert result["user_id"] == "user@example.com"
def test_channel_prefix_stripped(self):
result = normalize_googlechat_target("googlechat:spaces/ABC")
assert result["type"] == "space"
def test_gchat_prefix_stripped(self):
result = normalize_googlechat_target("gchat:users/user@x.com")
assert result["type"] == "user"
def test_google_chat_prefix_stripped(self):
result = normalize_googlechat_target("google-chat:spaces/XYZ")
assert result["type"] == "space"
def test_unknown_target(self):
result = normalize_googlechat_target("some_random_string")
assert result["type"] == "unknown"
class TestIsGooglechatUserTarget:
def test_users_prefix(self):
assert is_googlechat_user_target("users/user@x.com") is True
def test_user_colon_prefix(self):
assert is_googlechat_user_target("user:user@x.com") is True
def test_with_channel_prefix(self):
assert is_googlechat_user_target("googlechat:users/user@x.com") is True
def test_not_user_target(self):
assert is_googlechat_user_target("spaces/ABC") is False
def test_email_only(self):
assert is_googlechat_user_target("user@x.com") is False
class TestIsGooglechatSpaceTarget:
def test_spaces_prefix(self):
assert is_googlechat_space_target("spaces/ABC") is True
def test_not_space_target(self):
assert is_googlechat_space_target("users/user@x.com") is False
def test_with_channel_prefix(self):
assert is_googlechat_space_target("gchat:spaces/ABC") is True
class TestResolveTargets:
def test_all_targets(self):
results = resolve_targets(["spaces/ABC", "users/user@x.com", "spaces/XYZ/threads/t1"])
assert len(results) == 3
assert results[0]["type"] == "space"
assert results[1]["type"] == "user"
assert results[2]["type"] == "thread"
def test_filter_by_kind_space(self):
results = resolve_targets(
["spaces/ABC", "users/user@x.com", "spaces/XYZ"],
kind="space",
)
assert len(results) == 2
assert all(r["type"] == "space" for r in results)
def test_filter_by_kind_user(self):
results = resolve_targets(
["spaces/ABC", "users/user@x.com", "users/user2@y.com"],
kind="user",
)
assert len(results) == 2
assert all(r["type"] == "user" for r in results)
def test_empty_input(self):
assert resolve_targets([]) == []
def test_unknown_targets_filtered_by_kind(self):
results = resolve_targets(["unknown_str"], kind="space")
assert results == []
# ============================================================================
# threads.py tests
# ============================================================================
class TestParseThreadKey:
def test_has_thread_key(self):
msg = {"thread": {"threadKey": "thread-001"}}
assert parse_thread_key(msg) == "thread-001"
def test_no_thread_key(self):
msg = {"thread": {}}
assert parse_thread_key(msg) is None
def test_no_thread_field(self):
assert parse_thread_key({}) is None
class TestExtractThreadMetadata:
def test_full_metadata(self):
msg = {"thread": {"threadKey": "t1", "name": "spaces/ABC/threads/t1"}}
result = extract_thread_metadata(msg)
assert result["thread_key"] == "t1"
assert result["thread_name"] == "spaces/ABC/threads/t1"
assert result["is_thread_root"] is False
def test_is_thread_root(self):
msg = {"thread": {}}
result = extract_thread_metadata(msg)
assert result["is_thread_root"] is True
def test_no_thread_field(self):
result = extract_thread_metadata({})
assert result["thread_key"] is None
assert result["is_thread_root"] is True
class TestIsThreadMessage:
def test_is_thread(self):
assert is_thread_message({"thread": {"threadKey": "t1"}}) is True
def test_is_not_thread(self):
assert is_thread_message({"thread": {}}) is False
def test_no_thread_field(self):
assert is_thread_message({}) is False
# ============================================================================
# session.py tests
# ============================================================================
class TestBuildThreadId:
def test_space_session(self):
tid = build_thread_id("agent1", "spaces/ABC123")
assert tid == "agent:agent1:googlechat:space:ABC123"
def test_dm_session(self):
tid = build_thread_id("agent1", "spaces/DM_123", "user@example.com")
assert tid == "agent:agent1:googlechat:space:DM_123"
def test_empty_user_email(self):
tid = build_thread_id("agent1", "spaces/ABC123", "")
assert "space" in tid
# ============================================================================
# approval_auth.py tests
# ============================================================================
class TestNormalizeApproverId:
def test_empty(self):
assert normalize_approver_id("") == ""
def test_already_prefixed_users(self):
assert normalize_approver_id("users/user@x.com") == "users/user@x.com"
def test_already_prefixed_user_colon(self):
assert normalize_approver_id("user:user@x.com") == "user:user@x.com"
def test_email_without_prefix(self):
assert normalize_approver_id("user@example.com") == "users/user@example.com"
def test_plain_id(self):
assert normalize_approver_id("user123") == "users/user123"
class TestResolveApproverIds:
def test_normal_entries(self):
result = resolve_approver_ids(["user@a.com", "user@b.com"])
assert len(result) == 2
assert "users/user@a.com" in result
assert "users/user@b.com" in result
def test_skip_wildcard(self):
result = resolve_approver_ids(["*", "user@a.com"])
assert len(result) == 1
assert "users/user@a.com" in result
def test_empty_with_default(self):
result = resolve_approver_ids([], default_to="user@default.com")
assert result == ["users/user@default.com"]
def test_empty_no_default(self):
result = resolve_approver_ids([])
assert result == []
def test_all_wildcards(self):
result = resolve_approver_ids(["*"])
assert result == []
class TestBuildExecApprovalContext:
def test_basic_context(self):
ctx = build_exec_approval_context("action_1", ["approver1", "approver2"])
assert ctx["action_id"] == "action_1"
assert ctx["approvers"] == ["approver1", "approver2"]
assert ctx["approved_by"] == []
assert ctx["rejected_by"] == []
def test_with_metadata(self):
ctx = build_exec_approval_context("action_2", ["approver1"], {"key": "value"})
assert ctx["metadata"]["key"] == "value"
def test_default_metadata_is_dict(self):
ctx = build_exec_approval_context("action_3", [])
assert ctx["metadata"] == {}
class TestRecordApprovalDecision:
def test_approve_success(self):
ctx = build_exec_approval_context("a1", ["users/u1", "users/u2"])
assert record_approval_decision(ctx, "users/u1", approved=True) is True
assert "users/u1" in ctx["approved_by"]
def test_reject_success(self):
ctx = build_exec_approval_context("a1", ["users/u1"])
assert record_approval_decision(ctx, "users/u1", approved=False) is True
assert "users/u1" in ctx["rejected_by"]
def test_unauthorized_user(self):
ctx = build_exec_approval_context("a1", ["users/u1"])
assert record_approval_decision(ctx, "users/u2", approved=True) is False
def test_duplicate_approval(self):
ctx = build_exec_approval_context("a1", ["users/u1"])
record_approval_decision(ctx, "users/u1", approved=True)
assert record_approval_decision(ctx, "users/u1", approved=True) is True
assert len(ctx["approved_by"]) == 1
def test_duplicate_rejection(self):
ctx = build_exec_approval_context("a1", ["users/u1"])
record_approval_decision(ctx, "users/u1", approved=False)
assert record_approval_decision(ctx, "users/u1", approved=False) is True
assert len(ctx["rejected_by"]) == 1
def test_normalizes_user_id(self):
ctx = build_exec_approval_context("a1", ["users/u1@x.com"])
record_approval_decision(ctx, "u1@x.com", approved=True)
assert "users/u1@x.com" in ctx["approved_by"]
class TestIsFullyApproved:
def test_all_approved(self):
ctx = build_exec_approval_context("a1", ["u1", "u2"])
ctx["approved_by"] = ["u1", "u2"]
assert is_fully_approved(ctx) is True
def test_partial_approved(self):
ctx = build_exec_approval_context("a1", ["u1", "u2"])
ctx["approved_by"] = ["u1"]
assert is_fully_approved(ctx) is False
def test_none_approved(self):
ctx = build_exec_approval_context("a1", ["u1", "u2"])
assert is_fully_approved(ctx) is False
def test_empty_approvers(self):
ctx = build_exec_approval_context("a1", [])
assert is_fully_approved(ctx) is False
# ============================================================================
# directory.py tests
# ============================================================================
class TestNormalizeId:
def test_empty(self):
assert normalize_id("") == ""
def test_spaces_prefix(self):
assert normalize_id("spaces/ABC") == "spaces/ABC"
def test_users_prefix(self):
assert normalize_id("users/user@x.com") == "users/user@x.com"
def test_user_colon_prefix(self):
assert normalize_id("user:user@x.com") == "users/user@x.com"
def test_email(self):
assert normalize_id("user@example.com") == "users/user@example.com"
def test_plain_id(self):
assert normalize_id("someuser") == "users/someuser"
def test_whitespace_trimmed(self):
assert normalize_id(" users/abc ") == "users/abc"
class TestListPeers:
def test_normal_entries(self):
peers = list_peers(["user@a.com", "user@b.com"])
assert len(peers) == 2
assert peers[0]["type"] == "user"
assert peers[0]["id"] == "users/user@a.com"
def test_skip_wildcard(self):
peers = list_peers(["*", "user@a.com"])
assert len(peers) == 1
def test_empty_list(self):
assert list_peers([]) == []
class TestListGroups:
def test_empty_config(self):
assert list_groups({}) == []
def test_groups_with_config(self):
groups = list_groups({
"spaces/ABC": {"name": "General", "requireMention": True},
"spaces/XYZ": {"name": "Support", "enabled": False},
})
assert len(groups) == 2
assert groups[0]["id"] == "spaces/ABC"
assert groups[1]["requires_mention"] is False
def test_non_dict_entries_skipped(self):
groups = list_groups({
"spaces/ABC": "not_a_dict",
})
assert groups == []
# ============================================================================
# proxy.py tests
# ============================================================================
class TestProxyConfig:
def test_empty_config(self):
result = resolve_proxy_config()
assert result["http"] is None
assert result["https"] is None
def test_config_provided(self):
result = resolve_proxy_config({"httpProxy": "http://proxy:8080"})
assert result["http"] == "http://proxy:8080"
def test_https_fallback_to_http(self):
result = resolve_proxy_config({"httpProxy": "http://proxy:8080"})
assert result["https"] == "http://proxy:8080"
def test_env_var_fallback(self, monkeypatch):
monkeypatch.setenv("HTTP_PROXY", "http://env-proxy:3128")
result = resolve_proxy_config()
assert result["http"] == "http://env-proxy:3128"
def test_no_proxy_from_config(self):
result = resolve_proxy_config({"noProxy": "localhost,.local"})
assert result["no_proxy"] == "localhost,.local"
class TestBuildProxiesDict:
def test_empty_config(self):
assert build_proxies_dict({}) is None
def test_http_only(self):
result = build_proxies_dict({"http": "http://proxy:8080"})
assert result is not None
assert result["http"] == "http://proxy:8080"
def test_all_proxies(self):
result = build_proxies_dict({
"http": "http://proxy:8080",
"https": "https://proxy:8443",
"no_proxy": "localhost",
})
assert result is not None
assert result["http"] == "http://proxy:8080"
assert result["https"] == "https://proxy:8443"
assert result["no_proxy"] == "localhost"
class TestResolveTlsConfig:
def test_empty_config(self):
result = resolve_tls_config()
assert result["cert"] is None
assert result["key"] is None
def test_config_provided(self):
result = resolve_tls_config({"cert": "/path/cert.pem", "key": "/path/key.pem"})
assert result["cert"] == "/path/cert.pem"
assert result["key"] == "/path/key.pem"
def test_env_var_fallback(self, monkeypatch):
monkeypatch.setenv("GOOGLE_CHAT_TLS_CERT", "/env/cert.pem")
result = resolve_tls_config()
assert result["cert"] == "/env/cert.pem"
def test_snake_case_keys(self):
result = resolve_tls_config({"tlsCert": "/path/cert.pem", "tlsKey": "/path/key.pem"})
assert result["cert"] == "/path/cert.pem"
assert result["key"] == "/path/key.pem"
# ============================================================================
# ssrf.py tests
# ============================================================================
class TestSSRF:
def test_sanitize_google_auth_init(self):
result = sanitize_google_auth_init({"timeout": 30, "verify": False})
assert "timeout" in result
assert "verify" in result
def test_sanitize_request_kwargs(self):
result = sanitize_request_kwargs({"timeout": 10})
assert isinstance(result, dict)
def test_apply_ssrf_guard(self):
result = apply_ssrf_guard()
assert isinstance(result, dict)
# ============================================================================
# cards.py tests
# ============================================================================
class TestBuildApprovalCard:
def test_basic_structure(self):
card = build_approval_card("Approve?", "action_1")
assert "cards_v2" in card
assert len(card["cards_v2"]) == 1
assert card["cards_v2"][0]["card"]["header"]["title"] == "Approve?"
def test_custom_button_texts(self):
card = build_approval_card("Title", "action_1", confirm_text="Yes", reject_text="No")
widgets = card["cards_v2"][0]["card"]["sections"][0]["widgets"][0]["buttonList"]["buttons"]
assert widgets[0]["text"] == "Yes"
assert widgets[1]["text"] == "No"
def test_has_two_buttons(self):
card = build_approval_card("Title", "action_1")
widgets = card["cards_v2"][0]["card"]["sections"][0]["widgets"][0]["buttonList"]["buttons"]
assert len(widgets) == 2
class TestBuildPollCard:
def test_basic_poll(self):
card = build_poll_card("Question?", ["A", "B"], "poll_1")
assert "cards_v2" in card
assert card["cards_v2"][0]["card"]["header"]["title"] == "Question?"
def test_multiple_options(self):
card = build_poll_card("Q", ["A", "B", "C", "D"], "poll_1")
assert len(card["cards_v2"][0]["card"]["sections"]) >= 2
class TestBuildInfoCard:
def test_basic_info_card(self):
card = build_info_card("Info", {"Key1": "Val1", "Key2": "Val2"})
assert "cards_v2" in card
widgets = card["cards_v2"][0]["card"]["sections"][0]["widgets"]
assert len(widgets) == 2
def test_decorated_text_structure(self):
card = build_info_card("Title", {"Key": "Value"})
widget = card["cards_v2"][0]["card"]["sections"][0]["widgets"][0]
assert widget["decoratedText"]["topLabel"] == "Key"
assert widget["decoratedText"]["text"] == "Value"
class TestBuildFormCard:
def test_text_input_field(self):
card = build_form_card(
"Form",
[{"name": "email", "label": "Email", "input_type": "SINGLE_LINE"}],
"submit_1",
)
widgets = card["cards_v2"][0]["card"]["sections"][0]["widgets"]
assert widgets[0]["textInput"]["name"] == "email"
def test_selection_field(self):
card = build_form_card(
"Form",
[{"name": "country", "label": "Country", "input_type": "SINGLE_SELECT", "options": [{"text": "CN", "value": "cn"}]}],
"submit_1",
)
widgets = card["cards_v2"][0]["card"]["sections"][0]["widgets"]
assert widgets[0]["selectionInput"]["name"] == "country"
def test_date_picker_field(self):
card = build_form_card(
"Form",
[{"name": "date", "label": "Date", "input_type": "DATE_AND_TIME"}],
"submit_1",
)
widgets = card["cards_v2"][0]["card"]["sections"][0]["widgets"]
assert "dateTimePicker" in widgets[0]
def test_divider_field(self):
card = build_form_card(
"Form",
[
{"name": "email", "label": "Email", "input_type": "SINGLE_LINE"},
{"input_type": "DIVIDER"},
],
"submit_1",
)
widgets = card["cards_v2"][0]["card"]["sections"][0]["widgets"]
assert "divider" in widgets[1]
def test_has_submit_button(self):
card = build_form_card("Form", [{"name": "x", "label": "X"}], "submit_1")
widgets = card["cards_v2"][0]["card"]["sections"][0]["widgets"]
last = widgets[-1]
assert "buttonList" in last
class TestBuildExecApprovalCard:
def test_basic_structure(self):
card = build_exec_approval_card("Execute?", "exec_1")
assert "cards_v2" in card
def test_with_detail_fields(self):
card = build_exec_approval_card("Execute?", "exec_1", {"Module": "Payments"})
widgets = card["cards_v2"][0]["card"]["sections"][0]["widgets"]
assert widgets[0]["decoratedText"]["topLabel"] == "Module"
def test_has_two_buttons(self):
card = build_exec_approval_card("Execute?", "exec_1")
widgets = card["cards_v2"][0]["card"]["sections"][0]["widgets"]
buttons = widgets[-1]["buttonList"]["buttons"]
assert len(buttons) == 2
class TestBuildSelectionCard:
def test_basic_structure(self):
card = build_selection_card(
"Choose",
"select_1",
"Pick one",
[{"text": "Option A", "value": "a"}],
)
assert "cards_v2" in card
widget = card["cards_v2"][0]["card"]["sections"][0]["widgets"][0]
assert widget["selectionInput"]["type"] == "SINGLE_SELECT"
def test_multi_select(self):
card = build_selection_card(
"Choose",
"select_1",
"Pick",
[{"text": "A", "value": "a"}],
selection_type="MULTI_SELECT",
)
widget = card["cards_v2"][0]["card"]["sections"][0]["widgets"][0]
assert widget["selectionInput"]["type"] == "MULTI_SELECT"
def test_with_submit_button(self):
card = build_selection_card(
"Choose", "select_1", "Pick", [{"text": "A", "value": "a"}],
submit_action="submit_1",
)
widgets = card["cards_v2"][0]["card"]["sections"][0]["widgets"]
assert len(widgets) == 2
assert "buttonList" in widgets[1]
def test_no_submit_button(self):
card = build_selection_card(
"Choose", "select_1", "Pick", [{"text": "A", "value": "a"}],
)
widgets = card["cards_v2"][0]["card"]["sections"][0]["widgets"]
assert len(widgets) == 1
# ============================================================================
# streaming.py tests
# ============================================================================
class TestStreamManager:
@pytest.fixture
def stream_mgr(self):
return StreamManager()
def test_initial_state(self, stream_mgr):
assert stream_mgr.pending_count == 0
assert stream_mgr.has_pending("any") is False
assert stream_mgr.get_message_name("any") is None
assert stream_mgr.get_accumulated_text("any") == ""
@pytest.mark.asyncio
async def test_register_first_chunk(self, stream_mgr):
await stream_mgr.register_first_chunk("chat1", "msg1", "Hello")
assert stream_mgr.has_pending("chat1") is True
assert stream_mgr.get_message_name("chat1") == "msg1"
assert stream_mgr.get_accumulated_text("chat1") == "Hello"
@pytest.mark.asyncio
async def test_append_text(self, stream_mgr):
await stream_mgr.register_first_chunk("chat1", "msg1", "Hello")
result = await stream_mgr.append_text("chat1", " World")
assert result == "Hello World"
assert stream_mgr.get_accumulated_text("chat1") == "Hello World"
@pytest.mark.asyncio
async def test_append_to_nonexistent(self, stream_mgr):
result = await stream_mgr.append_text("new_chat", "First")
assert result == "First"
def test_should_update_initial(self, stream_mgr):
assert stream_mgr.should_update("chat1") is True
@pytest.mark.asyncio
async def test_should_update_after_mark(self, stream_mgr):
await stream_mgr.register_first_chunk("chat1", "msg1", "Hello")
await stream_mgr.mark_update("chat1")
assert stream_mgr.should_update("chat1") is False
@pytest.mark.asyncio
async def test_should_update_after_interval(self, stream_mgr):
stream_mgr._update_interval_ms = 1
await stream_mgr.register_first_chunk("chat1", "msg1", "Hello")
await stream_mgr.mark_update("chat1")
await asyncio.sleep(0.01)
assert stream_mgr.should_update("chat1") is True
@pytest.mark.asyncio
async def test_send_update_clears_on_finished(self, stream_mgr):
mock_service = MagicMock()
await stream_mgr.register_first_chunk("chat1", "msg1", "Hello")
await stream_mgr.append_text("chat1", " World")
with patch(
"yuxi.channels.adapters.googlechat.streaming.update_message",
new_callable=AsyncMock,
return_value=MagicMock(success=True),
):
result = await stream_mgr.send_update(mock_service, "chat1", finished=True)
assert stream_mgr.has_pending("chat1") is False
@pytest.mark.asyncio
async def test_send_update_not_finished_keeps_pending(self, stream_mgr):
mock_service = MagicMock()
await stream_mgr.register_first_chunk("chat1", "msg1", "Hello")
with patch(
"yuxi.channels.adapters.googlechat.streaming.update_message",
new_callable=AsyncMock,
return_value=MagicMock(success=True),
):
result = await stream_mgr.send_update(mock_service, "chat1", finished=False)
assert stream_mgr.has_pending("chat1") is True
@pytest.mark.asyncio
async def test_send_update_no_message(self, stream_mgr):
mock_service = MagicMock()
result = await stream_mgr.send_update(mock_service, "nonexistent")
assert result is None
@pytest.mark.asyncio
async def test_finalize(self, stream_mgr):
mock_service = MagicMock()
await stream_mgr.register_first_chunk("chat1", "msg1", "Hello")
with patch(
"yuxi.channels.adapters.googlechat.streaming.update_message",
new_callable=AsyncMock,
return_value=MagicMock(success=True, message_id="msg1"),
):
result = await stream_mgr.finalize(mock_service, "chat1")
assert stream_mgr.has_pending("chat1") is False
@pytest.mark.asyncio
async def test_finalize_no_pending(self, stream_mgr):
mock_service = MagicMock()
result = await stream_mgr.finalize(mock_service, "nonexistent")
assert result.success is False
@pytest.mark.asyncio
async def test_handle_error_cleanup(self, stream_mgr):
mock_service = MagicMock()
await stream_mgr.register_first_chunk("chat1", "msg1", "Hello")
await stream_mgr.handle_error(mock_service, "chat1")
assert stream_mgr.has_pending("chat1") is False
@pytest.mark.asyncio
async def test_clear(self, stream_mgr):
await stream_mgr.register_first_chunk("chat1", "msg1", "Hello")
await stream_mgr.register_first_chunk("chat2", "msg2", "World")
await stream_mgr.clear()
assert stream_mgr.pending_count == 0
@pytest.mark.asyncio
async def test_concurrent_registration(self, stream_mgr):
mgr = stream_mgr
await asyncio.gather(
mgr.register_first_chunk("chat1", "msg1", "A"),
mgr.register_first_chunk("chat2", "msg2", "B"),
mgr.register_first_chunk("chat3", "msg3", "C"),
)
assert mgr.pending_count == 3
# ============================================================================
# msg_cache.py tests
# ============================================================================
class TestSentMessageCache:
def test_put_and_get(self):
cache = SentMessageCache()
cache.put("msg1", {"chat_id": "chat1"})
result = cache.get("msg1")
assert result is not None
assert result["chat_id"] == "chat1"
def test_get_missing(self):
cache = SentMessageCache()
assert cache.get("nonexistent") is None
def test_expired_entry(self):
cache = SentMessageCache(max_age_s=0)
cache.put("msg1", {"chat_id": "chat1"})
assert cache.get("msg1") is None
def test_remove(self):
cache = SentMessageCache()
cache.put("msg1")
cache.remove("msg1")
assert cache.get("msg1") is None
def test_update_existing(self):
cache = SentMessageCache()
cache.put("msg1", {"chat_id": "chat1"})
cache.update("msg1", {"status": "delivered"})
result = cache.get("msg1")
assert result["chat_id"] == "chat1"
assert result["status"] == "delivered"
def test_update_missing(self):
cache = SentMessageCache()
cache.update("nonexistent", {"status": "delivered"})
def test_size(self):
cache = SentMessageCache(max_size=5)
for i in range(3):
cache.put(f"msg{i}")
assert cache.size() == 3
def test_max_size_eviction(self):
cache = SentMessageCache(max_size=3)
for i in range(5):
cache.put(f"msg{i}")
assert cache.size() == 3
def test_clear(self):
cache = SentMessageCache()
for i in range(5):
cache.put(f"msg{i}")
cache.clear()
assert cache.size() == 0
def test_expired_count(self):
cache = SentMessageCache(max_age_s=0)
cache.put("msg1")
cache.put("msg2")
assert cache.expired_count() == 2
def test_expired_count_none_expired(self):
cache = SentMessageCache(max_age_s=3600)
cache.put("msg1")
assert cache.expired_count() == 0
def test_put_with_no_metadata(self):
cache = SentMessageCache()
cache.put("msg1")
result = cache.get("msg1")
assert result == {}
class TestTrackSentMessage:
def test_tracks_message(self):
cache = SentMessageCache()
track_sent_message(cache, "msg1", "chat1", "text")
result = cache.get("msg1")
assert result is not None
assert result["chat_id"] == "chat1"
assert result["message_type"] == "text"
class TestUpdateSentMessageStatus:
def test_updates_status(self):
cache = SentMessageCache()
cache.put("msg1", {"chat_id": "chat1"})
update_sent_message_status(cache, "msg1", "delivered")
result = cache.get("msg1")
assert result["status"] == "delivered"
def test_updates_with_metadata(self):
cache = SentMessageCache()
cache.put("msg1")
update_sent_message_status(cache, "msg1", "failed", {"error": "timeout"})
result = cache.get("msg1")
assert result["status"] == "failed"
assert result["error"] == "timeout"
# ============================================================================
# doctor.py tests
# ============================================================================
class TestRunDiagnostics:
def test_empty_config(self):
assert run_diagnostics(None) == []
assert run_diagnostics({}) == []
def test_credential_missing_error(self):
issues = run_diagnostics({"some": "config"})
cred_issues = [i for i in issues if i["category"] == "credential_readiness"]
assert len(cred_issues) >= 1
assert cred_issues[0]["severity"] == "error"
def test_deprecated_stream_mode(self):
issues = run_diagnostics({"streamMode": "on"})
dep_issues = [i for i in issues if i["field"] == "streamMode"]
assert len(dep_issues) >= 1
def test_deprecated_enable_thread_reply(self):
issues = run_diagnostics({"enableThreadReply": True})
dep_issues = [i for i in issues if i["field"] == "enableThreadReply"]
assert len(dep_issues) >= 1
def test_mutable_allowlist_warning(self):
issues = run_diagnostics({"allowFrom": ["user@example.com"]})
mutable = [i for i in issues if i["category"] == "mutable_allowlist"]
assert len(mutable) >= 1
def test_credential_readiness_with_service_account(self):
issues = run_diagnostics({"service_account": "{}"})
cred_issues = [i for i in issues if i["category"] == "credential_readiness"]
assert len(cred_issues) == 0
# ============================================================================
# setup.py tests
# ============================================================================
class TestWizardValidateServiceAccount:
def test_valid_credentials(self):
result = wizard_validate_service_account({
"type": "service_account",
"private_key": "-----BEGIN PRIVATE KEY-----\nxxx\n-----END PRIVATE KEY-----\n",
"client_email": "sa@project.iam.gserviceaccount.com",
"token_uri": "https://oauth2.googleapis.com/token",
})
assert result["valid"] is True
assert result["errors"] == []
def test_invalid_type(self):
result = wizard_validate_service_account({"type": "authorized_user"})
assert result["valid"] is False
assert any("type" in e for e in result["errors"])
def test_missing_private_key(self):
result = wizard_validate_service_account({
"type": "service_account",
"client_email": "sa@project.iam.gserviceaccount.com",
"token_uri": "https://oauth2.googleapis.com/token",
})
assert result["valid"] is False
assert any("private_key" in e for e in result["errors"])
def test_missing_client_email(self):
result = wizard_validate_service_account({
"type": "service_account",
"private_key": "key",
"token_uri": "https://oauth2.googleapis.com/token",
})
assert result["valid"] is False
assert any("client_email" in e for e in result["errors"])
def test_missing_token_uri(self):
result = wizard_validate_service_account({
"type": "service_account",
"private_key": "key",
"client_email": "sa@project.iam.gserviceaccount.com",
})
assert result["valid"] is False
assert any("token_uri" in e for e in result["errors"])
class TestSetupWizard:
def test_start(self):
wizard = SetupWizard()
result = wizard.start()
assert result["step"] == 0
assert result["steps"] == 5
def test_next_advances(self):
wizard = SetupWizard()
wizard.start()
result = wizard.next("env_file")
assert result["step"] == 1
def test_full_flow(self):
wizard = SetupWizard()
wizard.start()
wizard.next("env_file")
wizard.next(None)
wizard.next({"audience_type": "app-url", "audience": "https://example.com"})
wizard.next({"dm_policy": "open", "group_policy": "open", "require_mention": False})
result = wizard.next(None)
assert result["done"] is True
assert "config" in result
assert result["config"]["enabled"] is True
def test_inline_credential_flow(self):
wizard = SetupWizard()
wizard.start()
wizard.next("inline")
wizard.next('{"type":"service_account"}')
wizard.next({"audience_type": "app-url"})
wizard.next({"dm_policy": "open"})
result = wizard.next(None)
assert result["done"] is True
assert result["config"].get("serviceAccount") == '{"type":"service_account"}'
def test_current_step(self):
wizard = SetupWizard()
wizard.start()
step = wizard.current_step
assert step is not None
assert "id" in step
def test_current_step_none_after_finish(self):
wizard = SetupWizard()
wizard.start()
for _ in range(5):
wizard.next()
assert wizard.current_step is None
class TestGetSetupGuide:
def test_returns_non_empty_string(self):
guide = get_setup_guide()
assert len(guide) > 100
assert "Google Cloud Console" in guide
# ============================================================================
# secret_contract.py tests
# ============================================================================
class TestSecretContracts:
def test_get_secret_contracts(self):
contracts = get_secret_contracts()
assert "serviceAccount" in contracts
assert contracts["serviceAccount"]["required"] is True
assert contracts["serviceAccount"]["sensitive"] is True
def test_get_required_secrets(self):
required = get_required_secrets()
assert "serviceAccount" in required
assert "serviceAccountFile" not in required
def test_get_sensitive_fields(self):
sensitive = get_sensitive_fields()
assert "serviceAccount" in sensitive
assert "webhookSecret" in sensitive
def test_is_sensitive_field(self):
assert is_sensitive_field("serviceAccount") is True
assert is_sensitive_field("some_key") is True
assert is_sensitive_field("my_secret") is True
assert is_sensitive_field("normal_field") is False
def test_register_secret_contract(self):
register_secret_contract("testContract", {"required": False, "sensitive": True})
contracts = get_secret_contracts()
assert "testContract" in contracts
def test_contracts_is_a_copy(self):
original = get_secret_contracts()
original["modified"] = {"required": False}
fresh = get_secret_contracts()
assert "modified" not in fresh
# ============================================================================
# auth.py tests
# ============================================================================
class TestGoogleChatCertCache:
def test_initial_state(self):
cache = GoogleChatCertCache()
assert cache.is_valid is False
@pytest.mark.asyncio
async def test_get_certs_returns_dict(self):
cache = GoogleChatCertCache()
with patch("httpx.AsyncClient.get") as mock_get:
mock_get.return_value = MagicMock(status_code=200, json=MagicMock(return_value={"key1": "cert1"}))
mock_get.return_value.raise_for_status = MagicMock()
mock_get.return_value.json = MagicMock(return_value={"key1": "cert1"})
certs = await cache.get_certs()
assert isinstance(certs, dict)
assert "key1" in certs
@pytest.mark.asyncio
async def test_get_certs_empty_on_error_no_cache(self):
cache = GoogleChatCertCache()
with patch("httpx.AsyncClient.get", side_effect=Exception("network error")):
certs = await cache.get_certs()
assert certs == {}
@pytest.mark.asyncio
async def test_get_certs_returns_stale_on_error(self):
cache = GoogleChatCertCache()
cache._cache = {"key1": "cert1"}
cache._cache_at = time.monotonic() - 100
with patch("httpx.AsyncClient.get", side_effect=Exception("network error")):
certs = await cache.get_certs()
assert certs == {"key1": "cert1"}
def test_clear(self):
cache = GoogleChatCertCache()
cache._cache = {"key1": "cert1"}
cache._cache_at = time.monotonic()
cache.clear()
assert cache.is_valid is False
assert cache._cache is None
def test_is_valid_within_ttl(self):
cache = GoogleChatCertCache(ttl_s=600)
cache._cache = {"key1": "cert1"}
cache._cache_at = time.monotonic()
assert cache.is_valid is True
def test_is_valid_expired(self):
cache = GoogleChatCertCache(ttl_s=0)
cache._cache = {"key1": "cert1"}
cache._cache_at = time.monotonic() - 1
assert cache.is_valid is False
class TestVerifyProjectNumberToken:
def test_no_certs(self):
assert verify_project_number_token("token", "123", {}) is False
def test_without_google_auth_installed(self):
import google.auth
if hasattr(google.auth, "jwt"):
pytest.skip("google.auth.jwt is installed — ImportError branch not testable")
assert verify_project_number_token("token", "123", {"key1": "cert1"}) is True
def test_invalid_token(self):
with patch("google.auth.jwt.decode", side_effect=ValueError("invalid")):
assert verify_project_number_token("token", "123", {"key1": "cert1"}) is False
class TestGetCertCache:
def test_singleton(self):
cache1 = get_cert_cache()
cache2 = get_cert_cache()
assert cache1 is cache2