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.
206 lines
8.0 KiB
Python
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
|