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:
Yaojia Wang
2026-03-24 17:38:23 +01:00
commit f5c2733cfb
104 changed files with 19721 additions and 0 deletions

0
tests/api/__init__.py Normal file
View File

259
tests/api/test_approvals.py Normal file
View File

@@ -0,0 +1,259 @@
"""Tests for approvals endpoint. Written FIRST (TDD RED phase)."""
from datetime import datetime, timezone
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from release_agent.api.approvals import router as approvals_router
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_test_app(
*,
interrupted_threads: list[dict] | None = None,
graph_resume_result: dict | None = None,
) -> FastAPI:
"""Return a FastAPI app with mocked state for approvals tests."""
app = FastAPI()
app.include_router(approvals_router)
if interrupted_threads is None:
interrupted_threads = []
mock_settings = MagicMock()
mock_settings.operator_token.get_secret_value.return_value = ""
mock_graphs = {
"pr_completed": MagicMock(),
"release": MagicMock(),
}
mock_clients = MagicMock()
# Mock pool that returns interrupted threads from DB
mock_pool = MagicMock()
mock_conn = AsyncMock()
mock_cursor = AsyncMock()
rows = [
(
t["thread_id"],
t.get("graph_name", "pr_completed"),
t.get("interrupt_value", "Confirm?"),
t.get("created_at", datetime.now(tz=timezone.utc)),
t.get("repo_name"),
t.get("pr_id"),
t.get("version"),
)
for t in interrupted_threads
]
mock_cursor.fetchall = AsyncMock(return_value=rows)
mock_cursor.fetchone = AsyncMock(return_value=("pr_completed",))
mock_cursor.__aenter__ = AsyncMock(return_value=mock_cursor)
mock_cursor.__aexit__ = AsyncMock(return_value=False)
mock_conn.cursor = MagicMock(return_value=mock_cursor)
mock_conn.__aenter__ = AsyncMock(return_value=mock_conn)
mock_conn.__aexit__ = AsyncMock(return_value=False)
mock_pool.connection = MagicMock(return_value=mock_conn)
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
# ---------------------------------------------------------------------------
# POST /approvals/{thread_id}
# ---------------------------------------------------------------------------
class TestPostApproval:
def test_valid_merge_decision_returns_200(self) -> None:
app = _make_test_app()
mock_graph = MagicMock()
mock_graph.ainvoke = AsyncMock(return_value={"messages": ["done"]})
app.state.graphs["pr_completed"] = mock_graph
with patch("release_agent.api.approvals._resume_graph", new_callable=AsyncMock) as mock_resume:
mock_resume.return_value = {"messages": ["resumed"]}
with TestClient(app) as client:
response = client.post(
"/approvals/thread-123",
json={"decision": "merge"},
)
assert response.status_code == 200
data = response.json()
assert data["thread_id"] == "thread-123"
assert "status" in data
assert "message" in data
def test_valid_cancel_decision_returns_200(self) -> None:
app = _make_test_app()
with patch("release_agent.api.approvals._resume_graph", new_callable=AsyncMock) as mock_resume:
mock_resume.return_value = {"messages": ["cancelled"]}
with TestClient(app) as client:
response = client.post(
"/approvals/thread-456",
json={"decision": "cancel"},
)
assert response.status_code == 200
def test_invalid_decision_returns_422(self) -> None:
app = _make_test_app()
with TestClient(app) as client:
response = client.post(
"/approvals/thread-123",
json={"decision": "invalid_decision"},
)
assert response.status_code == 422
def test_missing_decision_returns_422(self) -> None:
app = _make_test_app()
with TestClient(app) as client:
response = client.post(
"/approvals/thread-123",
json={},
)
assert response.status_code == 422
def test_response_contains_thread_id(self) -> None:
app = _make_test_app()
with patch("release_agent.api.approvals._resume_graph", new_callable=AsyncMock) as mock_resume:
mock_resume.return_value = {}
with TestClient(app) as client:
response = client.post(
"/approvals/my-thread-id",
json={"decision": "approve"},
)
assert response.json()["thread_id"] == "my-thread-id"
def test_approve_decision_returns_200(self) -> None:
app = _make_test_app()
with patch("release_agent.api.approvals._resume_graph", new_callable=AsyncMock) as mock_resume:
mock_resume.return_value = {}
with TestClient(app) as client:
response = client.post(
"/approvals/t1",
json={"decision": "approve"},
)
assert response.status_code == 200
def test_skip_decision_returns_200(self) -> None:
app = _make_test_app()
with patch("release_agent.api.approvals._resume_graph", new_callable=AsyncMock) as mock_resume:
mock_resume.return_value = {}
with TestClient(app) as client:
response = client.post(
"/approvals/t1",
json={"decision": "skip"},
)
assert response.status_code == 200
def test_trigger_decision_returns_200(self) -> None:
app = _make_test_app()
with patch("release_agent.api.approvals._resume_graph", new_callable=AsyncMock) as mock_resume:
mock_resume.return_value = {}
with TestClient(app) as client:
response = client.post(
"/approvals/t1",
json={"decision": "trigger"},
)
assert response.status_code == 200
# ---------------------------------------------------------------------------
# GET /approvals/pending
# ---------------------------------------------------------------------------
class TestGetPendingApprovals:
def test_empty_pending_returns_200(self) -> None:
app = _make_test_app(interrupted_threads=[])
with TestClient(app) as client:
response = client.get("/approvals/pending")
assert response.status_code == 200
data = response.json()
assert data["count"] == 0
assert data["items"] == []
def test_pending_approvals_list_structure(self) -> None:
now = datetime.now(tz=timezone.utc)
threads = [
{
"thread_id": "t1",
"graph_name": "pr_completed",
"interrupt_value": "Confirm merge?",
"created_at": now,
"repo_name": "my-repo",
"pr_id": "42",
"version": "v1.0.0",
}
]
app = _make_test_app(interrupted_threads=threads)
with TestClient(app) as client:
response = client.get("/approvals/pending")
assert response.status_code == 200
data = response.json()
assert data["count"] == 1
assert data["items"][0]["thread_id"] == "t1"
assert data["items"][0]["graph_name"] == "pr_completed"
def test_multiple_pending_approvals(self) -> None:
now = datetime.now(tz=timezone.utc)
threads = [
{
"thread_id": f"t{i}",
"graph_name": "pr_completed",
"interrupt_value": "Confirm?",
"created_at": now,
"repo_name": None,
"pr_id": None,
"version": None,
}
for i in range(3)
]
app = _make_test_app(interrupted_threads=threads)
with TestClient(app) as client:
response = client.get("/approvals/pending")
assert response.status_code == 200
data = response.json()
assert data["count"] == 3
assert len(data["items"]) == 3
def test_pending_approval_optional_fields_nullable(self) -> None:
now = datetime.now(tz=timezone.utc)
threads = [
{
"thread_id": "t1",
"graph_name": "release",
"interrupt_value": "Run release?",
"created_at": now,
"repo_name": None,
"pr_id": None,
"version": None,
}
]
app = _make_test_app(interrupted_threads=threads)
with TestClient(app) as client:
response = client.get("/approvals/pending")
item = response.json()["items"][0]
assert item["repo_name"] is None
assert item["pr_id"] is None
assert item["version"] is None
# ---------------------------------------------------------------------------
# _resume_graph helper function tests
# ---------------------------------------------------------------------------
class TestResumeGraph:
def test_resume_graph_callable(self) -> None:
from release_agent.api.approvals import _resume_graph
import inspect
assert inspect.iscoroutinefunction(_resume_graph)

View File

