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>
372 lines
15 KiB
Python
372 lines
15 KiB
Python
"""
|
|
Tests for shared autocomplete utility functions.
|
|
|
|
Validates the shared autocomplete functions used across multiple command modules.
|
|
"""
|
|
|
|
import pytest
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
import utils.autocomplete
|
|
from utils.autocomplete import (
|
|
player_autocomplete,
|
|
team_autocomplete,
|
|
major_league_team_autocomplete,
|
|
)
|
|
from tests.factories import PlayerFactory, TeamFactory
|
|
from models.team import RosterType
|
|
|
|
|
|
class TestPlayerAutocomplete:
|
|
"""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
|
|
def mock_interaction(self):
|
|
"""Create a mock Discord interaction."""
|
|
interaction = MagicMock()
|
|
interaction.user.id = 12345
|
|
return interaction
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_player_autocomplete_success(self, mock_interaction):
|
|
"""Test successful player autocomplete."""
|
|
mock_players = [
|
|
PlayerFactory.mike_trout(id=1),
|
|
PlayerFactory.ronald_acuna(id=2),
|
|
]
|
|
|
|
with patch("utils.autocomplete.player_service") as mock_service:
|
|
mock_service.search_players = AsyncMock(return_value=mock_players)
|
|
|
|
choices = await player_autocomplete(mock_interaction, "Trout")
|
|
|
|
assert len(choices) == 2
|
|
assert choices[0].name == "Mike Trout (CF)"
|
|
assert choices[0].value == "Mike Trout"
|
|
assert choices[1].name == "Ronald Acuna Jr. (OF)"
|
|
assert choices[1].value == "Ronald Acuna Jr."
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_player_autocomplete_with_team_info(self, mock_interaction):
|
|
"""Test player autocomplete with team information."""
|
|
mock_team = TeamFactory.create(
|
|
id=499, abbrev="LAA", sname="Angels", lname="Los Angeles Angels"
|
|
)
|
|
mock_player = PlayerFactory.mike_trout(id=1)
|
|
mock_player.team = mock_team
|
|
|
|
with patch("utils.autocomplete.player_service") as mock_service:
|
|
mock_service.search_players = AsyncMock(return_value=[mock_player])
|
|
|
|
choices = await player_autocomplete(mock_interaction, "Trout")
|
|
|
|
assert len(choices) == 1
|
|
assert choices[0].name == "Mike Trout (CF - LAA)"
|
|
assert choices[0].value == "Mike Trout"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_player_autocomplete_prioritizes_user_team(self, mock_interaction):
|
|
"""Test that user's team players are prioritized in autocomplete."""
|
|
user_team = TeamFactory.create(id=1, abbrev="POR", sname="Loggers")
|
|
other_team = TeamFactory.create(id=2, abbrev="LAA", sname="Angels")
|
|
|
|
# Create players - one from user's team, one from other team
|
|
user_player = PlayerFactory.mike_trout(id=1)
|
|
user_player.team = user_team
|
|
user_player.team_id = user_team.id
|
|
|
|
other_player = PlayerFactory.ronald_acuna(id=2)
|
|
other_player.team = other_team
|
|
other_player.team_id = other_team.id
|
|
|
|
with (
|
|
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_get_team.return_value = user_team
|
|
|
|
choices = await player_autocomplete(mock_interaction, "player")
|
|
|
|
assert len(choices) == 2
|
|
# User's team player should be first
|
|
assert choices[0].name == "Mike Trout (CF - POR)"
|
|
assert choices[1].name == "Ronald Acuna Jr. (OF - LAA)"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_player_autocomplete_short_input(self, mock_interaction):
|
|
"""Test player autocomplete with short input returns empty."""
|
|
choices = await player_autocomplete(mock_interaction, "T")
|
|
assert len(choices) == 0
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_player_autocomplete_error_handling(self, mock_interaction):
|
|
"""Test player autocomplete error handling."""
|
|
with patch("utils.autocomplete.player_service") as mock_service:
|
|
mock_service.search_players.side_effect = Exception("API Error")
|
|
|
|
choices = await player_autocomplete(mock_interaction, "Trout")
|
|
assert len(choices) == 0
|
|
|
|
|
|
class TestTeamAutocomplete:
|
|
"""Test team autocomplete functionality."""
|
|
|
|
@pytest.fixture
|
|
def mock_interaction(self):
|
|
"""Create a mock Discord interaction."""
|
|
interaction = MagicMock()
|
|
interaction.user.id = 12345
|
|
return interaction
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_team_autocomplete_success(self, mock_interaction):
|
|
"""Test successful team autocomplete."""
|
|
mock_teams = [
|
|
TeamFactory.create(id=1, abbrev="LAA", sname="Angels"),
|
|
TeamFactory.create(id=2, abbrev="LAAMIL", sname="Salt Lake Bees"),
|
|
TeamFactory.create(id=3, abbrev="LAAAIL", sname="Angels IL"),
|
|
TeamFactory.create(id=4, abbrev="POR", sname="Loggers"),
|
|
]
|
|
|
|
with patch("utils.autocomplete.team_service") as mock_service:
|
|
mock_service.get_teams_by_season = AsyncMock(return_value=mock_teams)
|
|
|
|
choices = await team_autocomplete(mock_interaction, "la")
|
|
|
|
assert len(choices) == 3 # All teams with 'la' in abbrev or sname
|
|
assert any("LAA" 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)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_team_autocomplete_short_input(self, mock_interaction):
|
|
"""Test team autocomplete with very short input."""
|
|
choices = await team_autocomplete(mock_interaction, "")
|
|
assert len(choices) == 0
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_team_autocomplete_error_handling(self, mock_interaction):
|
|
"""Test team autocomplete error handling."""
|
|
with patch("utils.autocomplete.team_service") as mock_service:
|
|
mock_service.get_teams_by_season.side_effect = Exception("API Error")
|
|
|
|
choices = await team_autocomplete(mock_interaction, "LAA")
|
|
assert len(choices) == 0
|
|
|
|
|
|
class TestMajorLeagueTeamAutocomplete:
|
|
"""Test major league team autocomplete functionality."""
|
|
|
|
@pytest.fixture
|
|
def mock_interaction(self):
|
|
"""Create a mock Discord interaction."""
|
|
interaction = MagicMock()
|
|
interaction.user.id = 12345
|
|
return interaction
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_major_league_team_autocomplete_filters_correctly(
|
|
self, mock_interaction
|
|
):
|
|
"""Test that only major league teams are returned."""
|
|
# Create teams with different roster types
|
|
mock_teams = [
|
|
TeamFactory.create(id=1, abbrev="LAA", sname="Angels"), # ML
|
|
TeamFactory.create(id=2, abbrev="LAAMIL", sname="Salt Lake Bees"), # MiL
|
|
TeamFactory.create(id=3, abbrev="LAAAIL", sname="Angels IL"), # IL
|
|
TeamFactory.create(id=4, abbrev="FA", sname="Free Agents"), # FA
|
|
TeamFactory.create(id=5, abbrev="POR", sname="Loggers"), # ML
|
|
TeamFactory.create(id=6, abbrev="PORMIL", sname="Portland MiL"), # MiL
|
|
]
|
|
|
|
with patch("utils.autocomplete.team_service") as mock_service:
|
|
mock_service.get_teams_by_season = AsyncMock(return_value=mock_teams)
|
|
|
|
choices = await major_league_team_autocomplete(mock_interaction, "l")
|
|
|
|
# Should only return major league teams that match 'l' (LAA, POR)
|
|
choice_values = [choice.value for choice in choices]
|
|
assert "LAA" in choice_values
|
|
assert "POR" in choice_values
|
|
assert len(choice_values) == 2
|
|
# Should NOT include MiL, IL, or FA teams
|
|
assert "LAAMIL" not in choice_values
|
|
assert "LAAAIL" not in choice_values
|
|
assert "FA" not in choice_values
|
|
assert "PORMIL" not in choice_values
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_major_league_team_autocomplete_matching(self, mock_interaction):
|
|
"""Test search matching on abbreviation and short name."""
|
|
mock_teams = [
|
|
TeamFactory.create(id=1, abbrev="LAA", sname="Angels"),
|
|
TeamFactory.create(id=2, abbrev="LAD", sname="Dodgers"),
|
|
TeamFactory.create(id=3, abbrev="POR", sname="Loggers"),
|
|
TeamFactory.create(id=4, abbrev="BOS", sname="Red Sox"),
|
|
]
|
|
|
|
with patch("utils.autocomplete.team_service") as mock_service:
|
|
mock_service.get_teams_by_season = AsyncMock(return_value=mock_teams)
|
|
|
|
# Test abbreviation matching
|
|
choices = await major_league_team_autocomplete(mock_interaction, "la")
|
|
assert len(choices) == 2 # LAA and LAD
|
|
choice_values = [choice.value for choice in choices]
|
|
assert "LAA" in choice_values
|
|
assert "LAD" in choice_values
|
|
|
|
# Test short name matching
|
|
choices = await major_league_team_autocomplete(mock_interaction, "red")
|
|
assert len(choices) == 1
|
|
assert choices[0].value == "BOS"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_major_league_team_autocomplete_short_input(self, mock_interaction):
|
|
"""Test major league team autocomplete with very short input."""
|
|
choices = await major_league_team_autocomplete(mock_interaction, "")
|
|
assert len(choices) == 0
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_major_league_team_autocomplete_error_handling(
|
|
self, mock_interaction
|
|
):
|
|
"""Test major league team autocomplete error handling."""
|
|
with patch("utils.autocomplete.team_service") as mock_service:
|
|
mock_service.get_teams_by_season.side_effect = Exception("API Error")
|
|
|
|
choices = await major_league_team_autocomplete(mock_interaction, "LAA")
|
|
assert len(choices) == 0
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_major_league_team_autocomplete_roster_type_detection(
|
|
self, mock_interaction
|
|
):
|
|
"""Test that roster type detection works correctly for edge cases."""
|
|
# Test edge cases like teams whose abbreviation ends in 'M' + 'IL'
|
|
mock_teams = [
|
|
TeamFactory.create(
|
|
id=1, abbrev="BHM", sname="Iron"
|
|
), # ML team ending in 'M'
|
|
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:
|
|
mock_service.get_teams_by_season = AsyncMock(return_value=mock_teams)
|
|
|
|
choices = await major_league_team_autocomplete(mock_interaction, "b")
|
|
|
|
# Should only return major league teams
|
|
choice_values = [choice.value for choice in choices]
|
|
assert "BHM" in choice_values # Major league team
|
|
assert "BHMIL" not in choice_values # Should be detected as IL, not MiL
|
|
assert "NYYMIL" not in choice_values # Minor league team
|
|
|
|
# Verify the roster type detection is working
|
|
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")
|
|
nyymil_team = next(t for t in mock_teams if t.abbrev == "NYYMIL")
|
|
|
|
assert bhm_team.roster_type() == RosterType.MAJOR_LEAGUE
|
|
assert bhmil_team.roster_type() == RosterType.INJURED_LIST
|
|
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()
|