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
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:
parent
6c49233392
commit
c8ed4dee38
@ -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()
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user