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