"""Unit tests for Google Chat adapter - send.py module. Tests cover: - Error classification (_classify_error) - Exponential backoff with jitter - Retry with backoff (_retry_with_backoff) - All send functions with proper error handling Note: Tests mock googleapiclient to run without the package installed. """ from __future__ import annotations import asyncio import sys from unittest.mock import AsyncMock, MagicMock, patch import pytest from yuxi.channels.models import DeliveryResult from yuxi.channels.exceptions import ( ChannelAuthenticationError, ChannelRateLimitError, DeliveryFailedError, ) def _make_fake_googleapiclient(): fake_errors = MagicMock() resp_cls = MagicMock() resp_cls.status = 200 resp_cls.reason = "OK" http_error_cls = type("HttpError", (Exception,), {}) http_error_cls.resp = resp_cls def _mock_http_error_init(self, resp, content, uri=None): self.resp = resp self.content = content self.uri = uri http_error_cls.__init__ = _mock_http_error_init fake_errors.HttpError = http_error_cls fake_http = MagicMock() fake_http.MediaIoBaseUpload = MagicMock() fake_apiclient = MagicMock() fake_apiclient.errors = fake_errors fake_apiclient.http = fake_http return fake_apiclient, http_error_cls def _install_googleapiclient_mock(): if "googleapiclient" not in sys.modules: fake, _ = _make_fake_googleapiclient() sys.modules["googleapiclient"] = fake sys.modules["googleapiclient.errors"] = fake.errors sys.modules["googleapiclient.http"] = fake.http def _make_http_error(status: int, reason: str = "", content: bytes = b""): from googleapiclient.errors import HttpError resp = MagicMock(status=status, reason=reason) error = HttpError(resp, content) return error class TestClassifyError: @pytest.fixture(autouse=True) def _setup_googleapiclient(self): _install_googleapiclient_mock() yield def test_429_raises_rate_limit(self): from yuxi.channels.adapters.googlechat.send import _classify_error with pytest.raises(ChannelRateLimitError): _classify_error(_make_http_error(429)) def test_401_raises_auth_error(self): from yuxi.channels.adapters.googlechat.send import _classify_error with pytest.raises(ChannelAuthenticationError): _classify_error(_make_http_error(401)) def test_403_without_rate_limit_raises_auth_error(self): from yuxi.channels.adapters.googlechat.send import _classify_error with pytest.raises(ChannelAuthenticationError): _classify_error(_make_http_error(403, content=b'{"error": "forbidden"}')) def test_403_with_rate_limit_raises_rate_limit(self): from yuxi.channels.adapters.googlechat.send import _classify_error with pytest.raises(ChannelRateLimitError): _classify_error(_make_http_error(403, content=b"rateLimitExceeded")) def test_500_raises_delivery_failed(self): from yuxi.channels.adapters.googlechat.send import _classify_error with pytest.raises(DeliveryFailedError): _classify_error(_make_http_error(500)) def test_non_http_error_raises_delivery_failed(self): from yuxi.channels.adapters.googlechat.send import _classify_error with pytest.raises(DeliveryFailedError): _classify_error(ValueError("unexpected")) class TestBackoffWithJitter: def test_backoff_range(self): from yuxi.channels.adapters.googlechat.send import _backoff_with_jitter for i in range(5): delay = _backoff_with_jitter(i) assert 0 < delay <= 64, f"iter {i}: delay={delay}" def test_max_cap(self): from yuxi.channels.adapters.googlechat.send import _backoff_with_jitter delay = _backoff_with_jitter(20) assert delay <= 64 class TestExtractRateLimitReason: def test_rate_limit_exceeded_bytes(self): from yuxi.channels.adapters.googlechat.send import _extract_rate_limit_reason error = _make_http_error(403, content=b"rateLimitExceeded") assert _extract_rate_limit_reason(error) is True def test_user_rate_limit_exceeded(self): from yuxi.channels.adapters.googlechat.send import _extract_rate_limit_reason error = _make_http_error(403, content=b"userRateLimitExceeded") assert _extract_rate_limit_reason(error) is True def test_no_match(self): from yuxi.channels.adapters.googlechat.send import _extract_rate_limit_reason error = _make_http_error(403, content=b'{"error": "forbidden"}') assert _extract_rate_limit_reason(error) is False def test_no_content_attr(self): from yuxi.channels.adapters.googlechat.send import _extract_rate_limit_reason e = Exception("test") assert _extract_rate_limit_reason(e) is False @pytest.fixture def mock_chat_service(): service = MagicMock() return service class TestSendMessage: @pytest.fixture(autouse=True) def _setup_googleapiclient(self): _install_googleapiclient_mock() yield def test_success(self, mock_chat_service): from yuxi.channels.adapters.googlechat.send import send_message mock_chat_service.spaces().messages().create().execute.return_value = {"name": "spaces/ABC/messages/xyz"} result = asyncio.run(send_message(mock_chat_service, "spaces/ABC", {"text": "hello"})) assert result.success is True assert result.message_id == "spaces/ABC/messages/xyz" def test_rate_limit_with_eventual_success(self, mock_chat_service): from yuxi.channels.adapters.googlechat.send import send_message call_count = [0] def mock_execute(): call_count[0] += 1 if call_count[0] < 3: raise ChannelRateLimitError() return {"name": "spaces/ABC/messages/ok"} mock_chat_service.spaces().messages().create.return_value.execute.side_effect = mock_execute with patch("yuxi.channels.adapters.googlechat.send.asyncio.sleep", new_callable=AsyncMock): result = asyncio.run(send_message(mock_chat_service, "spaces/ABC", {"text": "hello"})) assert result.success is True assert result.message_id == "spaces/ABC/messages/ok" def test_auth_error_no_retry(self, mock_chat_service): from yuxi.channels.adapters.googlechat.send import send_message mock_chat_service.spaces().messages().create.return_value.execute.side_effect = ChannelAuthenticationError( "bad creds" ) result = asyncio.run(send_message(mock_chat_service, "spaces/ABC", {"text": "hello"})) assert result.success is False def test_max_retries_exhausted(self, mock_chat_service): from yuxi.channels.adapters.googlechat.send import send_message mock_chat_service.spaces().messages().create.return_value.execute.side_effect = ChannelRateLimitError() with patch("yuxi.channels.adapters.googlechat.send.asyncio.sleep", new_callable=AsyncMock): result = asyncio.run(send_message(mock_chat_service, "spaces/ABC", {"text": "hello"})) assert result.success is False def test_generic_http_error_retries(self, mock_chat_service): from yuxi.channels.adapters.googlechat.send import send_message call_count = [0] def mock_execute(): call_count[0] += 1 if call_count[0] < 3: raise _make_http_error(500, content=b"internal error") return {"name": "spaces/ABC/messages/ok"} mock_chat_service.spaces().messages().create.return_value.execute.side_effect = mock_execute with patch("yuxi.channels.adapters.googlechat.send.asyncio.sleep", new_callable=AsyncMock): result = asyncio.run(send_message(mock_chat_service, "spaces/ABC", {"text": "hello"})) assert result.success is True class TestUpdateMessage: def test_success(self, mock_chat_service): from yuxi.channels.adapters.googlechat.send import update_message mock_chat_service.spaces().messages().update().execute.return_value = {"name": "spaces/ABC/messages/xyz"} result = asyncio.run(update_message(mock_chat_service, "spaces/ABC/messages/old", {"text": "updated"})) assert result.success is True def test_with_custom_update_mask(self, mock_chat_service): from yuxi.channels.adapters.googlechat.send import update_message mock_chat_service.spaces().messages().update().execute.return_value = {"name": "spaces/ABC/messages/xyz"} result = asyncio.run( update_message( mock_chat_service, "spaces/ABC/messages/old", {"text": "updated", "cardsV2": []}, update_mask="text,cardsV2", ) ) assert result.success is True class TestDeleteMessage: def test_success(self, mock_chat_service): from yuxi.channels.adapters.googlechat.send import delete_message mock_chat_service.spaces().messages().delete().execute.return_value = None result = asyncio.run(delete_message(mock_chat_service, "spaces/ABC/messages/xyz")) assert result.success is True class TestSendMedia: def test_success(self, mock_chat_service): from yuxi.channels.adapters.googlechat.send import send_media mock_chat_service.spaces().messages().create().execute.return_value = {"name": "spaces/ABC/messages/media1"} result = asyncio.run(send_media(mock_chat_service, "spaces/ABC", "https://example.com/img.png")) assert result.success is True class TestSendReaction: def test_success(self, mock_chat_service): from yuxi.channels.adapters.googlechat.send import send_reaction mock_chat_service.spaces().messages().reactions().create().execute.return_value = {} result = asyncio.run(send_reaction(mock_chat_service, "spaces/ABC/messages/xyz", "\U0001f44d")) assert result.success is True def test_failure(self, mock_chat_service): from yuxi.channels.adapters.googlechat.send import send_reaction mock_chat_service.spaces().messages().reactions().create().execute.side_effect = DeliveryFailedError("oops") result = asyncio.run(send_reaction(mock_chat_service, "spaces/ABC/messages/xyz", "\U0001f44d")) assert result.success is False class TestUploadFileMessage: def test_success(self, mock_chat_service): _install_googleapiclient_mock() from yuxi.channels.adapters.googlechat.send import upload_file_message mock_chat_service.spaces().messages().create().execute.return_value = {"name": "spaces/ABC/messages/file1"} result = asyncio.run(upload_file_message(mock_chat_service, "spaces/ABC", b"file data", "test.txt")) assert result.success is True class TestUploadImageMessage: def test_success(self, mock_chat_service): _install_googleapiclient_mock() from yuxi.channels.adapters.googlechat.send import upload_image_message mock_chat_service.spaces().messages().create().execute.return_value = {"name": "spaces/ABC/messages/img1"} result = asyncio.run(upload_image_message(mock_chat_service, "spaces/ABC", b"png data")) assert result.success is True class TestRetryWithBackoff: async def test_immediate_success(self): from yuxi.channels.adapters.googlechat.send import _retry_with_backoff async def succeed(): return DeliveryResult(success=True) result = await _retry_with_backoff(succeed) assert result.success is True async def test_non_retryable_error(self): from yuxi.channels.adapters.googlechat.send import _retry_with_backoff async def fail(): raise DeliveryFailedError("hard failure") with pytest.raises(DeliveryFailedError): await _retry_with_backoff(fail) async def test_max_retries_exhausted(self): from yuxi.channels.adapters.googlechat.send import _retry_with_backoff async def always_fail(): raise ChannelRateLimitError() result = await _retry_with_backoff(always_fail) assert result.success is False