Daily now refreshes the 15 oldest watchlist pros, keeps prior cache for the rest, fails fast on consecutive 429s, and still writes a partial result. Co-authored-by: Cursor <cursoragent@cursor.com>
145 lines
5.1 KiB
Python
145 lines
5.1 KiB
Python
"""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()
|