perf: cache user team lookup in player_autocomplete, reduce limit to 25
All checks were successful
Build Docker Image / build (pull_request) Successful in 1m16s

Closes #99

- Add module-level `_user_team_cache` dict with 60-second TTL so
  `get_user_major_league_team` is called at most once per minute per
  user instead of on every keystroke.
- Reduce `search_players(limit=50)` to `limit=25` to match Discord's
  25-choice display cap and avoid fetching unused results.
- Add `TestGetCachedUserTeam` class covering cache hit, TTL expiry, and
  None caching; add `clear_user_team_cache` autouse fixture to prevent
  test interference via module-level state.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Cal Corum 2026-03-20 08:34:32 -05:00
parent 6c49233392
commit c8ed4dee38
2 changed files with 216 additions and 85 deletions

View File

@ -3,10 +3,16 @@ Tests for shared autocomplete utility functions.
Validates the shared autocomplete functions used across multiple command modules. Validates the shared autocomplete functions used across multiple command modules.
""" """
import pytest import pytest
from unittest.mock import AsyncMock, MagicMock, patch from unittest.mock import AsyncMock, MagicMock, patch
from utils.autocomplete import player_autocomplete, team_autocomplete, major_league_team_autocomplete import utils.autocomplete
from utils.autocomplete import (
player_autocomplete,
team_autocomplete,
major_league_team_autocomplete,
)
from tests.factories import PlayerFactory, TeamFactory from tests.factories import PlayerFactory, TeamFactory
from models.team import RosterType from models.team import RosterType
@ -14,6 +20,13 @@ from models.team import RosterType
class TestPlayerAutocomplete: class TestPlayerAutocomplete:
"""Test player autocomplete functionality.""" """Test player autocomplete functionality."""
@pytest.fixture(autouse=True)
def clear_user_team_cache(self):
"""Clear the module-level user team cache before each test to prevent interference."""
utils.autocomplete._user_team_cache.clear()
yield
utils.autocomplete._user_team_cache.clear()
@pytest.fixture @pytest.fixture
def mock_interaction(self): def mock_interaction(self):
"""Create a mock Discord interaction.""" """Create a mock Discord interaction."""
@ -26,41 +39,43 @@ class TestPlayerAutocomplete:
"""Test successful player autocomplete.""" """Test successful player autocomplete."""
mock_players = [ mock_players = [
PlayerFactory.mike_trout(id=1), PlayerFactory.mike_trout(id=1),
PlayerFactory.ronald_acuna(id=2) PlayerFactory.ronald_acuna(id=2),
] ]
with patch('utils.autocomplete.player_service') as mock_service: with patch("utils.autocomplete.player_service") as mock_service:
mock_service.search_players = AsyncMock(return_value=mock_players) mock_service.search_players = AsyncMock(return_value=mock_players)
choices = await player_autocomplete(mock_interaction, 'Trout') choices = await player_autocomplete(mock_interaction, "Trout")
assert len(choices) == 2 assert len(choices) == 2
assert choices[0].name == 'Mike Trout (CF)' assert choices[0].name == "Mike Trout (CF)"
assert choices[0].value == 'Mike Trout' assert choices[0].value == "Mike Trout"
assert choices[1].name == 'Ronald Acuna Jr. (OF)' assert choices[1].name == "Ronald Acuna Jr. (OF)"
assert choices[1].value == 'Ronald Acuna Jr.' assert choices[1].value == "Ronald Acuna Jr."
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_player_autocomplete_with_team_info(self, mock_interaction): async def test_player_autocomplete_with_team_info(self, mock_interaction):
"""Test player autocomplete with team information.""" """Test player autocomplete with team information."""
mock_team = TeamFactory.create(id=499, abbrev='LAA', sname='Angels', lname='Los Angeles Angels') mock_team = TeamFactory.create(
id=499, abbrev="LAA", sname="Angels", lname="Los Angeles Angels"
)
mock_player = PlayerFactory.mike_trout(id=1) mock_player = PlayerFactory.mike_trout(id=1)
mock_player.team = mock_team mock_player.team = mock_team
with patch('utils.autocomplete.player_service') as mock_service: with patch("utils.autocomplete.player_service") as mock_service:
mock_service.search_players = AsyncMock(return_value=[mock_player]) mock_service.search_players = AsyncMock(return_value=[mock_player])
choices = await player_autocomplete(mock_interaction, 'Trout') choices = await player_autocomplete(mock_interaction, "Trout")
assert len(choices) == 1 assert len(choices) == 1
assert choices[0].name == 'Mike Trout (CF - LAA)' assert choices[0].name == "Mike Trout (CF - LAA)"
assert choices[0].value == 'Mike Trout' assert choices[0].value == "Mike Trout"
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_player_autocomplete_prioritizes_user_team(self, mock_interaction): async def test_player_autocomplete_prioritizes_user_team(self, mock_interaction):
"""Test that user's team players are prioritized in autocomplete.""" """Test that user's team players are prioritized in autocomplete."""
user_team = TeamFactory.create(id=1, abbrev='POR', sname='Loggers') user_team = TeamFactory.create(id=1, abbrev="POR", sname="Loggers")
other_team = TeamFactory.create(id=2, abbrev='LAA', sname='Angels') other_team = TeamFactory.create(id=2, abbrev="LAA", sname="Angels")
# Create players - one from user's team, one from other team # Create players - one from user's team, one from other team
user_player = PlayerFactory.mike_trout(id=1) user_player = PlayerFactory.mike_trout(id=1)
@ -71,32 +86,35 @@ class TestPlayerAutocomplete:
other_player.team = other_team other_player.team = other_team
other_player.team_id = other_team.id other_player.team_id = other_team.id
with patch('utils.autocomplete.player_service') as mock_service, \ with (
patch('utils.autocomplete.get_user_major_league_team') as mock_get_team: patch("utils.autocomplete.player_service") as mock_service,
patch("utils.autocomplete.get_user_major_league_team") as mock_get_team,
mock_service.search_players = AsyncMock(return_value=[other_player, user_player]) ):
mock_service.search_players = AsyncMock(
return_value=[other_player, user_player]
)
mock_get_team.return_value = user_team mock_get_team.return_value = user_team
choices = await player_autocomplete(mock_interaction, 'player') choices = await player_autocomplete(mock_interaction, "player")
assert len(choices) == 2 assert len(choices) == 2
# User's team player should be first # User's team player should be first
assert choices[0].name == 'Mike Trout (CF - POR)' assert choices[0].name == "Mike Trout (CF - POR)"
assert choices[1].name == 'Ronald Acuna Jr. (OF - LAA)' assert choices[1].name == "Ronald Acuna Jr. (OF - LAA)"
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_player_autocomplete_short_input(self, mock_interaction): async def test_player_autocomplete_short_input(self, mock_interaction):
"""Test player autocomplete with short input returns empty.""" """Test player autocomplete with short input returns empty."""
choices = await player_autocomplete(mock_interaction, 'T') choices = await player_autocomplete(mock_interaction, "T")
assert len(choices) == 0 assert len(choices) == 0
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_player_autocomplete_error_handling(self, mock_interaction): async def test_player_autocomplete_error_handling(self, mock_interaction):
"""Test player autocomplete error handling.""" """Test player autocomplete error handling."""
with patch('utils.autocomplete.player_service') as mock_service: with patch("utils.autocomplete.player_service") as mock_service:
mock_service.search_players.side_effect = Exception("API Error") mock_service.search_players.side_effect = Exception("API Error")
choices = await player_autocomplete(mock_interaction, 'Trout') choices = await player_autocomplete(mock_interaction, "Trout")
assert len(choices) == 0 assert len(choices) == 0
@ -114,35 +132,35 @@ class TestTeamAutocomplete:
async def test_team_autocomplete_success(self, mock_interaction): async def test_team_autocomplete_success(self, mock_interaction):
"""Test successful team autocomplete.""" """Test successful team autocomplete."""
mock_teams = [ mock_teams = [
TeamFactory.create(id=1, abbrev='LAA', sname='Angels'), TeamFactory.create(id=1, abbrev="LAA", sname="Angels"),
TeamFactory.create(id=2, abbrev='LAAMIL', sname='Salt Lake Bees'), TeamFactory.create(id=2, abbrev="LAAMIL", sname="Salt Lake Bees"),
TeamFactory.create(id=3, abbrev='LAAAIL', sname='Angels IL'), TeamFactory.create(id=3, abbrev="LAAAIL", sname="Angels IL"),
TeamFactory.create(id=4, abbrev='POR', sname='Loggers') TeamFactory.create(id=4, abbrev="POR", sname="Loggers"),
] ]
with patch('utils.autocomplete.team_service') as mock_service: with patch("utils.autocomplete.team_service") as mock_service:
mock_service.get_teams_by_season = AsyncMock(return_value=mock_teams) mock_service.get_teams_by_season = AsyncMock(return_value=mock_teams)
choices = await team_autocomplete(mock_interaction, 'la') choices = await team_autocomplete(mock_interaction, "la")
assert len(choices) == 3 # All teams with 'la' in abbrev or sname assert len(choices) == 3 # All teams with 'la' in abbrev or sname
assert any('LAA' in choice.name for choice in choices) assert any("LAA" in choice.name for choice in choices)
assert any('LAAMIL' in choice.name for choice in choices) assert any("LAAMIL" in choice.name for choice in choices)
assert any('LAAAIL' in choice.name for choice in choices) assert any("LAAAIL" in choice.name for choice in choices)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_team_autocomplete_short_input(self, mock_interaction): async def test_team_autocomplete_short_input(self, mock_interaction):
"""Test team autocomplete with very short input.""" """Test team autocomplete with very short input."""
choices = await team_autocomplete(mock_interaction, '') choices = await team_autocomplete(mock_interaction, "")
assert len(choices) == 0 assert len(choices) == 0
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_team_autocomplete_error_handling(self, mock_interaction): async def test_team_autocomplete_error_handling(self, mock_interaction):
"""Test team autocomplete error handling.""" """Test team autocomplete error handling."""
with patch('utils.autocomplete.team_service') as mock_service: with patch("utils.autocomplete.team_service") as mock_service:
mock_service.get_teams_by_season.side_effect = Exception("API Error") mock_service.get_teams_by_season.side_effect = Exception("API Error")
choices = await team_autocomplete(mock_interaction, 'LAA') choices = await team_autocomplete(mock_interaction, "LAA")
assert len(choices) == 0 assert len(choices) == 0
@ -157,101 +175,197 @@ class TestMajorLeagueTeamAutocomplete:
return interaction return interaction
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_major_league_team_autocomplete_filters_correctly(self, mock_interaction): async def test_major_league_team_autocomplete_filters_correctly(
self, mock_interaction
):
"""Test that only major league teams are returned.""" """Test that only major league teams are returned."""
# Create teams with different roster types # Create teams with different roster types
mock_teams = [ mock_teams = [
TeamFactory.create(id=1, abbrev='LAA', sname='Angels'), # ML TeamFactory.create(id=1, abbrev="LAA", sname="Angels"), # ML
TeamFactory.create(id=2, abbrev='LAAMIL', sname='Salt Lake Bees'), # MiL TeamFactory.create(id=2, abbrev="LAAMIL", sname="Salt Lake Bees"), # MiL
TeamFactory.create(id=3, abbrev='LAAAIL', sname='Angels IL'), # IL TeamFactory.create(id=3, abbrev="LAAAIL", sname="Angels IL"), # IL
TeamFactory.create(id=4, abbrev='FA', sname='Free Agents'), # FA TeamFactory.create(id=4, abbrev="FA", sname="Free Agents"), # FA
TeamFactory.create(id=5, abbrev='POR', sname='Loggers'), # ML TeamFactory.create(id=5, abbrev="POR", sname="Loggers"), # ML
TeamFactory.create(id=6, abbrev='PORMIL', sname='Portland MiL'), # MiL TeamFactory.create(id=6, abbrev="PORMIL", sname="Portland MiL"), # MiL
] ]
with patch('utils.autocomplete.team_service') as mock_service: with patch("utils.autocomplete.team_service") as mock_service:
mock_service.get_teams_by_season = AsyncMock(return_value=mock_teams) mock_service.get_teams_by_season = AsyncMock(return_value=mock_teams)
choices = await major_league_team_autocomplete(mock_interaction, 'l') choices = await major_league_team_autocomplete(mock_interaction, "l")
# Should only return major league teams that match 'l' (LAA, POR) # Should only return major league teams that match 'l' (LAA, POR)
choice_values = [choice.value for choice in choices] choice_values = [choice.value for choice in choices]
assert 'LAA' in choice_values assert "LAA" in choice_values
assert 'POR' in choice_values assert "POR" in choice_values
assert len(choice_values) == 2 assert len(choice_values) == 2
# Should NOT include MiL, IL, or FA teams # Should NOT include MiL, IL, or FA teams
assert 'LAAMIL' not in choice_values assert "LAAMIL" not in choice_values
assert 'LAAAIL' not in choice_values assert "LAAAIL" not in choice_values
assert 'FA' not in choice_values assert "FA" not in choice_values
assert 'PORMIL' not in choice_values assert "PORMIL" not in choice_values
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_major_league_team_autocomplete_matching(self, mock_interaction): async def test_major_league_team_autocomplete_matching(self, mock_interaction):
"""Test search matching on abbreviation and short name.""" """Test search matching on abbreviation and short name."""
mock_teams = [ mock_teams = [
TeamFactory.create(id=1, abbrev='LAA', sname='Angels'), TeamFactory.create(id=1, abbrev="LAA", sname="Angels"),
TeamFactory.create(id=2, abbrev='LAD', sname='Dodgers'), TeamFactory.create(id=2, abbrev="LAD", sname="Dodgers"),
TeamFactory.create(id=3, abbrev='POR', sname='Loggers'), TeamFactory.create(id=3, abbrev="POR", sname="Loggers"),
TeamFactory.create(id=4, abbrev='BOS', sname='Red Sox'), TeamFactory.create(id=4, abbrev="BOS", sname="Red Sox"),
] ]
with patch('utils.autocomplete.team_service') as mock_service: with patch("utils.autocomplete.team_service") as mock_service:
mock_service.get_teams_by_season = AsyncMock(return_value=mock_teams) mock_service.get_teams_by_season = AsyncMock(return_value=mock_teams)
# Test abbreviation matching # Test abbreviation matching
choices = await major_league_team_autocomplete(mock_interaction, 'la') choices = await major_league_team_autocomplete(mock_interaction, "la")
assert len(choices) == 2 # LAA and LAD assert len(choices) == 2 # LAA and LAD
choice_values = [choice.value for choice in choices] choice_values = [choice.value for choice in choices]
assert 'LAA' in choice_values assert "LAA" in choice_values
assert 'LAD' in choice_values assert "LAD" in choice_values
# Test short name matching # Test short name matching
choices = await major_league_team_autocomplete(mock_interaction, 'red') choices = await major_league_team_autocomplete(mock_interaction, "red")
assert len(choices) == 1 assert len(choices) == 1
assert choices[0].value == 'BOS' assert choices[0].value == "BOS"
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_major_league_team_autocomplete_short_input(self, mock_interaction): async def test_major_league_team_autocomplete_short_input(self, mock_interaction):
"""Test major league team autocomplete with very short input.""" """Test major league team autocomplete with very short input."""
choices = await major_league_team_autocomplete(mock_interaction, '') choices = await major_league_team_autocomplete(mock_interaction, "")
assert len(choices) == 0 assert len(choices) == 0
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_major_league_team_autocomplete_error_handling(self, mock_interaction): async def test_major_league_team_autocomplete_error_handling(
self, mock_interaction
):
"""Test major league team autocomplete error handling.""" """Test major league team autocomplete error handling."""
with patch('utils.autocomplete.team_service') as mock_service: with patch("utils.autocomplete.team_service") as mock_service:
mock_service.get_teams_by_season.side_effect = Exception("API Error") mock_service.get_teams_by_season.side_effect = Exception("API Error")
choices = await major_league_team_autocomplete(mock_interaction, 'LAA') choices = await major_league_team_autocomplete(mock_interaction, "LAA")
assert len(choices) == 0 assert len(choices) == 0
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_major_league_team_autocomplete_roster_type_detection(self, mock_interaction): async def test_major_league_team_autocomplete_roster_type_detection(
self, mock_interaction
):
"""Test that roster type detection works correctly for edge cases.""" """Test that roster type detection works correctly for edge cases."""
# Test edge cases like teams whose abbreviation ends in 'M' + 'IL' # Test edge cases like teams whose abbreviation ends in 'M' + 'IL'
mock_teams = [ mock_teams = [
TeamFactory.create(id=1, abbrev='BHM', sname='Iron'), # ML team ending in 'M' TeamFactory.create(
TeamFactory.create(id=2, abbrev='BHMIL', sname='Iron IL'), # IL team (BHM + IL) id=1, abbrev="BHM", sname="Iron"
TeamFactory.create(id=3, abbrev='NYYMIL', sname='Staten Island RailRiders'), # MiL team (NYY + MIL) ), # ML team ending in 'M'
TeamFactory.create(id=4, abbrev='NYY', sname='Yankees'), # ML team TeamFactory.create(
id=2, abbrev="BHMIL", sname="Iron IL"
), # IL team (BHM + IL)
TeamFactory.create(
id=3, abbrev="NYYMIL", sname="Staten Island RailRiders"
), # MiL team (NYY + MIL)
TeamFactory.create(id=4, abbrev="NYY", sname="Yankees"), # ML team
] ]
with patch('utils.autocomplete.team_service') as mock_service: with patch("utils.autocomplete.team_service") as mock_service:
mock_service.get_teams_by_season = AsyncMock(return_value=mock_teams) mock_service.get_teams_by_season = AsyncMock(return_value=mock_teams)
choices = await major_league_team_autocomplete(mock_interaction, 'b') choices = await major_league_team_autocomplete(mock_interaction, "b")
# Should only return major league teams # Should only return major league teams
choice_values = [choice.value for choice in choices] choice_values = [choice.value for choice in choices]
assert 'BHM' in choice_values # Major league team assert "BHM" in choice_values # Major league team
assert 'BHMIL' not in choice_values # Should be detected as IL, not MiL assert "BHMIL" not in choice_values # Should be detected as IL, not MiL
assert 'NYYMIL' not in choice_values # Minor league team assert "NYYMIL" not in choice_values # Minor league team
# Verify the roster type detection is working # Verify the roster type detection is working
bhm_team = next(t for t in mock_teams if t.abbrev == 'BHM') bhm_team = next(t for t in mock_teams if t.abbrev == "BHM")
bhmil_team = next(t for t in mock_teams if t.abbrev == 'BHMIL') bhmil_team = next(t for t in mock_teams if t.abbrev == "BHMIL")
nyymil_team = next(t for t in mock_teams if t.abbrev == 'NYYMIL') nyymil_team = next(t for t in mock_teams if t.abbrev == "NYYMIL")
assert bhm_team.roster_type() == RosterType.MAJOR_LEAGUE assert bhm_team.roster_type() == RosterType.MAJOR_LEAGUE
assert bhmil_team.roster_type() == RosterType.INJURED_LIST assert bhmil_team.roster_type() == RosterType.INJURED_LIST
assert nyymil_team.roster_type() == RosterType.MINOR_LEAGUE assert nyymil_team.roster_type() == RosterType.MINOR_LEAGUE
class TestGetCachedUserTeam:
"""Test the _get_cached_user_team caching helper.
Verifies that the cache avoids redundant get_user_major_league_team calls
on repeated invocations within the TTL window, and that expired entries are
re-fetched.
"""
@pytest.fixture(autouse=True)
def clear_cache(self):
"""Isolate each test from cache state left by other tests."""
utils.autocomplete._user_team_cache.clear()
yield
utils.autocomplete._user_team_cache.clear()
@pytest.fixture
def mock_interaction(self):
interaction = MagicMock()
interaction.user.id = 99999
return interaction
@pytest.mark.asyncio
async def test_caches_result_on_first_call(self, mock_interaction):
"""First call populates the cache; API function called exactly once."""
user_team = TeamFactory.create(id=1, abbrev="POR", sname="Loggers")
with patch(
"utils.autocomplete.get_user_major_league_team", new_callable=AsyncMock
) as mock_get_team:
mock_get_team.return_value = user_team
from utils.autocomplete import _get_cached_user_team
result1 = await _get_cached_user_team(mock_interaction)
result2 = await _get_cached_user_team(mock_interaction)
assert result1 is user_team
assert result2 is user_team
# API called only once despite two invocations
mock_get_team.assert_called_once_with(99999)
@pytest.mark.asyncio
async def test_re_fetches_after_ttl_expires(self, mock_interaction):
"""Expired cache entries cause a fresh API call."""
import time
user_team = TeamFactory.create(id=1, abbrev="POR", sname="Loggers")
with patch(
"utils.autocomplete.get_user_major_league_team", new_callable=AsyncMock
) as mock_get_team:
mock_get_team.return_value = user_team
from utils.autocomplete import _get_cached_user_team, _USER_TEAM_CACHE_TTL
# Seed the cache with a timestamp that is already expired
utils.autocomplete._user_team_cache[99999] = (
user_team,
time.time() - _USER_TEAM_CACHE_TTL - 1,
)
await _get_cached_user_team(mock_interaction)
# Should have called the API to refresh the stale entry
mock_get_team.assert_called_once_with(99999)
@pytest.mark.asyncio
async def test_caches_none_result(self, mock_interaction):
"""None (user has no team) is cached to avoid repeated API calls."""
with patch(
"utils.autocomplete.get_user_major_league_team", new_callable=AsyncMock
) as mock_get_team:
mock_get_team.return_value = None
from utils.autocomplete import _get_cached_user_team
result1 = await _get_cached_user_team(mock_interaction)
result2 = await _get_cached_user_team(mock_interaction)
assert result1 is None
assert result2 is None
mock_get_team.assert_called_once()