@@ -0,0 +1,139 @@
"""Tests for approvals endpoints with operator token authentication.
Phase 5 - Step 3: Verifies that POST /approvals/{thread_id} and
GET /approvals/pending require operator token when configured.
Written FIRST (TDD RED phase).
"""
from datetime import datetime, timezone
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from release_agent.api.approvals import router as approvals_router
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_test_app(operator_token: str = "") -> FastAPI:
"""Return a FastAPI app with approvals router and configurable operator token."""
app = FastAPI()
app.include_router(approvals_router)
mock_settings = MagicMock()
mock_settings.operator_token.get_secret_value.return_value = operator_token
mock_pool = MagicMock()
mock_conn = AsyncMock()
mock_cursor = AsyncMock()
mock_cursor.fetchall = AsyncMock(return_value=[])
mock_cursor.fetchone = AsyncMock(return_value=("pr_completed",))
mock_cursor.__aenter__ = AsyncMock(return_value=mock_cursor)
mock_cursor.__aexit__ = AsyncMock(return_value=False)
mock_conn.cursor = MagicMock(return_value=mock_cursor)
mock_conn.__aenter__ = AsyncMock(return_value=mock_conn)
mock_conn.__aexit__ = AsyncMock(return_value=False)
mock_pool.connection = MagicMock(return_value=mock_conn)
app.state.settings = mock_settings
app.state.graphs = {
"pr_completed": MagicMock(),
"release": MagicMock(),
}
app.state.tool_clients = MagicMock()
app.state.db_pool = mock_pool
app.state.background_tasks = set()
return app
# ---------------------------------------------------------------------------
# POST /approvals/{thread_id} with auth
# ---------------------------------------------------------------------------
class TestPostApprovalWithAuth:
def test_valid_token_allows_post_approval(self) -> None:
app = _make_test_app(operator_token="secret-token")
with patch(
"release_agent.api.approvals._resume_graph", new_callable=AsyncMock
) as mock_resume:
mock_resume.return_value = {}
with TestClient(app) as client:
response = client.post(
"/approvals/thread-123",
json={"decision": "merge"},
headers={"X-Operator-Token": "secret-token"},
)
assert response.status_code == 200
def test_missing_token_rejects_post_approval(self) -> None:
app = _make_test_app(operator_token="secret-token")
with TestClient(app) as client:
response = client.post(
"/approvals/thread-123",
json={"decision": "merge"},
)
assert response.status_code == 401
def test_wrong_token_rejects_post_approval(self) -> None:
app = _make_test_app(operator_token="secret-token")
with TestClient(app) as client:
response = client.post(
"/approvals/thread-123",
json={"decision": "merge"},
headers={"X-Operator-Token": "wrong-token"},
)
assert response.status_code == 401
def test_no_auth_required_when_token_not_configured(self) -> None:
app = _make_test_app(operator_token="")
with patch(
"release_agent.api.approvals._resume_graph", new_callable=AsyncMock
) as mock_resume:
mock_resume.return_value = {}
with TestClient(app) as client:
response = client.post(
"/approvals/thread-123",
json={"decision": "merge"},
)
assert response.status_code == 200
# ---------------------------------------------------------------------------
# GET /approvals/pending with auth
# ---------------------------------------------------------------------------
class TestGetPendingApprovalsWithAuth:
def test_valid_token_allows_get_pending(self) -> None:
app = _make_test_app(operator_token="secret-token")
with TestClient(app) as client:
response = client.get(
"/approvals/pending",
headers={"X-Operator-Token": "secret-token"},
)
assert response.status_code == 200
def test_missing_token_rejects_get_pending(self) -> None:
app = _make_test_app(operator_token="secret-token")
with TestClient(app) as client:
response = client.get("/approvals/pending")
assert response.status_code == 401
def test_wrong_token_rejects_get_pending(self) -> None:
app = _make_test_app(operator_token="secret-token")
with TestClient(app) as client:
response = client.get(
"/approvals/pending",
headers={"X-Operator-Token": "wrong"},
)
assert response.status_code == 401
def test_no_auth_required_when_token_not_configured(self) -> None:
app = _make_test_app(operator_token="")
with TestClient(app) as client:
response = client.get("/approvals/pending")
assert response.status_code == 200

View File

@@ -0,0 +1,149 @@
"""Tests for API FastAPI dependencies. Written FIRST (TDD RED phase)."""
from unittest.mock import MagicMock
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from release_agent.api.dependencies import (
get_db_pool,
get_graphs,
get_settings,
get_staging_store,
get_tool_clients,
)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_app_with_state(**state_kwargs) -> FastAPI:
"""Return a minimal FastAPI app with app.state attributes set."""
app = FastAPI()
for key, value in state_kwargs.items():
setattr(app.state, key, value)
return app
# ---------------------------------------------------------------------------
# get_settings
# ---------------------------------------------------------------------------
class TestGetSettings:
def test_returns_settings_from_state(self) -> None:
mock_settings = MagicMock()
app = _make_app_with_state(settings=mock_settings)
with TestClient(app) as client:
# We test the dependency directly by simulating a request
request = MagicMock()
request.app = app
result = get_settings(request)
assert result is mock_settings
def test_raises_when_settings_missing(self) -> None:
app = FastAPI() # no state.settings
request = MagicMock()
request.app = app
with pytest.raises(AttributeError):
get_settings(request)
# ---------------------------------------------------------------------------
# get_graphs
# ---------------------------------------------------------------------------
class TestGetGraphs:
def test_returns_graphs_from_state(self) -> None:
mock_graphs = {"pr_completed": MagicMock(), "release": MagicMock()}
app = _make_app_with_state(graphs=mock_graphs)
request = MagicMock()
request.app = app
result = get_graphs(request)
assert result is mock_graphs
def test_raises_when_graphs_missing(self) -> None:
app = FastAPI()
request = MagicMock()
request.app = app
with pytest.raises(AttributeError):
get_graphs(request)
# ---------------------------------------------------------------------------
# get_tool_clients
# ---------------------------------------------------------------------------
class TestGetToolClients:
def test_returns_tool_clients_from_state(self) -> None:
mock_clients = MagicMock()
app = _make_app_with_state(tool_clients=mock_clients)
request = MagicMock()
request.app = app
result = get_tool_clients(request)
assert result is mock_clients
def test_raises_when_tool_clients_missing(self) -> None:
app = FastAPI()
request = MagicMock()
request.app = app
with pytest.raises(AttributeError):
get_tool_clients(request)
# ---------------------------------------------------------------------------
# get_staging_store
# ---------------------------------------------------------------------------
class TestGetStagingStore:
def test_returns_staging_store_from_state(self) -> None:
mock_store = MagicMock()
app = _make_app_with_state(staging_store=mock_store)
request = MagicMock()
request.app = app
result = get_staging_store(request)
assert result is mock_store
def test_raises_when_staging_store_missing(self) -> None:
app = FastAPI()
request = MagicMock()
request.app = app
with pytest.raises(AttributeError):
get_staging_store(request)
# ---------------------------------------------------------------------------
# get_db_pool
# ---------------------------------------------------------------------------
class TestGetDbPool:
def test_returns_db_pool_from_state(self) -> None:
mock_pool = MagicMock()
app = _make_app_with_state(db_pool=mock_pool)
request = MagicMock()
request.app = app
result = get_db_pool(request)
assert result is mock_pool
def test_raises_when_db_pool_missing(self) -> None:
app = FastAPI()
request = MagicMock()
request.app = app
with pytest.raises(AttributeError):
get_db_pool(request)

446
tests/api/test_internals.py Normal file
View File

