Skip to content

Commit 5c321b2

Browse files
bensynapseclaude
andcommitted
fix(livetennisapi): address review — clamp limit, guard malformed payloads, dark-mode icon, more tests
- Clamp the limit input to the API's documented 1-200 range before the request instead of forwarding out-of-range values. - Validate decoded 200 payloads (object with a 'data' list / score object) so malformed responses surface as friendly error rows instead of AttributeError/TypeError escaping the component. - Icon: complete the isDark contract — index.tsx reads the dark store and passes isDark; the SVG uses it for the tile color and omits it from the DOM spread (Valkey pattern). - Tests: player-search API-error, fixtures non-timeout HTTP error, malformed-payload and limit-clamp cases for each component; docstrings. The free-tier quota text (30 requests/minute, 100/day) is correct per the API's OpenAPI description — 1,000/day is the BASIC tier — so it is deliberately unchanged. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent d917ecf commit 5c321b2

10 files changed

Lines changed: 201 additions & 20 deletions

File tree

src/backend/tests/unit/components/bundles/livetennisapi/test_fixtures.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,3 +88,37 @@ def test_timeout_returns_error_data(self, component_class, default_kwargs, mock_
8888

8989
assert len(results) == 1
9090
assert "error" in results[0].data
91+
92+
def test_http_error_returns_error_data(self, component_class, default_kwargs, mock_httpx_client):
93+
"""An API error (e.g. 401) comes back as a single error Data row, not an exception."""
94+
component = component_class(**default_kwargs)
95+
mock_response = mock_httpx_client.return_value.__enter__.return_value.get.return_value
96+
mock_response.status_code = 401
97+
mock_response.text = '{"error":"unauthorized"}'
98+
mock_response.raise_for_status.side_effect = httpx.HTTPStatusError(
99+
"401", request=MagicMock(), response=mock_response
100+
)
101+
102+
results = component.fetch_fixtures()
103+
104+
assert len(results) == 1
105+
assert "error" in results[0].data
106+
107+
def test_malformed_payload_returns_error_data(self, component_class, default_kwargs, mock_httpx_client):
108+
"""A 200 response whose body is not the documented shape yields an error Data row."""
109+
component = component_class(**default_kwargs)
110+
mock_httpx_client.return_value.__enter__.return_value.get.return_value.json.return_value = None
111+
112+
results = component.fetch_fixtures()
113+
114+
assert len(results) == 1
115+
assert "error" in results[0].data
116+
117+
def test_limit_is_clamped_to_api_range(self, component_class, default_kwargs, mock_httpx_client):
118+
"""Out-of-range limit values are clamped to the API's 1-200 range."""
119+
component = component_class(**{**default_kwargs, "limit": 0})
120+
121+
component.fetch_fixtures()
122+
123+
_, kwargs = mock_httpx_client.return_value.__enter__.return_value.get.call_args
124+
assert kwargs["params"]["limit"] == 1

src/backend/tests/unit/components/bundles/livetennisapi/test_live_matches.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,3 +114,22 @@ def test_http_error_returns_error_data(self, component_class, default_kwargs, mo
114114

115115
assert len(results) == 1
116116
assert "error" in results[0].data
117+
118+
def test_malformed_payload_returns_error_data(self, component_class, default_kwargs, mock_httpx_client):
119+
"""A 200 response whose body is not the documented shape yields an error Data row."""
120+
component = component_class(**default_kwargs)
121+
mock_httpx_client.return_value.__enter__.return_value.get.return_value.json.return_value = {"data": "nope"}
122+
123+
results = component.fetch_matches()
124+
125+
assert len(results) == 1
126+
assert "error" in results[0].data
127+
128+
def test_limit_is_clamped_to_api_range(self, component_class, default_kwargs, mock_httpx_client):
129+
"""Out-of-range limit values are clamped to the API's 1-200 range."""
130+
component = component_class(**{**default_kwargs, "limit": -5})
131+
132+
component.fetch_matches()
133+
134+
_, kwargs = mock_httpx_client.return_value.__enter__.return_value.get.call_args
135+
assert kwargs["params"]["limit"] == 1

src/backend/tests/unit/components/bundles/livetennisapi/test_match_score.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,3 +89,12 @@ def test_not_found_returns_friendly_error(self, component_class, default_kwargs,
8989

9090
assert "error" in result.data
9191
assert "not found" in result.text
92+
93+
def test_malformed_payload_returns_error_data(self, component_class, default_kwargs, mock_httpx_client):
94+
"""A 200 response whose body is not a score object yields an error Data result."""
95+
component = component_class(**default_kwargs)
96+
mock_httpx_client.return_value.__enter__.return_value.get.return_value.json.return_value = [1, 2, 3]
97+
98+
result = component.fetch_score()
99+
100+
assert "error" in result.data

src/backend/tests/unit/components/bundles/livetennisapi/test_player_search.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from unittest.mock import MagicMock, patch
22

3+
import httpx
34
import pytest
45

56
pytest.importorskip("lfx_bundles")
@@ -85,3 +86,37 @@ def test_empty_search_omits_param(self, component_class, default_kwargs, mock_ht
8586

8687
_, kwargs = mock_httpx_client.return_value.__enter__.return_value.get.call_args
8788
assert "search" not in kwargs["params"]
89+
90+
def test_http_error_returns_error_data(self, component_class, default_kwargs, mock_httpx_client):
91+
"""An API error (e.g. 401) comes back as a single error Data row, not an exception."""
92+
component = component_class(**default_kwargs)
93+
mock_response = mock_httpx_client.return_value.__enter__.return_value.get.return_value
94+
mock_response.status_code = 401
95+
mock_response.text = '{"error":"unauthorized"}'
96+
mock_response.raise_for_status.side_effect = httpx.HTTPStatusError(
97+
"401", request=MagicMock(), response=mock_response
98+
)
99+
100+
results = component.fetch_players()
101+
102+
assert len(results) == 1
103+
assert "error" in results[0].data
104+
105+
def test_malformed_payload_returns_error_data(self, component_class, default_kwargs, mock_httpx_client):
106+
"""A 200 response whose body is not the documented shape yields an error Data row."""
107+
component = component_class(**default_kwargs)
108+
mock_httpx_client.return_value.__enter__.return_value.get.return_value.json.return_value = ["not", "a", "dict"]
109+
110+
results = component.fetch_players()
111+
112+
assert len(results) == 1
113+
assert "error" in results[0].data
114+
115+
def test_limit_is_clamped_to_api_range(self, component_class, default_kwargs, mock_httpx_client):
116+
"""Out-of-range limit values are clamped to the API's 1-200 range."""
117+
component = component_class(**{**default_kwargs, "limit": 999})
118+
119+
component.fetch_players()
120+
121+
_, kwargs = mock_httpx_client.return_value.__enter__.return_value.get.call_args
122+
assert kwargs["params"]["limit"] == 200

src/bundles/lfx-bundles/src/lfx_bundles/livetennisapi/fixtures.py

Lines changed: 29 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,21 @@
88

99
BASE_URL = "https://api.livetennisapi.com/api/public/v1"
1010
HTTP_TOO_MANY_REQUESTS = 429
11+
MIN_LIMIT = 1
12+
MAX_LIMIT = 200
13+
DEFAULT_LIMIT = 50
14+
15+
16+
def _validated_items(payload: object) -> list[dict]:
17+
"""Return the list under ``data``, or raise ``TypeError`` on a malformed 200 payload."""
18+
if not isinstance(payload, dict) or not isinstance(payload.get("data"), list):
19+
msg = "Live Tennis API returned an unexpected response shape (expected an object with a 'data' list)."
20+
raise TypeError(msg)
21+
items = payload["data"]
22+
if not all(isinstance(item, dict) for item in items):
23+
msg = "Live Tennis API returned a malformed 'data' entry (expected objects)."
24+
raise TypeError(msg)
25+
return items
1126

1227

1328
class LiveTennisFixturesComponent(Component):
@@ -37,7 +52,7 @@ class LiveTennisFixturesComponent(Component):
3752
IntInput(
3853
name="limit",
3954
display_name="Max Results",
40-
info="Maximum number of fixtures to return (1-200).",
55+
info="Maximum number of fixtures to return (1-200). Out-of-range values are clamped.",
4156
value=50,
4257
advanced=True,
4358
),
@@ -47,9 +62,18 @@ class LiveTennisFixturesComponent(Component):
4762
Output(display_name="Fixtures", name="fixtures", method="fetch_fixtures_dataframe"),
4863
]
4964

65+
def _clamped_limit(self) -> int:
66+
"""Clamp the limit input to the API's documented 1-200 range."""
67+
try:
68+
limit = int(self.limit)
69+
except (TypeError, ValueError):
70+
return DEFAULT_LIMIT
71+
return max(MIN_LIMIT, min(MAX_LIMIT, limit))
72+
5073
def fetch_fixtures(self) -> list[Data]:
74+
"""Call ``GET /fixtures`` and return one ``Data`` row per fixture, or a single error row."""
5175
try:
52-
params: dict = {"limit": self.limit}
76+
params: dict = {"limit": self._clamped_limit()}
5377
if self.tour and self.tour != "all":
5478
params["tour"] = self.tour
5579

@@ -60,10 +84,9 @@ def fetch_fixtures(self) -> list[Data]:
6084
headers={"X-API-Key": self.api_key, "accept": "application/json"},
6185
)
6286
response.raise_for_status()
63-
payload = response.json()
6487

6588
results = []
66-
for fixture in payload.get("data", []):
89+
for fixture in _validated_items(response.json()):
6790
row = {
6891
"id": fixture.get("id"),
6992
"event_date": fixture.get("event_date"),
@@ -91,7 +114,7 @@ def fetch_fixtures(self) -> list[Data]:
91114
error_message = "Rate limited. The free tier allows 30 requests/minute and 100/day."
92115
logger.error(error_message)
93116
return [Data(text=error_message, data={"error": error_message})]
94-
except (httpx.RequestError, ValueError) as exc:
117+
except (httpx.RequestError, ValueError, TypeError) as exc:
95118
error_message = f"Request error occurred: {exc}"
96119
logger.error(error_message)
97120
return [Data(text=error_message, data={"error": error_message})]
@@ -100,5 +123,6 @@ def fetch_fixtures(self) -> list[Data]:
100123
return results
101124

102125
def fetch_fixtures_dataframe(self) -> DataFrame:
126+
"""Return the fixtures as a ``DataFrame`` (one row per fixture)."""
103127
data = self.fetch_fixtures()
104128
return DataFrame(data)

src/bundles/lfx-bundles/src/lfx_bundles/livetennisapi/live_matches.py

Lines changed: 30 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,21 @@
99
BASE_URL = "https://api.livetennisapi.com/api/public/v1"
1010
HTTP_TOO_MANY_REQUESTS = 429
1111
PLAYERS_PER_MATCH = 2
12+
MIN_LIMIT = 1
13+
MAX_LIMIT = 200
14+
DEFAULT_LIMIT = 50
15+
16+
17+
def _validated_items(payload: object) -> list[dict]:
18+
"""Return the list under ``data``, or raise ``TypeError`` on a malformed 200 payload."""
19+
if not isinstance(payload, dict) or not isinstance(payload.get("data"), list):
20+
msg = "Live Tennis API returned an unexpected response shape (expected an object with a 'data' list)."
21+
raise TypeError(msg)
22+
items = payload["data"]
23+
if not all(isinstance(item, dict) for item in items):
24+
msg = "Live Tennis API returned a malformed 'data' entry (expected objects)."
25+
raise TypeError(msg)
26+
return items
1227

1328

1429
class LiveTennisMatchesComponent(Component):
@@ -53,7 +68,7 @@ class LiveTennisMatchesComponent(Component):
5368
IntInput(
5469
name="limit",
5570
display_name="Max Results",
56-
info="Maximum number of matches to return (1-200).",
71+
info="Maximum number of matches to return (1-200). Out-of-range values are clamped.",
5772
value=50,
5873
advanced=True,
5974
),
@@ -63,7 +78,16 @@ class LiveTennisMatchesComponent(Component):
6378
Output(display_name="Matches", name="matches", method="fetch_matches_dataframe"),
6479
]
6580

81+
def _clamped_limit(self) -> int:
82+
"""Clamp the limit input to the API's documented 1-200 range."""
83+
try:
84+
limit = int(self.limit)
85+
except (TypeError, ValueError):
86+
return DEFAULT_LIMIT
87+
return max(MIN_LIMIT, min(MAX_LIMIT, limit))
88+
6689
def _flatten_match(self, match: dict) -> dict:
90+
"""Flatten one match object into a single-level row for the DataFrame output."""
6791
players = match.get("players") or {}
6892
p1 = players.get("p1") or {}
6993
p2 = players.get("p2") or {}
@@ -102,8 +126,9 @@ def _flatten_match(self, match: dict) -> dict:
102126
}
103127

104128
def fetch_matches(self) -> list[Data]:
129+
"""Call ``GET /matches`` and return one ``Data`` row per match, or a single error row."""
105130
try:
106-
params: dict = {"status": self.match_status, "limit": self.limit}
131+
params: dict = {"status": self.match_status, "limit": self._clamped_limit()}
107132
if self.tour and self.tour != "all":
108133
params["tour"] = self.tour
109134

@@ -114,10 +139,9 @@ def fetch_matches(self) -> list[Data]:
114139
headers={"X-API-Key": self.api_key, "accept": "application/json"},
115140
)
116141
response.raise_for_status()
117-
payload = response.json()
118142

