diff --git a/l4d2web/services/steam_users.py b/l4d2web/services/steam_users.py new file mode 100644 index 0000000..074daef --- /dev/null +++ b/l4d2web/services/steam_users.py @@ -0,0 +1,76 @@ +"""Steam Web API client for player profile lookups. + +Mirrors the shape of l4d2web/services/steam_workshop.py:17-43: +- single thread-local requests.Session +- 30s timeout +- HTTPS only + +Difference: GetPlayerSummaries requires an API key in the querystring, +unlike the anonymous workshop endpoints. +""" +from __future__ import annotations + +import threading +from dataclasses import dataclass +from typing import Iterable + +import requests + + +GET_PLAYER_SUMMARIES_URL = ( + "https://api.steampowered.com/ISteamUser/GetPlayerSummaries/v0002/" +) + +REQUEST_TIMEOUT_SECONDS = 30.0 +MAX_IDS_PER_CALL = 100 + +_session_local = threading.local() + + +def _session() -> requests.Session: + sess = getattr(_session_local, "session", None) + if sess is None: + sess = requests.Session() + _session_local.session = sess + return sess + + +def _session_get(url: str, params: dict, timeout: float = REQUEST_TIMEOUT_SECONDS): + """Indirection seam so tests can monkeypatch a fake here.""" + return _session().get(url, params=params, timeout=timeout) + + +@dataclass(slots=True, frozen=True) +class SteamProfile: + steam_id_64: str + persona_name: str + avatar_url: str + + +def fetch_profiles_batch( + steam_ids: Iterable[str], *, api_key: str +) -> list[SteamProfile]: + """Resolve a batch of SteamID64 strings to persona name + avatar URL. + + Steam's API caps each call at 100 IDs; this helper chunks transparently. + IDs that Steam can't resolve (private, deleted) are simply absent from + the response and from the returned list. + """ + ids = list(steam_ids) + out: list[SteamProfile] = [] + for i in range(0, len(ids), MAX_IDS_PER_CALL): + chunk = ids[i : i + MAX_IDS_PER_CALL] + params = {"key": api_key, "steamids": ",".join(chunk)} + resp = _session_get(GET_PLAYER_SUMMARIES_URL, params=params) + resp.raise_for_status() + payload = resp.json() or {} + players = (payload.get("response") or {}).get("players") or [] + for p in players: + out.append( + SteamProfile( + steam_id_64=str(p["steamid"]), + persona_name=str(p.get("personaname", "")), + avatar_url=str(p.get("avatarmedium", "")), + ) + ) + return out diff --git a/l4d2web/tests/test_steam_users.py b/l4d2web/tests/test_steam_users.py new file mode 100644 index 0000000..a2460d7 --- /dev/null +++ b/l4d2web/tests/test_steam_users.py @@ -0,0 +1,78 @@ +from __future__ import annotations + +from typing import Any + +import pytest + +from l4d2web.services import steam_users + + +class _FakeResponse: + def __init__(self, json_body: dict[str, Any], status: int = 200) -> None: + self._body = json_body + self.status_code = status + + def raise_for_status(self) -> None: + if self.status_code >= 400: + raise RuntimeError(f"http {self.status_code}") + + def json(self) -> dict[str, Any]: + return self._body + + +def _patched_get(monkeypatch: pytest.MonkeyPatch, body: dict, capture: list) -> None: + def fake_get(url: str, params: dict, timeout: float = 30.0) -> _FakeResponse: + capture.append({"url": url, "params": params, "timeout": timeout}) + return _FakeResponse(body) + + monkeypatch.setattr(steam_users, "_session_get", fake_get) + + +def test_fetch_profiles_batch_builds_correct_request(monkeypatch: pytest.MonkeyPatch) -> None: + captured: list = [] + body = {"response": {"players": [ + {"steamid": "76561197960828710", "personaname": "Alice", + "avatarmedium": "https://avatars.../alice_medium.jpg"}, + ]}} + _patched_get(monkeypatch, body, captured) + + profiles = steam_users.fetch_profiles_batch( + ["76561197960828710", "76561198021234567"], api_key="KEY" + ) + + assert captured[0]["url"].endswith("/GetPlayerSummaries/v0002/") + assert captured[0]["params"]["key"] == "KEY" + assert captured[0]["params"]["steamids"] == "76561197960828710,76561198021234567" + assert len(profiles) == 1 + p = profiles[0] + assert p.steam_id_64 == "76561197960828710" + assert p.persona_name == "Alice" + assert p.avatar_url.endswith("alice_medium.jpg") + + +def test_fetch_profiles_batch_skips_private_or_missing(monkeypatch: pytest.MonkeyPatch) -> None: + # The Steam API simply omits non-resolvable IDs from the response. Caller + # should accept that and return only what's there. + body = {"response": {"players": [ + {"steamid": "76561197960828710", "personaname": "Alice", + "avatarmedium": "https://avatars.../alice_medium.jpg"}, + ]}} + _patched_get(monkeypatch, body, []) + profiles = steam_users.fetch_profiles_batch( + ["76561197960828710", "76561197999999999"], api_key="KEY" + ) + assert len(profiles) == 1 + assert profiles[0].steam_id_64 == "76561197960828710" + + +def test_fetch_profiles_batch_chunks_by_100(monkeypatch: pytest.MonkeyPatch) -> None: + ids = [str(76561197960000000 + i) for i in range(150)] + calls: list = [] + body = {"response": {"players": []}} + _patched_get(monkeypatch, body, calls) + + steam_users.fetch_profiles_batch(ids, api_key="KEY") + + assert len(calls) == 2 + assert calls[0]["params"]["steamids"].count(",") == 99 # 100 ids -> 99 commas + assert calls[1]["params"]["steamids"].count(",") == 49 # 50 ids