@@ -0,0 +1,446 @@
"""Tests for internal async helper functions.
Tests _run_graph, _upsert_thread, _resume_graph, and exception handlers.
Written FIRST then verified (TDD GREEN phase for internal helpers).
"""
import json
from unittest.mock import AsyncMock, MagicMock, call, patch
import pytest
# ---------------------------------------------------------------------------
# _upsert_thread tests
# ---------------------------------------------------------------------------
class TestUpsertThread:
@pytest.mark.asyncio
async def test_upsert_thread_executes_sql(self) -> None:
from release_agent.api.webhooks import _upsert_thread
mock_pool = MagicMock()
mock_conn = AsyncMock()
mock_cursor = AsyncMock()
mock_cursor.execute = AsyncMock()
mock_cursor.__aenter__ = AsyncMock(return_value=mock_cursor)
mock_cursor.__aexit__ = AsyncMock(return_value=False)
mock_conn.cursor = MagicMock(return_value=mock_cursor)
mock_conn.__aenter__ = AsyncMock(return_value=mock_conn)
mock_conn.__aexit__ = AsyncMock(return_value=False)
mock_pool.connection = MagicMock(return_value=mock_conn)
await _upsert_thread(
mock_pool,
thread_id="t1",
thread_status="running",
state={"repo_name": "my-repo"},
)
mock_cursor.execute.assert_called_once()
args = mock_cursor.execute.call_args[0]
assert "agent_threads" in args[0]
assert args[1][0] == "t1"
assert args[1][4] == "running"
# state is JSON-encoded
state_json = json.loads(args[1][5])
assert state_json["repo_name"] == "my-repo"
@pytest.mark.asyncio
async def test_upsert_thread_completed_status(self) -> None:
from release_agent.api.webhooks import _upsert_thread
mock_pool = MagicMock()
mock_conn = AsyncMock()
mock_cursor = AsyncMock()
mock_cursor.execute = AsyncMock()
mock_cursor.__aenter__ = AsyncMock(return_value=mock_cursor)
mock_cursor.__aexit__ = AsyncMock(return_value=False)
mock_conn.cursor = MagicMock(return_value=mock_cursor)
mock_conn.__aenter__ = AsyncMock(return_value=mock_conn)
mock_conn.__aexit__ = AsyncMock(return_value=False)
mock_pool.connection = MagicMock(return_value=mock_conn)
await _upsert_thread(
mock_pool,
thread_id="t2",
thread_status="completed",
state={},
)
args = mock_cursor.execute.call_args[0]
assert args[1][4] == "completed"
@pytest.mark.asyncio
async def test_upsert_thread_failed_status(self) -> None:
from release_agent.api.webhooks import _upsert_thread
mock_pool = MagicMock()
mock_conn = AsyncMock()
mock_cursor = AsyncMock()
mock_cursor.execute = AsyncMock()
mock_cursor.__aenter__ = AsyncMock(return_value=mock_cursor)
mock_cursor.__aexit__ = AsyncMock(return_value=False)
mock_conn.cursor = MagicMock(return_value=mock_cursor)
mock_conn.__aenter__ = AsyncMock(return_value=mock_conn)
mock_conn.__aexit__ = AsyncMock(return_value=False)
mock_pool.connection = MagicMock(return_value=mock_conn)
await _upsert_thread(
mock_pool,
thread_id="t3",
thread_status="failed",
state={"errors": ["something went wrong"]},
)
args = mock_cursor.execute.call_args[0]
assert args[1][4] == "failed"
state_json = json.loads(args[1][5])
assert state_json["errors"] == ["something went wrong"]
# ---------------------------------------------------------------------------
# _run_graph tests
# ---------------------------------------------------------------------------
class TestRunGraph:
@pytest.mark.asyncio
async def test_run_graph_success_upserts_completed(self) -> None:
from release_agent.api.webhooks import _run_graph
mock_graph = AsyncMock()
mock_graph.ainvoke = AsyncMock(return_value={"messages": ["done"]})
mock_pool = MagicMock()
mock_conn = AsyncMock()
mock_cursor = AsyncMock()
mock_cursor.execute = AsyncMock()
mock_cursor.__aenter__ = AsyncMock(return_value=mock_cursor)
mock_cursor.__aexit__ = AsyncMock(return_value=False)
mock_conn.cursor = MagicMock(return_value=mock_cursor)
mock_conn.__aenter__ = AsyncMock(return_value=mock_conn)
mock_conn.__aexit__ = AsyncMock(return_value=False)
mock_pool.connection = MagicMock(return_value=mock_conn)
await _run_graph(
graph=mock_graph,
initial_state={"repo_name": "test"},
thread_id="t1",
tool_clients=MagicMock(),
db_pool=mock_pool,
)
# Should have been called with "running" then "completed"
calls = mock_cursor.execute.call_args_list
assert len(calls) == 2
# First call: "running", second call: "completed"
assert calls[0][0][1][4] == "running"
assert calls[1][0][1][4] == "completed"
@pytest.mark.asyncio
async def test_run_graph_failure_upserts_failed(self) -> None:
from release_agent.api.webhooks import _run_graph
mock_graph = AsyncMock()
mock_graph.ainvoke = AsyncMock(side_effect=RuntimeError("graph crashed"))
mock_pool = MagicMock()
mock_conn = AsyncMock()
mock_cursor = AsyncMock()
mock_cursor.execute = AsyncMock()
mock_cursor.__aenter__ = AsyncMock(return_value=mock_cursor)
mock_cursor.__aexit__ = AsyncMock(return_value=False)
mock_conn.cursor = MagicMock(return_value=mock_cursor)
mock_conn.__aenter__ = AsyncMock(return_value=mock_conn)
mock_conn.__aexit__ = AsyncMock(return_value=False)
mock_pool.connection = MagicMock(return_value=mock_conn)
await _run_graph(
graph=mock_graph,
initial_state={"repo_name": "test"},
thread_id="t-fail",
tool_clients=MagicMock(),
db_pool=mock_pool,
)
calls = mock_cursor.execute.call_args_list
# First call: "running", second call: "failed"
assert calls[0][0][1][4] == "running"
assert calls[1][0][1][4] == "failed"
# State should contain errors
failed_state = json.loads(calls[1][0][1][5])
assert "errors" in failed_state
@pytest.mark.asyncio
async def test_run_graph_invokes_with_correct_config(self) -> None:
from release_agent.api.webhooks import _run_graph
mock_graph = AsyncMock()
mock_graph.ainvoke = AsyncMock(return_value={})
mock_clients = MagicMock()
mock_pool = MagicMock()
mock_conn = AsyncMock()
mock_cursor = AsyncMock()
mock_cursor.execute = AsyncMock()
mock_cursor.__aenter__ = AsyncMock(return_value=mock_cursor)
mock_cursor.__aexit__ = AsyncMock(return_value=False)
mock_conn.cursor = MagicMock(return_value=mock_cursor)
mock_conn.__aenter__ = AsyncMock(return_value=mock_conn)
mock_conn.__aexit__ = AsyncMock(return_value=False)
mock_pool.connection = MagicMock(return_value=mock_conn)
await _run_graph(
graph=mock_graph,
initial_state={"repo_name": "test"},
thread_id="t-config",
tool_clients=mock_clients,
db_pool=mock_pool,
)
call_args = mock_graph.ainvoke.call_args
config = call_args[1]["config"]
assert config["configurable"]["thread_id"] == "t-config"
assert config["configurable"]["clients"] is mock_clients
# ---------------------------------------------------------------------------
# _resume_graph tests
# ---------------------------------------------------------------------------
class TestResumeGraphInternal:
@pytest.mark.asyncio
async def test_resume_graph_success(self) -> None:
from release_agent.api.approvals import _resume_graph
mock_graph = AsyncMock()
mock_graph.ainvoke = AsyncMock(return_value={"result": "ok"})
mock_pool = MagicMock()
mock_conn = AsyncMock()
mock_cursor = AsyncMock()
mock_cursor.execute = AsyncMock()
mock_cursor.__aenter__ = AsyncMock(return_value=mock_cursor)
mock_cursor.__aexit__ = AsyncMock(return_value=False)
mock_conn.cursor = MagicMock(return_value=mock_cursor)
mock_conn.__aenter__ = AsyncMock(return_value=mock_conn)
mock_conn.__aexit__ = AsyncMock(return_value=False)
mock_pool.connection = MagicMock(return_value=mock_conn)
result = await _resume_graph(
graph=mock_graph,
thread_id="t1",
decision="merge",
tool_clients=MagicMock(),
db_pool=mock_pool,
)
assert result == {"result": "ok"}
mock_graph.ainvoke.assert_called_once()
# Verify the decision was passed
call_args = mock_graph.ainvoke.call_args
assert call_args[0][0]["decision"] == "merge"
@pytest.mark.asyncio
async def test_resume_graph_failure_re_raises(self) -> None:
from release_agent.api.approvals import _resume_graph
mock_graph = AsyncMock()
mock_graph.ainvoke = AsyncMock(side_effect=RuntimeError("resume failed"))
mock_pool = MagicMock()
mock_conn = AsyncMock()
mock_cursor = AsyncMock()
mock_cursor.execute = AsyncMock()
mock_cursor.__aenter__ = AsyncMock(return_value=mock_cursor)
mock_cursor.__aexit__ = AsyncMock(return_value=False)
mock_conn.cursor = MagicMock(return_value=mock_cursor)
mock_conn.__aenter__ = AsyncMock(return_value=mock_conn)
mock_conn.__aexit__ = AsyncMock(return_value=False)
mock_pool.connection = MagicMock(return_value=mock_conn)
with pytest.raises(RuntimeError, match="resume failed"):
await _resume_graph(
graph=mock_graph,
thread_id="t1",
decision="cancel",
tool_clients=MagicMock(),
db_pool=mock_pool,
)
@pytest.mark.asyncio
async def test_resume_graph_upserts_completed_on_success(self) -> None:
from release_agent.api.approvals import _resume_graph
mock_graph = AsyncMock()
mock_graph.ainvoke = AsyncMock(return_value={"messages": ["done"]})
mock_pool = MagicMock()
mock_conn = AsyncMock()
mock_cursor = AsyncMock()
mock_cursor.execute = AsyncMock()
mock_cursor.__aenter__ = AsyncMock(return_value=mock_cursor)
mock_cursor.__aexit__ = AsyncMock(return_value=False)
mock_conn.cursor = MagicMock(return_value=mock_cursor)
mock_conn.__aenter__ = AsyncMock(return_value=mock_conn)
mock_conn.__aexit__ = AsyncMock(return_value=False)
mock_pool.connection = MagicMock(return_value=mock_conn)
await _resume_graph(
graph=mock_graph,
thread_id="t-success",
decision="approve",
tool_clients=MagicMock(),
db_pool=mock_pool,
)
# The last execute call should be "completed"
last_call = mock_cursor.execute.call_args_list[-1]
assert last_call[0][1][4] == "completed"
@pytest.mark.asyncio
async def test_resume_graph_upserts_failed_on_exception(self) -> None:
from release_agent.api.approvals import _resume_graph
mock_graph = AsyncMock()
mock_graph.ainvoke = AsyncMock(side_effect=ValueError("bad"))
mock_pool = MagicMock()
mock_conn = AsyncMock()
mock_cursor = AsyncMock()
mock_cursor.execute = AsyncMock()
mock_cursor.__aenter__ = AsyncMock(return_value=mock_cursor)
mock_cursor.__aexit__ = AsyncMock(return_value=False)
mock_conn.cursor = MagicMock(return_value=mock_cursor)
mock_conn.__aenter__ = AsyncMock(return_value=mock_conn)
mock_conn.__aexit__ = AsyncMock(return_value=False)
mock_pool.connection = MagicMock(return_value=mock_conn)
with pytest.raises(ValueError):
await _resume_graph(
graph=mock_graph,
thread_id="t-fail",
decision="skip",
tool_clients=MagicMock(),
db_pool=mock_pool,
)
last_call = mock_cursor.execute.call_args_list[-1]
assert last_call[0][1][4] == "failed"
# ---------------------------------------------------------------------------
# run_graph_in_background tests (main.py)
# ---------------------------------------------------------------------------
class TestRunGraphInBackground:
@pytest.mark.asyncio
async def test_success_with_db_pool(self) -> None:
from release_agent.main import run_graph_in_background
mock_graph = AsyncMock()
mock_graph.ainvoke = AsyncMock(return_value={"done": True})
mock_pool = MagicMock()
mock_conn = AsyncMock()
mock_cursor = AsyncMock()
mock_cursor.execute = AsyncMock()
mock_cursor.__aenter__ = AsyncMock(return_value=mock_cursor)
mock_cursor.__aexit__ = AsyncMock(return_value=False)
mock_conn.cursor = MagicMock(return_value=mock_cursor)
mock_conn.__aenter__ = AsyncMock(return_value=mock_conn)
mock_conn.__aexit__ = AsyncMock(return_value=False)
mock_pool.connection = MagicMock(return_value=mock_conn)
await run_graph_in_background(
graph=mock_graph,
initial_state={"repo_name": "test"},
thread_id="t-bg",
db_pool=mock_pool,
)
calls = mock_cursor.execute.call_args_list
assert calls[0][0][1][4] == "running"
assert calls[1][0][1][4] == "completed"
@pytest.mark.asyncio
async def test_failure_with_db_pool(self) -> None:
from release_agent.main import run_graph_in_background
mock_graph = AsyncMock()
mock_graph.ainvoke = AsyncMock(side_effect=RuntimeError("bg failed"))
mock_pool = MagicMock()
mock_conn = AsyncMock()
mock_cursor = AsyncMock()
mock_cursor.execute = AsyncMock()
mock_cursor.__aenter__ = AsyncMock(return_value=mock_cursor)
mock_cursor.__aexit__ = AsyncMock(return_value=False)
mock_conn.cursor = MagicMock(return_value=mock_cursor)
mock_conn.__aenter__ = AsyncMock(return_value=mock_conn)
mock_conn.__aexit__ = AsyncMock(return_value=False)
mock_pool.connection = MagicMock(return_value=mock_conn)
await run_graph_in_background(
graph=mock_graph,
initial_state={},
thread_id="t-bg-fail",
db_pool=mock_pool,
)
last_call = mock_cursor.execute.call_args_list[-1]
assert last_call[0][1][4] == "failed"
@pytest.mark.asyncio
async def test_success_without_db_pool(self) -> None:
"""run_graph_in_background works even without a db_pool."""
from release_agent.main import run_graph_in_background
mock_graph = AsyncMock()
mock_graph.ainvoke = AsyncMock(return_value={})
# Should not raise even with no db_pool
await run_graph_in_background(
graph=mock_graph,
initial_state={},
thread_id="t-no-pool",
db_pool=None,
)
mock_graph.ainvoke.assert_called_once()
# ---------------------------------------------------------------------------
# Exception handler tests (main.py)
# ---------------------------------------------------------------------------
class TestExceptionHandlerFunctions:
@pytest.mark.asyncio
async def test_release_agent_error_handler_returns_500(self) -> None:
from release_agent.main import _release_agent_error_handler
from release_agent.exceptions import ServiceError
request = MagicMock()
exc = ServiceError(service="azdo", status_code=503, detail="unavailable")
response = await _release_agent_error_handler(request, exc)
assert response.status_code == 500
body = json.loads(response.body)
assert body["error"] == "ServiceError"
assert "unavailable" in body["detail"]
@pytest.mark.asyncio
async def test_generic_error_handler_returns_500(self) -> None:
from release_agent.main import _generic_error_handler
request = MagicMock()
exc = ValueError("something generic")
response = await _generic_error_handler(request, exc)
assert response.status_code == 500
body = json.loads(response.body)
assert body["error"] == "InternalServerError"
assert "An unexpected error occurred" in body["detail"]

