import json 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 TestRecordStartDate: def test_no_playoff_games_no_write(self, tmp_db): result = playoff_cache.record_start_date_if_missing([{"gameType": 2}]) assert result is None assert playoff_cache.get_playoff_start_date() is None def test_records_on_first_playoff_sighting(self, tmp_db): now = datetime(2026, 4, 18, 20, 0, tzinfo=EASTERN) result = playoff_cache.record_start_date_if_missing( [{"gameType": 3}], now=now ) assert result == "2026-04-18" assert playoff_cache.get_playoff_start_date().isoformat() == "2026-04-18" def test_idempotent_after_first_write(self, tmp_db): first_now = datetime(2026, 4, 18, 20, 0, tzinfo=EASTERN) second_now = datetime(2026, 4, 25, 20, 0, tzinfo=EASTERN) playoff_cache.record_start_date_if_missing([{"gameType": 3}], now=first_now) # Second call should not overwrite result = playoff_cache.record_start_date_if_missing( [{"gameType": 3}], now=second_now ) assert result == "2026-04-18" class TestDayN: def test_no_start_date(self, tmp_db): assert playoff_cache.day_n() == (None, None) def test_day_one(self, tmp_db): now = datetime(2026, 4, 18, 20, 0, tzinfo=EASTERN) playoff_cache.record_start_date_if_missing([{"gameType": 3}], now=now) n, total = playoff_cache.day_n(now=now) assert n == 1 assert total == 60 def test_day_five(self, tmp_db): start = datetime(2026, 4, 18, 20, 0, tzinfo=EASTERN) later = datetime(2026, 4, 22, 20, 0, tzinfo=EASTERN) playoff_cache.record_start_date_if_missing([{"gameType": 3}], now=start) n, _ = playoff_cache.day_n(now=later) assert n == 5 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}