From a668a3505c26ac84c56a52006000b8c2cfb5cd45 Mon Sep 17 00:00:00 2001 From: Cal Corum Date: Fri, 20 Feb 2026 14:35:02 -0600 Subject: [PATCH 1/2] docs: add release workflow section to CLAUDE.md Documents the next-release staging branch pattern used for batching changes before merging to main. Co-Authored-By: Claude Opus 4.6 --- CLAUDE.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index 14cfded..3e1a819 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -59,6 +59,13 @@ class MyCog(commands.Cog): - **Image**: `manticorum67/major-domo-discordapp` (no dash between discord and app) - **Health**: Process liveness only (no HTTP endpoint) - **CI/CD**: Gitea Actions on PR to `main` — builds Docker image, auto-generates CalVer version (`YYYY.MM.BUILD`) on merge + +### Release Workflow +1. Create feature/fix branches off `next-release` (e.g., `fix/scorebug-bugs`) +2. When done, merge the branch into `next-release` — this is the staging branch where changes accumulate +3. When ready to release, open a PR from `next-release` → `main` +4. CI builds Docker image on PR; CalVer tag is created on merge +5. Deploy the new image to production (see `/deploy` skill) - **Other services on same host**: `sba_db_api`, `sba_postgres`, `sba_redis`, `sba-website-sba-web-1`, `pd_api` ### Logs From 8a1a957c2ac9d2b1cdff07f73fd7829dcfeb2414 Mon Sep 17 00:00:00 2001 From: Cal Corum Date: Sun, 22 Feb 2026 15:55:49 -0600 Subject: [PATCH 2/2] fix: use explicit America/Chicago timezone for freeze/thaw scheduling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The production container has ambiguous timezone config — /etc/localtime points to Etc/UTC but date reports CST. The transaction freeze/thaw task used datetime.now() (naive, relying on OS timezone), causing scheduling to fire at unpredictable wall-clock times. - Add utils/timezone.py with centralized Chicago timezone helpers - Fix tasks/transaction_freeze.py to use now_chicago() for scheduling - Fix utils/logging.py timestamp to use proper UTC-aware datetime - Add 14 timezone utility tests - Update freeze task tests to mock now_chicago instead of datetime Closes #43 Co-Authored-By: Claude Opus 4.6 --- tasks/transaction_freeze.py | 4 +- tests/test_tasks_transaction_freeze.py | 577 ++++++++++++++----------- tests/test_utils_timezone.py | 129 ++++++ utils/logging.py | 4 +- utils/timezone.py | 54 +++ 5 files changed, 514 insertions(+), 254 deletions(-) create mode 100644 tests/test_utils_timezone.py create mode 100644 utils/timezone.py diff --git a/tasks/transaction_freeze.py b/tasks/transaction_freeze.py index fb93d48..c14684b 100644 --- a/tasks/transaction_freeze.py +++ b/tasks/transaction_freeze.py @@ -8,6 +8,8 @@ Runs on a schedule to increment weeks and process contested transactions. import asyncio import random from datetime import datetime, UTC + +from utils.timezone import now_chicago from typing import Dict, List, Tuple, Set, Optional from dataclasses import dataclass @@ -325,7 +327,7 @@ class TransactionFreezeTask: self.logger.warning("Could not get current league state") return - now = datetime.now(UTC) + now = now_chicago() self.logger.info( f"Weekly loop check", datetime=now.isoformat(), diff --git a/tests/test_tasks_transaction_freeze.py b/tests/test_tasks_transaction_freeze.py index 31f4c20..aaa11b4 100644 --- a/tests/test_tasks_transaction_freeze.py +++ b/tests/test_tasks_transaction_freeze.py @@ -8,25 +8,23 @@ Validates the automated weekly freeze system for transactions, including: - GM notifications - Transaction processing """ + import pytest +from datetime import datetime from unittest.mock import AsyncMock, MagicMock, patch from typing import List from tasks.transaction_freeze import ( TransactionFreezeTask, resolve_contested_transactions, - TransactionPriority + TransactionPriority, ) from models.transaction import Transaction from models.current import Current from models.team import Team from models.player import Player from models.standings import TeamStandings -from tests.factories import ( - PlayerFactory, - TeamFactory, - CurrentFactory -) +from tests.factories import PlayerFactory, TeamFactory, CurrentFactory @pytest.fixture @@ -53,11 +51,7 @@ def mock_bot(): def current_state() -> Current: """Fixture providing current league state.""" return CurrentFactory.create( - week=10, - season=12, - freeze=False, - trade_deadline=14, - playoffs_begin=19 + week=10, season=12, freeze=False, trade_deadline=14, playoffs_begin=19 ) @@ -65,77 +59,55 @@ def current_state() -> Current: def frozen_state() -> Current: """Fixture providing frozen league state.""" return CurrentFactory.create( - week=10, - season=12, - freeze=True, - trade_deadline=14, - playoffs_begin=19 + week=10, season=12, freeze=True, trade_deadline=14, playoffs_begin=19 ) @pytest.fixture def sample_team_wv() -> Team: """Fixture providing West Virginia team.""" - return TeamFactory.west_virginia( - id=499, - gmid=111111, - gmid2=222222 - ) + return TeamFactory.west_virginia(id=499, gmid=111111, gmid2=222222) @pytest.fixture def sample_team_ny() -> Team: """Fixture providing New York team.""" - return TeamFactory.new_york( - id=500, - gmid=333333, - gmid2=None - ) + return TeamFactory.new_york(id=500, gmid=333333, gmid2=None) @pytest.fixture def sample_player() -> Player: """Fixture providing a test player.""" - return PlayerFactory.mike_trout( - id=12472, - team_id=None, # Free agent - wara=2.5 - ) + return PlayerFactory.mike_trout(id=12472, team_id=None, wara=2.5) # Free agent @pytest.fixture def sample_transaction(sample_player, sample_team_wv) -> Transaction: """Fixture providing a sample transaction.""" fa_team = TeamFactory.create( - id=999, - abbrev="FA", - sname="Free Agents", - lname="Free Agents", - season=12 + id=999, abbrev="FA", sname="Free Agents", lname="Free Agents", season=12 ) return Transaction( id=27787, week=10, season=12, - moveid='Season-012-Week-10-19-13:04:41', + moveid="Season-012-Week-10-19-13:04:41", player=sample_player, oldteam=fa_team, newteam=sample_team_wv, cancelled=False, - frozen=True + frozen=True, ) @pytest.fixture -def contested_transactions(sample_player, sample_team_wv, sample_team_ny) -> List[Transaction]: +def contested_transactions( + sample_player, sample_team_wv, sample_team_ny +) -> List[Transaction]: """Fixture providing contested transactions (two teams want same player).""" fa_team = TeamFactory.create( - id=999, - abbrev="FA", - sname="Free Agents", - lname="Free Agents", - season=12 + id=999, abbrev="FA", sname="Free Agents", lname="Free Agents", season=12 ) # Transaction 1: WV wants the player @@ -143,12 +115,12 @@ def contested_transactions(sample_player, sample_team_wv, sample_team_ny) -> Lis id=27787, week=10, season=12, - moveid='Season-012-Week-10-WV-13:04:41', + moveid="Season-012-Week-10-WV-13:04:41", player=sample_player, oldteam=fa_team, newteam=sample_team_wv, cancelled=False, - frozen=True + frozen=True, ) # Transaction 2: NY wants the same player @@ -156,12 +128,12 @@ def contested_transactions(sample_player, sample_team_wv, sample_team_ny) -> Lis id=27788, week=10, season=12, - moveid='Season-012-Week-10-NY-13:05:00', + moveid="Season-012-Week-10-NY-13:05:00", player=sample_player, oldteam=fa_team, newteam=sample_team_ny, cancelled=False, - frozen=True + frozen=True, ) return [tx1, tx2] @@ -171,11 +143,7 @@ def contested_transactions(sample_player, sample_team_wv, sample_team_ny) -> Lis def mil_transaction(sample_player, sample_team_wv) -> Transaction: """Fixture providing a MiL team transaction.""" fa_team = TeamFactory.create( - id=999, - abbrev="FA", - sname="Free Agents", - lname="Free Agents", - season=12 + id=999, abbrev="FA", sname="Free Agents", lname="Free Agents", season=12 ) mil_team = TeamFactory.create( @@ -183,19 +151,19 @@ def mil_transaction(sample_player, sample_team_wv) -> Transaction: abbrev="WVMiL", sname="Black Bears MiL", lname="West Virginia Black Bears MiL", - season=12 + season=12, ) return Transaction( id=27789, week=10, season=12, - moveid='Season-012-Week-10-WVMiL-14:00:00', + moveid="Season-012-Week-10-WVMiL-14:00:00", player=sample_player, oldteam=fa_team, newteam=mil_team, cancelled=False, - frozen=True + frozen=True, ) @@ -234,7 +202,7 @@ def sample_standings_wv() -> TeamStandings: div3_wins=8, div3_losses=12, div4_wins=7, - div4_losses=13 + div4_losses=13, ) @@ -273,7 +241,7 @@ def sample_standings_ny() -> TeamStandings: div3_wins=12, div3_losses=8, div4_wins=13, - div4_losses=7 + div4_losses=7, ) @@ -285,7 +253,7 @@ class TestTransactionPriority: priority = TransactionPriority( transaction=sample_transaction, team_win_percentage=0.500, - tiebreaker=0.50012345 + tiebreaker=0.50012345, ) assert priority.transaction == sample_transaction @@ -297,13 +265,13 @@ class TestTransactionPriority: priority1 = TransactionPriority( transaction=sample_transaction, team_win_percentage=0.300, - tiebreaker=0.30012345 + tiebreaker=0.30012345, ) priority2 = TransactionPriority( transaction=sample_transaction, team_win_percentage=0.700, - tiebreaker=0.70012345 + tiebreaker=0.70012345, ) priorities = [priority2, priority1] @@ -316,15 +284,11 @@ class TestTransactionPriority: def test_priority_comparison(self, sample_transaction): """Test priority comparison operators.""" priority_low = TransactionPriority( - transaction=sample_transaction, - team_win_percentage=0.300, - tiebreaker=0.300 + transaction=sample_transaction, team_win_percentage=0.300, tiebreaker=0.300 ) priority_high = TransactionPriority( - transaction=sample_transaction, - team_win_percentage=0.700, - tiebreaker=0.700 + transaction=sample_transaction, team_win_percentage=0.700, tiebreaker=0.700 ) assert priority_low < priority_high @@ -339,10 +303,12 @@ class TestResolveContestedTransactions: """Test with no contested transactions (single team wants player).""" transactions = [sample_transaction] - with patch('tasks.transaction_freeze.standings_service') as mock_standings: + with patch("tasks.transaction_freeze.standings_service") as mock_standings: mock_standings.get_team_standings = AsyncMock(return_value=None) - winning_ids, losing_ids, conflicts = await resolve_contested_transactions(transactions, 12) + winning_ids, losing_ids, conflicts = await resolve_contested_transactions( + transactions, 12 + ) # Single transaction should win automatically assert sample_transaction.moveid in winning_ids @@ -350,13 +316,11 @@ class TestResolveContestedTransactions: @pytest.mark.asyncio async def test_contested_transaction_resolution( - self, - contested_transactions, - sample_standings_wv, - sample_standings_ny + self, contested_transactions, sample_standings_wv, sample_standings_ny ): """Test contested transaction resolution with priority.""" - with patch('tasks.transaction_freeze.standings_service') as mock_standings: + with patch("tasks.transaction_freeze.standings_service") as mock_standings: + async def get_standings(team_abbrev, season): if team_abbrev == "WV": return sample_standings_wv # 0.300 win% @@ -367,9 +331,9 @@ class TestResolveContestedTransactions: mock_standings.get_team_standings = AsyncMock(side_effect=get_standings) # Mock random for deterministic testing - with patch('tasks.transaction_freeze.random.randint', return_value=50000): - winning_ids, losing_ids, conflicts = await resolve_contested_transactions( - contested_transactions, 12 + with patch("tasks.transaction_freeze.random.randint", return_value=50000): + winning_ids, losing_ids, conflicts = ( + await resolve_contested_transactions(contested_transactions, 12) ) # WV should win (lower win% = higher priority) @@ -377,22 +341,24 @@ class TestResolveContestedTransactions: assert len(losing_ids) == 1 # Find which transaction won - wv_tx = next(tx for tx in contested_transactions if tx.newteam.abbrev == "WV") - ny_tx = next(tx for tx in contested_transactions if tx.newteam.abbrev == "NY") + wv_tx = next( + tx for tx in contested_transactions if tx.newteam.abbrev == "WV" + ) + ny_tx = next( + tx for tx in contested_transactions if tx.newteam.abbrev == "NY" + ) assert wv_tx.moveid in winning_ids assert ny_tx.moveid in losing_ids @pytest.mark.asyncio - async def test_mil_team_uses_parent_standings(self, sample_player, sample_standings_wv): + async def test_mil_team_uses_parent_standings( + self, sample_player, sample_standings_wv + ): """Test that MiL team transactions use parent ML team standings.""" # Create MiL team transaction that WILL be contested fa_team = TeamFactory.create( - id=999, - abbrev="FA", - sname="Free Agents", - lname="Free Agents", - season=12 + id=999, abbrev="FA", sname="Free Agents", lname="Free Agents", season=12 ) mil_team = TeamFactory.create( @@ -400,7 +366,7 @@ class TestResolveContestedTransactions: abbrev="WVMiL", sname="Black Bears MiL", lname="West Virginia Black Bears MiL", - season=12 + season=12, ) # Create TWO transactions for the same player to trigger contest resolution @@ -408,12 +374,12 @@ class TestResolveContestedTransactions: id=27789, week=10, season=12, - moveid='Season-012-Week-10-WVMiL-14:00:00', + moveid="Season-012-Week-10-WVMiL-14:00:00", player=sample_player, oldteam=fa_team, newteam=mil_team, cancelled=False, - frozen=True + frozen=True, ) # Second transaction to create a contest @@ -422,29 +388,34 @@ class TestResolveContestedTransactions: id=27790, week=10, season=12, - moveid='Season-012-Week-10-NY-14:01:00', + moveid="Season-012-Week-10-NY-14:01:00", player=sample_player, oldteam=fa_team, newteam=ny_team, cancelled=False, - frozen=True + frozen=True, ) transactions = [mil_transaction, ny_transaction] - with patch('tasks.transaction_freeze.standings_service') as mock_standings: + with patch("tasks.transaction_freeze.standings_service") as mock_standings: # Should request standings for "WV" (parent), not "WVMiL" - mock_standings.get_team_standings = AsyncMock(return_value=sample_standings_wv) + mock_standings.get_team_standings = AsyncMock( + return_value=sample_standings_wv + ) # Mock random for deterministic testing - with patch('tasks.transaction_freeze.random.randint', return_value=50000): - winning_ids, losing_ids, conflicts = await resolve_contested_transactions(transactions, 12) + with patch("tasks.transaction_freeze.random.randint", return_value=50000): + winning_ids, losing_ids, conflicts = ( + await resolve_contested_transactions(transactions, 12) + ) # Should have called with "WV" (stripped "MiL" suffix) # Will be called twice (once for WVMiL, once for NY) calls = mock_standings.get_team_standings.call_args_list - assert any(call[0] == ("WV", 12) for call in calls), \ - f"Expected call with ('WV', 12), got {calls}" + assert any( + call[0] == ("WV", 12) for call in calls + ), f"Expected call with ('WV', 12), got {calls}" # Should have resolved (one winner, one loser) assert len(winning_ids) == 1 @@ -454,11 +425,7 @@ class TestResolveContestedTransactions: async def test_fa_drops_not_contested(self, sample_player, sample_team_wv): """Test that FA drops are not considered for contests.""" fa_team = TeamFactory.create( - id=999, - abbrev="FA", - sname="Free Agents", - lname="Free Agents", - season=12 + id=999, abbrev="FA", sname="Free Agents", lname="Free Agents", season=12 ) # Drop to FA (not an acquisition) @@ -466,17 +433,19 @@ class TestResolveContestedTransactions: id=27790, week=10, season=12, - moveid='Season-012-Week-10-DROP-15:00:00', + moveid="Season-012-Week-10-DROP-15:00:00", player=sample_player, oldteam=sample_team_wv, newteam=fa_team, # Dropping to FA cancelled=False, - frozen=True + frozen=True, ) transactions = [drop_tx] - winning_ids, losing_ids, conflicts = await resolve_contested_transactions(transactions, 12) + winning_ids, losing_ids, conflicts = await resolve_contested_transactions( + transactions, 12 + ) # FA drops are not winners or losers (they're not acquisitions) assert len(winning_ids) == 0 @@ -485,14 +454,16 @@ class TestResolveContestedTransactions: @pytest.mark.asyncio async def test_standings_error_fallback(self, contested_transactions): """Test that standings errors result in 0.0 priority.""" - with patch('tasks.transaction_freeze.standings_service') as mock_standings: + with patch("tasks.transaction_freeze.standings_service") as mock_standings: # Simulate standings service error - mock_standings.get_team_standings = AsyncMock(side_effect=Exception("API Error")) + mock_standings.get_team_standings = AsyncMock( + side_effect=Exception("API Error") + ) # Mock random for deterministic testing - with patch('tasks.transaction_freeze.random.randint', return_value=50000): - winning_ids, losing_ids, conflicts = await resolve_contested_transactions( - contested_transactions, 12 + with patch("tasks.transaction_freeze.random.randint", return_value=50000): + winning_ids, losing_ids, conflicts = ( + await resolve_contested_transactions(contested_transactions, 12) ) # Should still resolve (one wins, one loses) @@ -503,67 +474,150 @@ class TestResolveContestedTransactions: async def test_three_way_contest(self, sample_player): """Test contest with three teams wanting same player.""" fa_team = TeamFactory.create( - id=999, - abbrev="FA", - sname="Free Agents", - lname="Free Agents", - season=12 + id=999, abbrev="FA", sname="Free Agents", lname="Free Agents", season=12 ) - team1 = TeamFactory.create(id=1, abbrev="T1", sname="Team 1", lname="Team 1", season=12) - team2 = TeamFactory.create(id=2, abbrev="T2", sname="Team 2", lname="Team 2", season=12) - team3 = TeamFactory.create(id=3, abbrev="T3", sname="Team 3", lname="Team 3", season=12) + team1 = TeamFactory.create( + id=1, abbrev="T1", sname="Team 1", lname="Team 1", season=12 + ) + team2 = TeamFactory.create( + id=2, abbrev="T2", sname="Team 2", lname="Team 2", season=12 + ) + team3 = TeamFactory.create( + id=3, abbrev="T3", sname="Team 3", lname="Team 3", season=12 + ) tx1 = Transaction( - id=1, week=10, season=12, moveid='move-1', player=sample_player, - oldteam=fa_team, newteam=team1, cancelled=False, frozen=True + id=1, + week=10, + season=12, + moveid="move-1", + player=sample_player, + oldteam=fa_team, + newteam=team1, + cancelled=False, + frozen=True, ) tx2 = Transaction( - id=2, week=10, season=12, moveid='move-2', player=sample_player, - oldteam=fa_team, newteam=team2, cancelled=False, frozen=True + id=2, + week=10, + season=12, + moveid="move-2", + player=sample_player, + oldteam=fa_team, + newteam=team2, + cancelled=False, + frozen=True, ) tx3 = Transaction( - id=3, week=10, season=12, moveid='move-3', player=sample_player, - oldteam=fa_team, newteam=team3, cancelled=False, frozen=True + id=3, + week=10, + season=12, + moveid="move-3", + player=sample_player, + oldteam=fa_team, + newteam=team3, + cancelled=False, + frozen=True, ) transactions = [tx1, tx2, tx3] - with patch('tasks.transaction_freeze.standings_service') as mock_standings: + with patch("tasks.transaction_freeze.standings_service") as mock_standings: + async def get_standings(team_abbrev, season): # Create minimal team objects for standings standings_map = { "T1": TeamStandings( - id=1, team=team1, wins=20, losses=80, run_diff=0, - home_wins=10, home_losses=40, away_wins=10, away_losses=40, - last8_wins=1, last8_losses=7, streak_wl="l", streak_num=5, - one_run_wins=5, one_run_losses=10, pythag_wins=22, pythag_losses=78, - div1_wins=5, div1_losses=15, div2_wins=5, div2_losses=15, - div3_wins=5, div3_losses=15, div4_wins=5, div4_losses=15 + id=1, + team=team1, + wins=20, + losses=80, + run_diff=0, + home_wins=10, + home_losses=40, + away_wins=10, + away_losses=40, + last8_wins=1, + last8_losses=7, + streak_wl="l", + streak_num=5, + one_run_wins=5, + one_run_losses=10, + pythag_wins=22, + pythag_losses=78, + div1_wins=5, + div1_losses=15, + div2_wins=5, + div2_losses=15, + div3_wins=5, + div3_losses=15, + div4_wins=5, + div4_losses=15, ), "T2": TeamStandings( - id=2, team=team2, wins=50, losses=50, run_diff=0, - home_wins=25, home_losses=25, away_wins=25, away_losses=25, - last8_wins=4, last8_losses=4, streak_wl="w", streak_num=2, - one_run_wins=10, one_run_losses=10, pythag_wins=50, pythag_losses=50, - div1_wins=12, div1_losses=13, div2_wins=13, div2_losses=12, - div3_wins=12, div3_losses=13, div4_wins=13, div4_losses=12 + id=2, + team=team2, + wins=50, + losses=50, + run_diff=0, + home_wins=25, + home_losses=25, + away_wins=25, + away_losses=25, + last8_wins=4, + last8_losses=4, + streak_wl="w", + streak_num=2, + one_run_wins=10, + one_run_losses=10, + pythag_wins=50, + pythag_losses=50, + div1_wins=12, + div1_losses=13, + div2_wins=13, + div2_losses=12, + div3_wins=12, + div3_losses=13, + div4_wins=13, + div4_losses=12, ), "T3": TeamStandings( - id=3, team=team3, wins=80, losses=20, run_diff=0, - home_wins=40, home_losses=10, away_wins=40, away_losses=10, - last8_wins=7, last8_losses=1, streak_wl="w", streak_num=8, - one_run_wins=15, one_run_losses=5, pythag_wins=78, pythag_losses=22, - div1_wins=20, div1_losses=5, div2_wins=20, div2_losses=5, - div3_wins=20, div3_losses=5, div4_wins=20, div4_losses=5 + id=3, + team=team3, + wins=80, + losses=20, + run_diff=0, + home_wins=40, + home_losses=10, + away_wins=40, + away_losses=10, + last8_wins=7, + last8_losses=1, + streak_wl="w", + streak_num=8, + one_run_wins=15, + one_run_losses=5, + pythag_wins=78, + pythag_losses=22, + div1_wins=20, + div1_losses=5, + div2_wins=20, + div2_losses=5, + div3_wins=20, + div3_losses=5, + div4_wins=20, + div4_losses=5, ), } return standings_map.get(team_abbrev) mock_standings.get_team_standings = AsyncMock(side_effect=get_standings) - with patch('tasks.transaction_freeze.random.randint', return_value=50000): - winning_ids, losing_ids, conflicts = await resolve_contested_transactions(transactions, 12) + with patch("tasks.transaction_freeze.random.randint", return_value=50000): + winning_ids, losing_ids, conflicts = ( + await resolve_contested_transactions(transactions, 12) + ) # Only one winner assert len(winning_ids) == 1 @@ -581,7 +635,7 @@ class TestTransactionFreezeTaskInitialization: def test_task_initialization(self, mock_bot): """Test task initialization.""" - with patch.object(TransactionFreezeTask, 'weekly_loop') as mock_loop: + with patch.object(TransactionFreezeTask, "weekly_loop") as mock_loop: task = TransactionFreezeTask(mock_bot) assert task.bot == mock_bot @@ -591,7 +645,7 @@ class TestTransactionFreezeTaskInitialization: def test_cog_unload(self, mock_bot): """Test that cog_unload cancels the task.""" - with patch.object(TransactionFreezeTask, 'weekly_loop') as mock_loop: + with patch.object(TransactionFreezeTask, "weekly_loop") as mock_loop: task = TransactionFreezeTask(mock_bot) task.cog_unload() @@ -605,15 +659,13 @@ class TestFreezeBeginLogic: @pytest.mark.asyncio async def test_begin_freeze_increments_week(self, mock_bot, current_state): """Test that freeze begin increments week and sets freeze flag.""" - with patch.object(TransactionFreezeTask, 'weekly_loop'): + with patch.object(TransactionFreezeTask, "weekly_loop"): task = TransactionFreezeTask(mock_bot) - with patch('tasks.transaction_freeze.league_service') as mock_league: + with patch("tasks.transaction_freeze.league_service") as mock_league: # Mock the update call updated_state = CurrentFactory.create( - week=11, # Incremented - season=12, - freeze=True # Set to True + week=11, season=12, freeze=True # Incremented # Set to True ) mock_league.update_current_state = AsyncMock(return_value=updated_state) @@ -626,20 +678,21 @@ class TestFreezeBeginLogic: # Verify week was incremented and freeze set mock_league.update_current_state.assert_called_once_with( - week=11, - freeze=True + week=11, freeze=True ) # Verify freeze announcement was sent - task._send_freeze_announcement.assert_called_once_with(11, is_beginning=True) + task._send_freeze_announcement.assert_called_once_with( + 11, is_beginning=True + ) @pytest.mark.asyncio async def test_begin_freeze_runs_transactions(self, mock_bot, current_state): """Test that freeze begin runs regular transactions.""" - with patch.object(TransactionFreezeTask, 'weekly_loop'): + with patch.object(TransactionFreezeTask, "weekly_loop"): task = TransactionFreezeTask(mock_bot) - with patch('tasks.transaction_freeze.league_service') as mock_league: + with patch("tasks.transaction_freeze.league_service") as mock_league: updated_state = CurrentFactory.create(week=11, season=12, freeze=True) mock_league.update_current_state = AsyncMock(return_value=updated_state) @@ -653,12 +706,14 @@ class TestFreezeBeginLogic: task._run_transactions.assert_called_once() @pytest.mark.asyncio - async def test_begin_freeze_posts_weekly_info_weeks_1_18(self, mock_bot, current_state): + async def test_begin_freeze_posts_weekly_info_weeks_1_18( + self, mock_bot, current_state + ): """Test that weekly info is posted for weeks 1-18.""" - with patch.object(TransactionFreezeTask, 'weekly_loop'): + with patch.object(TransactionFreezeTask, "weekly_loop"): task = TransactionFreezeTask(mock_bot) - with patch('tasks.transaction_freeze.league_service') as mock_league: + with patch("tasks.transaction_freeze.league_service") as mock_league: # Week 5 (within 1-18 range) updated_state = CurrentFactory.create(week=5, season=12, freeze=True) mock_league.update_current_state = AsyncMock(return_value=updated_state) @@ -674,12 +729,14 @@ class TestFreezeBeginLogic: task._post_weekly_info.assert_called_once() @pytest.mark.asyncio - async def test_begin_freeze_skips_weekly_info_after_week_18(self, mock_bot, current_state): + async def test_begin_freeze_skips_weekly_info_after_week_18( + self, mock_bot, current_state + ): """Test that weekly info is NOT posted after week 18.""" - with patch.object(TransactionFreezeTask, 'weekly_loop'): + with patch.object(TransactionFreezeTask, "weekly_loop"): task = TransactionFreezeTask(mock_bot) - with patch('tasks.transaction_freeze.league_service') as mock_league: + with patch("tasks.transaction_freeze.league_service") as mock_league: # Week 19 (playoffs) updated_state = CurrentFactory.create(week=19, season=12, freeze=True) mock_league.update_current_state = AsyncMock(return_value=updated_state) @@ -697,16 +754,16 @@ class TestFreezeBeginLogic: @pytest.mark.asyncio async def test_begin_freeze_error_handling(self, mock_bot, current_state): """Test that errors in freeze begin are raised.""" - with patch.object(TransactionFreezeTask, 'weekly_loop'): + with patch.object(TransactionFreezeTask, "weekly_loop"): task = TransactionFreezeTask(mock_bot) - with patch('tasks.transaction_freeze.league_service') as mock_league: + with patch("tasks.transaction_freeze.league_service") as mock_league: mock_league.update_current_state = AsyncMock( side_effect=Exception("Database error") ) # Patch logger to avoid exc_info conflict - with patch.object(task.logger, 'error'): + with patch.object(task.logger, "error"): with pytest.raises(Exception, match="Database error"): await task._begin_freeze(current_state) @@ -717,10 +774,10 @@ class TestFreezeEndLogic: @pytest.mark.asyncio async def test_end_freeze_processes_transactions(self, mock_bot, frozen_state): """Test that freeze end processes frozen transactions.""" - with patch.object(TransactionFreezeTask, 'weekly_loop'): + with patch.object(TransactionFreezeTask, "weekly_loop"): task = TransactionFreezeTask(mock_bot) - with patch('tasks.transaction_freeze.league_service') as mock_league: + with patch("tasks.transaction_freeze.league_service") as mock_league: updated_state = CurrentFactory.create(week=10, season=12, freeze=False) mock_league.update_current_state = AsyncMock(return_value=updated_state) @@ -735,10 +792,10 @@ class TestFreezeEndLogic: @pytest.mark.asyncio async def test_end_freeze_sets_freeze_false(self, mock_bot, frozen_state): """Test that freeze end sets freeze flag to False.""" - with patch.object(TransactionFreezeTask, 'weekly_loop'): + with patch.object(TransactionFreezeTask, "weekly_loop"): task = TransactionFreezeTask(mock_bot) - with patch('tasks.transaction_freeze.league_service') as mock_league: + with patch("tasks.transaction_freeze.league_service") as mock_league: updated_state = CurrentFactory.create(week=10, season=12, freeze=False) mock_league.update_current_state = AsyncMock(return_value=updated_state) @@ -753,10 +810,10 @@ class TestFreezeEndLogic: @pytest.mark.asyncio async def test_end_freeze_sends_announcement(self, mock_bot, frozen_state): """Test that freeze end sends thaw announcement.""" - with patch.object(TransactionFreezeTask, 'weekly_loop'): + with patch.object(TransactionFreezeTask, "weekly_loop"): task = TransactionFreezeTask(mock_bot) - with patch('tasks.transaction_freeze.league_service') as mock_league: + with patch("tasks.transaction_freeze.league_service") as mock_league: updated_state = CurrentFactory.create(week=10, season=12, freeze=False) mock_league.update_current_state = AsyncMock(return_value=updated_state) @@ -766,15 +823,17 @@ class TestFreezeEndLogic: await task._end_freeze(frozen_state) # Verify thaw announcement was sent - task._send_freeze_announcement.assert_called_once_with(10, is_beginning=False) + task._send_freeze_announcement.assert_called_once_with( + 10, is_beginning=False + ) @pytest.mark.asyncio async def test_end_freeze_error_handling(self, mock_bot, frozen_state): """Test that errors in freeze end are raised.""" - with patch.object(TransactionFreezeTask, 'weekly_loop'): + with patch.object(TransactionFreezeTask, "weekly_loop"): task = TransactionFreezeTask(mock_bot) - with patch('tasks.transaction_freeze.league_service') as mock_league: + with patch("tasks.transaction_freeze.league_service") as mock_league: mock_league.update_current_state = AsyncMock( side_effect=Exception("Database error") ) @@ -782,7 +841,7 @@ class TestFreezeEndLogic: task._process_frozen_transactions = AsyncMock() # Patch logger to avoid exc_info conflict - with patch.object(task.logger, 'error'): + with patch.object(task.logger, "error"): with pytest.raises(Exception, match="Database error"): await task._end_freeze(frozen_state) @@ -792,22 +851,23 @@ class TestProcessFrozenTransactions: @pytest.mark.asyncio async def test_process_frozen_transactions_basic( - self, - mock_bot, - frozen_state, - sample_transaction + self, mock_bot, frozen_state, sample_transaction ): """Test basic frozen transaction processing.""" - with patch.object(TransactionFreezeTask, 'weekly_loop'): + with patch.object(TransactionFreezeTask, "weekly_loop"): task = TransactionFreezeTask(mock_bot) - with patch('tasks.transaction_freeze.transaction_service') as mock_tx_service: + with patch( + "tasks.transaction_freeze.transaction_service" + ) as mock_tx_service: mock_tx_service.get_frozen_transactions_by_week = AsyncMock( return_value=[sample_transaction] ) mock_tx_service.unfreeze_transaction = AsyncMock(return_value=True) - with patch('tasks.transaction_freeze.resolve_contested_transactions') as mock_resolve: + with patch( + "tasks.transaction_freeze.resolve_contested_transactions" + ) as mock_resolve: # Returns (winning_ids, losing_ids, conflict_resolutions) mock_resolve.return_value = ([sample_transaction.moveid], [], []) @@ -826,25 +886,26 @@ class TestProcessFrozenTransactions: @pytest.mark.asyncio async def test_process_frozen_transactions_with_cancellations( - self, - mock_bot, - frozen_state, - contested_transactions + self, mock_bot, frozen_state, contested_transactions ): """Test processing with contested transactions and cancellations.""" - with patch.object(TransactionFreezeTask, 'weekly_loop'): + with patch.object(TransactionFreezeTask, "weekly_loop"): task = TransactionFreezeTask(mock_bot) tx1, tx2 = contested_transactions - with patch('tasks.transaction_freeze.transaction_service') as mock_tx_service: + with patch( + "tasks.transaction_freeze.transaction_service" + ) as mock_tx_service: mock_tx_service.get_frozen_transactions_by_week = AsyncMock( return_value=contested_transactions ) mock_tx_service.cancel_transaction = AsyncMock(return_value=True) mock_tx_service.unfreeze_transaction = AsyncMock(return_value=True) - with patch('tasks.transaction_freeze.resolve_contested_transactions') as mock_resolve: + with patch( + "tasks.transaction_freeze.resolve_contested_transactions" + ) as mock_resolve: # tx1 wins, tx2 loses - returns (winning_ids, losing_ids, conflict_resolutions) mock_resolve.return_value = ([tx1.moveid], [tx2.moveid], []) @@ -855,22 +916,30 @@ class TestProcessFrozenTransactions: await task._process_frozen_transactions(frozen_state) # Verify losing transaction was cancelled (uses moveid, not id) - mock_tx_service.cancel_transaction.assert_called_once_with(tx2.moveid) + mock_tx_service.cancel_transaction.assert_called_once_with( + tx2.moveid + ) # Verify GM was notified task._notify_gm_of_cancellation.assert_called_once() # Verify winning transaction was unfrozen (uses moveid, not id) - mock_tx_service.unfreeze_transaction.assert_called_once_with(tx1.moveid) + mock_tx_service.unfreeze_transaction.assert_called_once_with( + tx1.moveid + ) @pytest.mark.asyncio async def test_process_frozen_no_transactions(self, mock_bot, frozen_state): """Test processing when no frozen transactions exist.""" - with patch.object(TransactionFreezeTask, 'weekly_loop'): + with patch.object(TransactionFreezeTask, "weekly_loop"): task = TransactionFreezeTask(mock_bot) - with patch('tasks.transaction_freeze.transaction_service') as mock_tx_service: - mock_tx_service.get_frozen_transactions_by_week = AsyncMock(return_value=None) + with patch( + "tasks.transaction_freeze.transaction_service" + ) as mock_tx_service: + mock_tx_service.get_frozen_transactions_by_week = AsyncMock( + return_value=None + ) # Mock the thaw report posting (empty report is still posted for visibility) task._post_thaw_report = AsyncMock() @@ -883,23 +952,24 @@ class TestProcessFrozenTransactions: @pytest.mark.asyncio async def test_process_frozen_transaction_error_recovery( - self, - mock_bot, - frozen_state, - sample_transaction + self, mock_bot, frozen_state, sample_transaction ): """Test that processing continues despite individual transaction errors.""" - with patch.object(TransactionFreezeTask, 'weekly_loop'): + with patch.object(TransactionFreezeTask, "weekly_loop"): task = TransactionFreezeTask(mock_bot) - with patch('tasks.transaction_freeze.transaction_service') as mock_tx_service: + with patch( + "tasks.transaction_freeze.transaction_service" + ) as mock_tx_service: mock_tx_service.get_frozen_transactions_by_week = AsyncMock( return_value=[sample_transaction] ) # Simulate unfreeze failure mock_tx_service.unfreeze_transaction = AsyncMock(return_value=False) - with patch('tasks.transaction_freeze.resolve_contested_transactions') as mock_resolve: + with patch( + "tasks.transaction_freeze.resolve_contested_transactions" + ) as mock_resolve: # Returns (winning_ids, losing_ids, conflict_resolutions) mock_resolve.return_value = ([sample_transaction.moveid], [], []) @@ -919,7 +989,7 @@ class TestNotificationsAndEmbeds: @pytest.mark.asyncio async def test_send_freeze_announcement_begin(self, mock_bot, current_state): """Test freeze begin announcement.""" - with patch.object(TransactionFreezeTask, 'weekly_loop'): + with patch.object(TransactionFreezeTask, "weekly_loop"): task = TransactionFreezeTask(mock_bot) # Mock guild and channel (get_guild is sync, returns MagicMock not AsyncMock) @@ -927,14 +997,17 @@ class TestNotificationsAndEmbeds: mock_channel = MagicMock() mock_channel.send = AsyncMock() # send is async mock_guild.text_channels = [mock_channel] - mock_channel.name = 'transaction-log' + mock_channel.name = "transaction-log" - with patch('tasks.transaction_freeze.get_config') as mock_config: + with patch("tasks.transaction_freeze.get_config") as mock_config: config = MagicMock() config.guild_id = 12345 mock_config.return_value = config - with patch('tasks.transaction_freeze.discord.utils.get', return_value=mock_channel): + with patch( + "tasks.transaction_freeze.discord.utils.get", + return_value=mock_channel, + ): # get_guild should return sync, not async task.bot.get_guild = MagicMock(return_value=mock_guild) @@ -945,28 +1018,33 @@ class TestNotificationsAndEmbeds: # Verify message content call_args = mock_channel.send.call_args - message = call_args[0][0] if call_args[0] else call_args[1]['content'] - assert 'Week 10' in message - assert 'Freeze Period Begins' in message + message = ( + call_args[0][0] if call_args[0] else call_args[1]["content"] + ) + assert "Week 10" in message + assert "Freeze Period Begins" in message @pytest.mark.asyncio async def test_send_freeze_announcement_end(self, mock_bot, current_state): """Test freeze end (thaw) announcement.""" - with patch.object(TransactionFreezeTask, 'weekly_loop'): + with patch.object(TransactionFreezeTask, "weekly_loop"): task = TransactionFreezeTask(mock_bot) mock_guild = MagicMock() mock_channel = MagicMock() mock_channel.send = AsyncMock() # send is async mock_guild.text_channels = [mock_channel] - mock_channel.name = 'transaction-log' + mock_channel.name = "transaction-log" - with patch('tasks.transaction_freeze.get_config') as mock_config: + with patch("tasks.transaction_freeze.get_config") as mock_config: config = MagicMock() config.guild_id = 12345 mock_config.return_value = config - with patch('tasks.transaction_freeze.discord.utils.get', return_value=mock_channel): + with patch( + "tasks.transaction_freeze.discord.utils.get", + return_value=mock_channel, + ): # get_guild should return sync, not async task.bot.get_guild = MagicMock(return_value=mock_guild) @@ -977,19 +1055,18 @@ class TestNotificationsAndEmbeds: # Verify message content call_args = mock_channel.send.call_args - message = call_args[0][0] if call_args[0] else call_args[1]['content'] - assert 'Week 10' in message - assert 'Freeze Period Ends' in message + message = ( + call_args[0][0] if call_args[0] else call_args[1]["content"] + ) + assert "Week 10" in message + assert "Freeze Period Ends" in message @pytest.mark.asyncio async def test_notify_gm_of_cancellation( - self, - mock_bot, - sample_transaction, - sample_team_wv + self, mock_bot, sample_transaction, sample_team_wv ): """Test GM notification of cancelled transaction.""" - with patch.object(TransactionFreezeTask, 'weekly_loop'): + with patch.object(TransactionFreezeTask, "weekly_loop"): task = TransactionFreezeTask(mock_bot) # Mock guild members (get_member is sync, but send is async) @@ -1001,10 +1078,10 @@ class TestNotificationsAndEmbeds: mock_guild.get_member.side_effect = lambda id: { 111111: mock_gm1, - 222222: mock_gm2 + 222222: mock_gm2, }.get(id) - with patch('tasks.transaction_freeze.get_config') as mock_config: + with patch("tasks.transaction_freeze.get_config") as mock_config: config = MagicMock() config.guild_id = 12345 mock_config.return_value = config @@ -1012,7 +1089,9 @@ class TestNotificationsAndEmbeds: # get_guild should return sync, not async task.bot.get_guild = MagicMock(return_value=mock_guild) - await task._notify_gm_of_cancellation(sample_transaction, sample_team_wv) + await task._notify_gm_of_cancellation( + sample_transaction, sample_team_wv + ) # Verify both GMs were sent messages mock_gm1.send.assert_called_once() @@ -1021,7 +1100,7 @@ class TestNotificationsAndEmbeds: # Verify message content message = mock_gm1.send.call_args[0][0] assert sample_transaction.player.name in message - assert 'cancelled' in message.lower() + assert "cancelled" in message.lower() class TestOffseasonMode: @@ -1034,12 +1113,12 @@ class TestOffseasonMode: task = TransactionFreezeTask(mock_bot) task.weekly_loop.cancel() # Stop the actual loop - with patch('tasks.transaction_freeze.get_config') as mock_config: + with patch("tasks.transaction_freeze.get_config") as mock_config: config = MagicMock() config.offseason_flag = True # Offseason enabled mock_config.return_value = config - with patch('tasks.transaction_freeze.league_service') as mock_league: + with patch("tasks.transaction_freeze.league_service") as mock_league: mock_league.get_current_state = AsyncMock(return_value=current_state) task._begin_freeze = AsyncMock() @@ -1063,12 +1142,12 @@ class TestErrorHandlingAndRecovery: task = TransactionFreezeTask(mock_bot) task.weekly_loop.cancel() # Stop the actual loop - with patch('tasks.transaction_freeze.get_config') as mock_config: + with patch("tasks.transaction_freeze.get_config") as mock_config: config = MagicMock() config.offseason_flag = False mock_config.return_value = config - with patch('tasks.transaction_freeze.league_service') as mock_league: + with patch("tasks.transaction_freeze.league_service") as mock_league: # Simulate error getting current state mock_league.get_current_state = AsyncMock( side_effect=Exception("Database connection failed") @@ -1093,12 +1172,12 @@ class TestErrorHandlingAndRecovery: task.weekly_loop.cancel() # Stop the actual loop task.error_notification_sent = True # Already sent - with patch('tasks.transaction_freeze.get_config') as mock_config: + with patch("tasks.transaction_freeze.get_config") as mock_config: config = MagicMock() config.offseason_flag = False mock_config.return_value = config - with patch('tasks.transaction_freeze.league_service') as mock_league: + with patch("tasks.transaction_freeze.league_service") as mock_league: mock_league.get_current_state = AsyncMock( side_effect=Exception("Another error") ) @@ -1114,7 +1193,7 @@ class TestErrorHandlingAndRecovery: @pytest.mark.asyncio async def test_send_owner_notification(self, mock_bot): """Test sending owner notification.""" - with patch.object(TransactionFreezeTask, 'weekly_loop'): + with patch.object(TransactionFreezeTask, "weekly_loop"): task = TransactionFreezeTask(mock_bot) await task._send_owner_notification("Test error message") @@ -1136,23 +1215,23 @@ class TestWeeklyScheduleTiming: # Don't patch weekly_loop - let it initialize naturally then cancel it task = TransactionFreezeTask(mock_bot) task.weekly_loop.cancel() # Stop the actual loop - task.error_notification_sent = True # Set to True (as if Saturday thaw completed) + task.error_notification_sent = ( + True # Set to True (as if Saturday thaw completed) + ) - # Mock datetime to be Monday (weekday=0) at 00:00 - mock_now = MagicMock() - mock_now.weekday.return_value = 0 # Monday - mock_now.hour = 0 + # Mock now_chicago to return Monday (weekday=0) at 00:00 + mock_now = datetime(2024, 7, 15, 0, 0, 0) # Monday 2024-07-15 - with patch('tasks.transaction_freeze.datetime') as mock_datetime: - mock_datetime.now.return_value = mock_now - - with patch('tasks.transaction_freeze.get_config') as mock_config: + with patch("tasks.transaction_freeze.now_chicago", return_value=mock_now): + with patch("tasks.transaction_freeze.get_config") as mock_config: config = MagicMock() config.offseason_flag = False mock_config.return_value = config - with patch('tasks.transaction_freeze.league_service') as mock_league: - mock_league.get_current_state = AsyncMock(return_value=current_state) + with patch("tasks.transaction_freeze.league_service") as mock_league: + mock_league.get_current_state = AsyncMock( + return_value=current_state + ) task._begin_freeze = AsyncMock() task._end_freeze = AsyncMock() @@ -1171,20 +1250,16 @@ class TestWeeklyScheduleTiming: task = TransactionFreezeTask(mock_bot) task.weekly_loop.cancel() # Stop the actual loop - # Mock datetime to be Saturday (weekday=5) at 00:00 - mock_now = MagicMock() - mock_now.weekday.return_value = 5 # Saturday - mock_now.hour = 0 + # Mock now_chicago to return Saturday (weekday=5) at 00:00 + mock_now = datetime(2024, 7, 20, 0, 0, 0) # Saturday 2024-07-20 - with patch('tasks.transaction_freeze.datetime') as mock_datetime: - mock_datetime.now.return_value = mock_now - - with patch('tasks.transaction_freeze.get_config') as mock_config: + with patch("tasks.transaction_freeze.now_chicago", return_value=mock_now): + with patch("tasks.transaction_freeze.get_config") as mock_config: config = MagicMock() config.offseason_flag = False mock_config.return_value = config - with patch('tasks.transaction_freeze.league_service') as mock_league: + with patch("tasks.transaction_freeze.league_service") as mock_league: mock_league.get_current_state = AsyncMock(return_value=frozen_state) task._begin_freeze = AsyncMock() diff --git a/tests/test_utils_timezone.py b/tests/test_utils_timezone.py new file mode 100644 index 0000000..63c3903 --- /dev/null +++ b/tests/test_utils_timezone.py @@ -0,0 +1,129 @@ +""" +Tests for timezone utility module. + +Validates centralized timezone helpers that ensure scheduling logic uses +explicit America/Chicago timezone rather than relying on OS defaults. +""" + +import pytest +from datetime import datetime, timezone +from zoneinfo import ZoneInfo + +from utils.timezone import ( + CHICAGO_TZ, + now_utc, + now_chicago, + to_chicago, + to_discord_timestamp, +) + + +class TestChicagoTZ: + """Tests for the CHICAGO_TZ constant.""" + + def test_chicago_tz_is_zoneinfo(self): + """CHICAGO_TZ should be a ZoneInfo instance for America/Chicago.""" + assert isinstance(CHICAGO_TZ, ZoneInfo) + assert str(CHICAGO_TZ) == "America/Chicago" + + +class TestNowUtc: + """Tests for now_utc().""" + + def test_returns_aware_datetime(self): + """now_utc() should return a timezone-aware datetime.""" + result = now_utc() + assert result.tzinfo is not None + + def test_returns_utc(self): + """now_utc() should return a datetime in UTC.""" + result = now_utc() + assert result.tzinfo == timezone.utc + + +class TestNowChicago: + """Tests for now_chicago().""" + + def test_returns_aware_datetime(self): + """now_chicago() should return a timezone-aware datetime.""" + result = now_chicago() + assert result.tzinfo is not None + + def test_returns_chicago_tz(self): + """now_chicago() should return a datetime in America/Chicago timezone.""" + result = now_chicago() + assert result.tzinfo == CHICAGO_TZ + + +class TestToChicago: + """Tests for to_chicago().""" + + def test_converts_utc_datetime(self): + """to_chicago() should correctly convert a UTC datetime to Chicago time.""" + # 2024-07-15 18:00 UTC = 2024-07-15 13:00 CDT (UTC-5 during summer) + utc_dt = datetime(2024, 7, 15, 18, 0, 0, tzinfo=timezone.utc) + result = to_chicago(utc_dt) + assert result.hour == 13 + assert result.tzinfo == CHICAGO_TZ + + def test_handles_naive_datetime_assumes_utc(self): + """to_chicago() should treat naive datetimes as UTC.""" + naive_dt = datetime(2024, 7, 15, 18, 0, 0) + result = to_chicago(naive_dt) + # Same as converting from UTC + utc_dt = datetime(2024, 7, 15, 18, 0, 0, tzinfo=timezone.utc) + expected = to_chicago(utc_dt) + assert result.hour == expected.hour + assert result.tzinfo == CHICAGO_TZ + + def test_preserves_already_chicago(self): + """to_chicago() on an already-Chicago datetime should be a no-op.""" + chicago_dt = datetime(2024, 7, 15, 13, 0, 0, tzinfo=CHICAGO_TZ) + result = to_chicago(chicago_dt) + assert result.hour == 13 + assert result.tzinfo == CHICAGO_TZ + + def test_winter_offset(self): + """to_chicago() should use CST (UTC-6) during winter months.""" + # 2024-01-15 18:00 UTC = 2024-01-15 12:00 CST (UTC-6 during winter) + utc_dt = datetime(2024, 1, 15, 18, 0, 0, tzinfo=timezone.utc) + result = to_chicago(utc_dt) + assert result.hour == 12 + + +class TestToDiscordTimestamp: + """Tests for to_discord_timestamp().""" + + def test_default_style(self): + """to_discord_timestamp() should use 'f' style by default.""" + dt = datetime(2024, 7, 15, 18, 0, 0, tzinfo=timezone.utc) + result = to_discord_timestamp(dt) + unix_ts = int(dt.timestamp()) + assert result == f"" + + def test_relative_style(self): + """to_discord_timestamp() with style='R' should produce relative format.""" + dt = datetime(2024, 7, 15, 18, 0, 0, tzinfo=timezone.utc) + result = to_discord_timestamp(dt, style="R") + unix_ts = int(dt.timestamp()) + assert result == f"" + + def test_all_styles(self): + """to_discord_timestamp() should support all Discord timestamp styles.""" + dt = datetime(2024, 7, 15, 18, 0, 0, tzinfo=timezone.utc) + unix_ts = int(dt.timestamp()) + for style in ("R", "f", "F", "t", "T", "d", "D"): + result = to_discord_timestamp(dt, style=style) + assert result == f"" + + def test_naive_datetime_assumes_utc(self): + """to_discord_timestamp() should treat naive datetimes as UTC.""" + naive_dt = datetime(2024, 7, 15, 18, 0, 0) + aware_dt = datetime(2024, 7, 15, 18, 0, 0, tzinfo=timezone.utc) + assert to_discord_timestamp(naive_dt) == to_discord_timestamp(aware_dt) + + def test_chicago_datetime_same_instant(self): + """to_discord_timestamp() should produce the same unix timestamp regardless of tz.""" + utc_dt = datetime(2024, 7, 15, 18, 0, 0, tzinfo=timezone.utc) + chicago_dt = utc_dt.astimezone(CHICAGO_TZ) + assert to_discord_timestamp(utc_dt) == to_discord_timestamp(chicago_dt) diff --git a/utils/logging.py b/utils/logging.py index 2b16f26..92c0f05 100644 --- a/utils/logging.py +++ b/utils/logging.py @@ -10,7 +10,7 @@ import json import logging import time import uuid -from datetime import datetime +from datetime import datetime, UTC from typing import Dict, Any, Optional, Union # Context variable for request tracking across async calls @@ -32,7 +32,7 @@ class JSONFormatter(logging.Formatter): """Format log record as JSON with context information.""" # Base log object log_obj: dict[str, JSONValue] = { - "timestamp": datetime.now().isoformat() + "Z", + "timestamp": datetime.now(UTC).isoformat(), "level": record.levelname, "logger": record.name, "message": record.getMessage(), diff --git a/utils/timezone.py b/utils/timezone.py new file mode 100644 index 0000000..056e8b5 --- /dev/null +++ b/utils/timezone.py @@ -0,0 +1,54 @@ +""" +Timezone Utilities + +Centralized timezone handling for the Discord bot. The SBA league operates +in America/Chicago time, but the production container may have ambiguous +timezone config. These helpers ensure scheduling logic uses explicit timezones +rather than relying on the OS default. + +- Internal storage/logging: UTC +- Scheduling checks (freeze/thaw): America/Chicago +""" + +from datetime import datetime, timezone +from zoneinfo import ZoneInfo + +# League timezone — all scheduling decisions use this +CHICAGO_TZ = ZoneInfo("America/Chicago") + + +def now_utc() -> datetime: + """Return the current time as a timezone-aware UTC datetime.""" + return datetime.now(timezone.utc) + + +def now_chicago() -> datetime: + """Return the current time as a timezone-aware America/Chicago datetime.""" + return datetime.now(CHICAGO_TZ) + + +def to_chicago(dt: datetime) -> datetime: + """Convert a datetime to America/Chicago. + + If *dt* is naive (no tzinfo), it is assumed to be UTC. + """ + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + return dt.astimezone(CHICAGO_TZ) + + +def to_discord_timestamp(dt: datetime, style: str = "f") -> str: + """Format a datetime as a Discord dynamic timestamp. + + Args: + dt: A datetime (naive datetimes are assumed UTC). + style: Discord timestamp style letter. + R = relative, f = long date/short time, F = long date/time, + t = short time, T = long time, d = short date, D = long date. + + Returns: + A string like ````. + """ + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + return f""