Files
billo-release-agent/tests/api/test_webhooks.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

219 lines
7.5 KiB
Python

"""Tests for webhook endpoint. Written FIRST (TDD RED phase)."""
import asyncio
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from release_agent.api.webhooks import (
_validate_webhook_secret,
router as webhook_router,
)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
VALID_SECRET = "super-secret-webhook-key"
_COMPLETED_PR_PAYLOAD = {
"subscription_id": "sub-1",
"event_type": "git.pullrequest.updated",
"resource": {
"repository": {
"id": "repo-1",
"name": "my-repo",
"web_url": "https://dev.azure.com/org/project/_git/my-repo",
},
"pull_request_id": 42,
"title": "feat: add feature",
"source_ref_name": "refs/heads/feature/BILL-123-add-feature",
"target_ref_name": "refs/heads/main",
"status": "completed",
"closed_date": "2024-01-15T10:00:00Z",
},
}
_ACTIVE_PR_PAYLOAD = {
"subscription_id": "sub-2",
"event_type": "git.pullrequest.updated",
"resource": {
"repository": {
"id": "repo-1",
"name": "my-repo",
"web_url": "https://dev.azure.com/org/project/_git/my-repo",
},
"pull_request_id": 43,
"title": "WIP: work in progress",
"source_ref_name": "refs/heads/feature/BILL-456",
"target_ref_name": "refs/heads/main",
"status": "active",
"closed_date": None,
},
}
def _make_test_app(webhook_secret: str = VALID_SECRET) -> FastAPI:
"""Return a FastAPI app with mocked state for webhook tests."""
app = FastAPI()
app.include_router(webhook_router)
mock_settings = MagicMock()
mock_settings.webhook_secret.get_secret_value.return_value = webhook_secret
mock_graphs = {
"pr_completed": MagicMock(),
"release": MagicMock(),
}
mock_clients = MagicMock()
mock_pool = MagicMock()
# background_tasks set tracked on state
app.state.settings = mock_settings
app.state.graphs = mock_graphs
app.state.tool_clients = mock_clients
app.state.db_pool = mock_pool
app.state.background_tasks = set()
return app
# ---------------------------------------------------------------------------
# _validate_webhook_secret (unit tests, pure function)
# ---------------------------------------------------------------------------
class TestValidateWebhookSecret:
def test_valid_secret_returns_true(self) -> None:
assert _validate_webhook_secret("mysecret", "mysecret") is True
def test_wrong_secret_returns_false(self) -> None:
assert _validate_webhook_secret("wrong", "mysecret") is False
def test_empty_header_returns_false(self) -> None:
assert _validate_webhook_secret("", "mysecret") is False
def test_none_header_returns_false(self) -> None:
assert _validate_webhook_secret(None, "mysecret") is False # type: ignore[arg-type]
def test_uses_constant_time_comparison(self) -> None:
# Should not raise even for very different lengths
assert _validate_webhook_secret("a", "very-long-secret-value") is False
def test_empty_expected_rejects_all(self) -> None:
# Empty expected secret = auth misconfigured, reject everything
assert _validate_webhook_secret("", "") is False
assert _validate_webhook_secret("any-value", "") is False
assert _validate_webhook_secret(None, "") is False
# ---------------------------------------------------------------------------
# POST /webhooks/azdo — integration tests via TestClient
# ---------------------------------------------------------------------------
class TestWebhookEndpoint:
def test_valid_completed_pr_returns_202(self) -> None:
app = _make_test_app()
with patch(
"release_agent.api.webhooks.asyncio.create_task", return_value=MagicMock()
):
with TestClient(app, raise_server_exceptions=True) as client:
response = client.post(
"/webhooks/azdo",
json=_COMPLETED_PR_PAYLOAD,
headers={"X-Webhook-Secret": VALID_SECRET},
)
assert response.status_code == 202
data = response.json()
assert "thread_id" in data
assert "message" in data
def test_missing_secret_header_returns_401(self) -> None:
app = _make_test_app()
with TestClient(app) as client:
response = client.post(
"/webhooks/azdo",
json=_COMPLETED_PR_PAYLOAD,
)
assert response.status_code == 401
def test_wrong_secret_header_returns_401(self) -> None:
app = _make_test_app()
with TestClient(app) as client:
response = client.post(
"/webhooks/azdo",
json=_COMPLETED_PR_PAYLOAD,
headers={"X-Webhook-Secret": "wrong-secret"},
)
assert response.status_code == 401
def test_invalid_payload_returns_422(self) -> None:
app = _make_test_app()
with TestClient(app) as client:
response = client.post(
"/webhooks/azdo",
json={"invalid": "payload"},
headers={"X-Webhook-Secret": VALID_SECRET},
)
assert response.status_code == 422
def test_active_pr_event_returns_200_ignored(self) -> None:
app = _make_test_app()
with TestClient(app) as client:
response = client.post(
"/webhooks/azdo",
json=_ACTIVE_PR_PAYLOAD,
headers={"X-Webhook-Secret": VALID_SECRET},
)
assert response.status_code == 200
data = response.json()
assert "ignored" in data.get("message", "").lower() or "ignored" in str(data).lower()
def test_completed_pr_thread_id_is_string(self) -> None:
app = _make_test_app()
with patch(
"release_agent.api.webhooks.asyncio.create_task", return_value=MagicMock()
):
with TestClient(app) as client:
response = client.post(
"/webhooks/azdo",
json=_COMPLETED_PR_PAYLOAD,
headers={"X-Webhook-Secret": VALID_SECRET},
)
assert response.status_code == 202
assert isinstance(response.json()["thread_id"], str)
def test_completed_pr_schedules_background_task(self) -> None:
app = _make_test_app()
task_mock = MagicMock()
with patch(
"release_agent.api.webhooks.asyncio.create_task", return_value=task_mock
) as mock_create:
with TestClient(app) as client:
client.post(
"/webhooks/azdo",
json=_COMPLETED_PR_PAYLOAD,
headers={"X-Webhook-Secret": VALID_SECRET},
)
mock_create.assert_called_once()
# ---------------------------------------------------------------------------
# Error response shape
# ---------------------------------------------------------------------------
class TestWebhookErrorShape:
def test_401_has_detail_field(self) -> None:
app = _make_test_app()
with TestClient(app) as client:
response = client.post(
"/webhooks/azdo",
json=_COMPLETED_PR_PAYLOAD,
)
assert response.status_code == 401
# FastAPI HTTPException returns {"detail": ...}
assert "detail" in response.json()