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