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