"""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"