Build reusable bot framework
Some checks failed
CI / test (push) Has been cancelled
CI / compose-smoke (push) Has been cancelled

This commit is contained in:
Chelsea Lee
2026-07-19 21:53:24 -05:00
parent 7ecc1107b2
commit fbdf33e894
66 changed files with 8428 additions and 0 deletions

View 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()