Skip to content

Commit caa832f

Browse files
JacobCoffeeclaude
andcommitted
merge: Phase 3.4 bot service comprehensive tests (92% coverage)
Merged 12 commits adding 165+ tests for bot service: - Forums view tests for 96.77% coverage (32 tests) - Forums, astral, python plugin tests at 100% (64 tests) - Utils tests with subprocess/HTTP mocking at 100% (67 tests) - Custom plugins (litestar) at 100% (21 tests) - Bot lifecycle and event handlers at 100% (16 tests) - All plugin edge cases and error handling (60+ tests) Coverage: 79.59% → ~92% (+12.41%) Total bot tests: 305 → 470 (+165, 54% increase) Plugin coverage: 99.59% (near-perfect) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]>
2 parents df34dda + 0518517 commit caa832f

28 files changed

+8416
-1
lines changed

pyproject.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,8 @@ dev = [
3939
"sourcery>=1.14.0",
4040
"ty>=0.0.1a26",
4141
"aiosqlite>=0.21.0",
42+
"respx>=0.22.0",
43+
"ruff>=0.14.6",
4244
]
4345

4446
[tool.codespell]

services/bot/src/byte_bot/lib/settings.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@
2626

2727
DEFAULT_MODULE_NAME: str = "byte_bot"
2828
BASE_DIR: Final = module_to_os_path(DEFAULT_MODULE_NAME)
29-
PLUGINS_DIR: Final = module_to_os_path("byte_bot.byte.plugins")
29+
PLUGINS_DIR: Final = module_to_os_path("byte_bot.plugins")
3030

3131

3232
class DiscordSettings(BaseSettings):

tests/conftest.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,18 +2,45 @@
22

33
from __future__ import annotations
44

5+
import os
56
from collections.abc import AsyncGenerator
67
from typing import TYPE_CHECKING
78

89
import pytest
910
from advanced_alchemy.base import UUIDAuditBase
1011
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker, create_async_engine
1112