119143
results = []
120-
for match in payload.get("data", []):
144+
for match in _validated_items(response.json()):
121145
row = self._flatten_match(match)
122146
summary = f"{row.get('player1')} vs {row.get('player2')}{row.get('tournament')}"
123147
results.append(Data(text=summary, data=row))
@@ -131,7 +155,7 @@ def fetch_matches(self) -> list[Data]:
131155
error_message = "Rate limited. The free tier allows 30 requests/minute and 100/day."
132156
logger.error(error_message)
133157
return [Data(text=error_message, data={"error": error_message})]
134-
except (httpx.RequestError, ValueError) as exc:
158+
except (httpx.RequestError, ValueError, TypeError) as exc:
135159
error_message = f"Request error occurred: {exc}"
136160
logger.error(error_message)
137161
return [Data(text=error_message, data={"error": error_message})]
@@ -140,5 +164,6 @@ def fetch_matches(self) -> list[Data]:
140164
return results
141165

142166
def fetch_matches_dataframe(self) -> DataFrame:
167+
"""Return the matches as a ``DataFrame`` (one row per match)."""
143168
data = self.fetch_matches()
144169
return DataFrame(data)

src/bundles/lfx-bundles/src/lfx_bundles/livetennisapi/match_score.py

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,14 @@
1111
PLAYERS_PER_MATCH = 2
1212

