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.
This commit is contained in:
205
tests/tools/test_http.py
Normal file
205
tests/tools/test_http.py
Normal file
@@ -0,0 +1,205 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user