294
tests/api/test_models.py Normal file
View File

@@ -0,0 +1,294 @@
"""Tests for API request/response models. Written FIRST (TDD RED phase)."""
from datetime import datetime, timezone
import pytest
from pydantic import ValidationError
from release_agent.api.models import (
ApprovalDecision,
ApprovalResponse,
ErrorResponse,
HealthResponse,
ManualReleaseRequest,
ManualTriggerResponse,
PendingApproval,
PendingApprovalsResponse,
ReleaseVersionListResponse,
StagingResponse,
WebhookResponse,
)
# ---------------------------------------------------------------------------
# WebhookResponse
# ---------------------------------------------------------------------------
class TestWebhookResponse:
def test_valid_construction(self) -> None:
resp = WebhookResponse(thread_id="thread-123", message="scheduled")
assert resp.thread_id == "thread-123"
assert resp.message == "scheduled"
def test_frozen_immutable(self) -> None:
resp = WebhookResponse(thread_id="t1", message="ok")
with pytest.raises((TypeError, ValidationError)):
resp.thread_id = "other" # type: ignore[misc]
def test_missing_thread_id_raises(self) -> None:
with pytest.raises(ValidationError):
WebhookResponse(message="ok") # type: ignore[call-arg]
def test_missing_message_raises(self) -> None:
with pytest.raises(ValidationError):
WebhookResponse(thread_id="t1") # type: ignore[call-arg]
# ---------------------------------------------------------------------------
# ApprovalDecision
# ---------------------------------------------------------------------------
class TestApprovalDecision:
def test_merge_decision(self) -> None:
d = ApprovalDecision(decision="merge")
assert d.decision == "merge"
def test_cancel_decision(self) -> None:
d = ApprovalDecision(decision="cancel")
assert d.decision == "cancel"
def test_approve_decision(self) -> None:
d = ApprovalDecision(decision="approve")
assert d.decision == "approve"
def test_skip_decision(self) -> None:
d = ApprovalDecision(decision="skip")
assert d.decision == "skip"
def test_trigger_decision(self) -> None:
d = ApprovalDecision(decision="trigger")
assert d.decision == "trigger"
def test_invalid_decision_raises(self) -> None:
with pytest.raises(ValidationError):
ApprovalDecision(decision="invalid") # type: ignore[arg-type]
def test_frozen_immutable(self) -> None:
d = ApprovalDecision(decision="merge")
with pytest.raises((TypeError, ValidationError)):
d.decision = "cancel" # type: ignore[misc]
# ---------------------------------------------------------------------------
# ApprovalResponse
# ---------------------------------------------------------------------------
class TestApprovalResponse:
def test_valid_construction(self) -> None:
resp = ApprovalResponse(
thread_id="t1", status="resumed", message="Graph resumed"
)
assert resp.thread_id == "t1"
assert resp.status == "resumed"
assert resp.message == "Graph resumed"
def test_frozen_immutable(self) -> None:
resp = ApprovalResponse(thread_id="t1", status="ok", message="m")
with pytest.raises((TypeError, ValidationError)):
resp.status = "bad" # type: ignore[misc]
# ---------------------------------------------------------------------------
# PendingApproval
# ---------------------------------------------------------------------------
class TestPendingApproval:
def test_full_construction(self) -> None:
now = datetime.now(tz=timezone.utc)
pa = PendingApproval(
thread_id="t1",
graph_name="pr_completed",
interrupt_value="Confirm merge?",
created_at=now,
repo_name="my-repo",
pr_id="42",
version="v1.2.3",
)
assert pa.thread_id == "t1"
assert pa.graph_name == "pr_completed"
assert pa.repo_name == "my-repo"
assert pa.pr_id == "42"
assert pa.version == "v1.2.3"
def test_optional_fields_none(self) -> None:
now = datetime.now(tz=timezone.utc)
pa = PendingApproval(
thread_id="t1",
graph_name="release",
interrupt_value="Confirm?",
created_at=now,
)
assert pa.repo_name is None
assert pa.pr_id is None
assert pa.version is None
def test_frozen_immutable(self) -> None:
now = datetime.now(tz=timezone.utc)
pa = PendingApproval(
thread_id="t1",
graph_name="g",
interrupt_value="v",
created_at=now,
)
with pytest.raises((TypeError, ValidationError)):
pa.thread_id = "other" # type: ignore[misc]
# ---------------------------------------------------------------------------
# PendingApprovalsResponse
# ---------------------------------------------------------------------------
class TestPendingApprovalsResponse:
def test_empty_list(self) -> None:
resp = PendingApprovalsResponse(items=[], count=0)
assert resp.items == []
assert resp.count == 0
def test_with_items(self) -> None:
now = datetime.now(tz=timezone.utc)
item = PendingApproval(
thread_id="t1",
graph_name="g",
interrupt_value="v",
created_at=now,
)
resp = PendingApprovalsResponse(items=[item], count=1)
assert resp.count == 1
assert len(resp.items) == 1
def test_frozen_immutable(self) -> None:
resp = PendingApprovalsResponse(items=[], count=0)
with pytest.raises((TypeError, ValidationError)):
resp.count = 5 # type: ignore[misc]
# ---------------------------------------------------------------------------
# HealthResponse
# ---------------------------------------------------------------------------
class TestHealthResponse:
def test_ok_status(self) -> None:
resp = HealthResponse(status="ok", version="0.1.0", uptime_seconds=123.4)
assert resp.status == "ok"
assert resp.version == "0.1.0"
assert resp.uptime_seconds == pytest.approx(123.4)
def test_degraded_status(self) -> None:
resp = HealthResponse(status="degraded", version="0.1.0", uptime_seconds=0.0)
assert resp.status == "degraded"
def test_invalid_status_raises(self) -> None:
with pytest.raises(ValidationError):
HealthResponse(status="unknown", version="0.1.0", uptime_seconds=0.0) # type: ignore[arg-type]
def test_frozen_immutable(self) -> None:
resp = HealthResponse(status="ok", version="0.1.0", uptime_seconds=1.0)
with pytest.raises((TypeError, ValidationError)):
resp.status = "degraded" # type: ignore[misc]
# ---------------------------------------------------------------------------
# ReleaseVersionListResponse
# ---------------------------------------------------------------------------
class TestReleaseVersionListResponse:
def test_valid_construction(self) -> None:
resp = ReleaseVersionListResponse(repo="my-repo", versions=["v1.0.0", "v1.1.0"])
assert resp.repo == "my-repo"
assert resp.versions == ["v1.0.0", "v1.1.0"]
def test_empty_versions(self) -> None:
resp = ReleaseVersionListResponse(repo="r", versions=[])
assert resp.versions == []
def test_frozen_immutable(self) -> None:
resp = ReleaseVersionListResponse(repo="r", versions=[])
with pytest.raises((TypeError, ValidationError)):
resp.repo = "other" # type: ignore[misc]
# ---------------------------------------------------------------------------
# StagingResponse
# ---------------------------------------------------------------------------
class TestStagingResponse:
def test_with_staging(self) -> None:
staging_data = {"version": "v1.0.0", "repo": "my-repo", "tickets": []}
resp = StagingResponse(repo="my-repo", staging=staging_data)
assert resp.repo == "my-repo"
assert resp.staging is not None
assert resp.staging["version"] == "v1.0.0"
def test_without_staging(self) -> None:
resp = StagingResponse(repo="my-repo", staging=None)
assert resp.staging is None
def test_frozen_immutable(self) -> None:
resp = StagingResponse(repo="r", staging=None)
with pytest.raises((TypeError, ValidationError)):
resp.repo = "other" # type: ignore[misc]
# ---------------------------------------------------------------------------
# ManualTriggerResponse
# ---------------------------------------------------------------------------
class TestManualTriggerResponse:
def test_valid_construction(self) -> None:
resp = ManualTriggerResponse(thread_id="t1", message="triggered")
assert resp.thread_id == "t1"
assert resp.message == "triggered"
def test_frozen_immutable(self) -> None:
resp = ManualTriggerResponse(thread_id="t1", message="m")
with pytest.raises((TypeError, ValidationError)):
resp.thread_id = "other" # type: ignore[misc]
# ---------------------------------------------------------------------------
# ManualReleaseRequest
# ---------------------------------------------------------------------------
class TestManualReleaseRequest:
def test_valid_construction(self) -> None:
req = ManualReleaseRequest(repo="my-repo")
assert req.repo == "my-repo"
def test_missing_repo_raises(self) -> None:
with pytest.raises(ValidationError):
ManualReleaseRequest() # type: ignore[call-arg]
def test_frozen_immutable(self) -> None:
req = ManualReleaseRequest(repo="r")
with pytest.raises((TypeError, ValidationError)):
req.repo = "other" # type: ignore[misc]
# ---------------------------------------------------------------------------
# ErrorResponse
# ---------------------------------------------------------------------------
class TestErrorResponse:
def test_error_only(self) -> None:
resp = ErrorResponse(error="Something went wrong")
assert resp.error == "Something went wrong"
assert resp.detail is None
def test_error_with_detail(self) -> None:
resp = ErrorResponse(error="Not found", detail="Thread t1 not found")
assert resp.detail == "Thread t1 not found"
def test_frozen_immutable(self) -> None:
resp = ErrorResponse(error="e")
with pytest.raises((TypeError, ValidationError)):
resp.error = "other" # type: ignore[misc]