1313

14+
def _validated_score(payload: object) -> dict:
15+
"""Return the decoded score object, or raise ``TypeError`` on a malformed 200 payload."""
16+
if not isinstance(payload, dict):
17+
msg = "Live Tennis API returned an unexpected response shape (expected a score object)."
18+
raise TypeError(msg)
19+
return payload
20+
21+
1422
class LiveTennisMatchScoreComponent(Component):
1523
display_name = "Match Score"
1624
description = "Get the current score of one match — sets, games, in-game points and who is serving."
@@ -42,6 +50,7 @@ class LiveTennisMatchScoreComponent(Component):
4250
]
4351

4452
def fetch_score(self) -> Data:
53+
"""Call ``GET /matches/{id}/score`` and return the score snapshot as ``Data``."""
4554
try:
4655
match_id = int(str(self.match_id).strip())
4756
except (TypeError, ValueError):
@@ -56,7 +65,7 @@ def fetch_score(self) -> Data:
5665
headers={"X-API-Key": self.api_key, "accept": "application/json"},
5766
)
5867
response.raise_for_status()
59-
score = response.json()
68+
score = _validated_score(response.json())
6069

6170
games = score.get("games") or []
6271
games_str = None
@@ -83,7 +92,7 @@ def fetch_score(self) -> Data:
8392
error_message = "Rate limited. The free tier allows 30 requests/minute and 100/day."
8493
logger.error(error_message)
8594
return Data(text=error_message, data={"error": error_message})
86-
except (httpx.RequestError, ValueError) as exc:
95+
except (httpx.RequestError, ValueError, TypeError) as exc:
8796
error_message = f"Request error occurred: {exc}"
8897
logger.error(error_message)
8998
return Data(text=error_message, data={"error": error_message})

0 commit comments

Comments
 (0)