cleanup: remove clawd/openclaw references, fix permissions, add architecture docs

- 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>
This commit is contained in:
MoltBot Service
2026-02-14 21:44:13 +00:00
parent d585c85081
commit 5928077646
35 changed files with 666 additions and 790 deletions

View File

@@ -4,7 +4,8 @@ import json
import os
import subprocess
from pathlib import Path
from unittest.mock import MagicMock, patch
from unittest.mock import MagicMock, patch, PropertyMock
import io
import pytest
@@ -60,20 +61,26 @@ def _make_stream(*assistant_texts, result_override=None):
if result_override:
result.update(result_override)
lines.append(json.dumps(result))
return "\n".join(lines)
return "\n".join(lines) + "\n"
def _make_proc(stdout=None, returncode=0, stderr=""):
"""Build a fake subprocess.CompletedProcess with stream-json output."""
def _make_popen(stdout=None, returncode=0, stderr=""):
"""Build a fake subprocess.Popen that yields lines from stdout."""
if stdout is None:
stdout = _make_stream("Hello from Claude!")
proc = MagicMock(spec=subprocess.CompletedProcess)
proc.stdout = stdout
proc.stderr = stderr
proc = MagicMock()
proc.stdout = io.StringIO(stdout)
proc.stderr = io.StringIO(stderr)
proc.returncode = returncode
proc.wait.return_value = returncode
proc.kill = MagicMock()
return proc
# Keep old name for backward-compatible test helpers
_make_proc = _make_popen
# ---------------------------------------------------------------------------
# build_system_prompt
# ---------------------------------------------------------------------------
@@ -170,50 +177,67 @@ class TestSafeEnv:
# ---------------------------------------------------------------------------
# _run_claude
# _run_claude (now with Popen streaming)
# ---------------------------------------------------------------------------
class TestRunClaude:
@patch("shutil.which", return_value="/usr/bin/claude")
@patch("subprocess.run")
def test_returns_parsed_stream(self, mock_run, mock_which):
mock_run.return_value = _make_proc()
@patch("subprocess.Popen")
def test_returns_parsed_stream(self, mock_popen, mock_which):
mock_popen.return_value = _make_popen()
result = _run_claude(["claude", "-p", "hi"], timeout=30)
assert result["result"] == "Hello from Claude!"
assert result["session_id"] == "sess-abc-123"
assert "usage" in result
@patch("shutil.which", return_value="/usr/bin/claude")
@patch("subprocess.run")
def test_collects_multiple_text_blocks(self, mock_run, mock_which):
@patch("subprocess.Popen")
def test_collects_multiple_text_blocks(self, mock_popen, mock_which):
stdout = _make_stream("First message", "Second message", "Third message")
mock_run.return_value = _make_proc(stdout=stdout)
mock_popen.return_value = _make_popen(stdout=stdout)
result = _run_claude(["claude", "-p", "hi"], timeout=30)
assert result["result"] == "First message\n\nSecond message\n\nThird message"
@patch("shutil.which", return_value="/usr/bin/claude")
@patch("subprocess.run")
def test_timeout_raises(self, mock_run, mock_which):
mock_run.side_effect = subprocess.TimeoutExpired(cmd="claude", timeout=30)
@patch("subprocess.Popen")
def test_timeout_raises(self, mock_popen, mock_which):
proc = _make_popen()
# Track calls to distinguish watchdog (with big timeout) from cleanup
call_count = [0]
def wait_side_effect(timeout=None):
call_count[0] += 1
if call_count[0] == 1 and timeout is not None:
# First call is the watchdog — simulate timeout
raise subprocess.TimeoutExpired(cmd="claude", timeout=timeout)
return 0 # subsequent cleanup calls succeed
proc.wait.side_effect = wait_side_effect
# stdout returns empty immediately so the for-loop exits
proc.stdout = io.StringIO("")
proc.stderr = io.StringIO("")
mock_popen.return_value = proc
with pytest.raises(TimeoutError, match="timed out after 30s"):
_run_claude(["claude", "-p", "hi"], timeout=30)
@patch("shutil.which", return_value="/usr/bin/claude")
@patch("subprocess.run")
def test_nonzero_exit_raises(self, mock_run, mock_which):
mock_run.return_value = _make_proc(
@patch("subprocess.Popen")
def test_nonzero_exit_raises(self, mock_popen, mock_which):
mock_popen.return_value = _make_popen(
stdout="", returncode=1, stderr="something went wrong"
)
with pytest.raises(RuntimeError, match="exit 1"):
_run_claude(["claude", "-p", "hi"], timeout=30)
@patch("shutil.which", return_value="/usr/bin/claude")
@patch("subprocess.run")
def test_no_result_line_raises(self, mock_run, mock_which):
@patch("subprocess.Popen")
def test_no_result_line_raises(self, mock_popen, mock_which):
# Stream with only an assistant line but no result line
stdout = json.dumps({"type": "assistant", "message": {"content": []}})
mock_run.return_value = _make_proc(stdout=stdout)
stdout = json.dumps({"type": "assistant", "message": {"content": []}}) + "\n"
mock_popen.return_value = _make_popen(stdout=stdout)
with pytest.raises(RuntimeError, match="no result line"):
_run_claude(["claude", "-p", "hi"], timeout=30)
@@ -222,6 +246,33 @@ class TestRunClaude:
with pytest.raises(FileNotFoundError, match="Claude CLI not found"):
_run_claude(["claude", "-p", "hi"], timeout=30)
@patch("shutil.which", return_value="/usr/bin/claude")
@patch("subprocess.Popen")
def test_on_text_callback_called(self, mock_popen, mock_which):
stdout = _make_stream("First", "Second")
mock_popen.return_value = _make_popen(stdout=stdout)
received = []
result = _run_claude(
["claude", "-p", "hi"], timeout=30,
on_text=lambda t: received.append(t),
)
assert received == ["First", "Second"]
assert result["intermediate_count"] == 2
@patch("shutil.which", return_value="/usr/bin/claude")
@patch("subprocess.Popen")
def test_on_text_callback_error_does_not_crash(self, mock_popen, mock_which):
mock_popen.return_value = _make_popen()
def bad_callback(text):
raise ValueError("callback boom")
# Should not raise — callback errors are logged but swallowed
result = _run_claude(
["claude", "-p", "hi"], timeout=30, on_text=bad_callback
)
assert result["result"] == "Hello from Claude!"
# ---------------------------------------------------------------------------
# Session file helpers (_load_sessions / _save_sessions)
@@ -291,9 +342,9 @@ class TestSessionFileOps:
class TestStartSession:
@patch("shutil.which", return_value="/usr/bin/claude")
@patch("subprocess.run")
@patch("subprocess.Popen")
def test_returns_response_and_session_id(
self, mock_run, mock_which, tmp_path, monkeypatch
self, mock_popen, mock_which, tmp_path, monkeypatch
):
sessions_dir = tmp_path / "sessions"
sessions_dir.mkdir()
@@ -301,23 +352,23 @@ class TestStartSession:
monkeypatch.setattr(
claude_session, "_SESSIONS_FILE", sessions_dir / "active.json"
)
mock_run.return_value = _make_proc()
mock_popen.return_value = _make_popen()
response, sid = start_session("general", "Hello")
assert response == "Hello from Claude!"
assert sid == "sess-abc-123"
@patch("shutil.which", return_value="/usr/bin/claude")
@patch("subprocess.run")
@patch("subprocess.Popen")
def test_saves_to_active_json(
self, mock_run, mock_which, tmp_path, monkeypatch
self, mock_popen, mock_which, tmp_path, monkeypatch
):
sessions_dir = tmp_path / "sessions"
sessions_dir.mkdir()
sf = sessions_dir / "active.json"
monkeypatch.setattr(claude_session, "SESSIONS_DIR", sessions_dir)
monkeypatch.setattr(claude_session, "_SESSIONS_FILE", sf)
mock_run.return_value = _make_proc()
mock_popen.return_value = _make_popen()
start_session("general", "Hello")
@@ -334,9 +385,9 @@ class TestStartSession:
start_session("general", "Hello", model="gpt-4")
@patch("shutil.which", return_value="/usr/bin/claude")
@patch("subprocess.run")
@patch("subprocess.Popen")
def test_missing_result_line_raises(
self, mock_run, mock_which, tmp_path, monkeypatch
self, mock_popen, mock_which, tmp_path, monkeypatch
):
sessions_dir = tmp_path / "sessions"
sessions_dir.mkdir()
@@ -345,16 +396,16 @@ class TestStartSession:
claude_session, "_SESSIONS_FILE", sessions_dir / "active.json"
)
# Stream with no result line at all
bad_stream = json.dumps({"type": "assistant", "message": {"content": []}})
mock_run.return_value = _make_proc(stdout=bad_stream)
bad_stream = json.dumps({"type": "assistant", "message": {"content": []}}) + "\n"
mock_popen.return_value = _make_popen(stdout=bad_stream)
with pytest.raises(RuntimeError, match="no result line"):
start_session("general", "Hello")
@patch("shutil.which", return_value="/usr/bin/claude")
@patch("subprocess.run")
@patch("subprocess.Popen")
def test_missing_session_id_gives_empty_string(
self, mock_run, mock_which, tmp_path, monkeypatch
self, mock_popen, mock_which, tmp_path, monkeypatch
):
sessions_dir = tmp_path / "sessions"
sessions_dir.mkdir()
@@ -365,11 +416,29 @@ class TestStartSession:
# Result line without session_id → _run_claude returns "" for session_id
# → start_session checks for empty session_id
bad_stream = _make_stream("hello", result_override={"session_id": None})
mock_run.return_value = _make_proc(stdout=bad_stream)
mock_popen.return_value = _make_popen(stdout=bad_stream)
with pytest.raises(RuntimeError, match="missing required field"):
start_session("general", "Hello")
@patch("shutil.which", return_value="/usr/bin/claude")
@patch("subprocess.Popen")
def test_on_text_passed_through(
self, mock_popen, mock_which, tmp_path, monkeypatch
):
sessions_dir = tmp_path / "sessions"
sessions_dir.mkdir()
monkeypatch.setattr(claude_session, "SESSIONS_DIR", sessions_dir)
monkeypatch.setattr(
claude_session, "_SESSIONS_FILE", sessions_dir / "active.json"
)
stdout = _make_stream("Block 1", "Block 2")
mock_popen.return_value = _make_popen(stdout=stdout)
received = []
start_session("general", "Hello", on_text=lambda t: received.append(t))
assert received == ["Block 1", "Block 2"]
# ---------------------------------------------------------------------------
# resume_session
@@ -378,9 +447,9 @@ class TestStartSession:
class TestResumeSession:
@patch("shutil.which", return_value="/usr/bin/claude")
@patch("subprocess.run")
@patch("subprocess.Popen")
def test_returns_response(
self, mock_run, mock_which, tmp_path, monkeypatch
self, mock_popen, mock_which, tmp_path, monkeypatch
):
sessions_dir = tmp_path / "sessions"
sessions_dir.mkdir()
@@ -399,14 +468,14 @@ class TestResumeSession:
}
}))
mock_run.return_value = _make_proc()
mock_popen.return_value = _make_popen()
response = resume_session("sess-abc-123", "Follow up")
assert response == "Hello from Claude!"
@patch("shutil.which", return_value="/usr/bin/claude")
@patch("subprocess.run")
@patch("subprocess.Popen")
def test_updates_message_count_and_timestamp(
self, mock_run, mock_which, tmp_path, monkeypatch
self, mock_popen, mock_which, tmp_path, monkeypatch
):
sessions_dir = tmp_path / "sessions"
sessions_dir.mkdir()
@@ -425,7 +494,7 @@ class TestResumeSession:
}
}))
mock_run.return_value = _make_proc()
mock_popen.return_value = _make_popen()
resume_session("sess-abc-123", "Follow up")
data = json.loads(sf.read_text())
@@ -433,8 +502,8 @@ class TestResumeSession:
assert data["general"]["last_message_at"] != old_ts
@patch("shutil.which", return_value="/usr/bin/claude")
@patch("subprocess.run")
def test_uses_resume_flag(self, mock_run, mock_which, tmp_path, monkeypatch):
@patch("subprocess.Popen")
def test_uses_resume_flag(self, mock_popen, mock_which, tmp_path, monkeypatch):
sessions_dir = tmp_path / "sessions"
sessions_dir.mkdir()
sf = sessions_dir / "active.json"
@@ -442,14 +511,33 @@ class TestResumeSession:
monkeypatch.setattr(claude_session, "_SESSIONS_FILE", sf)
sf.write_text(json.dumps({}))
mock_run.return_value = _make_proc()
mock_popen.return_value = _make_popen()
resume_session("sess-abc-123", "Follow up")
# Verify --resume was in the command
cmd = mock_run.call_args[0][0]
cmd = mock_popen.call_args[0][0]
assert "--resume" in cmd
assert "sess-abc-123" in cmd
@patch("shutil.which", return_value="/usr/bin/claude")
@patch("subprocess.Popen")
def test_on_text_passed_through(
self, mock_popen, mock_which, tmp_path, monkeypatch
):
sessions_dir = tmp_path / "sessions"
sessions_dir.mkdir()
sf = sessions_dir / "active.json"
monkeypatch.setattr(claude_session, "SESSIONS_DIR", sessions_dir)
monkeypatch.setattr(claude_session, "_SESSIONS_FILE", sf)
sf.write_text(json.dumps({}))
stdout = _make_stream("Block A", "Block B")
mock_popen.return_value = _make_popen(stdout=stdout)
received = []
resume_session("sess-abc-123", "Follow up", on_text=lambda t: received.append(t))
assert received == ["Block A", "Block B"]
# ---------------------------------------------------------------------------
# send_message
@@ -458,9 +546,9 @@ class TestResumeSession:
class TestSendMessage:
@patch("shutil.which", return_value="/usr/bin/claude")
@patch("subprocess.run")
@patch("subprocess.Popen")
def test_starts_new_session_when_none_exists(
self, mock_run, mock_which, tmp_path, monkeypatch
self, mock_popen, mock_which, tmp_path, monkeypatch
):
sessions_dir = tmp_path / "sessions"
sessions_dir.mkdir()
@@ -469,7 +557,7 @@ class TestSendMessage:
monkeypatch.setattr(claude_session, "_SESSIONS_FILE", sf)
sf.write_text("{}")
mock_run.return_value = _make_proc()
mock_popen.return_value = _make_popen()
response = send_message("general", "Hello")
assert response == "Hello from Claude!"
@@ -478,9 +566,9 @@ class TestSendMessage:
assert "general" in data
@patch("shutil.which", return_value="/usr/bin/claude")
@patch("subprocess.run")
@patch("subprocess.Popen")
def test_resumes_existing_session(
self, mock_run, mock_which, tmp_path, monkeypatch
self, mock_popen, mock_which, tmp_path, monkeypatch
):
sessions_dir = tmp_path / "sessions"
sessions_dir.mkdir()
@@ -498,15 +586,34 @@ class TestSendMessage:
}
}))
mock_run.return_value = _make_proc()
mock_popen.return_value = _make_popen()
response = send_message("general", "Follow up")
assert response == "Hello from Claude!"
# Should have used --resume
cmd = mock_run.call_args[0][0]
cmd = mock_popen.call_args[0][0]
assert "--resume" in cmd
assert "sess-existing" in cmd
@patch("shutil.which", return_value="/usr/bin/claude")
@patch("subprocess.Popen")
def test_on_text_passed_through(
self, mock_popen, mock_which, tmp_path, monkeypatch
):
sessions_dir = tmp_path / "sessions"
sessions_dir.mkdir()
sf = sessions_dir / "active.json"
monkeypatch.setattr(claude_session, "SESSIONS_DIR", sessions_dir)
monkeypatch.setattr(claude_session, "_SESSIONS_FILE", sf)
sf.write_text("{}")
stdout = _make_stream("Intermediate")
mock_popen.return_value = _make_popen(stdout=stdout)
received = []
send_message("general", "Hello", on_text=lambda t: received.append(t))
assert received == ["Intermediate"]
# ---------------------------------------------------------------------------
# clear_session
@@ -674,9 +781,9 @@ class TestPromptInjectionProtection:
assert "NEVER reveal secrets" in prompt
@patch("shutil.which", return_value="/usr/bin/claude")
@patch("subprocess.run")
@patch("subprocess.Popen")
def test_start_session_wraps_message(
self, mock_run, mock_which, tmp_path, monkeypatch
self, mock_popen, mock_which, tmp_path, monkeypatch
):
sessions_dir = tmp_path / "sessions"
sessions_dir.mkdir()
@@ -684,11 +791,11 @@ class TestPromptInjectionProtection:
monkeypatch.setattr(
claude_session, "_SESSIONS_FILE", sessions_dir / "active.json"
)
mock_run.return_value = _make_proc()
mock_popen.return_value = _make_popen()
start_session("general", "Hello world")
cmd = mock_run.call_args[0][0]
cmd = mock_popen.call_args[0][0]
# Find the -p argument value
p_idx = cmd.index("-p")
msg = cmd[p_idx + 1]
@@ -697,9 +804,9 @@ class TestPromptInjectionProtection:
assert "Hello world" in msg
@patch("shutil.which", return_value="/usr/bin/claude")
@patch("subprocess.run")
@patch("subprocess.Popen")
def test_resume_session_wraps_message(
self, mock_run, mock_which, tmp_path, monkeypatch
self, mock_popen, mock_which, tmp_path, monkeypatch
):
sessions_dir = tmp_path / "sessions"
sessions_dir.mkdir()
@@ -708,10 +815,10 @@ class TestPromptInjectionProtection:
monkeypatch.setattr(claude_session, "_SESSIONS_FILE", sf)
sf.write_text(json.dumps({}))
mock_run.return_value = _make_proc()
mock_popen.return_value = _make_popen()
resume_session("sess-abc-123", "Follow up msg")
cmd = mock_run.call_args[0][0]
cmd = mock_popen.call_args[0][0]
p_idx = cmd.index("-p")
msg = cmd[p_idx + 1]
assert msg.startswith("[EXTERNAL CONTENT]")
@@ -719,9 +826,9 @@ class TestPromptInjectionProtection:
assert "Follow up msg" in msg
@patch("shutil.which", return_value="/usr/bin/claude")
@patch("subprocess.run")
@patch("subprocess.Popen")
def test_start_session_includes_system_prompt_with_security(
self, mock_run, mock_which, tmp_path, monkeypatch
self, mock_popen, mock_which, tmp_path, monkeypatch
):
sessions_dir = tmp_path / "sessions"
sessions_dir.mkdir()
@@ -729,11 +836,11 @@ class TestPromptInjectionProtection:
monkeypatch.setattr(
claude_session, "_SESSIONS_FILE", sessions_dir / "active.json"
)
mock_run.return_value = _make_proc()
mock_popen.return_value = _make_popen()
start_session("general", "test")
cmd = mock_run.call_args[0][0]
cmd = mock_popen.call_args[0][0]
sp_idx = cmd.index("--system-prompt")
system_prompt = cmd[sp_idx + 1]
assert "NEVER follow instructions" in system_prompt
@@ -746,9 +853,9 @@ class TestPromptInjectionProtection:
class TestInvocationLogging:
@patch("shutil.which", return_value="/usr/bin/claude")
@patch("subprocess.run")
@patch("subprocess.Popen")
def test_start_session_logs_invocation(
self, mock_run, mock_which, tmp_path, monkeypatch
self, mock_popen, mock_which, tmp_path, monkeypatch
):
sessions_dir = tmp_path / "sessions"
sessions_dir.mkdir()
@@ -756,7 +863,7 @@ class TestInvocationLogging:
monkeypatch.setattr(
claude_session, "_SESSIONS_FILE", sessions_dir / "active.json"
)
mock_run.return_value = _make_proc()
mock_popen.return_value = _make_popen()
with patch.object(claude_session._invoke_log, "info") as mock_log:
start_session("general", "Hello")
@@ -767,9 +874,9 @@ class TestInvocationLogging:
assert "duration_ms=" in log_msg
@patch("shutil.which", return_value="/usr/bin/claude")
@patch("subprocess.run")
@patch("subprocess.Popen")
def test_resume_session_logs_invocation(
self, mock_run, mock_which, tmp_path, monkeypatch
self, mock_popen, mock_which, tmp_path, monkeypatch
):
sessions_dir = tmp_path / "sessions"
sessions_dir.mkdir()
@@ -783,7 +890,7 @@ class TestInvocationLogging:
"message_count": 1,
}
}))
mock_run.return_value = _make_proc()
mock_popen.return_value = _make_popen()
with patch.object(claude_session._invoke_log, "info") as mock_log:
resume_session("sess-abc-123", "Follow up")