View File

@@ -0,0 +1,111 @@
"""Tests for operator token authentication dependency.
Phase 5 - Step 3: require_operator_token FastAPI dependency.
Written FIRST (TDD RED phase).
"""
from unittest.mock import MagicMock
import pytest
from fastapi import FastAPI, Depends, HTTPException
from fastapi.testclient import TestClient
from release_agent.api.dependencies import require_operator_token
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_app_with_token(operator_token: str = "") -> FastAPI:
"""Return a minimal app with a protected route and the given token config."""
app = FastAPI()
mock_settings = MagicMock()
mock_settings.operator_token.get_secret_value.return_value = operator_token
app.state.settings = mock_settings
@app.get("/protected")
async def protected_route(_: None = Depends(require_operator_token)):
return {"ok": True}
return app
# ---------------------------------------------------------------------------
# require_operator_token tests
# ---------------------------------------------------------------------------
class TestRequireOperatorToken:
def test_valid_token_allows_access(self) -> None:
app = _make_app_with_token("super-secret-token")
with TestClient(app) as client:
response = client.get(
"/protected",
headers={"X-Operator-Token": "super-secret-token"},
)
assert response.status_code == 200
def test_missing_token_header_returns_401_when_token_configured(self) -> None:
app = _make_app_with_token("super-secret-token")
with TestClient(app) as client:
response = client.get("/protected")
assert response.status_code == 401
def test_wrong_token_returns_401(self) -> None:
app = _make_app_with_token("super-secret-token")
with TestClient(app) as client:
response = client.get(
"/protected",
headers={"X-Operator-Token": "wrong-token"},
)
assert response.status_code == 401
def test_empty_operator_token_config_skips_auth(self) -> None:
"""When operator_token is not configured (empty), all requests pass."""
app = _make_app_with_token("")
with TestClient(app) as client:
response = client.get("/protected")
assert response.status_code == 200
def test_empty_operator_token_config_passes_even_without_header(self) -> None:
app = _make_app_with_token("")
with TestClient(app) as client:
response = client.get("/protected", headers={})
assert response.status_code == 200
def test_token_comparison_is_constant_time(self) -> None:
"""Verify hmac.compare_digest is used (not == operator) — tested by checking
that the function still works correctly, not timing (which we can't test here)."""
app = _make_app_with_token("my-token")
with TestClient(app) as client:
response = client.get(
"/protected",
headers={"X-Operator-Token": "my-token"},
)
assert response.status_code == 200
def test_empty_string_token_header_rejected_when_token_configured(self) -> None:
app = _make_app_with_token("configured-token")
with TestClient(app) as client:
response = client.get(
"/protected",
headers={"X-Operator-Token": ""},
)
assert response.status_code == 401
def test_401_response_has_detail_field(self) -> None:
app = _make_app_with_token("secret")
with TestClient(app) as client:
response = client.get("/protected")
data = response.json()
assert "detail" in data
def test_valid_token_returns_correct_response_body(self) -> None:
app = _make_app_with_token("token123")
with TestClient(app) as client:
response = client.get(
"/protected",
headers={"X-Operator-Token": "token123"},
)
assert response.json() == {"ok": True}

