f99738d2e4
Enrich raw score-endpoint games with gameNumber from the series cache before parsing. The score API omits gameNumber and its seriesStatus reflects current wins, so all future games in a series computed the same number. Now we cross-reference by game id against the cached series-detail endpoint which includes the correct gameNumber per game. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
371 lines
13 KiB
Python
371 lines
13 KiB
Python
import time
|
|
from datetime import datetime
|
|
from zoneinfo import ZoneInfo
|
|
|
|
import pytest
|
|
|
|
from app import playoff_cache
|
|
|
|
|
|
EASTERN = ZoneInfo("America/New_York")
|
|
|
|
|
|
@pytest.fixture
|
|
def tmp_db(tmp_path, monkeypatch):
|
|
db_path = tmp_path / "playoff_cache.db"
|
|
monkeypatch.setattr(playoff_cache, "DB_PATH", str(db_path))
|
|
return str(db_path)
|
|
|
|
|
|
class _Resp:
|
|
def __init__(self, payload, status=200):
|
|
self._payload = payload
|
|
self.status_code = status
|
|
|
|
def json(self):
|
|
return self._payload
|
|
|
|
def raise_for_status(self):
|
|
if self.status_code >= 400:
|
|
import requests
|
|
|
|
raise requests.HTTPError(f"HTTP {self.status_code}")
|
|
|
|
|
|
class TestParseSeriesId:
|
|
def test_valid(self):
|
|
assert playoff_cache.parse_series_id("2026-A") == ("20252026", "A")
|
|
|
|
def test_lowercase_rejected(self):
|
|
assert playoff_cache.parse_series_id("2026-a") is None
|
|
|
|
def test_invalid_letter(self):
|
|
assert playoff_cache.parse_series_id("2026-Q") is None
|
|
|
|
def test_malformed(self):
|
|
assert playoff_cache.parse_series_id("abc") is None
|
|
|
|
def test_none(self):
|
|
assert playoff_cache.parse_series_id(None) is None
|
|
|
|
|
|
class TestBracket:
|
|
def test_refresh_success_stores_payload(self, tmp_db, monkeypatch):
|
|
payload = {"series": [{"seriesLetter": "A"}], "year": 2026}
|
|
monkeypatch.setattr(
|
|
"app.playoff_cache.requests.get",
|
|
lambda *a, **kw: _Resp(payload),
|
|
)
|
|
result = playoff_cache.refresh_bracket(2026)
|
|
assert result == payload
|
|
|
|
cached, fetched = playoff_cache.get_bracket(2026)
|
|
assert cached == payload
|
|
assert fetched is not None
|
|
|
|
def test_refresh_failure_returns_none(self, tmp_db, monkeypatch):
|
|
import requests
|
|
|
|
def raiser(*a, **kw):
|
|
raise requests.ConnectionError("boom")
|
|
|
|
monkeypatch.setattr("app.playoff_cache.requests.get", raiser)
|
|
assert playoff_cache.refresh_bracket(2026) is None
|
|
|
|
def test_get_bracket_empty(self, tmp_db):
|
|
payload, fetched = playoff_cache.get_bracket(2026)
|
|
assert payload is None and fetched is None
|
|
|
|
|
|
class TestFetchSeries:
|
|
def test_success_stores_and_returns(self, tmp_db, monkeypatch):
|
|
payload = {"seriesLetter": "A", "games": []}
|
|
monkeypatch.setattr(
|
|
"app.playoff_cache.requests.get",
|
|
lambda *a, **kw: _Resp(payload),
|
|
)
|
|
result = playoff_cache.fetch_series("2026-A")
|
|
assert result == payload
|
|
|
|
def test_invalid_id_returns_none(self, tmp_db):
|
|
assert playoff_cache.fetch_series("garbage") is None
|
|
|
|
def test_cache_hit_skips_network(self, tmp_db, monkeypatch):
|
|
payload_cached = {"from": "cache"}
|
|
playoff_cache._put(playoff_cache.series_key("20252026", "A"), payload_cached)
|
|
|
|
def should_not_be_called(*a, **kw):
|
|
raise AssertionError("network should not be called within TTL")
|
|
|
|
monkeypatch.setattr("app.playoff_cache.requests.get", should_not_be_called)
|
|
|
|
assert playoff_cache.fetch_series("2026-A") == payload_cached
|
|
|
|
def test_stale_cache_falls_back_on_failure(self, tmp_db, monkeypatch):
|
|
import requests
|
|
|
|
key = playoff_cache.series_key("20252026", "A")
|
|
playoff_cache._put(key, {"from": "stale"})
|
|
|
|
# Force the cached row to look older than the TTL but within MAX_STALE
|
|
with playoff_cache._connect() as conn:
|
|
old_ts = int(time.time()) - (playoff_cache.SERIES_TTL + 60)
|
|
conn.execute(
|
|
"UPDATE playoff_cache SET fetched_at = ? WHERE cache_key = ?",
|
|
(old_ts, key),
|
|
)
|
|
conn.commit()
|
|
|
|
def raiser(*a, **kw):
|
|
raise requests.ConnectionError("network gone")
|
|
|
|
monkeypatch.setattr("app.playoff_cache.requests.get", raiser)
|
|
|
|
assert playoff_cache.fetch_series("2026-A") == {"from": "stale"}
|
|
|
|
def test_ancient_stale_cache_returns_none(self, tmp_db, monkeypatch):
|
|
import requests
|
|
|
|
key = playoff_cache.series_key("20252026", "A")
|
|
playoff_cache._put(key, {"from": "ancient"})
|
|
|
|
with playoff_cache._connect() as conn:
|
|
ancient_ts = int(time.time()) - (playoff_cache.MAX_STALE_SECONDS + 60)
|
|
conn.execute(
|
|
"UPDATE playoff_cache SET fetched_at = ? WHERE cache_key = ?",
|
|
(ancient_ts, key),
|
|
)
|
|
conn.commit()
|
|
|
|
monkeypatch.setattr(
|
|
"app.playoff_cache.requests.get",
|
|
lambda *a, **kw: (_ for _ in ()).throw(requests.ConnectionError("x")),
|
|
)
|
|
assert playoff_cache.fetch_series("2026-A") is None
|
|
|
|
|
|
class TestRefreshRoundStartDates:
|
|
def test_no_bracket_returns_none(self, tmp_db):
|
|
assert playoff_cache.refresh_round_start_dates(2026) is None
|
|
|
|
def test_anchors_round_to_earliest_game(self, tmp_db, monkeypatch):
|
|
playoff_cache._put(
|
|
playoff_cache.bracket_key(2026),
|
|
{"series": [{"seriesLetter": "A", "playoffRound": 1}]},
|
|
)
|
|
series_payload = {
|
|
"games": [
|
|
{"startTimeUTC": "2026-04-19T23:00:00Z"},
|
|
{"startTimeUTC": "2026-04-18T23:00:00Z"},
|
|
{"startTimeUTC": "2026-04-21T23:00:00Z"},
|
|
]
|
|
}
|
|
monkeypatch.setattr(
|
|
"app.playoff_cache.requests.get",
|
|
lambda *a, **kw: _Resp(series_payload),
|
|
)
|
|
|
|
merged = playoff_cache.refresh_round_start_dates(2026)
|
|
assert merged == {"1": "2026-04-18"}
|
|
assert playoff_cache.get_round_start_date(1).isoformat() == "2026-04-18"
|
|
|
|
def test_merges_multiple_rounds_min_per_round(self, tmp_db, monkeypatch):
|
|
playoff_cache._put(
|
|
playoff_cache.bracket_key(2026),
|
|
{
|
|
"series": [
|
|
{"seriesLetter": "A", "playoffRound": 1},
|
|
{"seriesLetter": "B", "playoffRound": 1},
|
|
{"seriesLetter": "I", "playoffRound": 2},
|
|
]
|
|
},
|
|
)
|
|
payloads = {
|
|
"A": {"games": [{"startTimeUTC": "2026-04-19T23:00:00Z"}]},
|
|
"B": {"games": [{"startTimeUTC": "2026-04-18T23:00:00Z"}]},
|
|
"I": {"games": [{"startTimeUTC": "2026-04-29T23:00:00Z"}]},
|
|
}
|
|
|
|
def fake_get(url, *a, **kw):
|
|
letter = url.rstrip("/").rsplit("/", 1)[-1].upper()
|
|
return _Resp(payloads[letter])
|
|
|
|
monkeypatch.setattr("app.playoff_cache.requests.get", fake_get)
|
|
|
|
merged = playoff_cache.refresh_round_start_dates(2026)
|
|
assert merged == {"1": "2026-04-18", "2": "2026-04-29"}
|
|
|
|
def test_preserves_existing_rounds_on_merge(self, tmp_db, monkeypatch):
|
|
playoff_cache._put(
|
|
playoff_cache.ROUND_DATES_KEY, {"1": "2026-04-18", "2": "2026-04-29"}
|
|
)
|
|
playoff_cache._put(
|
|
playoff_cache.bracket_key(2026),
|
|
{"series": [{"seriesLetter": "M", "playoffRound": 3}]},
|
|
)
|
|
monkeypatch.setattr(
|
|
"app.playoff_cache.requests.get",
|
|
lambda *a, **kw: _Resp(
|
|
{"games": [{"startTimeUTC": "2026-05-15T23:00:00Z"}]}
|
|
),
|
|
)
|
|
|
|
merged = playoff_cache.refresh_round_start_dates(2026)
|
|
assert merged["1"] == "2026-04-18"
|
|
assert merged["2"] == "2026-04-29"
|
|
assert merged["3"] == "2026-05-15"
|
|
|
|
|
|
class TestDayNForRound:
|
|
def test_no_round_num(self, tmp_db):
|
|
assert playoff_cache.day_n_for_round(None) is None
|
|
|
|
def test_round_not_anchored(self, tmp_db):
|
|
assert playoff_cache.day_n_for_round(1) is None
|
|
|
|
def test_day_one(self, tmp_db):
|
|
playoff_cache._put(playoff_cache.ROUND_DATES_KEY, {"1": "2026-04-18"})
|
|
now = datetime(2026, 4, 18, 20, 0, tzinfo=EASTERN)
|
|
assert playoff_cache.day_n_for_round(1, now=now) == 1
|
|
|
|
def test_day_two(self, tmp_db):
|
|
playoff_cache._put(playoff_cache.ROUND_DATES_KEY, {"1": "2026-04-18"})
|
|
now = datetime(2026, 4, 19, 10, 0, tzinfo=EASTERN)
|
|
assert playoff_cache.day_n_for_round(1, now=now) == 2
|
|
|
|
def test_day_five(self, tmp_db):
|
|
playoff_cache._put(playoff_cache.ROUND_DATES_KEY, {"1": "2026-04-18"})
|
|
now = datetime(2026, 4, 22, 20, 0, tzinfo=EASTERN)
|
|
assert playoff_cache.day_n_for_round(1, now=now) == 5
|
|
|
|
def test_round_two_resets_to_day_one(self, tmp_db):
|
|
playoff_cache._put(
|
|
playoff_cache.ROUND_DATES_KEY,
|
|
{"1": "2026-04-18", "2": "2026-04-29"},
|
|
)
|
|
now = datetime(2026, 4, 29, 20, 0, tzinfo=EASTERN)
|
|
assert playoff_cache.day_n_for_round(2, now=now) == 1
|
|
|
|
def test_before_start_returns_none(self, tmp_db):
|
|
playoff_cache._put(playoff_cache.ROUND_DATES_KEY, {"2": "2026-04-29"})
|
|
now = datetime(2026, 4, 20, 20, 0, tzinfo=EASTERN)
|
|
assert playoff_cache.day_n_for_round(2, now=now) is None
|
|
|
|
|
|
class TestSchema:
|
|
def test_table_created_on_first_use(self, tmp_db):
|
|
# Accessing _get triggers create_cache_table
|
|
payload, fetched = playoff_cache._get("missing")
|
|
assert payload is None
|
|
|
|
conn = playoff_cache._connect()
|
|
try:
|
|
cur = conn.execute(
|
|
"SELECT name FROM sqlite_master WHERE type='table' "
|
|
"AND name='playoff_cache'"
|
|
)
|
|
assert cur.fetchone() is not None
|
|
finally:
|
|
conn.close()
|
|
|
|
def test_put_upserts(self, tmp_db):
|
|
playoff_cache._put("k", {"v": 1})
|
|
playoff_cache._put("k", {"v": 2})
|
|
cached, _ = playoff_cache._get("k")
|
|
assert cached == {"v": 2}
|
|
|
|
|
|
def _raw_playoff_game(game_id, series_letter="A", game_number=None):
|
|
"""Minimal raw score-endpoint playoff game for enrichment tests."""
|
|
game = {
|
|
"id": game_id,
|
|
"gameType": 3,
|
|
"gameState": "FUT",
|
|
"startTimeUTC": "2026-04-25T23:00:00Z",
|
|
"seriesStatus": {"seriesLetter": series_letter, "round": 1},
|
|
}
|
|
if game_number is not None:
|
|
game["gameNumber"] = game_number
|
|
return game
|
|
|
|
|
|
class TestEnrichGameNumbers:
|
|
def test_basic_enrichment(self, tmp_db, monkeypatch):
|
|
games = [_raw_playoff_game(101), _raw_playoff_game(102)]
|
|
series_payload = {
|
|
"games": [
|
|
{"id": 101, "gameNumber": 3},
|
|
{"id": 102, "gameNumber": 4},
|
|
]
|
|
}
|
|
monkeypatch.setattr(
|
|
"app.playoff_cache.requests.get",
|
|
lambda *a, **kw: _Resp(series_payload),
|
|
)
|
|
playoff_cache.enrich_game_numbers(games)
|
|
assert games[0]["gameNumber"] == 3
|
|
assert games[1]["gameNumber"] == 4
|
|
|
|
def test_skips_games_with_existing_game_number(self, tmp_db, monkeypatch):
|
|
games = [_raw_playoff_game(101, game_number=2)]
|
|
called = []
|
|
monkeypatch.setattr(
|
|
"app.playoff_cache.requests.get",
|
|
lambda *a, **kw: called.append(1) or _Resp({"games": []}),
|
|
)
|
|
playoff_cache.enrich_game_numbers(games)
|
|
assert games[0]["gameNumber"] == 2
|
|
assert len(called) == 0
|
|
|
|
def test_skips_non_playoff_games(self, tmp_db, monkeypatch):
|
|
games = [{"id": 101, "gameType": 2, "gameState": "FUT"}]
|
|
called = []
|
|
monkeypatch.setattr(
|
|
"app.playoff_cache.requests.get",
|
|
lambda *a, **kw: called.append(1) or _Resp({"games": []}),
|
|
)
|
|
playoff_cache.enrich_game_numbers(games)
|
|
assert "gameNumber" not in games[0]
|
|
assert len(called) == 0
|
|
|
|
def test_graceful_on_cache_miss(self, tmp_db, monkeypatch):
|
|
import requests as req
|
|
|
|
games = [_raw_playoff_game(101)]
|
|
monkeypatch.setattr(
|
|
"app.playoff_cache.requests.get",
|
|
lambda *a, **kw: (_ for _ in ()).throw(req.ConnectionError("x")),
|
|
)
|
|
playoff_cache.enrich_game_numbers(games)
|
|
assert "gameNumber" not in games[0]
|
|
|
|
def test_handles_missing_id(self, tmp_db, monkeypatch):
|
|
game = {
|
|
"gameType": 3,
|
|
"gameState": "FUT",
|
|
"startTimeUTC": "2026-04-25T23:00:00Z",
|
|
"seriesStatus": {"seriesLetter": "A", "round": 1},
|
|
}
|
|
monkeypatch.setattr(
|
|
"app.playoff_cache.requests.get",
|
|
lambda *a, **kw: _Resp({"games": [{"id": 101, "gameNumber": 3}]}),
|
|
)
|
|
playoff_cache.enrich_game_numbers([game])
|
|
assert "gameNumber" not in game
|
|
|
|
def test_multiple_series(self, tmp_db, monkeypatch):
|
|
games = [_raw_playoff_game(101, "A"), _raw_playoff_game(201, "B")]
|
|
payloads = {
|
|
"a": {"games": [{"id": 101, "gameNumber": 2}]},
|
|
"b": {"games": [{"id": 201, "gameNumber": 5}]},
|
|
}
|
|
|
|
def fake_get(url, *a, **kw):
|
|
letter = url.rstrip("/").rsplit("/", 1)[-1]
|
|
return _Resp(payloads[letter])
|
|
|
|
monkeypatch.setattr("app.playoff_cache.requests.get", fake_get)
|
|
playoff_cache.enrich_game_numbers(games)
|
|
assert games[0]["gameNumber"] == 2
|
|
assert games[1]["gameNumber"] == 5
|