Compare commits

..

12 Commits

Author SHA1 Message Date
Cal Corum
174ce4474d fix: use per-user dict for _checked_teams to prevent race condition (#116)
Replace single `_checked_team: Optional[Team]` with `_checked_teams: dict[int, Team]`
keyed by `interaction.user.id`. The previous single field was shared across all
concurrent interaction tasks, allowing User B's interaction_check to overwrite
`_checked_team` before User A's button handler read it — crediting the wrong team.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 19:46:42 +00:00
Cal Corum
2091302b8a perf: eliminate redundant API calls in trade views (#94)
Closes #94

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 19:46:42 +00:00
cal
27a272b813 Merge pull request 'perf: eliminate redundant GET after create/update and parallelize stats (#95)' (#112) from ai/major-domo-v2#95 into main
Reviewed-on: #112
2026-03-31 19:46:19 +00:00
Cal Corum
95010bfd5d perf: eliminate redundant GET after create/update and parallelize stats (#95)
- custom_commands_service: return POST response directly from create_command()
  instead of a follow-up GET by_name
- custom_commands_service: return PUT response directly from update_command()
  instead of a follow-up GET by_name
- custom_commands_service: avoid GET after PUT in get_or_create_creator() by
  constructing updated creator from model_copy()
- custom_commands_service: return POST response directly from get_or_create_creator()
  creator creation instead of a follow-up GET
- custom_commands_service: parallelize all 9 sequential API calls in
  get_statistics() with asyncio.gather()
- help_commands_service: return POST response directly from create_help()
  instead of a follow-up GET by_name
- help_commands_service: return PUT response directly from update_help()
  instead of a follow-up GET by_name
- tests: update test_update_help_success to mock PUT returning dict data

Closes #95

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 19:45:44 +00:00
cal
deb40476a4 Merge pull request 'perf: parallelize N+1 player/creator lookups with asyncio.gather (#89)' (#118) from ai/major-domo-v2-89 into main
Reviewed-on: #118
2026-03-31 19:45:01 +00:00
Cal Corum
65d3099a7c perf: parallelize N+1 player/creator lookups with asyncio.gather (#89)
Closes #89

Replace sequential per-item await loops with asyncio.gather() to fetch
all results in parallel:

- decision_service.find_winning_losing_pitchers: gather wp, lp, sv,
  hold_ids, and bsv_ids (5-10 calls) in a single parallel batch
- custom_commands_service: parallelize get_creator_by_id() in
  get_popular_commands, get_commands_needing_warning, and
  get_commands_eligible_for_deletion using return_exceptions=True to
  preserve the existing BotException-skip / re-raise-other behavior

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 19:42:53 +00:00
cal
8e02889fd4 Merge pull request 'feat: enforce trade deadline in /trade commands' (#121) from feature/trade-deadline-enforcement into main
All checks were successful
Build Docker Image / build (push) Successful in 1m30s
2026-03-30 21:46:18 +00:00
Cal Corum
b872a05397 feat: enforce trade deadline in /trade commands
Add is_past_trade_deadline property to Current model and guard /trade initiate,
submit, and finalize flows. All checks fail-closed (block if API unreachable).
981 tests pass.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 16:39:04 -05:00
cal
6889499fff Merge pull request 'fix: update chart_service path from data/ to storage/' (#119) from fix/chart-service-storage-path into main
All checks were successful
Build Docker Image / build (push) Successful in 1m46s
Reviewed-on: #119
2026-03-21 02:01:55 +00:00
Cal Corum
3c453c89ce fix: update chart_service path from data/ to storage/
PR #86 moved state files to storage/ but missed chart_service.py,
which still pointed to data/charts.json. The file exists at
/app/storage/charts.json in the container but the code looked
in /app/data/charts.json, causing empty autocomplete results.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 20:30:41 -05:00
cal
be4213aab6 Merge pull request 'hotfix: make ScorecardTracker methods async to match await callers' (#117) from hotfix/scorecard-tracker-async into main
All checks were successful
Build Docker Image / build (push) Successful in 1m21s
Reviewed-on: #117
2026-03-20 18:41:44 +00:00
Cal Corum
4e75656225 hotfix: make ScorecardTracker methods async to match await callers
PR #106 added await to scorecard_tracker calls but the tracker
methods were still sync, causing TypeError in production:
- /scorebug: "object NoneType can't be used in 'await' expression"
- live_scorebug_tracker: "object list can't be used in 'await' expression"

Also fixes 5 missing awaits in cleanup_service.py and updates tests.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 13:37:47 -05:00
10 changed files with 916 additions and 620 deletions

View File

@ -26,6 +26,7 @@ from services.trade_builder import (
clear_trade_builder,
clear_trade_builder_by_team,
)
from services.league_service import league_service
from services.player_service import player_service
from services.team_service import team_service
from models.team import RosterType
@ -130,6 +131,22 @@ class TradeCommands(commands.Cog):
)
return
# Check trade deadline
current = await league_service.get_current_state()
if not current:
await interaction.followup.send(
"❌ Could not retrieve league state. Please try again later.",
ephemeral=True,
)
return
if current.is_past_trade_deadline:
await interaction.followup.send(
f"❌ **The trade deadline has passed.** The deadline was Week {current.trade_deadline} "
f"and we are currently in Week {current.week}. No new trades can be initiated.",
ephemeral=True,
)
return
# Clear any existing trade and create new one
clear_trade_builder(interaction.user.id)
trade_builder = get_trade_builder(interaction.user.id, user_team)

View File

@ -3,6 +3,7 @@ Current league state model
Represents the current state of the league including week, season, and settings.
"""
from pydantic import Field, field_validator
from models.base import SBABaseModel
@ -10,38 +11,45 @@ from models.base import SBABaseModel
class Current(SBABaseModel):
"""Model representing current league state and settings."""
week: int = Field(69, description="Current week number")
season: int = Field(69, description="Current season number")
freeze: bool = Field(True, description="Whether league is frozen")
bet_week: str = Field('sheets', description="Betting week identifier")
bet_week: str = Field("sheets", description="Betting week identifier")
trade_deadline: int = Field(1, description="Trade deadline week")
pick_trade_start: int = Field(69, description="Draft pick trading start week")
pick_trade_end: int = Field(420, description="Draft pick trading end week")
playoffs_begin: int = Field(420, description="Week when playoffs begin")
@field_validator("bet_week", mode="before")
@classmethod
def cast_bet_week_to_string(cls, v):
"""Ensure bet_week is always a string."""
return str(v) if v is not None else 'sheets'
return str(v) if v is not None else "sheets"
@property
def is_offseason(self) -> bool:
"""Check if league is currently in offseason."""
return self.week > 18
@property
def is_playoffs(self) -> bool:
"""Check if league is currently in playoffs."""
return self.week >= self.playoffs_begin
@property
def can_trade_picks(self) -> bool:
"""Check if draft pick trading is currently allowed."""
return self.pick_trade_start <= self.week <= self.pick_trade_end
@property
def ever_trade_picks(self) -> bool:
"""Check if draft pick trading is allowed this season at all"""
return self.pick_trade_start <= self.playoffs_begin + 4
return self.pick_trade_start <= self.playoffs_begin + 4
@property
def is_past_trade_deadline(self) -> bool:
"""Check if the trade deadline has passed."""
if self.is_offseason:
return False
return self.week > self.trade_deadline

View File

@ -4,6 +4,7 @@ Chart Service for managing gameplay charts and infographics.
This service handles loading, saving, and managing chart definitions
from the JSON configuration file.
"""
import json
import logging
from pathlib import Path
@ -18,6 +19,7 @@ logger = logging.getLogger(__name__)
@dataclass
class Chart:
"""Represents a gameplay chart or infographic."""
key: str
name: str
category: str
@ -27,17 +29,17 @@ class Chart:
def to_dict(self) -> Dict[str, Any]:
"""Convert chart to dictionary (excluding key)."""
return {
'name': self.name,
'category': self.category,
'description': self.description,
'urls': self.urls
"name": self.name,
"category": self.category,
"description": self.description,
"urls": self.urls,
}
class ChartService:
"""Service for managing gameplay charts and infographics."""
CHARTS_FILE = Path(__file__).parent.parent / 'data' / 'charts.json'
CHARTS_FILE = Path(__file__).parent.parent / "storage" / "charts.json"
def __init__(self):
"""Initialize the chart service."""
@ -54,21 +56,21 @@ class ChartService:
self._categories = {}
return
with open(self.CHARTS_FILE, 'r') as f:
with open(self.CHARTS_FILE, "r") as f:
data = json.load(f)
# Load categories
self._categories = data.get('categories', {})
self._categories = data.get("categories", {})
# Load charts
charts_data = data.get('charts', {})
charts_data = data.get("charts", {})
for key, chart_data in charts_data.items():
self._charts[key] = Chart(
key=key,
name=chart_data['name'],
category=chart_data['category'],
description=chart_data.get('description', ''),
urls=chart_data.get('urls', [])
name=chart_data["name"],
category=chart_data["category"],
description=chart_data.get("description", ""),
urls=chart_data.get("urls", []),
)
logger.info(f"Loaded {len(self._charts)} charts from {self.CHARTS_FILE}")
@ -81,20 +83,17 @@ class ChartService:
def _save_charts(self) -> None:
"""Save charts to JSON file."""
try:
# Ensure data directory exists
# Ensure storage directory exists
self.CHARTS_FILE.parent.mkdir(parents=True, exist_ok=True)
# Build data structure
data = {
'charts': {
key: chart.to_dict()
for key, chart in self._charts.items()
},
'categories': self._categories
"charts": {key: chart.to_dict() for key, chart in self._charts.items()},
"categories": self._categories,
}
# Write to file
with open(self.CHARTS_FILE, 'w') as f:
with open(self.CHARTS_FILE, "w") as f:
json.dump(data, f, indent=2)
logger.info(f"Saved {len(self._charts)} charts to {self.CHARTS_FILE}")
@ -134,10 +133,7 @@ class ChartService:
Returns:
List of charts in the specified category
"""
return [
chart for chart in self._charts.values()
if chart.category == category
]
return [chart for chart in self._charts.values() if chart.category == category]
def get_chart_keys(self) -> List[str]:
"""
@ -157,8 +153,9 @@ class ChartService:
"""
return self._categories.copy()
def add_chart(self, key: str, name: str, category: str,
urls: List[str], description: str = "") -> None:
def add_chart(
self, key: str, name: str, category: str, urls: List[str], description: str = ""
) -> None:
"""
Add a new chart.
@ -176,18 +173,19 @@ class ChartService:
raise BotException(f"Chart '{key}' already exists")
self._charts[key] = Chart(
key=key,
name=name,
category=category,
description=description,
urls=urls
key=key, name=name, category=category, description=description, urls=urls
)
self._save_charts()
logger.info(f"Added chart: {key}")
def update_chart(self, key: str, name: Optional[str] = None,
category: Optional[str] = None, urls: Optional[List[str]] = None,
description: Optional[str] = None) -> None:
def update_chart(
self,
key: str,
name: Optional[str] = None,
category: Optional[str] = None,
urls: Optional[List[str]] = None,
description: Optional[str] = None,
) -> None:
"""
Update an existing chart.

View File

@ -4,6 +4,7 @@ Custom Commands Service for Discord Bot v2.0
Modern async service layer for managing custom commands with full type safety.
"""
import asyncio
import math
from datetime import UTC, datetime, timedelta
from typing import Optional, List, Any, Tuple
@ -119,8 +120,8 @@ class CustomCommandsService(BaseService[CustomCommand]):
content_length=len(content),
)
# Return full command with creator info
return await self.get_command_by_name(name)
# Return command with creator info (use POST response directly)
return result.model_copy(update={"creator": creator})
async def get_command_by_name(self, name: str) -> CustomCommand:
"""
@ -217,7 +218,8 @@ class CustomCommandsService(BaseService[CustomCommand]):
new_content_length=len(new_content),
)
return await self.get_command_by_name(name)
# Return updated command with creator info (use PUT response directly)
return result.model_copy(update={"creator": command.creator})
async def delete_command(
self, name: str, deleter_discord_id: int, force: bool = False
@ -466,21 +468,28 @@ class CustomCommandsService(BaseService[CustomCommand]):
commands_data = await self.get_items_with_params(params)
creators = await asyncio.gather(
*[
self.get_creator_by_id(cmd_data.creator_id)
for cmd_data in commands_data
],
return_exceptions=True,
)
commands = []
for cmd_data in commands_data:
try:
creator = await self.get_creator_by_id(cmd_data.creator_id)
commands.append(CustomCommand(**cmd_data.model_dump(), creator=creator))
except BotException as e:
# Handle missing creator gracefully
for cmd_data, creator in zip(commands_data, creators):
if isinstance(creator, BotException):
self.logger.warning(
"Skipping popular command with missing creator",
command_id=cmd_data.id,
command_name=cmd_data.name,
creator_id=cmd_data.creator_id,
error=str(e),
error=str(creator),
)
continue
if isinstance(creator, BaseException):
raise creator
commands.append(CustomCommand(**cmd_data.model_dump(), creator=creator))
return commands
@ -536,7 +545,9 @@ class CustomCommandsService(BaseService[CustomCommand]):
# Update username if it changed
if creator.username != username or creator.display_name != display_name:
await self._update_creator_info(creator.id, username, display_name)
creator = await self.get_creator_by_discord_id(discord_id)
creator = creator.model_copy(
update={"username": username, "display_name": display_name}
)
return creator
except BotException:
# Creator doesn't exist, create new one
@ -557,7 +568,8 @@ class CustomCommandsService(BaseService[CustomCommand]):
if not result:
raise BotException("Failed to create command creator")
return await self.get_creator_by_discord_id(discord_id)
# Return created creator directly from POST response
return CustomCommandCreator(**result)
async def get_creator_by_discord_id(self, discord_id: int) -> CustomCommandCreator:
"""Get creator by Discord ID.
@ -610,31 +622,34 @@ class CustomCommandsService(BaseService[CustomCommand]):
async def get_statistics(self) -> CustomCommandStats:
"""Get comprehensive statistics about custom commands."""
# Get basic counts
total_commands = await self._get_search_count([])
active_commands = await self._get_search_count([("is_active", True)])
total_creators = await self._get_creator_count()
# Get total uses
all_commands = await self.get_items_with_params([("is_active", True)])
total_uses = sum(cmd.use_count for cmd in all_commands)
# Get most popular command
popular_commands = await self.get_popular_commands(limit=1)
most_popular = popular_commands[0] if popular_commands else None
# Get most active creator
most_active_creator = await self._get_most_active_creator()
# Get recent commands count
week_ago = datetime.now(UTC) - timedelta(days=7)
recent_count = await self._get_search_count(
[("created_at__gte", week_ago.isoformat()), ("is_active", True)]
(
total_commands,
active_commands,
total_creators,
all_commands,
popular_commands,
most_active_creator,
recent_count,
warning_count,
deletion_count,
) = await asyncio.gather(
self._get_search_count([]),
self._get_search_count([("is_active", True)]),
self._get_creator_count(),
self.get_items_with_params([("is_active", True)]),
self.get_popular_commands(limit=1),
self._get_most_active_creator(),
self._get_search_count(
[("created_at__gte", week_ago.isoformat()), ("is_active", True)]
),
self._get_commands_needing_warning_count(),
self._get_commands_eligible_for_deletion_count(),
)
# Get cleanup statistics
warning_count = await self._get_commands_needing_warning_count()
deletion_count = await self._get_commands_eligible_for_deletion_count()
total_uses = sum(cmd.use_count for cmd in all_commands)
most_popular = popular_commands[0] if popular_commands else None
return CustomCommandStats(
total_commands=total_commands,
@ -662,21 +677,28 @@ class CustomCommandsService(BaseService[CustomCommand]):
commands_data = await self.get_items_with_params(params)
creators = await asyncio.gather(
*[
self.get_creator_by_id(cmd_data.creator_id)
for cmd_data in commands_data
],
return_exceptions=True,
)
commands = []
for cmd_data in commands_data:
try:
creator = await self.get_creator_by_id(cmd_data.creator_id)
commands.append(CustomCommand(**cmd_data.model_dump(), creator=creator))
except BotException as e:
# Handle missing creator gracefully
for cmd_data, creator in zip(commands_data, creators):
if isinstance(creator, BotException):
self.logger.warning(
"Skipping command with missing creator",
command_id=cmd_data.id,
command_name=cmd_data.name,
creator_id=cmd_data.creator_id,
error=str(e),
error=str(creator),
)
continue
if isinstance(creator, BaseException):
raise creator
commands.append(CustomCommand(**cmd_data.model_dump(), creator=creator))
return commands
@ -688,21 +710,28 @@ class CustomCommandsService(BaseService[CustomCommand]):
commands_data = await self.get_items_with_params(params)
creators = await asyncio.gather(
*[
self.get_creator_by_id(cmd_data.creator_id)
for cmd_data in commands_data
],
return_exceptions=True,
)
commands = []
for cmd_data in commands_data:
try:
creator = await self.get_creator_by_id(cmd_data.creator_id)
commands.append(CustomCommand(**cmd_data.model_dump(), creator=creator))
except BotException as e:
# Handle missing creator gracefully
for cmd_data, creator in zip(commands_data, creators):
if isinstance(creator, BotException):
self.logger.warning(
"Skipping command with missing creator",
command_id=cmd_data.id,
command_name=cmd_data.name,
creator_id=cmd_data.creator_id,
error=str(e),
error=str(creator),
)
continue
if isinstance(creator, BaseException):
raise creator
commands.append(CustomCommand(**cmd_data.model_dump(), creator=creator))
return commands

View File

@ -4,6 +4,7 @@ Decision Service
Manages pitching decision operations for game submission.
"""
import asyncio
from typing import List, Dict, Any, Optional, Tuple
from utils.logging import get_contextual_logger
@ -124,22 +125,19 @@ class DecisionService:
if int(decision.get("b_save", 0)) == 1:
bsv_ids.append(pitcher_id)
# Second pass: Fetch Player objects
wp = await player_service.get_player(wp_id) if wp_id else None
lp = await player_service.get_player(lp_id) if lp_id else None
sv = await player_service.get_player(sv_id) if sv_id else None
# Second pass: Fetch all Player objects in parallel
# Order: [wp_id, lp_id, sv_id, *hold_ids, *bsv_ids]; None IDs resolve immediately
ordered_ids = [wp_id, lp_id, sv_id] + hold_ids + bsv_ids
results = await asyncio.gather(
*[
player_service.get_player(pid) if pid else asyncio.sleep(0, result=None)
for pid in ordered_ids
]
)
holders = []
for hold_id in hold_ids:
holder = await player_service.get_player(hold_id)
if holder:
holders.append(holder)
blown_saves = []
for bsv_id in bsv_ids:
bsv = await player_service.get_player(bsv_id)
if bsv:
blown_saves.append(bsv)
wp, lp, sv = results[0], results[1], results[2]
holders = [p for p in results[3 : 3 + len(hold_ids)] if p]
blown_saves = [p for p in results[3 + len(hold_ids) :] if p]
return wp, lp, sv, holders, blown_saves

View File

@ -5,6 +5,7 @@ Modern async service layer for managing help commands with full type safety.
Allows admins and help editors to create custom help topics for league documentation,
resources, FAQs, links, and guides.
"""
from typing import Optional, List
from utils.logging import get_contextual_logger
@ -12,7 +13,7 @@ from models.help_command import (
HelpCommand,
HelpCommandSearchFilters,
HelpCommandSearchResult,
HelpCommandStats
HelpCommandStats,
)
from services.base_service import BaseService
from exceptions import BotException
@ -20,16 +21,19 @@ from exceptions import BotException
class HelpCommandNotFoundError(BotException):
"""Raised when a help command is not found."""
pass
class HelpCommandExistsError(BotException):
"""Raised when trying to create a help command that already exists."""
pass
class HelpCommandPermissionError(BotException):
"""Raised when user lacks permission for help command operation."""
pass
@ -37,8 +41,8 @@ class HelpCommandsService(BaseService[HelpCommand]):
"""Service for managing help commands."""
def __init__(self):
super().__init__(HelpCommand, 'help_commands')
self.logger = get_contextual_logger(f'{__name__}.HelpCommandsService')
super().__init__(HelpCommand, "help_commands")
self.logger = get_contextual_logger(f"{__name__}.HelpCommandsService")
self.logger.info("HelpCommandsService initialized")
# === Command CRUD Operations ===
@ -50,7 +54,7 @@ class HelpCommandsService(BaseService[HelpCommand]):
content: str,
creator_discord_id: str,
category: Optional[str] = None,
display_order: int = 0
display_order: int = 0,
) -> HelpCommand:
"""
Create a new help command.
@ -80,14 +84,16 @@ class HelpCommandsService(BaseService[HelpCommand]):
# Create help command data
help_data = {
'name': name.lower().strip(),
'title': title.strip(),
'content': content.strip(),
'category': category.lower().strip() if category else None,
'created_by_discord_id': str(creator_discord_id), # Convert to string for safe storage
'display_order': display_order,
'is_active': True,
'view_count': 0
"name": name.lower().strip(),
"title": title.strip(),
"content": content.strip(),
"category": category.lower().strip() if category else None,
"created_by_discord_id": str(
creator_discord_id
), # Convert to string for safe storage
"display_order": display_order,
"is_active": True,
"view_count": 0,
}
# Create via API
@ -95,18 +101,18 @@ class HelpCommandsService(BaseService[HelpCommand]):
if not result:
raise BotException("Failed to create help command")
self.logger.info("Help command created",
help_name=name,
creator_id=creator_discord_id,
category=category)
self.logger.info(
"Help command created",
help_name=name,
creator_id=creator_discord_id,
category=category,
)
# Return full help command
return await self.get_help_by_name(name)
# Return help command directly from POST response
return result
async def get_help_by_name(
self,
name: str,
include_inactive: bool = False
self, name: str, include_inactive: bool = False
) -> HelpCommand:
"""
Get a help command by name.
@ -126,8 +132,12 @@ class HelpCommandsService(BaseService[HelpCommand]):
try:
# Use the dedicated by_name endpoint for exact lookup
client = await self.get_client()
params = [('include_inactive', include_inactive)] if include_inactive else []
data = await client.get(f'help_commands/by_name/{normalized_name}', params=params)
params = (
[("include_inactive", include_inactive)] if include_inactive else []
)
data = await client.get(
f"help_commands/by_name/{normalized_name}", params=params
)
if not data:
raise HelpCommandNotFoundError(f"Help topic '{name}' not found")
@ -139,9 +149,9 @@ class HelpCommandsService(BaseService[HelpCommand]):
if "404" in str(e) or "not found" in str(e).lower():
raise HelpCommandNotFoundError(f"Help topic '{name}' not found")
else:
self.logger.error("Failed to get help command by name",
help_name=name,
error=e)
self.logger.error(
"Failed to get help command by name", help_name=name, error=e
)
raise BotException(f"Failed to retrieve help topic '{name}': {e}")
async def update_help(
@ -151,7 +161,7 @@ class HelpCommandsService(BaseService[HelpCommand]):
new_content: Optional[str] = None,
updater_discord_id: Optional[str] = None,
new_category: Optional[str] = None,
new_display_order: Optional[int] = None
new_display_order: Optional[int] = None,
) -> HelpCommand:
"""
Update an existing help command.
@ -176,35 +186,42 @@ class HelpCommandsService(BaseService[HelpCommand]):
update_data = {}
if new_title is not None:
update_data['title'] = new_title.strip()
update_data["title"] = new_title.strip()
if new_content is not None:
update_data['content'] = new_content.strip()
update_data["content"] = new_content.strip()
if new_category is not None:
update_data['category'] = new_category.lower().strip() if new_category else None
update_data["category"] = (
new_category.lower().strip() if new_category else None
)
if new_display_order is not None:
update_data['display_order'] = new_display_order
update_data["display_order"] = new_display_order
if updater_discord_id is not None:
update_data['last_modified_by'] = str(updater_discord_id) # Convert to string for safe storage
update_data["last_modified_by"] = str(
updater_discord_id
) # Convert to string for safe storage
if not update_data:
raise BotException("No fields to update")
# Update via API
client = await self.get_client()
result = await client.put(f'help_commands/{help_cmd.id}', update_data)
result = await client.put(f"help_commands/{help_cmd.id}", update_data)
if not result:
raise BotException("Failed to update help command")
self.logger.info("Help command updated",
help_name=name,
updater_id=updater_discord_id,
fields_updated=list(update_data.keys()))
self.logger.info(
"Help command updated",
help_name=name,
updater_id=updater_discord_id,
fields_updated=list(update_data.keys()),
)
return await self.get_help_by_name(name)
# Return updated help command directly from PUT response
return self.model_class.from_api_data(result)
async def delete_help(self, name: str) -> bool:
"""
@ -223,11 +240,11 @@ class HelpCommandsService(BaseService[HelpCommand]):
# Soft delete via API
client = await self.get_client()
await client.delete(f'help_commands/{help_cmd.id}')
await client.delete(f"help_commands/{help_cmd.id}")
self.logger.info("Help command soft deleted",
help_name=name,
help_id=help_cmd.id)
self.logger.info(
"Help command soft deleted", help_name=name, help_id=help_cmd.id
)
return True
@ -252,13 +269,11 @@ class HelpCommandsService(BaseService[HelpCommand]):
# Restore via API
client = await self.get_client()
result = await client.patch(f'help_commands/{help_cmd.id}/restore')
result = await client.patch(f"help_commands/{help_cmd.id}/restore")
if not result:
raise BotException("Failed to restore help command")
self.logger.info("Help command restored",
help_name=name,
help_id=help_cmd.id)
self.logger.info("Help command restored", help_name=name, help_id=help_cmd.id)
return self.model_class.from_api_data(result)
@ -279,10 +294,9 @@ class HelpCommandsService(BaseService[HelpCommand]):
try:
client = await self.get_client()
await client.patch(f'help_commands/by_name/{normalized_name}/view')
await client.patch(f"help_commands/by_name/{normalized_name}/view")
self.logger.debug("Help command view count incremented",
help_name=name)
self.logger.debug("Help command view count incremented", help_name=name)
# Return updated command
return await self.get_help_by_name(name)
@ -291,16 +305,15 @@ class HelpCommandsService(BaseService[HelpCommand]):
if "404" in str(e) or "not found" in str(e).lower():
raise HelpCommandNotFoundError(f"Help topic '{name}' not found")
else:
self.logger.error("Failed to increment view count",
help_name=name,
error=e)
self.logger.error(
"Failed to increment view count", help_name=name, error=e
)
raise BotException(f"Failed to increment view count for '{name}': {e}")
# === Search and Listing ===
async def search_help_commands(
self,
filters: HelpCommandSearchFilters
self, filters: HelpCommandSearchFilters
) -> HelpCommandSearchResult:
"""
Search for help commands with filtering and pagination.
@ -316,23 +329,23 @@ class HelpCommandsService(BaseService[HelpCommand]):
# Apply filters
if filters.name_contains:
params.append(('name', filters.name_contains)) # API will do ILIKE search
params.append(("name", filters.name_contains)) # API will do ILIKE search
if filters.category:
params.append(('category', filters.category))
params.append(("category", filters.category))
params.append(('is_active', filters.is_active))
params.append(("is_active", filters.is_active))
# Add sorting
params.append(('sort', filters.sort_by))
params.append(("sort", filters.sort_by))
# Add pagination
params.append(('page', filters.page))
params.append(('page_size', filters.page_size))
params.append(("page", filters.page))
params.append(("page_size", filters.page_size))
# Execute search via API
client = await self.get_client()
data = await client.get('help_commands', params=params)
data = await client.get("help_commands", params=params)
if not data:
return HelpCommandSearchResult(
@ -341,14 +354,14 @@ class HelpCommandsService(BaseService[HelpCommand]):
page=filters.page,
page_size=filters.page_size,
total_pages=0,
has_more=False
has_more=False,
)
# Extract response data
help_commands_data = data.get('help_commands', [])
total_count = data.get('total_count', 0)
total_pages = data.get('total_pages', 0)
has_more = data.get('has_more', False)
help_commands_data = data.get("help_commands", [])
total_count = data.get("total_count", 0)
total_pages = data.get("total_pages", 0)
has_more = data.get("has_more", False)
# Convert to HelpCommand objects
help_commands = []
@ -356,15 +369,21 @@ class HelpCommandsService(BaseService[HelpCommand]):
try:
help_commands.append(self.model_class.from_api_data(cmd_data))
except Exception as e:
self.logger.warning("Failed to create HelpCommand from API data",
help_id=cmd_data.get('id'),
error=e)
self.logger.warning(
"Failed to create HelpCommand from API data",
help_id=cmd_data.get("id"),
error=e,
)
continue
self.logger.debug("Help commands search completed",
total_results=total_count,
page=filters.page,
filters_applied=len([p for p in params if p[0] not in ['sort', 'page', 'page_size']]))
self.logger.debug(
"Help commands search completed",
total_results=total_count,
page=filters.page,
filters_applied=len(
[p for p in params if p[0] not in ["sort", "page", "page_size"]]
),
)
return HelpCommandSearchResult(
help_commands=help_commands,
@ -372,13 +391,11 @@ class HelpCommandsService(BaseService[HelpCommand]):
page=filters.page,
page_size=filters.page_size,
total_pages=total_pages,
has_more=has_more
has_more=has_more,
)
async def get_all_help_topics(
self,
category: Optional[str] = None,
include_inactive: bool = False
self, category: Optional[str] = None, include_inactive: bool = False
) -> List[HelpCommand]:
"""
Get all help topics, optionally filtered by category.
@ -393,37 +410,36 @@ class HelpCommandsService(BaseService[HelpCommand]):
params = []
if category:
params.append(('category', category))
params.append(("category", category))
params.append(('is_active', not include_inactive))
params.append(('sort', 'display_order'))
params.append(('page_size', 100)) # Get all
params.append(("is_active", not include_inactive))
params.append(("sort", "display_order"))
params.append(("page_size", 100)) # Get all
client = await self.get_client()
data = await client.get('help_commands', params=params)
data = await client.get("help_commands", params=params)
if not data:
return []
help_commands_data = data.get('help_commands', [])
help_commands_data = data.get("help_commands", [])
help_commands = []
for cmd_data in help_commands_data:
try:
help_commands.append(self.model_class.from_api_data(cmd_data))
except Exception as e:
self.logger.warning("Failed to create HelpCommand from API data",
help_id=cmd_data.get('id'),
error=e)
self.logger.warning(
"Failed to create HelpCommand from API data",
help_id=cmd_data.get("id"),
error=e,
)
continue
return help_commands
async def get_help_names_for_autocomplete(
self,
partial_name: str = "",
limit: int = 25,
include_inactive: bool = False
self, partial_name: str = "", limit: int = 25, include_inactive: bool = False
) -> List[str]:
"""
Get help command names for Discord autocomplete.
@ -439,25 +455,28 @@ class HelpCommandsService(BaseService[HelpCommand]):
try:
# Use the dedicated autocomplete endpoint
client = await self.get_client()
params = [('limit', limit)]
params = [("limit", limit)]
if partial_name:
params.append(('q', partial_name.lower()))
params.append(("q", partial_name.lower()))
result = await client.get('help_commands/autocomplete', params=params)
result = await client.get("help_commands/autocomplete", params=params)
# The autocomplete endpoint returns results with name, title, category
if isinstance(result, dict) and 'results' in result:
return [item['name'] for item in result['results']]
if isinstance(result, dict) and "results" in result:
return [item["name"] for item in result["results"]]
else:
self.logger.warning("Unexpected autocomplete response format",
response=result)
self.logger.warning(
"Unexpected autocomplete response format", response=result
)
return []
except Exception as e:
self.logger.error("Failed to get help names for autocomplete",
partial_name=partial_name,
error=e)
self.logger.error(
"Failed to get help names for autocomplete",
partial_name=partial_name,
error=e,
)
# Return empty list on error to not break Discord autocomplete
return []
@ -467,7 +486,7 @@ class HelpCommandsService(BaseService[HelpCommand]):
"""Get comprehensive statistics about help commands."""
try:
client = await self.get_client()
data = await client.get('help_commands/stats')
data = await client.get("help_commands/stats")
if not data:
return HelpCommandStats(
@ -475,23 +494,25 @@ class HelpCommandsService(BaseService[HelpCommand]):
active_commands=0,
total_views=0,
most_viewed_command=None,
recent_commands_count=0
recent_commands_count=0,
)
# Convert most_viewed_command if present
most_viewed = None
if data.get('most_viewed_command'):
if data.get("most_viewed_command"):
try:
most_viewed = self.model_class.from_api_data(data['most_viewed_command'])
most_viewed = self.model_class.from_api_data(
data["most_viewed_command"]
)
except Exception as e:
self.logger.warning("Failed to parse most viewed command", error=e)
return HelpCommandStats(
total_commands=data.get('total_commands', 0),
active_commands=data.get('active_commands', 0),
total_views=data.get('total_views', 0),
total_commands=data.get("total_commands", 0),
active_commands=data.get("active_commands", 0),
total_views=data.get("total_views", 0),
most_viewed_command=most_viewed,
recent_commands_count=data.get('recent_commands_count', 0)
recent_commands_count=data.get("recent_commands_count", 0),
)
except Exception as e:
@ -502,7 +523,7 @@ class HelpCommandsService(BaseService[HelpCommand]):
active_commands=0,
total_views=0,
most_viewed_command=None,
recent_commands_count=0
recent_commands_count=0,
)

View File

@ -0,0 +1,143 @@
"""
Tests for trade deadline enforcement in /trade commands.
Validates that trades are blocked after the trade deadline and allowed during/before it.
"""
import pytest
from unittest.mock import AsyncMock, MagicMock, patch
from tests.factories import CurrentFactory, TeamFactory
class TestTradeInitiateDeadlineGuard:
"""Test trade deadline enforcement in /trade initiate command."""
@pytest.fixture
def mock_interaction(self):
"""Create mock Discord interaction with deferred response."""
interaction = AsyncMock()
interaction.user = MagicMock()
interaction.user.id = 258104532423147520
interaction.response = AsyncMock()
interaction.followup = AsyncMock()
interaction.guild = MagicMock()
interaction.guild.id = 669356687294988350
return interaction
@pytest.mark.asyncio
async def test_trade_initiate_blocked_past_deadline(self, mock_interaction):
"""After the trade deadline, /trade initiate should return a friendly error."""
user_team = TeamFactory.west_virginia()
other_team = TeamFactory.new_york()
past_deadline = CurrentFactory.create(week=15, trade_deadline=14)
with (
patch(
"commands.transactions.trade.validate_user_has_team",
new_callable=AsyncMock,
return_value=user_team,
),
patch(
"commands.transactions.trade.get_team_by_abbrev_with_validation",
new_callable=AsyncMock,
return_value=other_team,
),
patch("commands.transactions.trade.league_service") as mock_league,
):
mock_league.get_current_state = AsyncMock(return_value=past_deadline)
from commands.transactions.trade import TradeCommands
bot = MagicMock()
cog = TradeCommands(bot)
await cog.trade_initiate.callback(cog, mock_interaction, "NY")
mock_interaction.followup.send.assert_called_once()
call_kwargs = mock_interaction.followup.send.call_args
msg = (
call_kwargs[0][0]
if call_kwargs[0]
else call_kwargs[1].get("content", "")
)
assert "trade deadline has passed" in msg.lower()
@pytest.mark.asyncio
async def test_trade_initiate_allowed_at_deadline_week(self, mock_interaction):
"""During the deadline week itself, /trade initiate should proceed."""
user_team = TeamFactory.west_virginia()
other_team = TeamFactory.new_york()
at_deadline = CurrentFactory.create(week=14, trade_deadline=14)
with (
patch(
"commands.transactions.trade.validate_user_has_team",
new_callable=AsyncMock,
return_value=user_team,
),
patch(
"commands.transactions.trade.get_team_by_abbrev_with_validation",
new_callable=AsyncMock,
return_value=other_team,
),
patch("commands.transactions.trade.league_service") as mock_league,
patch("commands.transactions.trade.clear_trade_builder") as mock_clear,
patch("commands.transactions.trade.get_trade_builder") as mock_get_builder,
patch(
"commands.transactions.trade.create_trade_embed",
new_callable=AsyncMock,
return_value=MagicMock(),
),
):
mock_league.get_current_state = AsyncMock(return_value=at_deadline)
mock_builder = MagicMock()
mock_builder.add_team = AsyncMock(return_value=(True, None))
mock_builder.trade_id = "test-123"
mock_get_builder.return_value = mock_builder
from commands.transactions.trade import TradeCommands
bot = MagicMock()
cog = TradeCommands(bot)
cog.channel_manager = MagicMock()
cog.channel_manager.create_trade_channel = AsyncMock(return_value=None)
await cog.trade_initiate.callback(cog, mock_interaction, "NY")
# Should have proceeded past deadline check to clear/create trade
mock_clear.assert_called_once()
@pytest.mark.asyncio
async def test_trade_initiate_blocked_when_current_none(self, mock_interaction):
"""When league state can't be fetched, /trade initiate should fail closed."""
user_team = TeamFactory.west_virginia()
other_team = TeamFactory.new_york()
with (
patch(
"commands.transactions.trade.validate_user_has_team",
new_callable=AsyncMock,
return_value=user_team,
),
patch(
"commands.transactions.trade.get_team_by_abbrev_with_validation",
new_callable=AsyncMock,
return_value=other_team,
),
patch("commands.transactions.trade.league_service") as mock_league,
):
mock_league.get_current_state = AsyncMock(return_value=None)
from commands.transactions.trade import TradeCommands
bot = MagicMock()
cog = TradeCommands(bot)
await cog.trade_initiate.callback(cog, mock_interaction, "NY")
mock_interaction.followup.send.assert_called_once()
call_kwargs = mock_interaction.followup.send.call_args
msg = (
call_kwargs[0][0]
if call_kwargs[0]
else call_kwargs[1].get("content", "")
)
assert "could not retrieve league state" in msg.lower()

View File

@ -3,6 +3,7 @@ Tests for SBA data models
Validates model creation, validation, and business logic.
"""
import pytest
from models import Team, Player, Current, DraftPick, DraftData, DraftList
@ -10,94 +11,102 @@ from models import Team, Player, Current, DraftPick, DraftData, DraftList
class TestSBABaseModel:
"""Test base model functionality."""
def test_model_creation_with_api_data(self):
"""Test creating models from API data."""
team_data = {
'id': 1,
'abbrev': 'NYY',
'sname': 'Yankees',
'lname': 'New York Yankees',
'season': 12
"id": 1,
"abbrev": "NYY",
"sname": "Yankees",
"lname": "New York Yankees",
"season": 12,
}
team = Team.from_api_data(team_data)
assert team.id == 1
assert team.abbrev == 'NYY'
assert team.lname == 'New York Yankees'
assert team.abbrev == "NYY"
assert team.lname == "New York Yankees"
def test_to_dict_functionality(self):
"""Test model to dictionary conversion."""
team = Team(id=1, abbrev='LAA', sname='Angels', lname='Los Angeles Angels', season=12)
team = Team(
id=1, abbrev="LAA", sname="Angels", lname="Los Angeles Angels", season=12
)
team_dict = team.to_dict()
assert 'abbrev' in team_dict
assert team_dict['abbrev'] == 'LAA'
assert team_dict['lname'] == 'Los Angeles Angels'
assert "abbrev" in team_dict
assert team_dict["abbrev"] == "LAA"
assert team_dict["lname"] == "Los Angeles Angels"
def test_model_repr(self):
"""Test model string representation."""
team = Team(id=2, abbrev='BOS', sname='Red Sox', lname='Boston Red Sox', season=12)
team = Team(
id=2, abbrev="BOS", sname="Red Sox", lname="Boston Red Sox", season=12
)
repr_str = repr(team)
assert 'Team(' in repr_str
assert 'abbrev=BOS' in repr_str
assert "Team(" in repr_str
assert "abbrev=BOS" in repr_str
class TestTeamModel:
"""Test Team model functionality."""
def test_team_creation_minimal(self):
"""Test team creation with minimal required fields."""
team = Team(
id=4,
abbrev='HOU',
sname='Astros',
lname='Houston Astros',
season=12
id=4, abbrev="HOU", sname="Astros", lname="Houston Astros", season=12
)
assert team.abbrev == 'HOU'
assert team.sname == 'Astros'
assert team.lname == 'Houston Astros'
assert team.abbrev == "HOU"
assert team.sname == "Astros"
assert team.lname == "Houston Astros"
assert team.season == 12
def test_team_creation_with_optional_fields(self):
"""Test team creation with optional fields."""
team = Team(
id=5,
abbrev='SF',
sname='Giants',
lname='San Francisco Giants',
abbrev="SF",
sname="Giants",
lname="San Francisco Giants",
season=12,
gmid=100,
division_id=1,
stadium='Oracle Park',
color='FF8C00'
stadium="Oracle Park",
color="FF8C00",
)
assert team.gmid == 100
assert team.division_id == 1
assert team.stadium == 'Oracle Park'
assert team.color == 'FF8C00'
assert team.stadium == "Oracle Park"
assert team.color == "FF8C00"
def test_team_str_representation(self):
"""Test team string representation."""
team = Team(id=3, abbrev='SD', sname='Padres', lname='San Diego Padres', season=12)
assert str(team) == 'SD - San Diego Padres'
team = Team(
id=3, abbrev="SD", sname="Padres", lname="San Diego Padres", season=12
)
assert str(team) == "SD - San Diego Padres"
def test_team_roster_type_major_league(self):
"""Test roster type detection for Major League teams."""
from models.team import RosterType
# 3 chars or less → Major League
team = Team(id=1, abbrev='NYY', sname='Yankees', lname='New York Yankees', season=12)
team = Team(
id=1, abbrev="NYY", sname="Yankees", lname="New York Yankees", season=12
)
assert team.roster_type() == RosterType.MAJOR_LEAGUE
team = Team(id=2, abbrev='BOS', sname='Red Sox', lname='Boston Red Sox', season=12)
team = Team(
id=2, abbrev="BOS", sname="Red Sox", lname="Boston Red Sox", season=12
)
assert team.roster_type() == RosterType.MAJOR_LEAGUE
# Even "BHM" (ends in M) should be Major League
team = Team(id=3, abbrev='BHM', sname='Iron', lname='Birmingham Iron', season=12)
team = Team(
id=3, abbrev="BHM", sname="Iron", lname="Birmingham Iron", season=12
)
assert team.roster_type() == RosterType.MAJOR_LEAGUE
def test_team_roster_type_minor_league(self):
@ -105,14 +114,28 @@ class TestTeamModel:
from models.team import RosterType
# Standard Minor League: [Team] + "MIL"
team = Team(id=4, abbrev='NYYMIL', sname='RailRiders', lname='Staten Island RailRiders', season=12)
team = Team(
id=4,
abbrev="NYYMIL",
sname="RailRiders",
lname="Staten Island RailRiders",
season=12,
)
assert team.roster_type() == RosterType.MINOR_LEAGUE
team = Team(id=5, abbrev='PORMIL', sname='Portland MiL', lname='Portland Minor League', season=12)
team = Team(
id=5,
abbrev="PORMIL",
sname="Portland MiL",
lname="Portland Minor League",
season=12,
)
assert team.roster_type() == RosterType.MINOR_LEAGUE
# Case insensitive
team = Team(id=6, abbrev='LAAmil', sname='Bees', lname='Salt Lake Bees', season=12)
team = Team(
id=6, abbrev="LAAmil", sname="Bees", lname="Salt Lake Bees", season=12
)
assert team.roster_type() == RosterType.MINOR_LEAGUE
def test_team_roster_type_injured_list(self):
@ -120,14 +143,32 @@ class TestTeamModel:
from models.team import RosterType
# Standard Injured List: [Team] + "IL"
team = Team(id=7, abbrev='NYYIL', sname='Yankees IL', lname='New York Yankees IL', season=12)
team = Team(
id=7,
abbrev="NYYIL",
sname="Yankees IL",
lname="New York Yankees IL",
season=12,
)
assert team.roster_type() == RosterType.INJURED_LIST
team = Team(id=8, abbrev='PORIL', sname='Loggers IL', lname='Portland Loggers IL', season=12)
team = Team(
id=8,
abbrev="PORIL",
sname="Loggers IL",
lname="Portland Loggers IL",
season=12,
)
assert team.roster_type() == RosterType.INJURED_LIST
# Case insensitive
team = Team(id=9, abbrev='LAAil', sname='Angels IL', lname='Los Angeles Angels IL', season=12)
team = Team(
id=9,
abbrev="LAAil",
sname="Angels IL",
lname="Los Angeles Angels IL",
season=12,
)
assert team.roster_type() == RosterType.INJURED_LIST
def test_team_roster_type_edge_case_bhmil(self):
@ -143,16 +184,30 @@ class TestTeamModel:
from models.team import RosterType
# "BHMIL" = "BHM" + "IL" → sname contains "IL" → INJURED_LIST
team = Team(id=10, abbrev='BHMIL', sname='Iron IL', lname='Birmingham Iron IL', season=12)
team = Team(
id=10,
abbrev="BHMIL",
sname="Iron IL",
lname="Birmingham Iron IL",
season=12,
)
assert team.roster_type() == RosterType.INJURED_LIST
# Compare with a real Minor League team that has "Island" in name
# "NYYMIL" = "NYY" + "MIL", even though sname has "Island" → MINOR_LEAGUE
team = Team(id=11, abbrev='NYYMIL', sname='Staten Island RailRiders', lname='Staten Island RailRiders', season=12)
team = Team(
id=11,
abbrev="NYYMIL",
sname="Staten Island RailRiders",
lname="Staten Island RailRiders",
season=12,
)
assert team.roster_type() == RosterType.MINOR_LEAGUE
# Another IL edge case with sname containing "IL" at word boundary
team = Team(id=12, abbrev='WVMIL', sname='WV IL', lname='West Virginia IL', season=12)
team = Team(
id=12, abbrev="WVMIL", sname="WV IL", lname="West Virginia IL", season=12
)
assert team.roster_type() == RosterType.INJURED_LIST
def test_team_roster_type_sname_disambiguation(self):
@ -160,221 +215,231 @@ class TestTeamModel:
from models.team import RosterType
# MiL team - sname does NOT have "IL" as a word
team = Team(id=13, abbrev='WVMIL', sname='Miners', lname='West Virginia Miners', season=12)
team = Team(
id=13,
abbrev="WVMIL",
sname="Miners",
lname="West Virginia Miners",
season=12,
)
assert team.roster_type() == RosterType.MINOR_LEAGUE
# IL team - sname has "IL" at word boundary
team = Team(id=14, abbrev='WVMIL', sname='Miners IL', lname='West Virginia Miners IL', season=12)
team = Team(
id=14,
abbrev="WVMIL",
sname="Miners IL",
lname="West Virginia Miners IL",
season=12,
)
assert team.roster_type() == RosterType.INJURED_LIST
# MiL team - sname has "IL" but only in "Island" (substring, not word boundary)
team = Team(id=15, abbrev='CHIMIL', sname='Island Hoppers', lname='Chicago Island Hoppers', season=12)
team = Team(
id=15,
abbrev="CHIMIL",
sname="Island Hoppers",
lname="Chicago Island Hoppers",
season=12,
)
assert team.roster_type() == RosterType.MINOR_LEAGUE
class TestPlayerModel:
"""Test Player model functionality."""
def test_player_creation(self):
"""Test player creation with required fields."""
player = Player(
id=101,
name='Mike Trout',
name="Mike Trout",
wara=8.5,
season=12,
team_id=1,
image='trout.jpg',
pos_1='CF'
image="trout.jpg",
pos_1="CF",
)
assert player.name == 'Mike Trout'
assert player.name == "Mike Trout"
assert player.wara == 8.5
assert player.team_id == 1
assert player.pos_1 == 'CF'
assert player.pos_1 == "CF"
def test_player_positions_property(self):
"""Test player positions property."""
player = Player(
id=102,
name='Shohei Ohtani',
name="Shohei Ohtani",
wara=9.0,
season=12,
team_id=1,
image='ohtani.jpg',
pos_1='SP',
pos_2='DH',
pos_3='RF'
image="ohtani.jpg",
pos_1="SP",
pos_2="DH",
pos_3="RF",
)
positions = player.positions
assert len(positions) == 3
assert 'SP' in positions
assert 'DH' in positions
assert 'RF' in positions
assert "SP" in positions
assert "DH" in positions
assert "RF" in positions
def test_player_primary_position(self):
"""Test primary position property."""
player = Player(
id=103,
name='Mookie Betts',
name="Mookie Betts",
wara=7.2,
season=12,
team_id=1,
image='betts.jpg',
pos_1='RF',
pos_2='2B'
image="betts.jpg",
pos_1="RF",
pos_2="2B",
)
assert player.primary_position == 'RF'
assert player.primary_position == "RF"
def test_player_is_pitcher(self):
"""Test is_pitcher property."""
pitcher = Player(
id=104,
name='Gerrit Cole',
name="Gerrit Cole",
wara=6.8,
season=12,
team_id=1,
image='cole.jpg',
pos_1='SP'
image="cole.jpg",
pos_1="SP",
)
position_player = Player(
id=105,
name='Aaron Judge',
name="Aaron Judge",
wara=8.1,
season=12,
team_id=1,
image='judge.jpg',
pos_1='RF'
image="judge.jpg",
pos_1="RF",
)
assert pitcher.is_pitcher is True
assert position_player.is_pitcher is False
def test_player_str_representation(self):
"""Test player string representation."""
player = Player(
id=106,
name='Ronald Acuna Jr.',
name="Ronald Acuna Jr.",
wara=8.8,
season=12,
team_id=1,
image='acuna.jpg',
pos_1='OF'
image="acuna.jpg",
pos_1="OF",
)
assert str(player) == 'Ronald Acuna Jr. (OF)'
assert str(player) == "Ronald Acuna Jr. (OF)"
class TestCurrentModel:
"""Test Current league state model."""
def test_current_default_values(self):
"""Test current model with default values."""
current = Current()
assert current.week == 69
assert current.season == 69
assert current.freeze is True
assert current.bet_week == 'sheets'
assert current.bet_week == "sheets"
def test_current_with_custom_values(self):
"""Test current model with custom values."""
current = Current(
week=15,
season=12,
freeze=False,
trade_deadline=14,
playoffs_begin=19
week=15, season=12, freeze=False, trade_deadline=14, playoffs_begin=19
)
assert current.week == 15
assert current.season == 12
assert current.freeze is False
def test_current_properties(self):
"""Test current model properties."""
# Regular season
current = Current(week=10, playoffs_begin=19)
assert current.is_offseason is False
assert current.is_playoffs is False
# Playoffs
current = Current(week=20, playoffs_begin=19)
assert current.is_offseason is True
assert current.is_playoffs is True
# Pick trading
current = Current(week=15, pick_trade_start=10, pick_trade_end=20)
assert current.can_trade_picks is True
def test_is_past_trade_deadline(self):
"""Test trade deadline property — trades allowed during deadline week, blocked after."""
# Before deadline
current = Current(week=10, trade_deadline=14)
assert current.is_past_trade_deadline is False
# At deadline week (still allowed)
current = Current(week=14, trade_deadline=14)
assert current.is_past_trade_deadline is False
# One week past deadline
current = Current(week=15, trade_deadline=14)
assert current.is_past_trade_deadline is True
# Offseason bypasses deadline (week > 18)
current = Current(week=20, trade_deadline=14)
assert current.is_offseason is True
assert current.is_past_trade_deadline is False
class TestDraftPickModel:
"""Test DraftPick model functionality."""
def test_draft_pick_creation(self):
"""Test draft pick creation."""
pick = DraftPick(
season=12,
overall=1,
round=1,
origowner_id=1,
owner_id=1
)
pick = DraftPick(season=12, overall=1, round=1, origowner_id=1, owner_id=1)
assert pick.season == 12
assert pick.overall == 1
assert pick.origowner_id == 1
assert pick.owner_id == 1
def test_draft_pick_properties(self):
"""Test draft pick properties."""
# Not traded, not selected
pick = DraftPick(
season=12,
overall=5,
round=1,
origowner_id=1,
owner_id=1
)
pick = DraftPick(season=12, overall=5, round=1, origowner_id=1, owner_id=1)
assert pick.is_traded is False
assert pick.is_selected is False
# Traded pick
traded_pick = DraftPick(
season=12,
overall=10,
round=1,
origowner_id=1,
owner_id=2
season=12, overall=10, round=1, origowner_id=1, owner_id=2
)
assert traded_pick.is_traded is True
# Selected pick
selected_pick = DraftPick(
season=12,
overall=15,
round=1,
origowner_id=1,
owner_id=1,
player_id=100
season=12, overall=15, round=1, origowner_id=1, owner_id=1, player_id=100
)
assert selected_pick.is_selected is True
class TestDraftDataModel:
"""Test DraftData model functionality."""
def test_draft_data_creation(self):
"""Test draft data creation."""
draft_data = DraftData(
result_channel=123456789,
ping_channel=987654321,
pick_minutes=10
result_channel=123456789, ping_channel=987654321, pick_minutes=10
)
assert draft_data.result_channel == 123456789
@ -384,20 +449,12 @@ class TestDraftDataModel:
def test_draft_data_properties(self):
"""Test draft data properties."""
# Inactive draft
draft_data = DraftData(
result_channel=123,
ping_channel=456,
timer=False
)
draft_data = DraftData(result_channel=123, ping_channel=456, timer=False)
assert draft_data.is_draft_active is False
# Active draft
active_draft = DraftData(
result_channel=123,
ping_channel=456,
timer=True
)
active_draft = DraftData(result_channel=123, ping_channel=456, timer=True)
assert active_draft.is_draft_active is True
@ -409,17 +466,13 @@ class TestDraftListModel:
not just IDs. The API returns these objects populated.
"""
def _create_mock_team(self, team_id: int = 1) -> 'Team':
def _create_mock_team(self, team_id: int = 1) -> "Team":
"""Create a mock team for testing."""
return Team(
id=team_id,
abbrev="TST",
sname="Test",
lname="Test Team",
season=12
id=team_id, abbrev="TST", sname="Test", lname="Test Team", season=12
)
def _create_mock_player(self, player_id: int = 100) -> 'Player':
def _create_mock_player(self, player_id: int = 100) -> "Player":
"""Create a mock player for testing."""
return Player(
id=player_id,
@ -430,7 +483,7 @@ class TestDraftListModel:
team_id=1,
season=12,
wara=2.5,
image="https://example.com/test.jpg"
image="https://example.com/test.jpg",
)
def test_draft_list_creation(self):
@ -438,12 +491,7 @@ class TestDraftListModel:
mock_team = self._create_mock_team(team_id=1)
mock_player = self._create_mock_player(player_id=100)
draft_entry = DraftList(
season=12,
team=mock_team,
rank=1,
player=mock_player
)
draft_entry = DraftList(season=12, team=mock_team, rank=1, player=mock_player)
assert draft_entry.season == 12
assert draft_entry.team_id == 1
@ -456,18 +504,10 @@ class TestDraftListModel:
mock_player_top = self._create_mock_player(player_id=100)
mock_player_lower = self._create_mock_player(player_id=200)
top_pick = DraftList(
season=12,
team=mock_team,
rank=1,
player=mock_player_top
)
top_pick = DraftList(season=12, team=mock_team, rank=1, player=mock_player_top)
lower_pick = DraftList(
season=12,
team=mock_team,
rank=5,
player=mock_player_lower
season=12, team=mock_team, rank=5, player=mock_player_lower
)
assert top_pick.is_top_ranked is True
@ -486,32 +526,32 @@ class TestDraftListModel:
"""
# Simulate API response format - nested objects, NOT flat IDs
api_response = {
'id': 303,
'season': 13,
'rank': 1,
'team': {
'id': 548,
'abbrev': 'WV',
'sname': 'Black Bears',
'lname': 'West Virginia Black Bears',
'season': 13
"id": 303,
"season": 13,
"rank": 1,
"team": {
"id": 548,
"abbrev": "WV",
"sname": "Black Bears",
"lname": "West Virginia Black Bears",
"season": 13,
},
'player': {
'id': 12843,
'name': 'George Springer',
'wara': 0.31,
'image': 'https://example.com/springer.png',
'season': 13,
'pos_1': 'CF',
"player": {
"id": 12843,
"name": "George Springer",
"wara": 0.31,
"image": "https://example.com/springer.png",
"season": 13,
"pos_1": "CF",
# Note: NO flat team_id here - it's nested in 'team' below
'team': {
'id': 547, # Free Agent team
'abbrev': 'FA',
'sname': 'Free Agents',
'lname': 'Free Agents',
'season': 13
}
}
"team": {
"id": 547, # Free Agent team
"abbrev": "FA",
"sname": "Free Agents",
"lname": "Free Agents",
"season": 13,
},
},
}
# Create DraftList using from_api_data (what BaseService calls)
@ -522,87 +562,94 @@ class TestDraftListModel:
assert draft_entry.player is not None
# CRITICAL: player.team_id must be extracted from nested team object
assert draft_entry.player.team_id == 547, \
assert draft_entry.player.team_id == 547, (
f"player.team_id should be 547 (FA), got {draft_entry.player.team_id}"
)
# Verify the nested team object is also populated
assert draft_entry.player.team is not None
assert draft_entry.player.team.id == 547
assert draft_entry.player.team.abbrev == 'FA'
assert draft_entry.player.team.abbrev == "FA"
# Verify DraftList's own team data
assert draft_entry.team.id == 548
assert draft_entry.team.abbrev == 'WV'
assert draft_entry.team.abbrev == "WV"
assert draft_entry.team_id == 548 # Property from nested team
class TestModelCoverageExtras:
"""Additional model coverage tests."""
def test_base_model_from_api_data_validation(self):
"""Test from_api_data with various edge cases."""
from models.base import SBABaseModel
# Test with empty data raises ValueError
with pytest.raises(ValueError, match="Cannot create SBABaseModel from empty data"):
with pytest.raises(
ValueError, match="Cannot create SBABaseModel from empty data"
):
SBABaseModel.from_api_data({})
# Test with None raises ValueError
with pytest.raises(ValueError, match="Cannot create SBABaseModel from empty data"):
with pytest.raises(
ValueError, match="Cannot create SBABaseModel from empty data"
):
SBABaseModel.from_api_data(None)
def test_player_positions_comprehensive(self):
"""Test player positions property with all position variations."""
player_data = {
'id': 201,
'name': 'Multi-Position Player',
'wara': 3.0,
'season': 12,
'team_id': 5,
'image': 'https://example.com/player.jpg',
'pos_1': 'C',
'pos_2': '1B',
'pos_3': '3B',
'pos_4': None, # Test None handling
'pos_5': 'DH',
'pos_6': 'OF',
'pos_7': None, # Another None
'pos_8': 'SS'
"id": 201,
"name": "Multi-Position Player",
"wara": 3.0,
"season": 12,
"team_id": 5,
"image": "https://example.com/player.jpg",
"pos_1": "C",
"pos_2": "1B",
"pos_3": "3B",
"pos_4": None, # Test None handling
"pos_5": "DH",
"pos_6": "OF",
"pos_7": None, # Another None
"pos_8": "SS",
}
player = Player.from_api_data(player_data)
positions = player.positions
assert 'C' in positions
assert '1B' in positions
assert '3B' in positions
assert 'DH' in positions
assert 'OF' in positions
assert 'SS' in positions
assert "C" in positions
assert "1B" in positions
assert "3B" in positions
assert "DH" in positions
assert "OF" in positions
assert "SS" in positions
assert len(positions) == 6 # Should exclude None values
assert None not in positions
def test_player_is_pitcher_variations(self):
"""Test is_pitcher property with different positions."""
test_cases = [
('SP', True), # Starting pitcher
('RP', True), # Relief pitcher
('P', True), # Generic pitcher
('C', False), # Catcher
('1B', False), # First base
('OF', False), # Outfield
('DH', False), # Designated hitter
("SP", True), # Starting pitcher
("RP", True), # Relief pitcher
("P", True), # Generic pitcher
("C", False), # Catcher
("1B", False), # First base
("OF", False), # Outfield
("DH", False), # Designated hitter
]
for position, expected in test_cases:
player_data = {
'id': 300 + ord(position[0]), # Generate unique IDs based on position
'name': f'Test {position}',
'wara': 2.0,
'season': 12,
'team_id': 5,
'image': 'https://example.com/player.jpg',
'pos_1': position,
"id": 300 + ord(position[0]), # Generate unique IDs based on position
"name": f"Test {position}",
"wara": 2.0,
"season": 12,
"team_id": 5,
"image": "https://example.com/player.jpg",
"pos_1": position,
}
player = Player.from_api_data(player_data)
assert player.is_pitcher == expected, f"Position {position} should return {expected}"
assert player.primary_position == position
assert player.is_pitcher == expected, (
f"Position {position} should return {expected}"
)
assert player.primary_position == position

View File

@ -3,6 +3,7 @@ Tests for Help Commands Service in Discord Bot v2.0
Comprehensive tests for help commands CRUD operations and business logic.
"""
import pytest
from datetime import datetime, timezone
from unittest.mock import AsyncMock
@ -10,13 +11,13 @@ from unittest.mock import AsyncMock
from services.help_commands_service import (
HelpCommandsService,
HelpCommandNotFoundError,
HelpCommandExistsError
HelpCommandExistsError,
)
from models.help_command import (
HelpCommand,
HelpCommandSearchFilters,
HelpCommandSearchResult,
HelpCommandStats
HelpCommandStats,
)
@ -26,17 +27,17 @@ def sample_help_command() -> HelpCommand:
now = datetime.now(timezone.utc)
return HelpCommand(
id=1,
name='trading-rules',
title='Trading Rules & Guidelines',
content='Complete trading rules for the league...',
category='rules',
created_by_discord_id='123456789',
name="trading-rules",
title="Trading Rules & Guidelines",
content="Complete trading rules for the league...",
category="rules",
created_by_discord_id="123456789",
created_at=now,
updated_at=None,
last_modified_by=None,
is_active=True,
view_count=100,
display_order=10
display_order=10,
)
@ -64,6 +65,7 @@ class TestHelpCommandsServiceInit:
# Multiple imports should return the same instance
from services.help_commands_service import help_commands_service as service2
assert help_commands_service is service2
def test_service_has_required_methods(self):
@ -71,22 +73,22 @@ class TestHelpCommandsServiceInit:
from services.help_commands_service import help_commands_service
# Core CRUD operations
assert hasattr(help_commands_service, 'create_help')
assert hasattr(help_commands_service, 'get_help_by_name')
assert hasattr(help_commands_service, 'update_help')
assert hasattr(help_commands_service, 'delete_help')
assert hasattr(help_commands_service, 'restore_help')
assert hasattr(help_commands_service, "create_help")
assert hasattr(help_commands_service, "get_help_by_name")
assert hasattr(help_commands_service, "update_help")
assert hasattr(help_commands_service, "delete_help")
assert hasattr(help_commands_service, "restore_help")
# Search and listing
assert hasattr(help_commands_service, 'search_help_commands')
assert hasattr(help_commands_service, 'get_all_help_topics')
assert hasattr(help_commands_service, 'get_help_names_for_autocomplete')
assert hasattr(help_commands_service, "search_help_commands")
assert hasattr(help_commands_service, "get_all_help_topics")
assert hasattr(help_commands_service, "get_help_names_for_autocomplete")
# View tracking
assert hasattr(help_commands_service, 'increment_view_count')
assert hasattr(help_commands_service, "increment_view_count")
# Statistics
assert hasattr(help_commands_service, 'get_statistics')
assert hasattr(help_commands_service, "get_statistics")
class TestHelpCommandsServiceCRUD:
@ -118,7 +120,7 @@ class TestHelpCommandsServiceCRUD:
last_modified_by=None,
is_active=True,
view_count=0,
display_order=data.get("display_order", 0)
display_order=data.get("display_order", 0),
)
return created_help
@ -130,8 +132,8 @@ class TestHelpCommandsServiceCRUD:
name="test-topic",
title="Test Topic",
content="This is test content for the help topic.",
creator_discord_id='123456789',
category="info"
creator_discord_id="123456789",
category="info",
)
assert isinstance(result, HelpCommand)
@ -141,39 +143,48 @@ class TestHelpCommandsServiceCRUD:
assert result.view_count == 0
@pytest.mark.asyncio
async def test_create_help_already_exists(self, help_commands_service_instance, sample_help_command):
async def test_create_help_already_exists(
self, help_commands_service_instance, sample_help_command
):
"""Test help command creation when topic already exists."""
# Mock topic already exists
async def mock_get_help_by_name(*args, **kwargs):
return sample_help_command
help_commands_service_instance.get_help_by_name = mock_get_help_by_name
with pytest.raises(HelpCommandExistsError, match="Help topic 'trading-rules' already exists"):
with pytest.raises(
HelpCommandExistsError, match="Help topic 'trading-rules' already exists"
):
await help_commands_service_instance.create_help(
name="trading-rules",
title="Trading Rules",
content="Rules content",
creator_discord_id='123456789'
creator_discord_id="123456789",
)
@pytest.mark.asyncio
async def test_get_help_by_name_success(self, help_commands_service_instance, sample_help_command):
async def test_get_help_by_name_success(
self, help_commands_service_instance, sample_help_command
):
"""Test successful help command retrieval."""
# Mock the API client to return proper data structure
help_data = {
'id': sample_help_command.id,
'name': sample_help_command.name,
'title': sample_help_command.title,
'content': sample_help_command.content,
'category': sample_help_command.category,
'created_by_discord_id': sample_help_command.created_by_discord_id,
'created_at': sample_help_command.created_at.isoformat(),
'updated_at': sample_help_command.updated_at.isoformat() if sample_help_command.updated_at else None,
'last_modified_by': sample_help_command.last_modified_by,
'is_active': sample_help_command.is_active,
'view_count': sample_help_command.view_count,
'display_order': sample_help_command.display_order
"id": sample_help_command.id,
"name": sample_help_command.name,
"title": sample_help_command.title,
"content": sample_help_command.content,
"category": sample_help_command.category,
"created_by_discord_id": sample_help_command.created_by_discord_id,
"created_at": sample_help_command.created_at.isoformat(),
"updated_at": sample_help_command.updated_at.isoformat()
if sample_help_command.updated_at
else None,
"last_modified_by": sample_help_command.last_modified_by,
"is_active": sample_help_command.is_active,
"view_count": sample_help_command.view_count,
"display_order": sample_help_command.display_order,
}
help_commands_service_instance._client.get.return_value = help_data
@ -191,66 +202,61 @@ class TestHelpCommandsServiceCRUD:
# Mock the API client to return None (not found)
help_commands_service_instance._client.get.return_value = None
with pytest.raises(HelpCommandNotFoundError, match="Help topic 'nonexistent' not found"):
with pytest.raises(
HelpCommandNotFoundError, match="Help topic 'nonexistent' not found"
):
await help_commands_service_instance.get_help_by_name("nonexistent")
@pytest.mark.asyncio
async def test_update_help_success(self, help_commands_service_instance, sample_help_command):
async def test_update_help_success(
self, help_commands_service_instance, sample_help_command
):
"""Test successful help command update."""
# Mock getting the existing help command
async def mock_get_help_by_name(name, include_inactive=False):
if name == "trading-rules":
return sample_help_command
raise HelpCommandNotFoundError(f"Help topic '{name}' not found")
# Mock the API update call
# Mock the API update call returning the updated help command data directly
updated_data = {
"id": sample_help_command.id,
"name": sample_help_command.name,
"title": "Updated Trading Rules",
"content": "Updated content",
"category": sample_help_command.category,
"created_by_discord_id": sample_help_command.created_by_discord_id,
"created_at": sample_help_command.created_at.isoformat(),
"updated_at": datetime.now(timezone.utc).isoformat(),
"last_modified_by": "987654321",
"is_active": sample_help_command.is_active,
"view_count": sample_help_command.view_count,
"display_order": sample_help_command.display_order,
}
async def mock_put(*args, **kwargs):
return True
return updated_data
help_commands_service_instance.get_help_by_name = mock_get_help_by_name
help_commands_service_instance._client.put = mock_put
# Update should call get_help_by_name again at the end, so mock it to return updated version
updated_help = HelpCommand(
id=sample_help_command.id,
name=sample_help_command.name,
title="Updated Trading Rules",
content="Updated content",
category=sample_help_command.category,
created_by_discord_id=sample_help_command.created_by_discord_id,
created_at=sample_help_command.created_at,
updated_at=datetime.now(timezone.utc),
last_modified_by='987654321',
is_active=sample_help_command.is_active,
view_count=sample_help_command.view_count,
display_order=sample_help_command.display_order
)
call_count = 0
async def mock_get_with_counter(name, include_inactive=False):
nonlocal call_count
call_count += 1
if call_count == 1:
return sample_help_command
else:
return updated_help
help_commands_service_instance.get_help_by_name = mock_get_with_counter
result = await help_commands_service_instance.update_help(
name="trading-rules",
new_title="Updated Trading Rules",
new_content="Updated content",
updater_discord_id='987654321'
updater_discord_id="987654321",
)
assert isinstance(result, HelpCommand)
assert result.title == "Updated Trading Rules"
@pytest.mark.asyncio
async def test_delete_help_success(self, help_commands_service_instance, sample_help_command):
async def test_delete_help_success(
self, help_commands_service_instance, sample_help_command
):
"""Test successful help command deletion (soft delete)."""
# Mock getting the help command
async def mock_get_help_by_name(name, include_inactive=False):
return sample_help_command
@ -272,12 +278,12 @@ class TestHelpCommandsServiceCRUD:
# Mock getting a deleted help command
deleted_help = HelpCommand(
id=1,
name='deleted-topic',
title='Deleted Topic',
content='Content',
created_by_discord_id='123456789',
name="deleted-topic",
title="Deleted Topic",
content="Content",
created_by_discord_id="123456789",
created_at=datetime.now(timezone.utc),
is_active=False
is_active=False,
)
async def mock_get_help_by_name(name, include_inactive=False):
@ -285,15 +291,15 @@ class TestHelpCommandsServiceCRUD:
# Mock the API restore call
restored_data = {
'id': deleted_help.id,
'name': deleted_help.name,
'title': deleted_help.title,
'content': deleted_help.content,
'created_by_discord_id': deleted_help.created_by_discord_id,
'created_at': deleted_help.created_at.isoformat(),
'is_active': True,
'view_count': 0,
'display_order': 0
"id": deleted_help.id,
"name": deleted_help.name,
"title": deleted_help.title,
"content": deleted_help.content,
"created_by_discord_id": deleted_help.created_by_discord_id,
"created_at": deleted_help.created_at.isoformat(),
"is_active": True,
"view_count": 0,
"display_order": 0,
}
help_commands_service_instance.get_help_by_name = mock_get_help_by_name
@ -312,33 +318,30 @@ class TestHelpCommandsServiceSearch:
async def test_search_help_commands(self, help_commands_service_instance):
"""Test searching for help commands with filters."""
filters = HelpCommandSearchFilters(
name_contains='trading',
category='rules',
page=1,
page_size=10
name_contains="trading", category="rules", page=1, page_size=10
)
# Mock API response
api_response = {
'help_commands': [
"help_commands": [
{
'id': 1,
'name': 'trading-rules',
'title': 'Trading Rules',
'content': 'Content',
'category': 'rules',
'created_by_discord_id': '123',
'created_at': datetime.now(timezone.utc).isoformat(),
'is_active': True,
'view_count': 100,
'display_order': 0
"id": 1,
"name": "trading-rules",
"title": "Trading Rules",
"content": "Content",
"category": "rules",
"created_by_discord_id": "123",
"created_at": datetime.now(timezone.utc).isoformat(),
"is_active": True,
"view_count": 100,
"display_order": 0,
}
],
'total_count': 1,
'page': 1,
'page_size': 10,
'total_pages': 1,
'has_more': False
"total_count": 1,
"page": 1,
"page_size": 10,
"total_pages": 1,
"has_more": False,
}
help_commands_service_instance._client.get.return_value = api_response
@ -348,33 +351,33 @@ class TestHelpCommandsServiceSearch:
assert isinstance(result, HelpCommandSearchResult)
assert len(result.help_commands) == 1
assert result.total_count == 1
assert result.help_commands[0].name == 'trading-rules'
assert result.help_commands[0].name == "trading-rules"
@pytest.mark.asyncio
async def test_get_all_help_topics(self, help_commands_service_instance):
"""Test getting all help topics."""
# Mock API response
api_response = {
'help_commands': [
"help_commands": [
{
'id': i,
'name': f'topic-{i}',
'title': f'Topic {i}',
'content': f'Content {i}',
'category': 'rules' if i % 2 == 0 else 'guides',
'created_by_discord_id': '123',
'created_at': datetime.now(timezone.utc).isoformat(),
'is_active': True,
'view_count': i * 10,
'display_order': i
"id": i,
"name": f"topic-{i}",
"title": f"Topic {i}",
"content": f"Content {i}",
"category": "rules" if i % 2 == 0 else "guides",
"created_by_discord_id": "123",
"created_at": datetime.now(timezone.utc).isoformat(),
"is_active": True,
"view_count": i * 10,
"display_order": i,
}
for i in range(1, 6)
],
'total_count': 5,
'page': 1,
'page_size': 100,
'total_pages': 1,
'has_more': False
"total_count": 5,
"page": 1,
"page_size": 100,
"total_pages": 1,
"has_more": False,
}
help_commands_service_instance._client.get.return_value = api_response
@ -386,42 +389,45 @@ class TestHelpCommandsServiceSearch:
assert all(isinstance(cmd, HelpCommand) for cmd in result)
@pytest.mark.asyncio
async def test_get_help_names_for_autocomplete(self, help_commands_service_instance):
async def test_get_help_names_for_autocomplete(
self, help_commands_service_instance
):
"""Test getting help names for autocomplete."""
# Mock API response
api_response = {
'results': [
"results": [
{
'name': 'trading-rules',
'title': 'Trading Rules',
'category': 'rules'
"name": "trading-rules",
"title": "Trading Rules",
"category": "rules",
},
{
'name': 'trading-deadline',
'title': 'Trading Deadline',
'category': 'info'
}
"name": "trading-deadline",
"title": "Trading Deadline",
"category": "info",
},
]
}
help_commands_service_instance._client.get.return_value = api_response
result = await help_commands_service_instance.get_help_names_for_autocomplete(
partial_name='trading',
limit=25
partial_name="trading", limit=25
)
assert isinstance(result, list)
assert len(result) == 2
assert 'trading-rules' in result
assert 'trading-deadline' in result
assert "trading-rules" in result
assert "trading-deadline" in result
class TestHelpCommandsServiceViewTracking:
"""Test view count tracking."""
@pytest.mark.asyncio
async def test_increment_view_count(self, help_commands_service_instance, sample_help_command):
async def test_increment_view_count(
self, help_commands_service_instance, sample_help_command
):
"""Test incrementing view count."""
# Mock the API patch call
help_commands_service_instance._client.patch = AsyncMock()
@ -437,7 +443,7 @@ class TestHelpCommandsServiceViewTracking:
created_at=sample_help_command.created_at,
is_active=sample_help_command.is_active,
view_count=sample_help_command.view_count + 1,
display_order=sample_help_command.display_order
display_order=sample_help_command.display_order,
)
async def mock_get_help_by_name(name, include_inactive=False):
@ -445,7 +451,9 @@ class TestHelpCommandsServiceViewTracking:
help_commands_service_instance.get_help_by_name = mock_get_help_by_name
result = await help_commands_service_instance.increment_view_count("trading-rules")
result = await help_commands_service_instance.increment_view_count(
"trading-rules"
)
assert isinstance(result, HelpCommand)
assert result.view_count == 101
@ -459,21 +467,21 @@ class TestHelpCommandsServiceStatistics:
"""Test getting help command statistics."""
# Mock API response
api_response = {
'total_commands': 50,
'active_commands': 45,
'total_views': 5000,
'most_viewed_command': {
'id': 1,
'name': 'popular-topic',
'title': 'Popular Topic',
'content': 'Content',
'created_by_discord_id': '123',
'created_at': datetime.now(timezone.utc).isoformat(),
'is_active': True,
'view_count': 500,
'display_order': 0
"total_commands": 50,
"active_commands": 45,
"total_views": 5000,
"most_viewed_command": {
"id": 1,
"name": "popular-topic",
"title": "Popular Topic",
"content": "Content",
"created_by_discord_id": "123",
"created_at": datetime.now(timezone.utc).isoformat(),
"is_active": True,
"view_count": 500,
"display_order": 0,
},
'recent_commands_count': 5
"recent_commands_count": 5,
}
help_commands_service_instance._client.get.return_value = api_response
@ -485,7 +493,7 @@ class TestHelpCommandsServiceStatistics:
assert result.active_commands == 45
assert result.total_views == 5000
assert result.most_viewed_command is not None
assert result.most_viewed_command.name == 'popular-topic'
assert result.most_viewed_command.name == "popular-topic"
assert result.recent_commands_count == 5
@ -498,7 +506,9 @@ class TestHelpCommandsServiceErrorHandling:
from exceptions import APIException, BotException
# Mock the API client to raise an APIException
help_commands_service_instance._client.get.side_effect = APIException("Connection error")
help_commands_service_instance._client.get.side_effect = APIException(
"Connection error"
)
with pytest.raises(BotException, match="Failed to retrieve help topic 'test'"):
await help_commands_service_instance.get_help_by_name("test")

View File

@ -124,6 +124,22 @@ class TradeEmbedView(discord.ui.View):
self, interaction: discord.Interaction, button: discord.ui.Button
):
"""Handle submit trade button click."""
# Check trade deadline
current = await league_service.get_current_state()
if not current:
await interaction.response.send_message(
"❌ Could not retrieve league state. Please try again later.",
ephemeral=True,
)
return
if current.is_past_trade_deadline:
await interaction.response.send_message(
f"❌ **The trade deadline has passed** (Week {current.trade_deadline}). "
f"This trade can no longer be submitted.",
ephemeral=True,
)
return
if self.builder.is_empty:
await interaction.response.send_message(
"Cannot submit empty trade. Add some moves first!", ephemeral=True
@ -435,7 +451,16 @@ class TradeAcceptanceView(discord.ui.View):
config = get_config()
current = await league_service.get_current_state()
next_week = current.week + 1 if current else 1
if not current or current.is_past_trade_deadline:
deadline_msg = (
f"❌ **The trade deadline has passed** (Week {current.trade_deadline}). "
f"This trade cannot be finalized."
if current
else "❌ Could not retrieve league state. Please try again later."
)
await interaction.followup.send(deadline_msg, ephemeral=True)
return
next_week = current.week + 1
fa_team = Team(
id=config.free_agent_team_id,