View File

@@ -0,0 +1,473 @@
"""Tests for api/slack_interactions.py endpoint.
Written FIRST (TDD RED phase).
Tests cover:
- Signature verification (HMAC-SHA256)
- Payload parsing
- Button routing
- _resume_graph invocation
- Error handling
"""
import hashlib
import hmac
import json
import time
import urllib.parse
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from release_agent.api.slack_interactions import router as slack_interactions_router
from release_agent.api.slack_interactions import _verify_slack_signature
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
_TEST_SIGNING_SECRET = "test-signing-secret-abc"
def _make_slack_signature(*, signing_secret: str, timestamp: str, body: str) -> str:
"""Compute a valid Slack signing signature."""
base_string = f"v0:{timestamp}:{body}"
sig = hmac.new(
signing_secret.encode(),
base_string.encode(),
hashlib.sha256,
).hexdigest()
return f"v0={sig}"
def _make_test_app(
*,
signing_secret: str = _TEST_SIGNING_SECRET,
thread_graph_name: str | None = "release",
graph_result: dict | None = None,
) -> FastAPI:
"""Return a FastAPI test app with mocked state for slack interactions."""
app = FastAPI()
app.include_router(slack_interactions_router)
mock_settings = MagicMock()
mock_settings.slack_signing_secret.get_secret_value.return_value = signing_secret
mock_settings.operator_token.get_secret_value.return_value = ""
mock_graph = MagicMock()
mock_graph.ainvoke = AsyncMock(return_value=graph_result or {"messages": ["done"]})
mock_graphs = {
"release": mock_graph,
"pr_completed": MagicMock(),
}
mock_clients = MagicMock()
mock_pool = MagicMock()
mock_conn = AsyncMock()
mock_cursor = AsyncMock()
mock_cursor.fetchone = AsyncMock(
return_value=(thread_graph_name,) if thread_graph_name else None
)
mock_cursor.__aenter__ = AsyncMock(return_value=mock_cursor)
mock_cursor.__aexit__ = AsyncMock(return_value=False)
mock_conn.cursor = MagicMock(return_value=mock_cursor)
mock_conn.__aenter__ = AsyncMock(return_value=mock_conn)
mock_conn.__aexit__ = AsyncMock(return_value=False)
mock_pool.connection = MagicMock(return_value=mock_conn)
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
def _make_button_payload(
*,
thread_id: str = "test-thread-123",
value: str = "approve",
user_id: str = "U12345",
user_name: str = "alice",
) -> str:
"""Build a URL-encoded Slack button action payload."""
payload = {
"type": "block_actions",
"user": {"id": user_id, "name": user_name},
"actions": [
{
"type": "button",
"value": f"{thread_id}:{value}",
"action_id": f"approval_{value}_{thread_id}",
}
],
}
return urllib.parse.urlencode({"payload": json.dumps(payload)})
# ---------------------------------------------------------------------------
# _verify_slack_signature pure function tests
# ---------------------------------------------------------------------------
class TestVerifySlackSignature:
"""Tests for the _verify_slack_signature pure function."""
def test_returns_true_for_valid_signature(self) -> None:
timestamp = str(int(time.time()))
body = "test=body&data=here"
sig = _make_slack_signature(
signing_secret=_TEST_SIGNING_SECRET,
timestamp=timestamp,
body=body,
)
assert _verify_slack_signature(
signing_secret=_TEST_SIGNING_SECRET,
timestamp=timestamp,
body=body,
signature=sig,
) is True
def test_returns_false_for_wrong_secret(self) -> None:
timestamp = str(int(time.time()))
body = "test=body"
sig = _make_slack_signature(
signing_secret="wrong-secret",
timestamp=timestamp,
body=body,
)
assert _verify_slack_signature(
signing_secret=_TEST_SIGNING_SECRET,
timestamp=timestamp,
body=body,
signature=sig,
) is False
def test_returns_false_for_tampered_body(self) -> None:
timestamp = str(int(time.time()))
original_body = "original=body"
sig = _make_slack_signature(
signing_secret=_TEST_SIGNING_SECRET,
timestamp=timestamp,
body=original_body,
)
assert _verify_slack_signature(
signing_secret=_TEST_SIGNING_SECRET,
timestamp=timestamp,
body="tampered=body",
signature=sig,
) is False
def test_returns_false_for_wrong_timestamp(self) -> None:
body = "test=body"
sig = _make_slack_signature(
signing_secret=_TEST_SIGNING_SECRET,
timestamp="1000000",
body=body,
)
assert _verify_slack_signature(
signing_secret=_TEST_SIGNING_SECRET,
timestamp="9999999",
body=body,
signature=sig,
) is False
def test_returns_false_for_malformed_signature(self) -> None:
timestamp = str(int(time.time()))
assert _verify_slack_signature(
signing_secret=_TEST_SIGNING_SECRET,
timestamp=timestamp,
body="body",
signature="not-a-valid-sig",
) is False
def test_returns_false_for_empty_signature(self) -> None:
timestamp = str(int(time.time()))
assert _verify_slack_signature(
signing_secret=_TEST_SIGNING_SECRET,
timestamp=timestamp,
body="body",
signature="",
) is False
def test_uses_hmac_sha256(self) -> None:
timestamp = "1234567890"
body = "payload=data"
base = f"v0:{timestamp}:{body}"
expected_hash = hmac.new(
_TEST_SIGNING_SECRET.encode(),
base.encode(),
hashlib.sha256,
).hexdigest()
sig = f"v0={expected_hash}"
# Inject current_time matching timestamp to bypass replay prevention
assert _verify_slack_signature(
signing_secret=_TEST_SIGNING_SECRET,
timestamp=timestamp,
body=body,
signature=sig,
current_time=1234567890.0,
) is True
def test_rejects_stale_timestamp(self) -> None:
old_timestamp = "1000000000" # year 2001
body = "payload=data"
base = f"v0:{old_timestamp}:{body}"
expected_hash = hmac.new(
_TEST_SIGNING_SECRET.encode(),
base.encode(),
hashlib.sha256,
).hexdigest()
sig = f"v0={expected_hash}"
# Valid signature but timestamp too old
assert _verify_slack_signature(
signing_secret=_TEST_SIGNING_SECRET,
timestamp=old_timestamp,
body=body,
signature=sig,
) is False
def test_rejects_non_integer_timestamp(self) -> None:
assert _verify_slack_signature(
signing_secret=_TEST_SIGNING_SECRET,
timestamp="not-a-number",
body="body",
signature="v0=abc",
) is False
def test_signature_prefix_must_be_v0(self) -> None:
timestamp = "1234567890"
body = "payload=data"
base = f"v0:{timestamp}:{body}"
hash_val = hmac.new(
_TEST_SIGNING_SECRET.encode(),
base.encode(),
hashlib.sha256,
).hexdigest()
wrong_prefix_sig = f"v1={hash_val}"
assert _verify_slack_signature(
signing_secret=_TEST_SIGNING_SECRET,
timestamp=timestamp,
body=body,
signature=wrong_prefix_sig,
) is False
# ---------------------------------------------------------------------------
# POST /slack/interactions endpoint tests
# ---------------------------------------------------------------------------
class TestSlackInteractionsEndpoint:
"""Tests for POST /slack/interactions."""
def test_returns_200_for_valid_request(self) -> None:
app = _make_test_app()
client = TestClient(app, raise_server_exceptions=False)
timestamp = str(int(time.time()))
body = _make_button_payload(thread_id="t-abc", value="approve")
sig = _make_slack_signature(
signing_secret=_TEST_SIGNING_SECRET,
timestamp=timestamp,
body=body,
)
response = client.post(
"/slack/interactions",
content=body,
headers={
"Content-Type": "application/x-www-form-urlencoded",
"X-Slack-Request-Timestamp": timestamp,
"X-Slack-Signature": sig,
},
)
assert response.status_code == 200
def test_returns_403_for_invalid_signature(self) -> None:
app = _make_test_app()
client = TestClient(app, raise_server_exceptions=False)
timestamp = str(int(time.time()))
body = _make_button_payload()
response = client.post(
"/slack/interactions",
content=body,
headers={
"Content-Type": "application/x-www-form-urlencoded",
"X-Slack-Request-Timestamp": timestamp,
"X-Slack-Signature": "v0=invalid_signature",
},
)
assert response.status_code == 403
def test_returns_400_when_missing_timestamp_header(self) -> None:
app = _make_test_app()
client = TestClient(app, raise_server_exceptions=False)
body = _make_button_payload()
response = client.post(
"/slack/interactions",
content=body,
headers={
"Content-Type": "application/x-www-form-urlencoded",
"X-Slack-Signature": "v0=something",
},
)
assert response.status_code in (400, 403, 422)
def test_rejects_when_signing_secret_not_configured(self) -> None:
app = _make_test_app(signing_secret="")
client = TestClient(app, raise_server_exceptions=False)
timestamp = str(int(time.time()))
body = _make_button_payload(thread_id="t-abc", value="approve")
response = client.post(
"/slack/interactions",
content=body,
headers={
"Content-Type": "application/x-www-form-urlencoded",
"X-Slack-Request-Timestamp": timestamp,
"X-Slack-Signature": "v0=any_sig",
},
)
assert response.status_code == 503
def test_returns_200_with_approve_action(self) -> None:
app = _make_test_app()
client = TestClient(app, raise_server_exceptions=False)
timestamp = str(int(time.time()))
body = _make_button_payload(thread_id="thread-1", value="approve")
sig = _make_slack_signature(
signing_secret=_TEST_SIGNING_SECRET,
timestamp=timestamp,
body=body,
)
response = client.post(
"/slack/interactions",
content=body,
headers={
"Content-Type": "application/x-www-form-urlencoded",
"X-Slack-Request-Timestamp": timestamp,
"X-Slack-Signature": sig,
},
)
assert response.status_code == 200
def test_returns_200_with_reject_action(self) -> None:
app = _make_test_app()
client = TestClient(app, raise_server_exceptions=False)
timestamp = str(int(time.time()))
body = _make_button_payload(thread_id="thread-2", value="reject")
sig = _make_slack_signature(
signing_secret=_TEST_SIGNING_SECRET,
timestamp=timestamp,
body=body,
)
response = client.post(
"/slack/interactions",
content=body,
headers={
"Content-Type": "application/x-www-form-urlencoded",
"X-Slack-Request-Timestamp": timestamp,
"X-Slack-Signature": sig,
},
)
assert response.status_code == 200
def test_schedules_graph_resume_in_background(self) -> None:
app = _make_test_app()
client = TestClient(app, raise_server_exceptions=False)
timestamp = str(int(time.time()))
body = _make_button_payload(thread_id="t-bg", value="approve")
sig = _make_slack_signature(
signing_secret=_TEST_SIGNING_SECRET,
timestamp=timestamp,
body=body,
)
response = client.post(
"/slack/interactions",
content=body,
headers={
"Content-Type": "application/x-www-form-urlencoded",
"X-Slack-Request-Timestamp": timestamp,
"X-Slack-Signature": sig,
},
)
assert response.status_code == 200
def test_returns_404_for_unknown_thread(self) -> None:
app = _make_test_app(thread_graph_name=None)
client = TestClient(app, raise_server_exceptions=False)
timestamp = str(int(time.time()))
body = _make_button_payload(thread_id="unknown-thread", value="approve")
sig = _make_slack_signature(
signing_secret=_TEST_SIGNING_SECRET,
timestamp=timestamp,
body=body,
)
response = client.post(
"/slack/interactions",
content=body,
headers={
"Content-Type": "application/x-www-form-urlencoded",
"X-Slack-Request-Timestamp": timestamp,
"X-Slack-Signature": sig,
},
)
# Should return 200 immediately (Slack requires immediate 200)
# but the background task may log an error
assert response.status_code == 200
def test_response_body_is_empty_or_ok(self) -> None:
app = _make_test_app()
client = TestClient(app, raise_server_exceptions=False)
timestamp = str(int(time.time()))
body = _make_button_payload(thread_id="t-ok", value="approve")
sig = _make_slack_signature(
signing_secret=_TEST_SIGNING_SECRET,
timestamp=timestamp,
body=body,
)
response = client.post(
"/slack/interactions",
content=body,
headers={
"Content-Type": "application/x-www-form-urlencoded",
"X-Slack-Request-Timestamp": timestamp,
"X-Slack-Signature": sig,
},
)
assert response.status_code == 200
# Body may be empty or a simple JSON with ok=True
if response.content:
data = response.json()
assert data.get("ok") is True or "ok" not in data

