本次提交新增了超过20个单元测试文件,覆盖Twitch、Telegram、WeChat、Discord、Slack、Mattermost等多个渠道适配器以及核心组件的测试: 1. 新增Twitch令牌工具、限流器、去重器相关测试 2. 新增Telegram分块、格式、安全策略、流媒体相关测试 3. 新增微信适配器、限流、会话、链接格式、监控等测试 4. 新增Discord命令、会话、格式化、Webhook相关测试 5. 新增Slack、Mattermost、Nostr、Signal、Zalo、NextcloudTalk等渠道的测试用例 6. 新增核心路由、会话路由、文本分块器的冒烟测试
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 |