View File

@ -4,16 +4,33 @@ Autocomplete Utilities
Shared autocomplete functions for Discord slash commands. Shared autocomplete functions for Discord slash commands.
""" """
from typing import List import time
from typing import Dict, List, Optional, Tuple
import discord import discord
from discord import app_commands from discord import app_commands
from config import get_config from config import get_config
from models.team import RosterType from models.team import RosterType, Team
from services.player_service import player_service from services.player_service import player_service
from services.team_service import team_service from services.team_service import team_service
from utils.team_utils import get_user_major_league_team from utils.team_utils import get_user_major_league_team
# Cache for user team lookups: user_id -> (team, cached_at)
_user_team_cache: Dict[int, Tuple[Optional[Team], float]] = {}
_USER_TEAM_CACHE_TTL = 60 # seconds
async def _get_cached_user_team(interaction: discord.Interaction) -> Optional[Team]:
"""Return the user's major league team, cached for 60 seconds per user."""
user_id = interaction.user.id
if user_id in _user_team_cache:
team, cached_at = _user_team_cache[user_id]
if time.time() - cached_at < _USER_TEAM_CACHE_TTL:
return team
team = await get_user_major_league_team(user_id)
_user_team_cache[user_id] = (team, time.time())
return team
async def player_autocomplete( async def player_autocomplete(
interaction: discord.Interaction, current: str interaction: discord.Interaction, current: str
@ -34,12 +51,12 @@ async def player_autocomplete(
return [] return []
try: try:
# Get user's team for prioritization # Get user's team for prioritization (cached per user, 60s TTL)
user_team = await get_user_major_league_team(interaction.user.id) user_team = await _get_cached_user_team(interaction)
# Search for players using the search endpoint # Search for players using the search endpoint
players = await player_service.search_players( players = await player_service.search_players(
current, limit=50, season=get_config().sba_season current, limit=25, season=get_config().sba_season
) )
# Separate players by team (user's team vs others) # Separate players by team (user's team vs others)