"""Unit tests for pro_matches incremental refresh + 429 circuit breaker.""" from __future__ import annotations import json import sys import tempfile import unittest import urllib.error from datetime import datetime, timezone from pathlib import Path from unittest.mock import patch ROOT = Path(__file__).resolve().parents[2] sys.path.insert(0, str(ROOT)) sys.path.insert(0, str(ROOT / "web")) from web.fetch_pro_matches import ( # noqa: E402 OpenDotaClient, RateLimitTripped, load_existing, retain_cell, select_refresh_batch, write_out, ) class SelectRefreshBatchTests(unittest.TestCase): def test_oldest_and_missing_first(self) -> None: picked = [ (1, {"name": "A"}), (2, {"name": "B"}), (3, {"name": "C"}), (4, {"name": "D"}), ] existing = { "1": {"fetched_at": "2026-07-28T00:00:00+00:00", "matches": []}, "2": {"fetched_at": "2026-07-20T00:00:00+00:00", "matches": []}, # 3 missing => oldest "4": {"fetched_at": "2026-07-29T00:00:00+00:00", "matches": []}, } refresh, retain = select_refresh_batch(picked, existing, 2) self.assertEqual([aid for aid, _ in refresh], [3, 2]) self.assertEqual([aid for aid, _ in retain], [1, 4]) def test_refresh_limit_zero_means_all(self) -> None: picked = [(1, {}), (2, {})] refresh, retain = select_refresh_batch(picked, {}, 0) self.assertEqual(len(refresh), 2) self.assertEqual(retain, []) def test_retain_keeps_prior_matches(self) -> None: existing = { "account_id": 9, "name": "Old", "matches": [{"match_id": 1}], "fetched_at": "2026-07-01T00:00:00+00:00", } cell = retain_cell(9, {"name": "New"}, existing) self.assertEqual(cell["matches"], [{"match_id": 1}]) self.assertEqual(cell["fetched_at"], "2026-07-01T00:00:00+00:00") self.assertEqual(cell["match_count"], 1) class RateLimitClientTests(unittest.TestCase): def test_trips_after_consecutive_429(self) -> None: client = OpenDotaClient(consecutive_limit=2) def boom(_url: str, **_kwargs): raise urllib.error.HTTPError( "https://api.opendota.com/api/x", 429, "Too Many", hdrs=None, fp=None ) with patch("web.fetch_pro_matches.http_json", side_effect=boom): with self.assertRaises(urllib.error.HTTPError): client.json("/players/1/matches") with self.assertRaises(RateLimitTripped): client.json("/players/1/matches") self.assertTrue(client.tripped) def test_success_resets_streak(self) -> None: client = OpenDotaClient(consecutive_limit=3) calls = {"n": 0} def flaky(url: str, **_kwargs): calls["n"] += 1 if calls["n"] == 1: raise urllib.error.HTTPError(url, 429, "Too Many", hdrs=None, fp=None) return [] with patch("web.fetch_pro_matches.http_json", side_effect=flaky): with self.assertRaises(urllib.error.HTTPError): client.json("/players/1/matches") self.assertEqual(client.consecutive_429, 1) self.assertEqual(client.json("/players/2/matches"), []) self.assertEqual(client.consecutive_429, 0) class WriteOutMergeTests(unittest.TestCase): def test_write_out_records_refresh_meta(self) -> None: with tempfile.TemporaryDirectory() as tmp: path = Path(tmp) / "pro_matches.json" by_pro = { "1": { "account_id": 1, "name": "A", "matches": [], "match_count": 0, "fetched_at": datetime.now(timezone.utc).isoformat(), }, "2": { "account_id": 2, "name": "B", "matches": [{"match_id": 9, "hero_key": "axe"}], "match_count": 1, "fetched_at": "2026-07-01T00:00:00+00:00", }, } write_out( path, by_pro=by_pro, by_hero={"axe": {"matches": [{"match_id": 9}]}}, pros_meta={"1": {"account_id": 1}, "2": {"account_id": 2}}, item_catalog={}, limit=8, limit_pros=2, lobby_types=(1, 2, 7), player_source="watchlist:x.json", refreshed_count=1, retained_count=1, refresh_limit=15, rate_limited=True, ) payload = json.loads(path.read_text(encoding="utf-8")) meta = payload["meta"] self.assertEqual(meta["refreshed_count"], 1) self.assertEqual(meta["retained_count"], 1) self.assertEqual(meta["refresh_limit"], 15) self.assertTrue(meta["rate_limited"]) self.assertEqual(load_existing(path)["by_pro"]["2"]["match_count"], 1) if __name__ == "__main__": unittest.main()