From 0a0a25dc3c27026f08c101debc75c8b6fb1e7e5e Mon Sep 17 00:00:00 2001 From: adrunkhuman <16039109+adrunkhuman@users.noreply.github.com> Date: Fri, 22 May 2026 00:09:20 +0200 Subject: [PATCH] test: split oversized test modules --- tests/admin_panel/conftest.py | 9 + .../test_fixture_discord_cleanup.py | 47 + .../test_fixture_panel_creation.py | 81 + .../test_fixture_panel_jump_to_week.py | 105 + .../test_fixture_panel_pending_partials.py | 187 ++ .../test_fixture_panel_results_actions.py | 463 ++++ .../test_fixture_panel_scoring_rules.py | 307 +++ .../admin_panel/test_fixture_panel_seasons.py | 134 + .../test_fixture_panel_selection.py | 234 ++ tests/database/conftest.py | 39 + tests/database/helpers.py | 22 + tests/database/test_fixtures.py | 336 +++ tests/database/test_guild_config.py | 35 + tests/database/test_predictions.py | 246 ++ tests/database/test_schema_validation.py | 428 ++++ tests/database/test_scores.py | 624 +++++ tests/database/test_seasons.py | 495 ++++ tests/test_admin_panel_fixtures.py | 1510 ----------- tests/test_database.py | 2202 ----------------- tests/user_commands/conftest.py | 11 + tests/user_commands/test_fixtures_command.py | 56 + tests/user_commands/test_help_command.py | 28 + .../test_my_predictions_command.py | 90 + .../test_predict_command.py} | 1971 +++++++-------- tests/user_commands/test_standings_command.py | 86 + 25 files changed, 4924 insertions(+), 4822 deletions(-) create mode 100644 tests/admin_panel/conftest.py create mode 100644 tests/admin_panel/test_fixture_discord_cleanup.py create mode 100644 tests/admin_panel/test_fixture_panel_creation.py create mode 100644 tests/admin_panel/test_fixture_panel_jump_to_week.py create mode 100644 tests/admin_panel/test_fixture_panel_pending_partials.py create mode 100644 tests/admin_panel/test_fixture_panel_results_actions.py create mode 100644 tests/admin_panel/test_fixture_panel_scoring_rules.py create mode 100644 tests/admin_panel/test_fixture_panel_seasons.py create mode 100644 tests/admin_panel/test_fixture_panel_selection.py create mode 100644 tests/database/conftest.py create mode 100644 tests/database/helpers.py create mode 100644 tests/database/test_fixtures.py create mode 100644 tests/database/test_guild_config.py create mode 100644 tests/database/test_predictions.py create mode 100644 tests/database/test_schema_validation.py create mode 100644 tests/database/test_scores.py create mode 100644 tests/database/test_seasons.py delete mode 100644 tests/test_admin_panel_fixtures.py delete mode 100644 tests/test_database.py create mode 100644 tests/user_commands/conftest.py create mode 100644 tests/user_commands/test_fixtures_command.py create mode 100644 tests/user_commands/test_help_command.py create mode 100644 tests/user_commands/test_my_predictions_command.py rename tests/{test_user_commands.py => user_commands/test_predict_command.py} (78%) create mode 100644 tests/user_commands/test_standings_command.py diff --git a/tests/admin_panel/conftest.py b/tests/admin_panel/conftest.py new file mode 100644 index 0000000..e7ac9ef --- /dev/null +++ b/tests/admin_panel/conftest.py @@ -0,0 +1,9 @@ +import pytest + +from typer_bot.commands.admin_commands import AdminCommands + + +@pytest.fixture +def admin_cog(mock_bot, database): + mock_bot.db = database + return AdminCommands(mock_bot) diff --git a/tests/admin_panel/test_fixture_discord_cleanup.py b/tests/admin_panel/test_fixture_discord_cleanup.py new file mode 100644 index 0000000..bbc05a8 --- /dev/null +++ b/tests/admin_panel/test_fixture_discord_cleanup.py @@ -0,0 +1,47 @@ +from unittest.mock import AsyncMock, MagicMock + +import discord +import pytest + +from typer_bot.commands.admin_panel.fixtures import _cleanup_discord_announcement + + +class TestDiscordCleanup: + """_cleanup_discord_announcement should delete thread+message, tolerating Discord errors.""" + + @pytest.mark.asyncio + async def test_cleanup_deletes_thread_and_message(self): + bot = MagicMock(spec=discord.Client) + mock_thread = AsyncMock() + mock_message = AsyncMock() + mock_message.thread = mock_thread + channel = AsyncMock() + channel.fetch_message = AsyncMock(return_value=mock_message) + bot.get_channel.return_value = channel + + await _cleanup_discord_announcement(bot, "111", "222", week_number=5) + + mock_thread.delete.assert_called_once() + mock_message.delete.assert_called_once() + + @pytest.mark.asyncio + async def test_cleanup_no_thread_deletes_message_only(self): + bot = MagicMock(spec=discord.Client) + mock_message = AsyncMock() + mock_message.thread = None + channel = AsyncMock() + channel.fetch_message = AsyncMock(return_value=mock_message) + bot.get_channel.return_value = channel + + await _cleanup_discord_announcement(bot, "111", "222", week_number=5) + + mock_message.delete.assert_called_once() + + @pytest.mark.asyncio + async def test_cleanup_swallows_discord_errors(self): + bot = MagicMock(spec=discord.Client) + channel = AsyncMock() + channel.fetch_message.side_effect = Exception("Discord unavailable") + bot.get_channel.return_value = channel + + await _cleanup_discord_announcement(bot, "111", "222", week_number=5) diff --git a/tests/admin_panel/test_fixture_panel_creation.py b/tests/admin_panel/test_fixture_panel_creation.py new file mode 100644 index 0000000..6f29fcf --- /dev/null +++ b/tests/admin_panel/test_fixture_panel_creation.py @@ -0,0 +1,81 @@ +from unittest.mock import MagicMock + +import discord +import pytest + +from tests.admin_panel_helpers import get_button as _get_button +from typer_bot.commands.admin_panel import ( + CreateFixtureModal, + UnifiedAdminPanelView, +) + + +class TestFixturePanelCreation: + @pytest.mark.asyncio + async def test_unified_panel_create_fixture_button_opens_modal( + self, + admin_cog, + mock_interaction_admin, + ): + channel = MagicMock(spec=discord.TextChannel) + channel.id = mock_interaction_admin.channel.id + mock_interaction_admin.channel = channel + view = UnifiedAdminPanelView( + admin_cog.db, + admin_cog.service, + str(mock_interaction_admin.user.id), + "111111", + admin_commands=admin_cog, + bot=admin_cog.bot, + ) + create_button = _get_button(view, "Create Fixture") + + await create_button.callback(mock_interaction_admin) + + assert isinstance(mock_interaction_admin.modal_sent["modal"], CreateFixtureModal) + + @pytest.mark.asyncio + async def test_unified_panel_create_fixture_button_uses_parent_channel_from_thread( + self, + admin_cog, + mock_interaction_admin, + ): + parent_channel = MagicMock(spec=discord.TextChannel) + parent_channel.id = 123456 + thread = MagicMock(spec=discord.Thread) + thread.parent = parent_channel + mock_interaction_admin.channel = thread + + view = UnifiedAdminPanelView( + admin_cog.db, + admin_cog.service, + str(mock_interaction_admin.user.id), + "111111", + admin_commands=admin_cog, + bot=admin_cog.bot, + ) + create_button = _get_button(view, "Create Fixture") + await create_button.callback(mock_interaction_admin) + + assert mock_interaction_admin.modal_sent["modal"].channel is parent_channel + + @pytest.mark.asyncio + async def test_unified_panel_create_fixture_button_rejects_invalid_context( + self, + admin_cog, + mock_interaction_admin, + ): + mock_interaction_admin.channel = MagicMock() + + view = UnifiedAdminPanelView( + admin_cog.db, + admin_cog.service, + str(mock_interaction_admin.user.id), + "111111", + admin_commands=admin_cog, + bot=admin_cog.bot, + ) + create_button = _get_button(view, "Create Fixture") + await create_button.callback(mock_interaction_admin) + + assert "text channel" in mock_interaction_admin.response_sent[-1]["content"].lower() diff --git a/tests/admin_panel/test_fixture_panel_jump_to_week.py b/tests/admin_panel/test_fixture_panel_jump_to_week.py new file mode 100644 index 0000000..26f1859 --- /dev/null +++ b/tests/admin_panel/test_fixture_panel_jump_to_week.py @@ -0,0 +1,105 @@ +from datetime import UTC, datetime, timedelta + +import pytest + +from tests.admin_panel_helpers import get_button as _get_button +from tests.admin_panel_helpers import has_button as _has_button +from typer_bot.commands.admin_panel import ( + UnifiedAdminPanelView, +) + + +class TestFixturePanelJumpToWeek: + @pytest.mark.asyncio + async def test_unified_panel_jump_to_week_reaches_older_open_fixture( + self, + admin_cog, + mock_interaction_admin, + sample_games, + ): + deadline = datetime.now(UTC) + timedelta(days=1) + first_fixture_id = None + for week in range(1, 28): + fixture_id = await admin_cog.db.create_fixture("111111", week, sample_games, deadline) + if week == 1: + first_fixture_id = fixture_id + assert first_fixture_id is not None + await admin_cog.db.save_results(first_fixture_id, ["1-0", "1-1", "0-0"]) + + 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() + + assert all(option.label != "Week 1 [OPEN]" for option in view.fixture_select.options) + + jump_button = _get_button(view, "Jump To Week") + await jump_button.callback(mock_interaction_admin) + modal = mock_interaction_admin.modal_sent["modal"] + modal.week_input._value = "1" + + await modal.on_submit(mock_interaction_admin) + + assert view.selection.fixture_label == "Week 1 [OPEN]" + assert "Fixture: Week 1 [OPEN]" in mock_interaction_admin.response_sent[-1]["content"] + assert _has_button(view, "Enter Results") is False + assert _has_button(view, "Calculate Scores") is True + assert _has_button(view, "Correct Results") is True + + @pytest.mark.asyncio + async def test_unified_panel_jump_to_week_rejects_invalid_input( + self, + admin_cog, + mock_interaction_admin, + ): + view = UnifiedAdminPanelView( + admin_cog.db, + admin_cog.service, + str(mock_interaction_admin.user.id), + "111111", + admin_commands=admin_cog, + bot=admin_cog.bot, + ) + jump_button = _get_button(view, "Jump To Week") + await jump_button.callback(mock_interaction_admin) + modal = mock_interaction_admin.modal_sent["modal"] + modal.week_input._value = "abc" + + await modal.on_submit(mock_interaction_admin) + + assert "whole number" in mock_interaction_admin.response_sent[-1]["content"] + assert view.selection.fixture_id is None + + @pytest.mark.asyncio + async def test_unified_panel_jump_to_week_rejects_duplicate_open_weeks( + self, + admin_cog, + mock_interaction_admin, + sample_games, + ): + deadline = datetime.now(UTC) + timedelta(days=1) + await admin_cog.db.create_fixture("111111", 5, sample_games, deadline) + await admin_cog.db.create_fixture("111111", 5, sample_games, deadline) + + view = UnifiedAdminPanelView( + admin_cog.db, + admin_cog.service, + str(mock_interaction_admin.user.id), + "111111", + admin_commands=admin_cog, + bot=admin_cog.bot, + ) + jump_button = _get_button(view, "Jump To Week") + await jump_button.callback(mock_interaction_admin) + modal = mock_interaction_admin.modal_sent["modal"] + modal.week_input._value = "5" + + await modal.on_submit(mock_interaction_admin) + + assert "More than one open fixture" in mock_interaction_admin.response_sent[-1]["content"] + assert view.selection.fixture_id is None diff --git a/tests/admin_panel/test_fixture_panel_pending_partials.py b/tests/admin_panel/test_fixture_panel_pending_partials.py new file mode 100644 index 0000000..cdd4951 --- /dev/null +++ b/tests/admin_panel/test_fixture_panel_pending_partials.py @@ -0,0 +1,187 @@ +from datetime import UTC, datetime, timedelta + +import pytest + +from tests.admin_panel_helpers import get_button as _get_button +from tests.admin_panel_helpers import has_button as _has_button +from typer_bot.commands.admin_panel import ( + UnifiedAdminPanelView, +) + + +class TestFixturePanelPendingPartials: + @pytest.mark.asyncio + async def test_unified_panel_hides_review_pending_button_without_pending_partials( + self, + admin_cog, + mock_interaction_admin, + ): + 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._refresh_items() + + assert _has_button(view, "Review Late") is False + + @pytest.mark.asyncio + async def test_unified_panel_shows_review_pending_button_when_pending_partials_exist( + self, + admin_cog, + mock_interaction_admin, + sample_games, + ): + fixture_id = await admin_cog.db.create_fixture( + "111111", 55, sample_games, datetime.now(UTC) + timedelta(days=1) + ) + await admin_cog.db.save_prediction( + fixture_id, + "111", + "User One", + ["1-1", "0-2"], + True, + predicted_game_indexes=[1, 2], + pending_partial_approval=True, + ) + + 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() + + assert _has_button(view, "Review Late") is True + + @pytest.mark.asyncio + async def test_unified_panel_hides_other_guild_pending_partials( + self, + admin_cog, + mock_interaction_admin, + sample_games, + ): + fixture_id = await admin_cog.db.create_fixture( + "guild-2", 55, sample_games, datetime.now(UTC) + timedelta(days=1) + ) + await admin_cog.db.save_prediction( + fixture_id, + "111", + "User One", + ["1-1", "0-2"], + True, + predicted_game_indexes=[1, 2], + pending_partial_approval=True, + ) + + 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() + + assert _has_button(view, "Review Late") is False + + @pytest.mark.asyncio + async def test_unified_panel_review_pending_button_jumps_to_pending_submission( + self, + admin_cog, + mock_interaction_admin, + sample_games, + ): + fixture_id = await admin_cog.db.create_fixture( + "111111", 56, sample_games, datetime.now(UTC) + timedelta(days=1) + ) + await admin_cog.db.save_results(fixture_id, ["1-0", "1-1", "0-0"]) + await admin_cog.db.save_prediction( + fixture_id, + "111", + "User One", + ["1-1", "0-2"], + True, + predicted_game_indexes=[1, 2], + pending_partial_approval=True, + ) + + 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() + + review_button = _get_button(view, "Review Late") + await review_button.callback(mock_interaction_admin) + + assert view.selection.fixture_label == "Week 56 [OPEN]" + assert view.selection.user_id == "111" + assert _has_button(view, "Approve Late") is True + assert _has_button(view, "Reject Late") is True + assert _has_button(view, "Enter Results") is False + assert _has_button(view, "Correct Results") is True + + @pytest.mark.asyncio + async def test_unified_panel_review_pending_button_cycles_pending_submissions( + self, + admin_cog, + mock_interaction_admin, + sample_games, + ): + fixture_a = await admin_cog.db.create_fixture( + "111111", 57, sample_games, datetime.now(UTC) + timedelta(days=1) + ) + fixture_b = await admin_cog.db.create_fixture( + "111111", 58, sample_games, datetime.now(UTC) + timedelta(days=1) + ) + await admin_cog.db.save_prediction( + fixture_a, + "111", + "User One", + ["1-1", "0-2"], + True, + predicted_game_indexes=[1, 2], + pending_partial_approval=True, + ) + await admin_cog.db.save_prediction( + fixture_b, + "222", + "User Two", + ["2-1"], + True, + predicted_game_indexes=[0], + pending_partial_approval=True, + ) + + 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._refresh_items() + + review_button = _get_button(view, "Review Late") + await review_button.callback(mock_interaction_admin) + first_selection = (view.selection.fixture_id, view.selection.user_id) + + await review_button.callback(mock_interaction_admin) + second_selection = (view.selection.fixture_id, view.selection.user_id) + + assert first_selection != second_selection diff --git a/tests/admin_panel/test_fixture_panel_results_actions.py b/tests/admin_panel/test_fixture_panel_results_actions.py new file mode 100644 index 0000000..71cec1c --- /dev/null +++ b/tests/admin_panel/test_fixture_panel_results_actions.py @@ -0,0 +1,463 @@ +from datetime import UTC, datetime, timedelta +from unittest.mock import AsyncMock, MagicMock + +import discord +import pytest + +from tests.admin_panel_helpers import get_button as _get_button +from tests.admin_panel_helpers import has_button as _has_button +from typer_bot.commands.admin_panel import ( + EnterResultsModal, + PostResultsConfirmView, + UnifiedAdminPanelView, +) +from typer_bot.utils import now + + +class TestFixturePanelResultsActions: + @pytest.mark.asyncio + async def test_unified_panel_enter_results_button_opens_modal( + self, + admin_cog, + mock_interaction_admin, + sample_games, + ): + fixture_id = await admin_cog.db.create_fixture( + "111111", 44, sample_games, datetime.now(UTC) + timedelta(days=1) + ) + 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) + + enter_button = _get_button(view, "Enter Results") + await enter_button.callback(mock_interaction_admin) + + assert isinstance(mock_interaction_admin.modal_sent["modal"], EnterResultsModal) + + @pytest.mark.asyncio + async def test_unified_panel_hides_enter_results_button_after_results_are_saved( + self, + admin_cog, + mock_interaction_admin, + sample_games, + ): + fixture_id = await admin_cog.db.create_fixture( + "111111", 46, sample_games, datetime.now(UTC) + timedelta(days=1) + ) + await admin_cog.db.save_results(fixture_id, ["1-0", "1-1", "0-0"]) + 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) + + assert _has_button(view, "Enter Results") is False + assert _has_button(view, "Correct Results") is True + + @pytest.mark.asyncio + async def test_unified_panel_calculate_scores_button_posts_results( + self, + admin_cog, + mock_interaction_admin, + sample_games, + ): + fixture_id = await admin_cog.db.create_fixture( + "111111", 45, sample_games, datetime.now(UTC) + timedelta(days=1) + ) + await admin_cog.db.save_results(fixture_id, ["2-1", "1-1", "0-2"]) + await admin_cog.db.save_prediction( + fixture_id, + "111", + "User One", + ["2-1", "1-1", "0-2"], + False, + ) + command_channel = MagicMock(spec=discord.TextChannel) + command_channel.id = 999999 + command_channel.send = AsyncMock() + league_channel = MagicMock(spec=discord.TextChannel) + league_channel.id = 123456 + league_channel.send = AsyncMock() + mock_interaction_admin.channel = command_channel + admin_cog.bot.get_channel.return_value = None + admin_cog.bot.fetch_channel = AsyncMock(return_value=league_channel) + mock_interaction_admin.message = MagicMock() + mock_interaction_admin.message.edit = AsyncMock() + admin_cog._create_backup = 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 ( + admin_cog.get_calculate_cooldown("111111", str(mock_interaction_admin.user.id)) + is not None + ) + league_channel.send.assert_awaited_once() + command_channel.send.assert_not_awaited() + assert ( + "Week 45 results calculated and posted to the league channel" + in mock_interaction_admin.response_sent[-1]["content"] + ) + assert "User One" in league_channel.send.call_args.args[0] + assert view.selection.fixture_label == "Week 45 [CLOSED]" + assert _has_button(view, "Calculate Scores") is False + 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_button_rejects_unavailable_league_channel( + self, + admin_cog, + mock_interaction_admin, + sample_games, + ): + fixture_id = await admin_cog.db.create_fixture( + "111111", 46, sample_games, datetime.now(UTC) + timedelta(days=1) + ) + await admin_cog.db.save_results(fixture_id, ["2-1", "1-1", "0-2"]) + await admin_cog.db.save_prediction( + fixture_id, + "111", + "User One", + ["2-1", "1-1", "0-2"], + False, + ) + command_channel = MagicMock(spec=discord.TextChannel) + command_channel.send = AsyncMock() + mock_interaction_admin.channel = command_channel + admin_cog.bot.get_channel.return_value = None + admin_cog.bot.fetch_channel = AsyncMock( + side_effect=discord.InvalidData("unknown channel type") + ) + mock_interaction_admin.message = MagicMock() + mock_interaction_admin.message.edit = AsyncMock() + admin_cog._create_backup = 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) + + command_channel.send.assert_not_awaited() + assert ( + "configured league channel is unavailable" + in mock_interaction_admin.response_sent[-1]["content"].lower() + ) + + @pytest.mark.asyncio + async def test_stale_calculate_scores_button_refreshes_when_fixture_already_scored( + self, + admin_cog, + mock_interaction_admin, + sample_games, + ): + fixture_id = await admin_cog.db.create_fixture( + "111111", 45, sample_games, datetime.now(UTC) + timedelta(days=1) + ) + await admin_cog.db.save_results(fixture_id, ["2-1", "1-1", "0-2"]) + await admin_cog.db.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() + admin_cog._create_backup = AsyncMock() + admin_cog._post_calculation_to_channel = 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) + stale_button = _get_button(view, "Calculate Scores") + await admin_cog.db.recalculate_fixture_scores(fixture_id) + + await stale_button.callback(mock_interaction_admin) + + assert "no longer open" in mock_interaction_admin.response_sent[-1]["content"] + admin_cog._create_backup.assert_not_awaited() + admin_cog._post_calculation_to_channel.assert_not_awaited() + assert view.selection.fixture_label == "Week 45 [CLOSED]" + assert _has_button(view, "Calculate Scores") is False + 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_button_rejects_active_cooldown( + self, + admin_cog, + mock_interaction_admin, + sample_games, + ): + fixture_id = await admin_cog.db.create_fixture( + "111111", 47, sample_games, datetime.now(UTC) + timedelta(days=1) + ) + await admin_cog.db.save_results(fixture_id, ["1-0", "1-1", "0-0"]) + admin_cog.record_calculate_cooldown( + "111111", str(mock_interaction_admin.user.id), current_time=now().timestamp() + ) + admin_cog.service.calculate_fixture_scores = 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 "Please wait" in mock_interaction_admin.response_sent[-1]["content"] + + @pytest.mark.asyncio + async def test_unified_panel_calculate_scores_button_handles_service_error( + self, + admin_cog, + mock_interaction_admin, + sample_games, + ): + fixture_id = await admin_cog.db.create_fixture( + "111111", 48, sample_games, datetime.now(UTC) + timedelta(days=1) + ) + await admin_cog.db.save_results(fixture_id, ["1-0", "1-1", "0-0"]) + admin_cog._create_backup = 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 ( + mock_interaction_admin.response_sent[-1]["content"] + == "No predictions found for this fixture" + ) + + @pytest.mark.asyncio + async def test_unified_panel_post_results_button_opens_confirmation( + self, + admin_cog, + mock_interaction_admin, + ): + command_channel = MagicMock(spec=discord.TextChannel) + command_channel.id = 999999 + league_channel = MagicMock(spec=discord.TextChannel) + league_channel.id = 123456 + league_channel.send = AsyncMock() + mock_interaction_admin.channel = command_channel + admin_cog.bot.get_channel.return_value = None + admin_cog.bot.fetch_channel = AsyncMock(return_value=league_channel) + admin_cog.db.get_last_fixture_scores = AsyncMock( + return_value={ + "week_number": 1, + "games": ["A - B"], + "results": ["2-1"], + "scores": [ + { + "user_id": "123", + "user_name": "User1", + "points": 3, + "exact_scores": 1, + "correct_results": 1, + } + ], + } + ) + admin_cog.db.get_standings = AsyncMock( + return_value=[ + { + "user_id": "123", + "user_name": "User1", + "total_points": 3, + "total_exact": 1, + "total_correct": 1, + } + ] + ) + + 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) + + admin_cog.bot.get_channel.assert_called_with(123456) + admin_cog.bot.fetch_channel.assert_awaited_once_with(123456) + confirm_view = mock_interaction_admin.response_sent[-1]["view"] + assert isinstance(confirm_view, PostResultsConfirmView) + assert confirm_view.channel is league_channel + + @pytest.mark.asyncio + async def test_unified_panel_post_results_only_previews_current_guild_scores( + self, + admin_cog, + mock_interaction_admin, + ): + channel = MagicMock(spec=discord.TextChannel) + channel.id = mock_interaction_admin.channel.id + channel.send = AsyncMock() + mock_interaction_admin.channel = channel + admin_cog.bot.get_channel.return_value = channel + deadline = datetime.now(UTC) - timedelta(days=1) + current_fixture_id = await admin_cog.db.create_fixture( + "111111", 1, ["Team A - Team B"], deadline + ) + other_fixture_id = await admin_cog.db.create_fixture( + "guild-2", 2, ["Team C - Team D"], deadline + ) + await admin_cog.db.save_scores( + current_fixture_id, + [ + { + "user_id": "current-user", + "user_name": "Current Guild", + "points": 3, + "exact_scores": 1, + "correct_results": 0, + } + ], + ) + await admin_cog.db.save_scores( + other_fixture_id, + [ + { + "user_id": "other-user", + "user_name": "Other Guild", + "points": 9, + "exact_scores": 3, + "correct_results": 3, + } + ], + ) + + 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) + + content = mock_interaction_admin.response_sent[-1]["content"] + assert "Current Guild" in content + assert "Other Guild" not in content + + @pytest.mark.asyncio + async def test_unified_panel_post_results_button_rejects_unavailable_league_channel( + self, + admin_cog, + mock_interaction_admin, + ): + admin_cog.db.get_last_fixture_scores = AsyncMock(return_value={"scores": []}) + admin_cog.db.get_standings = AsyncMock(return_value=[]) + admin_cog.bot.get_channel.return_value = None + admin_cog.bot.fetch_channel = AsyncMock( + side_effect=discord.InvalidData("unknown channel type") + ) + + 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) + + assert ( + "configured league channel is unavailable" + in mock_interaction_admin.response_sent[-1]["content"].lower() + ) + + @pytest.mark.asyncio + async def test_unified_panel_post_results_button_rejects_missing_scores( + self, + admin_cog, + mock_interaction_admin, + ): + channel = MagicMock(spec=discord.TextChannel) + channel.id = mock_interaction_admin.channel.id + mock_interaction_admin.channel = channel + admin_cog.db.get_last_fixture_scores = AsyncMock(return_value=None) + + 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) + + assert "No completed fixtures found" in mock_interaction_admin.response_sent[-1]["content"] diff --git a/tests/admin_panel/test_fixture_panel_scoring_rules.py b/tests/admin_panel/test_fixture_panel_scoring_rules.py new file mode 100644 index 0000000..2372cb5 --- /dev/null +++ b/tests/admin_panel/test_fixture_panel_scoring_rules.py @@ -0,0 +1,307 @@ +from datetime import UTC, datetime, timedelta + +import pytest + +from tests.admin_panel_helpers import get_button as _get_button +from tests.admin_panel_helpers import has_button as _has_button +from tests.conftest import MockInteraction, MockUser +from typer_bot.commands.admin_panel import ( + ScoringRulesModal, + UnifiedAdminPanelView, +) + + +class TestFixturePanelScoringRules: + @pytest.mark.asyncio + async def test_unified_panel_scoring_rules_button_opens_modal( + self, + admin_cog, + mock_interaction_admin, + ): + 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() + scoring_button = _get_button(view, "Scoring Rules") + + await scoring_button.callback(mock_interaction_admin) + + modal = mock_interaction_admin.modal_sent["modal"] + assert isinstance(modal, ScoringRulesModal) + assert modal.exact_input.default == "3" + assert modal.outcome_input.default == "1" + assert modal.wrong_input.default == "0" + assert modal.late_input.default == "0" + + @pytest.mark.asyncio + async def test_scoring_rules_modal_updates_active_season_rules( + self, + admin_cog, + mock_interaction_admin, + ): + 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() + modal = ScoringRulesModal(view) + modal.exact_input._value = "5" + modal.outcome_input._value = "2" + modal.wrong_input._value = "1" + modal.late_input._value = "1" + + await modal.on_submit(mock_interaction_admin) + + assert await admin_cog.db.get_active_scoring_rules("111111") == { + "exact_score_points": 5, + "correct_outcome_points": 2, + "wrong_outcome_points": 1, + "late_prediction_points": 1, + } + content = mock_interaction_admin.response_sent[-1]["content"] + assert "Updated active-season scoring rules." in content + assert "Scoring: exact 5, outcome 2, wrong 1, late 1" in content + + @pytest.mark.asyncio + async def test_scoring_rules_button_refreshes_modal_defaults( + self, + admin_cog, + mock_interaction_admin, + ): + 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() + await admin_cog.db.update_active_scoring_rules("111111", {"exact_score_points": 5}) + + scoring_button = _get_button(view, "Scoring Rules") + await scoring_button.callback(mock_interaction_admin) + + modal = mock_interaction_admin.modal_sent["modal"] + assert modal.exact_input.default == "5" + + @pytest.mark.asyncio + async def test_scoring_rules_modal_rejects_stale_season_submit( + self, + admin_cog, + mock_interaction_admin, + ): + 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() + old_season_rules = view.active_season["scoring_rules"] + modal = ScoringRulesModal(view) + modal.exact_input._value = "5" + modal.outcome_input._value = "2" + modal.wrong_input._value = "1" + modal.late_input._value = "1" + await admin_cog.db.start_new_season("111111", "Next Season") + + await modal.on_submit(mock_interaction_admin) + + assert "active season changed" in mock_interaction_admin.response_sent[-1]["content"] + assert await admin_cog.db.get_active_scoring_rules("111111") == { + "exact_score_points": 3, + "correct_outcome_points": 1, + "wrong_outcome_points": 0, + "late_prediction_points": 0, + } + seasons = await admin_cog.db.get_seasons("111111") + assert seasons[0]["status"] == "archived" + assert seasons[0]["scoring_rules"] == old_season_rules + + @pytest.mark.asyncio + async def test_scoring_rules_modal_rejects_non_owner( + self, + admin_cog, + mock_interaction_admin, + ): + 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() + modal = ScoringRulesModal(view) + modal.exact_input._value = "5" + modal.outcome_input._value = "2" + modal.wrong_input._value = "1" + modal.late_input._value = "1" + outsider = MockInteraction( + user=MockUser(user_id="999999", name="Outsider"), + guild=mock_interaction_admin.guild, + channel=mock_interaction_admin.channel, + ) + + await modal.on_submit(outsider) + + assert "permission" in outsider.response_sent[-1]["content"] + assert await admin_cog.db.get_active_scoring_rules("111111") == { + "exact_score_points": 3, + "correct_outcome_points": 1, + "wrong_outcome_points": 0, + "late_prediction_points": 0, + } + + @pytest.mark.asyncio + async def test_scoring_rules_modal_rechecks_admin_permission( + self, + admin_cog, + mock_interaction_admin, + ): + 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() + modal = ScoringRulesModal(view) + modal.exact_input._value = "5" + modal.outcome_input._value = "2" + modal.wrong_input._value = "1" + modal.late_input._value = "1" + member = mock_interaction_admin.guild.get_member(mock_interaction_admin.user.id) + member.roles = [] + + await modal.on_submit(mock_interaction_admin) + + assert "no longer have permission" in mock_interaction_admin.response_sent[-1]["content"] + assert await admin_cog.db.get_active_scoring_rules("111111") == { + "exact_score_points": 3, + "correct_outcome_points": 1, + "wrong_outcome_points": 0, + "late_prediction_points": 0, + } + + @pytest.mark.asyncio + async def test_scoring_rules_modal_blocks_changes_after_scores_exist( + self, + admin_cog, + mock_interaction_admin, + sample_games, + ): + fixture_id = await admin_cog.db.create_fixture( + "111111", 1, sample_games, datetime.now(UTC) + timedelta(days=1) + ) + await admin_cog.db.save_results(fixture_id, ["2-1", "1-1", "0-0"]) + await admin_cog.db.save_prediction( + fixture_id, + "user-1", + "User One", + ["2-1", "1-1", "0-0"], + False, + ) + 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() + assert _has_button(view, "Scoring Rules") is True + + modal = ScoringRulesModal(view) + modal.exact_input._value = "5" + modal.outcome_input._value = "2" + modal.wrong_input._value = "1" + modal.late_input._value = "1" + await admin_cog.db.recalculate_fixture_scores(fixture_id) + + await modal.on_submit(mock_interaction_admin) + + assert "Cannot change scoring rules" in mock_interaction_admin.response_sent[-1]["content"] + assert await admin_cog.db.get_active_scoring_rules("111111") == { + "exact_score_points": 3, + "correct_outcome_points": 1, + "wrong_outcome_points": 0, + "late_prediction_points": 0, + } + + @pytest.mark.asyncio + async def test_scoring_rules_modal_rejects_invalid_values( + self, + admin_cog, + mock_interaction_admin, + ): + 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() + modal = ScoringRulesModal(view) + modal.exact_input._value = "many" + modal.outcome_input._value = "2" + modal.wrong_input._value = "1" + modal.late_input._value = "1" + + await modal.on_submit(mock_interaction_admin) + + assert "whole numbers" in mock_interaction_admin.response_sent[-1]["content"] + + @pytest.mark.asyncio + async def test_stale_scoring_rules_button_refreshes_when_scores_now_exist( + self, + admin_cog, + mock_interaction_admin, + sample_games, + ): + fixture_id = await admin_cog.db.create_fixture( + "111111", 45, sample_games, datetime.now(UTC) + timedelta(days=1) + ) + await admin_cog.db.save_results(fixture_id, ["2-1", "1-1", "0-2"]) + await admin_cog.db.save_prediction( + fixture_id, + "111", + "User One", + ["2-1", "1-1", "0-2"], + False, + ) + 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() + stale_button = _get_button(view, "Scoring Rules") + await admin_cog.db.recalculate_fixture_scores(fixture_id) + + await stale_button.callback(mock_interaction_admin) + + assert not hasattr(mock_interaction_admin, "modal_sent") + assert "Scoring rules are locked" in mock_interaction_admin.response_sent[-1]["content"] + assert _has_button(view, "Scoring Rules") is False diff --git a/tests/admin_panel/test_fixture_panel_seasons.py b/tests/admin_panel/test_fixture_panel_seasons.py new file mode 100644 index 0000000..6354df4 --- /dev/null +++ b/tests/admin_panel/test_fixture_panel_seasons.py @@ -0,0 +1,134 @@ +from datetime import UTC, datetime, timedelta + +import pytest + +from tests.admin_panel_helpers import get_button as _get_button +from tests.admin_panel_helpers import has_button as _has_button +from typer_bot.commands.admin_panel import ( + NewSeasonModal, + UnifiedAdminPanelView, +) + + +class TestFixturePanelSeasons: + @pytest.mark.asyncio + async def test_unified_panel_shows_active_season_and_new_season_button( + self, + admin_cog, + mock_interaction_admin, + ): + 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() + + assert "Active season: Default Season" in view.render_content() + assert "Scoring: exact 3, outcome 1, wrong 0, late 0" in view.render_content() + assert _has_button(view, "Scoring Rules") is True + assert _has_button(view, "New Season") is True + + @pytest.mark.asyncio + async def test_unified_panel_new_season_button_opens_modal( + self, + admin_cog, + mock_interaction_admin, + ): + view = UnifiedAdminPanelView( + admin_cog.db, + admin_cog.service, + str(mock_interaction_admin.user.id), + "111111", + admin_commands=admin_cog, + bot=admin_cog.bot, + ) + new_season_button = _get_button(view, "New Season") + + await new_season_button.callback(mock_interaction_admin) + + assert isinstance(mock_interaction_admin.modal_sent["modal"], NewSeasonModal) + + @pytest.mark.asyncio + async def test_new_season_modal_blocks_open_fixtures( + self, + admin_cog, + mock_interaction_admin, + sample_games, + ): + await admin_cog.db.create_fixture( + "111111", 1, sample_games, datetime.now(UTC) + timedelta(days=1) + ) + 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.current_prediction = {"pending_partial_approval": True} + view.has_user_overflow = True + modal = NewSeasonModal(view) + modal.name_input._value = "2026/27" + + await modal.on_submit(mock_interaction_admin) + + assert "Close all open fixtures" in mock_interaction_admin.response_sent[-1]["content"] + assert (await admin_cog.db.get_active_season("111111"))["name"] == "Default Season" + + @pytest.mark.asyncio + async def test_new_season_modal_starts_season_and_refreshes_panel( + self, + admin_cog, + mock_interaction_admin, + sample_games, + ): + fixture_id = await admin_cog.db.create_fixture( + "111111", 1, sample_games, datetime.now(UTC) + timedelta(days=1) + ) + await admin_cog.db.save_scores( + fixture_id, + [ + { + "user_id": "user-1", + "user_name": "User One", + "points": 3, + "exact_scores": 1, + "correct_results": 0, + } + ], + ) + 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) + modal = NewSeasonModal(view) + modal.name_input._value = "2026/27" + + await modal.on_submit(mock_interaction_admin) + _new_fixture_id, new_week = await admin_cog.db.create_next_fixture( + "111111", sample_games, datetime.now(UTC) + timedelta(days=1) + ) + + content = mock_interaction_admin.response_sent[-1]["content"] + assert "Active season: 2026/27" in content + assert "Started new active season: 2026/27" in content + assert view.current_prediction is None + assert view.has_user_overflow is False + assert _has_button(view, "Enter Results") is False + assert _has_button(view, "Calculate Scores") is False + assert _has_button(view, "Correct Results") is False + assert _has_button(view, "Delete Fixture") is False + assert new_week == 1 diff --git a/tests/admin_panel/test_fixture_panel_selection.py b/tests/admin_panel/test_fixture_panel_selection.py new file mode 100644 index 0000000..ec145e2 --- /dev/null +++ b/tests/admin_panel/test_fixture_panel_selection.py @@ -0,0 +1,234 @@ +from datetime import UTC, datetime, timedelta +from unittest.mock import AsyncMock + +import pytest + +from tests.admin_panel_helpers import get_button as _get_button +from tests.admin_panel_helpers import has_button as _has_button +from tests.admin_panel_helpers import option_values as _option_values +from typer_bot.commands.admin_panel import ( + DeleteConfirmView, + FixturesPanelView, + PredictionsPanelView, + ResultsPanelView, + UnifiedAdminPanelView, +) +from typer_bot.database import Database + + +class TestFixturePanelSelection: + @pytest.mark.asyncio + async def test_fixture_button_populates_open_fixture_options( + self, + admin_cog, + mock_interaction_admin, + sample_games, + ): + await admin_cog.db.create_fixture( + "111111", 4, sample_games, datetime.now(UTC) + timedelta(days=1) + ) + + 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() + + assert view.fixture_select.disabled is False + assert view.fixture_select.options[0].label == "Week 4 [OPEN]" + + @pytest.mark.asyncio + @pytest.mark.parametrize( + "view_cls", + [FixturesPanelView, PredictionsPanelView, ResultsPanelView, UnifiedAdminPanelView], + ) + async def test_admin_fixture_selectors_only_show_current_guild( + self, + view_cls, + admin_cog, + mock_interaction_admin, + sample_games, + ): + deadline = datetime.now(UTC) + timedelta(days=1) + current_guild_fixture_id = await admin_cog.db.create_fixture( + "111111", 1, sample_games, deadline + ) + other_guild_fixture_id = await admin_cog.db.create_fixture( + "guild-2", 2, sample_games, deadline + ) + + view = view_cls( + admin_cog.db, + admin_cog.service, + str(mock_interaction_admin.user.id), + "111111", + ) + await view.load_fixture_options() + + option_values = _option_values(view.fixture_select) + assert str(current_guild_fixture_id) in option_values + assert str(other_guild_fixture_id) not in option_values + + @pytest.mark.asyncio + async def test_fixture_panel_delete_button_enables_after_fixture_selection( + self, + admin_cog, + mock_interaction_admin, + sample_games, + ): + fixture_id = await admin_cog.db.create_fixture( + "111111", 5, sample_games, datetime.now(UTC) + timedelta(days=1) + ) + + view = FixturesPanelView( + admin_cog.db, + admin_cog.service, + str(mock_interaction_admin.user.id), + "111111", + bot=admin_cog.bot, + ) + await view.load_fixture_options() + + assert _get_button(view, "Delete Fixture").disabled is True + + view.fixture_select._values = [str(fixture_id)] + await view.fixture_select.callback(mock_interaction_admin) + + assert "Fixture: Week 5 [OPEN]" in mock_interaction_admin.response_sent[-1]["content"] + assert _get_button(view, "Delete Fixture").disabled is False + + @pytest.mark.asyncio + async def test_fixture_panel_delete_confirmation_shows_games( + self, + admin_cog, + mock_interaction_admin, + sample_games, + ): + """Deletion confirmation must show game list so admin can verify the right fixture.""" + fixture_id = await admin_cog.db.create_fixture( + "111111", 6, sample_games, datetime.now(UTC) + timedelta(days=1) + ) + + 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) + + delete_button = next( + child for child in view.children if getattr(child, "label", None) == "Delete Fixture" + ) + await delete_button.callback(mock_interaction_admin) + + confirmation_content = mock_interaction_admin.response_sent[-1]["content"] + assert "Delete Week 6?" in confirmation_content + assert "Team A - Team B" in confirmation_content + + @pytest.mark.asyncio + async def test_unified_panel_hides_contextual_actions_until_fixture_selection( + self, + admin_cog, + mock_interaction_admin, + sample_games, + ): + fixture_id = await admin_cog.db.create_fixture( + "111111", 1, sample_games, datetime.now(UTC) + timedelta(days=1) + ) + 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() + + assert _has_button(view, "Calculate Scores") is False + assert _has_button(view, "Delete Fixture") is False + + view.fixture_select._values = [str(fixture_id)] + await view.fixture_select.callback(mock_interaction_admin) + + assert _has_button(view, "Enter Results") is True + assert _has_button(view, "Calculate Scores") is True + assert _has_button(view, "Correct Results") is False + assert _has_button(view, "Delete Fixture") is True + + await admin_cog.db.save_results(fixture_id, ["1-0", "1-1", "0-0"]) + view.fixture_select._values = [str(fixture_id)] + await view.fixture_select.callback(mock_interaction_admin) + + assert _has_button(view, "Enter Results") is False + assert _has_button(view, "Calculate Scores") is True + assert _has_button(view, "Correct Results") is True + + await admin_cog.db.save_scores( + fixture_id, + [ + { + "user_id": "user-1", + "user_name": "User One", + "points": 3, + "exact_scores": 1, + "correct_results": 0, + } + ], + ) + view.fixture_select._values = [str(fixture_id)] + await view.fixture_select.callback(mock_interaction_admin) + + assert _has_button(view, "Enter Results") is False + assert _has_button(view, "Calculate Scores") is False + assert _has_button(view, "Correct Results") is True + assert _has_button(view, "Delete Fixture") is False + + @pytest.mark.asyncio + async def test_fixture_panel_delete_confirm_shows_error_on_db_failure( + self, + admin_cog, + mock_interaction_admin, + sample_games, + ): + """Silent DB failures surface as a visible error instead of timing out the interaction.""" + fixture_id = await admin_cog.db.create_fixture( + "111111", 7, sample_games, datetime.now(UTC) + timedelta(days=1) + ) + + db_mock = AsyncMock(spec=Database) + db_mock.get_guild_config.return_value = { + "admin_role_id": str( + mock_interaction_admin.guild.get_member(mock_interaction_admin.user.id).roles[0].id + ), + "league_channel_id": "123456", + } + db_mock.delete_fixture.side_effect = RuntimeError("DB locked") + + confirm_view = DeleteConfirmView( + db_mock, + str(mock_interaction_admin.user.id), + "111111", + fixture_id, + week_number=7, + ) + confirm_button = next( + child + for child in confirm_view.children + if getattr(child, "label", None) == "Yes, Delete" + ) + await confirm_button.callback(mock_interaction_admin) + + response = mock_interaction_admin.response_sent[-1] + assert "Failed to delete" in response["content"] + assert response.get("view") is None diff --git a/tests/database/conftest.py b/tests/database/conftest.py new file mode 100644 index 0000000..844ee66 --- /dev/null +++ b/tests/database/conftest.py @@ -0,0 +1,39 @@ +import tempfile +from datetime import UTC, datetime, timedelta +from pathlib import Path + +import aiosqlite +import pytest + +from typer_bot.database import Database + + +@pytest.fixture +def temp_db_path(): + with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f: + path = f.name + yield path + Path(path).unlink(missing_ok=True) + + +@pytest.fixture +async def prediction_db(temp_db_path): + database = Database(temp_db_path) + await database.initialize() + return database + + +@pytest.fixture +async def open_fixture_id(prediction_db): + deadline = datetime.now(UTC) + timedelta(hours=1) + return await prediction_db.create_fixture("111111", 1, ["A - B", "C - D"], deadline) + + +@pytest.fixture +async def closed_fixture_id(prediction_db): + deadline = datetime.now(UTC) + timedelta(hours=1) + fixture_id = await prediction_db.create_fixture("111111", 2, ["A - B", "C - D"], deadline) + async with aiosqlite.connect(prediction_db.db_path) as conn: + await conn.execute("UPDATE fixtures SET status = 'closed' WHERE id = ?", (fixture_id,)) + await conn.commit() + return fixture_id diff --git a/tests/database/helpers.py b/tests/database/helpers.py new file mode 100644 index 0000000..6a1f8db --- /dev/null +++ b/tests/database/helpers.py @@ -0,0 +1,22 @@ +import aiosqlite + + +async def start_new_active_season(db_path: str, guild_id: str, name: str = "Next Season") -> int: + """Create a new active season while bypassing production season guards.""" + async with aiosqlite.connect(db_path) as conn: + await conn.execute( + "UPDATE seasons SET status = 'archived' WHERE guild_id = ? AND status = 'active'", + (guild_id,), + ) + cursor = await conn.execute( + "INSERT INTO seasons (guild_id, name, status) VALUES (?, ?, 'active')", + (guild_id, name), + ) + if cursor.lastrowid is None: + raise RuntimeError("Failed to create test season") + await conn.execute( + "UPDATE guild_config SET active_season_id = ? WHERE guild_id = ?", + (cursor.lastrowid, guild_id), + ) + await conn.commit() + return cursor.lastrowid diff --git a/tests/database/test_fixtures.py b/tests/database/test_fixtures.py new file mode 100644 index 0000000..310d476 --- /dev/null +++ b/tests/database/test_fixtures.py @@ -0,0 +1,336 @@ +import asyncio +from datetime import UTC, datetime, timedelta + +import aiosqlite +import pytest + +from tests.database.helpers import start_new_active_season +from typer_bot.database import Database + + +class TestGetMaxWeekNumber: + @pytest.mark.asyncio + async def test_get_max_week_number_empty_db(self, temp_db_path): + db = Database(temp_db_path) + await db.initialize() + + result = await db.get_max_week_number("111111") + assert result == 0 + + @pytest.mark.asyncio + async def test_get_max_week_number_with_fixtures(self, temp_db_path): + db = Database(temp_db_path) + await db.initialize() + + await db.create_fixture("111111", 1, ["Team A - Team B"], datetime.now(UTC)) + await db.create_fixture("111111", 3, ["Team C - Team D"], datetime.now(UTC)) + await db.create_fixture("111111", 5, ["Team E - Team F"], datetime.now(UTC)) + + result = await db.get_max_week_number("111111") + assert result == 5 + + @pytest.mark.asyncio + async def test_get_max_week_number_closed_fixtures(self, temp_db_path): + db = Database(temp_db_path) + await db.initialize() + + fixture_id = await db.create_fixture("111111", 10, ["Team A - Team B"], datetime.now(UTC)) + await db.save_scores( + fixture_id, + [ + { + "user_id": "123", + "user_name": "Test", + "points": 3, + "exact_scores": 1, + "correct_results": 0, + } + ], + ) + + await db.create_fixture("111111", 5, ["Team C - Team D"], datetime.now(UTC)) + + result = await db.get_max_week_number("111111") + assert result == 10 + + @pytest.mark.asyncio + async def test_get_max_week_number_is_active_season_scoped(self, temp_db_path): + db = Database(temp_db_path) + await db.initialize() + await db.create_fixture("111111", 10, ["Team A - Team B"], datetime.now(UTC)) + await start_new_active_season(temp_db_path, "111111") + + assert await db.get_max_week_number("111111") == 0 + + await db.create_fixture("111111", 1, ["Team C - Team D"], datetime.now(UTC)) + + assert await db.get_max_week_number("111111") == 1 + + +class TestOpenFixturesQueries: + @pytest.mark.asyncio + async def test_get_open_fixtures_returns_all_open_ordered(self, temp_db_path): + """Open fixtures are returned in week order for deterministic selection prompts.""" + db = Database(temp_db_path) + await db.initialize() + + fixture_week_2 = await db.create_fixture( + "111111", 2, ["Team C - Team D"], datetime.now(UTC) + ) + fixture_week_1 = await db.create_fixture( + "111111", 1, ["Team A - Team B"], datetime.now(UTC) + ) + fixture_week_3 = await db.create_fixture( + "111111", 3, ["Team E - Team F"], datetime.now(UTC) + ) + + # Close week 3 fixture so only weeks 1 and 2 remain open + await db.save_scores(fixture_week_3, []) + + await db.create_fixture("guild-2", 1, ["Other A - Other B"], datetime.now(UTC)) + + open_fixtures = await db.get_open_fixtures("111111") + open_ids = [fixture["id"] for fixture in open_fixtures] + open_weeks = [fixture["week_number"] for fixture in open_fixtures] + + assert fixture_week_3 not in open_ids + assert set(open_ids) == {fixture_week_1, fixture_week_2} + assert open_weeks == [1, 2] + + @pytest.mark.asyncio + async def test_get_open_fixture_by_week_ignores_closed_fixtures(self, temp_db_path): + db = Database(temp_db_path) + await db.initialize() + + open_fixture_id = await db.create_fixture( + "111111", 7, ["Team A - Team B"], datetime.now(UTC) + ) + closed_fixture_id = await db.create_fixture( + "111111", 8, ["Team C - Team D"], datetime.now(UTC) + ) + await db.save_scores(closed_fixture_id, []) + + open_fixture = await db.get_open_fixture_by_week("111111", 7) + closed_fixture = await db.get_open_fixture_by_week("111111", 8) + + assert open_fixture is not None + assert open_fixture["id"] == open_fixture_id + assert closed_fixture is None + + @pytest.mark.asyncio + async def test_week_and_recent_fixture_queries_are_guild_scoped(self, temp_db_path): + db = Database(temp_db_path) + await db.initialize() + + guild_one_week = await db.create_fixture( + "111111", 1, ["Team A - Team B"], datetime.now(UTC) + ) + guild_two_week = await db.create_fixture( + "guild-2", 1, ["Team C - Team D"], datetime.now(UTC) + ) + + assert (await db.get_fixture_by_week("111111", 1))["id"] == guild_one_week + assert (await db.get_fixture_by_week("guild-2", 1))["id"] == guild_two_week + assert [fixture["id"] for fixture in await db.get_recent_fixtures("111111")] == [ + guild_one_week + ] + assert await db.get_fixture_by_id(guild_two_week, "111111") is None + + @pytest.mark.asyncio + async def test_delete_fixture_can_require_guild_ownership(self, temp_db_path): + db = Database(temp_db_path) + await db.initialize() + + fixture_id = await db.create_fixture("guild-2", 1, ["Team A - Team B"], datetime.now(UTC)) + + assert await db.delete_fixture(fixture_id, "111111") is False + assert await db.get_fixture_by_id(fixture_id, "guild-2") is not None + + assert await db.delete_fixture(fixture_id, "guild-2") is True + assert await db.get_fixture_by_id(fixture_id, "guild-2") is None + + @pytest.mark.asyncio + async def test_get_prediction_requires_fixture_guild_ownership(self, temp_db_path): + db = Database(temp_db_path) + await db.initialize() + fixture_id = await db.create_fixture("guild-2", 1, ["Team A - Team B"], datetime.now(UTC)) + await db.save_prediction(fixture_id, "user-1", "User One", ["2-1"], False) + + assert await db.get_prediction(fixture_id, "user-1", "111111") is None + assert await db.get_prediction(fixture_id, "user-1", "guild-2") is not None + + @pytest.mark.asyncio + async def test_pending_partial_predictions_are_guild_scoped(self, temp_db_path): + db = Database(temp_db_path) + await db.initialize() + deadline = datetime.now(UTC) - timedelta(hours=1) + guild_one_fixture_id = await db.create_fixture( + "111111", 1, ["Team A - Team B", "Team C - Team D"], deadline + ) + guild_two_fixture_id = await db.create_fixture( + "guild-2", 1, ["Team E - Team F", "Team G - Team H"], deadline + ) + await db.save_prediction( + guild_one_fixture_id, + "guild-one-user", + "Guild One", + ["1-1"], + True, + predicted_game_indexes=[0], + pending_partial_approval=True, + ) + await db.save_prediction( + guild_two_fixture_id, + "guild-two-user", + "Guild Two", + ["2-2"], + True, + predicted_game_indexes=[1], + pending_partial_approval=True, + ) + + guild_one_pending = await db.get_pending_partial_predictions("111111") + guild_two_pending = await db.get_pending_partial_predictions("guild-2") + + assert [prediction["user_id"] for prediction in guild_one_pending] == ["guild-one-user"] + assert [prediction["user_id"] for prediction in guild_two_pending] == ["guild-two-user"] + + @pytest.mark.asyncio + async def test_create_next_fixture_allocates_incrementing_weeks(self, temp_db_path): + """Atomic allocator should issue increasing week numbers.""" + db = Database(temp_db_path) + await db.initialize() + + fixture_one_id, week_one = await db.create_next_fixture( + "111111", + ["Team A - Team B"], + datetime.now(UTC), + ) + fixture_two_id, week_two = await db.create_next_fixture( + "111111", + ["Team C - Team D"], + datetime.now(UTC), + ) + + fixture_one = await db.get_fixture_by_id(fixture_one_id, "111111") + fixture_two = await db.get_fixture_by_id(fixture_two_id, "111111") + + assert week_one == 1 + assert week_two == 2 + assert fixture_one is not None + assert fixture_one["week_number"] == 1 + assert fixture_two is not None + assert fixture_two["week_number"] == 2 + + @pytest.mark.asyncio + async def test_created_fixtures_store_guild_ownership(self, temp_db_path): + db = Database(temp_db_path) + await db.initialize() + + fixture_id = await db.create_fixture( + "guild-2", + 4, + ["Team A - Team B"], + datetime.now(UTC), + ) + + fixture = await db.get_fixture_by_id(fixture_id, "guild-2") + assert fixture is not None + assert fixture["guild_id"] == "guild-2" + + @pytest.mark.asyncio + async def test_create_fixture_rejects_missing_guild_id(self, temp_db_path): + db = Database(temp_db_path) + await db.initialize() + + with pytest.raises(ValueError, match="guild_id is required"): + await db.create_fixture("", 1, ["Team A - Team B"], datetime.now(UTC)) + + @pytest.mark.asyncio + async def test_create_next_fixture_rejects_missing_guild_id(self, temp_db_path): + db = Database(temp_db_path) + await db.initialize() + + with pytest.raises(ValueError, match="guild_id is required"): + await db.create_next_fixture("", ["Team A - Team B"], datetime.now(UTC)) + + @pytest.mark.asyncio + async def test_create_next_fixture_allocates_weeks_per_guild(self, temp_db_path): + db = Database(temp_db_path) + await db.initialize() + + _, guild_one_week = await db.create_next_fixture( + "111111", + ["Team A - Team B"], + datetime.now(UTC), + ) + _, guild_two_week = await db.create_next_fixture( + "guild-2", + ["Team C - Team D"], + datetime.now(UTC), + ) + guild_one_second_id = await db.create_fixture( + "111111", + 2, + ["Team E - Team F"], + datetime.now(UTC), + ) + await db.create_fixture( + "guild-2", + 9, + ["Team G - Team H"], + datetime.now(UTC), + ) + + assert guild_one_week == 1 + assert guild_two_week == 1 + assert await db.get_max_week_number("111111") == 2 + assert await db.get_max_week_number("guild-2") == 9 + + guild_one_second = await db.get_fixture_by_id(guild_one_second_id, "111111") + assert guild_one_second is not None + assert guild_one_second["guild_id"] == "111111" + + +class TestCreateNextFixtureConcurrency: + @pytest.mark.asyncio + async def test_concurrent_calls_allocate_distinct_week_numbers(self, temp_db_path): + db = Database(temp_db_path) + await db.initialize() + + created = await asyncio.gather( + db.create_next_fixture("111111", ["A - B"], datetime.now(UTC)), + db.create_next_fixture("111111", ["C - D"], datetime.now(UTC)), + ) + + fixture_ids = [fixture_id for fixture_id, _week in created] + weeks = sorted(week for _fixture_id, week in created) + + assert weeks == [1, 2] + + fixtures = [await db.get_fixture_by_id(fixture_id, "111111") for fixture_id in fixture_ids] + assert all(fixture is not None for fixture in fixtures) + assert sorted(fixture["week_number"] for fixture in fixtures if fixture is not None) == [ + 1, + 2, + ] + + +class TestRowToFixture: + @pytest.mark.asyncio + async def test_empty_games_column_returns_empty_list(self, temp_db_path): + """Empty games column must deserialize to [] not [''] (split artefact).""" + db = Database(temp_db_path) + await db.initialize() + season = await db.get_or_create_active_season("111111") + + async with aiosqlite.connect(temp_db_path) as conn: + await conn.execute( + "INSERT INTO fixtures (guild_id, season_id, week_number, games, deadline, status) VALUES (?, ?, ?, ?, ?, ?)", + ("111111", season["id"], 99, "", "2030-01-01T00:00:00+00:00", "open"), + ) + await conn.commit() + + fixture = await db.get_current_fixture("111111") + assert fixture is not None + assert fixture["games"] == [] diff --git a/tests/database/test_guild_config.py b/tests/database/test_guild_config.py new file mode 100644 index 0000000..66d40b0 --- /dev/null +++ b/tests/database/test_guild_config.py @@ -0,0 +1,35 @@ +import pytest + +from typer_bot.database import Database + + +class TestGuildConfig: + @pytest.mark.asyncio + async def test_guild_config_persists_and_updates(self, temp_db_path): + db = Database(temp_db_path) + await db.initialize() + + assert await db.get_guild_config("111111") is None + + await db.upsert_guild_config("111111", "role-1", "channel-1") + config = await db.get_guild_config("111111") + assert config["admin_role_id"] == "role-1" + assert config["league_channel_id"] == "channel-1" + + await db.upsert_guild_config("111111", "role-2", "channel-2") + updated = await db.get_guild_config("111111") + assert updated["admin_role_id"] == "role-2" + assert updated["league_channel_id"] == "channel-2" + + @pytest.mark.asyncio + async def test_guild_config_is_per_guild(self, temp_db_path): + db = Database(temp_db_path) + await db.initialize() + + await db.upsert_guild_config("111111", "role-1", "channel-1") + await db.upsert_guild_config("222222", "role-2", "channel-2") + + guild_one = await db.get_guild_config("111111") + guild_two = await db.get_guild_config("222222") + assert guild_one["admin_role_id"] == "role-1" + assert guild_two["admin_role_id"] == "role-2" diff --git a/tests/database/test_predictions.py b/tests/database/test_predictions.py new file mode 100644 index 0000000..0261c96 --- /dev/null +++ b/tests/database/test_predictions.py @@ -0,0 +1,246 @@ +import asyncio +from datetime import UTC, datetime + +import aiosqlite +import pytest + +from typer_bot.database import SaveResult +from typer_bot.database.predictions import PredictionRepository + + +class TestTrySavePrediction: + """Atomic first-write-wins insert with fixture-open guard.""" + + @pytest.mark.asyncio + async def test_saved_when_fixture_open_and_no_prior_prediction( + self, prediction_db, open_fixture_id + ): + result = await prediction_db.try_save_prediction( + open_fixture_id, "u1", "User", ["2-1", "0-0"] + ) + assert result == SaveResult.SAVED + prediction = await prediction_db.get_prediction(open_fixture_id, "u1", "111111") + assert prediction is not None + assert prediction["predictions"] == ["2-1", "0-0"] + + @pytest.mark.asyncio + async def test_duplicate_when_prior_prediction_exists(self, prediction_db, open_fixture_id): + await prediction_db.try_save_prediction(open_fixture_id, "u1", "User", ["2-1", "0-0"]) + result = await prediction_db.try_save_prediction( + open_fixture_id, "u1", "User", ["3-0", "1-1"] + ) + assert result == SaveResult.DUPLICATE + prediction = await prediction_db.get_prediction(open_fixture_id, "u1", "111111") + assert prediction["predictions"] == ["2-1", "0-0"] + + @pytest.mark.asyncio + async def test_fixture_closed_returns_fixture_closed(self, prediction_db, closed_fixture_id): + result = await prediction_db.try_save_prediction( + closed_fixture_id, "u1", "User", ["2-1", "0-0"] + ) + assert result == SaveResult.FIXTURE_CLOSED + + @pytest.mark.asyncio + async def test_no_row_written_on_fixture_closed(self, prediction_db, closed_fixture_id): + await prediction_db.try_save_prediction(closed_fixture_id, "u1", "User", ["2-1", "0-0"]) + prediction = await prediction_db.get_prediction(closed_fixture_id, "u1", "111111") + assert prediction is None + + @pytest.mark.asyncio + async def test_fixture_closed_checked_before_duplicate(self, prediction_db, closed_fixture_id): + async with aiosqlite.connect(prediction_db.db_path) as conn: + await conn.execute( + "INSERT INTO predictions (fixture_id, user_id, user_name, predictions, is_late) VALUES (?, 'u1', 'User', '2-1', 0)", + (closed_fixture_id,), + ) + await conn.commit() + result = await prediction_db.try_save_prediction( + closed_fixture_id, "u1", "User", ["3-0", "1-1"] + ) + assert result == SaveResult.FIXTURE_CLOSED + + @pytest.mark.asyncio + async def test_concurrent_writers_allow_only_one_prediction( + self, prediction_db, open_fixture_id + ): + async def save(user_name, predictions): + return await prediction_db.try_save_prediction( + open_fixture_id, + "u1", + user_name, + predictions, + ) + + first, second = await asyncio.gather( + save("First", ["2-1", "0-0"]), + save("Second", ["3-0", "1-1"]), + ) + + assert sorted([first, second]) == [SaveResult.DUPLICATE, SaveResult.SAVED] + + async with ( + aiosqlite.connect(prediction_db.db_path) as conn, + conn.execute( + "SELECT COUNT(*), user_name, predictions FROM predictions WHERE fixture_id = ? AND user_id = ?", + (open_fixture_id, "u1"), + ) as cursor, + ): + row = await cursor.fetchone() + + assert row is not None + assert row[0] == 1 + assert row[1] in {"First", "Second"} + assert row[2] in {"2-1\n0-0", "3-0\n1-1"} + + +class TestPredictionSaveMetadata: + @pytest.mark.asyncio + @pytest.mark.parametrize( + "method_name", + ["save_prediction", "try_save_prediction", "save_prediction_guarded"], + ) + async def test_save_paths_preserve_non_default_metadata( + self, prediction_db, open_fixture_id, method_name + ): + save = getattr(prediction_db, method_name) + + result = await save( + open_fixture_id, + "u1", + "User", + ["1-1"], + True, + predicted_game_indexes=[1], + pending_partial_approval=True, + public_message_id="message-1", + public_message_kind="bot_post", + ) + + if method_name != "save_prediction": + assert result == SaveResult.SAVED + prediction = await prediction_db.get_prediction(open_fixture_id, "u1", "111111") + assert prediction is not None + assert prediction["user_name"] == "User" + assert prediction["predictions"] == ["1-1"] + assert prediction["is_late"] == 1 + assert prediction["predicted_game_indexes"] == [1] + assert prediction["pending_partial_approval"] is True + assert prediction["public_message_id"] == "message-1" + assert prediction["public_message_kind"] == "bot_post" + + +class TestSavePredictionGuarded: + """Upsert with fixture-open guard for prediction resubmission paths.""" + + @pytest.mark.asyncio + async def test_saved_when_fixture_open(self, prediction_db, open_fixture_id): + result = await prediction_db.save_prediction_guarded( + open_fixture_id, "u1", "User", ["2-1", "0-0"] + ) + assert result == SaveResult.SAVED + prediction = await prediction_db.get_prediction(open_fixture_id, "u1", "111111") + assert prediction["predictions"] == ["2-1", "0-0"] + + @pytest.mark.asyncio + async def test_fixture_closed_blocks_write(self, prediction_db, closed_fixture_id): + result = await prediction_db.save_prediction_guarded( + closed_fixture_id, "u1", "User", ["2-1", "0-0"] + ) + assert result == SaveResult.FIXTURE_CLOSED + prediction = await prediction_db.get_prediction(closed_fixture_id, "u1", "111111") + assert prediction is None + + @pytest.mark.asyncio + async def test_allows_overwrite_of_existing_prediction(self, prediction_db, open_fixture_id): + await prediction_db.save_prediction_guarded(open_fixture_id, "u1", "User", ["2-1", "0-0"]) + result = await prediction_db.save_prediction_guarded( + open_fixture_id, "u1", "User", ["3-0", "1-1"] + ) + assert result == SaveResult.SAVED + prediction = await prediction_db.get_prediction(open_fixture_id, "u1", "111111") + assert prediction["predictions"] == ["3-0", "1-1"] + + @pytest.mark.asyncio + async def test_updates_user_name_on_resubmission(self, prediction_db, open_fixture_id): + await prediction_db.save_prediction_guarded( + open_fixture_id, "u1", "OldName", ["2-1", "0-0"] + ) + await prediction_db.save_prediction_guarded( + open_fixture_id, "u1", "NewName", ["3-0", "1-1"] + ) + prediction = await prediction_db.get_prediction(open_fixture_id, "u1", "111111") + assert prediction["user_name"] == "NewName" + + @pytest.mark.asyncio + async def test_resubmission_clears_admin_and_waiver_metadata( + self, prediction_db, open_fixture_id + ): + await prediction_db.save_prediction_guarded( + open_fixture_id, "u1", "User", ["2-1", "0-0"], True + ) + await prediction_db.set_late_penalty_waiver(open_fixture_id, "u1", True) + await prediction_db.admin_update_prediction( + open_fixture_id, "u1", ["2-0", "1-1"], "admin-1" + ) + async with aiosqlite.connect(prediction_db.db_path) as conn: + await conn.execute( + "UPDATE predictions SET submitted_at = '2000-01-01T00:00:00+00:00' WHERE fixture_id = ? AND user_id = ?", + (open_fixture_id, "u1"), + ) + await conn.commit() + + result = await prediction_db.save_prediction_guarded( + open_fixture_id, "u1", "User", ["3-0", "1-1"], False + ) + + assert result == SaveResult.SAVED + prediction = await prediction_db.get_prediction(open_fixture_id, "u1", "111111") + assert prediction is not None + assert prediction["late_penalty_waived"] == 0 + assert prediction["admin_edited_at"] is None + assert prediction["admin_edited_by"] is None + assert prediction["submitted_at"] > datetime(2000, 1, 1, tzinfo=UTC) + + +class TestPartialApprovalPending: + @pytest.mark.asyncio + async def test_clearing_pending_does_not_clear_late_status( + self, prediction_db, open_fixture_id + ): + await prediction_db.save_prediction( + open_fixture_id, + "u1", + "User", + ["2-1", "0-0"], + is_late=True, + pending_partial_approval=True, + ) + + predictions = PredictionRepository(prediction_db.db_path) + updated = await predictions.set_partial_approval_pending(open_fixture_id, "u1", False) + prediction = await prediction_db.get_prediction(open_fixture_id, "u1", "111111") + + assert updated is True + assert prediction is not None + assert prediction["pending_partial_approval"] is False + assert prediction["is_late"] == 1 + + @pytest.mark.asyncio + async def test_setting_pending_does_not_force_late_status(self, prediction_db, open_fixture_id): + await prediction_db.save_prediction( + open_fixture_id, + "u1", + "User", + ["2-1", "0-0"], + is_late=False, + pending_partial_approval=False, + ) + + predictions = PredictionRepository(prediction_db.db_path) + updated = await predictions.set_partial_approval_pending(open_fixture_id, "u1", True) + prediction = await prediction_db.get_prediction(open_fixture_id, "u1", "111111") + + assert updated is True + assert prediction is not None + assert prediction["pending_partial_approval"] is True + assert prediction["is_late"] == 0 diff --git a/tests/database/test_schema_validation.py b/tests/database/test_schema_validation.py new file mode 100644 index 0000000..fb7de5a --- /dev/null +++ b/tests/database/test_schema_validation.py @@ -0,0 +1,428 @@ +from datetime import UTC, datetime + +import aiosqlite +import pytest + +from typer_bot.database import Database + + +class TestSchemaValidation: + @pytest.mark.asyncio + async def test_initialize_is_safe_for_current_schema_existing_data(self, temp_db_path): + db = Database(temp_db_path) + await db.initialize() + fixture_id = await db.create_fixture("111111", 1, ["A - B"], datetime.now(UTC)) + await db.save_prediction( + fixture_id, + "user-1", + "User One", + ["2-1"], + public_message_id="message-1", + public_message_kind="thread_prediction", + ) + await db.save_results(fixture_id, ["2-1"]) + + restarted_db = Database(temp_db_path) + await restarted_db.initialize() + await restarted_db.save_results(fixture_id, ["3-1"]) + + fixture = await restarted_db.get_fixture_by_id(fixture_id, "111111") + prediction = await restarted_db.get_prediction(fixture_id, "user-1", "111111") + results = await restarted_db.get_results(fixture_id) + + assert fixture is not None + assert fixture["guild_id"] == "111111" + assert prediction["public_message_id"] == "message-1" + assert prediction["public_message_kind"] == "thread_prediction" + assert results == ["3-1"] + + @pytest.mark.asyncio + async def test_initialize_rejects_duplicate_result_rows_without_mutating(self, temp_db_path): + async with aiosqlite.connect(temp_db_path) as conn: + await conn.executescript( + """ + CREATE TABLE seasons ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + guild_id TEXT NOT NULL, + name TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'active', + exact_score_points INTEGER NOT NULL DEFAULT 3, + correct_outcome_points INTEGER NOT NULL DEFAULT 1, + wrong_outcome_points INTEGER NOT NULL DEFAULT 0, + late_prediction_points INTEGER NOT NULL DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + ended_at DATETIME + ); + CREATE TABLE fixtures ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + guild_id TEXT NOT NULL, + season_id INTEGER, + week_number INTEGER NOT NULL, + games TEXT NOT NULL, + deadline DATETIME NOT NULL, + status TEXT DEFAULT 'open', + message_id TEXT, + channel_id TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ); + CREATE TABLE predictions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + fixture_id INTEGER NOT NULL, + user_id TEXT NOT NULL, + user_name TEXT NOT NULL, + predictions TEXT NOT NULL, + submitted_at DATETIME DEFAULT CURRENT_TIMESTAMP, + is_late BOOLEAN DEFAULT FALSE, + late_penalty_waived BOOLEAN DEFAULT FALSE, + admin_edited_at DATETIME, + admin_edited_by TEXT, + predicted_game_indexes TEXT, + pending_partial_approval BOOLEAN DEFAULT FALSE, + public_message_id TEXT, + public_message_kind TEXT, + UNIQUE(fixture_id, user_id) + ); + CREATE TABLE results ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + fixture_id INTEGER NOT NULL, + results TEXT NOT NULL, + calculated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + ); + CREATE TABLE scores ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + fixture_id INTEGER NOT NULL, + user_id TEXT NOT NULL, + user_name TEXT NOT NULL, + points INTEGER NOT NULL, + exact_scores INTEGER DEFAULT 0, + correct_results INTEGER DEFAULT 0, + UNIQUE(fixture_id, user_id) + ); + CREATE TABLE guild_config ( + guild_id TEXT PRIMARY KEY, + admin_role_id TEXT NOT NULL, + league_channel_id TEXT NOT NULL, + active_season_id INTEGER, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + ); + """ + ) + await conn.execute( + "INSERT INTO seasons (id, guild_id, name, status) VALUES (1, '111111', 'Current Season', 'active')" + ) + await conn.execute( + "INSERT INTO fixtures (id, guild_id, season_id, week_number, games, deadline, status) VALUES (1, '111111', 1, 1, 'A - B', ?, 'open')", + (datetime.now(UTC).isoformat(),), + ) + await conn.execute( + "INSERT INTO results (fixture_id, results, calculated_at, updated_at) VALUES (1, '1-0', '2024-01-01T10:00:00+00:00', '2024-01-01T10:00:00+00:00')" + ) + await conn.execute( + "INSERT INTO results (fixture_id, results, calculated_at, updated_at) VALUES (1, '2-0', '2024-01-01T12:00:00+00:00', '2024-01-01T12:00:00+00:00')" + ) + await conn.commit() + + db = Database(temp_db_path) + + with pytest.raises( + RuntimeError, + match=r"results has duplicate rows for fixture_id\(s\): 1.*Keep one result row per fixture", + ): + await db.initialize() + + async with ( + aiosqlite.connect(temp_db_path) as conn, + conn.execute("SELECT results FROM results ORDER BY id") as cursor, + ): + assert await cursor.fetchall() == [("1-0",), ("2-0",)] + + @pytest.mark.asyncio + async def test_initialize_creates_missing_result_unique_index_for_current_schema( + self, temp_db_path + ): + db = Database(temp_db_path) + await db.initialize() + fixture_id = await db.create_fixture("111111", 1, ["A - B"], datetime.now(UTC)) + await db.save_results(fixture_id, ["1-0"]) + async with aiosqlite.connect(temp_db_path) as conn: + await conn.execute("DROP INDEX idx_results_fixture_id_unique") + await conn.commit() + + await db.initialize() + await db.save_results(fixture_id, ["2-0"]) + + assert await db.get_results(fixture_id) == ["2-0"] + async with ( + aiosqlite.connect(temp_db_path) as conn, + conn.execute( + "SELECT COUNT(*) FROM results WHERE fixture_id = ?", (fixture_id,) + ) as cursor, + ): + assert await cursor.fetchone() == (1,) + + @pytest.mark.asyncio + async def test_initialize_rejects_partial_result_unique_index(self, temp_db_path): + db = Database(temp_db_path) + await db.initialize() + async with aiosqlite.connect(temp_db_path) as conn: + await conn.execute("DROP INDEX idx_results_fixture_id_unique") + await conn.execute( + "CREATE UNIQUE INDEX idx_results_fixture_id_unique ON results(fixture_id) WHERE fixture_id > 0" + ) + await conn.commit() + + with pytest.raises(RuntimeError, match=r"results\(fixture_id\)"): + await db.initialize() + + @pytest.mark.asyncio + async def test_initialize_rejects_missing_prediction_unique_constraint(self, temp_db_path): + async with aiosqlite.connect(temp_db_path) as conn: + await conn.executescript( + """ + CREATE TABLE seasons ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + guild_id TEXT NOT NULL, + name TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'active', + exact_score_points INTEGER NOT NULL DEFAULT 3, + correct_outcome_points INTEGER NOT NULL DEFAULT 1, + wrong_outcome_points INTEGER NOT NULL DEFAULT 0, + late_prediction_points INTEGER NOT NULL DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + ended_at DATETIME + ); + CREATE TABLE fixtures ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + guild_id TEXT NOT NULL, + season_id INTEGER, + week_number INTEGER NOT NULL, + games TEXT NOT NULL, + deadline DATETIME NOT NULL, + status TEXT DEFAULT 'open', + message_id TEXT, + channel_id TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ); + CREATE TABLE predictions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + fixture_id INTEGER NOT NULL, + user_id TEXT NOT NULL, + user_name TEXT NOT NULL, + predictions TEXT NOT NULL, + submitted_at DATETIME DEFAULT CURRENT_TIMESTAMP, + is_late BOOLEAN DEFAULT FALSE, + late_penalty_waived BOOLEAN DEFAULT FALSE, + admin_edited_at DATETIME, + admin_edited_by TEXT, + predicted_game_indexes TEXT, + pending_partial_approval BOOLEAN DEFAULT FALSE, + public_message_id TEXT, + public_message_kind TEXT + ); + CREATE TABLE results ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + fixture_id INTEGER NOT NULL, + results TEXT NOT NULL, + calculated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + ); + CREATE TABLE scores ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + fixture_id INTEGER NOT NULL, + user_id TEXT NOT NULL, + user_name TEXT NOT NULL, + points INTEGER NOT NULL, + exact_scores INTEGER DEFAULT 0, + correct_results INTEGER DEFAULT 0, + UNIQUE(fixture_id, user_id) + ); + CREATE TABLE guild_config ( + guild_id TEXT PRIMARY KEY, + admin_role_id TEXT NOT NULL, + league_channel_id TEXT NOT NULL, + active_season_id INTEGER, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + ); + """ + ) + await conn.commit() + + db = Database(temp_db_path) + + with pytest.raises(RuntimeError, match=r"predictions\(fixture_id, user_id\)"): + await db.initialize() + + @pytest.mark.asyncio + async def test_initialize_rejects_stale_schema_without_required_columns(self, temp_db_path): + async with aiosqlite.connect(temp_db_path) as conn: + await conn.executescript( + """ + CREATE TABLE seasons ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + guild_id TEXT NOT NULL, + name TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'active', + exact_score_points INTEGER NOT NULL DEFAULT 3, + correct_outcome_points INTEGER NOT NULL DEFAULT 1, + wrong_outcome_points INTEGER NOT NULL DEFAULT 0, + late_prediction_points INTEGER NOT NULL DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + ended_at DATETIME + ); + CREATE TABLE fixtures ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + guild_id TEXT NOT NULL, + season_id INTEGER, + week_number INTEGER NOT NULL, + games TEXT NOT NULL, + deadline DATETIME NOT NULL, + status TEXT DEFAULT 'open', + message_id TEXT, + channel_id TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ); + CREATE TABLE predictions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + fixture_id INTEGER NOT NULL, + user_id TEXT NOT NULL, + user_name TEXT NOT NULL, + predictions TEXT NOT NULL, + submitted_at DATETIME DEFAULT CURRENT_TIMESTAMP, + is_late BOOLEAN DEFAULT FALSE, + late_penalty_waived BOOLEAN DEFAULT FALSE, + admin_edited_at DATETIME, + admin_edited_by TEXT, + predicted_game_indexes TEXT, + pending_partial_approval BOOLEAN DEFAULT FALSE, + public_message_kind TEXT, + UNIQUE(fixture_id, user_id) + ); + CREATE TABLE results ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + fixture_id INTEGER NOT NULL, + results TEXT NOT NULL, + calculated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + ); + CREATE TABLE scores ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + fixture_id INTEGER NOT NULL, + user_id TEXT NOT NULL, + user_name TEXT NOT NULL, + points INTEGER NOT NULL, + exact_scores INTEGER DEFAULT 0, + correct_results INTEGER DEFAULT 0, + UNIQUE(fixture_id, user_id) + ); + CREATE TABLE guild_config ( + guild_id TEXT PRIMARY KEY, + admin_role_id TEXT NOT NULL, + league_channel_id TEXT NOT NULL, + active_season_id INTEGER, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + ); + """ + ) + await conn.commit() + + db = Database(temp_db_path) + + with pytest.raises(RuntimeError, match="predictions.public_message_id"): + await db.initialize() + + @pytest.mark.asyncio + async def test_initialize_rejects_existing_schema_with_missing_table(self, temp_db_path): + db = Database(temp_db_path) + await db.initialize() + async with aiosqlite.connect(temp_db_path) as conn: + await conn.execute("DROP TABLE scores") + await conn.commit() + + with pytest.raises(RuntimeError, match="scores.fixture_id"): + await db.initialize() + + @pytest.mark.asyncio + @pytest.mark.parametrize("guild_id", ["", " "]) + async def test_initialize_rejects_blank_fixture_guild_ownership(self, temp_db_path, guild_id): + db = Database(temp_db_path) + await db.initialize() + fixture_id = await db.create_fixture("111111", 1, ["A - B"], datetime.now(UTC)) + async with aiosqlite.connect(temp_db_path) as conn: + await conn.execute( + "UPDATE fixtures SET guild_id = ? WHERE id = ?", (guild_id, fixture_id) + ) + await conn.commit() + + with pytest.raises(RuntimeError, match="fixtures.guild_id has empty rows"): + await db.initialize() + + @pytest.mark.asyncio + async def test_initialize_rejects_null_fixture_guild_ownership(self, temp_db_path): + db = Database(temp_db_path) + await db.initialize() + async with aiosqlite.connect(temp_db_path) as conn: + await conn.execute("DROP TABLE fixtures") + await conn.execute( + """ + CREATE TABLE fixtures ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + guild_id TEXT, + season_id INTEGER, + week_number INTEGER NOT NULL, + games TEXT NOT NULL, + deadline DATETIME NOT NULL, + status TEXT DEFAULT 'open', + message_id TEXT, + channel_id TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + """ + ) + await conn.execute( + "INSERT INTO fixtures (guild_id, season_id, week_number, games, deadline, status) VALUES (NULL, NULL, 1, 'A - B', ?, 'open')", + (datetime.now(UTC).isoformat(),), + ) + await conn.commit() + + with pytest.raises(RuntimeError, match="fixtures.guild_id has empty rows"): + await db.initialize() + + @pytest.mark.asyncio + @pytest.mark.parametrize( + "sql", + [ + "UPDATE fixtures SET season_id = NULL WHERE id = ?", + "UPDATE fixtures SET season_id = 999999 WHERE id = ?", + ], + ) + async def test_initialize_rejects_fixture_without_valid_season(self, temp_db_path, sql): + db = Database(temp_db_path) + await db.initialize() + fixture_id = await db.create_fixture("111111", 1, ["A - B"], datetime.now(UTC)) + async with aiosqlite.connect(temp_db_path) as conn: + await conn.execute(sql, (fixture_id,)) + await conn.commit() + + with pytest.raises(RuntimeError, match="same-guild season_id"): + await db.initialize() + + @pytest.mark.asyncio + async def test_initialize_rejects_fixture_with_other_guild_season(self, temp_db_path): + db = Database(temp_db_path) + await db.initialize() + fixture_id = await db.create_fixture("111111", 1, ["A - B"], datetime.now(UTC)) + other_fixture_id = await db.create_fixture("222222", 1, ["C - D"], datetime.now(UTC)) + other_fixture = await db.get_fixture_by_id(other_fixture_id, "222222") + async with aiosqlite.connect(temp_db_path) as conn: + await conn.execute( + "UPDATE fixtures SET season_id = ? WHERE id = ?", + (other_fixture["season_id"], fixture_id), + ) + await conn.commit() + + with pytest.raises(RuntimeError, match="same-guild season_id"): + await db.initialize() diff --git a/tests/database/test_scores.py b/tests/database/test_scores.py new file mode 100644 index 0000000..e678d40 --- /dev/null +++ b/tests/database/test_scores.py @@ -0,0 +1,624 @@ +from datetime import UTC, datetime + +import aiosqlite +import pytest + +from tests.database.helpers import start_new_active_season +from typer_bot.database import Database +from typer_bot.database import scores as scores_module + + +class TestScores: + @pytest.mark.asyncio + async def test_result_correction_recalculates_with_active_season_rules(self, temp_db_path): + db = Database(temp_db_path) + await db.initialize() + await db.update_active_scoring_rules( + "111111", {"exact_score_points": 5, "correct_outcome_points": 2} + ) + fixture_id = await db.create_fixture("111111", 1, ["A - B"], datetime.now(UTC)) + await db.save_results(fixture_id, ["2-1"]) + await db.save_prediction(fixture_id, "user-1", "User One", ["2-1"], False) + await db.recalculate_fixture_scores(fixture_id) + + await db.save_results_with_recalc(fixture_id, ["2-0"]) + + scores = await db.get_scores_for_fixture(fixture_id) + assert scores[0]["points"] == 2 + + @pytest.mark.asyncio + async def test_prediction_replacement_recalculates_with_active_season_rules(self, temp_db_path): + db = Database(temp_db_path) + await db.initialize() + await db.update_active_scoring_rules( + "111111", {"exact_score_points": 5, "wrong_outcome_points": 1} + ) + fixture_id = await db.create_fixture("111111", 1, ["A - B"], datetime.now(UTC)) + await db.save_results(fixture_id, ["2-1"]) + await db.save_prediction(fixture_id, "user-1", "User One", ["2-1"], False) + await db.recalculate_fixture_scores(fixture_id) + + updated = await db.admin_update_prediction_with_recalc( + fixture_id, "user-1", ["1-2"], "admin-1" + ) + + scores = await db.get_scores_for_fixture(fixture_id) + assert updated is True + assert scores[0]["points"] == 1 + + @pytest.mark.asyncio + async def test_waiver_toggle_recalculates_with_active_season_rules(self, temp_db_path): + db = Database(temp_db_path) + await db.initialize() + await db.update_active_scoring_rules( + "111111", {"exact_score_points": 5, "late_prediction_points": 1} + ) + fixture_id = await db.create_fixture("111111", 1, ["A - B"], datetime.now(UTC)) + await db.save_results(fixture_id, ["2-1"]) + await db.save_prediction(fixture_id, "user-1", "User One", ["2-1"], True) + await db.recalculate_fixture_scores(fixture_id) + + waived = await db.toggle_late_penalty_waiver_with_recalc(fixture_id, "user-1") + + scores = await db.get_scores_for_fixture(fixture_id) + assert waived is True + assert scores[0]["points"] == 5 + + @pytest.mark.asyncio + async def test_partial_approval_recalculates_with_active_season_rules(self, temp_db_path): + db = Database(temp_db_path) + await db.initialize() + await db.update_active_scoring_rules("111111", {"exact_score_points": 5}) + fixture_id = await db.create_fixture("111111", 1, ["A - B"], datetime.now(UTC)) + await db.save_results(fixture_id, ["2-1"]) + await db.save_prediction(fixture_id, "user-1", "User One", ["2-1"], False) + await db.recalculate_fixture_scores(fixture_id) + await db.save_prediction( + fixture_id, + "partial", + "Partial User", + ["2-1"], + True, + predicted_game_indexes=[0], + pending_partial_approval=True, + ) + + approved = await db.approve_partial_prediction(fixture_id, "partial", "admin-1") + + scores = await db.get_scores_for_fixture(fixture_id) + assert approved is True + assert [(score["user_id"], score["points"]) for score in scores] == [ + ("partial", 5), + ("user-1", 5), + ] + + @pytest.mark.asyncio + async def test_partial_rejection_recalculates_with_active_season_rules(self, temp_db_path): + db = Database(temp_db_path) + await db.initialize() + await db.update_active_scoring_rules("111111", {"exact_score_points": 5}) + fixture_id = await db.create_fixture("111111", 1, ["A - B"], datetime.now(UTC)) + await db.save_results(fixture_id, ["2-1"]) + await db.save_prediction(fixture_id, "user-1", "User One", ["2-1"], False) + await db.save_prediction( + fixture_id, + "partial", + "Partial User", + ["2-1"], + True, + predicted_game_indexes=[0], + pending_partial_approval=True, + ) + await db.recalculate_fixture_scores(fixture_id) + + rejected = await db.reject_partial_prediction(fixture_id, "partial") + + scores = await db.get_scores_for_fixture(fixture_id) + assert rejected is True + assert [(score["user_id"], score["points"]) for score in scores] == [("user-1", 5)] + + @pytest.mark.asyncio + async def test_recalculate_fixture_scores_uses_active_season_rules(self, temp_db_path): + db = Database(temp_db_path) + await db.initialize() + await db.update_active_scoring_rules( + "111111", + { + "exact_score_points": 5, + "correct_outcome_points": 2, + "wrong_outcome_points": 1, + "late_prediction_points": 0, + }, + ) + fixture_id = await db.create_fixture( + "111111", 1, ["A - B", "C - D", "E - F"], datetime.now(UTC) + ) + await db.save_results(fixture_id, ["2-1", "1-1", "2-0"]) + await db.save_prediction( + fixture_id, + "user-1", + "User One", + ["2-1", "2-2", "0-2"], + False, + ) + + await db.recalculate_fixture_scores(fixture_id) + + scores = await db.get_scores_for_fixture(fixture_id) + assert scores[0]["points"] == 8 + assert scores[0]["exact_scores"] == 1 + assert scores[0]["correct_results"] == 1 + + @pytest.mark.asyncio + async def test_late_prediction_uses_active_season_penalty_rule(self, temp_db_path): + db = Database(temp_db_path) + await db.initialize() + await db.update_active_scoring_rules("111111", {"late_prediction_points": 1}) + fixture_id = await db.create_fixture("111111", 1, ["A - B"], datetime.now(UTC)) + await db.save_results(fixture_id, ["2-1"]) + await db.save_prediction(fixture_id, "late", "Late User", ["2-1"], True) + await db.save_prediction(fixture_id, "waived", "Waived User", ["2-1"], True) + await db.set_late_penalty_waiver(fixture_id, "waived", True) + + await db.recalculate_fixture_scores(fixture_id) + + scores = await db.get_scores_for_fixture(fixture_id) + assert [(score["user_id"], score["points"]) for score in scores] == [ + ("waived", 3), + ("late", 1), + ] + + @pytest.mark.asyncio + async def test_scoring_rules_are_guild_isolated(self, temp_db_path): + db = Database(temp_db_path) + await db.initialize() + await db.update_active_scoring_rules("111111", {"exact_score_points": 5}) + guild_one_fixture_id = await db.create_fixture("111111", 1, ["A - B"], datetime.now(UTC)) + guild_two_fixture_id = await db.create_fixture("222222", 1, ["A - B"], datetime.now(UTC)) + for fixture_id in (guild_one_fixture_id, guild_two_fixture_id): + await db.save_results(fixture_id, ["2-1"]) + await db.save_prediction(fixture_id, "user-1", "User One", ["2-1"], False) + await db.recalculate_fixture_scores(fixture_id) + + guild_one_scores = await db.get_scores_for_fixture(guild_one_fixture_id) + guild_two_scores = await db.get_scores_for_fixture(guild_two_fixture_id) + assert guild_one_scores[0]["points"] == 5 + assert guild_two_scores[0]["points"] == 3 + + @pytest.mark.asyncio + async def test_scoring_rule_changes_are_blocked_after_scores_exist(self, temp_db_path): + db = Database(temp_db_path) + await db.initialize() + assert await db.active_season_has_scores("111111") is False + fixture_id = await db.create_fixture("111111", 1, ["A - B"], datetime.now(UTC)) + await db.save_results(fixture_id, ["2-1"]) + await db.save_prediction(fixture_id, "user-1", "User One", ["2-1"], False) + await db.recalculate_fixture_scores(fixture_id) + + assert await db.active_season_has_scores("111111") is True + + with pytest.raises(ValueError, match="Cannot change scoring rules"): + await db.update_active_scoring_rules("111111", {"exact_score_points": 5}) + + assert await db.get_active_scoring_rules("111111") == { + "exact_score_points": 3, + "correct_outcome_points": 1, + "wrong_outcome_points": 0, + "late_prediction_points": 0, + } + + @pytest.mark.asyncio + async def test_scoring_rule_change_block_is_guild_and_active_season_scoped(self, temp_db_path): + db = Database(temp_db_path) + await db.initialize() + old_fixture_id = await db.create_fixture("111111", 1, ["A - B"], datetime.now(UTC)) + await db.save_scores( + old_fixture_id, + [ + { + "user_id": "user-1", + "user_name": "User One", + "points": 3, + "exact_scores": 1, + "correct_results": 0, + } + ], + ) + await db.start_new_season("111111", "Next Season") + + await db.update_active_scoring_rules("111111", {"exact_score_points": 5}) + + other_guild_fixture_id = await db.create_fixture("222222", 1, ["C - D"], datetime.now(UTC)) + await db.save_scores( + other_guild_fixture_id, + [ + { + "user_id": "user-2", + "user_name": "User Two", + "points": 3, + "exact_scores": 1, + "correct_results": 0, + } + ], + ) + + await db.update_active_scoring_rules("111111", {"correct_outcome_points": 2}) + + active_fixture_id = await db.create_fixture("111111", 1, ["E - F"], datetime.now(UTC)) + await db.save_scores( + active_fixture_id, + [ + { + "user_id": "user-1", + "user_name": "User One", + "points": 5, + "exact_scores": 1, + "correct_results": 0, + } + ], + ) + + with pytest.raises(ValueError, match="Cannot change scoring rules"): + await db.update_active_scoring_rules("111111", {"wrong_outcome_points": 1}) + + assert await db.get_active_scoring_rules("111111") == { + "exact_score_points": 5, + "correct_outcome_points": 2, + "wrong_outcome_points": 0, + "late_prediction_points": 0, + } + + @pytest.mark.asyncio + async def test_save_scores_does_not_mutate_when_write_lock_is_held( + self, temp_db_path, monkeypatch + ): + db = Database(temp_db_path) + await db.initialize() + fixture_id = await db.create_fixture("111111", 1, ["Team A - Team B"], datetime.now(UTC)) + await db.save_scores( + fixture_id, + [ + { + "user_id": "user-1", + "user_name": "User One", + "points": 3, + "exact_scores": 1, + "correct_results": 0, + } + ], + ) + + real_connect = scores_module.aiosqlite.connect + + def connect_with_short_timeout(*args, **kwargs): + kwargs.setdefault("timeout", 0.05) + return real_connect(*args, **kwargs) + + monkeypatch.setattr(scores_module.aiosqlite, "connect", connect_with_short_timeout) + async with aiosqlite.connect(temp_db_path) as locked_conn: + await locked_conn.execute("BEGIN IMMEDIATE") + + with pytest.raises(aiosqlite.OperationalError, match="locked"): + await db.save_scores( + fixture_id, + [ + { + "user_id": "user-2", + "user_name": "User Two", + "points": 9, + "exact_scores": 3, + "correct_results": 3, + } + ], + ) + + await locked_conn.rollback() + + scores = await db.get_scores_for_fixture(fixture_id) + assert [score["user_id"] for score in scores] == ["user-1"] + + @pytest.mark.asyncio + async def test_save_scores_rolls_back_after_partial_write_failure(self, temp_db_path): + db = Database(temp_db_path) + await db.initialize() + fixture_id = await db.create_fixture("111111", 1, ["Team A - Team B"], datetime.now(UTC)) + await db.save_scores( + fixture_id, + [ + { + "user_id": "user-1", + "user_name": "User One", + "points": 3, + "exact_scores": 1, + "correct_results": 0, + } + ], + ) + + async with aiosqlite.connect(temp_db_path) as conn: + await conn.execute( + """ + CREATE TRIGGER fail_second_score_insert + BEFORE INSERT ON scores + WHEN NEW.user_id = 'user-3' + BEGIN + SELECT RAISE(FAIL, 'forced score insert failure'); + END + """ + ) + await conn.commit() + + with pytest.raises(aiosqlite.IntegrityError, match="forced score insert failure"): + await db.save_scores( + fixture_id, + [ + { + "user_id": "user-2", + "user_name": "User Two", + "points": 9, + "exact_scores": 3, + "correct_results": 3, + }, + { + "user_id": "user-3", + "user_name": "User Three", + "points": 0, + "exact_scores": 0, + "correct_results": 0, + }, + ], + ) + + scores = await db.get_scores_for_fixture(fixture_id) + fixture = await db.get_fixture_by_id(fixture_id, "111111") + assert scores == [ + { + "user_id": "user-1", + "user_name": "User One", + "points": 3, + "exact_scores": 1, + "correct_results": 0, + } + ] + assert fixture["status"] == "closed" + + @pytest.mark.asyncio + async def test_recalculate_fixture_scores_rolls_back_after_partial_write_failure( + self, temp_db_path + ): + db = Database(temp_db_path) + await db.initialize() + fixture_id = await db.create_fixture( + "111111", + 1, + ["Team A - Team B", "Team C - Team D"], + datetime.now(UTC), + ) + await db.save_prediction(fixture_id, "user-1", "User One", ["2-1", "1-1"], False) + await db.save_results(fixture_id, ["2-1", "1-1"]) + await db.save_scores( + fixture_id, + [ + { + "user_id": "original", + "user_name": "Original User", + "points": 1, + "exact_scores": 0, + "correct_results": 1, + } + ], + ) + + async with aiosqlite.connect(temp_db_path) as conn: + await conn.execute( + """ + CREATE TRIGGER fail_recalculated_score_insert + BEFORE INSERT ON scores + WHEN NEW.user_id = 'user-1' + BEGIN + SELECT RAISE(FAIL, 'forced score insert failure'); + END + """ + ) + await conn.commit() + + with pytest.raises(aiosqlite.IntegrityError, match="forced score insert failure"): + await db.recalculate_fixture_scores(fixture_id) + + scores = await db.get_scores_for_fixture(fixture_id) + assert [(score["user_id"], score["points"]) for score in scores] == [("original", 1)] + + @pytest.mark.asyncio + async def test_standings_order_by_points_tiebreakers_and_name(self, temp_db_path): + db = Database(temp_db_path) + await db.initialize() + fixture_id = await db.create_fixture("111111", 1, ["Team A - Team B"], datetime.now(UTC)) + + await db.save_scores( + fixture_id, + [ + { + "user_id": "total", + "user_name": "Total", + "points": 10, + "exact_scores": 0, + "correct_results": 0, + }, + { + "user_id": "exact", + "user_name": "Exact", + "points": 9, + "exact_scores": 2, + "correct_results": 0, + }, + { + "user_id": "correct", + "user_name": "Correct", + "points": 9, + "exact_scores": 1, + "correct_results": 3, + }, + { + "user_id": "alpha", + "user_name": "Alpha", + "points": 9, + "exact_scores": 1, + "correct_results": 2, + }, + { + "user_id": "beta", + "user_name": "Beta", + "points": 9, + "exact_scores": 1, + "correct_results": 2, + }, + ], + ) + + standings = await db.get_standings("111111") + + assert [row["user_id"] for row in standings] == [ + "total", + "exact", + "correct", + "alpha", + "beta", + ] + + @pytest.mark.asyncio + async def test_standings_are_active_season_scoped(self, temp_db_path): + db = Database(temp_db_path) + await db.initialize() + old_fixture_id = await db.create_fixture( + "111111", 1, ["Old Team A - Old Team B"], datetime.now(UTC) + ) + await db.save_scores( + old_fixture_id, + [ + { + "user_id": "shared-user", + "user_name": "Old Shared User", + "points": 30, + "exact_scores": 10, + "correct_results": 0, + } + ], + ) + await start_new_active_season(temp_db_path, "111111") + active_fixture_id = await db.create_fixture( + "111111", 1, ["New Team A - New Team B"], datetime.now(UTC) + ) + await db.save_scores( + active_fixture_id, + [ + { + "user_id": "shared-user", + "user_name": "Active Shared User", + "points": 3, + "exact_scores": 1, + "correct_results": 0, + } + ], + ) + + standings = await db.get_standings("111111") + + assert [row["user_id"] for row in standings] == ["shared-user"] + assert standings[0]["user_name"] == "Active Shared User" + assert standings[0]["total_points"] == 3 + + @pytest.mark.asyncio + async def test_get_standings_for_season_returns_archived_season_scores(self, temp_db_path): + db = Database(temp_db_path) + await db.initialize() + old_fixture_id = await db.create_fixture( + "111111", 1, ["Old Team A - Old Team B"], datetime.now(UTC) + ) + await db.save_scores( + old_fixture_id, + [ + { + "user_id": "old-user", + "user_name": "Old User", + "points": 30, + "exact_scores": 10, + "correct_results": 0, + } + ], + ) + old_season = await db.get_active_season("111111") + await start_new_active_season(temp_db_path, "111111") + active_fixture_id = await db.create_fixture( + "111111", 1, ["New Team A - New Team B"], datetime.now(UTC) + ) + await db.save_scores( + active_fixture_id, + [ + { + "user_id": "active-user", + "user_name": "Active User", + "points": 3, + "exact_scores": 1, + "correct_results": 0, + } + ], + ) + + standings = await db.get_standings_for_season("111111", old_season["id"]) + wrong_guild_standings = await db.get_standings_for_season("222222", old_season["id"]) + + assert [row["user_id"] for row in standings] == ["old-user"] + assert standings[0]["total_points"] == 30 + assert wrong_guild_standings == [] + + @pytest.mark.asyncio + async def test_last_fixture_scores_are_active_season_scoped(self, temp_db_path): + db = Database(temp_db_path) + await db.initialize() + await start_new_active_season(temp_db_path, "111111") + active_fixture_id = await db.create_fixture( + "111111", 1, ["New Team A - New Team B"], datetime.now(UTC) + ) + await db.save_scores( + active_fixture_id, + [ + { + "user_id": "active-user", + "user_name": "Active User", + "points": 3, + "exact_scores": 1, + "correct_results": 0, + } + ], + ) + await start_new_active_season(temp_db_path, "111111", "Archived Later Season") + later_archived_fixture_id = await db.create_fixture( + "111111", 1, ["Archived Team A - Archived Team B"], datetime.now(UTC) + ) + await db.save_scores( + later_archived_fixture_id, + [ + { + "user_id": "archived-user", + "user_name": "Archived User", + "points": 30, + "exact_scores": 10, + "correct_results": 0, + } + ], + ) + async with aiosqlite.connect(temp_db_path) as conn: + await conn.execute( + "UPDATE seasons SET status = 'archived' WHERE id = (SELECT season_id FROM fixtures WHERE id = ?)", + (later_archived_fixture_id,), + ) + await conn.execute( + "UPDATE seasons SET status = 'active' WHERE id = (SELECT season_id FROM fixtures WHERE id = ?)", + (active_fixture_id,), + ) + await conn.commit() + + last_fixture = await db.get_last_fixture_scores("111111") + + assert last_fixture["fixture_id"] == active_fixture_id + assert [score["user_id"] for score in last_fixture["scores"]] == ["active-user"] diff --git a/tests/database/test_seasons.py b/tests/database/test_seasons.py new file mode 100644 index 0000000..79ffbb1 --- /dev/null +++ b/tests/database/test_seasons.py @@ -0,0 +1,495 @@ +from datetime import UTC, datetime + +import aiosqlite +import pytest + +from tests.database.helpers import start_new_active_season +from typer_bot.database import Database, SaveResult + + +class TestSeasons: + @pytest.mark.asyncio + async def test_create_fixture_uses_fresh_guild_active_season(self, temp_db_path): + db = Database(temp_db_path) + await db.initialize() + await db.upsert_guild_config("111111", "role-1", "channel-1") + + first_fixture_id = await db.create_fixture( + "111111", 1, ["Team A - Team B"], datetime.now(UTC) + ) + second_fixture_id = await db.create_fixture( + "111111", 2, ["Team C - Team D"], datetime.now(UTC) + ) + + active_season = await db.get_active_season("111111") + first_fixture = await db.get_fixture_by_id(first_fixture_id, "111111") + second_fixture = await db.get_fixture_by_id(second_fixture_id, "111111") + config = await db.get_guild_config("111111") + + assert active_season is not None + assert active_season["guild_id"] == "111111" + assert active_season["name"] + assert active_season["status"] == "active" + assert first_fixture["season_id"] == active_season["id"] + assert second_fixture["season_id"] == active_season["id"] + assert config["active_season_id"] == active_season["id"] + + @pytest.mark.asyncio + async def test_active_seasons_are_guild_isolated(self, temp_db_path): + db = Database(temp_db_path) + await db.initialize() + + guild_one_fixture_id = await db.create_fixture( + "111111", 1, ["Team A - Team B"], datetime.now(UTC) + ) + guild_two_fixture_id = await db.create_fixture( + "222222", 1, ["Team C - Team D"], datetime.now(UTC) + ) + + guild_one_season = await db.get_active_season("111111") + guild_two_season = await db.get_active_season("222222") + guild_one_fixture = await db.get_fixture_by_id(guild_one_fixture_id, "111111") + guild_two_fixture = await db.get_fixture_by_id(guild_two_fixture_id, "222222") + + assert guild_one_season is not None + assert guild_two_season is not None + assert guild_one_season["guild_id"] == "111111" + assert guild_two_season["guild_id"] == "222222" + assert guild_one_season["id"] != guild_two_season["id"] + assert guild_one_fixture["season_id"] == guild_one_season["id"] + assert guild_two_fixture["season_id"] == guild_two_season["id"] + + @pytest.mark.asyncio + async def test_get_or_create_active_season_repairs_stale_config_pointer(self, temp_db_path): + db = Database(temp_db_path) + await db.initialize() + await db.upsert_guild_config("111111", "role-1", "channel-1") + fixture_id = await db.create_fixture("111111", 1, ["Team A - Team B"], datetime.now(UTC)) + fixture = await db.get_fixture_by_id(fixture_id, "111111") + + async with aiosqlite.connect(temp_db_path) as conn: + cursor = await conn.execute( + "INSERT INTO seasons (guild_id, name, status) VALUES ('222222', 'Wrong Guild', 'active')" + ) + await conn.execute( + "UPDATE guild_config SET active_season_id = ? WHERE guild_id = '111111'", + (cursor.lastrowid,), + ) + await conn.commit() + + active_season = await db.get_or_create_active_season("111111") + config = await db.get_guild_config("111111") + + assert active_season["id"] == fixture["season_id"] + assert config["active_season_id"] == active_season["id"] + + @pytest.mark.asyncio + async def test_create_next_fixture_restarts_week_numbers_per_active_season(self, temp_db_path): + db = Database(temp_db_path) + await db.initialize() + _old_fixture_id, old_week = await db.create_next_fixture( + "111111", ["Team A - Team B"], datetime.now(UTC) + ) + await start_new_active_season(temp_db_path, "111111") + + new_fixture_id, new_week = await db.create_next_fixture( + "111111", ["Team C - Team D"], datetime.now(UTC) + ) + new_fixture = await db.get_fixture_by_id(new_fixture_id, "111111") + active_season = await db.get_active_season("111111") + + assert old_week == 1 + assert new_week == 1 + assert new_fixture["season_id"] == active_season["id"] + + @pytest.mark.asyncio + async def test_start_new_season_archives_previous_active_season(self, temp_db_path): + db = Database(temp_db_path) + await db.initialize() + await db.upsert_guild_config("111111", "role-1", "channel-1") + old_fixture_id = await db.create_fixture( + "111111", 7, ["Team A - Team B"], datetime.now(UTC) + ) + await db.save_scores( + old_fixture_id, + [ + { + "user_id": "user-1", + "user_name": "User One", + "points": 3, + "exact_scores": 1, + "correct_results": 0, + } + ], + ) + old_season = await db.get_active_season("111111") + + new_season = await db.start_new_season("111111", "2026/27") + config = await db.get_guild_config("111111") + seasons = await db.get_seasons("111111") + + assert old_season is not None + assert new_season["name"] == "2026/27" + assert new_season["status"] == "active" + assert config["active_season_id"] == new_season["id"] + assert [(season["id"], season["status"]) for season in seasons] == [ + (old_season["id"], "archived"), + (new_season["id"], "active"), + ] + assert seasons[0]["ended_at"] is not None + + @pytest.mark.asyncio + async def test_start_new_season_blocks_open_active_fixture(self, temp_db_path): + db = Database(temp_db_path) + await db.initialize() + await db.create_fixture("111111", 1, ["Team A - Team B"], datetime.now(UTC)) + old_season = await db.get_active_season("111111") + + with pytest.raises(ValueError, match="Close all open fixtures"): + await db.start_new_season("111111", "2026/27") + + assert await db.get_active_season("111111") == old_season + + @pytest.mark.asyncio + async def test_start_new_season_rejects_blank_name_without_mutating(self, temp_db_path): + db = Database(temp_db_path) + await db.initialize() + old_fixture_id = await db.create_fixture( + "111111", 1, ["Team A - Team B"], datetime.now(UTC) + ) + await db.save_scores( + old_fixture_id, + [ + { + "user_id": "user-1", + "user_name": "User One", + "points": 1, + "exact_scores": 0, + "correct_results": 1, + } + ], + ) + old_season = await db.get_active_season("111111") + + with pytest.raises(ValueError, match="Season name is required"): + await db.start_new_season("111111", " ") + + assert await db.get_active_season("111111") == old_season + assert await db.get_seasons("111111") == [old_season] + + @pytest.mark.asyncio + async def test_start_new_season_rolls_back_when_new_season_insert_fails(self, temp_db_path): + db = Database(temp_db_path) + await db.initialize() + old_fixture_id = await db.create_fixture( + "111111", 1, ["Team A - Team B"], datetime.now(UTC) + ) + await db.save_scores( + old_fixture_id, + [ + { + "user_id": "user-1", + "user_name": "User One", + "points": 1, + "exact_scores": 0, + "correct_results": 1, + } + ], + ) + old_season = await db.get_active_season("111111") + async with aiosqlite.connect(temp_db_path) as conn: + await conn.execute( + """ + CREATE TRIGGER fail_broken_season_insert + BEFORE INSERT ON seasons + WHEN NEW.name = 'Broken Season' + BEGIN + SELECT RAISE(FAIL, 'broken season insert'); + END + """ + ) + await conn.commit() + + with pytest.raises(aiosqlite.IntegrityError, match="broken season insert"): + await db.start_new_season("111111", "Broken Season") + + assert await db.get_active_season("111111") == old_season + assert await db.get_seasons("111111") == [old_season] + + @pytest.mark.asyncio + async def test_start_new_season_resets_next_fixture_week(self, temp_db_path): + db = Database(temp_db_path) + await db.initialize() + old_fixture_id, old_week = await db.create_next_fixture( + "111111", ["Team A - Team B"], datetime.now(UTC) + ) + await db.save_scores( + old_fixture_id, + [ + { + "user_id": "user-1", + "user_name": "User One", + "points": 1, + "exact_scores": 0, + "correct_results": 1, + } + ], + ) + + await db.start_new_season("111111", "2026/27") + new_fixture_id, new_week = await db.create_next_fixture( + "111111", ["Team C - Team D"], datetime.now(UTC) + ) + new_fixture = await db.get_fixture_by_id(new_fixture_id, "111111") + active_season = await db.get_active_season("111111") + + assert old_week == 1 + assert new_week == 1 + assert new_fixture["season_id"] == active_season["id"] + + @pytest.mark.asyncio + async def test_start_new_season_uses_fresh_default_scoring_rules(self, temp_db_path): + db = Database(temp_db_path) + await db.initialize() + custom_rules = { + "exact_score_points": 5, + "correct_outcome_points": 2, + "wrong_outcome_points": 1, + "late_prediction_points": 1, + } + await db.update_active_scoring_rules("111111", custom_rules) + + await db.start_new_season("111111", "Next Season") + + seasons = await db.get_seasons("111111") + active_rules = await db.get_active_scoring_rules("111111") + assert seasons[0]["scoring_rules"] == custom_rules + assert active_rules == { + "exact_score_points": 3, + "correct_outcome_points": 1, + "wrong_outcome_points": 0, + "late_prediction_points": 0, + } + + @pytest.mark.asyncio + async def test_scoring_rule_updates_preserve_omitted_values(self, temp_db_path): + db = Database(temp_db_path) + await db.initialize() + await db.update_active_scoring_rules( + "111111", + { + "exact_score_points": 5, + "correct_outcome_points": 2, + "wrong_outcome_points": 1, + "late_prediction_points": 1, + }, + ) + + await db.update_active_scoring_rules("111111", {"late_prediction_points": 2}) + + assert await db.get_active_scoring_rules("111111") == { + "exact_score_points": 5, + "correct_outcome_points": 2, + "wrong_outcome_points": 1, + "late_prediction_points": 2, + } + + @pytest.mark.asyncio + @pytest.mark.parametrize( + ("rules", "message"), + [ + ({"exact_score_points": -1}, "zero or greater"), + ({"exact_score_points": "many"}, "whole numbers"), + ({"exact_points": 5}, "Unknown scoring rule"), + ], + ) + async def test_invalid_scoring_rule_updates_do_not_mutate_existing_rules( + self, temp_db_path, rules, message + ): + db = Database(temp_db_path) + await db.initialize() + await db.update_active_scoring_rules("111111", {"exact_score_points": 5}) + existing_rules = await db.get_active_scoring_rules("111111") + + with pytest.raises(ValueError, match=message): + await db.update_active_scoring_rules("111111", rules) + + assert await db.get_active_scoring_rules("111111") == existing_rules + + @pytest.mark.asyncio + async def test_fixture_queries_default_to_active_season(self, temp_db_path): + db = Database(temp_db_path) + await db.initialize() + old_fixture_id = await db.create_fixture( + "111111", 1, ["Old Team A - Old Team B"], datetime.now(UTC) + ) + await db.update_fixture_announcement(old_fixture_id, "old-message", "channel-1") + await start_new_active_season(temp_db_path, "111111") + active_fixture_id = await db.create_fixture( + "111111", 1, ["New Team A - New Team B"], datetime.now(UTC) + ) + await db.update_fixture_announcement(active_fixture_id, "new-message", "channel-1") + + current_fixture = await db.get_current_fixture("111111") + open_fixtures = await db.get_open_fixtures("111111") + recent_fixtures = await db.get_recent_fixtures("111111") + week_fixture = await db.get_open_fixture_by_week("111111", 1) + any_status_week_fixture = await db.get_fixture_by_week("111111", 1) + message_fixture = await db.get_fixture_by_message_id("new-message", "111111") + global_message_fixture = await db.get_fixture_by_message_id("new-message") + + assert await db.get_fixture_by_id(old_fixture_id, "111111") is None + assert current_fixture["id"] == active_fixture_id + assert [fixture["id"] for fixture in open_fixtures] == [active_fixture_id] + assert [fixture["id"] for fixture in recent_fixtures] == [active_fixture_id] + assert week_fixture["id"] == active_fixture_id + assert any_status_week_fixture["id"] == active_fixture_id + assert message_fixture["id"] == active_fixture_id + assert global_message_fixture["id"] == active_fixture_id + assert await db.get_fixture_by_message_id("old-message", "111111") is None + assert await db.get_fixture_by_message_id("old-message") is None + + @pytest.mark.asyncio + async def test_all_open_fixtures_only_returns_active_season_fixtures(self, temp_db_path): + db = Database(temp_db_path) + await db.initialize() + await db.create_fixture("111111", 1, ["Old Team A - Old Team B"], datetime.now(UTC)) + await start_new_active_season(temp_db_path, "111111") + active_fixture_id = await db.create_fixture( + "111111", 1, ["New Team A - New Team B"], datetime.now(UTC) + ) + other_guild_fixture_id = await db.create_fixture( + "222222", 1, ["Other Team A - Other Team B"], datetime.now(UTC) + ) + + open_fixture_ids = [fixture["id"] for fixture in await db.get_all_open_fixtures()] + + assert set(open_fixture_ids) == {active_fixture_id, other_guild_fixture_id} + + @pytest.mark.asyncio + async def test_archived_fixture_prediction_writes_are_rejected(self, temp_db_path): + db = Database(temp_db_path) + await db.initialize() + old_fixture_id = await db.create_fixture( + "111111", 1, ["Old Team A - Old Team B"], datetime.now(UTC) + ) + await db.save_prediction(old_fixture_id, "user-1", "User One", ["1-0"], False) + await start_new_active_season(temp_db_path, "111111") + + first_write = await db.try_save_prediction(old_fixture_id, "user-2", "User Two", ["2-0"]) + guarded_write = await db.save_prediction_guarded( + old_fixture_id, "user-1", "User One", ["9-9"] + ) + admin_write = await db.admin_update_prediction_with_recalc( + old_fixture_id, "user-1", ["8-8"], "admin-1" + ) + + assert first_write == SaveResult.FIXTURE_CLOSED + assert guarded_write == SaveResult.FIXTURE_CLOSED + assert admin_write is False + assert await db.get_prediction(old_fixture_id, "user-1", "111111") is None + async with ( + aiosqlite.connect(temp_db_path) as conn, + conn.execute( + "SELECT predictions FROM predictions WHERE fixture_id = ? AND user_id = ?", + (old_fixture_id, "user-1"), + ) as cursor, + ): + row = await cursor.fetchone() + assert row == ("1-0",) + + @pytest.mark.asyncio + async def test_archived_pending_partials_are_hidden_and_not_mutated(self, temp_db_path): + db = Database(temp_db_path) + await db.initialize() + old_fixture_id = await db.create_fixture( + "111111", 1, ["Old Team A - Old Team B"], datetime.now(UTC) + ) + await db.save_prediction( + old_fixture_id, + "user-1", + "User One", + ["1-0"], + True, + pending_partial_approval=True, + ) + await start_new_active_season(temp_db_path, "111111") + + approved = await db.approve_partial_prediction(old_fixture_id, "user-1", "admin-1") + rejected = await db.reject_partial_prediction(old_fixture_id, "user-1") + pending = await db.get_pending_partial_predictions("111111") + + assert approved is False + assert rejected is False + assert pending == [] + async with ( + aiosqlite.connect(temp_db_path) as conn, + conn.execute( + "SELECT pending_partial_approval FROM predictions WHERE fixture_id = ? AND user_id = ?", + (old_fixture_id, "user-1"), + ) as cursor, + ): + row = await cursor.fetchone() + assert row == (1,) + + @pytest.mark.asyncio + async def test_archived_fixture_result_and_score_writes_are_rejected(self, temp_db_path): + db = Database(temp_db_path) + await db.initialize() + old_fixture_id = await db.create_fixture( + "111111", 1, ["Old Team A - Old Team B"], datetime.now(UTC) + ) + await db.save_results(old_fixture_id, ["0-0"]) + await db.save_scores( + old_fixture_id, + [ + { + "user_id": "old-user", + "user_name": "Old User", + "points": 30, + "exact_scores": 10, + "correct_results": 0, + } + ], + ) + await start_new_active_season(temp_db_path, "111111") + + with pytest.raises(ValueError): + await db.save_results(old_fixture_id, ["1-0"]) + with pytest.raises(ValueError): + await db.save_results_with_recalc(old_fixture_id, ["1-0"]) + with pytest.raises(ValueError): + await db.recalculate_fixture_scores(old_fixture_id) + with pytest.raises(ValueError): + await db.save_scores( + old_fixture_id, + [ + { + "user_id": "new-user", + "user_name": "New User", + "points": 1, + "exact_scores": 0, + "correct_results": 1, + } + ], + ) + + assert await db.get_results(old_fixture_id) == ["0-0"] + scores = await db.get_scores_for_fixture(old_fixture_id) + assert [(score["user_id"], score["points"]) for score in scores] == [("old-user", 30)] + + @pytest.mark.asyncio + async def test_archived_fixture_delete_requires_active_season(self, temp_db_path): + db = Database(temp_db_path) + await db.initialize() + old_fixture_id = await db.create_fixture( + "111111", 1, ["Old Team A - Old Team B"], datetime.now(UTC) + ) + await start_new_active_season(temp_db_path, "111111") + + assert await db.delete_fixture(old_fixture_id, "111111") is False + async with ( + aiosqlite.connect(temp_db_path) as conn, + conn.execute("SELECT 1 FROM fixtures WHERE id = ?", (old_fixture_id,)) as cursor, + ): + assert await cursor.fetchone() == (1,) diff --git a/tests/test_admin_panel_fixtures.py b/tests/test_admin_panel_fixtures.py deleted file mode 100644 index f39378b..0000000 --- a/tests/test_admin_panel_fixtures.py +++ /dev/null @@ -1,1510 +0,0 @@ -from datetime import UTC, datetime, timedelta -from unittest.mock import AsyncMock, MagicMock - -import discord -import pytest - -from tests.admin_panel_helpers import get_button as _get_button -from tests.admin_panel_helpers import has_button as _has_button -from tests.admin_panel_helpers import option_values as _option_values -from tests.conftest import MockInteraction, MockUser -from typer_bot.commands.admin_commands import AdminCommands -from typer_bot.commands.admin_panel import ( - CreateFixtureModal, - DeleteConfirmView, - EnterResultsModal, - FixturesPanelView, - NewSeasonModal, - PostResultsConfirmView, - PredictionsPanelView, - ResultsPanelView, - ScoringRulesModal, - UnifiedAdminPanelView, -) -from typer_bot.commands.admin_panel.fixtures import _cleanup_discord_announcement -from typer_bot.database import Database -from typer_bot.utils import now - - -class TestFixturePanelFlows: - """Fixture panel should load current open fixtures before deletion.""" - - @pytest.fixture - def admin_cog(self, mock_bot, database): - mock_bot.db = database - return AdminCommands(mock_bot) - - @pytest.mark.asyncio - async def test_fixture_button_populates_open_fixture_options( - self, - admin_cog, - mock_interaction_admin, - sample_games, - ): - await admin_cog.db.create_fixture( - "111111", 4, sample_games, datetime.now(UTC) + timedelta(days=1) - ) - - 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() - - assert view.fixture_select.disabled is False - assert view.fixture_select.options[0].label == "Week 4 [OPEN]" - - @pytest.mark.asyncio - @pytest.mark.parametrize( - "view_cls", - [FixturesPanelView, PredictionsPanelView, ResultsPanelView, UnifiedAdminPanelView], - ) - async def test_admin_fixture_selectors_only_show_current_guild( - self, - view_cls, - admin_cog, - mock_interaction_admin, - sample_games, - ): - deadline = datetime.now(UTC) + timedelta(days=1) - current_guild_fixture_id = await admin_cog.db.create_fixture( - "111111", 1, sample_games, deadline - ) - other_guild_fixture_id = await admin_cog.db.create_fixture( - "guild-2", 2, sample_games, deadline - ) - - view = view_cls( - admin_cog.db, - admin_cog.service, - str(mock_interaction_admin.user.id), - "111111", - ) - await view.load_fixture_options() - - option_values = _option_values(view.fixture_select) - assert str(current_guild_fixture_id) in option_values - assert str(other_guild_fixture_id) not in option_values - - @pytest.mark.asyncio - async def test_fixture_panel_delete_button_enables_after_fixture_selection( - self, - admin_cog, - mock_interaction_admin, - sample_games, - ): - fixture_id = await admin_cog.db.create_fixture( - "111111", 5, sample_games, datetime.now(UTC) + timedelta(days=1) - ) - - view = FixturesPanelView( - admin_cog.db, - admin_cog.service, - str(mock_interaction_admin.user.id), - "111111", - bot=admin_cog.bot, - ) - await view.load_fixture_options() - - assert _get_button(view, "Delete Fixture").disabled is True - - view.fixture_select._values = [str(fixture_id)] - await view.fixture_select.callback(mock_interaction_admin) - - assert "Fixture: Week 5 [OPEN]" in mock_interaction_admin.response_sent[-1]["content"] - assert _get_button(view, "Delete Fixture").disabled is False - - @pytest.mark.asyncio - async def test_fixture_panel_delete_confirmation_shows_games( - self, - admin_cog, - mock_interaction_admin, - sample_games, - ): - """Deletion confirmation must show game list so admin can verify the right fixture.""" - fixture_id = await admin_cog.db.create_fixture( - "111111", 6, sample_games, datetime.now(UTC) + timedelta(days=1) - ) - - 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) - - delete_button = next( - child for child in view.children if getattr(child, "label", None) == "Delete Fixture" - ) - await delete_button.callback(mock_interaction_admin) - - confirmation_content = mock_interaction_admin.response_sent[-1]["content"] - assert "Delete Week 6?" in confirmation_content - assert "Team A - Team B" in confirmation_content - - @pytest.mark.asyncio - async def test_unified_panel_create_fixture_button_opens_modal( - self, - admin_cog, - mock_interaction_admin, - ): - channel = MagicMock(spec=discord.TextChannel) - channel.id = mock_interaction_admin.channel.id - mock_interaction_admin.channel = channel - view = UnifiedAdminPanelView( - admin_cog.db, - admin_cog.service, - str(mock_interaction_admin.user.id), - "111111", - admin_commands=admin_cog, - bot=admin_cog.bot, - ) - create_button = _get_button(view, "Create Fixture") - - await create_button.callback(mock_interaction_admin) - - assert isinstance(mock_interaction_admin.modal_sent["modal"], CreateFixtureModal) - - @pytest.mark.asyncio - async def test_unified_panel_shows_active_season_and_new_season_button( - self, - admin_cog, - mock_interaction_admin, - ): - 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() - - assert "Active season: Default Season" in view.render_content() - assert "Scoring: exact 3, outcome 1, wrong 0, late 0" in view.render_content() - assert _has_button(view, "Scoring Rules") is True - assert _has_button(view, "New Season") is True - - @pytest.mark.asyncio - async def test_unified_panel_scoring_rules_button_opens_modal( - self, - admin_cog, - mock_interaction_admin, - ): - 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() - scoring_button = _get_button(view, "Scoring Rules") - - await scoring_button.callback(mock_interaction_admin) - - modal = mock_interaction_admin.modal_sent["modal"] - assert isinstance(modal, ScoringRulesModal) - assert modal.exact_input.default == "3" - assert modal.outcome_input.default == "1" - assert modal.wrong_input.default == "0" - assert modal.late_input.default == "0" - - @pytest.mark.asyncio - async def test_scoring_rules_modal_updates_active_season_rules( - self, - admin_cog, - mock_interaction_admin, - ): - 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() - modal = ScoringRulesModal(view) - modal.exact_input._value = "5" - modal.outcome_input._value = "2" - modal.wrong_input._value = "1" - modal.late_input._value = "1" - - await modal.on_submit(mock_interaction_admin) - - assert await admin_cog.db.get_active_scoring_rules("111111") == { - "exact_score_points": 5, - "correct_outcome_points": 2, - "wrong_outcome_points": 1, - "late_prediction_points": 1, - } - content = mock_interaction_admin.response_sent[-1]["content"] - assert "Updated active-season scoring rules." in content - assert "Scoring: exact 5, outcome 2, wrong 1, late 1" in content - - @pytest.mark.asyncio - async def test_scoring_rules_button_refreshes_modal_defaults( - self, - admin_cog, - mock_interaction_admin, - ): - 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() - await admin_cog.db.update_active_scoring_rules("111111", {"exact_score_points": 5}) - - scoring_button = _get_button(view, "Scoring Rules") - await scoring_button.callback(mock_interaction_admin) - - modal = mock_interaction_admin.modal_sent["modal"] - assert modal.exact_input.default == "5" - - @pytest.mark.asyncio - async def test_scoring_rules_modal_rejects_stale_season_submit( - self, - admin_cog, - mock_interaction_admin, - ): - 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() - old_season_rules = view.active_season["scoring_rules"] - modal = ScoringRulesModal(view) - modal.exact_input._value = "5" - modal.outcome_input._value = "2" - modal.wrong_input._value = "1" - modal.late_input._value = "1" - await admin_cog.db.start_new_season("111111", "Next Season") - - await modal.on_submit(mock_interaction_admin) - - assert "active season changed" in mock_interaction_admin.response_sent[-1]["content"] - assert await admin_cog.db.get_active_scoring_rules("111111") == { - "exact_score_points": 3, - "correct_outcome_points": 1, - "wrong_outcome_points": 0, - "late_prediction_points": 0, - } - seasons = await admin_cog.db.get_seasons("111111") - assert seasons[0]["status"] == "archived" - assert seasons[0]["scoring_rules"] == old_season_rules - - @pytest.mark.asyncio - async def test_scoring_rules_modal_rejects_non_owner( - self, - admin_cog, - mock_interaction_admin, - ): - 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() - modal = ScoringRulesModal(view) - modal.exact_input._value = "5" - modal.outcome_input._value = "2" - modal.wrong_input._value = "1" - modal.late_input._value = "1" - outsider = MockInteraction( - user=MockUser(user_id="999999", name="Outsider"), - guild=mock_interaction_admin.guild, - channel=mock_interaction_admin.channel, - ) - - await modal.on_submit(outsider) - - assert "permission" in outsider.response_sent[-1]["content"] - assert await admin_cog.db.get_active_scoring_rules("111111") == { - "exact_score_points": 3, - "correct_outcome_points": 1, - "wrong_outcome_points": 0, - "late_prediction_points": 0, - } - - @pytest.mark.asyncio - async def test_scoring_rules_modal_rechecks_admin_permission( - self, - admin_cog, - mock_interaction_admin, - ): - 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() - modal = ScoringRulesModal(view) - modal.exact_input._value = "5" - modal.outcome_input._value = "2" - modal.wrong_input._value = "1" - modal.late_input._value = "1" - member = mock_interaction_admin.guild.get_member(mock_interaction_admin.user.id) - member.roles = [] - - await modal.on_submit(mock_interaction_admin) - - assert "no longer have permission" in mock_interaction_admin.response_sent[-1]["content"] - assert await admin_cog.db.get_active_scoring_rules("111111") == { - "exact_score_points": 3, - "correct_outcome_points": 1, - "wrong_outcome_points": 0, - "late_prediction_points": 0, - } - - @pytest.mark.asyncio - async def test_scoring_rules_modal_blocks_changes_after_scores_exist( - self, - admin_cog, - mock_interaction_admin, - sample_games, - ): - fixture_id = await admin_cog.db.create_fixture( - "111111", 1, sample_games, datetime.now(UTC) + timedelta(days=1) - ) - await admin_cog.db.save_results(fixture_id, ["2-1", "1-1", "0-0"]) - await admin_cog.db.save_prediction( - fixture_id, - "user-1", - "User One", - ["2-1", "1-1", "0-0"], - False, - ) - 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() - assert _has_button(view, "Scoring Rules") is True - - modal = ScoringRulesModal(view) - modal.exact_input._value = "5" - modal.outcome_input._value = "2" - modal.wrong_input._value = "1" - modal.late_input._value = "1" - await admin_cog.db.recalculate_fixture_scores(fixture_id) - - await modal.on_submit(mock_interaction_admin) - - assert "Cannot change scoring rules" in mock_interaction_admin.response_sent[-1]["content"] - assert await admin_cog.db.get_active_scoring_rules("111111") == { - "exact_score_points": 3, - "correct_outcome_points": 1, - "wrong_outcome_points": 0, - "late_prediction_points": 0, - } - - @pytest.mark.asyncio - async def test_scoring_rules_modal_rejects_invalid_values( - self, - admin_cog, - mock_interaction_admin, - ): - 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() - modal = ScoringRulesModal(view) - modal.exact_input._value = "many" - modal.outcome_input._value = "2" - modal.wrong_input._value = "1" - modal.late_input._value = "1" - - await modal.on_submit(mock_interaction_admin) - - assert "whole numbers" in mock_interaction_admin.response_sent[-1]["content"] - - @pytest.mark.asyncio - async def test_unified_panel_hides_contextual_actions_until_fixture_selection( - self, - admin_cog, - mock_interaction_admin, - sample_games, - ): - fixture_id = await admin_cog.db.create_fixture( - "111111", 1, sample_games, datetime.now(UTC) + timedelta(days=1) - ) - 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() - - assert _has_button(view, "Enter Results") is False - assert _has_button(view, "Calculate Scores") is False - assert _has_button(view, "Correct Results") is False - assert _has_button(view, "Delete Fixture") is False - assert _has_button(view, "Replace Prediction") is False - assert _has_button(view, "Toggle Late Waiver") is False - - view.fixture_select._values = [str(fixture_id)] - await view.fixture_select.callback(mock_interaction_admin) - - assert _has_button(view, "Enter Results") is True - assert _has_button(view, "Calculate Scores") is True - assert _has_button(view, "Correct Results") is False - assert _has_button(view, "Delete Fixture") is True - assert _has_button(view, "Replace Prediction") is False - assert _has_button(view, "Toggle Late Waiver") is False - - await admin_cog.db.save_results(fixture_id, ["1-0", "1-1", "0-0"]) - view.fixture_select._values = [str(fixture_id)] - await view.fixture_select.callback(mock_interaction_admin) - - assert _has_button(view, "Enter Results") is False - assert _has_button(view, "Calculate Scores") is True - assert _has_button(view, "Correct Results") is True - - await admin_cog.db.save_scores( - fixture_id, - [ - { - "user_id": "user-1", - "user_name": "User One", - "points": 3, - "exact_scores": 1, - "correct_results": 0, - } - ], - ) - view.fixture_select._values = [str(fixture_id)] - await view.fixture_select.callback(mock_interaction_admin) - - assert _has_button(view, "Enter Results") is False - assert _has_button(view, "Calculate Scores") is False - assert _has_button(view, "Correct Results") is True - assert _has_button(view, "Delete Fixture") is False - - @pytest.mark.asyncio - async def test_unified_panel_new_season_button_opens_modal( - self, - admin_cog, - mock_interaction_admin, - ): - view = UnifiedAdminPanelView( - admin_cog.db, - admin_cog.service, - str(mock_interaction_admin.user.id), - "111111", - admin_commands=admin_cog, - bot=admin_cog.bot, - ) - new_season_button = _get_button(view, "New Season") - - await new_season_button.callback(mock_interaction_admin) - - assert isinstance(mock_interaction_admin.modal_sent["modal"], NewSeasonModal) - - @pytest.mark.asyncio - async def test_new_season_modal_blocks_open_fixtures( - self, - admin_cog, - mock_interaction_admin, - sample_games, - ): - await admin_cog.db.create_fixture( - "111111", 1, sample_games, datetime.now(UTC) + timedelta(days=1) - ) - 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.current_prediction = {"pending_partial_approval": True} - view.has_user_overflow = True - modal = NewSeasonModal(view) - modal.name_input._value = "2026/27" - - await modal.on_submit(mock_interaction_admin) - - assert "Close all open fixtures" in mock_interaction_admin.response_sent[-1]["content"] - assert (await admin_cog.db.get_active_season("111111"))["name"] == "Default Season" - - @pytest.mark.asyncio - async def test_new_season_modal_starts_season_and_refreshes_panel( - self, - admin_cog, - mock_interaction_admin, - sample_games, - ): - fixture_id = await admin_cog.db.create_fixture( - "111111", 1, sample_games, datetime.now(UTC) + timedelta(days=1) - ) - await admin_cog.db.save_scores( - fixture_id, - [ - { - "user_id": "user-1", - "user_name": "User One", - "points": 3, - "exact_scores": 1, - "correct_results": 0, - } - ], - ) - 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) - modal = NewSeasonModal(view) - modal.name_input._value = "2026/27" - - await modal.on_submit(mock_interaction_admin) - _new_fixture_id, new_week = await admin_cog.db.create_next_fixture( - "111111", sample_games, datetime.now(UTC) + timedelta(days=1) - ) - - content = mock_interaction_admin.response_sent[-1]["content"] - assert "Active season: 2026/27" in content - assert "Started new active season: 2026/27" in content - assert view.current_prediction is None - assert view.has_user_overflow is False - assert _has_button(view, "Enter Results") is False - assert _has_button(view, "Calculate Scores") is False - assert _has_button(view, "Correct Results") is False - assert _has_button(view, "Delete Fixture") is False - assert new_week == 1 - - @pytest.mark.asyncio - async def test_unified_panel_hides_review_pending_button_without_pending_partials( - self, - admin_cog, - mock_interaction_admin, - ): - 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._refresh_items() - - assert _has_button(view, "Review Late") is False - - @pytest.mark.asyncio - async def test_unified_panel_shows_review_pending_button_when_pending_partials_exist( - self, - admin_cog, - mock_interaction_admin, - sample_games, - ): - fixture_id = await admin_cog.db.create_fixture( - "111111", 55, sample_games, datetime.now(UTC) + timedelta(days=1) - ) - await admin_cog.db.save_prediction( - fixture_id, - "111", - "User One", - ["1-1", "0-2"], - True, - predicted_game_indexes=[1, 2], - pending_partial_approval=True, - ) - - 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() - - assert _has_button(view, "Review Late") is True - - @pytest.mark.asyncio - async def test_unified_panel_hides_other_guild_pending_partials( - self, - admin_cog, - mock_interaction_admin, - sample_games, - ): - fixture_id = await admin_cog.db.create_fixture( - "guild-2", 55, sample_games, datetime.now(UTC) + timedelta(days=1) - ) - await admin_cog.db.save_prediction( - fixture_id, - "111", - "User One", - ["1-1", "0-2"], - True, - predicted_game_indexes=[1, 2], - pending_partial_approval=True, - ) - - 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() - - assert _has_button(view, "Review Late") is False - - @pytest.mark.asyncio - async def test_unified_panel_review_pending_button_jumps_to_pending_submission( - self, - admin_cog, - mock_interaction_admin, - sample_games, - ): - fixture_id = await admin_cog.db.create_fixture( - "111111", 56, sample_games, datetime.now(UTC) + timedelta(days=1) - ) - await admin_cog.db.save_results(fixture_id, ["1-0", "1-1", "0-0"]) - await admin_cog.db.save_prediction( - fixture_id, - "111", - "User One", - ["1-1", "0-2"], - True, - predicted_game_indexes=[1, 2], - pending_partial_approval=True, - ) - - 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() - - review_button = _get_button(view, "Review Late") - await review_button.callback(mock_interaction_admin) - - assert view.selection.fixture_label == "Week 56 [OPEN]" - assert view.selection.user_id == "111" - assert _has_button(view, "Approve Late") is True - assert _has_button(view, "Reject Late") is True - assert _has_button(view, "Enter Results") is False - assert _has_button(view, "Correct Results") is True - - @pytest.mark.asyncio - async def test_unified_panel_review_pending_button_cycles_pending_submissions( - self, - admin_cog, - mock_interaction_admin, - sample_games, - ): - fixture_a = await admin_cog.db.create_fixture( - "111111", 57, sample_games, datetime.now(UTC) + timedelta(days=1) - ) - fixture_b = await admin_cog.db.create_fixture( - "111111", 58, sample_games, datetime.now(UTC) + timedelta(days=1) - ) - await admin_cog.db.save_prediction( - fixture_a, - "111", - "User One", - ["1-1", "0-2"], - True, - predicted_game_indexes=[1, 2], - pending_partial_approval=True, - ) - await admin_cog.db.save_prediction( - fixture_b, - "222", - "User Two", - ["2-1"], - True, - predicted_game_indexes=[0], - pending_partial_approval=True, - ) - - 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._refresh_items() - - review_button = _get_button(view, "Review Late") - await review_button.callback(mock_interaction_admin) - first_selection = (view.selection.fixture_id, view.selection.user_id) - - await review_button.callback(mock_interaction_admin) - second_selection = (view.selection.fixture_id, view.selection.user_id) - - assert first_selection != second_selection - - @pytest.mark.asyncio - async def test_unified_panel_create_fixture_button_uses_parent_channel_from_thread( - self, - admin_cog, - mock_interaction_admin, - ): - parent_channel = MagicMock(spec=discord.TextChannel) - parent_channel.id = 123456 - thread = MagicMock(spec=discord.Thread) - thread.parent = parent_channel - mock_interaction_admin.channel = thread - - view = UnifiedAdminPanelView( - admin_cog.db, - admin_cog.service, - str(mock_interaction_admin.user.id), - "111111", - admin_commands=admin_cog, - bot=admin_cog.bot, - ) - create_button = _get_button(view, "Create Fixture") - await create_button.callback(mock_interaction_admin) - - assert mock_interaction_admin.modal_sent["modal"].channel is parent_channel - - @pytest.mark.asyncio - async def test_unified_panel_create_fixture_button_rejects_invalid_context( - self, - admin_cog, - mock_interaction_admin, - ): - mock_interaction_admin.channel = MagicMock() - - view = UnifiedAdminPanelView( - admin_cog.db, - admin_cog.service, - str(mock_interaction_admin.user.id), - "111111", - admin_commands=admin_cog, - bot=admin_cog.bot, - ) - create_button = _get_button(view, "Create Fixture") - await create_button.callback(mock_interaction_admin) - - assert "text channel" in mock_interaction_admin.response_sent[-1]["content"].lower() - - @pytest.mark.asyncio - async def test_unified_panel_enter_results_button_opens_modal( - self, - admin_cog, - mock_interaction_admin, - sample_games, - ): - fixture_id = await admin_cog.db.create_fixture( - "111111", 44, sample_games, datetime.now(UTC) + timedelta(days=1) - ) - 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) - - enter_button = _get_button(view, "Enter Results") - await enter_button.callback(mock_interaction_admin) - - assert isinstance(mock_interaction_admin.modal_sent["modal"], EnterResultsModal) - - @pytest.mark.asyncio - async def test_unified_panel_hides_enter_results_button_after_results_are_saved( - self, - admin_cog, - mock_interaction_admin, - sample_games, - ): - fixture_id = await admin_cog.db.create_fixture( - "111111", 46, sample_games, datetime.now(UTC) + timedelta(days=1) - ) - await admin_cog.db.save_results(fixture_id, ["1-0", "1-1", "0-0"]) - 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) - - assert _has_button(view, "Enter Results") is False - assert _has_button(view, "Correct Results") is True - - @pytest.mark.asyncio - async def test_unified_panel_calculate_scores_button_posts_results( - self, - admin_cog, - mock_interaction_admin, - sample_games, - ): - fixture_id = await admin_cog.db.create_fixture( - "111111", 45, sample_games, datetime.now(UTC) + timedelta(days=1) - ) - await admin_cog.db.save_results(fixture_id, ["2-1", "1-1", "0-2"]) - await admin_cog.db.save_prediction( - fixture_id, - "111", - "User One", - ["2-1", "1-1", "0-2"], - False, - ) - command_channel = MagicMock(spec=discord.TextChannel) - command_channel.id = 999999 - command_channel.send = AsyncMock() - league_channel = MagicMock(spec=discord.TextChannel) - league_channel.id = 123456 - league_channel.send = AsyncMock() - mock_interaction_admin.channel = command_channel - admin_cog.bot.get_channel.return_value = None - admin_cog.bot.fetch_channel = AsyncMock(return_value=league_channel) - mock_interaction_admin.message = MagicMock() - mock_interaction_admin.message.edit = AsyncMock() - admin_cog._create_backup = 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) - - admin_cog.bot.get_channel.assert_called_with(123456) - admin_cog.bot.fetch_channel.assert_awaited_once_with(123456) - assert ( - admin_cog.get_calculate_cooldown("111111", str(mock_interaction_admin.user.id)) - is not None - ) - league_channel.send.assert_awaited_once() - command_channel.send.assert_not_awaited() - assert ( - "Week 45 results calculated and posted to the league channel" - in mock_interaction_admin.response_sent[-1]["content"] - ) - assert "User One" in league_channel.send.call_args.args[0] - assert view.selection.fixture_label == "Week 45 [CLOSED]" - assert _has_button(view, "Scoring Rules") is False - assert _has_button(view, "Calculate Scores") is False - assert _has_button(view, "Delete Fixture") is False - mock_interaction_admin.message.edit.assert_awaited_once_with( - content=view.render_content(), view=view - ) - - @pytest.mark.asyncio - async def test_unified_panel_calculate_scores_button_rejects_unavailable_league_channel( - self, - admin_cog, - mock_interaction_admin, - sample_games, - ): - fixture_id = await admin_cog.db.create_fixture( - "111111", 46, sample_games, datetime.now(UTC) + timedelta(days=1) - ) - await admin_cog.db.save_results(fixture_id, ["2-1", "1-1", "0-2"]) - await admin_cog.db.save_prediction( - fixture_id, - "111", - "User One", - ["2-1", "1-1", "0-2"], - False, - ) - command_channel = MagicMock(spec=discord.TextChannel) - command_channel.send = AsyncMock() - mock_interaction_admin.channel = command_channel - admin_cog.bot.get_channel.return_value = None - admin_cog.bot.fetch_channel = AsyncMock( - side_effect=discord.InvalidData("unknown channel type") - ) - mock_interaction_admin.message = MagicMock() - mock_interaction_admin.message.edit = AsyncMock() - admin_cog._create_backup = 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) - - command_channel.send.assert_not_awaited() - assert ( - "configured league channel is unavailable" - in mock_interaction_admin.response_sent[-1]["content"].lower() - ) - - @pytest.mark.asyncio - async def test_stale_calculate_scores_button_refreshes_when_fixture_already_scored( - self, - admin_cog, - mock_interaction_admin, - sample_games, - ): - fixture_id = await admin_cog.db.create_fixture( - "111111", 45, sample_games, datetime.now(UTC) + timedelta(days=1) - ) - await admin_cog.db.save_results(fixture_id, ["2-1", "1-1", "0-2"]) - await admin_cog.db.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() - admin_cog._create_backup = AsyncMock() - admin_cog._post_calculation_to_channel = 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) - stale_button = _get_button(view, "Calculate Scores") - await admin_cog.db.recalculate_fixture_scores(fixture_id) - - await stale_button.callback(mock_interaction_admin) - - assert ( - mock_interaction_admin.response_sent[-1]["content"] == "That fixture is no longer open." - ) - admin_cog._create_backup.assert_not_awaited() - admin_cog._post_calculation_to_channel.assert_not_awaited() - assert view.selection.fixture_label == "Week 45 [CLOSED]" - assert _has_button(view, "Scoring Rules") is False - assert _has_button(view, "Calculate Scores") is False - assert _has_button(view, "Delete Fixture") is False - mock_interaction_admin.message.edit.assert_awaited_once_with( - content=view.render_content(), view=view - ) - - @pytest.mark.asyncio - async def test_stale_scoring_rules_button_refreshes_when_scores_now_exist( - self, - admin_cog, - mock_interaction_admin, - sample_games, - ): - fixture_id = await admin_cog.db.create_fixture( - "111111", 45, sample_games, datetime.now(UTC) + timedelta(days=1) - ) - await admin_cog.db.save_results(fixture_id, ["2-1", "1-1", "0-2"]) - await admin_cog.db.save_prediction( - fixture_id, - "111", - "User One", - ["2-1", "1-1", "0-2"], - False, - ) - 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() - stale_button = _get_button(view, "Scoring Rules") - await admin_cog.db.recalculate_fixture_scores(fixture_id) - - await stale_button.callback(mock_interaction_admin) - - assert not hasattr(mock_interaction_admin, "modal_sent") - assert "Scoring rules are locked" in mock_interaction_admin.response_sent[-1]["content"] - assert _has_button(view, "Scoring Rules") is False - - @pytest.mark.asyncio - async def test_unified_panel_calculate_scores_button_rejects_active_cooldown( - self, - admin_cog, - mock_interaction_admin, - sample_games, - ): - fixture_id = await admin_cog.db.create_fixture( - "111111", 47, sample_games, datetime.now(UTC) + timedelta(days=1) - ) - await admin_cog.db.save_results(fixture_id, ["1-0", "1-1", "0-0"]) - admin_cog.record_calculate_cooldown( - "111111", str(mock_interaction_admin.user.id), current_time=now().timestamp() - ) - admin_cog.service.calculate_fixture_scores = 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 "Please wait" in mock_interaction_admin.response_sent[-1]["content"] - - @pytest.mark.asyncio - async def test_unified_panel_calculate_scores_button_handles_service_error( - self, - admin_cog, - mock_interaction_admin, - sample_games, - ): - fixture_id = await admin_cog.db.create_fixture( - "111111", 48, sample_games, datetime.now(UTC) + timedelta(days=1) - ) - await admin_cog.db.save_results(fixture_id, ["1-0", "1-1", "0-0"]) - admin_cog._create_backup = 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 ( - mock_interaction_admin.response_sent[-1]["content"] - == "No predictions found for this fixture" - ) - - @pytest.mark.asyncio - async def test_unified_panel_post_results_button_opens_confirmation( - self, - admin_cog, - mock_interaction_admin, - ): - command_channel = MagicMock(spec=discord.TextChannel) - command_channel.id = 999999 - league_channel = MagicMock(spec=discord.TextChannel) - league_channel.id = 123456 - league_channel.send = AsyncMock() - mock_interaction_admin.channel = command_channel - admin_cog.bot.get_channel.return_value = None - admin_cog.bot.fetch_channel = AsyncMock(return_value=league_channel) - admin_cog.db.get_last_fixture_scores = AsyncMock( - return_value={ - "week_number": 1, - "games": ["A - B"], - "results": ["2-1"], - "scores": [ - { - "user_id": "123", - "user_name": "User1", - "points": 3, - "exact_scores": 1, - "correct_results": 1, - } - ], - } - ) - admin_cog.db.get_standings = AsyncMock( - return_value=[ - { - "user_id": "123", - "user_name": "User1", - "total_points": 3, - "total_exact": 1, - "total_correct": 1, - } - ] - ) - - 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) - - admin_cog.bot.get_channel.assert_called_with(123456) - admin_cog.bot.fetch_channel.assert_awaited_once_with(123456) - confirm_view = mock_interaction_admin.response_sent[-1]["view"] - assert isinstance(confirm_view, PostResultsConfirmView) - assert confirm_view.channel is league_channel - - @pytest.mark.asyncio - async def test_unified_panel_post_results_only_previews_current_guild_scores( - self, - admin_cog, - mock_interaction_admin, - ): - channel = MagicMock(spec=discord.TextChannel) - channel.id = mock_interaction_admin.channel.id - channel.send = AsyncMock() - mock_interaction_admin.channel = channel - admin_cog.bot.get_channel.return_value = channel - deadline = datetime.now(UTC) - timedelta(days=1) - current_fixture_id = await admin_cog.db.create_fixture( - "111111", 1, ["Team A - Team B"], deadline - ) - other_fixture_id = await admin_cog.db.create_fixture( - "guild-2", 2, ["Team C - Team D"], deadline - ) - await admin_cog.db.save_scores( - current_fixture_id, - [ - { - "user_id": "current-user", - "user_name": "Current Guild", - "points": 3, - "exact_scores": 1, - "correct_results": 0, - } - ], - ) - await admin_cog.db.save_scores( - other_fixture_id, - [ - { - "user_id": "other-user", - "user_name": "Other Guild", - "points": 9, - "exact_scores": 3, - "correct_results": 3, - } - ], - ) - - 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) - - content = mock_interaction_admin.response_sent[-1]["content"] - assert "Current Guild" in content - assert "Other Guild" not in content - - @pytest.mark.asyncio - async def test_unified_panel_jump_to_week_reaches_older_open_fixture( - self, - admin_cog, - mock_interaction_admin, - sample_games, - ): - deadline = datetime.now(UTC) + timedelta(days=1) - first_fixture_id = None - for week in range(1, 28): - fixture_id = await admin_cog.db.create_fixture("111111", week, sample_games, deadline) - if week == 1: - first_fixture_id = fixture_id - assert first_fixture_id is not None - await admin_cog.db.save_results(first_fixture_id, ["1-0", "1-1", "0-0"]) - - 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() - - assert all(option.label != "Week 1 [OPEN]" for option in view.fixture_select.options) - - jump_button = _get_button(view, "Jump To Week") - await jump_button.callback(mock_interaction_admin) - modal = mock_interaction_admin.modal_sent["modal"] - modal.week_input._value = "1" - - await modal.on_submit(mock_interaction_admin) - - assert view.selection.fixture_label == "Week 1 [OPEN]" - assert "Fixture: Week 1 [OPEN]" in mock_interaction_admin.response_sent[-1]["content"] - assert _has_button(view, "Enter Results") is False - assert _has_button(view, "Calculate Scores") is True - assert _has_button(view, "Correct Results") is True - - @pytest.mark.asyncio - async def test_unified_panel_jump_to_week_rejects_invalid_input( - self, - admin_cog, - mock_interaction_admin, - ): - view = UnifiedAdminPanelView( - admin_cog.db, - admin_cog.service, - str(mock_interaction_admin.user.id), - "111111", - admin_commands=admin_cog, - bot=admin_cog.bot, - ) - jump_button = _get_button(view, "Jump To Week") - await jump_button.callback(mock_interaction_admin) - modal = mock_interaction_admin.modal_sent["modal"] - modal.week_input._value = "abc" - - await modal.on_submit(mock_interaction_admin) - - assert "whole number" in mock_interaction_admin.response_sent[-1]["content"] - assert view.selection.fixture_id is None - - @pytest.mark.asyncio - async def test_unified_panel_jump_to_week_rejects_duplicate_open_weeks( - self, - admin_cog, - mock_interaction_admin, - sample_games, - ): - deadline = datetime.now(UTC) + timedelta(days=1) - await admin_cog.db.create_fixture("111111", 5, sample_games, deadline) - await admin_cog.db.create_fixture("111111", 5, sample_games, deadline) - - view = UnifiedAdminPanelView( - admin_cog.db, - admin_cog.service, - str(mock_interaction_admin.user.id), - "111111", - admin_commands=admin_cog, - bot=admin_cog.bot, - ) - jump_button = _get_button(view, "Jump To Week") - await jump_button.callback(mock_interaction_admin) - modal = mock_interaction_admin.modal_sent["modal"] - modal.week_input._value = "5" - - await modal.on_submit(mock_interaction_admin) - - assert "More than one open fixture" in mock_interaction_admin.response_sent[-1]["content"] - assert view.selection.fixture_id is None - - @pytest.mark.asyncio - async def test_unified_panel_post_results_button_rejects_unavailable_league_channel( - self, - admin_cog, - mock_interaction_admin, - ): - admin_cog.db.get_last_fixture_scores = AsyncMock(return_value={"scores": []}) - admin_cog.db.get_standings = AsyncMock(return_value=[]) - admin_cog.bot.get_channel.return_value = None - admin_cog.bot.fetch_channel = AsyncMock( - side_effect=discord.InvalidData("unknown channel type") - ) - - 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) - - assert ( - "configured league channel is unavailable" - in mock_interaction_admin.response_sent[-1]["content"].lower() - ) - - @pytest.mark.asyncio - async def test_unified_panel_post_results_button_rejects_missing_scores( - self, - admin_cog, - mock_interaction_admin, - ): - channel = MagicMock(spec=discord.TextChannel) - channel.id = mock_interaction_admin.channel.id - mock_interaction_admin.channel = channel - admin_cog.db.get_last_fixture_scores = AsyncMock(return_value=None) - - 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) - - assert "No completed fixtures found" in mock_interaction_admin.response_sent[-1]["content"] - - @pytest.mark.asyncio - async def test_fixture_panel_delete_confirm_shows_error_on_db_failure( - self, - admin_cog, - mock_interaction_admin, - sample_games, - ): - """Silent DB failures surface as a visible error instead of timing out the interaction.""" - fixture_id = await admin_cog.db.create_fixture( - "111111", 7, sample_games, datetime.now(UTC) + timedelta(days=1) - ) - - db_mock = AsyncMock(spec=Database) - db_mock.get_guild_config.return_value = { - "admin_role_id": str( - mock_interaction_admin.guild.get_member(mock_interaction_admin.user.id).roles[0].id - ), - "league_channel_id": "123456", - } - db_mock.delete_fixture.side_effect = RuntimeError("DB locked") - - confirm_view = DeleteConfirmView( - db_mock, - str(mock_interaction_admin.user.id), - "111111", - fixture_id, - week_number=7, - ) - confirm_button = next( - child - for child in confirm_view.children - if getattr(child, "label", None) == "Yes, Delete" - ) - await confirm_button.callback(mock_interaction_admin) - - response = mock_interaction_admin.response_sent[-1] - assert "Failed to delete" in response["content"] - assert response.get("view") is None - - -class TestDiscordCleanup: - """_cleanup_discord_announcement should delete thread+message, tolerating Discord errors.""" - - @pytest.mark.asyncio - async def test_cleanup_deletes_thread_and_message(self): - bot = MagicMock(spec=discord.Client) - mock_thread = AsyncMock() - mock_message = AsyncMock() - mock_message.thread = mock_thread - channel = AsyncMock() - channel.fetch_message = AsyncMock(return_value=mock_message) - bot.get_channel.return_value = channel - - await _cleanup_discord_announcement(bot, "111", "222", week_number=5) - - mock_thread.delete.assert_called_once() - mock_message.delete.assert_called_once() - - @pytest.mark.asyncio - async def test_cleanup_no_thread_deletes_message_only(self): - bot = MagicMock(spec=discord.Client) - mock_message = AsyncMock() - mock_message.thread = None - channel = AsyncMock() - channel.fetch_message = AsyncMock(return_value=mock_message) - bot.get_channel.return_value = channel - - await _cleanup_discord_announcement(bot, "111", "222", week_number=5) - - mock_message.delete.assert_called_once() - - @pytest.mark.asyncio - async def test_cleanup_swallows_discord_errors(self): - bot = MagicMock(spec=discord.Client) - channel = AsyncMock() - channel.fetch_message.side_effect = Exception("Discord unavailable") - bot.get_channel.return_value = channel - - await _cleanup_discord_announcement(bot, "111", "222", week_number=5) diff --git a/tests/test_database.py b/tests/test_database.py deleted file mode 100644 index c52ab95..0000000 --- a/tests/test_database.py +++ /dev/null @@ -1,2202 +0,0 @@ -"""Tests for database operations and defensive coding patterns.""" - -import asyncio -import tempfile -from datetime import UTC, datetime, timedelta -from pathlib import Path - -import aiosqlite -import pytest - -from typer_bot.database import Database, SaveResult -from typer_bot.database import scores as scores_module -from typer_bot.database.predictions import PredictionRepository - - -@pytest.fixture -def temp_db_path(): - """Provide a temporary database file path.""" - with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f: - path = f.name - yield path - Path(path).unlink(missing_ok=True) - - -async def _start_new_active_season(db_path: str, guild_id: str, name: str = "Next Season") -> int: - async with aiosqlite.connect(db_path) as conn: - await conn.execute( - "UPDATE seasons SET status = 'archived' WHERE guild_id = ? AND status = 'active'", - (guild_id,), - ) - cursor = await conn.execute( - "INSERT INTO seasons (guild_id, name, status) VALUES (?, ?, 'active')", - (guild_id, name), - ) - if cursor.lastrowid is None: - raise RuntimeError("Failed to create test season") - await conn.execute( - "UPDATE guild_config SET active_season_id = ? WHERE guild_id = ?", - (cursor.lastrowid, guild_id), - ) - await conn.commit() - return cursor.lastrowid - - -class TestGetMaxWeekNumber: - """Test suite for get_max_week_number method.""" - - @pytest.mark.asyncio - async def test_get_max_week_number_empty_db(self, temp_db_path): - """Should return 0 when no fixtures exist.""" - db = Database(temp_db_path) - await db.initialize() - - result = await db.get_max_week_number("111111") - assert result == 0 - - @pytest.mark.asyncio - async def test_get_max_week_number_with_fixtures(self, temp_db_path): - """Should return maximum week number from existing fixtures.""" - db = Database(temp_db_path) - await db.initialize() - - await db.create_fixture("111111", 1, ["Team A - Team B"], datetime.now(UTC)) - await db.create_fixture("111111", 3, ["Team C - Team D"], datetime.now(UTC)) - await db.create_fixture("111111", 5, ["Team E - Team F"], datetime.now(UTC)) - - result = await db.get_max_week_number("111111") - assert result == 5 - - @pytest.mark.asyncio - async def test_get_max_week_number_closed_fixtures(self, temp_db_path): - """Should include closed fixtures in maximum calculation.""" - db = Database(temp_db_path) - await db.initialize() - - fixture_id = await db.create_fixture("111111", 10, ["Team A - Team B"], datetime.now(UTC)) - await db.save_scores( - fixture_id, - [ - { - "user_id": "123", - "user_name": "Test", - "points": 3, - "exact_scores": 1, - "correct_results": 0, - } - ], - ) - - await db.create_fixture("111111", 5, ["Team C - Team D"], datetime.now(UTC)) - - result = await db.get_max_week_number("111111") - assert result == 10 - - @pytest.mark.asyncio - async def test_get_max_week_number_is_active_season_scoped(self, temp_db_path): - db = Database(temp_db_path) - await db.initialize() - await db.create_fixture("111111", 10, ["Team A - Team B"], datetime.now(UTC)) - await _start_new_active_season(temp_db_path, "111111") - - assert await db.get_max_week_number("111111") == 0 - - await db.create_fixture("111111", 1, ["Team C - Team D"], datetime.now(UTC)) - - assert await db.get_max_week_number("111111") == 1 - - -class TestGuildConfig: - @pytest.mark.asyncio - async def test_guild_config_persists_and_updates(self, temp_db_path): - db = Database(temp_db_path) - await db.initialize() - - assert await db.get_guild_config("111111") is None - - await db.upsert_guild_config("111111", "role-1", "channel-1") - config = await db.get_guild_config("111111") - assert config["admin_role_id"] == "role-1" - assert config["league_channel_id"] == "channel-1" - - await db.upsert_guild_config("111111", "role-2", "channel-2") - updated = await db.get_guild_config("111111") - assert updated["admin_role_id"] == "role-2" - assert updated["league_channel_id"] == "channel-2" - - @pytest.mark.asyncio - async def test_guild_config_is_per_guild(self, temp_db_path): - db = Database(temp_db_path) - await db.initialize() - - await db.upsert_guild_config("111111", "role-1", "channel-1") - await db.upsert_guild_config("222222", "role-2", "channel-2") - - guild_one = await db.get_guild_config("111111") - guild_two = await db.get_guild_config("222222") - assert guild_one["admin_role_id"] == "role-1" - assert guild_two["admin_role_id"] == "role-2" - - -class TestSeasons: - @pytest.mark.asyncio - async def test_create_fixture_uses_fresh_guild_active_season(self, temp_db_path): - db = Database(temp_db_path) - await db.initialize() - await db.upsert_guild_config("111111", "role-1", "channel-1") - - first_fixture_id = await db.create_fixture( - "111111", 1, ["Team A - Team B"], datetime.now(UTC) - ) - second_fixture_id = await db.create_fixture( - "111111", 2, ["Team C - Team D"], datetime.now(UTC) - ) - - active_season = await db.get_active_season("111111") - first_fixture = await db.get_fixture_by_id(first_fixture_id, "111111") - second_fixture = await db.get_fixture_by_id(second_fixture_id, "111111") - config = await db.get_guild_config("111111") - - assert active_season is not None - assert active_season["guild_id"] == "111111" - assert active_season["name"] - assert active_season["status"] == "active" - assert first_fixture["season_id"] == active_season["id"] - assert second_fixture["season_id"] == active_season["id"] - assert config["active_season_id"] == active_season["id"] - - @pytest.mark.asyncio - async def test_active_seasons_are_guild_isolated(self, temp_db_path): - db = Database(temp_db_path) - await db.initialize() - - guild_one_fixture_id = await db.create_fixture( - "111111", 1, ["Team A - Team B"], datetime.now(UTC) - ) - guild_two_fixture_id = await db.create_fixture( - "222222", 1, ["Team C - Team D"], datetime.now(UTC) - ) - - guild_one_season = await db.get_active_season("111111") - guild_two_season = await db.get_active_season("222222") - guild_one_fixture = await db.get_fixture_by_id(guild_one_fixture_id, "111111") - guild_two_fixture = await db.get_fixture_by_id(guild_two_fixture_id, "222222") - - assert guild_one_season is not None - assert guild_two_season is not None - assert guild_one_season["guild_id"] == "111111" - assert guild_two_season["guild_id"] == "222222" - assert guild_one_season["id"] != guild_two_season["id"] - assert guild_one_fixture["season_id"] == guild_one_season["id"] - assert guild_two_fixture["season_id"] == guild_two_season["id"] - - @pytest.mark.asyncio - async def test_get_or_create_active_season_repairs_stale_config_pointer(self, temp_db_path): - db = Database(temp_db_path) - await db.initialize() - await db.upsert_guild_config("111111", "role-1", "channel-1") - fixture_id = await db.create_fixture("111111", 1, ["Team A - Team B"], datetime.now(UTC)) - fixture = await db.get_fixture_by_id(fixture_id, "111111") - - async with aiosqlite.connect(temp_db_path) as conn: - cursor = await conn.execute( - "INSERT INTO seasons (guild_id, name, status) VALUES ('222222', 'Wrong Guild', 'active')" - ) - await conn.execute( - "UPDATE guild_config SET active_season_id = ? WHERE guild_id = '111111'", - (cursor.lastrowid,), - ) - await conn.commit() - - active_season = await db.get_or_create_active_season("111111") - config = await db.get_guild_config("111111") - - assert active_season["id"] == fixture["season_id"] - assert config["active_season_id"] == active_season["id"] - - @pytest.mark.asyncio - async def test_create_next_fixture_restarts_week_numbers_per_active_season(self, temp_db_path): - db = Database(temp_db_path) - await db.initialize() - _old_fixture_id, old_week = await db.create_next_fixture( - "111111", ["Team A - Team B"], datetime.now(UTC) - ) - await _start_new_active_season(temp_db_path, "111111") - - new_fixture_id, new_week = await db.create_next_fixture( - "111111", ["Team C - Team D"], datetime.now(UTC) - ) - new_fixture = await db.get_fixture_by_id(new_fixture_id, "111111") - active_season = await db.get_active_season("111111") - - assert old_week == 1 - assert new_week == 1 - assert new_fixture["season_id"] == active_season["id"] - - @pytest.mark.asyncio - async def test_start_new_season_archives_previous_active_season(self, temp_db_path): - db = Database(temp_db_path) - await db.initialize() - await db.upsert_guild_config("111111", "role-1", "channel-1") - old_fixture_id = await db.create_fixture( - "111111", 7, ["Team A - Team B"], datetime.now(UTC) - ) - await db.save_scores( - old_fixture_id, - [ - { - "user_id": "user-1", - "user_name": "User One", - "points": 3, - "exact_scores": 1, - "correct_results": 0, - } - ], - ) - old_season = await db.get_active_season("111111") - - new_season = await db.start_new_season("111111", "2026/27") - config = await db.get_guild_config("111111") - seasons = await db.get_seasons("111111") - - assert old_season is not None - assert new_season["name"] == "2026/27" - assert new_season["status"] == "active" - assert config["active_season_id"] == new_season["id"] - assert [(season["id"], season["status"]) for season in seasons] == [ - (old_season["id"], "archived"), - (new_season["id"], "active"), - ] - assert seasons[0]["ended_at"] is not None - - @pytest.mark.asyncio - async def test_start_new_season_blocks_open_active_fixture(self, temp_db_path): - db = Database(temp_db_path) - await db.initialize() - await db.create_fixture("111111", 1, ["Team A - Team B"], datetime.now(UTC)) - old_season = await db.get_active_season("111111") - - with pytest.raises(ValueError, match="Close all open fixtures"): - await db.start_new_season("111111", "2026/27") - - assert await db.get_active_season("111111") == old_season - - @pytest.mark.asyncio - async def test_start_new_season_rejects_blank_name_without_mutating(self, temp_db_path): - db = Database(temp_db_path) - await db.initialize() - old_fixture_id = await db.create_fixture( - "111111", 1, ["Team A - Team B"], datetime.now(UTC) - ) - await db.save_scores( - old_fixture_id, - [ - { - "user_id": "user-1", - "user_name": "User One", - "points": 1, - "exact_scores": 0, - "correct_results": 1, - } - ], - ) - old_season = await db.get_active_season("111111") - - with pytest.raises(ValueError, match="Season name is required"): - await db.start_new_season("111111", " ") - - assert await db.get_active_season("111111") == old_season - assert await db.get_seasons("111111") == [old_season] - - @pytest.mark.asyncio - async def test_start_new_season_rolls_back_when_new_season_insert_fails(self, temp_db_path): - db = Database(temp_db_path) - await db.initialize() - old_fixture_id = await db.create_fixture( - "111111", 1, ["Team A - Team B"], datetime.now(UTC) - ) - await db.save_scores( - old_fixture_id, - [ - { - "user_id": "user-1", - "user_name": "User One", - "points": 1, - "exact_scores": 0, - "correct_results": 1, - } - ], - ) - old_season = await db.get_active_season("111111") - async with aiosqlite.connect(temp_db_path) as conn: - await conn.execute( - """ - CREATE TRIGGER fail_broken_season_insert - BEFORE INSERT ON seasons - WHEN NEW.name = 'Broken Season' - BEGIN - SELECT RAISE(FAIL, 'broken season insert'); - END - """ - ) - await conn.commit() - - with pytest.raises(aiosqlite.IntegrityError, match="broken season insert"): - await db.start_new_season("111111", "Broken Season") - - assert await db.get_active_season("111111") == old_season - assert await db.get_seasons("111111") == [old_season] - - @pytest.mark.asyncio - async def test_start_new_season_resets_next_fixture_week(self, temp_db_path): - db = Database(temp_db_path) - await db.initialize() - old_fixture_id, old_week = await db.create_next_fixture( - "111111", ["Team A - Team B"], datetime.now(UTC) - ) - await db.save_scores( - old_fixture_id, - [ - { - "user_id": "user-1", - "user_name": "User One", - "points": 1, - "exact_scores": 0, - "correct_results": 1, - } - ], - ) - - await db.start_new_season("111111", "2026/27") - new_fixture_id, new_week = await db.create_next_fixture( - "111111", ["Team C - Team D"], datetime.now(UTC) - ) - new_fixture = await db.get_fixture_by_id(new_fixture_id, "111111") - active_season = await db.get_active_season("111111") - - assert old_week == 1 - assert new_week == 1 - assert new_fixture["season_id"] == active_season["id"] - - @pytest.mark.asyncio - async def test_start_new_season_uses_fresh_default_scoring_rules(self, temp_db_path): - db = Database(temp_db_path) - await db.initialize() - custom_rules = { - "exact_score_points": 5, - "correct_outcome_points": 2, - "wrong_outcome_points": 1, - "late_prediction_points": 1, - } - await db.update_active_scoring_rules("111111", custom_rules) - - await db.start_new_season("111111", "Next Season") - - seasons = await db.get_seasons("111111") - active_rules = await db.get_active_scoring_rules("111111") - assert seasons[0]["scoring_rules"] == custom_rules - assert active_rules == { - "exact_score_points": 3, - "correct_outcome_points": 1, - "wrong_outcome_points": 0, - "late_prediction_points": 0, - } - - @pytest.mark.asyncio - async def test_scoring_rule_updates_preserve_omitted_values(self, temp_db_path): - db = Database(temp_db_path) - await db.initialize() - await db.update_active_scoring_rules( - "111111", - { - "exact_score_points": 5, - "correct_outcome_points": 2, - "wrong_outcome_points": 1, - "late_prediction_points": 1, - }, - ) - - await db.update_active_scoring_rules("111111", {"late_prediction_points": 2}) - - assert await db.get_active_scoring_rules("111111") == { - "exact_score_points": 5, - "correct_outcome_points": 2, - "wrong_outcome_points": 1, - "late_prediction_points": 2, - } - - @pytest.mark.asyncio - @pytest.mark.parametrize( - ("rules", "message"), - [ - ({"exact_score_points": -1}, "zero or greater"), - ({"exact_score_points": "many"}, "whole numbers"), - ({"exact_points": 5}, "Unknown scoring rule"), - ], - ) - async def test_invalid_scoring_rule_updates_do_not_mutate_existing_rules( - self, temp_db_path, rules, message - ): - db = Database(temp_db_path) - await db.initialize() - await db.update_active_scoring_rules("111111", {"exact_score_points": 5}) - existing_rules = await db.get_active_scoring_rules("111111") - - with pytest.raises(ValueError, match=message): - await db.update_active_scoring_rules("111111", rules) - - assert await db.get_active_scoring_rules("111111") == existing_rules - - @pytest.mark.asyncio - async def test_fixture_queries_default_to_active_season(self, temp_db_path): - db = Database(temp_db_path) - await db.initialize() - old_fixture_id = await db.create_fixture( - "111111", 1, ["Old Team A - Old Team B"], datetime.now(UTC) - ) - await db.update_fixture_announcement(old_fixture_id, "old-message", "channel-1") - await _start_new_active_season(temp_db_path, "111111") - active_fixture_id = await db.create_fixture( - "111111", 1, ["New Team A - New Team B"], datetime.now(UTC) - ) - await db.update_fixture_announcement(active_fixture_id, "new-message", "channel-1") - - current_fixture = await db.get_current_fixture("111111") - open_fixtures = await db.get_open_fixtures("111111") - recent_fixtures = await db.get_recent_fixtures("111111") - week_fixture = await db.get_open_fixture_by_week("111111", 1) - any_status_week_fixture = await db.get_fixture_by_week("111111", 1) - message_fixture = await db.get_fixture_by_message_id("new-message", "111111") - global_message_fixture = await db.get_fixture_by_message_id("new-message") - - assert await db.get_fixture_by_id(old_fixture_id, "111111") is None - assert current_fixture["id"] == active_fixture_id - assert [fixture["id"] for fixture in open_fixtures] == [active_fixture_id] - assert [fixture["id"] for fixture in recent_fixtures] == [active_fixture_id] - assert week_fixture["id"] == active_fixture_id - assert any_status_week_fixture["id"] == active_fixture_id - assert message_fixture["id"] == active_fixture_id - assert global_message_fixture["id"] == active_fixture_id - assert await db.get_fixture_by_message_id("old-message", "111111") is None - assert await db.get_fixture_by_message_id("old-message") is None - - @pytest.mark.asyncio - async def test_all_open_fixtures_only_returns_active_season_fixtures(self, temp_db_path): - db = Database(temp_db_path) - await db.initialize() - await db.create_fixture("111111", 1, ["Old Team A - Old Team B"], datetime.now(UTC)) - await _start_new_active_season(temp_db_path, "111111") - active_fixture_id = await db.create_fixture( - "111111", 1, ["New Team A - New Team B"], datetime.now(UTC) - ) - other_guild_fixture_id = await db.create_fixture( - "222222", 1, ["Other Team A - Other Team B"], datetime.now(UTC) - ) - - open_fixture_ids = [fixture["id"] for fixture in await db.get_all_open_fixtures()] - - assert set(open_fixture_ids) == {active_fixture_id, other_guild_fixture_id} - - @pytest.mark.asyncio - async def test_archived_fixture_prediction_writes_are_rejected(self, temp_db_path): - db = Database(temp_db_path) - await db.initialize() - old_fixture_id = await db.create_fixture( - "111111", 1, ["Old Team A - Old Team B"], datetime.now(UTC) - ) - await db.save_prediction(old_fixture_id, "user-1", "User One", ["1-0"], False) - await _start_new_active_season(temp_db_path, "111111") - - first_write = await db.try_save_prediction(old_fixture_id, "user-2", "User Two", ["2-0"]) - guarded_write = await db.save_prediction_guarded( - old_fixture_id, "user-1", "User One", ["9-9"] - ) - admin_write = await db.admin_update_prediction_with_recalc( - old_fixture_id, "user-1", ["8-8"], "admin-1" - ) - - assert first_write == SaveResult.FIXTURE_CLOSED - assert guarded_write == SaveResult.FIXTURE_CLOSED - assert admin_write is False - assert await db.get_prediction(old_fixture_id, "user-1", "111111") is None - async with ( - aiosqlite.connect(temp_db_path) as conn, - conn.execute( - "SELECT predictions FROM predictions WHERE fixture_id = ? AND user_id = ?", - (old_fixture_id, "user-1"), - ) as cursor, - ): - row = await cursor.fetchone() - assert row == ("1-0",) - - @pytest.mark.asyncio - async def test_archived_pending_partials_are_hidden_and_not_mutated(self, temp_db_path): - db = Database(temp_db_path) - await db.initialize() - old_fixture_id = await db.create_fixture( - "111111", 1, ["Old Team A - Old Team B"], datetime.now(UTC) - ) - await db.save_prediction( - old_fixture_id, - "user-1", - "User One", - ["1-0"], - True, - pending_partial_approval=True, - ) - await _start_new_active_season(temp_db_path, "111111") - - approved = await db.approve_partial_prediction(old_fixture_id, "user-1", "admin-1") - rejected = await db.reject_partial_prediction(old_fixture_id, "user-1") - pending = await db.get_pending_partial_predictions("111111") - - assert approved is False - assert rejected is False - assert pending == [] - async with ( - aiosqlite.connect(temp_db_path) as conn, - conn.execute( - "SELECT pending_partial_approval FROM predictions WHERE fixture_id = ? AND user_id = ?", - (old_fixture_id, "user-1"), - ) as cursor, - ): - row = await cursor.fetchone() - assert row == (1,) - - @pytest.mark.asyncio - async def test_archived_fixture_result_and_score_writes_are_rejected(self, temp_db_path): - db = Database(temp_db_path) - await db.initialize() - old_fixture_id = await db.create_fixture( - "111111", 1, ["Old Team A - Old Team B"], datetime.now(UTC) - ) - await db.save_results(old_fixture_id, ["0-0"]) - await db.save_scores( - old_fixture_id, - [ - { - "user_id": "old-user", - "user_name": "Old User", - "points": 30, - "exact_scores": 10, - "correct_results": 0, - } - ], - ) - await _start_new_active_season(temp_db_path, "111111") - - with pytest.raises(ValueError): - await db.save_results(old_fixture_id, ["1-0"]) - with pytest.raises(ValueError): - await db.save_results_with_recalc(old_fixture_id, ["1-0"]) - with pytest.raises(ValueError): - await db.recalculate_fixture_scores(old_fixture_id) - with pytest.raises(ValueError): - await db.save_scores( - old_fixture_id, - [ - { - "user_id": "new-user", - "user_name": "New User", - "points": 1, - "exact_scores": 0, - "correct_results": 1, - } - ], - ) - - assert await db.get_results(old_fixture_id) == ["0-0"] - scores = await db.get_scores_for_fixture(old_fixture_id) - assert [(score["user_id"], score["points"]) for score in scores] == [("old-user", 30)] - - @pytest.mark.asyncio - async def test_archived_fixture_delete_requires_active_season(self, temp_db_path): - db = Database(temp_db_path) - await db.initialize() - old_fixture_id = await db.create_fixture( - "111111", 1, ["Old Team A - Old Team B"], datetime.now(UTC) - ) - await _start_new_active_season(temp_db_path, "111111") - - assert await db.delete_fixture(old_fixture_id, "111111") is False - async with ( - aiosqlite.connect(temp_db_path) as conn, - conn.execute("SELECT 1 FROM fixtures WHERE id = ?", (old_fixture_id,)) as cursor, - ): - assert await cursor.fetchone() == (1,) - - -class TestScores: - @pytest.mark.asyncio - async def test_result_correction_recalculates_with_active_season_rules(self, temp_db_path): - db = Database(temp_db_path) - await db.initialize() - await db.update_active_scoring_rules( - "111111", {"exact_score_points": 5, "correct_outcome_points": 2} - ) - fixture_id = await db.create_fixture("111111", 1, ["A - B"], datetime.now(UTC)) - await db.save_results(fixture_id, ["2-1"]) - await db.save_prediction(fixture_id, "user-1", "User One", ["2-1"], False) - await db.recalculate_fixture_scores(fixture_id) - - await db.save_results_with_recalc(fixture_id, ["2-0"]) - - scores = await db.get_scores_for_fixture(fixture_id) - assert scores[0]["points"] == 2 - - @pytest.mark.asyncio - async def test_prediction_replacement_recalculates_with_active_season_rules(self, temp_db_path): - db = Database(temp_db_path) - await db.initialize() - await db.update_active_scoring_rules( - "111111", {"exact_score_points": 5, "wrong_outcome_points": 1} - ) - fixture_id = await db.create_fixture("111111", 1, ["A - B"], datetime.now(UTC)) - await db.save_results(fixture_id, ["2-1"]) - await db.save_prediction(fixture_id, "user-1", "User One", ["2-1"], False) - await db.recalculate_fixture_scores(fixture_id) - - updated = await db.admin_update_prediction_with_recalc( - fixture_id, "user-1", ["1-2"], "admin-1" - ) - - scores = await db.get_scores_for_fixture(fixture_id) - assert updated is True - assert scores[0]["points"] == 1 - - @pytest.mark.asyncio - async def test_waiver_toggle_recalculates_with_active_season_rules(self, temp_db_path): - db = Database(temp_db_path) - await db.initialize() - await db.update_active_scoring_rules( - "111111", {"exact_score_points": 5, "late_prediction_points": 1} - ) - fixture_id = await db.create_fixture("111111", 1, ["A - B"], datetime.now(UTC)) - await db.save_results(fixture_id, ["2-1"]) - await db.save_prediction(fixture_id, "user-1", "User One", ["2-1"], True) - await db.recalculate_fixture_scores(fixture_id) - - waived = await db.toggle_late_penalty_waiver_with_recalc(fixture_id, "user-1") - - scores = await db.get_scores_for_fixture(fixture_id) - assert waived is True - assert scores[0]["points"] == 5 - - @pytest.mark.asyncio - async def test_partial_approval_recalculates_with_active_season_rules(self, temp_db_path): - db = Database(temp_db_path) - await db.initialize() - await db.update_active_scoring_rules("111111", {"exact_score_points": 5}) - fixture_id = await db.create_fixture("111111", 1, ["A - B"], datetime.now(UTC)) - await db.save_results(fixture_id, ["2-1"]) - await db.save_prediction(fixture_id, "user-1", "User One", ["2-1"], False) - await db.recalculate_fixture_scores(fixture_id) - await db.save_prediction( - fixture_id, - "partial", - "Partial User", - ["2-1"], - True, - predicted_game_indexes=[0], - pending_partial_approval=True, - ) - - approved = await db.approve_partial_prediction(fixture_id, "partial", "admin-1") - - scores = await db.get_scores_for_fixture(fixture_id) - assert approved is True - assert [(score["user_id"], score["points"]) for score in scores] == [ - ("partial", 5), - ("user-1", 5), - ] - - @pytest.mark.asyncio - async def test_partial_rejection_recalculates_with_active_season_rules(self, temp_db_path): - db = Database(temp_db_path) - await db.initialize() - await db.update_active_scoring_rules("111111", {"exact_score_points": 5}) - fixture_id = await db.create_fixture("111111", 1, ["A - B"], datetime.now(UTC)) - await db.save_results(fixture_id, ["2-1"]) - await db.save_prediction(fixture_id, "user-1", "User One", ["2-1"], False) - await db.save_prediction( - fixture_id, - "partial", - "Partial User", - ["2-1"], - True, - predicted_game_indexes=[0], - pending_partial_approval=True, - ) - await db.recalculate_fixture_scores(fixture_id) - - rejected = await db.reject_partial_prediction(fixture_id, "partial") - - scores = await db.get_scores_for_fixture(fixture_id) - assert rejected is True - assert [(score["user_id"], score["points"]) for score in scores] == [("user-1", 5)] - - @pytest.mark.asyncio - async def test_recalculate_fixture_scores_uses_active_season_rules(self, temp_db_path): - db = Database(temp_db_path) - await db.initialize() - await db.update_active_scoring_rules( - "111111", - { - "exact_score_points": 5, - "correct_outcome_points": 2, - "wrong_outcome_points": 1, - "late_prediction_points": 0, - }, - ) - fixture_id = await db.create_fixture( - "111111", 1, ["A - B", "C - D", "E - F"], datetime.now(UTC) - ) - await db.save_results(fixture_id, ["2-1", "1-1", "2-0"]) - await db.save_prediction( - fixture_id, - "user-1", - "User One", - ["2-1", "2-2", "0-2"], - False, - ) - - await db.recalculate_fixture_scores(fixture_id) - - scores = await db.get_scores_for_fixture(fixture_id) - assert scores[0]["points"] == 8 - assert scores[0]["exact_scores"] == 1 - assert scores[0]["correct_results"] == 1 - - @pytest.mark.asyncio - async def test_late_prediction_uses_active_season_penalty_rule(self, temp_db_path): - db = Database(temp_db_path) - await db.initialize() - await db.update_active_scoring_rules("111111", {"late_prediction_points": 1}) - fixture_id = await db.create_fixture("111111", 1, ["A - B"], datetime.now(UTC)) - await db.save_results(fixture_id, ["2-1"]) - await db.save_prediction(fixture_id, "late", "Late User", ["2-1"], True) - await db.save_prediction(fixture_id, "waived", "Waived User", ["2-1"], True) - await db.set_late_penalty_waiver(fixture_id, "waived", True) - - await db.recalculate_fixture_scores(fixture_id) - - scores = await db.get_scores_for_fixture(fixture_id) - assert [(score["user_id"], score["points"]) for score in scores] == [ - ("waived", 3), - ("late", 1), - ] - - @pytest.mark.asyncio - async def test_scoring_rules_are_guild_isolated(self, temp_db_path): - db = Database(temp_db_path) - await db.initialize() - await db.update_active_scoring_rules("111111", {"exact_score_points": 5}) - guild_one_fixture_id = await db.create_fixture("111111", 1, ["A - B"], datetime.now(UTC)) - guild_two_fixture_id = await db.create_fixture("222222", 1, ["A - B"], datetime.now(UTC)) - for fixture_id in (guild_one_fixture_id, guild_two_fixture_id): - await db.save_results(fixture_id, ["2-1"]) - await db.save_prediction(fixture_id, "user-1", "User One", ["2-1"], False) - await db.recalculate_fixture_scores(fixture_id) - - guild_one_scores = await db.get_scores_for_fixture(guild_one_fixture_id) - guild_two_scores = await db.get_scores_for_fixture(guild_two_fixture_id) - assert guild_one_scores[0]["points"] == 5 - assert guild_two_scores[0]["points"] == 3 - - @pytest.mark.asyncio - async def test_scoring_rule_changes_are_blocked_after_scores_exist(self, temp_db_path): - db = Database(temp_db_path) - await db.initialize() - assert await db.active_season_has_scores("111111") is False - fixture_id = await db.create_fixture("111111", 1, ["A - B"], datetime.now(UTC)) - await db.save_results(fixture_id, ["2-1"]) - await db.save_prediction(fixture_id, "user-1", "User One", ["2-1"], False) - await db.recalculate_fixture_scores(fixture_id) - - assert await db.active_season_has_scores("111111") is True - - with pytest.raises(ValueError, match="Cannot change scoring rules"): - await db.update_active_scoring_rules("111111", {"exact_score_points": 5}) - - assert await db.get_active_scoring_rules("111111") == { - "exact_score_points": 3, - "correct_outcome_points": 1, - "wrong_outcome_points": 0, - "late_prediction_points": 0, - } - - @pytest.mark.asyncio - async def test_scoring_rule_change_block_is_guild_and_active_season_scoped(self, temp_db_path): - db = Database(temp_db_path) - await db.initialize() - old_fixture_id = await db.create_fixture("111111", 1, ["A - B"], datetime.now(UTC)) - await db.save_scores( - old_fixture_id, - [ - { - "user_id": "user-1", - "user_name": "User One", - "points": 3, - "exact_scores": 1, - "correct_results": 0, - } - ], - ) - await db.start_new_season("111111", "Next Season") - - await db.update_active_scoring_rules("111111", {"exact_score_points": 5}) - - other_guild_fixture_id = await db.create_fixture("222222", 1, ["C - D"], datetime.now(UTC)) - await db.save_scores( - other_guild_fixture_id, - [ - { - "user_id": "user-2", - "user_name": "User Two", - "points": 3, - "exact_scores": 1, - "correct_results": 0, - } - ], - ) - - await db.update_active_scoring_rules("111111", {"correct_outcome_points": 2}) - - active_fixture_id = await db.create_fixture("111111", 1, ["E - F"], datetime.now(UTC)) - await db.save_scores( - active_fixture_id, - [ - { - "user_id": "user-1", - "user_name": "User One", - "points": 5, - "exact_scores": 1, - "correct_results": 0, - } - ], - ) - - with pytest.raises(ValueError, match="Cannot change scoring rules"): - await db.update_active_scoring_rules("111111", {"wrong_outcome_points": 1}) - - assert await db.get_active_scoring_rules("111111") == { - "exact_score_points": 5, - "correct_outcome_points": 2, - "wrong_outcome_points": 0, - "late_prediction_points": 0, - } - - @pytest.mark.asyncio - async def test_save_scores_does_not_mutate_when_write_lock_is_held( - self, temp_db_path, monkeypatch - ): - db = Database(temp_db_path) - await db.initialize() - fixture_id = await db.create_fixture("111111", 1, ["Team A - Team B"], datetime.now(UTC)) - await db.save_scores( - fixture_id, - [ - { - "user_id": "user-1", - "user_name": "User One", - "points": 3, - "exact_scores": 1, - "correct_results": 0, - } - ], - ) - - real_connect = scores_module.aiosqlite.connect - - def connect_with_short_timeout(*args, **kwargs): - kwargs.setdefault("timeout", 0.05) - return real_connect(*args, **kwargs) - - monkeypatch.setattr(scores_module.aiosqlite, "connect", connect_with_short_timeout) - async with aiosqlite.connect(temp_db_path) as locked_conn: - await locked_conn.execute("BEGIN IMMEDIATE") - - with pytest.raises(aiosqlite.OperationalError, match="locked"): - await db.save_scores( - fixture_id, - [ - { - "user_id": "user-2", - "user_name": "User Two", - "points": 9, - "exact_scores": 3, - "correct_results": 3, - } - ], - ) - - await locked_conn.rollback() - - scores = await db.get_scores_for_fixture(fixture_id) - assert [score["user_id"] for score in scores] == ["user-1"] - - @pytest.mark.asyncio - async def test_save_scores_rolls_back_after_partial_write_failure(self, temp_db_path): - db = Database(temp_db_path) - await db.initialize() - fixture_id = await db.create_fixture("111111", 1, ["Team A - Team B"], datetime.now(UTC)) - await db.save_scores( - fixture_id, - [ - { - "user_id": "user-1", - "user_name": "User One", - "points": 3, - "exact_scores": 1, - "correct_results": 0, - } - ], - ) - - async with aiosqlite.connect(temp_db_path) as conn: - await conn.execute( - """ - CREATE TRIGGER fail_second_score_insert - BEFORE INSERT ON scores - WHEN NEW.user_id = 'user-3' - BEGIN - SELECT RAISE(FAIL, 'forced score insert failure'); - END - """ - ) - await conn.commit() - - with pytest.raises(aiosqlite.IntegrityError, match="forced score insert failure"): - await db.save_scores( - fixture_id, - [ - { - "user_id": "user-2", - "user_name": "User Two", - "points": 9, - "exact_scores": 3, - "correct_results": 3, - }, - { - "user_id": "user-3", - "user_name": "User Three", - "points": 0, - "exact_scores": 0, - "correct_results": 0, - }, - ], - ) - - scores = await db.get_scores_for_fixture(fixture_id) - fixture = await db.get_fixture_by_id(fixture_id, "111111") - assert scores == [ - { - "user_id": "user-1", - "user_name": "User One", - "points": 3, - "exact_scores": 1, - "correct_results": 0, - } - ] - assert fixture["status"] == "closed" - - @pytest.mark.asyncio - async def test_recalculate_fixture_scores_rolls_back_after_partial_write_failure( - self, temp_db_path - ): - db = Database(temp_db_path) - await db.initialize() - fixture_id = await db.create_fixture( - "111111", - 1, - ["Team A - Team B", "Team C - Team D"], - datetime.now(UTC), - ) - await db.save_prediction(fixture_id, "user-1", "User One", ["2-1", "1-1"], False) - await db.save_results(fixture_id, ["2-1", "1-1"]) - await db.save_scores( - fixture_id, - [ - { - "user_id": "original", - "user_name": "Original User", - "points": 1, - "exact_scores": 0, - "correct_results": 1, - } - ], - ) - - async with aiosqlite.connect(temp_db_path) as conn: - await conn.execute( - """ - CREATE TRIGGER fail_recalculated_score_insert - BEFORE INSERT ON scores - WHEN NEW.user_id = 'user-1' - BEGIN - SELECT RAISE(FAIL, 'forced score insert failure'); - END - """ - ) - await conn.commit() - - with pytest.raises(aiosqlite.IntegrityError, match="forced score insert failure"): - await db.recalculate_fixture_scores(fixture_id) - - scores = await db.get_scores_for_fixture(fixture_id) - assert [(score["user_id"], score["points"]) for score in scores] == [("original", 1)] - - @pytest.mark.asyncio - async def test_standings_order_by_points_tiebreakers_and_name(self, temp_db_path): - db = Database(temp_db_path) - await db.initialize() - fixture_id = await db.create_fixture("111111", 1, ["Team A - Team B"], datetime.now(UTC)) - - await db.save_scores( - fixture_id, - [ - { - "user_id": "total", - "user_name": "Total", - "points": 10, - "exact_scores": 0, - "correct_results": 0, - }, - { - "user_id": "exact", - "user_name": "Exact", - "points": 9, - "exact_scores": 2, - "correct_results": 0, - }, - { - "user_id": "correct", - "user_name": "Correct", - "points": 9, - "exact_scores": 1, - "correct_results": 3, - }, - { - "user_id": "alpha", - "user_name": "Alpha", - "points": 9, - "exact_scores": 1, - "correct_results": 2, - }, - { - "user_id": "beta", - "user_name": "Beta", - "points": 9, - "exact_scores": 1, - "correct_results": 2, - }, - ], - ) - - standings = await db.get_standings("111111") - - assert [row["user_id"] for row in standings] == [ - "total", - "exact", - "correct", - "alpha", - "beta", - ] - - @pytest.mark.asyncio - async def test_standings_are_active_season_scoped(self, temp_db_path): - db = Database(temp_db_path) - await db.initialize() - old_fixture_id = await db.create_fixture( - "111111", 1, ["Old Team A - Old Team B"], datetime.now(UTC) - ) - await db.save_scores( - old_fixture_id, - [ - { - "user_id": "shared-user", - "user_name": "Old Shared User", - "points": 30, - "exact_scores": 10, - "correct_results": 0, - } - ], - ) - await _start_new_active_season(temp_db_path, "111111") - active_fixture_id = await db.create_fixture( - "111111", 1, ["New Team A - New Team B"], datetime.now(UTC) - ) - await db.save_scores( - active_fixture_id, - [ - { - "user_id": "shared-user", - "user_name": "Active Shared User", - "points": 3, - "exact_scores": 1, - "correct_results": 0, - } - ], - ) - - standings = await db.get_standings("111111") - - assert [row["user_id"] for row in standings] == ["shared-user"] - assert standings[0]["user_name"] == "Active Shared User" - assert standings[0]["total_points"] == 3 - - @pytest.mark.asyncio - async def test_get_standings_for_season_returns_archived_season_scores(self, temp_db_path): - db = Database(temp_db_path) - await db.initialize() - old_fixture_id = await db.create_fixture( - "111111", 1, ["Old Team A - Old Team B"], datetime.now(UTC) - ) - await db.save_scores( - old_fixture_id, - [ - { - "user_id": "old-user", - "user_name": "Old User", - "points": 30, - "exact_scores": 10, - "correct_results": 0, - } - ], - ) - old_season = await db.get_active_season("111111") - await _start_new_active_season(temp_db_path, "111111") - active_fixture_id = await db.create_fixture( - "111111", 1, ["New Team A - New Team B"], datetime.now(UTC) - ) - await db.save_scores( - active_fixture_id, - [ - { - "user_id": "active-user", - "user_name": "Active User", - "points": 3, - "exact_scores": 1, - "correct_results": 0, - } - ], - ) - - standings = await db.get_standings_for_season("111111", old_season["id"]) - wrong_guild_standings = await db.get_standings_for_season("222222", old_season["id"]) - - assert [row["user_id"] for row in standings] == ["old-user"] - assert standings[0]["total_points"] == 30 - assert wrong_guild_standings == [] - - @pytest.mark.asyncio - async def test_last_fixture_scores_are_active_season_scoped(self, temp_db_path): - db = Database(temp_db_path) - await db.initialize() - await _start_new_active_season(temp_db_path, "111111") - active_fixture_id = await db.create_fixture( - "111111", 1, ["New Team A - New Team B"], datetime.now(UTC) - ) - await db.save_scores( - active_fixture_id, - [ - { - "user_id": "active-user", - "user_name": "Active User", - "points": 3, - "exact_scores": 1, - "correct_results": 0, - } - ], - ) - await _start_new_active_season(temp_db_path, "111111", "Archived Later Season") - later_archived_fixture_id = await db.create_fixture( - "111111", 1, ["Archived Team A - Archived Team B"], datetime.now(UTC) - ) - await db.save_scores( - later_archived_fixture_id, - [ - { - "user_id": "archived-user", - "user_name": "Archived User", - "points": 30, - "exact_scores": 10, - "correct_results": 0, - } - ], - ) - async with aiosqlite.connect(temp_db_path) as conn: - await conn.execute( - "UPDATE seasons SET status = 'archived' WHERE id = (SELECT season_id FROM fixtures WHERE id = ?)", - (later_archived_fixture_id,), - ) - await conn.execute( - "UPDATE seasons SET status = 'active' WHERE id = (SELECT season_id FROM fixtures WHERE id = ?)", - (active_fixture_id,), - ) - await conn.commit() - - last_fixture = await db.get_last_fixture_scores("111111") - - assert last_fixture["fixture_id"] == active_fixture_id - assert [score["user_id"] for score in last_fixture["scores"]] == ["active-user"] - - -class TestOpenFixturesQueries: - """Test suite for multi-open fixture query helpers.""" - - @pytest.mark.asyncio - async def test_get_open_fixtures_returns_all_open_ordered(self, temp_db_path): - """Open fixtures are returned in week order for deterministic selection prompts.""" - db = Database(temp_db_path) - await db.initialize() - - fixture_week_2 = await db.create_fixture( - "111111", 2, ["Team C - Team D"], datetime.now(UTC) - ) - fixture_week_1 = await db.create_fixture( - "111111", 1, ["Team A - Team B"], datetime.now(UTC) - ) - fixture_week_3 = await db.create_fixture( - "111111", 3, ["Team E - Team F"], datetime.now(UTC) - ) - - # Close week 3 fixture so only weeks 1 and 2 remain open - await db.save_scores(fixture_week_3, []) - - await db.create_fixture("guild-2", 1, ["Other A - Other B"], datetime.now(UTC)) - - open_fixtures = await db.get_open_fixtures("111111") - open_ids = [fixture["id"] for fixture in open_fixtures] - open_weeks = [fixture["week_number"] for fixture in open_fixtures] - - assert fixture_week_3 not in open_ids - assert set(open_ids) == {fixture_week_1, fixture_week_2} - assert open_weeks == [1, 2] - - @pytest.mark.asyncio - async def test_get_open_fixture_by_week_ignores_closed_fixtures(self, temp_db_path): - """Week resolver should only return fixtures that are still open.""" - db = Database(temp_db_path) - await db.initialize() - - open_fixture_id = await db.create_fixture( - "111111", 7, ["Team A - Team B"], datetime.now(UTC) - ) - closed_fixture_id = await db.create_fixture( - "111111", 8, ["Team C - Team D"], datetime.now(UTC) - ) - await db.save_scores(closed_fixture_id, []) - - open_fixture = await db.get_open_fixture_by_week("111111", 7) - closed_fixture = await db.get_open_fixture_by_week("111111", 8) - - assert open_fixture is not None - assert open_fixture["id"] == open_fixture_id - assert closed_fixture is None - - @pytest.mark.asyncio - async def test_week_and_recent_fixture_queries_are_guild_scoped(self, temp_db_path): - db = Database(temp_db_path) - await db.initialize() - - guild_one_week = await db.create_fixture( - "111111", 1, ["Team A - Team B"], datetime.now(UTC) - ) - guild_two_week = await db.create_fixture( - "guild-2", 1, ["Team C - Team D"], datetime.now(UTC) - ) - - assert (await db.get_fixture_by_week("111111", 1))["id"] == guild_one_week - assert (await db.get_fixture_by_week("guild-2", 1))["id"] == guild_two_week - assert [fixture["id"] for fixture in await db.get_recent_fixtures("111111")] == [ - guild_one_week - ] - assert await db.get_fixture_by_id(guild_two_week, "111111") is None - - @pytest.mark.asyncio - async def test_delete_fixture_can_require_guild_ownership(self, temp_db_path): - db = Database(temp_db_path) - await db.initialize() - - fixture_id = await db.create_fixture("guild-2", 1, ["Team A - Team B"], datetime.now(UTC)) - - assert await db.delete_fixture(fixture_id, "111111") is False - assert await db.get_fixture_by_id(fixture_id, "guild-2") is not None - - assert await db.delete_fixture(fixture_id, "guild-2") is True - assert await db.get_fixture_by_id(fixture_id, "guild-2") is None - - @pytest.mark.asyncio - async def test_get_prediction_requires_fixture_guild_ownership(self, temp_db_path): - db = Database(temp_db_path) - await db.initialize() - fixture_id = await db.create_fixture("guild-2", 1, ["Team A - Team B"], datetime.now(UTC)) - await db.save_prediction(fixture_id, "user-1", "User One", ["2-1"], False) - - assert await db.get_prediction(fixture_id, "user-1", "111111") is None - assert await db.get_prediction(fixture_id, "user-1", "guild-2") is not None - - @pytest.mark.asyncio - async def test_pending_partial_predictions_are_guild_scoped(self, temp_db_path): - db = Database(temp_db_path) - await db.initialize() - deadline = datetime.now(UTC) - timedelta(hours=1) - guild_one_fixture_id = await db.create_fixture( - "111111", 1, ["Team A - Team B", "Team C - Team D"], deadline - ) - guild_two_fixture_id = await db.create_fixture( - "guild-2", 1, ["Team E - Team F", "Team G - Team H"], deadline - ) - await db.save_prediction( - guild_one_fixture_id, - "guild-one-user", - "Guild One", - ["1-1"], - True, - predicted_game_indexes=[0], - pending_partial_approval=True, - ) - await db.save_prediction( - guild_two_fixture_id, - "guild-two-user", - "Guild Two", - ["2-2"], - True, - predicted_game_indexes=[1], - pending_partial_approval=True, - ) - - guild_one_pending = await db.get_pending_partial_predictions("111111") - guild_two_pending = await db.get_pending_partial_predictions("guild-2") - - assert [prediction["user_id"] for prediction in guild_one_pending] == ["guild-one-user"] - assert [prediction["user_id"] for prediction in guild_two_pending] == ["guild-two-user"] - - @pytest.mark.asyncio - async def test_create_next_fixture_allocates_incrementing_weeks(self, temp_db_path): - """Atomic allocator should issue increasing week numbers.""" - db = Database(temp_db_path) - await db.initialize() - - fixture_one_id, week_one = await db.create_next_fixture( - "111111", - ["Team A - Team B"], - datetime.now(UTC), - ) - fixture_two_id, week_two = await db.create_next_fixture( - "111111", - ["Team C - Team D"], - datetime.now(UTC), - ) - - fixture_one = await db.get_fixture_by_id(fixture_one_id, "111111") - fixture_two = await db.get_fixture_by_id(fixture_two_id, "111111") - - assert week_one == 1 - assert week_two == 2 - assert fixture_one is not None - assert fixture_one["week_number"] == 1 - assert fixture_two is not None - assert fixture_two["week_number"] == 2 - - @pytest.mark.asyncio - async def test_created_fixtures_store_guild_ownership(self, temp_db_path): - db = Database(temp_db_path) - await db.initialize() - - fixture_id = await db.create_fixture( - "guild-2", - 4, - ["Team A - Team B"], - datetime.now(UTC), - ) - - fixture = await db.get_fixture_by_id(fixture_id, "guild-2") - assert fixture is not None - assert fixture["guild_id"] == "guild-2" - - @pytest.mark.asyncio - async def test_create_fixture_rejects_missing_guild_id(self, temp_db_path): - db = Database(temp_db_path) - await db.initialize() - - with pytest.raises(ValueError, match="guild_id is required"): - await db.create_fixture("", 1, ["Team A - Team B"], datetime.now(UTC)) - - @pytest.mark.asyncio - async def test_create_next_fixture_rejects_missing_guild_id(self, temp_db_path): - db = Database(temp_db_path) - await db.initialize() - - with pytest.raises(ValueError, match="guild_id is required"): - await db.create_next_fixture("", ["Team A - Team B"], datetime.now(UTC)) - - @pytest.mark.asyncio - async def test_create_next_fixture_allocates_weeks_per_guild(self, temp_db_path): - db = Database(temp_db_path) - await db.initialize() - - _, guild_one_week = await db.create_next_fixture( - "111111", - ["Team A - Team B"], - datetime.now(UTC), - ) - _, guild_two_week = await db.create_next_fixture( - "guild-2", - ["Team C - Team D"], - datetime.now(UTC), - ) - guild_one_second_id = await db.create_fixture( - "111111", - 2, - ["Team E - Team F"], - datetime.now(UTC), - ) - await db.create_fixture( - "guild-2", - 9, - ["Team G - Team H"], - datetime.now(UTC), - ) - - assert guild_one_week == 1 - assert guild_two_week == 1 - assert await db.get_max_week_number("111111") == 2 - assert await db.get_max_week_number("guild-2") == 9 - - guild_one_second = await db.get_fixture_by_id(guild_one_second_id, "111111") - assert guild_one_second is not None - assert guild_one_second["guild_id"] == "111111" - - -class TestSchemaValidation: - """Test suite for startup schema validation.""" - - @pytest.mark.asyncio - async def test_initialize_is_safe_for_current_schema_existing_data(self, temp_db_path): - db = Database(temp_db_path) - await db.initialize() - fixture_id = await db.create_fixture("111111", 1, ["A - B"], datetime.now(UTC)) - await db.save_prediction( - fixture_id, - "user-1", - "User One", - ["2-1"], - public_message_id="message-1", - public_message_kind="thread_prediction", - ) - await db.save_results(fixture_id, ["2-1"]) - - restarted_db = Database(temp_db_path) - await restarted_db.initialize() - await restarted_db.save_results(fixture_id, ["3-1"]) - - fixture = await restarted_db.get_fixture_by_id(fixture_id, "111111") - prediction = await restarted_db.get_prediction(fixture_id, "user-1", "111111") - results = await restarted_db.get_results(fixture_id) - - assert fixture is not None - assert fixture["guild_id"] == "111111" - assert prediction["public_message_id"] == "message-1" - assert prediction["public_message_kind"] == "thread_prediction" - assert results == ["3-1"] - - @pytest.mark.asyncio - async def test_initialize_rejects_duplicate_result_rows_without_mutating(self, temp_db_path): - async with aiosqlite.connect(temp_db_path) as conn: - await conn.executescript( - """ - CREATE TABLE seasons ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - guild_id TEXT NOT NULL, - name TEXT NOT NULL, - status TEXT NOT NULL DEFAULT 'active', - exact_score_points INTEGER NOT NULL DEFAULT 3, - correct_outcome_points INTEGER NOT NULL DEFAULT 1, - wrong_outcome_points INTEGER NOT NULL DEFAULT 0, - late_prediction_points INTEGER NOT NULL DEFAULT 0, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - ended_at DATETIME - ); - CREATE TABLE fixtures ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - guild_id TEXT NOT NULL, - season_id INTEGER, - week_number INTEGER NOT NULL, - games TEXT NOT NULL, - deadline DATETIME NOT NULL, - status TEXT DEFAULT 'open', - message_id TEXT, - channel_id TEXT, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP - ); - CREATE TABLE predictions ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - fixture_id INTEGER NOT NULL, - user_id TEXT NOT NULL, - user_name TEXT NOT NULL, - predictions TEXT NOT NULL, - submitted_at DATETIME DEFAULT CURRENT_TIMESTAMP, - is_late BOOLEAN DEFAULT FALSE, - late_penalty_waived BOOLEAN DEFAULT FALSE, - admin_edited_at DATETIME, - admin_edited_by TEXT, - predicted_game_indexes TEXT, - pending_partial_approval BOOLEAN DEFAULT FALSE, - public_message_id TEXT, - public_message_kind TEXT, - UNIQUE(fixture_id, user_id) - ); - CREATE TABLE results ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - fixture_id INTEGER NOT NULL, - results TEXT NOT NULL, - calculated_at DATETIME DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME DEFAULT CURRENT_TIMESTAMP - ); - CREATE TABLE scores ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - fixture_id INTEGER NOT NULL, - user_id TEXT NOT NULL, - user_name TEXT NOT NULL, - points INTEGER NOT NULL, - exact_scores INTEGER DEFAULT 0, - correct_results INTEGER DEFAULT 0, - UNIQUE(fixture_id, user_id) - ); - CREATE TABLE guild_config ( - guild_id TEXT PRIMARY KEY, - admin_role_id TEXT NOT NULL, - league_channel_id TEXT NOT NULL, - active_season_id INTEGER, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME DEFAULT CURRENT_TIMESTAMP - ); - """ - ) - await conn.execute( - "INSERT INTO seasons (id, guild_id, name, status) VALUES (1, '111111', 'Current Season', 'active')" - ) - await conn.execute( - "INSERT INTO fixtures (id, guild_id, season_id, week_number, games, deadline, status) VALUES (1, '111111', 1, 1, 'A - B', ?, 'open')", - (datetime.now(UTC).isoformat(),), - ) - await conn.execute( - "INSERT INTO results (fixture_id, results, calculated_at, updated_at) VALUES (1, '1-0', '2024-01-01T10:00:00+00:00', '2024-01-01T10:00:00+00:00')" - ) - await conn.execute( - "INSERT INTO results (fixture_id, results, calculated_at, updated_at) VALUES (1, '2-0', '2024-01-01T12:00:00+00:00', '2024-01-01T12:00:00+00:00')" - ) - await conn.commit() - - db = Database(temp_db_path) - - with pytest.raises( - RuntimeError, - match=r"results has duplicate rows for fixture_id\(s\): 1.*Keep one result row per fixture", - ): - await db.initialize() - - async with ( - aiosqlite.connect(temp_db_path) as conn, - conn.execute("SELECT results FROM results ORDER BY id") as cursor, - ): - assert await cursor.fetchall() == [("1-0",), ("2-0",)] - - @pytest.mark.asyncio - async def test_initialize_creates_missing_result_unique_index_for_current_schema( - self, temp_db_path - ): - db = Database(temp_db_path) - await db.initialize() - fixture_id = await db.create_fixture("111111", 1, ["A - B"], datetime.now(UTC)) - await db.save_results(fixture_id, ["1-0"]) - async with aiosqlite.connect(temp_db_path) as conn: - await conn.execute("DROP INDEX idx_results_fixture_id_unique") - await conn.commit() - - await db.initialize() - await db.save_results(fixture_id, ["2-0"]) - - assert await db.get_results(fixture_id) == ["2-0"] - async with ( - aiosqlite.connect(temp_db_path) as conn, - conn.execute( - "SELECT COUNT(*) FROM results WHERE fixture_id = ?", (fixture_id,) - ) as cursor, - ): - assert await cursor.fetchone() == (1,) - - @pytest.mark.asyncio - async def test_initialize_rejects_partial_result_unique_index(self, temp_db_path): - db = Database(temp_db_path) - await db.initialize() - async with aiosqlite.connect(temp_db_path) as conn: - await conn.execute("DROP INDEX idx_results_fixture_id_unique") - await conn.execute( - "CREATE UNIQUE INDEX idx_results_fixture_id_unique ON results(fixture_id) WHERE fixture_id > 0" - ) - await conn.commit() - - with pytest.raises(RuntimeError, match=r"results\(fixture_id\)"): - await db.initialize() - - @pytest.mark.asyncio - async def test_initialize_rejects_missing_prediction_unique_constraint(self, temp_db_path): - async with aiosqlite.connect(temp_db_path) as conn: - await conn.executescript( - """ - CREATE TABLE seasons ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - guild_id TEXT NOT NULL, - name TEXT NOT NULL, - status TEXT NOT NULL DEFAULT 'active', - exact_score_points INTEGER NOT NULL DEFAULT 3, - correct_outcome_points INTEGER NOT NULL DEFAULT 1, - wrong_outcome_points INTEGER NOT NULL DEFAULT 0, - late_prediction_points INTEGER NOT NULL DEFAULT 0, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - ended_at DATETIME - ); - CREATE TABLE fixtures ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - guild_id TEXT NOT NULL, - season_id INTEGER, - week_number INTEGER NOT NULL, - games TEXT NOT NULL, - deadline DATETIME NOT NULL, - status TEXT DEFAULT 'open', - message_id TEXT, - channel_id TEXT, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP - ); - CREATE TABLE predictions ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - fixture_id INTEGER NOT NULL, - user_id TEXT NOT NULL, - user_name TEXT NOT NULL, - predictions TEXT NOT NULL, - submitted_at DATETIME DEFAULT CURRENT_TIMESTAMP, - is_late BOOLEAN DEFAULT FALSE, - late_penalty_waived BOOLEAN DEFAULT FALSE, - admin_edited_at DATETIME, - admin_edited_by TEXT, - predicted_game_indexes TEXT, - pending_partial_approval BOOLEAN DEFAULT FALSE, - public_message_id TEXT, - public_message_kind TEXT - ); - CREATE TABLE results ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - fixture_id INTEGER NOT NULL, - results TEXT NOT NULL, - calculated_at DATETIME DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME DEFAULT CURRENT_TIMESTAMP - ); - CREATE TABLE scores ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - fixture_id INTEGER NOT NULL, - user_id TEXT NOT NULL, - user_name TEXT NOT NULL, - points INTEGER NOT NULL, - exact_scores INTEGER DEFAULT 0, - correct_results INTEGER DEFAULT 0, - UNIQUE(fixture_id, user_id) - ); - CREATE TABLE guild_config ( - guild_id TEXT PRIMARY KEY, - admin_role_id TEXT NOT NULL, - league_channel_id TEXT NOT NULL, - active_season_id INTEGER, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME DEFAULT CURRENT_TIMESTAMP - ); - """ - ) - await conn.commit() - - db = Database(temp_db_path) - - with pytest.raises(RuntimeError, match=r"predictions\(fixture_id, user_id\)"): - await db.initialize() - - @pytest.mark.asyncio - async def test_initialize_rejects_stale_schema_without_required_columns(self, temp_db_path): - async with aiosqlite.connect(temp_db_path) as conn: - await conn.executescript( - """ - CREATE TABLE seasons ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - guild_id TEXT NOT NULL, - name TEXT NOT NULL, - status TEXT NOT NULL DEFAULT 'active', - exact_score_points INTEGER NOT NULL DEFAULT 3, - correct_outcome_points INTEGER NOT NULL DEFAULT 1, - wrong_outcome_points INTEGER NOT NULL DEFAULT 0, - late_prediction_points INTEGER NOT NULL DEFAULT 0, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - ended_at DATETIME - ); - CREATE TABLE fixtures ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - guild_id TEXT NOT NULL, - season_id INTEGER, - week_number INTEGER NOT NULL, - games TEXT NOT NULL, - deadline DATETIME NOT NULL, - status TEXT DEFAULT 'open', - message_id TEXT, - channel_id TEXT, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP - ); - CREATE TABLE predictions ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - fixture_id INTEGER NOT NULL, - user_id TEXT NOT NULL, - user_name TEXT NOT NULL, - predictions TEXT NOT NULL, - submitted_at DATETIME DEFAULT CURRENT_TIMESTAMP, - is_late BOOLEAN DEFAULT FALSE, - late_penalty_waived BOOLEAN DEFAULT FALSE, - admin_edited_at DATETIME, - admin_edited_by TEXT, - predicted_game_indexes TEXT, - pending_partial_approval BOOLEAN DEFAULT FALSE, - public_message_kind TEXT, - UNIQUE(fixture_id, user_id) - ); - CREATE TABLE results ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - fixture_id INTEGER NOT NULL, - results TEXT NOT NULL, - calculated_at DATETIME DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME DEFAULT CURRENT_TIMESTAMP - ); - CREATE TABLE scores ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - fixture_id INTEGER NOT NULL, - user_id TEXT NOT NULL, - user_name TEXT NOT NULL, - points INTEGER NOT NULL, - exact_scores INTEGER DEFAULT 0, - correct_results INTEGER DEFAULT 0, - UNIQUE(fixture_id, user_id) - ); - CREATE TABLE guild_config ( - guild_id TEXT PRIMARY KEY, - admin_role_id TEXT NOT NULL, - league_channel_id TEXT NOT NULL, - active_season_id INTEGER, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME DEFAULT CURRENT_TIMESTAMP - ); - """ - ) - await conn.commit() - - db = Database(temp_db_path) - - with pytest.raises(RuntimeError, match="predictions.public_message_id"): - await db.initialize() - - @pytest.mark.asyncio - async def test_initialize_rejects_existing_schema_with_missing_table(self, temp_db_path): - db = Database(temp_db_path) - await db.initialize() - async with aiosqlite.connect(temp_db_path) as conn: - await conn.execute("DROP TABLE scores") - await conn.commit() - - with pytest.raises(RuntimeError, match="scores.fixture_id"): - await db.initialize() - - @pytest.mark.asyncio - @pytest.mark.parametrize("guild_id", ["", " "]) - async def test_initialize_rejects_blank_fixture_guild_ownership(self, temp_db_path, guild_id): - db = Database(temp_db_path) - await db.initialize() - fixture_id = await db.create_fixture("111111", 1, ["A - B"], datetime.now(UTC)) - async with aiosqlite.connect(temp_db_path) as conn: - await conn.execute( - "UPDATE fixtures SET guild_id = ? WHERE id = ?", (guild_id, fixture_id) - ) - await conn.commit() - - with pytest.raises(RuntimeError, match="fixtures.guild_id has empty rows"): - await db.initialize() - - @pytest.mark.asyncio - async def test_initialize_rejects_null_fixture_guild_ownership(self, temp_db_path): - db = Database(temp_db_path) - await db.initialize() - async with aiosqlite.connect(temp_db_path) as conn: - await conn.execute("DROP TABLE fixtures") - await conn.execute( - """ - CREATE TABLE fixtures ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - guild_id TEXT, - season_id INTEGER, - week_number INTEGER NOT NULL, - games TEXT NOT NULL, - deadline DATETIME NOT NULL, - status TEXT DEFAULT 'open', - message_id TEXT, - channel_id TEXT, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP - ) - """ - ) - await conn.execute( - "INSERT INTO fixtures (guild_id, season_id, week_number, games, deadline, status) VALUES (NULL, NULL, 1, 'A - B', ?, 'open')", - (datetime.now(UTC).isoformat(),), - ) - await conn.commit() - - with pytest.raises(RuntimeError, match="fixtures.guild_id has empty rows"): - await db.initialize() - - @pytest.mark.asyncio - @pytest.mark.parametrize( - "sql", - [ - "UPDATE fixtures SET season_id = NULL WHERE id = ?", - "UPDATE fixtures SET season_id = 999999 WHERE id = ?", - ], - ) - async def test_initialize_rejects_fixture_without_valid_season(self, temp_db_path, sql): - db = Database(temp_db_path) - await db.initialize() - fixture_id = await db.create_fixture("111111", 1, ["A - B"], datetime.now(UTC)) - async with aiosqlite.connect(temp_db_path) as conn: - await conn.execute(sql, (fixture_id,)) - await conn.commit() - - with pytest.raises(RuntimeError, match="same-guild season_id"): - await db.initialize() - - @pytest.mark.asyncio - async def test_initialize_rejects_fixture_with_other_guild_season(self, temp_db_path): - db = Database(temp_db_path) - await db.initialize() - fixture_id = await db.create_fixture("111111", 1, ["A - B"], datetime.now(UTC)) - other_fixture_id = await db.create_fixture("222222", 1, ["C - D"], datetime.now(UTC)) - other_fixture = await db.get_fixture_by_id(other_fixture_id, "222222") - async with aiosqlite.connect(temp_db_path) as conn: - await conn.execute( - "UPDATE fixtures SET season_id = ? WHERE id = ?", - (other_fixture["season_id"], fixture_id), - ) - await conn.commit() - - with pytest.raises(RuntimeError, match="same-guild season_id"): - await db.initialize() - - -@pytest.fixture -async def prediction_db(temp_db_path): - """Initialized Database for prediction-write tests.""" - database = Database(temp_db_path) - await database.initialize() - return database - - -@pytest.fixture -async def open_fixture_id(prediction_db): - deadline = datetime.now(UTC) + timedelta(hours=1) - return await prediction_db.create_fixture("111111", 1, ["A - B", "C - D"], deadline) - - -@pytest.fixture -async def closed_fixture_id(prediction_db): - deadline = datetime.now(UTC) + timedelta(hours=1) - fixture_id = await prediction_db.create_fixture("111111", 2, ["A - B", "C - D"], deadline) - async with aiosqlite.connect(prediction_db.db_path) as conn: - await conn.execute("UPDATE fixtures SET status = 'closed' WHERE id = ?", (fixture_id,)) - await conn.commit() - return fixture_id - - -class TestTrySavePrediction: - """Atomic first-write-wins insert with fixture-open guard.""" - - @pytest.mark.asyncio - async def test_saved_when_fixture_open_and_no_prior_prediction( - self, prediction_db, open_fixture_id - ): - result = await prediction_db.try_save_prediction( - open_fixture_id, "u1", "User", ["2-1", "0-0"] - ) - assert result == SaveResult.SAVED - prediction = await prediction_db.get_prediction(open_fixture_id, "u1", "111111") - assert prediction is not None - assert prediction["predictions"] == ["2-1", "0-0"] - - @pytest.mark.asyncio - async def test_duplicate_when_prior_prediction_exists(self, prediction_db, open_fixture_id): - await prediction_db.try_save_prediction(open_fixture_id, "u1", "User", ["2-1", "0-0"]) - result = await prediction_db.try_save_prediction( - open_fixture_id, "u1", "User", ["3-0", "1-1"] - ) - assert result == SaveResult.DUPLICATE - prediction = await prediction_db.get_prediction(open_fixture_id, "u1", "111111") - assert prediction["predictions"] == ["2-1", "0-0"] - - @pytest.mark.asyncio - async def test_fixture_closed_returns_fixture_closed(self, prediction_db, closed_fixture_id): - result = await prediction_db.try_save_prediction( - closed_fixture_id, "u1", "User", ["2-1", "0-0"] - ) - assert result == SaveResult.FIXTURE_CLOSED - - @pytest.mark.asyncio - async def test_no_row_written_on_fixture_closed(self, prediction_db, closed_fixture_id): - await prediction_db.try_save_prediction(closed_fixture_id, "u1", "User", ["2-1", "0-0"]) - prediction = await prediction_db.get_prediction(closed_fixture_id, "u1", "111111") - assert prediction is None - - @pytest.mark.asyncio - async def test_fixture_closed_checked_before_duplicate(self, prediction_db, closed_fixture_id): - async with aiosqlite.connect(prediction_db.db_path) as conn: - await conn.execute( - "INSERT INTO predictions (fixture_id, user_id, user_name, predictions, is_late) VALUES (?, 'u1', 'User', '2-1', 0)", - (closed_fixture_id,), - ) - await conn.commit() - result = await prediction_db.try_save_prediction( - closed_fixture_id, "u1", "User", ["3-0", "1-1"] - ) - assert result == SaveResult.FIXTURE_CLOSED - - @pytest.mark.asyncio - async def test_concurrent_writers_allow_only_one_prediction( - self, prediction_db, open_fixture_id - ): - async def save(user_name, predictions): - return await prediction_db.try_save_prediction( - open_fixture_id, - "u1", - user_name, - predictions, - ) - - first, second = await asyncio.gather( - save("First", ["2-1", "0-0"]), - save("Second", ["3-0", "1-1"]), - ) - - assert sorted([first, second]) == [SaveResult.DUPLICATE, SaveResult.SAVED] - - async with ( - aiosqlite.connect(prediction_db.db_path) as conn, - conn.execute( - "SELECT COUNT(*), user_name, predictions FROM predictions WHERE fixture_id = ? AND user_id = ?", - (open_fixture_id, "u1"), - ) as cursor, - ): - row = await cursor.fetchone() - - assert row is not None - assert row[0] == 1 - assert row[1] in {"First", "Second"} - assert row[2] in {"2-1\n0-0", "3-0\n1-1"} - - -class TestPredictionSaveMetadata: - @pytest.mark.asyncio - @pytest.mark.parametrize( - "method_name", - ["save_prediction", "try_save_prediction", "save_prediction_guarded"], - ) - async def test_save_paths_preserve_non_default_metadata( - self, prediction_db, open_fixture_id, method_name - ): - save = getattr(prediction_db, method_name) - - result = await save( - open_fixture_id, - "u1", - "User", - ["1-1"], - True, - predicted_game_indexes=[1], - pending_partial_approval=True, - public_message_id="message-1", - public_message_kind="bot_post", - ) - - if method_name != "save_prediction": - assert result == SaveResult.SAVED - prediction = await prediction_db.get_prediction(open_fixture_id, "u1", "111111") - assert prediction is not None - assert prediction["user_name"] == "User" - assert prediction["predictions"] == ["1-1"] - assert prediction["is_late"] == 1 - assert prediction["predicted_game_indexes"] == [1] - assert prediction["pending_partial_approval"] is True - assert prediction["public_message_id"] == "message-1" - assert prediction["public_message_kind"] == "bot_post" - - -class TestSavePredictionGuarded: - """Upsert with fixture-open guard (DM re-submission path).""" - - @pytest.mark.asyncio - async def test_saved_when_fixture_open(self, prediction_db, open_fixture_id): - result = await prediction_db.save_prediction_guarded( - open_fixture_id, "u1", "User", ["2-1", "0-0"] - ) - assert result == SaveResult.SAVED - prediction = await prediction_db.get_prediction(open_fixture_id, "u1", "111111") - assert prediction["predictions"] == ["2-1", "0-0"] - - @pytest.mark.asyncio - async def test_fixture_closed_blocks_write(self, prediction_db, closed_fixture_id): - result = await prediction_db.save_prediction_guarded( - closed_fixture_id, "u1", "User", ["2-1", "0-0"] - ) - assert result == SaveResult.FIXTURE_CLOSED - prediction = await prediction_db.get_prediction(closed_fixture_id, "u1", "111111") - assert prediction is None - - @pytest.mark.asyncio - async def test_allows_overwrite_of_existing_prediction(self, prediction_db, open_fixture_id): - await prediction_db.save_prediction_guarded(open_fixture_id, "u1", "User", ["2-1", "0-0"]) - result = await prediction_db.save_prediction_guarded( - open_fixture_id, "u1", "User", ["3-0", "1-1"] - ) - assert result == SaveResult.SAVED - prediction = await prediction_db.get_prediction(open_fixture_id, "u1", "111111") - assert prediction["predictions"] == ["3-0", "1-1"] - - @pytest.mark.asyncio - async def test_updates_user_name_on_resubmission(self, prediction_db, open_fixture_id): - await prediction_db.save_prediction_guarded( - open_fixture_id, "u1", "OldName", ["2-1", "0-0"] - ) - await prediction_db.save_prediction_guarded( - open_fixture_id, "u1", "NewName", ["3-0", "1-1"] - ) - prediction = await prediction_db.get_prediction(open_fixture_id, "u1", "111111") - assert prediction["user_name"] == "NewName" - - @pytest.mark.asyncio - async def test_resubmission_clears_admin_and_waiver_metadata( - self, prediction_db, open_fixture_id - ): - await prediction_db.save_prediction_guarded( - open_fixture_id, "u1", "User", ["2-1", "0-0"], True - ) - await prediction_db.set_late_penalty_waiver(open_fixture_id, "u1", True) - await prediction_db.admin_update_prediction( - open_fixture_id, "u1", ["2-0", "1-1"], "admin-1" - ) - async with aiosqlite.connect(prediction_db.db_path) as conn: - await conn.execute( - "UPDATE predictions SET submitted_at = '2000-01-01T00:00:00+00:00' WHERE fixture_id = ? AND user_id = ?", - (open_fixture_id, "u1"), - ) - await conn.commit() - - result = await prediction_db.save_prediction_guarded( - open_fixture_id, "u1", "User", ["3-0", "1-1"], False - ) - - assert result == SaveResult.SAVED - prediction = await prediction_db.get_prediction(open_fixture_id, "u1", "111111") - assert prediction is not None - assert prediction["late_penalty_waived"] == 0 - assert prediction["admin_edited_at"] is None - assert prediction["admin_edited_by"] is None - assert prediction["submitted_at"] > datetime(2000, 1, 1, tzinfo=UTC) - - -class TestPartialApprovalPending: - @pytest.mark.asyncio - async def test_clearing_pending_does_not_clear_late_status( - self, prediction_db, open_fixture_id - ): - await prediction_db.save_prediction( - open_fixture_id, - "u1", - "User", - ["2-1", "0-0"], - is_late=True, - pending_partial_approval=True, - ) - - predictions = PredictionRepository(prediction_db.db_path) - updated = await predictions.set_partial_approval_pending(open_fixture_id, "u1", False) - prediction = await prediction_db.get_prediction(open_fixture_id, "u1", "111111") - - assert updated is True - assert prediction is not None - assert prediction["pending_partial_approval"] is False - assert prediction["is_late"] == 1 - - @pytest.mark.asyncio - async def test_setting_pending_does_not_force_late_status(self, prediction_db, open_fixture_id): - await prediction_db.save_prediction( - open_fixture_id, - "u1", - "User", - ["2-1", "0-0"], - is_late=False, - pending_partial_approval=False, - ) - - predictions = PredictionRepository(prediction_db.db_path) - updated = await predictions.set_partial_approval_pending(open_fixture_id, "u1", True) - prediction = await prediction_db.get_prediction(open_fixture_id, "u1", "111111") - - assert updated is True - assert prediction is not None - assert prediction["pending_partial_approval"] is True - assert prediction["is_late"] == 0 - - -class TestCreateNextFixtureConcurrency: - @pytest.mark.asyncio - async def test_concurrent_calls_allocate_distinct_week_numbers(self, temp_db_path): - db = Database(temp_db_path) - await db.initialize() - - created = await asyncio.gather( - db.create_next_fixture("111111", ["A - B"], datetime.now(UTC)), - db.create_next_fixture("111111", ["C - D"], datetime.now(UTC)), - ) - - fixture_ids = [fixture_id for fixture_id, _week in created] - weeks = sorted(week for _fixture_id, week in created) - - assert weeks == [1, 2] - - fixtures = [await db.get_fixture_by_id(fixture_id, "111111") for fixture_id in fixture_ids] - assert all(fixture is not None for fixture in fixtures) - assert sorted(fixture["week_number"] for fixture in fixtures if fixture is not None) == [ - 1, - 2, - ] - - -class TestRowToFixture: - """Test edge cases in _row_to_fixture deserialization.""" - - @pytest.mark.asyncio - async def test_empty_games_column_returns_empty_list(self, temp_db_path): - """Empty games column must deserialize to [] not [''] (split artefact).""" - db = Database(temp_db_path) - await db.initialize() - season = await db.get_or_create_active_season("111111") - - async with aiosqlite.connect(temp_db_path) as conn: - await conn.execute( - "INSERT INTO fixtures (guild_id, season_id, week_number, games, deadline, status) VALUES (?, ?, ?, ?, ?, ?)", - ("111111", season["id"], 99, "", "2030-01-01T00:00:00+00:00", "open"), - ) - await conn.commit() - - fixture = await db.get_current_fixture("111111") - assert fixture is not None - assert fixture["games"] == [] diff --git a/tests/user_commands/conftest.py b/tests/user_commands/conftest.py new file mode 100644 index 0000000..1c20a7c --- /dev/null +++ b/tests/user_commands/conftest.py @@ -0,0 +1,11 @@ +import pytest + +from typer_bot.commands.user_commands import ( + UserCommands, +) + + +@pytest.fixture +async def user_commands(mock_bot, database): + mock_bot.db = database + return UserCommands(mock_bot) diff --git a/tests/user_commands/test_fixtures_command.py b/tests/user_commands/test_fixtures_command.py new file mode 100644 index 0000000..370438a --- /dev/null +++ b/tests/user_commands/test_fixtures_command.py @@ -0,0 +1,56 @@ +from datetime import UTC, datetime, timedelta + +import pytest + + +class TestFixturesCommand: + @pytest.mark.asyncio + async def test_no_open_fixture_shows_error(self, user_commands, mock_interaction): + await user_commands.fixtures.callback(user_commands, mock_interaction) + + assert "No active fixture" in mock_interaction.response_sent[0]["content"] + + @pytest.mark.asyncio + async def test_single_open_fixture_lists_games_and_deadline( + self, user_commands, mock_interaction, database, sample_games + ): + await database.create_fixture( + "111111", 1, sample_games, datetime.now(UTC) + timedelta(days=1) + ) + + await user_commands.fixtures.callback(user_commands, mock_interaction) + + content = mock_interaction.response_sent[0]["content"] + assert "Week 1 Fixtures" in content + assert sample_games[0] in content + assert "Deadline:" in content + assert mock_interaction.response_sent[0]["ephemeral"] is True + + @pytest.mark.asyncio + async def test_multiple_open_fixtures_list_each_week( + self, user_commands, mock_interaction, database, sample_games + ): + deadline = datetime.now(UTC) + timedelta(days=1) + await database.create_fixture("111111", 1, sample_games, deadline) + await database.create_fixture("111111", 2, sample_games, deadline) + + await user_commands.fixtures.callback(user_commands, mock_interaction) + + content = mock_interaction.response_sent[0]["content"] + assert "Open Fixtures" in content + assert "Week 1" in content + assert "Week 2" in content + + @pytest.mark.asyncio + async def test_fixtures_only_shows_current_guild( + self, user_commands, mock_interaction, database, sample_games + ): + deadline = datetime.now(UTC) + timedelta(days=1) + await database.create_fixture("111111", 1, sample_games, deadline) + await database.create_fixture("guild-2", 2, sample_games, deadline) + + await user_commands.fixtures.callback(user_commands, mock_interaction) + + content = mock_interaction.response_sent[0]["content"] + assert "Week 1" in content + assert "Week 2" not in content diff --git a/tests/user_commands/test_help_command.py b/tests/user_commands/test_help_command.py new file mode 100644 index 0000000..2742f18 --- /dev/null +++ b/tests/user_commands/test_help_command.py @@ -0,0 +1,28 @@ +import pytest + + +class TestHelpCommand: + @pytest.mark.asyncio + async def test_help_uses_active_season_scoring_rules( + self, + user_commands, + mock_interaction, + database, + ): + await database.update_active_scoring_rules( + "111111", + { + "exact_score_points": 5, + "correct_outcome_points": 2, + "wrong_outcome_points": 1, + "late_prediction_points": 1, + }, + ) + + await user_commands.help.callback(user_commands, mock_interaction) + + content = mock_interaction.response_sent[-1]["content"] + assert "Exact score: 5 points" in content + assert "Correct result (win/loss/draw): 2 points" in content + assert "Wrong: 1 point" in content + assert "Late full predictions: 1 point" in content diff --git a/tests/user_commands/test_my_predictions_command.py b/tests/user_commands/test_my_predictions_command.py new file mode 100644 index 0000000..d121f37 --- /dev/null +++ b/tests/user_commands/test_my_predictions_command.py @@ -0,0 +1,90 @@ +from datetime import UTC, datetime, timedelta + +import pytest + + +class TestMyPredictionsCommand: + @pytest.mark.asyncio + async def test_no_open_fixture_shows_error(self, user_commands, mock_interaction): + await user_commands.my_predictions.callback(user_commands, mock_interaction) + + assert "No active fixture" in mock_interaction.response_sent[0]["content"] + + @pytest.mark.asyncio + async def test_only_uses_current_guild_fixtures( + self, user_commands, mock_interaction, database, sample_games + ): + deadline = datetime.now(UTC) + timedelta(days=1) + fixture_id = await database.create_fixture("guild-2", 1, sample_games, deadline) + await database.save_prediction( + fixture_id, + str(mock_interaction.user.id), + mock_interaction.user.name, + ["2-1", "1-1", "0-2"], + False, + ) + + await user_commands.my_predictions.callback(user_commands, mock_interaction) + + assert "No active fixture" in mock_interaction.response_sent[0]["content"] + + @pytest.mark.asyncio + async def test_single_fixture_without_prediction_shows_prompt( + self, user_commands, mock_interaction, database, sample_games + ): + await database.create_fixture( + "111111", 1, sample_games, datetime.now(UTC) + timedelta(days=1) + ) + + await user_commands.my_predictions.callback(user_commands, mock_interaction) + + content = mock_interaction.response_sent[0]["content"] + assert "haven't submitted predictions" in content + assert "Use `/predict`" in content + + @pytest.mark.asyncio + async def test_single_fixture_prediction_shows_saved_scores( + self, user_commands, mock_interaction, database, sample_games + ): + fixture_id = await database.create_fixture( + "111111", 1, sample_games, datetime.now(UTC) + timedelta(days=1) + ) + await database.save_prediction( + fixture_id, + str(mock_interaction.user.id), + mock_interaction.user.name, + ["2-1", "1-1", "0-2"], + False, + ) + + await user_commands.my_predictions.callback(user_commands, mock_interaction) + + content = mock_interaction.response_sent[0]["content"] + assert "Your Predictions:" in content + assert f"1. {sample_games[0]} **2-1**" in content + assert "Status:" in content + assert "Submitted:" in content + + @pytest.mark.asyncio + async def test_multiple_open_fixtures_show_mixed_prediction_state( + self, user_commands, mock_interaction, database, sample_games + ): + deadline = datetime.now(UTC) + timedelta(days=1) + fixture_week_1 = await database.create_fixture("111111", 1, sample_games, deadline) + await database.create_fixture("111111", 2, sample_games, deadline) + await database.save_prediction( + fixture_week_1, + str(mock_interaction.user.id), + mock_interaction.user.name, + ["2-1", "1-1", "0-2"], + False, + ) + + await user_commands.my_predictions.callback(user_commands, mock_interaction) + + content = mock_interaction.response_sent[0]["content"] + assert "Your Predictions (Open Fixtures):" in content + assert "Week 1" in content + assert "Week 2" in content + assert f"1. {sample_games[0]} **2-1**" in content + assert "No prediction submitted yet." in content diff --git a/tests/test_user_commands.py b/tests/user_commands/test_predict_command.py similarity index 78% rename from tests/test_user_commands.py rename to tests/user_commands/test_predict_command.py index 54f0a41..cdacc17 100644 --- a/tests/test_user_commands.py +++ b/tests/user_commands/test_predict_command.py @@ -1,1110 +1,861 @@ -"""Tests for user command wiring.""" - -from datetime import UTC, datetime, timedelta -from unittest.mock import AsyncMock - -import pytest - -from tests.conftest import MockInteraction, MockRole, MockThread, MockUser -from typer_bot.commands.user_commands import ( - ContinuePredictView, - FixtureSelectView, - PredictModal, - UserCommands, -) -from typer_bot.database import Database, SaveResult - - -@pytest.fixture -async def user_commands(mock_bot, database): - mock_bot.db = database - return UserCommands(mock_bot) - - -async def _attach_prediction_threads(user_commands, database, fixture_ids, mock_guild): - threads = {} - for index, fixture_id in enumerate(fixture_ids, start=1): - message_id = str(700000 + index) - await database.update_fixture_announcement( - fixture_id, - message_id=message_id, - channel_id="123456", - ) - threads[int(message_id)] = MockThread( - thread_id=message_id, name=f"week-{fixture_id}", guild=mock_guild - ) - - user_commands.bot.get_channel.side_effect = lambda channel_id: threads.get(channel_id) - return threads - - -class TestPredictCommand: - @pytest.mark.asyncio - async def test_no_fixture_shows_error(self, user_commands, mock_interaction): - await user_commands.predict.callback(user_commands, mock_interaction) - - assert len(mock_interaction.response_sent) == 1 - assert "No active fixture" in mock_interaction.response_sent[0]["content"] - - @pytest.mark.asyncio - @pytest.mark.usefixtures("fixture_with_dm") - async def test_single_open_fixture_opens_predict_modal(self, user_commands, mock_interaction): - await user_commands.predict.callback(user_commands, mock_interaction) - - assert isinstance(mock_interaction.modal_sent["modal"], PredictModal) - - @pytest.mark.asyncio - async def test_multiple_open_fixtures_show_picker( - self, user_commands, mock_interaction, database, sample_games - ): - deadline = datetime.now(UTC) + timedelta(days=1) - await database.create_fixture("111111", 1, sample_games, deadline) - await database.create_fixture("111111", 2, sample_games, deadline) - - await user_commands.predict.callback(user_commands, mock_interaction) - - assert isinstance(mock_interaction.response_sent[0]["view"], FixtureSelectView) - - @pytest.mark.asyncio - async def test_predict_only_uses_current_guild_fixtures( - self, user_commands, mock_interaction, database, sample_games - ): - deadline = datetime.now(UTC) + timedelta(days=1) - await database.create_fixture("guild-2", 1, sample_games, deadline) - - await user_commands.predict.callback(user_commands, mock_interaction) - - assert "No active fixture" in mock_interaction.response_sent[0]["content"] - - @pytest.mark.asyncio - async def test_fixture_picker_rejects_cross_guild_fixture( - self, user_commands, mock_interaction, database, sample_games - ): - deadline = datetime.now(UTC) + timedelta(days=1) - fixture_id = await database.create_fixture("guild-2", 1, sample_games, deadline) - fixture = await database.get_fixture_by_id(fixture_id, "guild-2") - - view = FixtureSelectView( - database, - user_commands.bot, - str(mock_interaction.user.id), - "111111", - [fixture], - ) - select = view.children[0] - select._values = [str(fixture_id)] - await select.callback(mock_interaction) - - assert not hasattr(mock_interaction, "modal_sent") - assert "no longer open" in mock_interaction.response_sent[-1]["content"].lower() - - @pytest.mark.asyncio - async def test_continue_predict_rejects_cross_guild_fixture( - self, user_commands, mock_interaction, database, sample_games - ): - deadline = datetime.now(UTC) + timedelta(days=1) - fixture_id = await database.create_fixture("guild-2", 1, sample_games, deadline) - fixture = await database.get_fixture_by_id(fixture_id, "guild-2") - - view = ContinuePredictView( - database, - user_commands.bot, - str(mock_interaction.user.id), - "111111", - [fixture], - set(), - ) - button = view.children[0] - await button.callback(mock_interaction) - - assert not hasattr(mock_interaction, "modal_sent") - assert "no longer open" in mock_interaction.response_sent[-1]["content"].lower() - - @pytest.mark.asyncio - async def test_multiple_open_fixture_picker_opens_modal_for_selection( - self, user_commands, mock_interaction, database, sample_games - ): - deadline = datetime.now(UTC) + timedelta(days=1) - await database.create_fixture("111111", 1, sample_games, deadline) - await database.create_fixture("111111", 2, sample_games, deadline) - - await user_commands.predict.callback(user_commands, mock_interaction) - - view = mock_interaction.response_sent[0]["view"] - select = view.children[0] - select._values = ["1"] - await select.callback(mock_interaction) - - assert isinstance(mock_interaction.modal_sent["modal"], PredictModal) - - @pytest.mark.asyncio - async def test_multiple_open_fixture_picker_paginates_past_25( - self, user_commands, mock_interaction, database, sample_games - ): - deadline = datetime.now(UTC) + timedelta(days=1) - for week in range(1, 27): - await database.create_fixture("111111", week, sample_games, deadline) - - await user_commands.predict.callback(user_commands, mock_interaction) - - view = mock_interaction.response_sent[0]["view"] - next_button = next( - child for child in view.children if getattr(child, "label", None) == "Next" - ) - await next_button.callback(mock_interaction) - - select = mock_interaction.response_sent[-1]["view"].children[0] - select._values = ["26"] - await select.callback(mock_interaction) - - assert mock_interaction.modal_sent["modal"].title == "Predict Week 26" - - @pytest.mark.asyncio - @pytest.mark.usefixtures("fixture_with_dm") - async def test_predict_modal_prefills_existing_prediction( - self, user_commands, mock_interaction, database - ): - await database.save_prediction( - 1, - str(mock_interaction.user.id), - mock_interaction.user.name, - ["2-1", "1-1", "0-2"], - False, - ) - - await user_commands.predict.callback(user_commands, mock_interaction) - - modal = mock_interaction.modal_sent["modal"] - assert modal.predictions_input.default == ( - "Team A - Team B 2-1\nTeam C - Team D 1-1\nTeam E - Team F 0-2" - ) - - @pytest.mark.asyncio - @pytest.mark.usefixtures("fixture_with_dm") - async def test_predict_modal_shows_parse_errors(self, user_commands, mock_interaction): - await _attach_prediction_threads( - user_commands, user_commands.db, [1], mock_interaction.guild - ) - await user_commands.predict.callback(user_commands, mock_interaction) - - modal = mock_interaction.modal_sent["modal"] - modal.predictions_input._value = "Team A - Team B\nTeam C - Team D\nTeam E - Team F" - await modal.on_submit(mock_interaction) - - assert "Could not find score" in mock_interaction.response_sent[-1]["content"] - - @pytest.mark.asyncio - @pytest.mark.usefixtures("fixture_with_dm") - async def test_predict_modal_saves_prediction_and_offers_continue( - self, user_commands, mock_interaction, database, sample_games - ): - deadline = datetime.now(UTC) + timedelta(days=1) - fixture_two_id = await database.create_fixture("111111", 2, sample_games, deadline) - await _attach_prediction_threads( - user_commands, database, [1, fixture_two_id], mock_interaction.guild - ) - - await user_commands.predict.callback(user_commands, mock_interaction) - - picker = mock_interaction.response_sent[0]["view"] - select = picker.children[0] - select._values = ["1"] - await select.callback(mock_interaction) - - modal = mock_interaction.modal_sent["modal"] - modal.predictions_input._value = ( - "Team A - Team B 2-1\nTeam C - Team D 1-1\nTeam E - Team F 0-2" - ) - await modal.on_submit(mock_interaction) - - assert await database.get_prediction(1, str(mock_interaction.user.id), "111111") is not None - assert isinstance(mock_interaction.response_sent[-1]["view"], ContinuePredictView) - - @pytest.mark.asyncio - @pytest.mark.usefixtures("fixture_with_dm") - async def test_predict_modal_terminal_success_without_other_open_fixtures( - self, user_commands, mock_interaction, database - ): - await _attach_prediction_threads(user_commands, database, [1], mock_interaction.guild) - await user_commands.predict.callback(user_commands, mock_interaction) - - modal = mock_interaction.modal_sent["modal"] - modal.predictions_input._value = ( - "Team A - Team B 2-1\nTeam C - Team D 1-1\nTeam E - Team F 0-2" - ) - await modal.on_submit(mock_interaction) - - assert await database.get_prediction(1, str(mock_interaction.user.id), "111111") is not None - assert "You're done for now." in mock_interaction.response_sent[-1]["content"] - assert "view" not in mock_interaction.response_sent[-1] - - @pytest.mark.asyncio - @pytest.mark.usefixtures("fixture_with_dm") - async def test_predict_modal_overwrites_existing_prediction( - self, user_commands, mock_interaction, database - ): - await _attach_prediction_threads(user_commands, database, [1], mock_interaction.guild) - await database.save_prediction( - 1, - str(mock_interaction.user.id), - mock_interaction.user.name, - ["2-1", "1-1", "0-2"], - False, - ) - - await user_commands.predict.callback(user_commands, mock_interaction) - - modal = mock_interaction.modal_sent["modal"] - modal.predictions_input._value = ( - "Team A - Team B 3-0\nTeam C - Team D 0-0\nTeam E - Team F 1-1" - ) - await modal.on_submit(mock_interaction) - - prediction = await database.get_prediction(1, str(mock_interaction.user.id), "111111") - assert prediction is not None - assert prediction["predictions"] == ["3-0", "0-0", "1-1"] - - @pytest.mark.asyncio - @pytest.mark.usefixtures("fixture_with_dm") - async def test_predict_modal_marks_late_prediction( - self, user_commands, mock_interaction, database - ): - await database.update_active_scoring_rules("111111", {"late_prediction_points": 1}) - await _attach_prediction_threads(user_commands, database, [1], mock_interaction.guild) - fixture = await database.get_fixture_by_id(1, "111111") - assert fixture is not None - fixture["deadline"] = datetime.now(UTC) - timedelta(minutes=1) - user_commands.db.get_open_fixtures = AsyncMock(return_value=[fixture]) - user_commands.db.get_fixture_by_id = AsyncMock( - side_effect=lambda fixture_id, guild_id: ( - fixture if (fixture_id, guild_id) == (1, "111111") else None - ) - ) - - await user_commands.predict.callback(user_commands, mock_interaction) - - modal = mock_interaction.modal_sent["modal"] - modal.predictions_input._value = ( - "Team A - Team B 2-1\nTeam C - Team D 1-1\nTeam E - Team F 0-2" - ) - await modal.on_submit(mock_interaction) - - prediction = await database.get_prediction(1, str(mock_interaction.user.id), "111111") - assert prediction is not None - assert prediction["is_late"] == 1 - content = mock_interaction.response_sent[-1]["content"] - assert "Late prediction" in content - assert "active season's late penalty" in content - assert "0 points" not in content - - @pytest.mark.asyncio - @pytest.mark.usefixtures("fixture_with_dm") - async def test_predict_modal_accepts_pre_deadline_partial_prediction( - self, user_commands, mock_interaction, database - ): - await _attach_prediction_threads(user_commands, database, [1], mock_interaction.guild) - await user_commands.predict.callback(user_commands, mock_interaction) - - modal = mock_interaction.modal_sent["modal"] - modal.predictions_input._value = "Team C - Team D 1-1\nTeam E - Team F 0-2" - await modal.on_submit(mock_interaction) - - prediction = await database.get_prediction(1, str(mock_interaction.user.id), "111111") - assert prediction is not None - assert prediction["predictions"] == ["1-1", "0-2"] - assert prediction["predicted_game_indexes"] == [1, 2] - assert prediction["pending_partial_approval"] is False - assert "Partial prediction saved" in mock_interaction.response_sent[-1]["content"] - assert "fill the rest" in mock_interaction.response_sent[-1]["content"] - - @pytest.mark.asyncio - @pytest.mark.usefixtures("fixture_with_dm") - async def test_predict_modal_marks_late_partial_as_pending( - self, user_commands, mock_interaction, database - ): - await _attach_prediction_threads(user_commands, database, [1], mock_interaction.guild) - admin_role = MockRole("League Admin", role_id=4242) - await database.upsert_guild_config("111111", str(admin_role.id), "123456") - fixture = await database.get_fixture_by_id(1, "111111") - assert fixture is not None - fixture["deadline"] = datetime.now(UTC) - timedelta(minutes=1) - user_commands.db.get_open_fixtures = AsyncMock(return_value=[fixture]) - user_commands.db.get_fixture_by_id = AsyncMock( - side_effect=lambda fixture_id, guild_id: ( - fixture if (fixture_id, guild_id) == (1, "111111") else None - ) - ) - - await user_commands.predict.callback(user_commands, mock_interaction) - modal = mock_interaction.modal_sent["modal"] - modal.predictions_input._value = "Team C - Team D 1-1\nTeam E - Team F 0-2" - await modal.on_submit(mock_interaction) - - prediction = await database.get_prediction(1, str(mock_interaction.user.id), "111111") - assert prediction is not None - assert prediction["pending_partial_approval"] is True - assert prediction["predicted_game_indexes"] == [1, 2] - assert prediction["public_message_id"] == "1" - assert prediction["public_message_kind"] == "bot_post" - assert ( - "Late prediction awaiting admin review" in mock_interaction.response_sent[-1]["content"] - ) - assert "0 points" not in mock_interaction.response_sent[-1]["content"] - thread = user_commands.bot.get_channel(700001) - assert f"<@&{admin_role.id}>" in thread.messages_sent[-1]["content"] - - @pytest.mark.asyncio - async def test_predict_modal_without_setup_does_not_ping_legacy_admin_role( - self, - mock_bot, - mock_interaction, - temp_db_path, - sample_games, - ): - database = Database(temp_db_path) - await database.initialize() - mock_bot.db = database - user_commands = UserCommands(mock_bot) - deadline = datetime.now(UTC) - timedelta(minutes=1) - fixture_id = await database.create_fixture("111111", 1, sample_games, deadline) - await _attach_prediction_threads( - user_commands, database, [fixture_id], mock_interaction.guild - ) - mock_interaction.guild.roles = [MockRole("typer-admin", role_id=4242)] - - fixture = await database.get_fixture_by_id(fixture_id, "111111") - assert fixture is not None - user_commands.db.get_open_fixtures = AsyncMock(return_value=[fixture]) - user_commands.db.get_fixture_by_id = AsyncMock( - side_effect=lambda request_fixture_id, guild_id: ( - fixture if (request_fixture_id, guild_id) == (fixture_id, "111111") else None - ) - ) - - await user_commands.predict.callback(user_commands, mock_interaction) - modal = mock_interaction.modal_sent["modal"] - modal.predictions_input._value = "Team C - Team D 1-1\nTeam E - Team F 0-2" - await modal.on_submit(mock_interaction) - - thread = user_commands.bot.get_channel(700001) - assert "awaiting admin review" in thread.messages_sent[-1]["content"] - assert "<@&4242>" not in thread.messages_sent[-1]["content"] - - @pytest.mark.asyncio - @pytest.mark.usefixtures("fixture_with_dm") - async def test_predict_modal_replaces_previous_pending_bot_post( - self, user_commands, mock_interaction, database - ): - await _attach_prediction_threads(user_commands, database, [1], mock_interaction.guild) - fixture = await database.get_fixture_by_id(1, "111111") - assert fixture is not None - fixture["deadline"] = datetime.now(UTC) - timedelta(minutes=1) - user_commands.db.get_open_fixtures = AsyncMock(return_value=[fixture]) - user_commands.db.get_fixture_by_id = AsyncMock( - side_effect=lambda fixture_id, guild_id: ( - fixture if (fixture_id, guild_id) == (1, "111111") else None - ) - ) - - await user_commands.predict.callback(user_commands, mock_interaction) - first_modal = mock_interaction.modal_sent["modal"] - first_modal.predictions_input._value = "Team C - Team D 1-1\nTeam E - Team F 0-2" - await first_modal.on_submit(mock_interaction) - - thread = user_commands.bot.get_channel(700001) - first_public_message = thread.message_objects[1] - - await user_commands.predict.callback(user_commands, mock_interaction) - second_modal = mock_interaction.modal_sent["modal"] - second_modal.predictions_input._value = "Team A - Team B 2-0\nTeam C - Team D 1-1" - await second_modal.on_submit(mock_interaction) - - prediction = await database.get_prediction(1, str(mock_interaction.user.id), "111111") - assert prediction is not None - assert prediction["public_message_id"] == "2" - first_public_message.delete.assert_awaited_once() - - @pytest.mark.asyncio - @pytest.mark.usefixtures("fixture_with_dm") - async def test_my_predictions_shows_sparse_pending_prediction( - self, user_commands, mock_interaction, database - ): - await database.save_prediction( - 1, - str(mock_interaction.user.id), - mock_interaction.user.name, - ["1-1", "0-2"], - True, - predicted_game_indexes=[1, 2], - pending_partial_approval=True, - ) - - await user_commands.my_predictions.callback(user_commands, mock_interaction) - - content = mock_interaction.response_sent[-1]["content"] - assert "2. Team C - Team D **1-1**" in content - assert "3. Team E - Team F **0-2**" in content - assert "Late prediction awaiting admin review" in content - - @pytest.mark.asyncio - @pytest.mark.usefixtures("fixture_with_dm") - async def test_predict_modal_reports_closed_fixture_during_submit( - self, user_commands, mock_interaction, monkeypatch - ): - await _attach_prediction_threads( - user_commands, user_commands.db, [1], mock_interaction.guild - ) - await user_commands.predict.callback(user_commands, mock_interaction) - - modal = mock_interaction.modal_sent["modal"] - modal.predictions_input._value = ( - "Team A - Team B 2-1\nTeam C - Team D 1-1\nTeam E - Team F 0-2" - ) - monkeypatch.setattr( - user_commands.db, - "save_prediction_guarded", - AsyncMock(return_value=SaveResult.FIXTURE_CLOSED), - ) - - await modal.on_submit(mock_interaction) - - assert ( - "closed before your prediction could be saved" - in mock_interaction.response_sent[-1]["content"] - ) - - @pytest.mark.asyncio - @pytest.mark.usefixtures("fixture_with_dm") - async def test_predict_modal_reports_database_error( - self, user_commands, mock_interaction, monkeypatch - ): - await _attach_prediction_threads( - user_commands, user_commands.db, [1], mock_interaction.guild - ) - await user_commands.predict.callback(user_commands, mock_interaction) - - modal = mock_interaction.modal_sent["modal"] - modal.predictions_input._value = ( - "Team A - Team B 2-1\nTeam C - Team D 1-1\nTeam E - Team F 0-2" - ) - - async def _raise(*_args, **_kwargs): - raise RuntimeError("db failed") - - monkeypatch.setattr(user_commands.db, "save_prediction_guarded", _raise) - await modal.on_submit(mock_interaction) - - assert ( - "Something went wrong while saving your prediction" - in mock_interaction.response_sent[-1]["content"] - ) - thread = user_commands.bot.get_channel(700001) - thread.message_objects[1].delete.assert_awaited_once() - - @pytest.mark.asyncio - @pytest.mark.usefixtures("fixture_with_dm") - async def test_predict_modal_reports_missing_prediction_thread( - self, user_commands, mock_interaction - ): - await user_commands.predict.callback(user_commands, mock_interaction) - - modal = mock_interaction.modal_sent["modal"] - modal.predictions_input._value = ( - "Team A - Team B 2-1\nTeam C - Team D 1-1\nTeam E - Team F 0-2" - ) - await modal.on_submit(mock_interaction) - - assert ( - "does not have a usable prediction thread" - in mock_interaction.response_sent[-1]["content"] - ) - assert ( - await user_commands.db.get_prediction(1, str(mock_interaction.user.id), "111111") - is None - ) - - @pytest.mark.asyncio - @pytest.mark.usefixtures("fixture_with_dm") - async def test_predict_modal_uses_fetch_channel_fallback( - self, user_commands, mock_interaction, database - ): - thread = MockThread(thread_id="700001", name="week-1", guild=mock_interaction.guild) - await database.update_fixture_announcement(1, message_id="700001", channel_id="123456") - user_commands.bot.get_channel.return_value = None - user_commands.bot.fetch_channel = AsyncMock(return_value=thread) - - await user_commands.predict.callback(user_commands, mock_interaction) - - modal = mock_interaction.modal_sent["modal"] - modal.predictions_input._value = ( - "Team A - Team B 2-1\nTeam C - Team D 1-1\nTeam E - Team F 0-2" - ) - await modal.on_submit(mock_interaction) - - assert await database.get_prediction(1, str(mock_interaction.user.id), "111111") is not None - user_commands.bot.fetch_channel.assert_awaited_once_with(700001) - - @pytest.mark.asyncio - @pytest.mark.usefixtures("fixture_with_dm") - async def test_predict_modal_reports_thread_post_failure( - self, user_commands, mock_interaction, database, monkeypatch - ): - await _attach_prediction_threads(user_commands, database, [1], mock_interaction.guild) - thread = user_commands.bot.get_channel(700001) - - import discord - - async def raise_http_exception(*_args, **_kwargs): - raise discord.HTTPException(response=AsyncMock(status=500), message="boom") - - monkeypatch.setattr(thread, "send", raise_http_exception) - - await user_commands.predict.callback(user_commands, mock_interaction) - modal = mock_interaction.modal_sent["modal"] - modal.predictions_input._value = ( - "Team A - Team B 2-1\nTeam C - Team D 1-1\nTeam E - Team F 0-2" - ) - await modal.on_submit(mock_interaction) - - assert ( - "does not have a usable prediction thread" - in mock_interaction.response_sent[-1]["content"] - ) - assert await database.get_prediction(1, str(mock_interaction.user.id), "111111") is None - - @pytest.mark.asyncio - @pytest.mark.usefixtures("fixture_with_dm") - async def test_predict_modal_deletes_public_post_if_fixture_closes_during_save( - self, user_commands, mock_interaction, monkeypatch - ): - await _attach_prediction_threads( - user_commands, user_commands.db, [1], mock_interaction.guild - ) - await user_commands.predict.callback(user_commands, mock_interaction) - - modal = mock_interaction.modal_sent["modal"] - modal.predictions_input._value = ( - "Team A - Team B 2-1\nTeam C - Team D 1-1\nTeam E - Team F 0-2" - ) - monkeypatch.setattr( - user_commands.db, - "save_prediction_guarded", - AsyncMock(return_value=SaveResult.FIXTURE_CLOSED), - ) - - await modal.on_submit(mock_interaction) - - thread = user_commands.bot.get_channel(700001) - thread.message_objects[1].delete.assert_awaited_once() - assert ( - "closed before your prediction could be saved" - in mock_interaction.response_sent[-1]["content"] - ) - - @pytest.mark.asyncio - @pytest.mark.usefixtures("fixture_with_dm") - async def test_predict_modal_rejects_empty_partial_parse_result( - self, user_commands, mock_interaction, database - ): - await _attach_prediction_threads(user_commands, database, [1], mock_interaction.guild) - await user_commands.predict.callback(user_commands, mock_interaction) - - modal = mock_interaction.modal_sent["modal"] - modal.predictions_input._value = "," - await modal.on_submit(mock_interaction) - - assert ( - "Please enter at least one prediction before submitting." - in mock_interaction.response_sent[-1]["content"] - ) - assert await database.get_prediction(1, str(mock_interaction.user.id), "111111") is None - - @pytest.mark.asyncio - @pytest.mark.usefixtures("fixture_with_dm") - async def test_continue_predict_button_opens_next_modal( - self, user_commands, mock_interaction, database, sample_games - ): - deadline = datetime.now(UTC) + timedelta(days=1) - fixture_two_id = await database.create_fixture("111111", 2, sample_games, deadline) - await _attach_prediction_threads( - user_commands, database, [1, fixture_two_id], mock_interaction.guild - ) - - await user_commands.predict.callback(user_commands, mock_interaction) - picker = mock_interaction.response_sent[0]["view"] - select = picker.children[0] - select._values = ["1"] - await select.callback(mock_interaction) - - modal = mock_interaction.modal_sent["modal"] - modal.predictions_input._value = ( - "Team A - Team B 2-1\nTeam C - Team D 1-1\nTeam E - Team F 0-2" - ) - await modal.on_submit(mock_interaction) - - continue_view = mock_interaction.response_sent[-1]["view"] - button = continue_view.children[0] - await button.callback(mock_interaction) - - assert mock_interaction.modal_sent["modal"].title == "Predict Week 2" - - @pytest.mark.asyncio - async def test_multi_fixture_flow_ends_without_continue_view_after_last_save( - self, user_commands, mock_interaction, database, sample_games - ): - deadline = datetime.now(UTC) + timedelta(days=1) - fixture_one_id = await database.create_fixture("111111", 1, sample_games, deadline) - fixture_two_id = await database.create_fixture("111111", 2, sample_games, deadline) - await _attach_prediction_threads( - user_commands, database, [fixture_one_id, fixture_two_id], mock_interaction.guild - ) - - await user_commands.predict.callback(user_commands, mock_interaction) - picker = mock_interaction.response_sent[0]["view"] - select = picker.children[0] - select._values = ["1"] - await select.callback(mock_interaction) - - first_modal = mock_interaction.modal_sent["modal"] - first_modal.predictions_input._value = ( - "Team A - Team B 2-1\nTeam C - Team D 1-1\nTeam E - Team F 0-2" - ) - await first_modal.on_submit(mock_interaction) - - continue_view = mock_interaction.response_sent[-1]["view"] - continue_button = continue_view.children[0] - await continue_button.callback(mock_interaction) - - second_modal = mock_interaction.modal_sent["modal"] - second_modal.predictions_input._value = ( - "Team A - Team B 1-0\nTeam C - Team D 2-2\nTeam E - Team F 3-1" - ) - await second_modal.on_submit(mock_interaction) - - assert await database.get_prediction(1, str(mock_interaction.user.id), "111111") is not None - assert await database.get_prediction(2, str(mock_interaction.user.id), "111111") is not None - assert "You're done for now." in mock_interaction.response_sent[-1]["content"] - assert "view" not in mock_interaction.response_sent[-1] - - @pytest.mark.asyncio - async def test_continue_predict_view_paginates_past_25( - self, user_commands, mock_interaction, database, sample_games - ): - deadline = datetime.now(UTC) + timedelta(days=1) - fixture_ids = [] - for week in range(1, 28): - fixture_ids.append( - await database.create_fixture("111111", week, sample_games, deadline) - ) - await _attach_prediction_threads( - user_commands, database, fixture_ids, mock_interaction.guild - ) - - await user_commands.predict.callback(user_commands, mock_interaction) - picker = mock_interaction.response_sent[0]["view"] - select = picker.children[0] - select._values = ["1"] - await select.callback(mock_interaction) - - first_modal = mock_interaction.modal_sent["modal"] - first_modal.predictions_input._value = ( - "Team A - Team B 2-1\nTeam C - Team D 1-1\nTeam E - Team F 0-2" - ) - await first_modal.on_submit(mock_interaction) - - continue_view = mock_interaction.response_sent[-1]["view"] - next_button = next( - child for child in continue_view.children if getattr(child, "label", None) == "Next" - ) - await next_button.callback(mock_interaction) - - paged_continue_view = mock_interaction.response_sent[-1]["view"] - week_27_button = next( - child - for child in paged_continue_view.children - if getattr(child, "label", None) == "Predict Week 27" - ) - await week_27_button.callback(mock_interaction) - - assert mock_interaction.modal_sent["modal"].title == "Predict Week 27" - - @pytest.mark.asyncio - async def test_fixture_picker_rejects_wrong_user( - self, user_commands, mock_interaction, database, sample_games - ): - deadline = datetime.now(UTC) + timedelta(days=1) - await database.create_fixture("111111", 1, sample_games, deadline) - await database.create_fixture("111111", 2, sample_games, deadline) - - await user_commands.predict.callback(user_commands, mock_interaction) - - other_user_interaction = MockInteraction( - user=MockUser("999", "OtherUser"), - guild=mock_interaction.guild, - channel=mock_interaction.channel, - ) - view = mock_interaction.response_sent[0]["view"] - select = view.children[0] - select._values = ["1"] - await select.callback(other_user_interaction) - - assert ( - "don't have permission" in other_user_interaction.response_sent[-1]["content"].lower() - ) - - @pytest.mark.asyncio - async def test_fixture_picker_reports_closed_fixture( - self, user_commands, mock_interaction, database, sample_games - ): - deadline = datetime.now(UTC) + timedelta(days=1) - await database.create_fixture("111111", 1, sample_games, deadline) - await database.create_fixture("111111", 2, sample_games, deadline) - - await user_commands.predict.callback(user_commands, mock_interaction) - - await database.save_scores( - 1, - [ - { - "user_id": "u1", - "user_name": "User One", - "points": 0, - "exact_scores": 0, - "correct_results": 0, - } - ], - ) - view = mock_interaction.response_sent[0]["view"] - select = view.children[0] - select._values = ["1"] - await select.callback(mock_interaction) - - assert "no longer open" in mock_interaction.response_sent[-1]["content"].lower() - assert mock_interaction.response_sent[-1]["view"] is None - - @pytest.mark.asyncio - async def test_continue_predict_button_rejects_wrong_user( - self, user_commands, mock_interaction, database, sample_games - ): - deadline = datetime.now(UTC) + timedelta(days=1) - fixture_one_id = await database.create_fixture("111111", 1, sample_games, deadline) - fixture_two_id = await database.create_fixture("111111", 2, sample_games, deadline) - await _attach_prediction_threads( - user_commands, database, [fixture_one_id, fixture_two_id], mock_interaction.guild - ) - - await user_commands.predict.callback(user_commands, mock_interaction) - picker = mock_interaction.response_sent[0]["view"] - select = picker.children[0] - select._values = ["1"] - await select.callback(mock_interaction) - - modal = mock_interaction.modal_sent["modal"] - modal.predictions_input._value = ( - "Team A - Team B 2-1\nTeam C - Team D 1-1\nTeam E - Team F 0-2" - ) - await modal.on_submit(mock_interaction) - - other_user_interaction = MockInteraction( - user=MockUser("999", "OtherUser"), - guild=mock_interaction.guild, - channel=mock_interaction.channel, - ) - continue_view = mock_interaction.response_sent[-1]["view"] - button = continue_view.children[0] - await button.callback(other_user_interaction) - - assert ( - "don't have permission" in other_user_interaction.response_sent[-1]["content"].lower() - ) - - @pytest.mark.asyncio - async def test_continue_predict_button_reports_closed_fixture( - self, user_commands, mock_interaction, database, sample_games - ): - deadline = datetime.now(UTC) + timedelta(days=1) - fixture_one_id = await database.create_fixture("111111", 1, sample_games, deadline) - fixture_two_id = await database.create_fixture("111111", 2, sample_games, deadline) - await _attach_prediction_threads( - user_commands, database, [fixture_one_id, fixture_two_id], mock_interaction.guild - ) - - await user_commands.predict.callback(user_commands, mock_interaction) - picker = mock_interaction.response_sent[0]["view"] - select = picker.children[0] - select._values = ["1"] - await select.callback(mock_interaction) - - modal = mock_interaction.modal_sent["modal"] - modal.predictions_input._value = ( - "Team A - Team B 2-1\nTeam C - Team D 1-1\nTeam E - Team F 0-2" - ) - await modal.on_submit(mock_interaction) - await database.save_scores( - fixture_two_id, - [ - { - "user_id": "u1", - "user_name": "User One", - "points": 0, - "exact_scores": 0, - "correct_results": 0, - } - ], - ) - - continue_view = mock_interaction.response_sent[-1]["view"] - button = continue_view.children[0] - await button.callback(mock_interaction) - - assert "no longer open" in mock_interaction.response_sent[-1]["content"].lower() - assert mock_interaction.response_sent[-1]["view"] is None - - -class TestFixturesCommand: - @pytest.mark.asyncio - async def test_no_open_fixture_shows_error(self, user_commands, mock_interaction): - await user_commands.fixtures.callback(user_commands, mock_interaction) - - assert "No active fixture" in mock_interaction.response_sent[0]["content"] - - @pytest.mark.asyncio - async def test_single_open_fixture_lists_games_and_deadline( - self, user_commands, mock_interaction, database, sample_games - ): - await database.create_fixture( - "111111", 1, sample_games, datetime.now(UTC) + timedelta(days=1) - ) - - await user_commands.fixtures.callback(user_commands, mock_interaction) - - content = mock_interaction.response_sent[0]["content"] - assert "Week 1 Fixtures" in content - assert sample_games[0] in content - assert "Deadline:" in content - assert mock_interaction.response_sent[0]["ephemeral"] is True - - @pytest.mark.asyncio - async def test_multiple_open_fixtures_list_each_week( - self, user_commands, mock_interaction, database, sample_games - ): - deadline = datetime.now(UTC) + timedelta(days=1) - await database.create_fixture("111111", 1, sample_games, deadline) - await database.create_fixture("111111", 2, sample_games, deadline) - - await user_commands.fixtures.callback(user_commands, mock_interaction) - - content = mock_interaction.response_sent[0]["content"] - assert "Open Fixtures" in content - assert "Week 1" in content - assert "Week 2" in content - - @pytest.mark.asyncio - async def test_fixtures_only_shows_current_guild( - self, user_commands, mock_interaction, database, sample_games - ): - deadline = datetime.now(UTC) + timedelta(days=1) - await database.create_fixture("111111", 1, sample_games, deadline) - await database.create_fixture("guild-2", 2, sample_games, deadline) - - await user_commands.fixtures.callback(user_commands, mock_interaction) - - content = mock_interaction.response_sent[0]["content"] - assert "Week 1" in content - assert "Week 2" not in content - - -class TestStandingsCommand: - @pytest.mark.asyncio - async def test_standings_sends_empty_state(self, user_commands, mock_interaction): - await user_commands.standings.callback(user_commands, mock_interaction) - - assert "No standings yet" in mock_interaction.response_sent[0]["content"] - assert mock_interaction.response_sent[0]["ephemeral"] is True - - @pytest.mark.asyncio - async def test_standings_sends_formatted_leaderboard(self, user_commands, mock_interaction): - standings = [ - { - "user_id": "123", - "user_name": "User1", - "total_points": 9, - "total_exact": 3, - "total_correct": 3, - } - ] - last_fixture = { - "week_number": 4, - "games": ["A - B"], - "results": ["2-1"], - "scores": [ - { - "user_id": "123", - "user_name": "User1", - "points": 3, - "exact_scores": 1, - "correct_results": 1, - } - ], - } - user_commands.db.get_standings = AsyncMock(return_value=standings) - user_commands.db.get_last_fixture_scores = AsyncMock(return_value=last_fixture) - - await user_commands.standings.callback(user_commands, mock_interaction) - - content = mock_interaction.response_sent[0]["content"] - assert "User1" in content - assert "9" in content - - @pytest.mark.asyncio - async def test_standings_only_shows_current_guild_scores( - self, user_commands, mock_interaction, database - ): - games = ["Team A - Team B"] - deadline = datetime.now(UTC) - timedelta(days=1) - current_fixture_id = await database.create_fixture("111111", 1, games, deadline) - other_fixture_id = await database.create_fixture("guild-2", 2, games, deadline) - await database.save_scores( - current_fixture_id, - [ - { - "user_id": "current-user", - "user_name": "Current Guild", - "points": 3, - "exact_scores": 1, - "correct_results": 0, - } - ], - ) - await database.save_scores( - other_fixture_id, - [ - { - "user_id": "other-user", - "user_name": "Other Guild", - "points": 9, - "exact_scores": 3, - "correct_results": 3, - } - ], - ) - - await user_commands.standings.callback(user_commands, mock_interaction) - - content = mock_interaction.response_sent[0]["content"] - assert "Current Guild" in content - assert "Other Guild" not in content - - -class TestMyPredictionsCommand: - @pytest.mark.asyncio - async def test_no_open_fixture_shows_error(self, user_commands, mock_interaction): - await user_commands.my_predictions.callback(user_commands, mock_interaction) - - assert "No active fixture" in mock_interaction.response_sent[0]["content"] - - @pytest.mark.asyncio - async def test_only_uses_current_guild_fixtures( - self, user_commands, mock_interaction, database, sample_games - ): - deadline = datetime.now(UTC) + timedelta(days=1) - fixture_id = await database.create_fixture("guild-2", 1, sample_games, deadline) - await database.save_prediction( - fixture_id, - str(mock_interaction.user.id), - mock_interaction.user.name, - ["2-1", "1-1", "0-2"], - False, - ) - - await user_commands.my_predictions.callback(user_commands, mock_interaction) - - assert "No active fixture" in mock_interaction.response_sent[0]["content"] - - @pytest.mark.asyncio - async def test_single_fixture_without_prediction_shows_prompt( - self, user_commands, mock_interaction, database, sample_games - ): - await database.create_fixture( - "111111", 1, sample_games, datetime.now(UTC) + timedelta(days=1) - ) - - await user_commands.my_predictions.callback(user_commands, mock_interaction) - - content = mock_interaction.response_sent[0]["content"] - assert "haven't submitted predictions" in content - assert "Use `/predict`" in content - - @pytest.mark.asyncio - async def test_single_fixture_prediction_shows_saved_scores( - self, user_commands, mock_interaction, database, sample_games - ): - fixture_id = await database.create_fixture( - "111111", 1, sample_games, datetime.now(UTC) + timedelta(days=1) - ) - await database.save_prediction( - fixture_id, - str(mock_interaction.user.id), - mock_interaction.user.name, - ["2-1", "1-1", "0-2"], - False, - ) - - await user_commands.my_predictions.callback(user_commands, mock_interaction) - - content = mock_interaction.response_sent[0]["content"] - assert "Your Predictions:" in content - assert f"1. {sample_games[0]} **2-1**" in content - assert "Status:" in content - assert "Submitted:" in content - - @pytest.mark.asyncio - async def test_multiple_open_fixtures_show_mixed_prediction_state( - self, user_commands, mock_interaction, database, sample_games - ): - deadline = datetime.now(UTC) + timedelta(days=1) - fixture_week_1 = await database.create_fixture("111111", 1, sample_games, deadline) - await database.create_fixture("111111", 2, sample_games, deadline) - await database.save_prediction( - fixture_week_1, - str(mock_interaction.user.id), - mock_interaction.user.name, - ["2-1", "1-1", "0-2"], - False, - ) - - await user_commands.my_predictions.callback(user_commands, mock_interaction) - - content = mock_interaction.response_sent[0]["content"] - assert "Your Predictions (Open Fixtures):" in content - assert "Week 1" in content - assert "Week 2" in content - assert f"1. {sample_games[0]} **2-1**" in content - assert "No prediction submitted yet." in content - - -class TestHelpCommand: - @pytest.mark.asyncio - async def test_help_uses_active_season_scoring_rules( - self, - user_commands, - mock_interaction, - database, - ): - await database.update_active_scoring_rules( - "111111", - { - "exact_score_points": 5, - "correct_outcome_points": 2, - "wrong_outcome_points": 1, - "late_prediction_points": 1, - }, - ) - - await user_commands.help.callback(user_commands, mock_interaction) - - content = mock_interaction.response_sent[-1]["content"] - assert "Exact score: 5 points" in content - assert "Correct result (win/loss/draw): 2 points" in content - assert "Wrong: 1 point" in content - assert "Late full predictions: 1 point" in content +from datetime import UTC, datetime, timedelta +from unittest.mock import AsyncMock + +import pytest + +from tests.conftest import MockInteraction, MockRole, MockThread, MockUser +from typer_bot.commands.user_commands import ( + ContinuePredictView, + FixtureSelectView, + PredictModal, + UserCommands, +) +from typer_bot.database import Database, SaveResult + + +async def _attach_prediction_threads(user_commands, database, fixture_ids, mock_guild): + """Attach fixture announcement IDs to mock threads for public prediction posts.""" + threads = {} + for index, fixture_id in enumerate(fixture_ids, start=1): + message_id = str(700000 + index) + await database.update_fixture_announcement( + fixture_id, + message_id=message_id, + channel_id="123456", + ) + threads[int(message_id)] = MockThread( + thread_id=message_id, name=f"week-{fixture_id}", guild=mock_guild + ) + + user_commands.bot.get_channel.side_effect = lambda channel_id: threads.get(channel_id) + return threads + + +class TestPredictCommand: + @pytest.mark.asyncio + async def test_no_fixture_shows_error(self, user_commands, mock_interaction): + await user_commands.predict.callback(user_commands, mock_interaction) + + assert len(mock_interaction.response_sent) == 1 + assert "No active fixture" in mock_interaction.response_sent[0]["content"] + + @pytest.mark.asyncio + @pytest.mark.usefixtures("fixture_with_dm") + async def test_single_open_fixture_opens_predict_modal(self, user_commands, mock_interaction): + await user_commands.predict.callback(user_commands, mock_interaction) + + assert isinstance(mock_interaction.modal_sent["modal"], PredictModal) + + @pytest.mark.asyncio + async def test_multiple_open_fixtures_show_picker( + self, user_commands, mock_interaction, database, sample_games + ): + deadline = datetime.now(UTC) + timedelta(days=1) + await database.create_fixture("111111", 1, sample_games, deadline) + await database.create_fixture("111111", 2, sample_games, deadline) + + await user_commands.predict.callback(user_commands, mock_interaction) + + assert isinstance(mock_interaction.response_sent[0]["view"], FixtureSelectView) + + @pytest.mark.asyncio + async def test_predict_only_uses_current_guild_fixtures( + self, user_commands, mock_interaction, database, sample_games + ): + deadline = datetime.now(UTC) + timedelta(days=1) + await database.create_fixture("guild-2", 1, sample_games, deadline) + + await user_commands.predict.callback(user_commands, mock_interaction) + + assert "No active fixture" in mock_interaction.response_sent[0]["content"] + + @pytest.mark.asyncio + async def test_fixture_picker_rejects_cross_guild_fixture( + self, user_commands, mock_interaction, database, sample_games + ): + deadline = datetime.now(UTC) + timedelta(days=1) + fixture_id = await database.create_fixture("guild-2", 1, sample_games, deadline) + fixture = await database.get_fixture_by_id(fixture_id, "guild-2") + + view = FixtureSelectView( + database, + user_commands.bot, + str(mock_interaction.user.id), + "111111", + [fixture], + ) + select = view.children[0] + select._values = [str(fixture_id)] + await select.callback(mock_interaction) + + assert not hasattr(mock_interaction, "modal_sent") + assert "no longer open" in mock_interaction.response_sent[-1]["content"].lower() + + @pytest.mark.asyncio + async def test_continue_predict_rejects_cross_guild_fixture( + self, user_commands, mock_interaction, database, sample_games + ): + deadline = datetime.now(UTC) + timedelta(days=1) + fixture_id = await database.create_fixture("guild-2", 1, sample_games, deadline) + fixture = await database.get_fixture_by_id(fixture_id, "guild-2") + + view = ContinuePredictView( + database, + user_commands.bot, + str(mock_interaction.user.id), + "111111", + [fixture], + set(), + ) + button = view.children[0] + await button.callback(mock_interaction) + + assert not hasattr(mock_interaction, "modal_sent") + assert "no longer open" in mock_interaction.response_sent[-1]["content"].lower() + + @pytest.mark.asyncio + async def test_multiple_open_fixture_picker_opens_modal_for_selection( + self, user_commands, mock_interaction, database, sample_games + ): + deadline = datetime.now(UTC) + timedelta(days=1) + await database.create_fixture("111111", 1, sample_games, deadline) + await database.create_fixture("111111", 2, sample_games, deadline) + + await user_commands.predict.callback(user_commands, mock_interaction) + + view = mock_interaction.response_sent[0]["view"] + select = view.children[0] + select._values = ["1"] + await select.callback(mock_interaction) + + assert isinstance(mock_interaction.modal_sent["modal"], PredictModal) + + @pytest.mark.asyncio + async def test_multiple_open_fixture_picker_paginates_past_25( + self, user_commands, mock_interaction, database, sample_games + ): + deadline = datetime.now(UTC) + timedelta(days=1) + for week in range(1, 27): + await database.create_fixture("111111", week, sample_games, deadline) + + await user_commands.predict.callback(user_commands, mock_interaction) + + view = mock_interaction.response_sent[0]["view"] + next_button = next( + child for child in view.children if getattr(child, "label", None) == "Next" + ) + await next_button.callback(mock_interaction) + + select = mock_interaction.response_sent[-1]["view"].children[0] + select._values = ["26"] + await select.callback(mock_interaction) + + assert mock_interaction.modal_sent["modal"].title == "Predict Week 26" + + @pytest.mark.asyncio + @pytest.mark.usefixtures("fixture_with_dm") + async def test_predict_modal_prefills_existing_prediction( + self, user_commands, mock_interaction, database + ): + await database.save_prediction( + 1, + str(mock_interaction.user.id), + mock_interaction.user.name, + ["2-1", "1-1", "0-2"], + False, + ) + + await user_commands.predict.callback(user_commands, mock_interaction) + + modal = mock_interaction.modal_sent["modal"] + prefilled_lines = modal.predictions_input.default.splitlines() + expected_prefills = [ + ("Team A - Team B", "2-1"), + ("Team C - Team D", "1-1"), + ("Team E - Team F", "0-2"), + ] + assert len(prefilled_lines) == len(expected_prefills) + for line, (game, score) in zip(prefilled_lines, expected_prefills, strict=True): + assert game in line + assert score in line + + @pytest.mark.asyncio + @pytest.mark.usefixtures("fixture_with_dm") + async def test_predict_modal_shows_parse_errors(self, user_commands, mock_interaction): + await _attach_prediction_threads( + user_commands, user_commands.db, [1], mock_interaction.guild + ) + await user_commands.predict.callback(user_commands, mock_interaction) + + modal = mock_interaction.modal_sent["modal"] + modal.predictions_input._value = "Team A - Team B\nTeam C - Team D\nTeam E - Team F" + await modal.on_submit(mock_interaction) + + assert "Could not find score" in mock_interaction.response_sent[-1]["content"] + + @pytest.mark.asyncio + @pytest.mark.usefixtures("fixture_with_dm") + async def test_predict_modal_saves_prediction_and_offers_continue( + self, user_commands, mock_interaction, database, sample_games + ): + deadline = datetime.now(UTC) + timedelta(days=1) + fixture_two_id = await database.create_fixture("111111", 2, sample_games, deadline) + await _attach_prediction_threads( + user_commands, database, [1, fixture_two_id], mock_interaction.guild + ) + + await user_commands.predict.callback(user_commands, mock_interaction) + + picker = mock_interaction.response_sent[0]["view"] + select = picker.children[0] + select._values = ["1"] + await select.callback(mock_interaction) + + modal = mock_interaction.modal_sent["modal"] + modal.predictions_input._value = ( + "Team A - Team B 2-1\nTeam C - Team D 1-1\nTeam E - Team F 0-2" + ) + await modal.on_submit(mock_interaction) + + assert await database.get_prediction(1, str(mock_interaction.user.id), "111111") is not None + assert isinstance(mock_interaction.response_sent[-1]["view"], ContinuePredictView) + + @pytest.mark.asyncio + @pytest.mark.usefixtures("fixture_with_dm") + async def test_predict_modal_terminal_success_without_other_open_fixtures( + self, user_commands, mock_interaction, database + ): + await _attach_prediction_threads(user_commands, database, [1], mock_interaction.guild) + await user_commands.predict.callback(user_commands, mock_interaction) + + modal = mock_interaction.modal_sent["modal"] + modal.predictions_input._value = ( + "Team A - Team B 2-1\nTeam C - Team D 1-1\nTeam E - Team F 0-2" + ) + await modal.on_submit(mock_interaction) + + assert await database.get_prediction(1, str(mock_interaction.user.id), "111111") is not None + assert "You're done for now." in mock_interaction.response_sent[-1]["content"] + assert "view" not in mock_interaction.response_sent[-1] + + @pytest.mark.asyncio + @pytest.mark.usefixtures("fixture_with_dm") + async def test_predict_modal_overwrites_existing_prediction( + self, user_commands, mock_interaction, database + ): + await _attach_prediction_threads(user_commands, database, [1], mock_interaction.guild) + await database.save_prediction( + 1, + str(mock_interaction.user.id), + mock_interaction.user.name, + ["2-1", "1-1", "0-2"], + False, + ) + + await user_commands.predict.callback(user_commands, mock_interaction) + + modal = mock_interaction.modal_sent["modal"] + modal.predictions_input._value = ( + "Team A - Team B 3-0\nTeam C - Team D 0-0\nTeam E - Team F 1-1" + ) + await modal.on_submit(mock_interaction) + + prediction = await database.get_prediction(1, str(mock_interaction.user.id), "111111") + assert prediction is not None + assert prediction["predictions"] == ["3-0", "0-0", "1-1"] + + @pytest.mark.asyncio + @pytest.mark.usefixtures("fixture_with_dm") + async def test_predict_modal_marks_late_prediction( + self, user_commands, mock_interaction, database + ): + await database.update_active_scoring_rules("111111", {"late_prediction_points": 1}) + await _attach_prediction_threads(user_commands, database, [1], mock_interaction.guild) + fixture = await database.get_fixture_by_id(1, "111111") + assert fixture is not None + fixture["deadline"] = datetime.now(UTC) - timedelta(minutes=1) + user_commands.db.get_open_fixtures = AsyncMock(return_value=[fixture]) + user_commands.db.get_fixture_by_id = AsyncMock( + side_effect=lambda fixture_id, guild_id: ( + fixture if (fixture_id, guild_id) == (1, "111111") else None + ) + ) + + await user_commands.predict.callback(user_commands, mock_interaction) + + modal = mock_interaction.modal_sent["modal"] + modal.predictions_input._value = ( + "Team A - Team B 2-1\nTeam C - Team D 1-1\nTeam E - Team F 0-2" + ) + await modal.on_submit(mock_interaction) + + prediction = await database.get_prediction(1, str(mock_interaction.user.id), "111111") + assert prediction is not None + assert prediction["is_late"] == 1 + content = mock_interaction.response_sent[-1]["content"] + assert "Late prediction" in content + assert "active season's late penalty" in content + assert "0 points" not in content + + @pytest.mark.asyncio + @pytest.mark.usefixtures("fixture_with_dm") + async def test_predict_modal_accepts_pre_deadline_partial_prediction( + self, user_commands, mock_interaction, database + ): + await _attach_prediction_threads(user_commands, database, [1], mock_interaction.guild) + await user_commands.predict.callback(user_commands, mock_interaction) + + modal = mock_interaction.modal_sent["modal"] + modal.predictions_input._value = "Team C - Team D 1-1\nTeam E - Team F 0-2" + await modal.on_submit(mock_interaction) + + prediction = await database.get_prediction(1, str(mock_interaction.user.id), "111111") + assert prediction is not None + assert prediction["predictions"] == ["1-1", "0-2"] + assert prediction["predicted_game_indexes"] == [1, 2] + assert prediction["pending_partial_approval"] is False + assert "Partial prediction saved" in mock_interaction.response_sent[-1]["content"] + assert "fill the rest" in mock_interaction.response_sent[-1]["content"] + + @pytest.mark.asyncio + @pytest.mark.usefixtures("fixture_with_dm") + async def test_predict_modal_marks_late_partial_as_pending( + self, user_commands, mock_interaction, database + ): + await _attach_prediction_threads(user_commands, database, [1], mock_interaction.guild) + admin_role = MockRole("League Admin", role_id=4242) + await database.upsert_guild_config("111111", str(admin_role.id), "123456") + fixture = await database.get_fixture_by_id(1, "111111") + assert fixture is not None + fixture["deadline"] = datetime.now(UTC) - timedelta(minutes=1) + user_commands.db.get_open_fixtures = AsyncMock(return_value=[fixture]) + user_commands.db.get_fixture_by_id = AsyncMock( + side_effect=lambda fixture_id, guild_id: ( + fixture if (fixture_id, guild_id) == (1, "111111") else None + ) + ) + + await user_commands.predict.callback(user_commands, mock_interaction) + modal = mock_interaction.modal_sent["modal"] + modal.predictions_input._value = "Team C - Team D 1-1\nTeam E - Team F 0-2" + await modal.on_submit(mock_interaction) + + prediction = await database.get_prediction(1, str(mock_interaction.user.id), "111111") + assert prediction is not None + assert prediction["pending_partial_approval"] is True + assert prediction["predicted_game_indexes"] == [1, 2] + assert prediction["public_message_id"] == "1" + assert prediction["public_message_kind"] == "bot_post" + assert ( + "Late prediction awaiting admin review" in mock_interaction.response_sent[-1]["content"] + ) + assert "0 points" not in mock_interaction.response_sent[-1]["content"] + thread = user_commands.bot.get_channel(700001) + assert f"<@&{admin_role.id}>" in thread.messages_sent[-1]["content"] + + @pytest.mark.asyncio + async def test_predict_modal_without_setup_does_not_ping_legacy_admin_role( + self, + mock_bot, + mock_interaction, + temp_db_path, + sample_games, + ): + database = Database(temp_db_path) + await database.initialize() + mock_bot.db = database + user_commands = UserCommands(mock_bot) + deadline = datetime.now(UTC) - timedelta(minutes=1) + fixture_id = await database.create_fixture("111111", 1, sample_games, deadline) + await _attach_prediction_threads( + user_commands, database, [fixture_id], mock_interaction.guild + ) + mock_interaction.guild.roles = [MockRole("typer-admin", role_id=4242)] + + fixture = await database.get_fixture_by_id(fixture_id, "111111") + assert fixture is not None + user_commands.db.get_open_fixtures = AsyncMock(return_value=[fixture]) + user_commands.db.get_fixture_by_id = AsyncMock( + side_effect=lambda request_fixture_id, guild_id: ( + fixture if (request_fixture_id, guild_id) == (fixture_id, "111111") else None + ) + ) + + await user_commands.predict.callback(user_commands, mock_interaction) + modal = mock_interaction.modal_sent["modal"] + modal.predictions_input._value = "Team C - Team D 1-1\nTeam E - Team F 0-2" + await modal.on_submit(mock_interaction) + + thread = user_commands.bot.get_channel(700001) + assert "awaiting admin review" in thread.messages_sent[-1]["content"] + assert "<@&4242>" not in thread.messages_sent[-1]["content"] + + @pytest.mark.asyncio + @pytest.mark.usefixtures("fixture_with_dm") + async def test_predict_modal_replaces_previous_pending_bot_post( + self, user_commands, mock_interaction, database + ): + await _attach_prediction_threads(user_commands, database, [1], mock_interaction.guild) + fixture = await database.get_fixture_by_id(1, "111111") + assert fixture is not None + fixture["deadline"] = datetime.now(UTC) - timedelta(minutes=1) + user_commands.db.get_open_fixtures = AsyncMock(return_value=[fixture]) + user_commands.db.get_fixture_by_id = AsyncMock( + side_effect=lambda fixture_id, guild_id: ( + fixture if (fixture_id, guild_id) == (1, "111111") else None + ) + ) + + await user_commands.predict.callback(user_commands, mock_interaction) + first_modal = mock_interaction.modal_sent["modal"] + first_modal.predictions_input._value = "Team C - Team D 1-1\nTeam E - Team F 0-2" + await first_modal.on_submit(mock_interaction) + + thread = user_commands.bot.get_channel(700001) + first_public_message = thread.message_objects[1] + + await user_commands.predict.callback(user_commands, mock_interaction) + second_modal = mock_interaction.modal_sent["modal"] + second_modal.predictions_input._value = "Team A - Team B 2-0\nTeam C - Team D 1-1" + await second_modal.on_submit(mock_interaction) + + prediction = await database.get_prediction(1, str(mock_interaction.user.id), "111111") + assert prediction is not None + assert prediction["public_message_id"] == "2" + first_public_message.delete.assert_awaited_once() + + @pytest.mark.asyncio + @pytest.mark.usefixtures("fixture_with_dm") + async def test_my_predictions_shows_sparse_pending_prediction( + self, user_commands, mock_interaction, database + ): + await database.save_prediction( + 1, + str(mock_interaction.user.id), + mock_interaction.user.name, + ["1-1", "0-2"], + True, + predicted_game_indexes=[1, 2], + pending_partial_approval=True, + ) + + await user_commands.my_predictions.callback(user_commands, mock_interaction) + + content = mock_interaction.response_sent[-1]["content"] + assert "2. Team C - Team D **1-1**" in content + assert "3. Team E - Team F **0-2**" in content + assert "Late prediction awaiting admin review" in content + + @pytest.mark.asyncio + @pytest.mark.usefixtures("fixture_with_dm") + async def test_predict_modal_reports_closed_fixture_during_submit( + self, user_commands, mock_interaction, monkeypatch + ): + await _attach_prediction_threads( + user_commands, user_commands.db, [1], mock_interaction.guild + ) + await user_commands.predict.callback(user_commands, mock_interaction) + + modal = mock_interaction.modal_sent["modal"] + modal.predictions_input._value = ( + "Team A - Team B 2-1\nTeam C - Team D 1-1\nTeam E - Team F 0-2" + ) + monkeypatch.setattr( + user_commands.db, + "save_prediction_guarded", + AsyncMock(return_value=SaveResult.FIXTURE_CLOSED), + ) + + await modal.on_submit(mock_interaction) + + assert ( + "closed before your prediction could be saved" + in mock_interaction.response_sent[-1]["content"] + ) + + @pytest.mark.asyncio + @pytest.mark.usefixtures("fixture_with_dm") + async def test_predict_modal_reports_database_error( + self, user_commands, mock_interaction, monkeypatch + ): + await _attach_prediction_threads( + user_commands, user_commands.db, [1], mock_interaction.guild + ) + await user_commands.predict.callback(user_commands, mock_interaction) + + modal = mock_interaction.modal_sent["modal"] + modal.predictions_input._value = ( + "Team A - Team B 2-1\nTeam C - Team D 1-1\nTeam E - Team F 0-2" + ) + + async def _raise(*_args, **_kwargs): + raise RuntimeError("db failed") + + monkeypatch.setattr(user_commands.db, "save_prediction_guarded", _raise) + await modal.on_submit(mock_interaction) + + assert ( + "Something went wrong while saving your prediction" + in mock_interaction.response_sent[-1]["content"] + ) + thread = user_commands.bot.get_channel(700001) + thread.message_objects[1].delete.assert_awaited_once() + + @pytest.mark.asyncio + @pytest.mark.usefixtures("fixture_with_dm") + async def test_predict_modal_reports_missing_prediction_thread( + self, user_commands, mock_interaction + ): + await user_commands.predict.callback(user_commands, mock_interaction) + + modal = mock_interaction.modal_sent["modal"] + modal.predictions_input._value = ( + "Team A - Team B 2-1\nTeam C - Team D 1-1\nTeam E - Team F 0-2" + ) + await modal.on_submit(mock_interaction) + + assert ( + "does not have a usable prediction thread" + in mock_interaction.response_sent[-1]["content"] + ) + assert ( + await user_commands.db.get_prediction(1, str(mock_interaction.user.id), "111111") + is None + ) + + @pytest.mark.asyncio + @pytest.mark.usefixtures("fixture_with_dm") + async def test_predict_modal_uses_fetch_channel_fallback( + self, user_commands, mock_interaction, database + ): + thread = MockThread(thread_id="700001", name="week-1", guild=mock_interaction.guild) + await database.update_fixture_announcement(1, message_id="700001", channel_id="123456") + user_commands.bot.get_channel.return_value = None + user_commands.bot.fetch_channel = AsyncMock(return_value=thread) + + await user_commands.predict.callback(user_commands, mock_interaction) + + modal = mock_interaction.modal_sent["modal"] + modal.predictions_input._value = ( + "Team A - Team B 2-1\nTeam C - Team D 1-1\nTeam E - Team F 0-2" + ) + await modal.on_submit(mock_interaction) + + assert await database.get_prediction(1, str(mock_interaction.user.id), "111111") is not None + user_commands.bot.fetch_channel.assert_awaited_once_with(700001) + + @pytest.mark.asyncio + @pytest.mark.usefixtures("fixture_with_dm") + async def test_predict_modal_reports_thread_post_failure( + self, user_commands, mock_interaction, database, monkeypatch + ): + await _attach_prediction_threads(user_commands, database, [1], mock_interaction.guild) + thread = user_commands.bot.get_channel(700001) + + import discord + + async def raise_http_exception(*_args, **_kwargs): + raise discord.HTTPException(response=AsyncMock(status=500), message="boom") + + monkeypatch.setattr(thread, "send", raise_http_exception) + + await user_commands.predict.callback(user_commands, mock_interaction) + modal = mock_interaction.modal_sent["modal"] + modal.predictions_input._value = ( + "Team A - Team B 2-1\nTeam C - Team D 1-1\nTeam E - Team F 0-2" + ) + await modal.on_submit(mock_interaction) + + assert ( + "does not have a usable prediction thread" + in mock_interaction.response_sent[-1]["content"] + ) + assert await database.get_prediction(1, str(mock_interaction.user.id), "111111") is None + + @pytest.mark.asyncio + @pytest.mark.usefixtures("fixture_with_dm") + async def test_predict_modal_deletes_public_post_if_fixture_closes_during_save( + self, user_commands, mock_interaction, monkeypatch + ): + await _attach_prediction_threads( + user_commands, user_commands.db, [1], mock_interaction.guild + ) + await user_commands.predict.callback(user_commands, mock_interaction) + + modal = mock_interaction.modal_sent["modal"] + modal.predictions_input._value = ( + "Team A - Team B 2-1\nTeam C - Team D 1-1\nTeam E - Team F 0-2" + ) + monkeypatch.setattr( + user_commands.db, + "save_prediction_guarded", + AsyncMock(return_value=SaveResult.FIXTURE_CLOSED), + ) + + await modal.on_submit(mock_interaction) + + thread = user_commands.bot.get_channel(700001) + thread.message_objects[1].delete.assert_awaited_once() + assert ( + "closed before your prediction could be saved" + in mock_interaction.response_sent[-1]["content"] + ) + + @pytest.mark.asyncio + @pytest.mark.usefixtures("fixture_with_dm") + async def test_predict_modal_rejects_empty_partial_parse_result( + self, user_commands, mock_interaction, database + ): + await _attach_prediction_threads(user_commands, database, [1], mock_interaction.guild) + await user_commands.predict.callback(user_commands, mock_interaction) + + modal = mock_interaction.modal_sent["modal"] + modal.predictions_input._value = "," + await modal.on_submit(mock_interaction) + + assert ( + "Please enter at least one prediction before submitting." + in mock_interaction.response_sent[-1]["content"] + ) + assert await database.get_prediction(1, str(mock_interaction.user.id), "111111") is None + + @pytest.mark.asyncio + @pytest.mark.usefixtures("fixture_with_dm") + async def test_continue_predict_button_opens_next_modal( + self, user_commands, mock_interaction, database, sample_games + ): + deadline = datetime.now(UTC) + timedelta(days=1) + fixture_two_id = await database.create_fixture("111111", 2, sample_games, deadline) + await _attach_prediction_threads( + user_commands, database, [1, fixture_two_id], mock_interaction.guild + ) + + await user_commands.predict.callback(user_commands, mock_interaction) + picker = mock_interaction.response_sent[0]["view"] + select = picker.children[0] + select._values = ["1"] + await select.callback(mock_interaction) + + modal = mock_interaction.modal_sent["modal"] + modal.predictions_input._value = ( + "Team A - Team B 2-1\nTeam C - Team D 1-1\nTeam E - Team F 0-2" + ) + await modal.on_submit(mock_interaction) + + continue_view = mock_interaction.response_sent[-1]["view"] + button = continue_view.children[0] + await button.callback(mock_interaction) + + assert mock_interaction.modal_sent["modal"].title == "Predict Week 2" + + @pytest.mark.asyncio + async def test_multi_fixture_flow_ends_without_continue_view_after_last_save( + self, user_commands, mock_interaction, database, sample_games + ): + deadline = datetime.now(UTC) + timedelta(days=1) + fixture_one_id = await database.create_fixture("111111", 1, sample_games, deadline) + fixture_two_id = await database.create_fixture("111111", 2, sample_games, deadline) + await _attach_prediction_threads( + user_commands, database, [fixture_one_id, fixture_two_id], mock_interaction.guild + ) + + await user_commands.predict.callback(user_commands, mock_interaction) + picker = mock_interaction.response_sent[0]["view"] + select = picker.children[0] + select._values = ["1"] + await select.callback(mock_interaction) + + first_modal = mock_interaction.modal_sent["modal"] + first_modal.predictions_input._value = ( + "Team A - Team B 2-1\nTeam C - Team D 1-1\nTeam E - Team F 0-2" + ) + await first_modal.on_submit(mock_interaction) + + continue_view = mock_interaction.response_sent[-1]["view"] + continue_button = continue_view.children[0] + await continue_button.callback(mock_interaction) + + second_modal = mock_interaction.modal_sent["modal"] + second_modal.predictions_input._value = ( + "Team A - Team B 1-0\nTeam C - Team D 2-2\nTeam E - Team F 3-1" + ) + await second_modal.on_submit(mock_interaction) + + assert await database.get_prediction(1, str(mock_interaction.user.id), "111111") is not None + assert await database.get_prediction(2, str(mock_interaction.user.id), "111111") is not None + assert "You're done for now." in mock_interaction.response_sent[-1]["content"] + assert "view" not in mock_interaction.response_sent[-1] + + @pytest.mark.asyncio + async def test_continue_predict_view_paginates_past_25( + self, user_commands, mock_interaction, database, sample_games + ): + deadline = datetime.now(UTC) + timedelta(days=1) + fixture_ids = [] + for week in range(1, 28): + fixture_ids.append( + await database.create_fixture("111111", week, sample_games, deadline) + ) + await _attach_prediction_threads( + user_commands, database, fixture_ids, mock_interaction.guild + ) + + await user_commands.predict.callback(user_commands, mock_interaction) + picker = mock_interaction.response_sent[0]["view"] + select = picker.children[0] + select._values = ["1"] + await select.callback(mock_interaction) + + first_modal = mock_interaction.modal_sent["modal"] + first_modal.predictions_input._value = ( + "Team A - Team B 2-1\nTeam C - Team D 1-1\nTeam E - Team F 0-2" + ) + await first_modal.on_submit(mock_interaction) + + continue_view = mock_interaction.response_sent[-1]["view"] + next_button = next( + child for child in continue_view.children if getattr(child, "label", None) == "Next" + ) + await next_button.callback(mock_interaction) + + paged_continue_view = mock_interaction.response_sent[-1]["view"] + week_27_button = next( + child + for child in paged_continue_view.children + if getattr(child, "label", None) == "Predict Week 27" + ) + await week_27_button.callback(mock_interaction) + + assert mock_interaction.modal_sent["modal"].title == "Predict Week 27" + + @pytest.mark.asyncio + async def test_fixture_picker_rejects_wrong_user( + self, user_commands, mock_interaction, database, sample_games + ): + deadline = datetime.now(UTC) + timedelta(days=1) + await database.create_fixture("111111", 1, sample_games, deadline) + await database.create_fixture("111111", 2, sample_games, deadline) + + await user_commands.predict.callback(user_commands, mock_interaction) + + other_user_interaction = MockInteraction( + user=MockUser("999", "OtherUser"), + guild=mock_interaction.guild, + channel=mock_interaction.channel, + ) + view = mock_interaction.response_sent[0]["view"] + select = view.children[0] + select._values = ["1"] + await select.callback(other_user_interaction) + + assert ( + "don't have permission" in other_user_interaction.response_sent[-1]["content"].lower() + ) + + @pytest.mark.asyncio + async def test_fixture_picker_reports_closed_fixture( + self, user_commands, mock_interaction, database, sample_games + ): + deadline = datetime.now(UTC) + timedelta(days=1) + await database.create_fixture("111111", 1, sample_games, deadline) + await database.create_fixture("111111", 2, sample_games, deadline) + + await user_commands.predict.callback(user_commands, mock_interaction) + + await database.save_scores( + 1, + [ + { + "user_id": "u1", + "user_name": "User One", + "points": 0, + "exact_scores": 0, + "correct_results": 0, + } + ], + ) + view = mock_interaction.response_sent[0]["view"] + select = view.children[0] + select._values = ["1"] + await select.callback(mock_interaction) + + assert "no longer open" in mock_interaction.response_sent[-1]["content"].lower() + assert mock_interaction.response_sent[-1]["view"] is None + + @pytest.mark.asyncio + async def test_continue_predict_button_rejects_wrong_user( + self, user_commands, mock_interaction, database, sample_games + ): + deadline = datetime.now(UTC) + timedelta(days=1) + fixture_one_id = await database.create_fixture("111111", 1, sample_games, deadline) + fixture_two_id = await database.create_fixture("111111", 2, sample_games, deadline) + await _attach_prediction_threads( + user_commands, database, [fixture_one_id, fixture_two_id], mock_interaction.guild + ) + + await user_commands.predict.callback(user_commands, mock_interaction) + picker = mock_interaction.response_sent[0]["view"] + select = picker.children[0] + select._values = ["1"] + await select.callback(mock_interaction) + + modal = mock_interaction.modal_sent["modal"] + modal.predictions_input._value = ( + "Team A - Team B 2-1\nTeam C - Team D 1-1\nTeam E - Team F 0-2" + ) + await modal.on_submit(mock_interaction) + + other_user_interaction = MockInteraction( + user=MockUser("999", "OtherUser"), + guild=mock_interaction.guild, + channel=mock_interaction.channel, + ) + continue_view = mock_interaction.response_sent[-1]["view"] + button = continue_view.children[0] + await button.callback(other_user_interaction) + + assert ( + "don't have permission" in other_user_interaction.response_sent[-1]["content"].lower() + ) + + @pytest.mark.asyncio + async def test_continue_predict_button_reports_closed_fixture( + self, user_commands, mock_interaction, database, sample_games + ): + deadline = datetime.now(UTC) + timedelta(days=1) + fixture_one_id = await database.create_fixture("111111", 1, sample_games, deadline) + fixture_two_id = await database.create_fixture("111111", 2, sample_games, deadline) + await _attach_prediction_threads( + user_commands, database, [fixture_one_id, fixture_two_id], mock_interaction.guild + ) + + await user_commands.predict.callback(user_commands, mock_interaction) + picker = mock_interaction.response_sent[0]["view"] + select = picker.children[0] + select._values = ["1"] + await select.callback(mock_interaction) + + modal = mock_interaction.modal_sent["modal"] + modal.predictions_input._value = ( + "Team A - Team B 2-1\nTeam C - Team D 1-1\nTeam E - Team F 0-2" + ) + await modal.on_submit(mock_interaction) + await database.save_scores( + fixture_two_id, + [ + { + "user_id": "u1", + "user_name": "User One", + "points": 0, + "exact_scores": 0, + "correct_results": 0, + } + ], + ) + + continue_view = mock_interaction.response_sent[-1]["view"] + button = continue_view.children[0] + await button.callback(mock_interaction) + + assert "no longer open" in mock_interaction.response_sent[-1]["content"].lower() + assert mock_interaction.response_sent[-1]["view"] is None diff --git a/tests/user_commands/test_standings_command.py b/tests/user_commands/test_standings_command.py new file mode 100644 index 0000000..77477d1 --- /dev/null +++ b/tests/user_commands/test_standings_command.py @@ -0,0 +1,86 @@ +from datetime import UTC, datetime, timedelta +from unittest.mock import AsyncMock + +import pytest + + +class TestStandingsCommand: + @pytest.mark.asyncio + async def test_standings_sends_empty_state(self, user_commands, mock_interaction): + await user_commands.standings.callback(user_commands, mock_interaction) + + assert "No standings yet" in mock_interaction.response_sent[0]["content"] + assert mock_interaction.response_sent[0]["ephemeral"] is True + + @pytest.mark.asyncio + async def test_standings_sends_formatted_leaderboard(self, user_commands, mock_interaction): + standings = [ + { + "user_id": "123", + "user_name": "User1", + "total_points": 9, + "total_exact": 3, + "total_correct": 3, + } + ] + last_fixture = { + "week_number": 4, + "games": ["A - B"], + "results": ["2-1"], + "scores": [ + { + "user_id": "123", + "user_name": "User1", + "points": 3, + "exact_scores": 1, + "correct_results": 1, + } + ], + } + user_commands.db.get_standings = AsyncMock(return_value=standings) + user_commands.db.get_last_fixture_scores = AsyncMock(return_value=last_fixture) + + await user_commands.standings.callback(user_commands, mock_interaction) + + content = mock_interaction.response_sent[0]["content"] + assert "User1" in content + assert "9" in content + + @pytest.mark.asyncio + async def test_standings_only_shows_current_guild_scores( + self, user_commands, mock_interaction, database + ): + games = ["Team A - Team B"] + deadline = datetime.now(UTC) - timedelta(days=1) + current_fixture_id = await database.create_fixture("111111", 1, games, deadline) + other_fixture_id = await database.create_fixture("guild-2", 2, games, deadline) + await database.save_scores( + current_fixture_id, + [ + { + "user_id": "current-user", + "user_name": "Current Guild", + "points": 3, + "exact_scores": 1, + "correct_results": 0, + } + ], + ) + await database.save_scores( + other_fixture_id, + [ + { + "user_id": "other-user", + "user_name": "Other Guild", + "points": 9, + "exact_scores": 3, + "correct_results": 3, + } + ], + ) + + await user_commands.standings.callback(user_commands, mock_interaction) + + content = mock_interaction.response_sent[0]["content"] + assert "Current Guild" in content + assert "Other Guild" not in content