Files
NHL-Scoreboard/tests/test_playoff_cache.py
josh f99738d2e4
CI / Lint (push) Successful in 8s
CI / Test (push) Successful in 11s
CI / Build & Push (push) Successful in 20s
fix: show correct "Game X of 7" for future playoff dates
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>
2026-04-24 15:39:44 -04:00

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