"""Tests for shared HTTP helpers. Written FIRST (TDD RED phase).""" import base64 import httpx import pytest from release_agent.exceptions import ( AuthenticationError, NotFoundError, RateLimitError, ServiceError, ServiceUnavailableError, ) from release_agent.tools._http import build_auth_header, raise_for_status # --------------------------------------------------------------------------- # raise_for_status tests # --------------------------------------------------------------------------- def _make_response(status_code: int, headers: dict | None = None) -> httpx.Response: """Build a minimal httpx.Response with the given status code.""" return httpx.Response( status_code=status_code, headers=headers or {}, content=b"{}", request=httpx.Request("GET", "https://example.com"), ) class TestRaiseForStatus: """Tests for raise_for_status helper.""" def test_2xx_does_not_raise(self) -> None: for code in [200, 201, 204]: response = _make_response(code) # Should not raise anything raise_for_status(response, service="test") def test_401_raises_authentication_error(self) -> None: response = _make_response(401) with pytest.raises(AuthenticationError) as exc_info: raise_for_status(response, service="azdo") assert exc_info.value.service == "azdo" assert exc_info.value.status_code == 401 def test_403_raises_authentication_error(self) -> None: response = _make_response(403) with pytest.raises(AuthenticationError) as exc_info: raise_for_status(response, service="jira") assert exc_info.value.status_code == 403 def test_404_raises_not_found_error(self) -> None: response = _make_response(404) with pytest.raises(NotFoundError) as exc_info: raise_for_status(response, service="azdo") assert exc_info.value.service == "azdo" assert exc_info.value.status_code == 404 def test_429_raises_rate_limit_error(self) -> None: response = _make_response(429) with pytest.raises(RateLimitError) as exc_info: raise_for_status(response, service="jira") assert exc_info.value.status_code == 429 def test_429_with_retry_after_header_populates_retry_after(self) -> None: response = _make_response(429, headers={"Retry-After": "60"}) with pytest.raises(RateLimitError) as exc_info: raise_for_status(response, service="jira") assert exc_info.value.retry_after == 60 def test_429_without_retry_after_header_retry_after_is_none(self) -> None: response = _make_response(429) with pytest.raises(RateLimitError) as exc_info: raise_for_status(response, service="jira") assert exc_info.value.retry_after is None def test_503_raises_service_unavailable(self) -> None: response = _make_response(503) with pytest.raises(ServiceUnavailableError) as exc_info: raise_for_status(response, service="slack") assert exc_info.value.status_code == 503 def test_500_raises_service_error(self) -> None: response = _make_response(500) with pytest.raises(ServiceError) as exc_info: raise_for_status(response, service="azdo") assert exc_info.value.status_code == 500 assert exc_info.value.service == "azdo" def test_400_raises_service_error(self) -> None: response = _make_response(400) with pytest.raises(ServiceError) as exc_info: raise_for_status(response, service="jira") assert exc_info.value.status_code == 400 def test_422_raises_service_error(self) -> None: response = _make_response(422) with pytest.raises(ServiceError): raise_for_status(response, service="azdo") def test_service_name_propagated_in_all_errors(self) -> None: """Each error type must carry the service name.""" cases = [ (401, AuthenticationError), (404, NotFoundError), (429, RateLimitError), (503, ServiceUnavailableError), (500, ServiceError), ] for code, exc_type in cases: response = _make_response(code) with pytest.raises(exc_type) as exc_info: raise_for_status(response, service="my-service") assert exc_info.value.service == "my-service" def test_3xx_does_not_raise(self) -> None: """Redirects are not errors (httpx follows them).""" response = _make_response(301) raise_for_status(response, service="test") # --------------------------------------------------------------------------- # build_auth_header tests # --------------------------------------------------------------------------- class TestBuildAuthHeader: """Tests for build_auth_header helper.""" def test_returns_authorization_key(self) -> None: header = build_auth_header("user", "pass") assert "Authorization" in header def test_returns_basic_scheme(self) -> None: header = build_auth_header("user", "pass") assert header["Authorization"].startswith("Basic ") def test_value_is_base64_encoded(self) -> None: header = build_auth_header("user", "pass") encoded_part = header["Authorization"].removeprefix("Basic ") decoded = base64.b64decode(encoded_part).decode() assert decoded == "user:pass" def test_empty_username(self) -> None: # PAT auth uses empty username with token as password header = build_auth_header("", "my-token") encoded_part = header["Authorization"].removeprefix("Basic ") decoded = base64.b64decode(encoded_part).decode() assert decoded == ":my-token" def test_special_characters_in_password(self) -> None: header = build_auth_header("user", "p@ss!#$%") encoded_part = header["Authorization"].removeprefix("Basic ") decoded = base64.b64decode(encoded_part).decode() assert decoded == "user:p@ss!#$%" def test_returns_dict(self) -> None: result = build_auth_header("u", "p") assert isinstance(result, dict) def test_result_is_immutable_dict(self) -> None: result = build_auth_header("u", "p") # Ensure only the Authorization key is present assert list(result.keys()) == ["Authorization"] # --------------------------------------------------------------------------- # Edge case coverage for _extract_detail and _parse_retry_after # --------------------------------------------------------------------------- class TestExtractDetailEdgeCases: """Tests for the private _extract_detail helper via raise_for_status.""" def test_non_dict_body_still_raises_service_error(self) -> None: """A JSON array body (non-dict) should still raise ServiceError.""" response = httpx.Response( status_code=500, content=b'["error", "list"]', request=httpx.Request("GET", "https://example.com"), ) with pytest.raises(ServiceError): raise_for_status(response, service="test") def test_invalid_json_body_still_raises(self) -> None: """A non-JSON response body should still raise ServiceError.""" response = httpx.Response( status_code=500, content=b"Internal Server Error (plain text)", request=httpx.Request("GET", "https://example.com"), ) with pytest.raises(ServiceError): raise_for_status(response, service="test") def test_429_with_non_integer_retry_after_retry_after_is_none(self) -> None: """A non-integer Retry-After value should result in retry_after=None.""" response = httpx.Response( status_code=429, headers={"Retry-After": "Wed, 21 Oct 2015 07:28:00 GMT"}, content=b"{}", request=httpx.Request("GET", "https://example.com"), ) with pytest.raises(RateLimitError) as exc_info: raise_for_status(response, service="test") assert exc_info.value.retry_after is None