Compare commits

...

9 Commits

Author SHA1 Message Date
cal
c8cb80c5f3 Merge pull request 'feat: enforce FA lock deadline after week 14' (#122) from feature/fa-lock-enforcement into main
All checks were successful
Build Docker Image / build (push) Successful in 1m22s
Reviewed-on: #122
2026-03-31 21:20:41 +00:00
Cal Corum
6016afb999 feat: enforce FA lock deadline — block signing FA players after week 14
The fa_lock_week config existed but was never enforced. Now /dropadd blocks
adding players FROM Free Agency when current_week >= fa_lock_week (14).
Dropping players TO FA remains allowed after the deadline.

Also consolidates two league_service.get_current_state() calls into one
shared fetch at the top of add_move() to eliminate a redundant API round-trip.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 16:07:28 -05:00
cal
f95c857363 Merge pull request 'perf: eliminate redundant API calls in trade views (#94)' (#116) from ai/major-domo-v2#94 into main
Reviewed-on: #116
2026-03-31 19:54:18 +00:00
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
7 changed files with 482 additions and 344 deletions

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. Modern async service layer for managing custom commands with full type safety.
""" """
import asyncio
import math import math
from datetime import UTC, datetime, timedelta from datetime import UTC, datetime, timedelta
from typing import Optional, List, Any, Tuple from typing import Optional, List, Any, Tuple
@ -119,8 +120,8 @@ class CustomCommandsService(BaseService[CustomCommand]):
content_length=len(content), content_length=len(content),
) )
# Return full command with creator info # Return command with creator info (use POST response directly)
return await self.get_command_by_name(name) return result.model_copy(update={"creator": creator})
async def get_command_by_name(self, name: str) -> CustomCommand: async def get_command_by_name(self, name: str) -> CustomCommand:
""" """
@ -217,7 +218,8 @@ class CustomCommandsService(BaseService[CustomCommand]):
new_content_length=len(new_content), 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( async def delete_command(
self, name: str, deleter_discord_id: int, force: bool = False 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) 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 = [] commands = []
for cmd_data in commands_data: for cmd_data, creator in zip(commands_data, creators):
try: if isinstance(creator, BotException):
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
self.logger.warning( self.logger.warning(
"Skipping popular command with missing creator", "Skipping popular command with missing creator",
command_id=cmd_data.id, command_id=cmd_data.id,
command_name=cmd_data.name, command_name=cmd_data.name,
creator_id=cmd_data.creator_id, creator_id=cmd_data.creator_id,
error=str(e), error=str(creator),
) )
continue continue
if isinstance(creator, BaseException):
raise creator
commands.append(CustomCommand(**cmd_data.model_dump(), creator=creator))
return commands return commands
@ -536,7 +545,9 @@ class CustomCommandsService(BaseService[CustomCommand]):
# Update username if it changed # Update username if it changed
if creator.username != username or creator.display_name != display_name: if creator.username != username or creator.display_name != display_name:
await self._update_creator_info(creator.id, username, 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 return creator
except BotException: except BotException:
# Creator doesn't exist, create new one # Creator doesn't exist, create new one
@ -557,7 +568,8 @@ class CustomCommandsService(BaseService[CustomCommand]):
if not result: if not result:
raise BotException("Failed to create command creator") 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: async def get_creator_by_discord_id(self, discord_id: int) -> CustomCommandCreator:
"""Get creator by Discord ID. """Get creator by Discord ID.
@ -610,31 +622,34 @@ class CustomCommandsService(BaseService[CustomCommand]):
async def get_statistics(self) -> CustomCommandStats: async def get_statistics(self) -> CustomCommandStats:
"""Get comprehensive statistics about custom commands.""" """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) 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 total_uses = sum(cmd.use_count for cmd in all_commands)
warning_count = await self._get_commands_needing_warning_count() most_popular = popular_commands[0] if popular_commands else None
deletion_count = await self._get_commands_eligible_for_deletion_count()
return CustomCommandStats( return CustomCommandStats(
total_commands=total_commands, total_commands=total_commands,
@ -662,21 +677,28 @@ class CustomCommandsService(BaseService[CustomCommand]):
commands_data = await self.get_items_with_params(params) 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 = [] commands = []
for cmd_data in commands_data: for cmd_data, creator in zip(commands_data, creators):
try: if isinstance(creator, BotException):
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
self.logger.warning( self.logger.warning(
"Skipping command with missing creator", "Skipping command with missing creator",
command_id=cmd_data.id, command_id=cmd_data.id,
command_name=cmd_data.name, command_name=cmd_data.name,
creator_id=cmd_data.creator_id, creator_id=cmd_data.creator_id,
error=str(e), error=str(creator),
) )
continue continue
if isinstance(creator, BaseException):
raise creator
commands.append(CustomCommand(**cmd_data.model_dump(), creator=creator))
return commands return commands
@ -688,21 +710,28 @@ class CustomCommandsService(BaseService[CustomCommand]):
commands_data = await self.get_items_with_params(params) 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 = [] commands = []
for cmd_data in commands_data: for cmd_data, creator in zip(commands_data, creators):
try: if isinstance(creator, BotException):
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
self.logger.warning( self.logger.warning(
"Skipping command with missing creator", "Skipping command with missing creator",
command_id=cmd_data.id, command_id=cmd_data.id,
command_name=cmd_data.name, command_name=cmd_data.name,
creator_id=cmd_data.creator_id, creator_id=cmd_data.creator_id,
error=str(e), error=str(creator),
) )
continue continue
if isinstance(creator, BaseException):
raise creator
commands.append(CustomCommand(**cmd_data.model_dump(), creator=creator))
return commands return commands

View File

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

View File

@ -277,6 +277,35 @@ class TransactionBuilder:
Returns: Returns:
Tuple of (success: bool, error_message: str). If success is True, error_message is empty. Tuple of (success: bool, error_message: str). If success is True, error_message is empty.
""" """
# Fetch current state once if needed by FA lock or pending-transaction check
is_fa_pickup = (
move.from_roster == RosterType.FREE_AGENCY
and move.to_roster != RosterType.FREE_AGENCY
)
needs_current_state = is_fa_pickup or (
check_pending_transactions and next_week is None
)
current_week: Optional[int] = None
if needs_current_state:
try:
current_state = await league_service.get_current_state()
current_week = current_state.week if current_state else 1
except Exception as e:
logger.warning(f"Could not get current week: {e}")
current_week = 1
# Block adding players FROM Free Agency after the FA lock deadline
if is_fa_pickup and current_week is not None:
config = get_config()
if current_week >= config.fa_lock_week:
error_msg = (
f"Free agency is closed (week {current_week}, deadline was week {config.fa_lock_week}). "
f"Cannot add {move.player.name} from FA."
)
logger.warning(error_msg)
return False, error_msg
# Check if player is already in a move in this transaction builder # Check if player is already in a move in this transaction builder
existing_move = self.get_move_for_player(move.player.id) existing_move = self.get_move_for_player(move.player.id)
if existing_move: if existing_move:
@ -299,23 +328,15 @@ class TransactionBuilder:
return False, error_msg return False, error_msg
# Check if player is already in another team's pending transaction for next week # Check if player is already in another team's pending transaction for next week
# This prevents duplicate claims that would need to be resolved at freeze time
# Only applies to /dropadd (scheduled moves), not /ilmove (immediate moves)
if check_pending_transactions: if check_pending_transactions:
if next_week is None: if next_week is None:
try: next_week = (current_week + 1) if current_week else 1
current_state = await league_service.get_current_state()
next_week = (current_state.week + 1) if current_state else 1
except Exception as e:
logger.warning(
f"Could not get current week for pending transaction check: {e}"
)
next_week = 1
is_pending, claiming_team = ( (
await transaction_service.is_player_in_pending_transaction( is_pending,
player_id=move.player.id, week=next_week, season=self.season claiming_team,
) ) = await transaction_service.is_player_in_pending_transaction(
player_id=move.player.id, week=next_week, season=self.season
) )
if is_pending: if is_pending:

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

View File

@ -115,6 +115,13 @@ class TestTransactionBuilder:
svc.get_current_roster.return_value = mock_roster svc.get_current_roster.return_value = mock_roster
return svc return svc
@pytest.fixture(autouse=True)
def mock_league_service(self):
"""Patch league_service for all tests so FA lock check uses week 10 (before deadline)."""
with patch("services.transaction_builder.league_service") as mock_ls:
mock_ls.get_current_state = AsyncMock(return_value=MagicMock(week=10))
yield mock_ls
@pytest.fixture @pytest.fixture
def builder(self, mock_team, mock_roster_service): def builder(self, mock_team, mock_roster_service):
"""Create a TransactionBuilder for testing with injected roster service.""" """Create a TransactionBuilder for testing with injected roster service."""
@ -152,6 +159,50 @@ class TestTransactionBuilder:
assert builder.is_empty is False assert builder.is_empty is False
assert move in builder.moves assert move in builder.moves
@pytest.mark.asyncio
async def test_add_move_from_fa_blocked_after_deadline(self, builder, mock_player):
"""Test that adding a player FROM Free Agency is blocked after fa_lock_week."""
move = TransactionMove(
player=mock_player,
from_roster=RosterType.FREE_AGENCY,
to_roster=RosterType.MAJOR_LEAGUE,
to_team=builder.team,
)
with patch(
"services.transaction_builder.league_service"
) as mock_league_service:
mock_league_service.get_current_state = AsyncMock(
return_value=MagicMock(week=15)
)
success, error_message = await builder.add_move(
move, check_pending_transactions=False
)
assert success is False
assert "Free agency is closed" in error_message
assert builder.move_count == 0
@pytest.mark.asyncio
async def test_drop_to_fa_allowed_after_deadline(self, builder, mock_player):
"""Test that dropping a player TO Free Agency is still allowed after fa_lock_week."""
move = TransactionMove(
player=mock_player,
from_roster=RosterType.MAJOR_LEAGUE,
to_roster=RosterType.FREE_AGENCY,
from_team=builder.team,
)
# Drop to FA doesn't trigger the FA lock check (autouse fixture provides week 10)
success, error_message = await builder.add_move(
move, check_pending_transactions=False
)
assert success is True
assert error_message == ""
assert builder.move_count == 1
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_add_duplicate_move_fails(self, builder, mock_player): async def test_add_duplicate_move_fails(self, builder, mock_player):
"""Test that adding duplicate moves for same player fails.""" """Test that adding duplicate moves for same player fails."""
@ -809,6 +860,13 @@ class TestPendingTransactionValidation:
"""Create a mock player for testing.""" """Create a mock player for testing."""
return Player(id=12472, name="Test Player", wara=2.5, season=12, pos_1="OF") return Player(id=12472, name="Test Player", wara=2.5, season=12, pos_1="OF")
@pytest.fixture(autouse=True)
def mock_league_service(self):
"""Patch league_service so FA lock check and week resolution use week 10."""
with patch("services.transaction_builder.league_service") as mock_ls:
mock_ls.get_current_state = AsyncMock(return_value=MagicMock(week=10))
yield mock_ls
@pytest.fixture @pytest.fixture
def builder(self, mock_team): def builder(self, mock_team):
"""Create a TransactionBuilder for testing.""" """Create a TransactionBuilder for testing."""

View File

@ -344,6 +344,7 @@ class TradeAcceptanceView(discord.ui.View):
def __init__(self, builder: TradeBuilder): def __init__(self, builder: TradeBuilder):
super().__init__(timeout=3600.0) # 1 hour timeout super().__init__(timeout=3600.0) # 1 hour timeout
self.builder = builder self.builder = builder
self._checked_teams: dict[int, Team] = {}
async def _get_user_team(self, interaction: discord.Interaction) -> Optional[Team]: async def _get_user_team(self, interaction: discord.Interaction) -> Optional[Team]:
"""Get the team owned by the interacting user.""" """Get the team owned by the interacting user."""
@ -369,6 +370,7 @@ class TradeAcceptanceView(discord.ui.View):
) )
return False return False
self._checked_teams[interaction.user.id] = user_team
return True return True
async def on_timeout(self) -> None: async def on_timeout(self) -> None:
@ -382,7 +384,7 @@ class TradeAcceptanceView(discord.ui.View):
self, interaction: discord.Interaction, button: discord.ui.Button self, interaction: discord.Interaction, button: discord.ui.Button
): ):
"""Handle accept button click.""" """Handle accept button click."""
user_team = await self._get_user_team(interaction) user_team = self._checked_teams.get(interaction.user.id)
if not user_team: if not user_team:
return return
@ -417,7 +419,7 @@ class TradeAcceptanceView(discord.ui.View):
self, interaction: discord.Interaction, button: discord.ui.Button self, interaction: discord.Interaction, button: discord.ui.Button
): ):
"""Handle reject button click - moves trade back to DRAFT.""" """Handle reject button click - moves trade back to DRAFT."""
user_team = await self._get_user_team(interaction) user_team = self._checked_teams.get(interaction.user.id)
if not user_team: if not user_team:
return return
@ -733,10 +735,10 @@ async def create_trade_embed(builder: TradeBuilder) -> discord.Embed:
Returns: Returns:
Discord embed with current trade state Discord embed with current trade state
""" """
validation = await builder.validate_trade()
if builder.is_empty: if builder.is_empty:
color = EmbedColors.SECONDARY color = EmbedColors.SECONDARY
else: else:
validation = await builder.validate_trade()
color = EmbedColors.SUCCESS if validation.is_legal else EmbedColors.WARNING color = EmbedColors.SUCCESS if validation.is_legal else EmbedColors.WARNING
embed = EmbedTemplate.create_base_embed( embed = EmbedTemplate.create_base_embed(
@ -791,7 +793,6 @@ async def create_trade_embed(builder: TradeBuilder) -> discord.Embed:
inline=False, inline=False,
) )
validation = await builder.validate_trade()
if validation.is_legal: if validation.is_legal:
status_text = "Trade appears legal" status_text = "Trade appears legal"
else: else: