- Replace all ~/clawd and ~/.clawdbot paths with ~/echo-core equivalents in tools (git_commit, ralph_prd_generator, backup_config, lead-gen) - Update personality files: TOOLS.md repo/paths, AGENTS.md security audit cmd - Migrate HANDOFF.md architectural decisions to docs/architecture.md - Tighten credentials/ dir to 700, add to .gitignore - Add .claude/ and *.pid to .gitignore - Various adapter, router, and session improvements from prior work Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
540 lines
18 KiB
Python
540 lines
18 KiB
Python
"""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
|