Files
beads/integrations/beads-mcp/tests/test_mail.py

427 lines
15 KiB
Python

"""Tests for Agent Mail messaging integration."""
import os
from unittest.mock import Mock, patch
import pytest
from beads_mcp.mail import (
MailError,
mail_ack,
mail_delete,
mail_inbox,
mail_read,
mail_reply,
mail_send,
)
from beads_mcp.models import (
MailAckParams,
MailDeleteParams,
MailInboxParams,
MailReadParams,
MailReplyParams,
MailSendParams,
)
@pytest.fixture
def mock_agent_mail_env(tmp_path):
"""Set up Agent Mail environment variables."""
old_env = os.environ.copy()
os.environ["BEADS_AGENT_MAIL_URL"] = "http://127.0.0.1:8765"
os.environ["BEADS_AGENT_NAME"] = "test-agent"
os.environ["BEADS_PROJECT_ID"] = str(tmp_path)
yield
# Restore environment
os.environ.clear()
os.environ.update(old_env)
@pytest.fixture
def mock_requests():
"""Mock requests library for HTTP calls."""
with patch("beads_mcp.mail.requests.request") as mock_req:
yield mock_req
class TestMailConfiguration:
"""Test configuration and error handling."""
def test_missing_url_raises_error(self):
"""Test that missing BEADS_AGENT_MAIL_URL raises NOT_CONFIGURED."""
old_url = os.environ.pop("BEADS_AGENT_MAIL_URL", None)
try:
with pytest.raises(MailError) as exc_info:
mail_send(to=["alice"], subject="Test", body="Test")
assert exc_info.value.code == "NOT_CONFIGURED"
assert "BEADS_AGENT_MAIL_URL" in exc_info.value.message
finally:
if old_url:
os.environ["BEADS_AGENT_MAIL_URL"] = old_url
def test_missing_agent_name_derives_default(self, mock_agent_mail_env, mock_requests, tmp_path):
"""Test that missing BEADS_AGENT_NAME derives from user/repo."""
del os.environ["BEADS_AGENT_NAME"]
os.environ["BEADS_PROJECT_ID"] = str(tmp_path)
mock_requests.return_value.status_code = 200
mock_requests.return_value.json.return_value = {
"deliveries": [{
"payload": {
"id": 123,
"thread_id": "thread-1",
}
}]
}
mock_requests.return_value.content = b'{"deliveries": []}'
# Should not raise - derives agent name
result = mail_send(to=["alice"], subject="Test", body="Test")
assert result["message_id"] == 123
class TestMailSend:
"""Test mail_send function."""
def test_send_basic_message(self, mock_agent_mail_env, mock_requests):
"""Test sending a basic message."""
mock_requests.return_value.status_code = 200
mock_requests.return_value.json.return_value = {
"deliveries": [{
"payload": {
"id": 123,
"thread_id": "thread-abc",
}
}]
}
mock_requests.return_value.content = b'{"deliveries": []}'
result = mail_send(
to=["alice", "bob"],
subject="Test Message",
body="Hello world!",
)
assert result["message_id"] == 123
assert result["thread_id"] == "thread-abc"
assert result["sent_to"] == 1
# Verify HTTP request
mock_requests.assert_called_once()
args, call_kwargs = mock_requests.call_args
assert args[0] == "POST"
assert call_kwargs["json"]["params"]["name"] == "send_message"
assert call_kwargs["json"]["params"]["arguments"]["to"] == ["alice", "bob"]
assert call_kwargs["json"]["params"]["arguments"]["subject"] == "Test Message"
def test_send_urgent_message(self, mock_agent_mail_env, mock_requests):
"""Test sending urgent message."""
mock_requests.return_value.status_code = 200
mock_requests.return_value.json.return_value = {
"deliveries": [{
"payload": {"id": 456, "thread_id": "thread-xyz"}
}]
}
mock_requests.return_value.content = b'{"deliveries": []}'
result = mail_send(
to=["alice"],
subject="URGENT",
body="Need review now!",
urgent=True,
)
call_kwargs = mock_requests.call_args.kwargs
assert call_kwargs["json"]["params"]["arguments"]["importance"] == "urgent"
def test_send_with_cc(self, mock_agent_mail_env, mock_requests):
"""Test sending message with CC recipients."""
mock_requests.return_value.status_code = 200
mock_requests.return_value.json.return_value = {
"deliveries": [{
"payload": {"id": 789, "thread_id": "thread-123"}
}]
}
mock_requests.return_value.content = b'{"deliveries": []}'
result = mail_send(
to=["alice"],
subject="FYI",
body="For your info",
cc=["bob", "charlie"],
)
call_kwargs = mock_requests.call_args.kwargs
assert call_kwargs["json"]["params"]["arguments"]["cc"] == ["bob", "charlie"]
def test_send_connection_error(self, mock_agent_mail_env, mock_requests):
"""Test handling connection errors."""
import requests.exceptions
mock_requests.side_effect = requests.exceptions.ConnectionError("Connection refused")
with pytest.raises(MailError) as exc_info:
mail_send(to=["alice"], subject="Test", body="Test")
assert exc_info.value.code == "UNAVAILABLE"
assert "Cannot connect" in exc_info.value.message
class TestMailInbox:
"""Test mail_inbox function."""
def test_fetch_inbox_default(self, mock_agent_mail_env, mock_requests):
"""Test fetching inbox with default parameters."""
mock_requests.return_value.status_code = 200
mock_requests.return_value.json.return_value = [
{
"id": 1,
"thread_id": "thread-1",
"from": "alice",
"subject": "Hello",
"created_ts": "2025-01-01T00:00:00Z",
"read_ts": None,
"ack_required": False,
"importance": "normal",
"body_md": "This is a test message",
},
{
"id": 2,
"thread_id": "thread-2",
"from": "bob",
"subject": "Urgent!",
"created_ts": "2025-01-02T00:00:00Z",
"read_ts": "2025-01-02T01:00:00Z",
"ack_required": True,
"importance": "urgent",
"body_md": "Please review ASAP",
},
]
mock_requests.return_value.content = b'[]'
result = mail_inbox()
assert len(result["messages"]) == 2
assert result["messages"][0]["id"] == 1
assert result["messages"][0]["unread"] is True
assert result["messages"][0]["urgent"] is False
assert result["messages"][1]["id"] == 2
assert result["messages"][1]["unread"] is False
assert result["messages"][1]["urgent"] is True
def test_fetch_inbox_unread_only(self, mock_agent_mail_env, mock_requests):
"""Test fetching only unread messages."""
mock_requests.return_value.status_code = 200
mock_requests.return_value.json.return_value = [
{"id": 1, "thread_id": "t1", "from": "alice", "subject": "Test", "created_ts": "2025-01-01T00:00:00Z", "read_ts": None, "importance": "normal"},
{"id": 2, "thread_id": "t2", "from": "bob", "subject": "Test2", "created_ts": "2025-01-01T00:00:00Z", "read_ts": "2025-01-01T01:00:00Z", "importance": "normal"},
]
mock_requests.return_value.content = b'[]'
result = mail_inbox(unread_only=True)
# Should filter out message 2 (read)
assert len(result["messages"]) == 1
assert result["messages"][0]["id"] == 1
def test_fetch_inbox_pagination(self, mock_agent_mail_env, mock_requests):
"""Test inbox pagination with next_cursor."""
mock_requests.return_value.status_code = 200
# Simulate full page (limit reached)
mock_requests.return_value.json.return_value = [
{"id": i, "thread_id": f"t{i}", "from": "alice", "subject": f"Msg {i}", "created_ts": "2025-01-01T00:00:00Z", "importance": "normal"}
for i in range(20)
]
mock_requests.return_value.content = b'[]'
result = mail_inbox(limit=20)
# Should return next_cursor when limit reached
assert result["next_cursor"] == "19" # Last message ID
class TestMailRead:
"""Test mail_read function."""
def test_read_message_marks_read(self, mock_agent_mail_env, mock_requests):
"""Test reading message marks it as read by default."""
# Mock resource fetch
mock_requests.return_value.status_code = 200
mock_requests.return_value.json.return_value = {
"contents": [{
"id": 123,
"thread_id": "thread-1",
"from": "alice",
"to": ["test-agent"],
"subject": "Test",
"body_md": "Hello world!",
"created_ts": "2025-01-01T00:00:00Z",
"ack_required": False,
"importance": "normal",
"read_ts": None,
}]
}
mock_requests.return_value.content = b'{}'
result = mail_read(message_id=123)
assert result["id"] == 123
assert result["body"] == "Hello world!"
assert result["urgent"] is False
# Should have called both GET resource and POST mark_read
assert mock_requests.call_count == 2
def test_read_message_no_mark(self, mock_agent_mail_env, mock_requests):
"""Test reading without marking as read."""
mock_requests.return_value.status_code = 200
mock_requests.return_value.json.return_value = {
"contents": [{
"id": 123,
"thread_id": "thread-1",
"from": "alice",
"to": ["test-agent"],
"subject": "Test",
"body_md": "Preview",
"created_ts": "2025-01-01T00:00:00Z",
"importance": "normal",
}]
}
mock_requests.return_value.content = b'{}'
result = mail_read(message_id=123, mark_read=False)
# Should only call GET resource, not mark_read
assert mock_requests.call_count == 1
class TestMailReply:
"""Test mail_reply function."""
def test_reply_to_message(self, mock_agent_mail_env, mock_requests):
"""Test replying to a message."""
mock_requests.return_value.status_code = 200
mock_requests.return_value.json.return_value = {
"reply": {
"id": 456,
"thread_id": "thread-1",
}
}
mock_requests.return_value.content = b'{}'
result = mail_reply(
message_id=123,
body="Thanks for the message!",
)
assert result["message_id"] == 456
assert result["thread_id"] == "thread-1"
call_kwargs = mock_requests.call_args.kwargs
assert call_kwargs["json"]["params"]["name"] == "reply_message"
assert call_kwargs["json"]["params"]["arguments"]["message_id"] == 123
class TestMailAck:
"""Test mail_ack function."""
def test_acknowledge_message(self, mock_agent_mail_env, mock_requests):
"""Test acknowledging a message."""
mock_requests.return_value.status_code = 200
mock_requests.return_value.json.return_value = {}
mock_requests.return_value.content = b'{}'
result = mail_ack(message_id=123)
assert result["acknowledged"] is True
call_kwargs = mock_requests.call_args.kwargs
assert call_kwargs["json"]["params"]["name"] == "acknowledge_message"
class TestMailDelete:
"""Test mail_delete function."""
def test_delete_message(self, mock_agent_mail_env, mock_requests):
"""Test deleting/archiving a message."""
mock_requests.return_value.status_code = 200
mock_requests.return_value.json.return_value = {}
mock_requests.return_value.content = b'{}'
result = mail_delete(message_id=123)
assert result["archived"] is True
class TestMailRetries:
"""Test retry logic and error handling."""
def test_retries_on_server_error(self, mock_agent_mail_env, mock_requests):
"""Test that 500 errors trigger retries."""
mock_requests.return_value.status_code = 500
mock_requests.return_value.content = b'Internal Server Error'
with pytest.raises(MailError) as exc_info:
mail_send(to=["alice"], subject="Test", body="Test")
assert exc_info.value.code == "UNAVAILABLE"
# Should retry 3 times total (initial + 2 retries)
assert mock_requests.call_count == 3
def test_no_retry_on_client_error(self, mock_agent_mail_env, mock_requests):
"""Test that 404 errors don't trigger retries."""
mock_requests.return_value.status_code = 404
mock_requests.return_value.json.return_value = {"detail": "Not found"}
mock_requests.return_value.content = b'{"detail": "Not found"}'
with pytest.raises(MailError) as exc_info:
mail_read(message_id=999)
assert exc_info.value.code == "NOT_FOUND"
# Should not retry on 404
assert mock_requests.call_count == 1
class TestMailToolWrappers:
"""Test MCP tool wrappers."""
def test_mail_send_params(self, mock_agent_mail_env, mock_requests):
"""Test MailSendParams validation."""
from beads_mcp.mail_tools import beads_mail_send
mock_requests.return_value.status_code = 200
mock_requests.return_value.json.return_value = {
"deliveries": [{
"payload": {"id": 123, "thread_id": "t1"}
}]
}
mock_requests.return_value.content = b'{}'
params = MailSendParams(
to=["alice"],
subject="Test",
body="Hello",
urgent=True,
)
result = beads_mail_send(params)
assert result["message_id"] == 123
def test_mail_inbox_default_params(self, mock_agent_mail_env, mock_requests):
"""Test MailInboxParams with defaults."""
from beads_mcp.mail_tools import beads_mail_inbox
mock_requests.return_value.status_code = 200
mock_requests.return_value.json.return_value = []
mock_requests.return_value.content = b'[]'
params = MailInboxParams() # All defaults
result = beads_mail_inbox(params)
assert result["messages"] == []
assert result["next_cursor"] is None