Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,7 @@ guild_config (
- `typer_bot/commands/admin_commands.py`: `/admin` command surface and orchestration for admin workflows, including admin calculation cooldown state.
- `typer_bot/services/calculation_posting.py`: Post-calculation side effects: best-effort DB backup, league-channel publishing, and admin interaction responses.
- `typer_bot/utils/config.py`: Centralized configuration (data paths via env vars).
- `typer_bot/utils/discord_messages.py`: Shared Discord 2000-character-safe message chunking helpers.
- `typer_bot/utils/prediction_parser.py`: Central logic for parsing "2-1" or "2:1" strings.
- `typer_bot/utils/scoring.py`: Point calculation using season scoring rules.
- `typer_bot/utils/logger.py`: plain stdout logging setup with contextual fields.
Expand All @@ -128,6 +129,7 @@ guild_config (
- **New Commands:** Add Cog to `commands/` folder, load in `bot.py`.
- **Database Changes:** Edit `typer_bot/database/connection.py` `initialize()` and the focused repositories in `typer_bot/database/`. Keep fresh schema creation and startup validation explicit; handle production data ports manually when needed.
- **Debugging:** Check `utils/logger.py` for config. Set `LOG_LEVEL=DEBUG` in env.
- **Discord Message Lengths:** Use `utils/discord_messages.py` helpers instead of ad-hoc 2000-character splitting.
- **Database Restore:** Use `scripts/restore_db.py` from the host or container shell for manual database restoration from backups. The script restores into a temporary SQLite file first, then atomically replaces the live DB only after success.

## 5.5 Testing Guidelines
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ Server admins invite and configure the bot. They do not self-host it.
- Standings use the server's active season only.
- Scoring rules are stored per season and lock once scores exist.
- SQLite stores league data; backups run after successful score calculation.
- Large result/standings posts may be split across multiple Discord messages; participant mentions may be posted as separate `Participants` chunks.

## Server Admin Setup

Expand Down
101 changes: 100 additions & 1 deletion tests/admin_panel/test_fixture_panel_results_actions.py
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,49 @@ async def test_unified_panel_calculate_scores_button_posts_results(
assert _has_button(view, "Delete Fixture") is False
assert mock_interaction_admin.message.edit.await_count == 1

@pytest.mark.asyncio
async def test_unified_panel_calculate_scores_ignores_missing_panel_message(
self,
admin_cog,
mock_interaction_admin,
sample_games,
monkeypatch,
):
fixture_id = await admin_cog.db.fixtures.create_fixture(
"111111", 50, sample_games, datetime.now(UTC) + timedelta(days=1)
)
await admin_cog.db.results.save_results(fixture_id, ["2-1", "1-1", "0-2"])
await admin_cog.db.predictions.save_prediction(
fixture_id,
"111",
"User One",
["2-1", "1-1", "0-2"],
False,
)
mock_interaction_admin.message = MagicMock()
mock_interaction_admin.message.edit = AsyncMock(
side_effect=discord.NotFound(MagicMock(), "missing")
)
monkeypatch.setattr(unified_actions, "post_calculation_result", AsyncMock())

view = UnifiedAdminPanelView(
admin_cog.db,
admin_cog.service,
str(mock_interaction_admin.user.id),
"111111",
admin_commands=admin_cog,
bot=admin_cog.bot,
)
await view.load_fixture_options()
view.fixture_select._values = [str(fixture_id)]
await view.fixture_select.callback(mock_interaction_admin)

calculate_button = _get_button(view, "Calculate Scores")
await calculate_button.callback(mock_interaction_admin)

assert view.selection.fixture_label == "Week 50 [CLOSED]"
assert mock_interaction_admin.message.edit.await_count == 1

@pytest.mark.asyncio
async def test_stale_calculate_scores_button_refreshes_when_fixture_already_scored(
self,
Expand Down Expand Up @@ -236,7 +279,7 @@ async def test_unified_panel_calculate_scores_button_handles_service_error(
await calculate_button.callback(mock_interaction_admin)

assert (
mock_interaction_admin.response_sent[-1]["content"]
mock_interaction_admin.followup_sent[-1]["content"]
== "No predictions found for this fixture"
)
post_calculation_result.assert_not_awaited()
Expand Down Expand Up @@ -335,6 +378,62 @@ async def test_unified_panel_post_results_button_opens_confirmation(
assert isinstance(confirm_view, PostResultsConfirmView)
assert confirm_view.channel is league_channel

@pytest.mark.asyncio
async def test_unified_panel_post_results_button_splits_oversized_preview(
self,
admin_cog,
mock_interaction_admin,
):
league_channel = MagicMock(spec=discord.TextChannel)
league_channel.id = 123456
admin_cog.bot.get_channel.return_value = league_channel
admin_cog.db.scores.get_last_fixture_scores = AsyncMock(
return_value={
"week_number": 13,
"games": ["A - B"],
"results": ["2-1"],
"scores": [
{
"user_id": str(index),
"user_name": f"VeryLongUserName{index:03d}",
"points": index,
"exact_scores": index % 4,
"correct_results": index % 7,
}
for index in range(1, 90)
],
}
)
admin_cog.db.scores.get_standings = AsyncMock(
return_value=[
{
"user_id": str(index),
"user_name": f"VeryLongUserName{index:03d}",
"total_points": index * 2,
"total_exact": index % 5,
"total_correct": index % 9,
}
for index in range(1, 90)
]
)

view = UnifiedAdminPanelView(
admin_cog.db,
admin_cog.service,
str(mock_interaction_admin.user.id),
"111111",
admin_commands=admin_cog,
bot=admin_cog.bot,
)
post_button = _get_button(view, "Re-post Results")
await post_button.callback(mock_interaction_admin)

sent_messages = mock_interaction_admin.response_sent + mock_interaction_admin.followup_sent
assert len(sent_messages) > 1
assert all(message["ephemeral"] is True for message in sent_messages)
assert all(len(message["content"]) <= 2000 for message in sent_messages)
assert isinstance(mock_interaction_admin.followup_sent[-1]["view"], PostResultsConfirmView)

@pytest.mark.asyncio
async def test_unified_panel_post_results_only_previews_current_guild_scores(
self,
Expand Down
11 changes: 10 additions & 1 deletion tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -302,32 +302,41 @@ def __init__(
self.response_sent = []
self.followup_sent = []
self.id = 123456789
self._response_done = False

self.response = MagicMock()
self.response.is_done.return_value = False
self.response.is_done.side_effect = lambda: self._response_done
self.response.send_message = self._response_send_message
self.response.edit_message = self._response_edit_message
self.response.send_modal = self._response_send_modal
self.response.defer = self._response_defer

self.followup = MagicMock()
self.followup.send = self._followup_send

async def _response_send_message(self, content: str = None, **kwargs):
self._response_done = True
msg = {"content": content}
msg.update(kwargs)
self.response_sent.append(msg)

async def _response_defer(self, **kwargs):
self._response_done = True
self.response_sent.append({"deferred": True, **kwargs})

async def _followup_send(self, content: str = None, **kwargs):
msg = {"content": content}
msg.update(kwargs)
self.followup_sent.append(msg)

async def _response_edit_message(self, content: str = None, **kwargs):
self._response_done = True
msg = {"content": content}
msg.update(kwargs)
self.response_sent.append(msg)

async def _response_send_modal(self, modal, **kwargs):
self._response_done = True
self.modal_sent = {"modal": modal, **kwargs}

async def response_send_message(self, content: str = None, **kwargs):
Expand Down
95 changes: 86 additions & 9 deletions tests/test_admin_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,12 @@
from typer_bot.commands.admin_panel import PostResultsConfirmView, UnifiedAdminPanelView
from typer_bot.commands.admin_panel.unified import SetupBotButton
from typer_bot.database import Database
from typer_bot.utils import get_admin_permission_error, has_setup_permission, now
from typer_bot.utils import (
DISCORD_MESSAGE_LIMIT,
get_admin_permission_error,
has_setup_permission,
now,
)
from typer_bot.utils.permissions import is_admin


Expand Down Expand Up @@ -296,7 +301,6 @@ def admin_cog(self, mock_bot, database):
async def test_post_results_view_posts_without_mentions(
self, mock_text_channel, mock_interaction_admin
):
"""NO branch posts standings without pinging participants."""
fixture_data = {
"week_number": 1,
"games": ["A - B"],
Expand Down Expand Up @@ -336,7 +340,6 @@ async def test_post_results_view_posts_without_mentions(
async def test_post_results_view_posts_with_mentions(
self, mock_text_channel, mock_interaction_admin
):
"""YES branch appends participant mentions to the public post."""
fixture_data = {
"week_number": 1,
"games": ["A - B"],
Expand Down Expand Up @@ -484,10 +487,88 @@ async def test_post_results_view_mentions_branch_aborts_when_acknowledgement_fai

assert mock_text_channel.messages_sent == []

@pytest.mark.asyncio
async def test_post_results_view_splits_oversized_scoreboard_and_mentions(
self, mock_text_channel, mock_interaction_admin
):
fixture_data = {
"week_number": 12,
"games": ["A - B"],
"results": ["2-1"],
"scores": [
{
"user_id": str(index),
"user_name": f"User{index:03d}",
"points": index,
"exact_scores": index % 4,
"correct_results": index % 7,
}
for index in range(1, 260)
],
}
standings = [
{
"user_id": str(index),
"user_name": f"User{index:03d}",
"total_points": index * 2,
"total_exact": index % 5,
"total_correct": index % 9,
}
for index in range(1, 80)
]
view = PostResultsConfirmView(fixture_data, standings, mock_text_channel)
yes_button = next(child for child in view.children if child.label == "Mention Users")

class TestCalculationPostFormat:
"""Test that the calculation announcement includes entered match results."""
await yes_button.callback(mock_interaction_admin)

sent_contents = [message["content"] for message in mock_text_channel.messages_sent]
assert len(sent_contents) > 1
assert all(len(content) <= DISCORD_MESSAGE_LIMIT for content in sent_contents)
assert any(content.startswith("**Participants:**") for content in sent_contents)
assert "<@259>" in sent_contents[-1]

@pytest.mark.asyncio
async def test_post_results_view_splits_oversized_scoreboard_without_mentions(
self, mock_text_channel, mock_interaction_admin
):
fixture_data = {
"week_number": 12,
"games": ["A - B"],
"results": ["2-1"],
"scores": [
{
"user_id": str(index),
"user_name": f"User{index:03d}",
"points": index,
"exact_scores": index % 4,
"correct_results": index % 7,
}
for index in range(1, 260)
],
}
standings = [
{
"user_id": str(index),
"user_name": f"User{index:03d}",
"total_points": index * 2,
"total_exact": index % 5,
"total_correct": index % 9,
}
for index in range(1, 80)
]
view = PostResultsConfirmView(fixture_data, standings, mock_text_channel)
no_button = next(child for child in view.children if child.label == "No Mentions")

await no_button.callback(mock_interaction_admin)

sent_contents = [message["content"] for message in mock_text_channel.messages_sent]
assert len(sent_contents) > 1
assert all(len(content) <= DISCORD_MESSAGE_LIMIT for content in sent_contents)
assert "```\n```" not in sent_contents
assert any("User259" in content for content in sent_contents)


class TestCalculationPostFormat:
def test_format_fixture_results_included_in_post(self, sample_games):
from typer_bot.utils import format_fixture_results

Expand All @@ -502,15 +583,12 @@ def test_format_fixture_results_included_in_post(self, sample_games):


class TestCooldownLogic:
"""Test suite for rate limiting cooldown."""

@pytest.fixture
def admin_cog(self, mock_bot, database):
mock_bot.db = database
return AdminCommands(mock_bot)

def test_cooldown_enforced(self, admin_cog):
"""Rate limiting prevents leaderboard recalculation spam."""
import time

user_id = "user123"
Expand All @@ -527,7 +605,6 @@ def test_cooldown_enforced(self, admin_cog):
assert remaining > 0

def test_cooldown_expires(self, admin_cog):
"""Cooldown expires after 30 seconds."""
import time

user_id = "user123"
Expand Down
Loading
Loading