13+
# Set required environment variables for bot tests BEFORE dotenv loads
14+
# These must be set before importing bot modules
15+
# BotSettings uses BOT_ prefix with case_sensitive=True, so field names must match exactly
16+
os.environ["BOT_discord_token"] = "test_token_for_pytest"
17+
os.environ["BOT_discord_dev_guild_id"] = "123456789"
18+
os.environ["BOT_discord_dev_user_id"] = "987654321"
19+
20+
# Also set DISCORD_ prefix for DiscordSettings (used in settings.py)
21+
# Use assignment (not setdefault) to override .env values
22+
os.environ["DISCORD_TOKEN"] = "test_token_for_pytest"
23+
os.environ["DISCORD_DEV_GUILD_ID"] = "123456789"
24+
os.environ["DISCORD_DEV_USER_ID"] = "987654321"
25+
1226
from tests.fixtures.api_fixtures import (
1327
api_app,
1428
api_client,
1529
mock_db_session,
1630
)
31+
from tests.fixtures.bot_fixtures import (
32+
mock_api_client,
33+
mock_api_responses,
34+
mock_bot,
35+
mock_channel,
36+
mock_context,
37+
mock_guild,
38+
mock_interaction,
39+
mock_member,
40+
mock_message,
41+
mock_role,
42+
mock_user,
43+
)
1744
from tests.fixtures.db_fixtures import (
1845
create_sample_forum_config,
1946
create_sample_github_config,
@@ -33,7 +60,18 @@
3360
"async_engine",
3461
"async_session",
3562
"db_session",
63+
"mock_api_client",
64+
"mock_api_responses",
65+
"mock_bot",
66+
"mock_channel",
67+
"mock_context",
3668
"mock_db_session",
69+
"mock_guild",
70+
"mock_interaction",
71+
"mock_member",
72+
"mock_message",
73+
"mock_role",
74+
"mock_user",
3775
"sample_forum_config",
3876
"sample_github_config",
3977
"sample_guild",

tests/fixtures/bot_fixtures.py

Lines changed: 303 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,303 @@
1+
"""Test fixtures for bot service testing."""
2+
3+
from __future__ import annotations
4+
5+
from typing import TYPE_CHECKING
6+
from unittest.mock import AsyncMock, MagicMock
7+
from uuid import uuid4
8+
9+
import pytest
10+
import respx
11+
from httpx import Response
12+
13+
if TYPE_CHECKING:
14+
from discord import Guild, Interaction, Member, Message, Role, TextChannel, User
15+
from discord.ext.commands import Bot, Context
16+
17+
__all__ = (
18+
"mock_api_client",
19+
"mock_api_responses",
20+
"mock_bot",
21+
"mock_channel",
22+
"mock_context",
23+
"mock_guild",
24+
"mock_interaction",
25+
"mock_member",
26+
"mock_message",
27+
"mock_role",
28+
"mock_user",
29+
)
30+
31+
32+
@pytest.fixture
33+
def mock_bot() -> Bot:
34+
"""Create a mock discord.ext.commands.Bot.
35+
36+
Returns:
37+
Mock bot instance with common attributes.
38+
"""
39+
bot = MagicMock()
40+
bot.user = MagicMock()
41+
bot.user.id = 123456789
42+
bot.user.name = "ByteBot"
43+
bot.user.discriminator = "0000"
44+
bot.is_owner = AsyncMock(return_value=False)
45+
bot.load_extension = AsyncMock()
46+
bot.tree = MagicMock()
47+
bot.tree.sync = AsyncMock()
48+
bot.tree.copy_global_to = MagicMock()
49+
return bot
50+
51+
52+
@pytest.fixture
53+
def mock_user() -> User:
54+
"""Create a mock discord.User.
55+
56+
Returns:
57+
Mock user instance.
58+
"""
59+
user = MagicMock()
60+
user.id = 987654321
61+
user.name = "TestUser"
62+
user.discriminator = "1234"
63+
user.mention = "<@987654321>"
64+
user.avatar = MagicMock()
65+
user.avatar.url = "https://cdn.discordapp.com/avatars/987654321/test.png"
66+
user.bot = False
67+
return user
68+
69+
70+
@pytest.fixture
71+
def mock_role() -> Role:
72+
"""Create a mock discord.Role.
73+
74+
Returns:
75+
Mock role instance.
76+
"""
77+
role = MagicMock()
78+
role.id = 111111111
79+
role.name = "TestRole"
80+
role.mention = "<@&111111111>"
81+
return role
82+
83+
84+
@pytest.fixture
85+
def mock_member(mock_user: User, mock_role: Role) -> Member:
86+
"""Create a mock discord.Member.
87+
88+
Args:
89+
mock_user: Mock user fixture
90+
mock_role: Mock role fixture
91+
92+
Returns:
93+
Mock member instance.
94+
"""
95+
member = MagicMock()
96+
member.id = mock_user.id
97+
member.name = mock_user.name
98+
member.discriminator = mock_user.discriminator
99+
member.mention = mock_user.mention
100+
member.avatar = mock_user.avatar
101+
member.bot = False
102+
member.roles = [mock_role]
103+
member.guild_permissions = MagicMock()
104+
member.guild_permissions.administrator = False
105+
member.send = AsyncMock()
106+
return member
107+
108+
109+
@pytest.fixture
110+
def mock_guild(mock_member: Member) -> Guild:
111+
"""Create a mock discord.Guild.
112+
113+
Args:
114+
mock_member: Mock member fixture
115+
116+
Returns:
117+
Mock guild instance.
118+
"""
119+
guild = MagicMock()
120+
guild.id = 555555555
121+
guild.name = "Test Guild"
122+
guild.get_member = MagicMock(return_value=mock_member)
123+
guild.owner_id = 999999999
124+
return guild
125+
126+
127+
@pytest.fixture
128+
def mock_channel() -> TextChannel:
129+
"""Create a mock discord.TextChannel.
130+
131+
Returns:
132+
Mock text channel instance.
133+
"""
134+
channel = MagicMock()
135+
channel.id = 777777777
136+
channel.name = "test-channel"
137+
channel.mention = "<#777777777>"
138+
channel.send = AsyncMock()
139+
return channel
140+
141+
142+
@pytest.fixture
143+
def mock_message(mock_user: User, mock_channel: TextChannel, mock_guild: Guild) -> Message:
144+
"""Create a mock discord.Message.
145+
146+
Args:
147+
mock_user: Mock user fixture
148+
mock_channel: Mock channel fixture
149+
mock_guild: Mock guild fixture
150+
151+
Returns:
152+
Mock message instance.
153+
"""
154+
message = MagicMock()
155+
message.id = 888888888
156+
message.author = mock_user
157+
message.channel = mock_channel
158+
message.guild = mock_guild
159+
message.content = "!test command"
160+
message.jump_url = f"https://discord.com/channels/{mock_guild.id}/{mock_channel.id}/{message.id}"
161+
message.created_at = MagicMock()
162+
message.created_at.strftime = MagicMock(return_value="2024-01-01 12:00:00")
163+
message.delete = AsyncMock()
164+
return message
165+
166+
167+
@pytest.fixture
168+
def mock_interaction(mock_user: User, mock_guild: Guild, mock_channel: TextChannel) -> Interaction:
169+
"""Create a mock discord.Interaction.
170+
171+
Args:
172+
mock_user: Mock user fixture
173+
mock_guild: Mock guild fixture
174+
mock_channel: Mock channel fixture
175+
176+
Returns:
177+
Mock interaction instance.
178+
"""
179+
interaction = MagicMock()
180+
interaction.user = mock_user
181+
interaction.guild = mock_guild
182+
interaction.channel = mock_channel
183+
interaction.message = None
184+
interaction.response = MagicMock()
185+
interaction.response.send_message = AsyncMock()
186+
interaction.response.defer = AsyncMock()
187+
return interaction
188+
189+
190+
@pytest.fixture
191+
def mock_context(
192+
mock_bot: Bot,
193+
mock_user: User,
194+
mock_guild: Guild,
195+
mock_channel: TextChannel,
196+
mock_message: Message,
197+
) -> Context:
198+
"""Create a mock discord.ext.commands.Context.
199+
200+
Args:
201+
mock_bot: Mock bot fixture
202+
mock_user: Mock user fixture
203+
mock_guild: Mock guild fixture
204+
mock_channel: Mock channel fixture
205+
mock_message: Mock message fixture
206+
207+
Returns:
208+
Mock context instance.
209+
"""
210+
context = MagicMock()
211+
context.bot = mock_bot
212+
context.author = mock_user
213+
context.guild = mock_guild
214+
context.channel = mock_channel
215+
context.message = mock_message
216+
context.command = MagicMock()
217+
context.command.name = "test"
218+
context.interaction = None
219+
context.send = AsyncMock()
220+
return context
221+
222+
223+
@pytest.fixture
224+
def mock_api_responses() -> dict[str, dict]:
225+
"""Mock API response data.
226+
227+
Returns:
228+
Dictionary of mock API responses.
229+
"""
230+
guild_id = 555555555
231+
guild_uuid = str(uuid4())
232+
233+
return {
234+
"guild_created": {
235+
"id": guild_uuid,
236+
"guild_id": guild_id,
237+
"guild_name": "Test Guild",
238+
"prefix": "!",
239+
"created_at": "2024-01-01T12:00:00Z",
240+
"updated_at": "2024-01-01T12:00:00Z",
241+
},
242+
"guild_existing": {
243+
"id": guild_uuid,
244+
"guild_id": guild_id,
245+
"guild_name": "Existing Guild",
246+
"prefix": "?",
247+
"created_at": "2024-01-01T00:00:00Z",
248+
"updated_at": "2024-01-01T00:00:00Z",
249+
},
250+
"guild_updated": {
251+
"id": guild_uuid,
252+
"guild_id": guild_id,
253+
"guild_name": "Test Guild",
254+
"prefix": ">>",
255+
"created_at": "2024-01-01T12:00:00Z",
256+
"updated_at": "2024-01-01T13:00:00Z",
257+
},
258+
"health_check": {
259+
"status": "healthy",
260+
"database": "connected",
261+
"timestamp": "2024-01-01T12:00:00Z",
262+
},
263+
"validation_error": {
264+
"detail": [
265+
{
266+
"type": "string_too_short",
267+
"loc": ["body", "guild_name"],
268+
"msg": "String should have at least 1 character",
269+
}
270+
]
271+
},
272+
"server_error": {"detail": "Internal server error"},
273+
}
274+
275+
276+
@pytest.fixture
277+
def mock_api_client(respx_mock: respx.MockRouter, mock_api_responses: dict[str, dict]) -> respx.MockRouter:
278+
"""Mock ByteAPIClient HTTP calls using respx.
279+
280+
Args:
281+
respx_mock: respx mock router
282+
mock_api_responses: Mock API response data
283+
284+
Returns:
285+
Configured respx mock router.
286+
"""
287+
base_url = "http://localhost:8000"
288+
289+
# Health check endpoint
290+
respx_mock.get(f"{base_url}/health").mock(return_value=Response(200, json=mock_api_responses["health_check"]))
291+
292+
# Guild endpoints - GET by ID (success)
293+
respx_mock.get(f"{base_url}/api/guilds/555555555").mock(
294+
return_value=Response(200, json=mock_api_responses["guild_existing"])
295+
)
296+
297+
# Guild endpoints - GET by ID (not found)
298+
respx_mock.get(f"{base_url}/api/guilds/999999999").mock(return_value=Response(404, json={"detail": "Not found"}))
299+
300+
# Guild endpoints - POST (create)
301+
respx_mock.post(f"{base_url}/api/guilds").mock(return_value=Response(201, json=mock_api_responses["guild_created"]))
302+
303+
return respx_mock

tests/unit/bot/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
"""Bot service unit tests."""

0 commit comments

Comments
 (0)