"""Tests for WeChat iLink typing ticket refresh logic (issue #38085).""" import asyncio import time from unittest.mock import AsyncMock, MagicMock, patch import pytest @pytest.fixture def weixin_adapter(): """Create a minimal WeixinAdapter with mocked internals for typing tests.""" from gateway.platforms.weixin import WeixinAdapter, TypingTicketCache config = MagicMock() config.extra = {"account_id": "test-account"} config.name = "weixin" with patch.object(WeixinAdapter, "__init__", lambda self, cfg: None): adapter = WeixinAdapter.__new__(WeixinAdapter) adapter._send_session = AsyncMock() adapter._token = "test-token" adapter._base_url = "https://ilinkai.weixin.qq.com" adapter._account_id = "test-account" adapter._typing_cache = TypingTicketCache(ttl_seconds=600.0) adapter._token_store = MagicMock() adapter._token_store.get.return_value = None # no stored context_token adapter.platform = MagicMock() mock_value = MagicMock() mock_value.title.return_value = "Weixin" adapter.platform.value = mock_value return adapter class TestEnsureTypingTicket: """Tests for _ensure_typing_ticket — the fix for stuck typing indicator.""" @pytest.mark.asyncio async def test_returns_cached_ticket_when_fresh(self, weixin_adapter): """If the cached ticket is still valid, return it without refreshing.""" weixin_adapter._typing_cache.set("user-123", "cached-ticket-abc") ticket = await weixin_adapter._ensure_typing_ticket("user-123") assert ticket == "cached-ticket-abc" @pytest.mark.asyncio async def test_refreshes_when_ticket_expired(self, weixin_adapter): """When the cached ticket has expired, fetch a new one via getConfig.""" # Insert an expired ticket directly (bypass TTL check) weixin_adapter._typing_cache._cache["user-123"] = ( "old-ticket", time.time() - 601, # expired (TTL is 600s) ) mock_response = {"typing_ticket": "fresh-ticket-xyz"} with patch("gateway.platforms.weixin._get_config", new_callable=AsyncMock) as mock_get: mock_get.return_value = mock_response ticket = await weixin_adapter._ensure_typing_ticket("user-123") assert ticket == "fresh-ticket-xyz" mock_get.assert_called_once_with( weixin_adapter._send_session, base_url=weixin_adapter._base_url, token=weixin_adapter._token, user_id="user-123", context_token=None, ) @pytest.mark.asyncio async def test_refreshes_when_no_cached_ticket(self, weixin_adapter): """When there is no cached ticket at all, fetch a new one.""" mock_response = {"typing_ticket": "new-ticket"} with patch("gateway.platforms.weixin._get_config", new_callable=AsyncMock) as mock_get: mock_get.return_value = mock_response ticket = await weixin_adapter._ensure_typing_ticket("user-456") assert ticket == "new-ticket" @pytest.mark.asyncio async def test_uses_stored_context_token_when_available(self, weixin_adapter): """Pass the stored context_token to getConfig when available.""" weixin_adapter._token_store.get.return_value = "stored-ctx-token" mock_response = {"typing_ticket": "ticket-with-ctx"} with patch("gateway.platforms.weixin._get_config", new_callable=AsyncMock) as mock_get: mock_get.return_value = mock_response ticket = await weixin_adapter._ensure_typing_ticket("user-789") assert ticket == "ticket-with-ctx" mock_get.assert_called_once_with( weixin_adapter._send_session, base_url=weixin_adapter._base_url, token=weixin_adapter._token, user_id="user-789", context_token="stored-ctx-token", ) @pytest.mark.asyncio async def test_returns_none_when_no_session(self, weixin_adapter): """Return None when there is no send session.""" weixin_adapter._send_session = None ticket = await weixin_adapter._ensure_typing_ticket("user-123") assert ticket is None @pytest.mark.asyncio async def test_returns_none_when_getconfig_fails(self, weixin_adapter): """Return None when getConfig raises an exception.""" with patch("gateway.platforms.weixin._get_config", new_callable=AsyncMock) as mock_get: mock_get.side_effect = Exception("network error") ticket = await weixin_adapter._ensure_typing_ticket("user-123") assert ticket is None @pytest.mark.asyncio async def test_returns_none_when_getconfig_returns_empty_ticket(self, weixin_adapter): """Return None when getConfig returns no typing_ticket.""" with patch("gateway.platforms.weixin._get_config", new_callable=AsyncMock) as mock_get: mock_get.return_value = {"typing_ticket": ""} ticket = await weixin_adapter._ensure_typing_ticket("user-123") assert ticket is None @pytest.mark.asyncio async def test_stop_typing_refreshes_ticket(self, weixin_adapter): """stop_typing should refresh the ticket when expired, not silently no-op.""" # Expired ticket weixin_adapter._typing_cache._cache["user-123"] = ( "old-ticket", time.time() - 601, ) mock_response = {"typing_ticket": "refreshed-ticket"} with patch("gateway.platforms.weixin._get_config", new_callable=AsyncMock) as mock_get, \ patch("gateway.platforms.weixin._send_typing", new_callable=AsyncMock) as mock_send: mock_get.return_value = mock_response await weixin_adapter.stop_typing("user-123") # _send_typing should have been called with TYPING_STOP=2 mock_send.assert_called_once() call_kwargs = mock_send.call_args assert call_kwargs.kwargs["typing_ticket"] == "refreshed-ticket" assert call_kwargs.kwargs["status"] == 2 # TYPING_STOP @pytest.mark.asyncio async def test_send_typing_refreshes_ticket(self, weixin_adapter): """send_typing should refresh the ticket when expired.""" # Expired ticket weixin_adapter._typing_cache._cache["user-123"] = ( "old-ticket", time.time() - 601, ) mock_response = {"typing_ticket": "refreshed-ticket"} with patch("gateway.platforms.weixin._get_config", new_callable=AsyncMock) as mock_get, \ patch("gateway.platforms.weixin._send_typing", new_callable=AsyncMock) as mock_send: mock_get.return_value = mock_response await weixin_adapter.send_typing("user-123") mock_send.assert_called_once() call_kwargs = mock_send.call_args assert call_kwargs.kwargs["typing_ticket"] == "refreshed-ticket" assert call_kwargs.kwargs["status"] == 1 # TYPING_START class TestTypingTicketCache: """Tests for the TypingTicketCache TTL logic.""" def test_returns_ticket_when_fresh(self): from gateway.platforms.weixin import TypingTicketCache cache = TypingTicketCache(ttl_seconds=600.0) cache.set("user-1", "ticket-1") assert cache.get("user-1") == "ticket-1" def test_returns_none_when_expired(self): from gateway.platforms.weixin import TypingTicketCache cache = TypingTicketCache(ttl_seconds=600.0) cache._cache["user-1"] = ("ticket-1", time.time() - 601) assert cache.get("user-1") is None def test_returns_none_when_missing(self): from gateway.platforms.weixin import TypingTicketCache cache = TypingTicketCache(ttl_seconds=600.0) assert cache.get("nonexistent") is None def test_expired_entry_is_removed_from_cache(self): from gateway.platforms.weixin import TypingTicketCache cache = TypingTicketCache(ttl_seconds=600.0) cache._cache["user-1"] = ("ticket-1", time.time() - 601) cache.get("user-1") assert "user-1" not in cache._cache