Discord bot inbound adapter (adapters/inbound/discord_bot.py): - ChatService injected directly — no HTTP roundtrip to FastAPI API - No module-level singleton: create_bot() factory for construction - Pure functions extracted for testing: build_answer_embed, build_error_embed, parse_conversation_id - Uses message.reference.resolved cache before fetch_message - Error embeds never leak exception details - 19 new tests covering embed building, footer parsing, error safety Removed old app/ directory (9 files): - All functionality preserved in hexagonal domain/, adapters/, config/ - Old test_basic.py removed (superseded by 120 adapter/domain tests) Other changes: - docker-compose: api uses main:app, discord-bot uses run_discord.py with direct ChatService injection (no API dependency) - Removed unused openai dependency from pyproject.toml - Removed app/ from hatch build targets Test suite: 120 passed, 1 skipped Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
169 lines
6.4 KiB
Python
169 lines
6.4 KiB
Python
"""Tests for the Discord inbound adapter.
|
|
|
|
Discord.py makes it hard to test event handlers directly (they require a
|
|
running gateway connection). Instead, we test the *pure logic* that the
|
|
adapter extracts into standalone functions / methods:
|
|
|
|
- build_answer_embed: constructs the Discord embed from a ChatResult
|
|
- build_error_embed: constructs a safe error embed (no leaked details)
|
|
- parse_conversation_id: extracts conversation UUID from footer text
|
|
- truncate_response: handles Discord's 4000-char embed limit
|
|
|
|
The bot class itself (StratChatbot) is tested for construction, dependency
|
|
injection, and configuration — not for full gateway event handling.
|
|
"""
|
|
|
|
import pytest
|
|
|
|
from domain.models import ChatResult
|
|
from adapters.inbound.discord_bot import (
|
|
build_answer_embed,
|
|
build_error_embed,
|
|
parse_conversation_id,
|
|
FOOTER_PREFIX,
|
|
)
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Fixtures
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _make_result(**overrides) -> ChatResult:
|
|
"""Create a ChatResult with sensible defaults, overridable per-test."""
|
|
defaults = {
|
|
"response": "Based on Rule 5.2.1(b), runners can steal.",
|
|
"conversation_id": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
|
|
"message_id": "msg-123",
|
|
"parent_message_id": "msg-000",
|
|
"cited_rules": ["5.2.1(b)"],
|
|
"confidence": 0.9,
|
|
"needs_human": False,
|
|
}
|
|
defaults.update(overrides)
|
|
return ChatResult(**defaults)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# build_answer_embed
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestBuildAnswerEmbed:
|
|
"""build_answer_embed turns a ChatResult into a Discord Embed."""
|
|
|
|
def test_description_contains_response(self):
|
|
result = _make_result()
|
|
embed = build_answer_embed(result, title="Rules Answer")
|
|
assert result.response in embed.description
|
|
|
|
def test_footer_contains_full_conversation_id(self):
|
|
result = _make_result()
|
|
embed = build_answer_embed(result, title="Rules Answer")
|
|
assert result.conversation_id in embed.footer.text
|
|
|
|
def test_footer_starts_with_prefix(self):
|
|
result = _make_result()
|
|
embed = build_answer_embed(result, title="Rules Answer")
|
|
assert embed.footer.text.startswith(FOOTER_PREFIX)
|
|
|
|
def test_cited_rules_field_present(self):
|
|
result = _make_result(cited_rules=["5.2.1(b)", "3.1"])
|
|
embed = build_answer_embed(result, title="Rules Answer")
|
|
field_names = [f.name for f in embed.fields]
|
|
assert any("Cited" in name for name in field_names)
|
|
# Both rule IDs should be in the field value
|
|
rules_field = [f for f in embed.fields if "Cited" in f.name][0]
|
|
assert "5.2.1(b)" in rules_field.value
|
|
assert "3.1" in rules_field.value
|
|
|
|
def test_no_cited_rules_field_when_empty(self):
|
|
result = _make_result(cited_rules=[])
|
|
embed = build_answer_embed(result, title="Rules Answer")
|
|
field_names = [f.name for f in embed.fields]
|
|
assert not any("Cited" in name for name in field_names)
|
|
|
|
def test_low_confidence_adds_warning_field(self):
|
|
result = _make_result(confidence=0.2)
|
|
embed = build_answer_embed(result, title="Rules Answer")
|
|
field_names = [f.name for f in embed.fields]
|
|
assert any("Confidence" in name for name in field_names)
|
|
|
|
def test_high_confidence_no_warning_field(self):
|
|
result = _make_result(confidence=0.9)
|
|
embed = build_answer_embed(result, title="Rules Answer")
|
|
field_names = [f.name for f in embed.fields]
|
|
assert not any("Confidence" in name for name in field_names)
|
|
|
|
def test_response_truncated_at_4000_chars(self):
|
|
long_response = "x" * 5000
|
|
result = _make_result(response=long_response)
|
|
embed = build_answer_embed(result, title="Rules Answer")
|
|
assert len(embed.description) <= 4000
|
|
|
|
def test_truncation_notice_appended(self):
|
|
long_response = "x" * 5000
|
|
result = _make_result(response=long_response)
|
|
embed = build_answer_embed(result, title="Rules Answer")
|
|
assert "truncated" in embed.description.lower()
|
|
|
|
def test_custom_title(self):
|
|
result = _make_result()
|
|
embed = build_answer_embed(result, title="Follow-up Answer")
|
|
assert embed.title == "Follow-up Answer"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# build_error_embed
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestBuildErrorEmbed:
|
|
"""build_error_embed creates a safe error embed with no leaked details."""
|
|
|
|
def test_does_not_contain_exception_text(self):
|
|
error = RuntimeError("API key abc123 is invalid for https://internal.host")
|
|
embed = build_error_embed(error)
|
|
assert "abc123" not in embed.description
|
|
assert "internal.host" not in embed.description
|
|
|
|
def test_has_generic_message(self):
|
|
embed = build_error_embed(RuntimeError("anything"))
|
|
assert (
|
|
"try again" in embed.description.lower()
|
|
or "went wrong" in embed.description.lower()
|
|
)
|
|
|
|
def test_title_indicates_error(self):
|
|
embed = build_error_embed(ValueError("x"))
|
|
assert "Error" in embed.title or "error" in embed.title
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# parse_conversation_id
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestParseConversationId:
|
|
"""parse_conversation_id extracts the full UUID from embed footer text."""
|
|
|
|
def test_parses_valid_footer(self):
|
|
footer = "conv:aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee | Reply to ask a follow-up"
|
|
assert parse_conversation_id(footer) == "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"
|
|
|
|
def test_returns_none_for_missing_prefix(self):
|
|
assert parse_conversation_id("no prefix here") is None
|
|
|
|
def test_returns_none_for_empty_string(self):
|
|
assert parse_conversation_id("") is None
|
|
|
|
def test_returns_none_for_none_input(self):
|
|
assert parse_conversation_id(None) is None
|
|
|
|
def test_returns_none_for_malformed_footer(self):
|
|
assert parse_conversation_id("conv:") is None
|
|
|
|
def test_handles_no_pipe_separator(self):
|
|
footer = "conv:some-uuid-value"
|
|
result = parse_conversation_id(footer)
|
|
assert result == "some-uuid-value"
|