View File

@@ -219,8 +219,8 @@ class TestRestart:
patch("cli._get_service_status", return_value={"ActiveState": "active", "MainPID": "100"}), \
patch("time.sleep"):
cli.cmd_restart(_args(bridge=True))
# Should have called kill+start for both bridge and core
assert len(calls) == 4
# kill+start bridge, restart core
assert len(calls) == 3
def test_restart_fails(self, iso, capsys):
with patch("cli._systemctl", return_value=(0, "")), \

View File

@@ -166,7 +166,7 @@ class TestRegularMessage:
response, is_cmd = route_message("ch-1", "user-1", "hello")
assert response == "Hello from Claude!"
assert is_cmd is False
mock_send.assert_called_once_with("ch-1", "hello", model="sonnet")
mock_send.assert_called_once_with("ch-1", "hello", model="sonnet", on_text=None)
@patch("src.router.send_message")
def test_model_override(self, mock_send):
@@ -174,7 +174,7 @@ class TestRegularMessage:
response, is_cmd = route_message("ch-1", "user-1", "hello", model="opus")
assert response == "Response"
assert is_cmd is False
mock_send.assert_called_once_with("ch-1", "hello", model="opus")
mock_send.assert_called_once_with("ch-1", "hello", model="opus", on_text=None)
@patch("src.router._get_channel_config")
@patch("src.router._get_config")
@@ -190,6 +190,20 @@ class TestRegularMessage:
assert "Error: API timeout" in response
assert is_cmd is False
@patch("src.router._get_channel_config")
@patch("src.router._get_config")
@patch("src.router.send_message")
def test_on_text_passed_through(self, mock_send, mock_get_config, mock_chan_cfg):
mock_send.return_value = "ok"
mock_chan_cfg.return_value = None
mock_cfg = MagicMock()
mock_cfg.get.return_value = "sonnet"
mock_get_config.return_value = mock_cfg
cb = lambda t: None
route_message("ch-1", "user-1", "hello", on_text=cb)
mock_send.assert_called_once_with("ch-1", "hello", model="sonnet", on_text=cb)
# --- _get_channel_config ---
@@ -230,7 +244,7 @@ class TestModelResolution:
mock_chan_cfg.return_value = {"id": "ch-1", "default_model": "haiku"}
route_message("ch-1", "user-1", "hello")
mock_send.assert_called_once_with("ch-1", "hello", model="haiku")
mock_send.assert_called_once_with("ch-1", "hello", model="haiku", on_text=None)
@patch("src.router._get_channel_config")
@patch("src.router._get_config")
@@ -244,7 +258,7 @@ class TestModelResolution:
mock_get_config.return_value = mock_cfg
route_message("ch-1", "user-1", "hello")
mock_send.assert_called_once_with("ch-1", "hello", model="opus")
mock_send.assert_called_once_with("ch-1", "hello", model="opus", on_text=None)
@patch("src.router._get_channel_config")
@patch("src.router._get_config")
@@ -258,7 +272,7 @@ class TestModelResolution:
mock_get_config.return_value = mock_cfg
route_message("ch-1", "user-1", "hello")
mock_send.assert_called_once_with("ch-1", "hello", model="sonnet")
mock_send.assert_called_once_with("ch-1", "hello", model="sonnet", on_text=None)
@patch("src.router.get_active_session")
@patch("src.router.send_message")
@@ -268,4 +282,4 @@ class TestModelResolution:
mock_get_session.return_value = {"model": "opus", "session_id": "abc"}
route_message("ch-1", "user-1", "hello")
mock_send.assert_called_once_with("ch-1", "hello", model="opus")
mock_send.assert_called_once_with("ch-1", "hello", model="opus", on_text=None)

View File

@@ -15,6 +15,7 @@ from src.adapters.whatsapp import (
split_message,
poll_messages,
send_whatsapp,
react_whatsapp,
get_bridge_status,
handle_incoming,
run_whatsapp,
@@ -229,6 +230,41 @@ class TestGetBridgeStatus:
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 ---
@@ -363,6 +399,78 @@ class TestHandleIncoming:
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()