263 lines
9.1 KiB
Python
263 lines
9.1 KiB
Python
"""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()
|