Files
beads/lib/test_beads_mail_adapter.py
Steve Yegge 9351044539 feat(agent-mail): Add Python adapter library for Agent Mail integration
- Created lib/beads_mail_adapter.py with AgentMailAdapter class
- Automatic health check on initialization
- Graceful degradation when server unavailable
- Methods: reserve_issue(), release_issue(), notify(), check_inbox()
- Environment-based configuration (AGENT_MAIL_URL, AGENT_MAIL_TOKEN)
- Comprehensive unit tests (15 tests, 100% passing)
- Full documentation in lib/README.md

Closes bd-m9th

Amp-Thread-ID: https://ampcode.com/threads/T-caa26228-0d18-4b35-b98a-9f95f6a099fe
Co-authored-by: Amp <amp@ampcode.com>
2025-11-08 00:15:07 -08:00

286 lines
11 KiB
Python

#!/usr/bin/env python3
"""
Unit tests for beads_mail_adapter.py
Tests cover:
- Enabled mode (server available)
- Disabled mode (server unavailable)
- Graceful degradation (server dies mid-operation)
- Reservation conflicts
- Message sending/receiving
"""
import unittest
import json
import os
from unittest.mock import patch, Mock, MagicMock
from urllib.error import URLError, HTTPError
from io import BytesIO
from beads_mail_adapter import AgentMailAdapter
class TestAgentMailAdapterDisabled(unittest.TestCase):
"""Test adapter when server is unavailable (disabled mode)."""
@patch('beads_mail_adapter.urlopen')
def test_init_server_unavailable(self, mock_urlopen):
"""Test initialization when server is unreachable."""
mock_urlopen.side_effect = URLError("Connection refused")
adapter = AgentMailAdapter(
url="http://localhost:9999",
token="test-token",
agent_name="test-agent"
)
self.assertFalse(adapter.enabled)
self.assertEqual(adapter.url, "http://localhost:9999")
self.assertEqual(adapter.agent_name, "test-agent")
@patch('beads_mail_adapter.urlopen')
def test_operations_no_op_when_disabled(self, mock_urlopen):
"""Test that all operations gracefully no-op when disabled."""
mock_urlopen.side_effect = URLError("Connection refused")
adapter = AgentMailAdapter()
self.assertFalse(adapter.enabled)
# All operations should succeed without making requests
self.assertTrue(adapter.reserve_issue("bd-123"))
self.assertTrue(adapter.release_issue("bd-123"))
self.assertTrue(adapter.notify("test", {"foo": "bar"}))
self.assertEqual(adapter.check_inbox(), [])
self.assertEqual(adapter.get_reservations(), [])
class TestAgentMailAdapterEnabled(unittest.TestCase):
"""Test adapter when server is available (enabled mode)."""
def _mock_response(self, status_code=200, data=None):
"""Create mock HTTP response."""
mock_response = MagicMock()
mock_response.status = status_code
mock_response.__enter__ = Mock(return_value=mock_response)
mock_response.__exit__ = Mock(return_value=False)
if data is not None:
mock_response.read.return_value = json.dumps(data).encode('utf-8')
else:
mock_response.read.return_value = b''
return mock_response
@patch('beads_mail_adapter.urlopen')
def test_init_server_available(self, mock_urlopen):
"""Test initialization when server is healthy."""
mock_urlopen.return_value = self._mock_response(200, {"status": "ok"})
adapter = AgentMailAdapter(
url="http://localhost:8765",
token="test-token",
agent_name="test-agent"
)
self.assertTrue(adapter.enabled)
self.assertEqual(adapter.url, "http://localhost:8765")
@patch('beads_mail_adapter.urlopen')
def test_reserve_issue_success(self, mock_urlopen):
"""Test successful issue reservation."""
# Health check
mock_urlopen.return_value = self._mock_response(200, {"status": "ok"})
adapter = AgentMailAdapter(agent_name="test-agent")
# Reservation request
mock_urlopen.return_value = self._mock_response(201, {"reserved": True})
result = adapter.reserve_issue("bd-123")
self.assertTrue(result)
self.assertTrue(adapter.enabled)
@patch('beads_mail_adapter.urlopen')
def test_reserve_issue_conflict(self, mock_urlopen):
"""Test reservation conflict (issue already reserved)."""
# Health check
mock_urlopen.return_value = self._mock_response(200, {"status": "ok"})
adapter = AgentMailAdapter(agent_name="test-agent")
# Simulate 409 Conflict
error_response = HTTPError(
url="http://test",
code=409,
msg="Conflict",
hdrs={},
fp=BytesIO(json.dumps({"error": "Already reserved by other-agent"}).encode('utf-8'))
)
mock_urlopen.side_effect = error_response
result = adapter.reserve_issue("bd-123")
self.assertFalse(result)
@patch('beads_mail_adapter.urlopen')
def test_release_issue_success(self, mock_urlopen):
"""Test successful issue release."""
mock_urlopen.return_value = self._mock_response(200, {"status": "ok"})
adapter = AgentMailAdapter(agent_name="test-agent")
mock_urlopen.return_value = self._mock_response(204)
result = adapter.release_issue("bd-123")
self.assertTrue(result)
@patch('beads_mail_adapter.urlopen')
def test_notify_success(self, mock_urlopen):
"""Test sending notification."""
mock_urlopen.return_value = self._mock_response(200, {"status": "ok"})
adapter = AgentMailAdapter(agent_name="test-agent")
mock_urlopen.return_value = self._mock_response(201, {"sent": True})
result = adapter.notify("status_changed", {"issue_id": "bd-123", "status": "in_progress"})
self.assertTrue(result)
@patch('beads_mail_adapter.urlopen')
def test_check_inbox_with_messages(self, mock_urlopen):
"""Test checking inbox with messages."""
mock_urlopen.return_value = self._mock_response(200, {"status": "ok"})
adapter = AgentMailAdapter(agent_name="test-agent")
messages = [
{"from": "agent-1", "event": "completed", "data": {"issue_id": "bd-42"}},
{"from": "agent-2", "event": "started", "data": {"issue_id": "bd-99"}}
]
mock_urlopen.return_value = self._mock_response(200, messages)
result = adapter.check_inbox()
self.assertEqual(len(result), 2)
self.assertEqual(result[0]["from"], "agent-1")
@patch('beads_mail_adapter.urlopen')
def test_check_inbox_empty(self, mock_urlopen):
"""Test checking empty inbox."""
mock_urlopen.return_value = self._mock_response(200, {"status": "ok"})
adapter = AgentMailAdapter(agent_name="test-agent")
mock_urlopen.return_value = self._mock_response(200, [])
result = adapter.check_inbox()
self.assertEqual(result, [])
@patch('beads_mail_adapter.urlopen')
def test_get_reservations(self, mock_urlopen):
"""Test getting all reservations."""
mock_urlopen.return_value = self._mock_response(200, {"status": "ok"})
adapter = AgentMailAdapter(agent_name="test-agent")
reservations = [
{"issue_id": "bd-123", "agent": "agent-1"},
{"issue_id": "bd-456", "agent": "agent-2"}
]
mock_urlopen.return_value = self._mock_response(200, reservations)
result = adapter.get_reservations()
self.assertEqual(len(result), 2)
class TestGracefulDegradation(unittest.TestCase):
"""Test graceful degradation when server fails mid-operation."""
def _mock_response(self, status_code=200, data=None):
"""Create mock HTTP response."""
mock_response = MagicMock()
mock_response.status = status_code
mock_response.__enter__ = Mock(return_value=mock_response)
mock_response.__exit__ = Mock(return_value=False)
if data is not None:
mock_response.read.return_value = json.dumps(data).encode('utf-8')
else:
mock_response.read.return_value = b''
return mock_response
@patch('beads_mail_adapter.urlopen')
def test_server_dies_mid_operation(self, mock_urlopen):
"""Test that operations gracefully handle server failures."""
# Initially healthy
mock_urlopen.return_value = self._mock_response(200, {"status": "ok"})
adapter = AgentMailAdapter()
self.assertTrue(adapter.enabled)
# Server dies during operation
mock_urlopen.side_effect = URLError("Connection refused")
# Operations should still succeed (graceful degradation)
self.assertTrue(adapter.reserve_issue("bd-123"))
self.assertTrue(adapter.release_issue("bd-123"))
self.assertTrue(adapter.notify("test", {}))
self.assertEqual(adapter.check_inbox(), [])
@patch('beads_mail_adapter.urlopen')
def test_network_timeout(self, mock_urlopen):
"""Test handling of network timeouts."""
mock_urlopen.return_value = self._mock_response(200, {"status": "ok"})
adapter = AgentMailAdapter(timeout=1)
mock_urlopen.side_effect = URLError("Timeout")
# Should not crash
self.assertTrue(adapter.reserve_issue("bd-123"))
class TestConfiguration(unittest.TestCase):
"""Test environment variable configuration."""
@patch.dict(os.environ, {
'AGENT_MAIL_URL': 'http://custom:9000',
'AGENT_MAIL_TOKEN': 'custom-token',
'BEADS_AGENT_NAME': 'custom-agent',
'AGENT_MAIL_TIMEOUT': '10'
})
@patch('beads_mail_adapter.urlopen')
def test_env_var_configuration(self, mock_urlopen):
"""Test configuration from environment variables."""
mock_urlopen.side_effect = URLError("Not testing connection")
adapter = AgentMailAdapter()
self.assertEqual(adapter.url, "http://custom:9000")
self.assertEqual(adapter.token, "custom-token")
self.assertEqual(adapter.agent_name, "custom-agent")
self.assertEqual(adapter.timeout, 10)
@patch('beads_mail_adapter.urlopen')
def test_constructor_overrides_env(self, mock_urlopen):
"""Test that constructor args override environment variables."""
mock_urlopen.side_effect = URLError("Not testing connection")
adapter = AgentMailAdapter(
url="http://override:8765",
token="override-token",
agent_name="override-agent",
timeout=3
)
self.assertEqual(adapter.url, "http://override:8765")
self.assertEqual(adapter.token, "override-token")
self.assertEqual(adapter.agent_name, "override-agent")
self.assertEqual(adapter.timeout, 3)
@patch('beads_mail_adapter.urlopen')
def test_url_trailing_slash_removed(self, mock_urlopen):
"""Test that trailing slashes are removed from URL."""
mock_urlopen.side_effect = URLError("Not testing connection")
adapter = AgentMailAdapter(url="http://localhost:8765/")
self.assertEqual(adapter.url, "http://localhost:8765")
if __name__ == '__main__':
unittest.main()