"""Tests for src/adapters/whatsapp.py — WhatsApp adapter.""" import json import pytest from unittest.mock import AsyncMock, MagicMock, patch import httpx from src.config import Config from src.adapters import whatsapp from src.adapters.whatsapp import ( is_owner, is_admin, is_registered_chat, split_message, poll_messages, send_whatsapp, react_whatsapp, get_bridge_status, handle_incoming, run_whatsapp, stop_whatsapp, ) # --- Fixtures --- @pytest.fixture def tmp_config(tmp_path): """Create a Config backed by a temp file with default data.""" data = { "bot": { "name": "Echo", "default_model": "sonnet", }, "whatsapp": { "owner": None, "admins": [], }, "whatsapp_channels": {}, } config_file = tmp_path / "config.json" config_file.write_text(json.dumps(data, indent=2)) return Config(config_file) @pytest.fixture def owned_config(tmp_path): """Config with owner and whatsapp channels set.""" data = { "bot": { "name": "Echo", "default_model": "sonnet", }, "whatsapp": { "owner": "5511999990000", "admins": ["5511888880000"], }, "whatsapp_channels": { "general": {"id": "group123@g.us"}, }, } config_file = tmp_path / "config.json" config_file.write_text(json.dumps(data, indent=2)) return Config(config_file) @pytest.fixture(autouse=True) def _set_config(tmp_config): """Ensure module config is set for each test.""" whatsapp._config = tmp_config yield whatsapp._config = None @pytest.fixture def _set_owned(owned_config): """Set owned config for specific tests.""" whatsapp._config = owned_config yield whatsapp._config = None def _mock_httpx_response(status_code=200, json_data=None, text=""): """Create a mock httpx.Response.""" resp = MagicMock(spec=httpx.Response) resp.status_code = status_code resp.text = text if json_data is not None: resp.json.return_value = json_data return resp def _mock_client(): """Create a mock httpx.AsyncClient.""" client = AsyncMock(spec=httpx.AsyncClient) return client # --- Authorization helpers --- class TestIsOwner: def test_is_owner_true(self, _set_owned): assert is_owner("5511999990000") is True def test_is_owner_false(self, _set_owned): assert is_owner("9999999999") is False def test_is_owner_none_owner(self): assert is_owner("5511999990000") is False class TestIsAdmin: def test_is_admin_owner_is_admin(self, _set_owned): assert is_admin("5511999990000") is True def test_is_admin_listed(self, _set_owned): assert is_admin("5511888880000") is True def test_is_admin_not_listed(self, _set_owned): assert is_admin("9999999999") is False class TestIsRegisteredChat: def test_is_registered_true(self, _set_owned): assert is_registered_chat("group123@g.us") is True def test_is_registered_false(self, _set_owned): assert is_registered_chat("unknown@g.us") is False def test_is_registered_empty(self): assert is_registered_chat("group123@g.us") is False # --- split_message --- class TestSplitMessage: def test_short_message_not_split(self): assert split_message("hello") == ["hello"] def test_long_message_split(self): text = "a" * 8192 chunks = split_message(text, limit=4096) assert len(chunks) == 2 assert all(len(c) <= 4096 for c in chunks) assert "".join(chunks) == text def test_split_at_newline(self): text = "line1\n" * 1000 chunks = split_message(text, limit=100) assert all(len(c) <= 100 for c in chunks) def test_empty_string(self): assert split_message("") == [""] # --- Bridge communication --- class TestPollMessages: @pytest.mark.asyncio async def test_successful_poll(self): client = _mock_client() messages = [{"from": "123@s.whatsapp.net", "text": "hi"}] client.get.return_value = _mock_httpx_response( json_data={"messages": messages} ) result = await poll_messages(client) assert result == messages client.get.assert_called_once() @pytest.mark.asyncio async def test_poll_error_returns_empty(self): client = _mock_client() client.get.side_effect = httpx.ConnectError("bridge down") result = await poll_messages(client) assert result == [] class TestSendWhatsapp: @pytest.mark.asyncio async def test_successful_send(self): client = _mock_client() client.post.return_value = _mock_httpx_response( json_data={"ok": True} ) result = await send_whatsapp(client, "123@s.whatsapp.net", "hello") assert result is True client.post.assert_called_once() @pytest.mark.asyncio async def test_failed_send(self): client = _mock_client() client.post.return_value = _mock_httpx_response( status_code=500, json_data={"ok": False}, text="Server Error" ) result = await send_whatsapp(client, "123@s.whatsapp.net", "hello") assert result is False @pytest.mark.asyncio async def test_long_message_split_send(self): client = _mock_client() client.post.return_value = _mock_httpx_response( json_data={"ok": True} ) long_text = "a" * 8192 result = await send_whatsapp(client, "123@s.whatsapp.net", long_text) assert result is True assert client.post.call_count == 2 class TestGetBridgeStatus: @pytest.mark.asyncio async def test_connected(self): client = _mock_client() client.get.return_value = _mock_httpx_response( json_data={"connected": True, "phone": "5511999990000"} ) result = await get_bridge_status(client) assert result == {"connected": True, "phone": "5511999990000"} @pytest.mark.asyncio async def test_unreachable(self): client = _mock_client() client.get.side_effect = httpx.ConnectError("unreachable") result = await get_bridge_status(client) assert result is None class TestReactWhatsapp: @pytest.mark.asyncio async def test_successful_react(self): client = _mock_client() client.post.return_value = _mock_httpx_response(json_data={"ok": True}) result = await react_whatsapp(client, "123@s.whatsapp.net", "msg-id-1", "\U0001f440") assert result is True client.post.assert_called_once() sent_json = client.post.call_args[1]["json"] assert sent_json == {"to": "123@s.whatsapp.net", "id": "msg-id-1", "emoji": "\U0001f440", "fromMe": False} @pytest.mark.asyncio async def test_react_remove(self): client = _mock_client() client.post.return_value = _mock_httpx_response(json_data={"ok": True}) result = await react_whatsapp(client, "123@s.whatsapp.net", "msg-id-1", "") assert result is True @pytest.mark.asyncio async def test_react_bridge_error(self): client = _mock_client() client.post.side_effect = httpx.ConnectError("bridge down") result = await react_whatsapp(client, "123@s.whatsapp.net", "msg-id-1", "\U0001f440") assert result is False @pytest.mark.asyncio async def test_react_500(self): client = _mock_client() client.post.return_value = _mock_httpx_response( status_code=500, json_data={"ok": False} ) result = await react_whatsapp(client, "123@s.whatsapp.net", "msg-id-1", "\U0001f440") assert result is False # --- Message handler --- class TestHandleIncoming: @pytest.mark.asyncio async def test_private_admin_message(self, _set_owned): client = _mock_client() client.post.return_value = _mock_httpx_response(json_data={"ok": True}) msg = { "from": "5511999990000@s.whatsapp.net", "text": "Hello Claude", "pushName": "Owner", "isGroup": False, } with patch("src.adapters.whatsapp.route_message", return_value=("Hi!", False)) as mock_route: await handle_incoming(msg, client) mock_route.assert_called_once() client.post.assert_called_once() @pytest.mark.asyncio async def test_private_unauthorized(self, _set_owned): client = _mock_client() msg = { "from": "9999999999@s.whatsapp.net", "text": "Hello", "pushName": "Stranger", "isGroup": False, } with patch("src.adapters.whatsapp.route_message") as mock_route: await handle_incoming(msg, client) mock_route.assert_not_called() client.post.assert_not_called() @pytest.mark.asyncio async def test_group_unregistered(self, _set_owned): client = _mock_client() msg = { "from": "unknown@g.us", "text": "Hello", "pushName": "User", "isGroup": True, } with patch("src.adapters.whatsapp.route_message") as mock_route: await handle_incoming(msg, client) mock_route.assert_not_called() @pytest.mark.asyncio async def test_group_registered_routed(self, _set_owned): client = _mock_client() client.post.return_value = _mock_httpx_response(json_data={"ok": True}) msg = { "from": "group123@g.us", "participant": "5511999990000@s.whatsapp.net", "text": "Hello", "pushName": "User", "isGroup": True, } with patch("src.adapters.whatsapp.route_message", return_value=("Hi!", False)) as mock_route: await handle_incoming(msg, client) mock_route.assert_called_once() @pytest.mark.asyncio async def test_clear_command(self, _set_owned): client = _mock_client() client.post.return_value = _mock_httpx_response(json_data={"ok": True}) msg = { "from": "5511999990000@s.whatsapp.net", "text": "/clear", "pushName": "Owner", "isGroup": False, } with patch("src.adapters.whatsapp.clear_session", return_value=True) as mock_clear: await handle_incoming(msg, client) mock_clear.assert_called_once_with("wa-5511999990000") client.post.assert_called_once() sent_json = client.post.call_args[1]["json"] assert "cleared" in sent_json["text"].lower() @pytest.mark.asyncio async def test_status_command_with_session(self, _set_owned): client = _mock_client() client.post.return_value = _mock_httpx_response(json_data={"ok": True}) msg = { "from": "5511999990000@s.whatsapp.net", "text": "/status", "pushName": "Owner", "isGroup": False, } session = { "model": "opus", "session_id": "sess-abc-12345678", "message_count": 5, "total_input_tokens": 1000, "total_output_tokens": 500, } with patch("src.adapters.whatsapp.get_active_session", return_value=session): await handle_incoming(msg, client) client.post.assert_called_once() sent_json = client.post.call_args[1]["json"] assert "opus" in sent_json["text"] assert "5" in sent_json["text"] @pytest.mark.asyncio async def test_status_command_no_session(self, _set_owned): client = _mock_client() client.post.return_value = _mock_httpx_response(json_data={"ok": True}) msg = { "from": "5511999990000@s.whatsapp.net", "text": "/status", "pushName": "Owner", "isGroup": False, } with patch("src.adapters.whatsapp.get_active_session", return_value=None): await handle_incoming(msg, client) client.post.assert_called_once() sent_json = client.post.call_args[1]["json"] assert "No active session" in sent_json["text"] @pytest.mark.asyncio async def test_error_handling(self, _set_owned): client = _mock_client() client.post.return_value = _mock_httpx_response(json_data={"ok": True}) msg = { "from": "5511999990000@s.whatsapp.net", "text": "Hello", "pushName": "Owner", "isGroup": False, } with patch("src.adapters.whatsapp.route_message", side_effect=Exception("boom")): await handle_incoming(msg, client) client.post.assert_called_once() sent_json = client.post.call_args[1]["json"] assert "Sorry" in sent_json["text"] @pytest.mark.asyncio async def test_reaction_flow(self, _set_owned): """Eyes reaction added on receipt and removed after response.""" client = _mock_client() client.post.return_value = _mock_httpx_response(json_data={"ok": True}) msg = { "from": "5511999990000@s.whatsapp.net", "text": "Hello", "pushName": "Owner", "isGroup": False, "id": "msg-abc-123", } with patch("src.adapters.whatsapp.route_message", return_value=("Hi!", False)): await handle_incoming(msg, client) # Should have 3 post calls: react 👀, send response, react "" (remove) assert client.post.call_count == 3 calls = client.post.call_args_list # First call: eyes reaction react_json = calls[0][1]["json"] assert react_json["emoji"] == "\U0001f440" assert react_json["id"] == "msg-abc-123" assert react_json["fromMe"] is False # Second call: actual message send_json = calls[1][1]["json"] assert send_json["text"] == "Hi!" # Third call: remove reaction unreact_json = calls[2][1]["json"] assert unreact_json["emoji"] == "" assert unreact_json["id"] == "msg-abc-123" assert unreact_json["fromMe"] is False @pytest.mark.asyncio async def test_reaction_removed_on_error(self, _set_owned): """Eyes reaction removed even when route_message raises.""" client = _mock_client() client.post.return_value = _mock_httpx_response(json_data={"ok": True}) msg = { "from": "5511999990000@s.whatsapp.net", "text": "Hello", "pushName": "Owner", "isGroup": False, "id": "msg-abc-456", } with patch("src.adapters.whatsapp.route_message", side_effect=Exception("boom")): await handle_incoming(msg, client) # react 👀, send error, react "" (remove) — reaction still removed in finally calls = client.post.call_args_list unreact_call = calls[-1][1]["json"] assert unreact_call["emoji"] == "" assert unreact_call["id"] == "msg-abc-456" @pytest.mark.asyncio async def test_no_reaction_without_message_id(self, _set_owned): """No reaction calls when message has no id.""" client = _mock_client() client.post.return_value = _mock_httpx_response(json_data={"ok": True}) msg = { "from": "5511999990000@s.whatsapp.net", "text": "Hello", "pushName": "Owner", "isGroup": False, } with patch("src.adapters.whatsapp.route_message", return_value=("Hi!", False)): await handle_incoming(msg, client) # Only 1 call: send response (no react calls) client.post.assert_called_once() @pytest.mark.asyncio async def test_empty_text_ignored(self, _set_owned): client = _mock_client() msg = { "from": "5511999990000@s.whatsapp.net", "text": "", "pushName": "Owner", "isGroup": False, } with patch("src.adapters.whatsapp.route_message") as mock_route: await handle_incoming(msg, client) mock_route.assert_not_called() client.post.assert_not_called() # --- Security logging --- class TestSecurityLogging: @pytest.mark.asyncio async def test_unauthorized_dm_logged(self, _set_owned): client = _mock_client() msg = { "from": "9999999999@s.whatsapp.net", "text": "hack attempt", "pushName": "Stranger", "isGroup": False, } with patch.object(whatsapp._security_log, "warning") as mock_log: await handle_incoming(msg, client) mock_log.assert_called_once() assert "Unauthorized" in mock_log.call_args[0][0] # --- Lifecycle --- class TestRunWhatsapp: @pytest.mark.asyncio async def test_basic_start_stop(self, tmp_config): """Test that run_whatsapp sets state and exits when stopped.""" mock_client = _mock_client() mock_client.__aenter__ = AsyncMock(return_value=mock_client) mock_client.__aexit__ = AsyncMock(return_value=False) # get_bridge_status returns None → retries, but we stop after first sleep mock_client.get.return_value = _mock_httpx_response(status_code=500) async def stop_on_sleep(*args, **kwargs): whatsapp._running = False with ( patch("src.adapters.whatsapp.httpx.AsyncClient", return_value=mock_client), patch("src.adapters.whatsapp.asyncio.sleep", side_effect=stop_on_sleep), ): await run_whatsapp(tmp_config, bridge_url="http://127.0.0.1:9999") assert whatsapp._config is tmp_config assert whatsapp._bridge_url == "http://127.0.0.1:9999" class TestStopWhatsapp: def test_sets_running_false(self): whatsapp._running = True stop_whatsapp() assert whatsapp._running is False