From cfbebe02c4e715989ff30abd9139c20e72872d11 Mon Sep 17 00:00:00 2001 From: Cal Corum Date: Sun, 22 Feb 2026 15:55:49 -0600 Subject: [PATCH] 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 | 428 +++++++++++------- tests/test_tasks_transaction_freeze.py | 576 ++++++++++++++----------- tests/test_utils_timezone.py | 129 ++++++ utils/logging.py | 4 +- utils/timezone.py | 54 +++ 5 files changed, 783 insertions(+), 408 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 fee9ecd..d3536e3 100644 --- a/tasks/transaction_freeze.py +++ b/tasks/transaction_freeze.py @@ -4,9 +4,12 @@ Transaction Freeze/Thaw Task for Discord Bot v2.0 Automated weekly system for freezing and processing transactions. 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 @@ -30,6 +33,7 @@ class TransactionPriority: Data class for transaction priority calculation. Used to resolve contested transactions (multiple teams wanting same player). """ + transaction: Transaction team_win_percentage: float tiebreaker: float # win% + small random number for randomized tiebreak @@ -42,6 +46,7 @@ class TransactionPriority: @dataclass class ConflictContender: """A team contending for a contested player.""" + team_abbrev: str wins: int losses: int @@ -52,6 +57,7 @@ class ConflictContender: @dataclass class ConflictResolution: """Details of a conflict resolution for a contested player.""" + player_name: str player_swar: float contenders: List[ConflictContender] @@ -62,6 +68,7 @@ class ConflictResolution: @dataclass class ThawedMove: """A move that was successfully thawed (unfrozen).""" + move_id: str team_abbrev: str players: List[Tuple[str, float, str, str]] # (name, sWAR, old_team, new_team) @@ -71,6 +78,7 @@ class ThawedMove: @dataclass class CancelledMove: """A move that was cancelled due to conflict.""" + move_id: str team_abbrev: str players: List[Tuple[str, float, str, str]] # (name, sWAR, old_team, new_team) @@ -81,6 +89,7 @@ class CancelledMove: @dataclass class ThawReport: """Complete thaw report for admin review.""" + week: int season: int timestamp: datetime @@ -94,8 +103,7 @@ class ThawReport: async def resolve_contested_transactions( - transactions: List[Transaction], - season: int + transactions: List[Transaction], season: int ) -> Tuple[List[str], List[str], List[ConflictResolution]]: """ Resolve contested transactions where multiple teams want the same player. @@ -109,7 +117,7 @@ async def resolve_contested_transactions( Returns: Tuple of (winning_move_ids, losing_move_ids, conflict_resolutions) """ - logger = get_contextual_logger(f'{__name__}.resolve_contested_transactions') + logger = get_contextual_logger(f"{__name__}.resolve_contested_transactions") # Group transactions by player name player_transactions: Dict[str, List[Transaction]] = {} @@ -118,7 +126,7 @@ async def resolve_contested_transactions( player_name = transaction.player.name.lower() # Only consider transactions where a team is acquiring a player (not FA drops) - if transaction.newteam.abbrev.upper() != 'FA': + if transaction.newteam.abbrev.upper() != "FA": if player_name not in player_transactions: player_transactions[player_name] = [] player_transactions[player_name].append(transaction) @@ -130,7 +138,9 @@ async def resolve_contested_transactions( for player_name, player_transactions_list in player_transactions.items(): if len(player_transactions_list) > 1: contested_players[player_name] = player_transactions_list - logger.info(f"Contested player: {player_name} ({len(player_transactions_list)} teams)") + logger.info( + f"Contested player: {player_name} ({len(player_transactions_list)} teams)" + ) else: # Non-contested, automatically wins non_contested_moves.add(player_transactions_list[0].moveid) @@ -143,50 +153,66 @@ async def resolve_contested_transactions( for player_name, contested_transactions in contested_players.items(): priorities: List[TransactionPriority] = [] # Track standings data for each team for report - team_standings_data: Dict[str, Tuple[int, int, float]] = {} # abbrev -> (wins, losses, win_pct) + team_standings_data: Dict[str, Tuple[int, int, float]] = ( + {} + ) # abbrev -> (wins, losses, win_pct) for transaction in contested_transactions: # Get team for priority calculation # If adding to MiL team, use the parent ML team for standings - if transaction.newteam.abbrev.endswith('MiL'): + if transaction.newteam.abbrev.endswith("MiL"): team_abbrev = transaction.newteam.abbrev[:-3] # Remove 'MiL' suffix else: team_abbrev = transaction.newteam.abbrev try: # Get team standings to calculate win percentage - standings = await standings_service.get_team_standings(team_abbrev, season) + standings = await standings_service.get_team_standings( + team_abbrev, season + ) - if standings and standings.wins is not None and standings.losses is not None: + if ( + standings + and standings.wins is not None + and standings.losses is not None + ): total_games = standings.wins + standings.losses win_pct = standings.wins / total_games if total_games > 0 else 0.0 team_standings_data[transaction.newteam.abbrev] = ( - standings.wins, standings.losses, win_pct + standings.wins, + standings.losses, + win_pct, ) else: win_pct = 0.0 team_standings_data[transaction.newteam.abbrev] = (0, 0, 0.0) - logger.warning(f"Could not get standings for {team_abbrev}, using 0.0 win%") + logger.warning( + f"Could not get standings for {team_abbrev}, using 0.0 win%" + ) # Add small random component for tiebreaking (5 decimal precision) random_component = random.randint(10000, 99999) * 0.00000001 tiebreaker = win_pct + random_component - priorities.append(TransactionPriority( - transaction=transaction, - team_win_percentage=win_pct, - tiebreaker=tiebreaker - )) + priorities.append( + TransactionPriority( + transaction=transaction, + team_win_percentage=win_pct, + tiebreaker=tiebreaker, + ) + ) except Exception as e: logger.error(f"Error calculating priority for {team_abbrev}: {e}") team_standings_data[transaction.newteam.abbrev] = (0, 0, 0.0) # Give them 0.0 priority on error - priorities.append(TransactionPriority( - transaction=transaction, - team_win_percentage=0.0, - tiebreaker=random.randint(10000, 99999) * 0.00000001 - )) + priorities.append( + TransactionPriority( + transaction=transaction, + team_win_percentage=0.0, + tiebreaker=random.randint(10000, 99999) * 0.00000001, + ) + ) # Sort by tiebreaker (lowest win% wins - worst teams get priority) priorities.sort() @@ -204,7 +230,7 @@ async def resolve_contested_transactions( wins=winner_standings[0], losses=winner_standings[1], win_pct=winner_standings[2], - move_id=winner.transaction.moveid + move_id=winner.transaction.moveid, ) loser_contenders: List[ConflictContender] = [] @@ -224,7 +250,7 @@ async def resolve_contested_transactions( wins=loser_standings[0], losses=loser_standings[1], win_pct=loser_standings[2], - move_id=loser.transaction.moveid + move_id=loser.transaction.moveid, ) loser_contenders.append(loser_contender) all_contenders.append(loser_contender) @@ -236,13 +262,15 @@ async def resolve_contested_transactions( # Get player info from first transaction (they all have same player) player = contested_transactions[0].player - conflict_resolutions.append(ConflictResolution( - player_name=player.name, - player_swar=player.wara, - contenders=all_contenders, - winner=winner_contender, - losers=loser_contenders - )) + conflict_resolutions.append( + ConflictResolution( + player_name=player.name, + player_swar=player.wara, + contenders=all_contenders, + winner=winner_contender, + losers=loser_contenders, + ) + ) # Add non-contested moves to winners winning_move_ids.update(non_contested_moves) @@ -255,7 +283,7 @@ class TransactionFreezeTask: def __init__(self, bot: commands.Bot): self.bot = bot - self.logger = get_contextual_logger(f'{__name__}.TransactionFreezeTask') + self.logger = get_contextual_logger(f"{__name__}.TransactionFreezeTask") # Track last execution to prevent duplicate operations self.last_freeze_week: int | None = None @@ -288,7 +316,9 @@ class TransactionFreezeTask: # Skip if offseason mode is enabled if config.offseason_flag: - self.logger.info("Skipping freeze/thaw operations - offseason mode enabled") + self.logger.info( + "Skipping freeze/thaw operations - offseason mode enabled" + ) return # Get current league state @@ -297,14 +327,14 @@ class TransactionFreezeTask: self.logger.warning("Could not get current league state") return - now = datetime.now() + now = now_chicago() self.logger.info( f"Weekly loop check", datetime=now.isoformat(), weekday=now.weekday(), hour=now.hour, current_week=current.week, - freeze_status=current.freeze + freeze_status=current.freeze, ) # BEGIN FREEZE: Monday at 00:00, not already frozen @@ -312,13 +342,23 @@ class TransactionFreezeTask: # Only run if we haven't already frozen this week # Track the week we're freezing FROM (before increment) if self.last_freeze_week != current.week: - freeze_from_week = current.week # Save BEFORE _begin_freeze modifies it - self.logger.info("Triggering freeze begin", current_week=current.week) + freeze_from_week = ( + current.week + ) # Save BEFORE _begin_freeze modifies it + self.logger.info( + "Triggering freeze begin", current_week=current.week + ) await self._begin_freeze(current) - self.last_freeze_week = freeze_from_week # Track the week we froze FROM - self.error_notification_sent = False # Reset error flag for new cycle + self.last_freeze_week = ( + freeze_from_week # Track the week we froze FROM + ) + self.error_notification_sent = ( + False # Reset error flag for new cycle + ) else: - self.logger.debug("Freeze already executed for week", week=current.week) + self.logger.debug( + "Freeze already executed for week", week=current.week + ) # END FREEZE: Saturday at 00:00, currently frozen elif now.weekday() == 5 and now.hour == 0 and current.freeze: @@ -327,9 +367,13 @@ class TransactionFreezeTask: self.logger.info("Triggering freeze end", current_week=current.week) await self._end_freeze(current) self.last_thaw_week = current.week - self.error_notification_sent = False # Reset error flag for new cycle + self.error_notification_sent = ( + False # Reset error flag for new cycle + ) else: - self.logger.debug("Thaw already executed for week", week=current.week) + self.logger.debug( + "Thaw already executed for week", week=current.week + ) else: self.logger.debug("No freeze/thaw action needed at this time") @@ -375,8 +419,7 @@ class TransactionFreezeTask: # Increment week and set freeze via service new_week = current.week + 1 updated_current = await league_service.update_current_state( - week=new_week, - freeze=True + week=new_week, freeze=True ) if not updated_current: @@ -449,15 +492,18 @@ class TransactionFreezeTask: try: # Get non-frozen, non-cancelled transactions for current week via service transactions = await transaction_service.get_regular_transactions_by_week( - season=current.season, - week=current.week + season=current.season, week=current.week ) if not transactions: - self.logger.info(f"No regular transactions to process for week {current.week}") + self.logger.info( + f"No regular transactions to process for week {current.week}" + ) return - self.logger.info(f"Processing {len(transactions)} regular transactions for week {current.week}") + self.logger.info( + f"Processing {len(transactions)} regular transactions for week {current.week}" + ) # Execute player roster updates for all transactions success_count = 0 @@ -470,7 +516,7 @@ class TransactionFreezeTask: player_id=transaction.player.id, new_team_id=transaction.newteam.id, player_name=transaction.player.name, - dem_week=current.week + 2 + dem_week=current.week + 2, ) success_count += 1 @@ -482,7 +528,7 @@ class TransactionFreezeTask: f"Failed to execute transaction for {transaction.player.name}", player_id=transaction.player.id, new_team_id=transaction.newteam.id, - error=str(e) + error=str(e), ) failure_count += 1 @@ -490,7 +536,7 @@ class TransactionFreezeTask: f"Transaction execution complete for week {current.week}", success=success_count, failures=failure_count, - total=len(transactions) + total=len(transactions), ) except Exception as e: @@ -514,11 +560,13 @@ class TransactionFreezeTask: transactions = await transaction_service.get_frozen_transactions_by_week( season=current.season, week_start=current.week, - week_end=current.week + 1 + week_end=current.week + 1, ) if not transactions: - self.logger.warning(f"No frozen transactions to process for week {current.week}") + self.logger.warning( + f"No frozen transactions to process for week {current.week}" + ) # Still post an empty report for visibility empty_report = ThawReport( week=current.week, @@ -530,23 +578,26 @@ class TransactionFreezeTask: conflict_count=0, conflicts=[], thawed_moves=[], - cancelled_moves=[] + cancelled_moves=[], ) await self._post_thaw_report(empty_report) return - self.logger.info(f"Processing {len(transactions)} frozen transactions for week {current.week}") + self.logger.info( + f"Processing {len(transactions)} frozen transactions for week {current.week}" + ) # Resolve contested transactions - winning_move_ids, losing_move_ids, conflict_resolutions = await resolve_contested_transactions( - transactions, - current.season + winning_move_ids, losing_move_ids, conflict_resolutions = ( + await resolve_contested_transactions(transactions, current.season) ) # Build mapping from conflict player to winner for cancelled move tracking conflict_player_to_winner: Dict[str, str] = {} for conflict in conflict_resolutions: - conflict_player_to_winner[conflict.player_name.lower()] = conflict.winner.team_abbrev + conflict_player_to_winner[conflict.player_name.lower()] = ( + conflict.winner.team_abbrev + ) # Track cancelled moves for report cancelled_moves_report: List[CancelledMove] = [] @@ -555,24 +606,34 @@ class TransactionFreezeTask: for losing_move_id in losing_move_ids: try: # Get all moves with this moveid (could be multiple players in one transaction) - losing_moves = [t for t in transactions if t.moveid == losing_move_id] + losing_moves = [ + t for t in transactions if t.moveid == losing_move_id + ] if losing_moves: # Cancel the entire transaction (all moves with same moveid) for move in losing_moves: - success = await transaction_service.cancel_transaction(move.moveid) + success = await transaction_service.cancel_transaction( + move.moveid + ) if not success: - self.logger.warning(f"Failed to cancel transaction {move.moveid}") + self.logger.warning( + f"Failed to cancel transaction {move.moveid}" + ) # Notify the GM(s) about cancellation first_move = losing_moves[0] # Determine which team to notify (the team that was trying to acquire) - team_for_notification = (first_move.newteam - if first_move.newteam.abbrev.upper() != 'FA' - else first_move.oldteam) + team_for_notification = ( + first_move.newteam + if first_move.newteam.abbrev.upper() != "FA" + else first_move.oldteam + ) - await self._notify_gm_of_cancellation(first_move, team_for_notification) + await self._notify_gm_of_cancellation( + first_move, team_for_notification + ) # Find which player caused the conflict contested_player = "" @@ -586,16 +647,23 @@ class TransactionFreezeTask: # Build report entry players = [ - (move.player.name, move.player.wara, move.oldteam.abbrev, move.newteam.abbrev) + ( + move.player.name, + move.player.wara, + move.oldteam.abbrev, + move.newteam.abbrev, + ) for move in losing_moves ] - cancelled_moves_report.append(CancelledMove( - move_id=losing_move_id, - team_abbrev=team_for_notification.abbrev, - players=players, - lost_to=lost_to, - contested_player=contested_player - )) + cancelled_moves_report.append( + CancelledMove( + move_id=losing_move_id, + team_abbrev=team_for_notification.abbrev, + players=players, + lost_to=lost_to, + contested_player=contested_player, + ) + ) contested_players = [move.player.name for move in losing_moves] self.logger.info( @@ -604,7 +672,9 @@ class TransactionFreezeTask: ) except Exception as e: - self.logger.error(f"Error cancelling transaction {losing_move_id}: {e}") + self.logger.error( + f"Error cancelling transaction {losing_move_id}: {e}" + ) # Track thawed moves for report thawed_moves_report: List[ThawedMove] = [] @@ -613,13 +683,19 @@ class TransactionFreezeTask: for winning_move_id in winning_move_ids: try: # Get all moves with this moveid - winning_moves = [t for t in transactions if t.moveid == winning_move_id] + winning_moves = [ + t for t in transactions if t.moveid == winning_move_id + ] for move in winning_moves: # Unfreeze the transaction via service - success = await transaction_service.unfreeze_transaction(move.moveid) + success = await transaction_service.unfreeze_transaction( + move.moveid + ) if not success: - self.logger.warning(f"Failed to unfreeze transaction {move.moveid}") + self.logger.warning( + f"Failed to unfreeze transaction {move.moveid}" + ) # Post to transaction log await self._post_transaction_to_log(winning_move_id, transactions) @@ -629,32 +705,43 @@ class TransactionFreezeTask: first_move = winning_moves[0] # Extract timestamp from moveid (format: Season-XXX-Week-XX-DD-HH:MM:SS) try: - parts = winning_move_id.split('-') + parts = winning_move_id.split("-") submitted_at = parts[-1] if len(parts) >= 6 else "Unknown" except Exception: submitted_at = "Unknown" # Determine team abbrev - if first_move.newteam.abbrev.upper() != 'FA': + if first_move.newteam.abbrev.upper() != "FA": team_abbrev = first_move.newteam.abbrev else: team_abbrev = first_move.oldteam.abbrev players = [ - (move.player.name, move.player.wara, move.oldteam.abbrev, move.newteam.abbrev) + ( + move.player.name, + move.player.wara, + move.oldteam.abbrev, + move.newteam.abbrev, + ) for move in winning_moves ] - thawed_moves_report.append(ThawedMove( - move_id=winning_move_id, - team_abbrev=team_abbrev, - players=players, - submitted_at=submitted_at - )) + thawed_moves_report.append( + ThawedMove( + move_id=winning_move_id, + team_abbrev=team_abbrev, + players=players, + submitted_at=submitted_at, + ) + ) - self.logger.info(f"Processed successful transaction {winning_move_id}") + self.logger.info( + f"Processed successful transaction {winning_move_id}" + ) except Exception as e: - self.logger.error(f"Error processing winning transaction {winning_move_id}: {e}") + self.logger.error( + f"Error processing winning transaction {winning_move_id}: {e}" + ) # Generate and post thaw report thaw_report = ThawReport( @@ -667,7 +754,7 @@ class TransactionFreezeTask: conflict_count=len(conflict_resolutions), conflicts=conflict_resolutions, thawed_moves=thawed_moves_report, - cancelled_moves=cancelled_moves_report + cancelled_moves=cancelled_moves_report, ) await self._post_thaw_report(thaw_report) @@ -685,7 +772,7 @@ class TransactionFreezeTask: player_id: int, new_team_id: int, player_name: str, - dem_week: Optional[int] = None + dem_week: Optional[int] = None, ) -> bool: """ Execute a player roster update via API PATCH. @@ -708,13 +795,11 @@ class TransactionFreezeTask: player_id=player_id, player_name=player_name, new_team_id=new_team_id, - dem_week=dem_week + dem_week=dem_week, ) updated_player = await player_service.update_player_team( - player_id, - new_team_id, - dem_week=dem_week + player_id, new_team_id, dem_week=dem_week ) # Verify response (200 or 204 indicates success) @@ -724,7 +809,7 @@ class TransactionFreezeTask: player_id=player_id, player_name=player_name, new_team_id=new_team_id, - dem_week=dem_week + dem_week=dem_week, ) return True else: @@ -733,7 +818,7 @@ class TransactionFreezeTask: player_id=player_id, player_name=player_name, new_team_id=new_team_id, - dem_week=dem_week + dem_week=dem_week, ) return False @@ -745,7 +830,7 @@ class TransactionFreezeTask: new_team_id=new_team_id, dem_week=dem_week, error=str(e), - exc_info=True + exc_info=True, ) raise @@ -764,34 +849,36 @@ class TransactionFreezeTask: self.logger.warning("Could not find guild for freeze announcement") return - channel = discord.utils.get(guild.text_channels, name='transaction-log') + channel = discord.utils.get(guild.text_channels, name="transaction-log") if not channel: self.logger.warning("Could not find transaction-log channel") return # Create announcement message (formatted like legacy bot) - week_num = f'Week {week}' - stars = '*' * 32 + week_num = f"Week {week}" + stars = "*" * 32 if is_beginning: message = ( - f'```\n' - f'{stars}\n' - f'{week_num:>9} Freeze Period Begins\n' - f'{stars}\n' - f'```' + f"```\n" + f"{stars}\n" + f"{week_num:>9} Freeze Period Begins\n" + f"{stars}\n" + f"```" ) else: message = ( - f'```\n' + f"```\n" f'{"*" * 30}\n' - f'{week_num:>9} Freeze Period Ends\n' + f"{week_num:>9} Freeze Period Ends\n" f'{"*" * 30}\n' - f'```' + f"```" ) await channel.send(message) - self.logger.info(f"Freeze announcement sent for week {week} ({'begin' if is_beginning else 'end'})") + self.logger.info( + f"Freeze announcement sent for week {week} ({'begin' if is_beginning else 'end'})" + ) except Exception as e: self.logger.error(f"Error sending freeze announcement: {e}") @@ -809,7 +896,7 @@ class TransactionFreezeTask: if not guild: return - info_channel = discord.utils.get(guild.text_channels, name='weekly-info') + info_channel = discord.utils.get(guild.text_channels, name="weekly-info") if not info_channel: self.logger.warning("Could not find weekly-info channel") return @@ -835,17 +922,17 @@ class TransactionFreezeTask: is_div_week = current.week in [1, 3, 6, 14, 16, 18] weekly_str = ( - f'**Season**: {season_str}\n' - f'**Time of Day**: {night_str} / {night_str if is_div_week else day_str} / ' - f'{night_str} / {day_str}' + f"**Season**: {season_str}\n" + f"**Time of Day**: {night_str} / {night_str if is_div_week else day_str} / " + f"{night_str} / {day_str}" ) # Send info messages await info_channel.send( content=( - f'Each team has manage permissions in their home ballpark. ' - f'They may pin messages and rename the channel.\n\n' - f'**Make sure your ballpark starts with your team abbreviation.**' + f"Each team has manage permissions in their home ballpark. " + f"They may pin messages and rename the channel.\n\n" + f"**Make sure your ballpark starts with your team abbreviation.**" ) ) await info_channel.send(weekly_str) @@ -856,9 +943,7 @@ class TransactionFreezeTask: self.logger.error(f"Error posting weekly info: {e}") async def _post_transaction_to_log( - self, - move_id: str, - all_transactions: List[Transaction] + self, move_id: str, all_transactions: List[Transaction] ): """ Post a transaction to the transaction log channel. @@ -873,7 +958,7 @@ class TransactionFreezeTask: if not guild: return - channel = discord.utils.get(guild.text_channels, name='transaction-log') + channel = discord.utils.get(guild.text_channels, name="transaction-log") if not channel: return @@ -884,9 +969,15 @@ class TransactionFreezeTask: # Determine the team for the embed (team making the moves) first_move = moves[0] - if first_move.newteam.abbrev.upper() != 'FA' and 'IL' not in first_move.newteam.abbrev: + if ( + first_move.newteam.abbrev.upper() != "FA" + and "IL" not in first_move.newteam.abbrev + ): this_team = first_move.newteam - elif first_move.oldteam.abbrev.upper() != 'FA' and 'IL' not in first_move.oldteam.abbrev: + elif ( + first_move.oldteam.abbrev.upper() != "FA" + and "IL" not in first_move.oldteam.abbrev + ): this_team = first_move.oldteam else: # Default to newteam if both are FA/IL @@ -898,25 +989,29 @@ class TransactionFreezeTask: for move in moves: move_string += ( - f'**{move.player.name}** ({move.player.wara:.2f}) ' - f'from {move.oldteam.abbrev} to {move.newteam.abbrev}\n' + f"**{move.player.name}** ({move.player.wara:.2f}) " + f"from {move.oldteam.abbrev} to {move.newteam.abbrev}\n" ) # Create embed embed = EmbedTemplate.create_base_embed( - title=f'Week {week_num} Transaction', - description=this_team.sname if hasattr(this_team, 'sname') else this_team.lname, - color=EmbedColors.INFO + title=f"Week {week_num} Transaction", + description=( + this_team.sname if hasattr(this_team, "sname") else this_team.lname + ), + color=EmbedColors.INFO, ) # Set team color if available - if hasattr(this_team, 'color') and this_team.color: + if hasattr(this_team, "color") and this_team.color: try: - embed.color = discord.Color(int(this_team.color.replace('#', ''), 16)) + embed.color = discord.Color( + int(this_team.color.replace("#", ""), 16) + ) except: pass # Use default color on error - embed.add_field(name='Player Moves', value=move_string, inline=False) + embed.add_field(name="Player Moves", value=move_string, inline=False) await channel.send(embed=embed) self.logger.info(f"Transaction posted to log: {move_id}") @@ -924,11 +1019,7 @@ class TransactionFreezeTask: except Exception as e: self.logger.error(f"Error posting transaction to log: {e}") - async def _notify_gm_of_cancellation( - self, - transaction: Transaction, - team - ): + async def _notify_gm_of_cancellation(self, transaction: Transaction, team): """ Send DM to GM(s) about cancelled transaction. @@ -943,27 +1034,31 @@ class TransactionFreezeTask: return cancel_text = ( - f'Your transaction for **{transaction.player.name}** has been cancelled ' - f'because another team successfully claimed them during the freeze period.' + f"Your transaction for **{transaction.player.name}** has been cancelled " + f"because another team successfully claimed them during the freeze period." ) # Notify GM1 - if hasattr(team, 'gmid') and team.gmid: + if hasattr(team, "gmid") and team.gmid: try: gm_one = guild.get_member(team.gmid) if gm_one: await gm_one.send(cancel_text) - self.logger.info(f"Cancellation notification sent to GM1 of {team.abbrev}") + self.logger.info( + f"Cancellation notification sent to GM1 of {team.abbrev}" + ) except Exception as e: self.logger.error(f"Could not notify GM1 of {team.abbrev}: {e}") # Notify GM2 if exists - if hasattr(team, 'gmid2') and team.gmid2: + if hasattr(team, "gmid2") and team.gmid2: try: gm_two = guild.get_member(team.gmid2) if gm_two: await gm_two.send(cancel_text) - self.logger.info(f"Cancellation notification sent to GM2 of {team.abbrev}") + self.logger.info( + f"Cancellation notification sent to GM2 of {team.abbrev}" + ) except Exception as e: self.logger.error(f"Could not notify GM2 of {team.abbrev}: {e}") @@ -986,30 +1081,43 @@ class TransactionFreezeTask: admin_channel = self.bot.get_channel(config.thaw_report_channel_id) if not admin_channel: - self.logger.warning("Could not find thaw report channel", channel_id=config.thaw_report_channel_id) + self.logger.warning( + "Could not find thaw report channel", + channel_id=config.thaw_report_channel_id, + ) return # Build the report content report_lines = [] # Header with summary - timestamp_str = report.timestamp.strftime('%B %d, %Y %H:%M UTC') + timestamp_str = report.timestamp.strftime("%B %d, %Y %H:%M UTC") report_lines.append(f"# Transaction Thaw Report") - report_lines.append(f"**Week {report.week}** | **Season {report.season}** | {timestamp_str}") - report_lines.append(f"**Total:** {report.total_moves} moves | **Thawed:** {report.thawed_count} | **Cancelled:** {report.cancelled_count} | **Conflicts:** {report.conflict_count}") + report_lines.append( + f"**Week {report.week}** | **Season {report.season}** | {timestamp_str}" + ) + report_lines.append( + f"**Total:** {report.total_moves} moves | **Thawed:** {report.thawed_count} | **Cancelled:** {report.cancelled_count} | **Conflicts:** {report.conflict_count}" + ) report_lines.append("") # Conflict Resolution section (if any) if report.conflicts: report_lines.append("## Conflict Resolution") for conflict in report.conflicts: - report_lines.append(f"**{conflict.player_name}** (sWAR: {conflict.player_swar:.1f})") - contenders_str = " vs ".join([ - f"{c.team_abbrev} ({c.wins}-{c.losses})" - for c in conflict.contenders - ]) + report_lines.append( + f"**{conflict.player_name}** (sWAR: {conflict.player_swar:.1f})" + ) + contenders_str = " vs ".join( + [ + f"{c.team_abbrev} ({c.wins}-{c.losses})" + for c in conflict.contenders + ] + ) report_lines.append(f"- Contested by: {contenders_str}") - report_lines.append(f"- **Awarded to: {conflict.winner.team_abbrev}** (worst record wins)") + report_lines.append( + f"- **Awarded to: {conflict.winner.team_abbrev}** (worst record wins)" + ) report_lines.append("") # Thawed Moves section @@ -1018,7 +1126,9 @@ class TransactionFreezeTask: for move in report.thawed_moves: report_lines.append(f"**{move.move_id}** | {move.team_abbrev}") for player_name, swar, old_team, new_team in move.players: - report_lines.append(f" - {player_name} ({swar:.1f}): {old_team} → {new_team}") + report_lines.append( + f" - {player_name} ({swar:.1f}): {old_team} → {new_team}" + ) else: report_lines.append("*No moves thawed*") report_lines.append("") @@ -1027,10 +1137,18 @@ class TransactionFreezeTask: report_lines.append("## Cancelled Moves") if report.cancelled_moves: for move in report.cancelled_moves: - lost_info = f" (lost {move.contested_player} to {move.lost_to})" if move.lost_to else "" - report_lines.append(f"**{move.move_id}** | {move.team_abbrev}{lost_info}") + lost_info = ( + f" (lost {move.contested_player} to {move.lost_to})" + if move.lost_to + else "" + ) + report_lines.append( + f"**{move.move_id}** | {move.team_abbrev}{lost_info}" + ) for player_name, swar, old_team, new_team in move.players: - report_lines.append(f" - ❌ {player_name} ({swar:.1f}): {old_team} → {new_team}") + report_lines.append( + f" - ❌ {player_name} ({swar:.1f}): {old_team} → {new_team}" + ) else: report_lines.append("*No moves cancelled*") diff --git a/tests/test_tasks_transaction_freeze.py b/tests/test_tasks_transaction_freeze.py index f3fc032..9b4743a 100644 --- a/tests/test_tasks_transaction_freeze.py +++ b/tests/test_tasks_transaction_freeze.py @@ -8,6 +8,7 @@ Validates the automated weekly freeze system for transactions, including: - GM notifications - Transaction processing """ + import pytest from datetime import datetime, timezone, UTC from unittest.mock import AsyncMock, MagicMock, Mock, patch, call @@ -16,18 +17,14 @@ 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 @@ -54,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 ) @@ -66,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 @@ -144,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 @@ -157,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] @@ -172,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( @@ -184,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, ) @@ -235,7 +202,7 @@ def sample_standings_wv() -> TeamStandings: div3_wins=8, div3_losses=12, div4_wins=7, - div4_losses=13 + div4_losses=13, ) @@ -274,7 +241,7 @@ def sample_standings_ny() -> TeamStandings: div3_wins=12, div3_losses=8, div4_wins=13, - div4_losses=7 + div4_losses=7, ) @@ -286,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 @@ -298,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] @@ -317,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 @@ -340,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 @@ -351,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% @@ -368,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) @@ -378,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( @@ -401,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 @@ -409,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 @@ -423,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 @@ -455,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) @@ -467,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 @@ -486,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) @@ -504,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 @@ -582,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 @@ -592,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() @@ -606,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) @@ -627,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) @@ -654,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) @@ -675,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) @@ -698,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) @@ -718,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) @@ -736,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) @@ -754,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) @@ -767,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") ) @@ -783,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) @@ -793,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], [], []) @@ -827,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], []) @@ -856,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() @@ -884,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], [], []) @@ -920,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) @@ -928,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) @@ -946,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) @@ -978,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) @@ -1002,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 @@ -1013,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() @@ -1022,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: @@ -1035,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() @@ -1064,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") @@ -1094,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") ) @@ -1115,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") @@ -1137,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() @@ -1172,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""