Skip to content

Commit 987f7eb

Browse files
claudeMattsface
authored andcommitted
test: add async transport contract tests for #302 batch 1
Covers the remaining HTTP/result and error/warning contract gaps from #298 for AsyncMlbDataAdapter, without duplicating the suite added in #301/#314: - final non-404 4xx (403) raises MlbHttpError under strict_http=True, with structured status/reason/URL/method context - final non-404 4xx under strict_http=False emits one MlbHttpCompatibilityWarning and returns the historical empty MlbResult - compatibility warnings do not leak response bodies or headers - compatibility warnings are attributed to the awaiting caller's call site - a failure while extracting optional error-response context degrades that field instead of replacing the original MlbHttpError Test-only change. Existing helpers (run_async, _ScriptedHandler, _response, _owned_adapter, SLEEP_TARGET) and httpx.MockTransport are reused; no live MLB API requests. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EHYA36k526xUzTUxx4iPSZ
1 parent b9e050b commit 987f7eb

1 file changed

Lines changed: 141 additions & 0 deletions

File tree

tests/test_async_mlb_dataadapter.py

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@
2020

2121
import asyncio
2222
import contextlib
23+
import inspect
24+
import warnings
2325
from importlib.metadata import PackageNotFoundError
2426
from unittest.mock import AsyncMock, patch
2527

@@ -43,6 +45,7 @@
4345
from mlbstatsapi.mlb_dataadapter import PACKAGE_DISTRIBUTION_NAME # noqa: E402
4446

