"""Unit tests for GravityBot and ApprovalView — hub/bridge routing logic. Uses unittest.mock to avoid real Discord/Hub connections. Run: python -m pytest tests/test_bot_unit.py -v """ import asyncio import time import pytest from unittest.mock import AsyncMock, MagicMock, patch, PropertyMock import sys, os # Ensure project root is on path sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) from bridge import BridgeProtocol, ApprovalRequest, UserResponse pytestmark = pytest.mark.anyio # ─── Helpers ─────────────────────────────────────────────────────────── def make_request( request_id="req-001", conversation_id="conv-001", command="Run shell command", project_name="test_project", step_type="shell", status="pending", ): return ApprovalRequest( request_id=request_id, conversation_id=conversation_id, command=command, description="test description", timestamp=time.time(), project_name=project_name, step_type=step_type, status=status, ) def make_mock_hub( active_count=1, send_response_result=True, ): """Create a mock Hub with async methods.""" hub = MagicMock() hub.broadcast_to_project = AsyncMock() hub.send_to_instance = AsyncMock() hub.send_response_to_pending_owner = AsyncMock(return_value=send_response_result) hub.get_active_count = MagicMock(return_value=active_count) return hub def make_mock_bridge(): """Create a mock BridgeProtocol.""" bridge = MagicMock(spec=BridgeProtocol) bridge.write_command = MagicMock() bridge.write_response = MagicMock() return bridge def make_mock_gateway(): """Create a mock Gateway.""" gw = MagicMock() gw.push_command = MagicMock() return gw def make_bot_stub(hub=None, bridge=None, gateway=None): """Create a minimal bot-like object with the fields used by _write_command. We don't instantiate GravityBot (which requires Discord) — instead we create a simple namespace that has the same attributes the methods read. """ class BotStub: pass bot = BotStub() bot.hub = hub bot.bridge = bridge or make_mock_bridge() bot.gateway = gateway bot.auto_approve_projects = set() bot._sent_approval_ids = set() bot._approval_messages = {} bot._deferred_ids = {} bot._sent_commands = {} return bot # ─── _write_command Dual-Write Prevention ────────────────────────────── class TestWriteCommand: """_write_command must use Hub-only OR bridge-only, never both.""" def _call_write_command(self, bot, project, text, **kwargs): """Import and call _write_command bound to our stub.""" from bot import GravityBot return GravityBot._write_command(bot, project, text, **kwargs) async def test_hub_present_uses_ws_broadcast(self): """Hub connected + no target → broadcast_to_project, skip bridge.""" hub = make_mock_hub() bot = make_bot_stub(hub=hub) self._call_write_command(bot, "proj", "hello") hub.broadcast_to_project.assert_called_once() bot.bridge.write_command.assert_not_called() async def test_hub_present_skips_bridge(self): """Hub connected → bridge.write_command is NEVER called.""" hub = make_mock_hub() bot = make_bot_stub(hub=hub) self._call_write_command(bot, "proj", "hello") bot.bridge.write_command.assert_not_called() async def test_hub_present_with_target_instance(self): """Hub connected + target_instance → send_to_instance.""" hub = make_mock_hub() bot = make_bot_stub(hub=hub) self._call_write_command(bot, "proj", "hello", target_instance=2) hub.send_to_instance.assert_called_once() args = hub.send_to_instance.call_args assert args[0][0] == "proj" assert args[0][1] == 2 def test_no_hub_uses_bridge(self): """No Hub → bridge.write_command is called.""" bot = make_bot_stub(hub=None) self._call_write_command(bot, "proj", "hello") bot.bridge.write_command.assert_called_once() def test_no_hub_with_gateway(self): """No Hub + gateway → both bridge.write_command and gateway.push_command.""" gw = make_mock_gateway() bot = make_bot_stub(hub=None, gateway=gw) self._call_write_command(bot, "proj", "hello") bot.bridge.write_command.assert_called_once() gw.push_command.assert_called_once() def test_no_hub_no_gateway(self): """No Hub, no gateway → only bridge.write_command.""" bot = make_bot_stub(hub=None, gateway=None) self._call_write_command(bot, "proj", "hello") bot.bridge.write_command.assert_called_once() async def test_message_format_contains_type_and_text(self): """Hub broadcast message has correct structure.""" hub = make_mock_hub() bot = make_bot_stub(hub=hub) self._call_write_command(bot, "proj", "test msg") call_args = hub.broadcast_to_project.call_args msg = call_args[0][1] # second positional arg assert msg["type"] == "command" assert msg["data"]["text"] == "test msg" assert "id" in msg["data"] # ─── _hub_on_pending Routing ─────────────────────────────────────────── class TestHubOnPending: """_hub_on_pending routing: dedup, status checks, auto-approve, Discord send.""" async def _call_hub_on_pending(self, bot, project, data): from bot import GravityBot return await GravityBot._hub_on_pending(bot, project, data) async def test_missing_request_id_returns_early(self): """No request_id in data → silently skip.""" bot = make_bot_stub() # Should not raise await self._call_hub_on_pending(bot, "proj", {"status": "pending"}) # No crash = pass async def test_duplicate_request_id_skipped(self): """Already in _sent_approval_ids → skip.""" bot = make_bot_stub() bot._sent_approval_ids.add("req-dup") bot._get_channel = AsyncMock() await self._call_hub_on_pending(bot, "proj", { "request_id": "req-dup", "command": "test", }) bot._get_channel.assert_not_called() async def test_auto_resolved_status_handled(self): """status=auto_resolved → _handle_auto_resolved.""" bot = make_bot_stub() bot._handle_auto_resolved = AsyncMock() await self._call_hub_on_pending(bot, "proj", { "request_id": "req-ar", "status": "auto_resolved", }) bot._handle_auto_resolved.assert_awaited_once_with("req-ar", "auto_resolved") async def test_expired_status_handled(self): """status=expired → _handle_auto_resolved.""" bot = make_bot_stub() bot._handle_auto_resolved = AsyncMock() await self._call_hub_on_pending(bot, "proj", { "request_id": "req-exp", "status": "expired", }) bot._handle_auto_resolved.assert_awaited_once_with("req-exp", "expired") async def test_auto_approve_project_routes_to_auto_approve(self): """Project in auto_approve_projects → _auto_approve_via_hub.""" hub = make_mock_hub() bot = make_bot_stub(hub=hub) bot.auto_approve_projects = {"proj"} bot._auto_approve_via_hub = AsyncMock() bot._get_channel = AsyncMock() bot._get_instance_header = MagicMock(return_value="") await self._call_hub_on_pending(bot, "proj", { "request_id": "req-auto", "command": "Run", "conversation_id": "conv-1", }) bot._auto_approve_via_hub.assert_awaited_once() # Discord send should NOT have been called directly bot._get_channel.assert_not_called() async def test_normal_pending_sends_to_discord(self): """Normal pending → _get_channel + channel.send with embed + view.""" hub = make_mock_hub() bot = make_bot_stub(hub=hub) bot._get_instance_header = MagicMock(return_value="") mock_msg = MagicMock() mock_msg.id = 12345 mock_channel = AsyncMock() mock_channel.send = AsyncMock(return_value=mock_msg) bot._get_channel = AsyncMock(return_value=mock_channel) await self._call_hub_on_pending(bot, "proj", { "request_id": "req-normal", "command": "Run shell command", "conversation_id": "conv-1", }) mock_channel.send.assert_awaited_once() assert "req-normal" in bot._sent_approval_ids assert bot._approval_messages["req-normal"] == 12345 async def test_no_channel_skips_gracefully(self): """No channel found → warning, no crash.""" hub = make_mock_hub() bot = make_bot_stub(hub=hub) bot._get_instance_header = MagicMock(return_value="") bot._get_channel = AsyncMock(return_value=None) await self._call_hub_on_pending(bot, "proj", { "request_id": "req-nochan", "command": "test", "conversation_id": "conv-1", }) assert "req-nochan" not in bot._sent_approval_ids # ─── _auto_approve_via_hub ───────────────────────────────────────────── class TestAutoApproveViaHub: """_auto_approve_via_hub: hub routing with bridge fallback.""" async def _call_auto_approve(self, bot, request): from bot import GravityBot return await GravityBot._auto_approve_via_hub(bot, request) async def test_hub_delivered_skips_bridge(self): """Hub delivers successfully → bridge.write_response NOT called.""" hub = make_mock_hub(send_response_result=True) bot = make_bot_stub(hub=hub) bot._get_channel = AsyncMock(return_value=None) req = make_request() await self._call_auto_approve(bot, req) hub.send_response_to_pending_owner.assert_awaited_once() bot.bridge.write_response.assert_not_called() assert req.request_id in bot._sent_approval_ids async def test_hub_delivery_failed_falls_back_to_bridge(self): """Hub delivery fails → bridge.write_response called as fallback.""" hub = make_mock_hub(send_response_result=False) bot = make_bot_stub(hub=hub) bot._get_channel = AsyncMock(return_value=None) req = make_request() await self._call_auto_approve(bot, req) hub.send_response_to_pending_owner.assert_awaited_once() bot.bridge.write_response.assert_called_once() async def test_no_hub_uses_bridge_directly(self): """No Hub → bridge.write_response called directly.""" bot = make_bot_stub(hub=None) bot._get_channel = AsyncMock(return_value=None) req = make_request() await self._call_auto_approve(bot, req) bot.bridge.write_response.assert_called_once() async def test_sent_approval_ids_updated(self): """request_id added to _sent_approval_ids regardless of route.""" hub = make_mock_hub(send_response_result=True) bot = make_bot_stub(hub=hub) bot._get_channel = AsyncMock(return_value=None) req = make_request(request_id="req-track") await self._call_auto_approve(bot, req) assert "req-track" in bot._sent_approval_ids # ─── ApprovalView Hub/Bridge Branching ───────────────────────────────── class TestApprovalView: """ApprovalView approve/reject: Hub-first with bridge fallback. Note: approve()/reject() are @discord.ui.button-decorated methods. The decorator turns them into Button objects. To call the underlying coroutine we use view.approve.callback(view, interaction, button). """ def _make_interaction(self, user_name="TestUser"): """Create a mock Discord interaction.""" interaction = AsyncMock() interaction.user.display_name = user_name # Embed in message embed = MagicMock() embed.color = None embed.set_footer = MagicMock() interaction.message.embeds = [embed] interaction.response.edit_message = AsyncMock() interaction.response.send_message = AsyncMock() return interaction async def _invoke_approve(self, view, interaction): """Call the approve button's underlying callback. discord.ui.button wraps the method into an _ItemCallback. The callback signature is just (interaction) — the Button itself is bound internally via the descriptor. """ await view.approve.callback(interaction) async def _invoke_reject(self, view, interaction): """Call the reject button's underlying callback.""" await view.reject.callback(interaction) async def test_approve_hub_delivered_skips_bridge(self): """Approve: hub delivers → bridge NOT called.""" from bot import ApprovalView hub = make_mock_hub(send_response_result=True) bridge = make_mock_bridge() req = make_request() view = ApprovalView(bridge, req, hub=hub) interaction = self._make_interaction() await self._invoke_approve(view, interaction) hub.send_response_to_pending_owner.assert_awaited_once() bridge.write_response.assert_not_called() assert view.responded is True async def test_approve_hub_failed_uses_bridge(self): """Approve: hub delivery fails → bridge fallback.""" from bot import ApprovalView hub = make_mock_hub(send_response_result=False) bridge = make_mock_bridge() req = make_request() view = ApprovalView(bridge, req, hub=hub) interaction = self._make_interaction() await self._invoke_approve(view, interaction) bridge.write_response.assert_called_once() async def test_approve_no_hub_uses_bridge(self): """Approve: no hub → bridge.""" from bot import ApprovalView bridge = make_mock_bridge() req = make_request() view = ApprovalView(bridge, req, hub=None) interaction = self._make_interaction() await self._invoke_approve(view, interaction) bridge.write_response.assert_called_once() async def test_reject_hub_delivered(self): """Reject: hub delivers → bridge NOT called, approved=False.""" from bot import ApprovalView hub = make_mock_hub(send_response_result=True) bridge = make_mock_bridge() req = make_request() view = ApprovalView(bridge, req, hub=hub) interaction = self._make_interaction() await self._invoke_reject(view, interaction) call_args = hub.send_response_to_pending_owner.call_args response_msg = call_args[0][1] assert response_msg["data"]["approved"] is False bridge.write_response.assert_not_called() async def test_double_response_blocked(self): """Second response attempt → blocked with ephemeral message.""" from bot import ApprovalView hub = make_mock_hub(send_response_result=True) bridge = make_mock_bridge() req = make_request() view = ApprovalView(bridge, req, hub=hub) interaction1 = self._make_interaction() await self._invoke_approve(view, interaction1) interaction2 = self._make_interaction() await self._invoke_approve(view, interaction2) interaction2.response.send_message.assert_awaited_once() # Hub should only be called once (first response) assert hub.send_response_to_pending_owner.await_count == 1 async def test_on_timeout_sends_bridge_reject(self): """Timeout with no response → bridge.write_response(approved=False).""" from bot import ApprovalView bridge = make_mock_bridge() req = make_request() view = ApprovalView(bridge, req, hub=None) await view.on_timeout() bridge.write_response.assert_called_once() call_args = bridge.write_response.call_args[0][0] assert isinstance(call_args, UserResponse) assert call_args.approved is False # ─── _get_instance_header ────────────────────────────────────────────── class TestGetInstanceHeader: """Instance header formatting based on active count.""" def _call(self, bot, project, instance_number): from bot import GravityBot return GravityBot._get_instance_header(bot, project, instance_number) def test_no_hub_returns_empty(self): bot = make_bot_stub(hub=None) assert self._call(bot, "proj", 1) == "" def test_single_instance_returns_empty(self): hub = make_mock_hub(active_count=1) bot = make_bot_stub(hub=hub) assert self._call(bot, "proj", 1) == "" def test_multiple_instances_returns_header(self): hub = make_mock_hub(active_count=3) bot = make_bot_stub(hub=hub) result = self._call(bot, "proj", 2) assert "PC #2" in result