All checks were successful
Build Docker Image / build (pull_request) Successful in 58s
Replace custom _make_game/_make_team helpers with existing test factories for consistency with the rest of the test suite. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
285 lines
10 KiB
Python
285 lines
10 KiB
Python
"""
|
||
Tests for schedule service functionality.
|
||
|
||
Covers get_week_schedule, get_team_schedule, get_recent_games,
|
||
get_upcoming_games, and group_games_by_series — verifying the
|
||
asyncio.gather parallelization and post-fetch filtering logic.
|
||
"""
|
||
|
||
import pytest
|
||
from unittest.mock import AsyncMock, patch
|
||
|
||
from services.schedule_service import ScheduleService
|
||
from tests.factories import GameFactory, TeamFactory
|
||
|
||
|
||
def _game(game_id, week, away_abbrev, home_abbrev, **kwargs):
|
||
"""Create a Game with distinct team IDs per matchup."""
|
||
return GameFactory.create(
|
||
id=game_id,
|
||
week=week,
|
||
away_team=TeamFactory.create(id=game_id * 10, abbrev=away_abbrev),
|
||
home_team=TeamFactory.create(id=game_id * 10 + 1, abbrev=home_abbrev),
|
||
**kwargs,
|
||
)
|
||
|
||
|
||
class TestGetWeekSchedule:
|
||
"""Tests for ScheduleService.get_week_schedule — the HTTP layer."""
|
||
|
||
@pytest.fixture
|
||
def service(self):
|
||
svc = ScheduleService()
|
||
svc.get_client = AsyncMock()
|
||
return svc
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_success(self, service):
|
||
"""get_week_schedule returns parsed Game objects on a normal response."""
|
||
mock_client = AsyncMock()
|
||
mock_client.get.return_value = {
|
||
"games": [
|
||
{
|
||
"id": 1,
|
||
"season": 12,
|
||
"week": 5,
|
||
"game_num": 1,
|
||
"season_type": "regular",
|
||
"away_team": {
|
||
"id": 10,
|
||
"abbrev": "NYY",
|
||
"sname": "NYY",
|
||
"lname": "New York",
|
||
"season": 12,
|
||
},
|
||
"home_team": {
|
||
"id": 11,
|
||
"abbrev": "BOS",
|
||
"sname": "BOS",
|
||
"lname": "Boston",
|
||
"season": 12,
|
||
},
|
||
"away_score": 4,
|
||
"home_score": 2,
|
||
}
|
||
]
|
||
}
|
||
service.get_client.return_value = mock_client
|
||
|
||
games = await service.get_week_schedule(12, 5)
|
||
|
||
assert len(games) == 1
|
||
assert games[0].away_team.abbrev == "NYY"
|
||
assert games[0].home_team.abbrev == "BOS"
|
||
assert games[0].is_completed
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_empty_response(self, service):
|
||
"""get_week_schedule returns [] when the API has no games."""
|
||
mock_client = AsyncMock()
|
||
mock_client.get.return_value = {"games": []}
|
||
service.get_client.return_value = mock_client
|
||
|
||
games = await service.get_week_schedule(12, 99)
|
||
assert games == []
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_api_error_returns_empty(self, service):
|
||
"""get_week_schedule returns [] on API error (no exception raised)."""
|
||
service.get_client.side_effect = Exception("connection refused")
|
||
|
||
games = await service.get_week_schedule(12, 1)
|
||
assert games == []
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_missing_games_key(self, service):
|
||
"""get_week_schedule returns [] when response lacks 'games' key."""
|
||
mock_client = AsyncMock()
|
||
mock_client.get.return_value = {"status": "ok"}
|
||
service.get_client.return_value = mock_client
|
||
|
||
games = await service.get_week_schedule(12, 1)
|
||
assert games == []
|
||
|
||
|
||
class TestGetTeamSchedule:
|
||
"""Tests for get_team_schedule — gather + team-abbrev filter."""
|
||
|
||
@pytest.fixture
|
||
def service(self):
|
||
return ScheduleService()
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_filters_by_team_case_insensitive(self, service):
|
||
"""get_team_schedule returns only games involving the requested team,
|
||
regardless of abbreviation casing."""
|
||
week1 = [
|
||
_game(1, 1, "NYY", "BOS", away_score=3, home_score=1),
|
||
_game(2, 1, "LAD", "CHC", away_score=5, home_score=2),
|
||
]
|
||
week2 = [
|
||
_game(3, 2, "BOS", "NYY", away_score=2, home_score=4),
|
||
]
|
||
|
||
with patch.object(service, "get_week_schedule", new_callable=AsyncMock) as mock:
|
||
mock.side_effect = [week1, week2]
|
||
result = await service.get_team_schedule(12, "nyy", weeks=2)
|
||
|
||
assert len(result) == 2
|
||
assert all(
|
||
g.away_team.abbrev == "NYY" or g.home_team.abbrev == "NYY" for g in result
|
||
)
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_full_season_fetches_18_weeks(self, service):
|
||
"""When weeks is None, all 18 weeks are fetched via gather."""
|
||
with patch.object(service, "get_week_schedule", new_callable=AsyncMock) as mock:
|
||
mock.return_value = []
|
||
await service.get_team_schedule(12, "NYY")
|
||
|
||
assert mock.call_count == 18
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_limited_weeks(self, service):
|
||
"""When weeks=5, only 5 weeks are fetched."""
|
||
with patch.object(service, "get_week_schedule", new_callable=AsyncMock) as mock:
|
||
mock.return_value = []
|
||
await service.get_team_schedule(12, "NYY", weeks=5)
|
||
|
||
assert mock.call_count == 5
|
||
|
||
|
||
class TestGetRecentGames:
|
||
"""Tests for get_recent_games — gather + completed-only filter."""
|
||
|
||
@pytest.fixture
|
||
def service(self):
|
||
return ScheduleService()
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_returns_only_completed_games(self, service):
|
||
"""get_recent_games filters out games without scores."""
|
||
completed = GameFactory.completed(id=1, week=10)
|
||
incomplete = GameFactory.upcoming(id=2, week=10)
|
||
|
||
with patch.object(service, "get_week_schedule", new_callable=AsyncMock) as mock:
|
||
mock.return_value = [completed, incomplete]
|
||
result = await service.get_recent_games(12, weeks_back=1)
|
||
|
||
assert len(result) == 1
|
||
assert result[0].is_completed
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_sorted_descending_by_week_and_game_num(self, service):
|
||
"""Recent games are sorted most-recent first."""
|
||
game_w10 = GameFactory.completed(id=1, week=10, game_num=2)
|
||
game_w9 = GameFactory.completed(id=2, week=9, game_num=1)
|
||
|
||
with patch.object(service, "get_week_schedule", new_callable=AsyncMock) as mock:
|
||
mock.side_effect = [[game_w10], [game_w9]]
|
||
result = await service.get_recent_games(12, weeks_back=2)
|
||
|
||
assert result[0].week == 10
|
||
assert result[1].week == 9
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_skips_negative_weeks(self, service):
|
||
"""Weeks that would be <= 0 are excluded from fetch."""
|
||
with patch.object(service, "get_week_schedule", new_callable=AsyncMock) as mock:
|
||
mock.return_value = []
|
||
await service.get_recent_games(12, weeks_back=15)
|
||
|
||
# weeks_to_fetch = [10, 9, 8, 7, 6, 5, 4, 3, 2, 1] — only 10 valid weeks
|
||
assert mock.call_count == 10
|
||
|
||
|
||
class TestGetUpcomingGames:
|
||
"""Tests for get_upcoming_games — gather all 18 weeks + incomplete filter."""
|
||
|
||
@pytest.fixture
|
||
def service(self):
|
||
return ScheduleService()
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_returns_only_incomplete_games(self, service):
|
||
"""get_upcoming_games filters out completed games."""
|
||
completed = GameFactory.completed(id=1, week=5)
|
||
upcoming = GameFactory.upcoming(id=2, week=5)
|
||
|
||
with patch.object(service, "get_week_schedule", new_callable=AsyncMock) as mock:
|
||
mock.return_value = [completed, upcoming]
|
||
result = await service.get_upcoming_games(12)
|
||
|
||
assert len(result) == 18 # 1 incomplete game per week × 18 weeks
|
||
assert all(not g.is_completed for g in result)
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_sorted_ascending_by_week_and_game_num(self, service):
|
||
"""Upcoming games are sorted earliest first."""
|
||
game_w3 = GameFactory.upcoming(id=1, week=3, game_num=1)
|
||
game_w1 = GameFactory.upcoming(id=2, week=1, game_num=2)
|
||
|
||
with patch.object(service, "get_week_schedule", new_callable=AsyncMock) as mock:
|
||
|
||
def side_effect(season, week):
|
||
if week == 1:
|
||
return [game_w1]
|
||
if week == 3:
|
||
return [game_w3]
|
||
return []
|
||
|
||
mock.side_effect = side_effect
|
||
result = await service.get_upcoming_games(12)
|
||
|
||
assert result[0].week == 1
|
||
assert result[1].week == 3
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_fetches_all_18_weeks(self, service):
|
||
"""All 18 weeks are fetched in parallel (no early exit)."""
|
||
with patch.object(service, "get_week_schedule", new_callable=AsyncMock) as mock:
|
||
mock.return_value = []
|
||
await service.get_upcoming_games(12)
|
||
|
||
assert mock.call_count == 18
|
||
|
||
|
||
class TestGroupGamesBySeries:
|
||
"""Tests for group_games_by_series — synchronous grouping logic."""
|
||
|
||
@pytest.fixture
|
||
def service(self):
|
||
return ScheduleService()
|
||
|
||
def test_groups_by_alphabetical_pairing(self, service):
|
||
"""Games between the same two teams are grouped under one key,
|
||
with the alphabetically-first team first in the tuple."""
|
||
games = [
|
||
_game(1, 1, "NYY", "BOS", game_num=1),
|
||
_game(2, 1, "BOS", "NYY", game_num=2),
|
||
_game(3, 1, "LAD", "CHC", game_num=1),
|
||
]
|
||
|
||
result = service.group_games_by_series(games)
|
||
|
||
assert ("BOS", "NYY") in result
|
||
assert len(result[("BOS", "NYY")]) == 2
|
||
assert ("CHC", "LAD") in result
|
||
assert len(result[("CHC", "LAD")]) == 1
|
||
|
||
def test_sorted_by_game_num_within_series(self, service):
|
||
"""Games within each series are sorted by game_num."""
|
||
games = [
|
||
_game(1, 1, "NYY", "BOS", game_num=3),
|
||
_game(2, 1, "NYY", "BOS", game_num=1),
|
||
_game(3, 1, "NYY", "BOS", game_num=2),
|
||
]
|
||
|
||
result = service.group_games_by_series(games)
|
||
series = result[("BOS", "NYY")]
|
||
assert [g.game_num for g in series] == [1, 2, 3]
|
||
|
||
def test_empty_input(self, service):
|
||
"""Empty games list returns empty dict."""
|
||
assert service.group_games_by_series([]) == {}
|