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重连相关测试
2141 lines
78 KiB
Python
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 |