270
tests/api/test_status.py Normal file
View File

@@ -0,0 +1,270 @@
"""Tests for status, releases, staging, and manual trigger endpoints.
Written FIRST (TDD RED phase).
"""
from datetime import datetime, timezone
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from release_agent.api.status import router as status_router
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_test_app(
*,
versions: list[str] | None = None,
staging_data: dict | None = None,
) -> FastAPI:
"""Return a FastAPI app with mocked state for status tests."""
app = FastAPI()
app.include_router(status_router)
mock_settings = MagicMock()
mock_settings.operator_token.get_secret_value.return_value = ""
mock_graphs = {
"pr_completed": MagicMock(),
"release": MagicMock(),
}
mock_clients = MagicMock()
mock_staging_store = MagicMock()
mock_staging_store.list_versions = AsyncMock(return_value=versions or [])
# staging store returns StagingRelease-like or None
if staging_data is not None:
mock_staging_obj = MagicMock()
mock_staging_obj.model_dump = MagicMock(return_value=staging_data)
mock_staging_store.load = AsyncMock(return_value=mock_staging_obj)
else:
mock_staging_store.load = AsyncMock(return_value=None)
mock_pool = MagicMock()
app.state.settings = mock_settings
app.state.graphs = mock_graphs
app.state.tool_clients = mock_clients
app.state.staging_store = mock_staging_store
app.state.db_pool = mock_pool
app.state.background_tasks = set()
app.state.started_at = datetime.now(tz=timezone.utc)
return app
# ---------------------------------------------------------------------------
# GET /status
# ---------------------------------------------------------------------------
class TestGetStatus:
def test_returns_200(self) -> None:
app = _make_test_app()
with TestClient(app) as client:
response = client.get("/status")
assert response.status_code == 200
def test_response_has_status_field(self) -> None:
app = _make_test_app()
with TestClient(app) as client:
response = client.get("/status")
data = response.json()
assert "status" in data
assert data["status"] in ("ok", "degraded")
def test_response_has_version_field(self) -> None:
app = _make_test_app()
with TestClient(app) as client:
response = client.get("/status")
data = response.json()
assert "version" in data
assert isinstance(data["version"], str)
def test_response_has_uptime_seconds(self) -> None:
app = _make_test_app()
with TestClient(app) as client:
response = client.get("/status")
data = response.json()
assert "uptime_seconds" in data
assert data["uptime_seconds"] >= 0.0
def test_status_is_ok_when_healthy(self) -> None:
app = _make_test_app()
with TestClient(app) as client:
response = client.get("/status")
assert response.json()["status"] == "ok"
# ---------------------------------------------------------------------------
# GET /releases/{repo}
# ---------------------------------------------------------------------------
class TestGetReleaseVersions:
def test_returns_200(self) -> None:
app = _make_test_app(versions=["v1.0.0", "v1.1.0"])
with TestClient(app) as client:
response = client.get("/releases/my-repo")
assert response.status_code == 200
def test_response_has_repo_and_versions(self) -> None:
app = _make_test_app(versions=["v1.0.0", "v1.1.0"])
with TestClient(app) as client:
response = client.get("/releases/my-repo")
data = response.json()
assert data["repo"] == "my-repo"
assert data["versions"] == ["v1.0.0", "v1.1.0"]
def test_empty_versions_list(self) -> None:
app = _make_test_app(versions=[])
with TestClient(app) as client:
response = client.get("/releases/unknown-repo")
data = response.json()
assert data["versions"] == []
def test_repo_name_in_path_used(self) -> None:
mock_staging_store = MagicMock()
mock_staging_store.list_versions = AsyncMock(return_value=[])
app = _make_test_app()
app.state.staging_store = mock_staging_store
with TestClient(app) as client:
client.get("/releases/specific-repo")
mock_staging_store.list_versions.assert_called_once_with("specific-repo")
# ---------------------------------------------------------------------------
# GET /staging
# ---------------------------------------------------------------------------
class TestGetStaging:
def test_returns_200_with_staging(self) -> None:
staging_data = {"version": "v1.0.0", "repo": "my-repo", "tickets": []}
app = _make_test_app(staging_data=staging_data)
with TestClient(app) as client:
response = client.get("/staging?repo=my-repo")
assert response.status_code == 200
def test_response_has_repo_and_staging(self) -> None:
staging_data = {"version": "v1.0.0", "repo": "my-repo", "tickets": []}
app = _make_test_app(staging_data=staging_data)
with TestClient(app) as client:
response = client.get("/staging?repo=my-repo")
data = response.json()
assert data["repo"] == "my-repo"
assert data["staging"] is not None
assert data["staging"]["version"] == "v1.0.0"
def test_returns_null_staging_when_not_found(self) -> None:
app = _make_test_app(staging_data=None)
with TestClient(app) as client:
response = client.get("/staging?repo=no-staging-repo")
assert response.status_code == 200
data = response.json()
assert data["staging"] is None
def test_missing_repo_query_returns_422(self) -> None:
app = _make_test_app()
with TestClient(app) as client:
response = client.get("/staging")
assert response.status_code == 422
# ---------------------------------------------------------------------------
# POST /manual/pr/{pr_id}
# ---------------------------------------------------------------------------
class TestManualPrTrigger:
def test_returns_202(self) -> None:
app = _make_test_app()
with patch(
"release_agent.api.status.asyncio.create_task", return_value=MagicMock()
):
with TestClient(app) as client:
response = client.post("/manual/pr/42")
assert response.status_code == 202
def test_response_has_thread_id(self) -> None:
app = _make_test_app()
with patch(
"release_agent.api.status.asyncio.create_task", return_value=MagicMock()
):
with TestClient(app) as client:
response = client.post("/manual/pr/42")
data = response.json()
assert "thread_id" in data
assert isinstance(data["thread_id"], str)
def test_response_has_message(self) -> None:
app = _make_test_app()
with patch(
"release_agent.api.status.asyncio.create_task", return_value=MagicMock()
):
with TestClient(app) as client:
response = client.post("/manual/pr/42")
assert "message" in response.json()
def test_schedules_background_task(self) -> None:
app = _make_test_app()
with patch(
"release_agent.api.status.asyncio.create_task", return_value=MagicMock()
) as mock_create:
with TestClient(app) as client:
client.post("/manual/pr/99")
mock_create.assert_called_once()
# ---------------------------------------------------------------------------
# POST /manual/release
# ---------------------------------------------------------------------------
class TestManualReleaseTrigger:
def test_returns_202(self) -> None:
app = _make_test_app()
with patch(
"release_agent.api.status.asyncio.create_task", return_value=MagicMock()
):
with TestClient(app) as client:
response = client.post(
"/manual/release",
json={"repo": "my-repo"},
)
assert response.status_code == 202
def test_response_has_thread_id(self) -> None:
app = _make_test_app()
with patch(
"release_agent.api.status.asyncio.create_task", return_value=MagicMock()
):
with TestClient(app) as client:
response = client.post(
"/manual/release",
json={"repo": "my-repo"},
)
data = response.json()
assert "thread_id" in data
def test_missing_repo_returns_422(self) -> None:
app = _make_test_app()
with TestClient(app) as client:
response = client.post(
"/manual/release",
json={},
)
assert response.status_code == 422
def test_schedules_background_task(self) -> None:
app = _make_test_app()
with patch(
"release_agent.api.status.asyncio.create_task", return_value=MagicMock()
) as mock_create:
with TestClient(app) as client:
client.post(
"/manual/release",
json={"repo": "my-repo"},
)
mock_create.assert_called_once()

