"""Network-free tests for Discord adapter routing and delivery behavior.""" from types import SimpleNamespace from unittest.mock import AsyncMock, MagicMock import pytest from bot import bot as botModule class TypingContext: async def __aenter__(self): return self async def __aexit__(self, *_args): return False class FakeChannel: def __init__(self): self.sent = [] async def send(self, content): self.sent.append(content) return content def typing(self): return TypingContext() def _message(content="hello", authorID=123, channel=None): author = SimpleNamespace( id=authorID, display_name="Alice", __str__=lambda self: "Alice", ) return SimpleNamespace( content=content, author=author, channel=channel or FakeChannel(), ) @pytest.fixture(autouse=True) def cleanBotCaches(): botModule.api_clients.clear() botModule.message_history.clear() botModule.user_locks.clear() yield botModule.api_clients.clear() botModule.message_history.clear() botModule.user_locks.clear() def test_api_clients_are_cached_per_stable_discord_id(monkeypatch): created = [] def clientFactory(discordID, displayName): client = SimpleNamespace(discord_id=str(discordID), display_name=displayName) created.append(client) return client monkeypatch.setattr(botModule, "ApiClient", clientFactory) first = botModule.getApiClient(_message(authorID=123)) second = botModule.getApiClient(_message(authorID=123)) third = botModule.getApiClient(_message(authorID=456)) assert first is second and third is not first assert len(created) == 2 and first.display_name == "Alice" @pytest.mark.asyncio async def test_help_message_uses_registered_module_help(monkeypatch): message = _message("help") monkeypatch.setattr( botModule.module_registry, "help_lines", MagicMock(return_value=["- remind me", "- list reminders"]), ) await botModule.sendHelpMessage(message) assert "remind me" in message.channel.sent[0] assert "Just talk naturally" in message.channel.sent[0] @pytest.mark.asyncio async def test_authentication_reuses_token_and_maps_api_failures(monkeypatch): message = _message() existing = SimpleNamespace(token="already-authenticated") monkeypatch.setattr(botModule, "getApiClient", lambda _message: existing) assert await botModule.authenticateMessage(message) is existing for status, expected in [ (403, "not enabled"), (503, "still starting"), (401, "couldn't start"), ]: message = _message() client = SimpleNamespace( token=None, authenticate=AsyncMock(return_value=({"error": "denied"}, status)), ) monkeypatch.setattr(botModule, "getApiClient", lambda _message, value=client: value) assert await botModule.authenticateMessage(message) is None assert expected in message.channel.sent[0] accepted = SimpleNamespace( token=None, authenticate=AsyncMock(return_value=({"token": "new"}, 200)), ) monkeypatch.setattr(botModule, "getApiClient", lambda _message: accepted) assert await botModule.authenticateMessage(_message()) is accepted @pytest.mark.asyncio async def test_command_routing_handles_help_parser_errors_and_unknowns(monkeypatch): apiClient = SimpleNamespace(timezone="UTC", user_uuid="user-one") helpMessage = _message("help") helpCall = AsyncMock() monkeypatch.setattr(botModule, "sendHelpMessage", helpCall) await botModule.routeCommand(helpMessage, apiClient) helpCall.assert_awaited_once_with(helpMessage) parse = AsyncMock( side_effect=[ {"needs_clarification": "When should I do that?"}, {"error": "provider unavailable"}, {"interaction_type": "missing"}, ] ) monkeypatch.setattr(botModule.ai_parser, "parse_command_async", parse) monkeypatch.setattr(botModule.module_registry, "get_command", lambda _name: None) clarification = _message("do the thing") await botModule.routeCommand(clarification, apiClient) assert clarification.channel.sent == ["When should I do that?"] failed = _message("do the other thing") await botModule.routeCommand(failed, apiClient) assert "provider unavailable" in failed.channel.sent[0] unknown = _message("unknown feature") await botModule.routeCommand(unknown, apiClient) assert unknown.channel.sent == ["Unknown command type: missing"] assert len(botModule.message_history[123]) == 3 @pytest.mark.asyncio async def test_command_handler_receives_context_and_failure_is_isolated(monkeypatch): apiClient = SimpleNamespace(timezone="UTC", user_uuid="user-one") parse = AsyncMock(return_value={"interaction_type": "sample", "value": 1}) handler = AsyncMock() monkeypatch.setattr(botModule.ai_parser, "parse_command_async", parse) monkeypatch.setattr( botModule.module_registry, "get_command", lambda _name: {"handler": handler}, ) message = _message("run sample") await botModule.routeCommand(message, apiClient) context, parsed = handler.await_args.args assert context.user_uuid == "user-one" and parsed["value"] == 1 handler.side_effect = RuntimeError("module failed") message = _message("run sample again") await botModule.routeCommand(message, apiClient) assert "failed unexpectedly" in message.channel.sent[0] @pytest.mark.asyncio async def test_on_message_filters_and_serializes_dm_work(monkeypatch): class FakeDMChannel(FakeChannel): pass monkeypatch.setattr(botModule.discord, "DMChannel", FakeDMChannel) botUser = SimpleNamespace(id=999) monkeypatch.setattr(botModule, "client", SimpleNamespace(user=botUser)) authenticate = AsyncMock(return_value=SimpleNamespace()) route = AsyncMock() monkeypatch.setattr(botModule, "authenticateMessage", authenticate) monkeypatch.setattr(botModule, "routeCommand", route) ownMessage = _message(channel=FakeDMChannel()) ownMessage.author = botUser await botModule.on_message(ownMessage) await botModule.on_message(_message(channel=FakeChannel())) authenticate.assert_not_awaited() dm = _message(channel=FakeDMChannel()) await botModule.on_message(dm) authenticate.assert_awaited_once_with(dm) route.assert_awaited_once() assert dm.author.id in botModule.user_locks @pytest.mark.asyncio async def test_outbound_delivery_reports_success_and_retry(monkeypatch): sent = SimpleNamespace(id=987) discordUser = SimpleNamespace(send=AsyncMock(return_value=sent)) client = SimpleNamespace(fetch_user=AsyncMock(return_value=discordUser)) service = SimpleNamespace(service_request=AsyncMock(return_value=({}, 200))) monkeypatch.setattr(botModule, "client", client) monkeypatch.setattr(botModule, "service_client", service) outbound = { "id": "message-one", "provider_user_id": "123", "content": "hello", "worker_id": "worker-one", } await botModule.deliverOutboundMessage(outbound) payload = service.service_request.await_args.args[2] assert payload == { "status": "sent", "external_message_id": "987", "worker_id": "worker-one", } client.fetch_user.side_effect = RuntimeError("Discord offline") service.service_request.reset_mock() await botModule.deliverOutboundMessage(outbound) payload = service.service_request.await_args.args[2] assert payload["status"] == "retry" assert payload["worker_id"] == "worker-one" assert "Discord offline" in payload["error"] @pytest.mark.asyncio async def test_ready_starts_outbox_loop_once(monkeypatch): loop = SimpleNamespace(is_running=MagicMock(side_effect=[False, True]), start=MagicMock()) monkeypatch.setattr(botModule, "outboxLoop", loop) monkeypatch.setattr(botModule, "client", SimpleNamespace(user="bot-user")) await botModule.on_ready() await botModule.on_ready() loop.start.assert_called_once() @pytest.mark.asyncio async def test_outbox_poll_handles_api_failure_and_delivers_batch(monkeypatch): outbound = {"id": "message-one"} service = SimpleNamespace( service_request=AsyncMock( side_effect=[ ({"error": "offline"}, 503), ({"messages": [outbound]}, 200), ] ) ) deliver = AsyncMock() monkeypatch.setattr(botModule, "service_client", service) monkeypatch.setattr(botModule, "deliverOutboundMessage", deliver) await botModule.outboxLoop.coro() deliver.assert_not_awaited() await botModule.outboxLoop.coro() deliver.assert_awaited_once_with(outbound) request = service.service_request.await_args.args assert request[0:2] == ("post", "/api/internal/outbox/claim") assert request[2]["worker_id"] == botModule.OUTBOX_WORKER_ID @pytest.mark.asyncio async def test_outbox_before_loop_waits_for_discord_ready(monkeypatch): wait = AsyncMock() monkeypatch.setattr(botModule, "client", SimpleNamespace(wait_until_ready=wait)) await botModule.beforeOutboxLoop() wait.assert_awaited_once()