Files
billo-release-agent/tests/tools/test_http.py
Yaojia Wang f5c2733cfb feat: initial commit — Billo Release Agent (LangGraph)
LangGraph-based release automation agent with:
- PR discovery (webhook + polling)
- AI code review via Claude Code CLI (subscription-based)
- Auto-create Jira tickets for PRs without ticket ID
- Jira ticket lifecycle management (code review -> staging -> done)
- CI/CD pipeline trigger, polling, and approval gates
- Slack interactive messages with approval buttons
- Per-repo semantic versioning
- PostgreSQL persistence (threads, staging, releases)
- FastAPI API (webhooks, approvals, status, manual triggers)
- Docker Compose deployment

1069 tests, 95%+ coverage.
2026-03-24 17:38:23 +01:00

206 lines
8.0 KiB
Python

"""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