View File

@@ -0,0 +1,166 @@
"""Tests for status/manual endpoints with operator token authentication.
Phase 5 - Step 3: Verifies that POST /manual/* require operator token
when configured. GET endpoints are not protected.
Written FIRST (TDD RED phase).
"""
from datetime import datetime, timezone
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from release_agent.api.status import router as status_router
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_test_app(operator_token: str = "") -> FastAPI:
"""Return a FastAPI app with status router and configurable operator token."""
app = FastAPI()
app.include_router(status_router)
mock_settings = MagicMock()
mock_settings.operator_token.get_secret_value.return_value = operator_token
mock_staging_store = MagicMock()
mock_staging_store.list_versions = AsyncMock(return_value=[])
mock_staging_store.load = AsyncMock(return_value=None)
mock_pool = MagicMock()
app.state.settings = mock_settings
app.state.graphs = {
"pr_completed": MagicMock(),
"release": MagicMock(),
}
app.state.tool_clients = MagicMock()
app.state.staging_store = mock_staging_store
app.state.db_pool = mock_pool
app.state.background_tasks = set()
app.state.started_at = datetime.now(tz=timezone.utc)
return app
# ---------------------------------------------------------------------------
# POST /manual/pr/{pr_id} with auth
# ---------------------------------------------------------------------------
class TestManualPrTriggerWithAuth:
def test_valid_token_allows_manual_pr(self) -> None:
app = _make_test_app(operator_token="secure-token")
with patch(
"release_agent.api.status.asyncio.create_task", return_value=MagicMock()
):
with TestClient(app) as client:
response = client.post(
"/manual/pr/42",
headers={"X-Operator-Token": "secure-token"},
)
assert response.status_code == 202
def test_missing_token_rejects_manual_pr(self) -> None:
app = _make_test_app(operator_token="secure-token")
with TestClient(app) as client:
response = client.post("/manual/pr/42")
assert response.status_code == 401
def test_wrong_token_rejects_manual_pr(self) -> None:
app = _make_test_app(operator_token="secure-token")
with TestClient(app) as client:
response = client.post(
"/manual/pr/42",
headers={"X-Operator-Token": "bad-token"},
)
assert response.status_code == 401
def test_no_auth_required_when_token_not_configured(self) -> None:
app = _make_test_app(operator_token="")
with patch(
"release_agent.api.status.asyncio.create_task", return_value=MagicMock()
):
with TestClient(app) as client:
response = client.post("/manual/pr/42")
assert response.status_code == 202
# ---------------------------------------------------------------------------
# POST /manual/release with auth
# ---------------------------------------------------------------------------
class TestManualReleaseTriggerWithAuth:
def test_valid_token_allows_manual_release(self) -> None:
app = _make_test_app(operator_token="secure-token")
with patch(
"release_agent.api.status.asyncio.create_task", return_value=MagicMock()
):
with TestClient(app) as client:
response = client.post(
"/manual/release",
json={"repo": "my-repo"},
headers={"X-Operator-Token": "secure-token"},
)
assert response.status_code == 202
def test_missing_token_rejects_manual_release(self) -> None:
app = _make_test_app(operator_token="secure-token")
with TestClient(app) as client:
response = client.post(
"/manual/release",
json={"repo": "my-repo"},
)
assert response.status_code == 401
def test_wrong_token_rejects_manual_release(self) -> None:
app = _make_test_app(operator_token="secure-token")
with TestClient(app) as client:
response = client.post(
"/manual/release",
json={"repo": "my-repo"},
headers={"X-Operator-Token": "wrong"},
)
assert response.status_code == 401
def test_no_auth_required_when_token_not_configured(self) -> None:
app = _make_test_app(operator_token="")
with patch(
"release_agent.api.status.asyncio.create_task", return_value=MagicMock()
):
with TestClient(app) as client:
response = client.post(
"/manual/release",
json={"repo": "my-repo"},
)
assert response.status_code == 202
# ---------------------------------------------------------------------------
# GET /status, /releases, /staging do NOT require auth
# ---------------------------------------------------------------------------
class TestReadEndpointsNoAuth:
def test_get_status_no_token_needed(self) -> None:
"""GET /status should never require auth."""
app = _make_test_app(operator_token="super-secret")
with TestClient(app) as client:
response = client.get("/status")
assert response.status_code == 200
def test_get_releases_no_token_needed(self) -> None:
app = _make_test_app(operator_token="super-secret")
with TestClient(app) as client:
response = client.get("/releases/my-repo")
assert response.status_code == 200
def test_get_staging_no_token_needed(self) -> None:
app = _make_test_app(operator_token="super-secret")
with TestClient(app) as client:
response = client.get("/staging?repo=my-repo")
assert response.status_code == 200

218
tests/api/test_webhooks.py Normal file
View File

@@ -0,0 +1,218 @@
"""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()