4547
from http_contract_support import ( # noqa: E402
48+
HTTP_REASON_BY_STATUS,
4649
RETRYABLE_STATUS_CODES,
4750
SERVER_ERRORS,
4851
assert_library_retry_policy,
@@ -61,6 +64,10 @@
6164
MOCKED_PACKAGE_VERSION = "9.8.7"
6265
MOCKED_USER_AGENT = f"python-mlb-statsapi/{MOCKED_PACKAGE_VERSION}"
6366

67+
# Obvious sentinels, so a leak into a compatibility warning is unmistakable.
68+
SECRET_BODY_MARKER = "SUPER_SECRET_RESPONSE"
69+
SECRET_HEADER_MARKER = "SUPER_SECRET_HEADER"
70+
6471

6572
# Adapters built by _owned_adapter(); run_async() closes them inside the same
6673
# event loop that used them, so no AsyncClient is left open by a test.
@@ -683,6 +690,140 @@ async def scenario():
683690
assert handler.call_count == 1
684691

685692

693+
# --- Final non-404 4xx contract ---
694+
695+
696+
def test_final_non_404_client_error_raises_under_strict_http():
697+
"""Strict mode raises MlbHttpError with the sync structured context.
698+
699+
test_400_is_not_retried already proves a 4xx is final on the first
700+
response; this asserts the #298 decision-table outcome for an explicit
701+
strict_http=True adapter, including the structured error context.
702+
"""
703+
handler = _ScriptedHandler(_response(403))
704+
705+
async def scenario():
706+
adapter = _owned_adapter(handler, strict_http=True)
707+
with pytest.raises(MlbHttpError) as exc_info:
708+
await adapter.get(endpoint="sports")
709+
return exc_info.value
710+
711+
error = run_async(scenario())
712+
assert error.status_code == 403
713+
assert error.reason == HTTP_REASON_BY_STATUS[403]
714+
assert error.method == "GET"
715+
assert error.url == f"{BASE_URL}sports"
716+
assert handler.call_count == 1
717+
718+
719+
def test_final_non_404_client_error_returns_empty_result_in_compatibility_mode():
720+
"""strict_http=False suppresses a non-404 4xx into a warned empty result."""
721+
handler = _ScriptedHandler(_response(403, text='{"message": "denied"}'))
722+
723+
async def scenario():
724+
adapter = _owned_adapter(handler, strict_http=False)
725+
with pytest.warns(MlbHttpCompatibilityWarning) as warning_info:
726+
result = await adapter.get(endpoint="sports")
727+
return result, [str(warning.message) for warning in warning_info]
728+
729+
result, messages = run_async(scenario())
730+
assert result.status_code == 403
731+
assert result.message == HTTP_REASON_BY_STATUS[403]
732+
assert result.data == {}
733+
assert len(messages) == 1
734+
assert "403" in messages[0]
735+
assert f"{BASE_URL}sports" in messages[0]
736+
assert handler.call_count == 1
737+
738+
739+
# --- Compatibility warning safety ---
740+
741+
742+
def test_compatibility_warning_does_not_leak_response_body_or_headers():
743+
"""Response bodies and headers must never reach the warning message."""
744+
handler = _ScriptedHandler(
745+
_response(
746+
403,
747+
headers={"X-Debug-Token": SECRET_HEADER_MARKER},
748+
text=f'{{"message": "{SECRET_BODY_MARKER}"}}',
749+
),
750+
)
751+
752+
async def scenario():
753+
adapter = _owned_adapter(handler, strict_http=False)
754+
with pytest.warns(MlbHttpCompatibilityWarning) as warning_info:
755+
await adapter.get(endpoint="sports")
756+
return [str(warning.message) for warning in warning_info]
757+
758+
messages = run_async(scenario())
759+
assert len(messages) == 1
760+
assert SECRET_BODY_MARKER not in messages[0]
761+
assert SECRET_HEADER_MARKER not in messages[0]
762+
assert "X-Debug-Token" not in messages[0]
763+
764+
765+
def test_compatibility_warning_points_to_awaiting_caller_line():
766+
"""The warning is attributed to the awaiting caller, not package internals.
767+
768+
Mirrors test_http_warnings.test_compatibility_warning_points_to_direct_
769+
adapter_caller_line for an awaited call.
770+
"""
771+
handler = _ScriptedHandler(_response(403))
772+
773+
async def scenario():
774+
adapter = _owned_adapter(handler, strict_http=False)
775+
with warnings.catch_warnings(record=True) as caught:
776+
warnings.simplefilter("always", MlbHttpCompatibilityWarning)
777+
expected_lineno = inspect.currentframe().f_lineno + 1
778+
await adapter.get(endpoint="sports")
779+
return caught, expected_lineno
780+
781+
caught, expected_lineno = run_async(scenario())
782+
compatibility = [
783+
warning
784+
for warning in caught
785+
if issubclass(warning.category, MlbHttpCompatibilityWarning)
786+
]
787+
assert len(compatibility) == 1
788+
assert compatibility[0].filename == __file__
789+
assert compatibility[0].lineno == expected_lineno
790+
791+
792+
# --- Structured MlbHttpError context ---
793+
794+
795+
def test_error_context_extraction_failure_does_not_replace_http_error():
796+
"""A broken optional-context extraction must not hide the HTTP failure.
797+
798+
The best-effort response context is a debugging aid, so a failure while
799+
collecting it degrades that one field instead of raising something other
800+
than the original MlbHttpError.
801+
"""
802+
handler = _ScriptedHandler(
803+
_response(500, text='{"message": "Internal error occurred"}'),
804+
)
805+
806+
async def scenario():
807+
adapter = _owned_adapter(handler)
808+
with patch(
809+
"mlbstatsapi._http._extract_error_response_data",
810+
side_effect=RuntimeError("error-context extraction failed"),
811+
):
812+
with patch(SLEEP_TARGET, new_callable=AsyncMock):
813+
with pytest.raises(MlbHttpError) as exc_info:
814+
await adapter.get(endpoint="sports")
815+
return exc_info.value
816+
817+
error = run_async(scenario())
818+
assert error.status_code == 500
819+
assert error.reason == HTTP_REASON_BY_STATUS[500]
820+
assert error.method == "GET"
821+
assert error.url == f"{BASE_URL}sports"
822+
assert error.response_data is None
823+
# The independent excerpt extraction still succeeds.
824+
assert "Internal error occurred" in (error.body_excerpt or "")
825+
826+
686827
# --- Versioned User-Agent ---
687828

688829

0 commit comments

Comments
 (0)