diff --git a/README.md b/README.md index bd4a51c..a879fc3 100644 --- a/README.md +++ b/README.md @@ -75,11 +75,11 @@ I recommend **Railway** because it's cheap/free and supports persistent storage - Mount path: `/app/data` - If you skip this, your database will vanish every time you deploy. 4. Set Variables: - - `DISCORD_TOKEN`: Get this from Discord Developer Portal. - - `DATA_DIR`: (Optional) Base data directory. Code default is `./data`. On Railway, set it to `/app/data` so the database lives on the mounted volume. - - `DB_PATH`: (Optional) Database path. Code default is `{DATA_DIR}/typer.db`. - - `BACKUP_DIR`: (Optional) Backup storage. Code default is `{DATA_DIR}/backups`. - - `TZ`: (Optional) Timezone for deadline inputs in the admin DM workflow. Default `UTC`. Examples: `Europe/Warsaw`, `America/New_York`, `Asia/Tokyo`. + - `DISCORD_TOKEN`: Get this from Discord Developer Portal. + - `DATA_DIR`: (Optional) Base data directory. Code default is `./data` for local development. On Railway, set it to `/app/data` so the database lives on the mounted volume instead of the container filesystem. + - `DB_PATH`: (Optional) Database path. Code default is `{DATA_DIR}/typer.db`. + - `BACKUP_DIR`: (Optional) Backup storage. Code default is `{DATA_DIR}/backups`. + - `TZ`: (Optional) Timezone for deadline inputs in the admin DM workflow. Default `UTC` so new deployments behave predictably until you choose a league timezone. Examples: `Europe/Warsaw`, `America/New_York`, `Asia/Tokyo`. - `REMINDER_CHANNEL_ID`: (Optional) ID of channel to spam reminders in. - `LOG_LEVEL`: (Optional) Set to `DEBUG` for verbose logs. Default `INFO`. - `ENVIRONMENT`: (Optional) Set to `production` for live bot operation. Other values run smoke-test mode (validates config then exits). Default: `development`. @@ -88,6 +88,8 @@ I recommend **Railway** because it's cheap/free and supports persistent storage By default the bot runs in smoke-test mode: it validates config and exits without connecting to Discord. That is intentional so preview deployments do not fight production for the same token. +Local runs also default to `DATA_DIR=./data` and `TZ=UTC`. That gives predictable filesystem and deadline behavior until you explicitly point production at a persistent volume and pick your league timezone. + If you want a real local bot session, set `ENVIRONMENT=production`. ```bash diff --git a/tests/test_admin_commands.py b/tests/test_admin_commands.py index 105a13a..886e91b 100644 --- a/tests/test_admin_commands.py +++ b/tests/test_admin_commands.py @@ -12,6 +12,7 @@ ) from typer_bot.commands.admin_panel import OpenFixtureWarningView from typer_bot.services.admin_service import FixtureScoreResult +from typer_bot.utils import now from typer_bot.utils.permissions import is_admin @@ -322,6 +323,29 @@ async def test_results_calculate_does_not_record_cooldown_when_scoring_fails( admin_cog._create_backup.assert_not_called() admin_cog._post_calculation_to_channel.assert_not_called() + @pytest.mark.asyncio + async def test_results_calculate_rejects_active_cooldown( + self, admin_cog, mock_interaction_admin + ): + """Cooldown should stop duplicate clicks before any scoring work starts.""" + user_id = str(mock_interaction_admin.user.id) + admin_cog.workflow_state.record_calculate_cooldown(user_id, current_time=now().timestamp()) + admin_cog.service.calculate_fixture_scores = AsyncMock() + + await admin_cog.results_calculate.callback(admin_cog, mock_interaction_admin, None) + + assert "Please wait" in mock_interaction_admin.response_sent[-1]["content"] + admin_cog.service.calculate_fixture_scores.assert_not_called() + + @pytest.mark.asyncio + async def test_results_calculate_reports_missing_open_fixture( + self, admin_cog, mock_interaction_admin + ): + """Missing fixture selection should fail through the slash-command response path.""" + await admin_cog.results_calculate.callback(admin_cog, mock_interaction_admin, None) + + assert mock_interaction_admin.response_sent[-1]["content"] == "No open fixtures found!" + class TestResultsPostFlow: @pytest.fixture diff --git a/tests/test_user_commands.py b/tests/test_user_commands.py index a04aaad..2742333 100644 --- a/tests/test_user_commands.py +++ b/tests/test_user_commands.py @@ -1,11 +1,13 @@ """Tests for user command wiring.""" +from datetime import UTC, datetime, timedelta from unittest.mock import AsyncMock, MagicMock import discord import pytest from typer_bot.commands.user_commands import UserCommands +from typer_bot.utils import format_standings @pytest.fixture @@ -48,3 +50,150 @@ async def test_handles_dm_permission_error(self, user_commands, mock_interaction assert len(mock_interaction.followup_sent) == 1 assert "can't send you DMs" in mock_interaction.followup_sent[0]["content"] + + +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 mock_interaction.response_sent[0]["content"] == "❌ No active fixture found!" + + @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(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(1, sample_games, deadline) + await database.create_fixture(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 + + +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 mock_interaction.response_sent[0]["content"] == format_standings([], None) + 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) + + assert mock_interaction.response_sent[0]["content"] == format_standings( + standings, last_fixture + ) + + +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 mock_interaction.response_sent[0]["content"] == "❌ No active fixture found!" + + @pytest.mark.asyncio + async def test_single_fixture_without_prediction_shows_prompt( + self, user_commands, mock_interaction, database, sample_games + ): + await database.create_fixture(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( + 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(1, sample_games, deadline) + await database.create_fixture(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/typer_bot/utils/config.py b/typer_bot/utils/config.py index 924217f..77a1aec 100644 --- a/typer_bot/utils/config.py +++ b/typer_bot/utils/config.py @@ -1,8 +1,11 @@ """Deployment-sensitive configuration defaults. The bot boots in smoke-test mode unless ``ENVIRONMENT`` is set to a production -value. Data paths default to a local ``./data`` tree so development does not -silently depend on Railway's ``/app/data`` volume layout. +value. + +``DATA_DIR`` intentionally defaults to ``./data`` so local development writes +into a repo-adjacent folder without depending on Railway's volume mount. +Production deploys must override it to a persistent path such as ``/app/data``. """ import os diff --git a/typer_bot/utils/timezone.py b/typer_bot/utils/timezone.py index d33d412..10aeb2c 100644 --- a/typer_bot/utils/timezone.py +++ b/typer_bot/utils/timezone.py @@ -1,6 +1,8 @@ """Timezone utilities for the prediction bot. -All datetime operations use a single configurable timezone (from TZ env var). +All datetime operations use a single configurable timezone from ``TZ``. +The default is ``UTC`` so fresh deploys get deterministic deadline handling +until an operator explicitly chooses a league-specific timezone. """ import os