From b3d4410127950322f334225b70885a08118af7de Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 1 Aug 2026 05:04:44 +0000 Subject: [PATCH 1/2] test: document MlbDataAdapter and MlbResult contract offline Separate mocked HTTP adapter characterization from live MLB API tests. Add deterministic requests-mock coverage and strict xfail markers for known defects that fix/http-adapter-correctness will address. Co-authored-by: Matthew Spah --- .../mlbdataadapter/test_mlbadapter.py | 83 +------ tests/test_mlb_dataadapter.py | 229 ++++++++++++++++++ tests/test_mlb_result.py | 101 ++++++++ 3 files changed, 335 insertions(+), 78 deletions(-) create mode 100644 tests/test_mlb_dataadapter.py create mode 100644 tests/test_mlb_result.py diff --git a/tests/external_tests/mlbdataadapter/test_mlbadapter.py b/tests/external_tests/mlbdataadapter/test_mlbadapter.py index 225fa46..57e554a 100644 --- a/tests/external_tests/mlbdataadapter/test_mlbadapter.py +++ b/tests/external_tests/mlbdataadapter/test_mlbadapter.py @@ -1,7 +1,6 @@ import unittest -import requests -from unittest.mock import patch -from mlbstatsapi import MlbDataAdapter, TheMlbStatsApiException + +from mlbstatsapi import MlbDataAdapter class TestMlbAdapter(unittest.TestCase): @@ -13,7 +12,6 @@ def setUpClass(cls) -> None: def tearDownClass(cls) -> None: pass - def test_mlbadapter_get_200(self): """mlbadapter should return 200 and data for sports endpoint""" @@ -27,25 +25,17 @@ def test_mlbadapter_get_200(self): self.assertTrue(result.data) def test_mlbadapter_get_400(self): - """mlbadapter should return 404, and result.data should be None""" + """mlbadapter should return 404, and result.data should be empty""" - # invalid endpoint + # invalid endpoint result = self.mlb_adapter.get(endpoint="teams/19990") # result.status_code should be 404 self.assertEqual(result.status_code, 404) - # result.data should be None + # result.data should be empty self.assertEqual(result.data, {}) - @unittest.skip("External API no longer returns 500 for this endpoint - covered by mock tests") - def test_mlbadapter_get_500(self): - """mlbadapter should raise TheMlbStatsApiException for 500""" - - # bad endpoint should raise exception due to 500 - with self.assertRaises(TheMlbStatsApiException): - result = self.mlb_adapter.get(endpoint="teams/133/stats?stats=vsPlayer&group=catching") - def test_mlbadapter_get_params(self): """mlbadapter should accept params and parse them to the url""" @@ -60,66 +50,3 @@ def test_mlbadapter_get_params(self): # result should have data self.assertTrue(result.data) - -class MlbAdapterMockTesting(unittest.TestCase): - @classmethod - def setUpClass(cls) -> None: - cls.mlb_adapter = MlbDataAdapter() - cls.response = requests.Response() - - @classmethod - def tearDownClass(cls) -> None: - pass - - def test_mlbadapter_mock_bad_json(self): - """mlbadapter should raise TheMlbStatsApiException""" - # setting up mock - self.response.status_code = 200 - self.response._content = '{"some bad json": sdfsd'.encode() - - # params to pass - self.params = {"stats": "season", "group": "hitting"} - - # patch mlbdataadapter to return bad JSON - with patch("mlbstatsapi.mlb_dataadapter.requests.get", return_value=self.response): - - # mlb_adapter should raise exception due to bad JSON - with self.assertRaises(TheMlbStatsApiException): - result = self.mlb_adapter.get(endpoint="teams/133/stats", ep_params=self.params) - - def test_mlbadapter_mock_404_json(self): - """mlbadapter should raise TheMlbStatsApiException""" - # setting up mock - self.response.status_code = 404 - self.response._content = '{ "messageNumber": 10, "message": "Object not found", "timestamp": "2022-10-13T18:16:41.886604Z", "traceId": null }'.encode() - - # params to pass - self.params = { "stats": "standard", "group": "hitting" } - - # patch mlbdataadapter to return 404 response - with patch("mlbstatsapi.mlb_dataadapter.requests.get", return_value=self.response): - - # mlb_adapter should raise exception due to bad JSON - result = self.mlb_adapter.get(endpoint="teams/133/stats", ep_params=self.params) - - # result.status_code should be 404 - self.assertEqual(result.status_code, 404) - - # result.data should be None - self.assertEqual(result.data, {}) - - def test_mlbadapter_mock_500_json(self): - """mlbadapter should raise TheMlbStatsApiException""" - # setting up mock - self.response.status_code = 500 - self.response._content = '{ "messageNumber" : 1, "message" : "Internal error occurred", "timestamp" : "2022-10-13T18:37:47.600274Z", "traceId" : "9318607c0b50f493e9056648614a5cea" }'.encode() - - # params to pass - self.params = {"stats": "standard", "group": "hitting"} - - # patch mlbdataadapter to return mocked response - with patch("mlbstatsapi.mlb_dataadapter.requests.get", return_value=self.response): - - # mlb_adapter should raise exception due to 500 status code - with self.assertRaises(TheMlbStatsApiException): - result = self.mlb_adapter.get(endpoint="teams/133/stats", ep_params=self.params) diff --git a/tests/test_mlb_dataadapter.py b/tests/test_mlb_dataadapter.py new file mode 100644 index 0000000..a826a53 --- /dev/null +++ b/tests/test_mlb_dataadapter.py @@ -0,0 +1,229 @@ +"""Offline characterization tests for MlbDataAdapter. + +These tests document current HTTP adapter behavior with requests-mock before +the transport is refactored in version 0.8.0. Known defects use strict xfail +markers so a future fix causes XPASS until the marker is removed. +""" + +import requests +import pytest + +from mlbstatsapi import MlbDataAdapter, MlbResult, TheMlbStatsApiException + + +BASE_URL = "https://statsapi.mlb.com/api/v1/" + + +@pytest.fixture +def adapter() -> MlbDataAdapter: + return MlbDataAdapter() + + +def test_successful_json_response_returns_exact_values(adapter, requests_mock): + payload = { + "copyright": "Copyright 2026 MLB Advanced Media, L.P.", + "sports": [{"id": 1, "name": "Major League Baseball"}], + } + requests_mock.get( + f"{BASE_URL}sports", + json=payload, + status_code=200, + reason="OK", + ) + + result = adapter.get(endpoint="sports") + + assert result.status_code == 200 + assert result.message == "OK" + assert result.data == {"sports": [{"id": 1, "name": "Major League Baseball"}]} + + +def test_ep_params_are_sent_as_query_parameters(adapter, requests_mock): + requests_mock.get( + f"{BASE_URL}teams/133/stats", + json={"stats": []}, + status_code=200, + reason="OK", + ) + + result = adapter.get( + endpoint="teams/133/stats", + ep_params={"stats": "season", "group": "hitting", "season": 2022}, + ) + + assert result.status_code == 200 + assert requests_mock.called + assert requests_mock.last_request.qs == { + "stats": ["season"], + "group": ["hitting"], + "season": ["2022"], + } + + +def test_status_code_and_reason_are_preserved_on_result(adapter, requests_mock): + requests_mock.get( + f"{BASE_URL}sports", + json={"sports": []}, + status_code=200, + reason="OK", + ) + + result = adapter.get(endpoint="sports") + + assert result.status_code == 200 + assert result.message == "OK" + + +def test_copyright_is_removed_from_result_data(adapter, requests_mock): + requests_mock.get( + f"{BASE_URL}sports", + json={ + "copyright": "Copyright 2026 MLB Advanced Media, L.P.", + "sports": [], + }, + status_code=200, + reason="OK", + ) + + result = adapter.get(endpoint="sports") + + assert "copyright" not in result.data + assert result.data == {"sports": []} + + +def test_json_404_returns_empty_data(adapter, requests_mock): + requests_mock.get( + f"{BASE_URL}teams/19990", + json={ + "messageNumber": 10, + "message": "Object not found", + "timestamp": "2022-10-13T18:16:41.886604Z", + "traceId": None, + }, + status_code=404, + reason="Not Found", + ) + + result = adapter.get(endpoint="teams/19990") + + assert result.status_code == 404 + assert result.data == {} + + +def test_json_500_raises_the_mlb_stats_api_exception(adapter, requests_mock): + requests_mock.get( + f"{BASE_URL}teams/133/stats", + json={ + "messageNumber": 1, + "message": "Internal error occurred", + "timestamp": "2022-10-13T18:37:47.600274Z", + "traceId": "9318607c0b50f493e9056648614a5cea", + }, + status_code=500, + reason="Internal Server Error", + ) + + with pytest.raises(TheMlbStatsApiException, match=r"^500: Internal Server Error$") as exc_info: + adapter.get( + endpoint="teams/133/stats", + ep_params={"stats": "standard", "group": "hitting"}, + ) + + assert str(exc_info.value) == "500: Internal Server Error" + + +def test_connection_failure_raises_request_failed(adapter, requests_mock): + requests_mock.get( + f"{BASE_URL}sports", + exc=requests.exceptions.ConnectionError("connection refused"), + ) + + with pytest.raises(TheMlbStatsApiException, match=r"^Request failed$") as exc_info: + adapter.get(endpoint="sports") + + assert str(exc_info.value) == "Request failed" + assert isinstance(exc_info.value.__cause__, requests.exceptions.ConnectionError) + + +def test_invalid_json_on_successful_response_raises_bad_json(adapter, requests_mock): + requests_mock.get( + f"{BASE_URL}teams/133/stats", + text='{"some bad json": sdfsd', + status_code=200, + reason="OK", + headers={"Content-Type": "application/json"}, + ) + + with pytest.raises(TheMlbStatsApiException, match=r"^Bad JSON in response$") as exc_info: + adapter.get( + endpoint="teams/133/stats", + ep_params={"stats": "season", "group": "hitting"}, + ) + + assert str(exc_info.value) == "Bad JSON in response" + + +def test_mlb_result_optional_data_argument_omitted(): + result = MlbResult(200, "OK") + + assert result.status_code == 200 + assert result.message == "OK" + assert result.data == {} + + +def test_mlb_result_optional_data_argument_explicit_dict(): + result = MlbResult(404, "Not Found", {"ignored": True}) + + assert result.status_code == 404 + assert result.message == "Not Found" + assert result.data == {"ignored": True} + + +def test_mlb_result_type_coercion(): + result = MlbResult("200", 123, {"value": True}) + + assert result.status_code == 200 + assert result.message == "123" + + +@pytest.mark.xfail( + strict=True, + reason=( + "Adapter always JSON-decodes before status handling; " + "fix/http-adapter-correctness should return empty data for empty successful bodies" + ), +) +def test_empty_successful_response_returns_empty_data(adapter, requests_mock): + requests_mock.get( + f"{BASE_URL}sports", + text="", + status_code=204, + reason="No Content", + ) + + result = adapter.get(endpoint="sports") + + assert result.status_code == 204 + assert result.data == {} + + +@pytest.mark.xfail( + strict=True, + reason=( + "Adapter JSON-decodes before HTTP status handling; " + "fix/http-adapter-correctness should return empty data for non-JSON 404 bodies" + ), +) +def test_non_json_404_returns_empty_data(adapter, requests_mock): + requests_mock.get( + f"{BASE_URL}teams/19990", + text="Not Found", + status_code=404, + reason="Not Found", + headers={"Content-Type": "text/html"}, + ) + + result = adapter.get(endpoint="teams/19990") + + assert result.status_code == 404 + assert result.data == {} diff --git a/tests/test_mlb_result.py b/tests/test_mlb_result.py new file mode 100644 index 0000000..4446e64 --- /dev/null +++ b/tests/test_mlb_result.py @@ -0,0 +1,101 @@ +"""Offline characterization tests for MlbResult. + +These tests document current constructor behavior before the HTTP adapter +correctness work in version 0.8.0. Known defects use strict xfail markers. +""" + +import pytest + +from mlbstatsapi import MlbResult + + +def test_stores_status_message_and_data(): + result = MlbResult(200, "OK", {"sports": [{"id": 1}]}) + + assert result.status_code == 200 + assert result.message == "OK" + assert result.data == {"sports": [{"id": 1}]} + + +def test_converts_status_code_to_int(): + result = MlbResult("200", "OK", {"value": True}) + + assert result.status_code == 200 + assert isinstance(result.status_code, int) + + +def test_converts_message_to_str(): + result = MlbResult(200, 123, {"value": True}) + + assert result.message == "123" + assert isinstance(result.message, str) + + +def test_type_coercion_for_status_and_message(): + result = MlbResult("200", 123, {"value": True}) + + assert result.status_code == 200 + assert result.message == "123" + assert result.data == {"value": True} + + +def test_removes_copyright_from_result_data(): + result = MlbResult( + 200, + "OK", + { + "copyright": "Copyright 2026 MLB Advanced Media, L.P.", + "sports": [{"id": 1, "name": "Major League Baseball"}], + }, + ) + + assert "copyright" not in result.data + assert result.data == {"sports": [{"id": 1, "name": "Major League Baseball"}]} + + +def test_accepts_omitted_data_argument(): + result = MlbResult(200, "OK") + + assert result.status_code == 200 + assert result.message == "OK" + assert result.data == {} + + +def test_accepts_explicitly_supplied_dictionary(): + payload = {"teams": [{"id": 133}]} + result = MlbResult(200, "OK", payload) + + assert result.data == {"teams": [{"id": 133}]} + assert result.data is payload + + +@pytest.mark.xfail( + strict=True, + reason=( + "MlbResult uses a shared mutable default for data; " + "fix/http-adapter-correctness should give each instance its own dict" + ), +) +def test_each_instance_gets_independent_data_dict(): + first = MlbResult(200, "OK") + second = MlbResult(200, "OK") + + assert first.data is not second.data + + +@pytest.mark.xfail( + strict=True, + reason=( + "MlbResult deletes copyright from the caller-owned dictionary; " + "fix/http-adapter-correctness should leave the input unchanged" + ), +) +def test_does_not_mutate_caller_owned_dictionary(): + payload = { + "copyright": "MLB", + "sports": [], + } + + MlbResult(200, "OK", payload) + + assert "copyright" in payload From 89b289b6b054d77b877b4c5d26dc990605b33ec7 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 1 Aug 2026 05:20:46 +0000 Subject: [PATCH 2/2] test: avoid requiring shared result data reference Co-authored-by: Matthew Spah --- tests/test_mlb_result.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/test_mlb_result.py b/tests/test_mlb_result.py index 4446e64..9443b1a 100644 --- a/tests/test_mlb_result.py +++ b/tests/test_mlb_result.py @@ -66,7 +66,6 @@ def test_accepts_explicitly_supplied_dictionary(): result = MlbResult(200, "OK", payload) assert result.data == {"teams": [{"id": 133}]} - assert result.data is payload @pytest.mark.xfail(