Build reusable bot framework
This commit is contained in:
262
tests/unit/test_bot_runtime.py
Normal file
262
tests/unit/test_bot_runtime.py
Normal file
@@ -0,0 +1,262 @@
|
||||
"""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()
|
||||
Reference in New Issue
Block a user