"""Unit tests for Google Chat adapter - adapter.py module. Tests cover: - GoogleChatAdapter initialization and configuration - CircuitBreaker integration - Connection timeout - send/edit/delete/reaction/stream methods - receive() stub - Health check - Token refresh """ from __future__ import annotations import asyncio import time from unittest.mock import AsyncMock, MagicMock, patch import pytest from yuxi.channels.exceptions import ( ChannelAuthenticationError, ChannelException, ) from yuxi.channels.models import ( ChannelIdentity, ChannelResponse, ChannelStatus, ChannelType, DeliveryResult, ) from yuxi.channels.adapters.googlechat.adapter import ( GoogleChatAdapter, ChatRateLimiter, ) @pytest.fixture def adapter(): return GoogleChatAdapter( config={ "name": "test-gc", "service_account_file": "/fake/sa.json", "project_id": "test-project", } ) @pytest.fixture def connected_adapter(): adapter = GoogleChatAdapter( config={ "name": "test-gc", "service_account_file": "/fake/sa.json", "project_id": "test-project", } ) adapter._status = ChannelStatus.CONNECTED adapter._chat_service = MagicMock() adapter._connected_at = 1234567890.0 adapter._service_account_email = "test@project.iam.gserviceaccount.com" return adapter class TestGoogleChatAdapterInit: def test_default_status_is_disconnected(self, adapter): assert adapter._status == ChannelStatus.DISCONNECTED def test_channel_id_class_var(self): assert GoogleChatAdapter.channel_id == "googlechat" def test_channel_type(self): assert GoogleChatAdapter.channel_type == ChannelType.GOOGLE_CHAT def test_supports_streaming(self): assert GoogleChatAdapter.supports_streaming is True def test_has_circuit_breaker(self, adapter): assert adapter._circuit_breaker is not None assert adapter._circuit_breaker.state == "closed" def test_has_rate_limiter(self, adapter): assert adapter._rate_limiter is not None class TestChatRateLimiter: def test_first_acquire_no_wait(self): limiter = ChatRateLimiter(ops_per_second=10.0) asyncio.run(limiter.acquire("space1")) def test_rapid_acquires_throttled(self): limiter = ChatRateLimiter(ops_per_second=0.5) start = time.monotonic() asyncio.run(limiter.acquire("space1")) asyncio.run(limiter.acquire("space1")) elapsed = time.monotonic() - start assert elapsed >= 1.9 class TestConnect: def test_connect_timeout(self, adapter): async def slow_connect(): await asyncio.sleep(99) with patch.object(adapter, "_connect_impl", side_effect=slow_connect): with pytest.raises(ChannelException, match="timed out"): asyncio.run(adapter.connect()) def test_connect_with_service_account(self, adapter): async def mock_connect(): adapter._chat_service = MagicMock() with patch.object(adapter, "_connect_impl", side_effect=mock_connect): asyncio.run(adapter.connect()) assert adapter._status == ChannelStatus.CONNECTED def test_connect_auth_failure(self, adapter): with patch.object(adapter, "_init_service_account", side_effect=ChannelAuthenticationError()): with pytest.raises(ChannelAuthenticationError): asyncio.run(asyncio.wait_for(adapter._connect_impl(), timeout=5)) class TestDisconnect: def test_disconnect_clears_state(self, connected_adapter): asyncio.run(connected_adapter.disconnect()) assert connected_adapter._status == ChannelStatus.DISCONNECTED assert connected_adapter._chat_service is None def test_disconnect_resets_circuit_breaker(self, connected_adapter): asyncio.run(connected_adapter.disconnect()) assert connected_adapter._circuit_breaker.state == "closed" class TestSend: def test_send_success(self, connected_adapter): with patch( "yuxi.channels.adapters.googlechat.adapter.send_message", new_callable=AsyncMock, ) as mock_send: mock_send.return_value = DeliveryResult(success=True, message_id="msg1") resp = ChannelResponse( identity=ChannelIdentity( channel_id="googlechat", channel_type=ChannelType.GOOGLE_CHAT, channel_user_id="user1", channel_chat_id="spaces/ABC", ), content="hello", ) result = asyncio.run(connected_adapter.send(resp)) assert result.success is True assert result.message_id == "msg1" class TestEditMessage: def test_edit_success(self, connected_adapter): with patch( "yuxi.channels.adapters.googlechat.adapter.update_message", new_callable=AsyncMock, ) as mock_update: mock_update.return_value = DeliveryResult(success=True, message_id="msg1") result = asyncio.run(connected_adapter.edit_message("spaces/ABC", "spaces/ABC/messages/old", "updated")) assert result.success is True class TestDeleteMessage: def test_delete_success(self, connected_adapter): with patch( "yuxi.channels.adapters.googlechat.adapter._delete_message", new_callable=AsyncMock, ) as mock_del: mock_del.return_value = DeliveryResult(success=True) result = asyncio.run(connected_adapter.delete_message("spaces/ABC", "spaces/ABC/messages/xyz")) assert result.success is True class TestSendReaction: def test_reaction_success(self, connected_adapter): with patch( "yuxi.channels.adapters.googlechat.adapter.send_reaction", new_callable=AsyncMock, ) as mock_react: mock_react.return_value = DeliveryResult(success=True) result = asyncio.run(connected_adapter.send_reaction("spaces/ABC", "spaces/ABC/messages/xyz", "👍")) assert result.success is True class TestSendStreamChunk: def test_first_chunk_creates_message(self, connected_adapter): with patch( "yuxi.channels.adapters.googlechat.adapter.send_message", new_callable=AsyncMock, ) as mock_send: mock_send.return_value = DeliveryResult(success=True, message_id="stream_msg_1") result = asyncio.run(connected_adapter.send_stream_chunk("spaces/ABC", "", "hello", False)) assert result.success is True assert mock_send.called def test_circuit_breaker_catches_error(self, connected_adapter): connected_adapter._circuit_breaker.state = "open" connected_adapter._circuit_breaker._last_failure_time = time.monotonic() result = asyncio.run(connected_adapter.send_stream_chunk("spaces/ABC", "", "hello", False)) assert result.success is False assert "Circuit breaker open" in result.error class TestReceive: def test_receive_yields_nothing(self, adapter): async def collect(): items = [] async for msg in adapter.receive(): items.append(msg) return items items = asyncio.run(collect()) assert items == [] class TestNormalizeInbound: def test_normalize_delegates(self, adapter): raw = {"event": {"type": "MESSAGE", "message": {"text": "hi"}}} result = adapter.normalize_inbound(raw) assert result.content == "hi" class TestFormatOutbound: def test_format_outbound(self, adapter): resp = ChannelResponse( identity=ChannelIdentity( channel_id="googlechat", channel_type=ChannelType.GOOGLE_CHAT, channel_user_id="user1", channel_chat_id="spaces/ABC", ), content="hello", ) result = adapter.format_outbound(resp) assert result["text"] == "hello" class TestHealthCheck: def test_unhealthy_when_disconnected(self, adapter): result = asyncio.run(adapter.health_check()) assert result.status == "unhealthy" def test_healthy_when_connected(self, connected_adapter): connected_adapter._chat_service.spaces().list().execute.return_value = {} result = asyncio.run(connected_adapter.health_check()) assert result.status == "healthy" assert "circuit_breaker_state" in result.metadata def test_unhealthy_on_api_failure(self, connected_adapter): connected_adapter._chat_service.spaces().list().execute.side_effect = Exception("API down") result = asyncio.run(connected_adapter.health_check()) assert result.status == "unhealthy" class TestRefreshToken: def test_no_credentials_returns_false(self, adapter): result = asyncio.run(adapter._refresh_token_if_needed()) assert result is False def test_invalid_credentials_returns_false(self, connected_adapter): connected_adapter._credentials = MagicMock(valid=False) result = asyncio.run(connected_adapter._refresh_token_if_needed()) assert result is False def test_valid_not_expired_returns_true(self, connected_adapter): connected_adapter._credentials = MagicMock(valid=True, expired=False) result = asyncio.run(connected_adapter._refresh_token_if_needed()) assert result is True class TestVerifyWebhookSignature: def test_missing_auth_header(self, connected_adapter): result = asyncio.run(connected_adapter.verify_webhook_signature({}, b"body")) assert result is False def test_invalid_auth_header(self, connected_adapter): result = asyncio.run(connected_adapter.verify_webhook_signature({"Authorization": "Basic xyz"}, b"body")) assert result is False def test_valid_bearer_token(self, connected_adapter): with patch( "yuxi.channels.adapters.googlechat.adapter._verify_jwt", return_value=True, ): result = asyncio.run( connected_adapter.verify_webhook_signature( {"Authorization": "Bearer valid.jwt.token", "Content-Type": "application/json"}, b"body" ) ) assert result is True class TestGetUserInfo: def test_success(self, connected_adapter): connected_adapter._chat_service.users().get().execute.return_value = { "name": "users/123", "displayName": "Test User", "email": "test@example.com", } result = asyncio.run(connected_adapter.get_user_info("users/123")) assert result["display_name"] == "Test User" assert result["email"] == "test@example.com" def test_failure_returns_empty_dict(self, connected_adapter): connected_adapter._chat_service.users().get().execute.side_effect = Exception("not found") result = asyncio.run(connected_adapter.get_user_info("users/999")) assert result == {} class TestDownloadMedia: def test_success(self, connected_adapter): with patch( "yuxi.channels.adapters.googlechat.adapter._download_media", new_callable=AsyncMock, ) as mock_dl: mock_dl.return_value = b"file contents" result = asyncio.run(connected_adapter.download_media('{"name":"att/1"}')) assert result == b"file contents" class TestSendMedia: def test_url_media(self, connected_adapter): with patch( "yuxi.channels.adapters.googlechat.adapter.send_media", new_callable=AsyncMock, ) as mock_sm: mock_sm.return_value = DeliveryResult(success=True, message_id="m1") result = asyncio.run(connected_adapter.send_media("spaces/ABC", "IMAGE", "https://example.com/photo.png")) assert result.success is True def test_unsupported_type(self, connected_adapter): result = asyncio.run(connected_adapter.send_media("spaces/ABC", "TEXT", 123)) assert result.success is False assert "Unsupported media data type" in result.error