strat-chatbot/tests/adapters/test_discord.py
Cal Corum 1f1048ee08 refactor: migrate Discord bot to hexagonal adapter, remove old app/ directory
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>
2026-03-08 16:07:36 -05:00

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"