Files
billo-release-agent/tests/tools/test_slack.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

756 lines
25 KiB
Python

"""Tests for SlackClient and Block Kit builders. Written FIRST (TDD RED phase)."""
import json
from datetime import date
import httpx
import pytest
from release_agent.exceptions import ServiceError
from release_agent.models.ticket import TicketEntry
from release_agent.tools.slack import (
SlackClient,
_build_approval_blocks,
_build_ci_status_blocks,
_build_interactive_approval_blocks,
_build_release_blocks,
_build_resolved_approval_blocks,
)
# ---------------------------------------------------------------------------
# Fixture helpers
# ---------------------------------------------------------------------------
def _make_ticket(ticket_id: str = "ALLPOST-100", summary: str = "Fix bug") -> TicketEntry:
return TicketEntry(
id=ticket_id,
summary=summary,
pr_id="PR-42",
pr_url="https://dev.azure.com/org/project/_git/repo/pullrequest/42",
pr_title="Fix bug PR",
branch=f"bug/{ticket_id}_fix-bug",
merged_at=date(2024, 1, 15),
)
def _make_transport(status: int = 200, body: bytes = b'{"ok": true}') -> httpx.MockTransport:
return httpx.MockTransport(lambda r: httpx.Response(status_code=status, content=body))
def _make_client(status: int = 200) -> SlackClient:
transport = _make_transport(status)
http_client = httpx.AsyncClient(transport=transport)
return SlackClient(
webhook_url="https://hooks.slack.com/services/T000/B000/xxxx",
http_client=http_client,
)
def _make_web_api_client(
status: int = 200,
body: bytes = b'{"ok": true, "ts": "1234567890.123456"}',
) -> SlackClient:
transport = httpx.MockTransport(lambda r: httpx.Response(status_code=status, content=body))
http_client = httpx.AsyncClient(transport=transport)
return SlackClient(
bot_token="xoxb-test-token",
channel_id="C12345678",
http_client=http_client,
)
# ---------------------------------------------------------------------------
# _build_release_blocks tests (pure function)
# ---------------------------------------------------------------------------
class TestBuildReleaseBlocks:
"""Tests for the _build_release_blocks pure function."""
def test_returns_list(self) -> None:
blocks = _build_release_blocks(
repo="my-repo",
version="v1.2.0",
release_date=date(2024, 1, 15),
tickets=[],
)
assert isinstance(blocks, list)
def test_has_at_least_one_block(self) -> None:
blocks = _build_release_blocks(
repo="my-repo",
version="v1.2.0",
release_date=date(2024, 1, 15),
tickets=[],
)
assert len(blocks) >= 1
def test_repo_name_present_in_blocks(self) -> None:
blocks = _build_release_blocks(
repo="my-repo",
version="v1.2.0",
release_date=date(2024, 1, 15),
tickets=[],
)
text = json.dumps(blocks)
assert "my-repo" in text
def test_version_present_in_blocks(self) -> None:
blocks = _build_release_blocks(
repo="my-repo",
version="v1.2.0",
release_date=date(2024, 1, 15),
tickets=[],
)
text = json.dumps(blocks)
assert "v1.2.0" in text
def test_release_date_present_in_blocks(self) -> None:
blocks = _build_release_blocks(
repo="my-repo",
version="v1.2.0",
release_date=date(2024, 1, 15),
tickets=[],
)
text = json.dumps(blocks)
assert "2024" in text
def test_ticket_ids_present_in_blocks(self) -> None:
tickets = [_make_ticket("ALLPOST-100"), _make_ticket("ALLPOST-200")]
blocks = _build_release_blocks(
repo="my-repo",
version="v1.2.0",
release_date=date(2024, 1, 15),
tickets=tickets,
)
text = json.dumps(blocks)
assert "ALLPOST-100" in text
assert "ALLPOST-200" in text
def test_empty_tickets_still_valid(self) -> None:
blocks = _build_release_blocks(
repo="my-repo",
version="v1.2.0",
release_date=date(2024, 1, 15),
tickets=[],
)
assert len(blocks) >= 1
def test_blocks_are_dicts(self) -> None:
blocks = _build_release_blocks(
repo="my-repo",
version="v1.2.0",
release_date=date(2024, 1, 15),
tickets=[_make_ticket()],
)
assert all(isinstance(b, dict) for b in blocks)
def test_each_block_has_type_key(self) -> None:
blocks = _build_release_blocks(
repo="my-repo",
version="v1.2.0",
release_date=date(2024, 1, 15),
tickets=[],
)
for block in blocks:
assert "type" in block
def test_ticket_summaries_included(self) -> None:
tickets = [_make_ticket("ALLPOST-100", "Fix the auth bug")]
blocks = _build_release_blocks(
repo="my-repo",
version="v1.2.0",
release_date=date(2024, 1, 15),
tickets=tickets,
)
text = json.dumps(blocks)
assert "Fix the auth bug" in text
# ---------------------------------------------------------------------------
# _build_approval_blocks tests (pure function)
# ---------------------------------------------------------------------------
class TestBuildApprovalBlocks:
"""Tests for the _build_approval_blocks pure function."""
def test_returns_list(self) -> None:
blocks = _build_approval_blocks(
action="Deploy to Production",
details="v1.2.0 for my-repo",
approval_url="https://dev.azure.com/approve/123",
)
assert isinstance(blocks, list)
def test_has_at_least_one_block(self) -> None:
blocks = _build_approval_blocks(
action="Deploy",
details="v1.0.0",
approval_url="https://example.com",
)
assert len(blocks) >= 1
def test_action_present_in_blocks(self) -> None:
blocks = _build_approval_blocks(
action="Deploy to Production",
details="v1.2.0",
approval_url="https://example.com",
)
text = json.dumps(blocks)
assert "Deploy to Production" in text
def test_details_present_in_blocks(self) -> None:
blocks = _build_approval_blocks(
action="Deploy",
details="version v1.2.0 of my-repo",
approval_url="https://example.com",
)
text = json.dumps(blocks)
assert "version v1.2.0 of my-repo" in text
def test_approval_url_present_in_blocks(self) -> None:
blocks = _build_approval_blocks(
action="Deploy",
details="details",
approval_url="https://dev.azure.com/approve/abc",
)
text = json.dumps(blocks)
assert "https://dev.azure.com/approve/abc" in text
def test_blocks_are_dicts(self) -> None:
blocks = _build_approval_blocks(
action="Deploy",
details="details",
approval_url="https://example.com",
)
assert all(isinstance(b, dict) for b in blocks)
def test_each_block_has_type_key(self) -> None:
blocks = _build_approval_blocks(
action="Deploy",
details="details",
approval_url="https://example.com",
)
for block in blocks:
assert "type" in block
# ---------------------------------------------------------------------------
# SlackClient.send_release_notification tests
# ---------------------------------------------------------------------------
class TestSendReleaseNotification:
"""Tests for SlackClient.send_release_notification."""
async def test_returns_true_on_success(self) -> None:
client = _make_client(status=200)
result = await client.send_release_notification(
repo="my-repo",
version="v1.2.0",
release_date=date(2024, 1, 15),
tickets=[_make_ticket()],
)
assert result is True
async def test_returns_true_with_empty_tickets(self) -> None:
client = _make_client(status=200)
result = await client.send_release_notification(
repo="my-repo",
version="v1.2.0",
release_date=date(2024, 1, 15),
tickets=[],
)
assert result is True
async def test_500_raises_service_error(self) -> None:
client = _make_client(status=500)
with pytest.raises(ServiceError):
await client.send_release_notification(
repo="my-repo",
version="v1.2.0",
release_date=date(2024, 1, 15),
tickets=[],
)
async def test_sends_post_request(self) -> None:
requests_captured: list[httpx.Request] = []
def handler(request: httpx.Request) -> httpx.Response:
requests_captured.append(request)
return httpx.Response(200, content=b'{"ok": true}')
transport = httpx.MockTransport(handler)
http_client = httpx.AsyncClient(transport=transport)
client = SlackClient(
webhook_url="https://hooks.slack.com/services/T000/B000/xxxx",
http_client=http_client,
)
await client.send_release_notification(
repo="my-repo",
version="v1.2.0",
release_date=date(2024, 1, 15),
tickets=[],
)
assert len(requests_captured) == 1
assert requests_captured[0].method == "POST"
# ---------------------------------------------------------------------------
# SlackClient.send_approval_request tests
# ---------------------------------------------------------------------------
class TestSendApprovalRequest:
"""Tests for SlackClient.send_approval_request."""
async def test_returns_true_on_success(self) -> None:
client = _make_client(status=200)
result = await client.send_approval_request(
action="Deploy to Production",
details="v1.2.0 for my-repo",
approval_url="https://dev.azure.com/approve/123",
)
assert result is True
async def test_500_raises_service_error(self) -> None:
client = _make_client(status=500)
with pytest.raises(ServiceError):
await client.send_approval_request(
action="Deploy",
details="v1.0.0",
approval_url="https://example.com",
)
# ---------------------------------------------------------------------------
# SlackClient lifecycle tests
# ---------------------------------------------------------------------------
class TestSlackClientLifecycle:
"""Tests for SlackClient close() and context manager."""
async def test_close_closes_http_client(self) -> None:
transport = _make_transport()
http_client = httpx.AsyncClient(transport=transport)
client = SlackClient(
webhook_url="https://hooks.slack.com/services/T000/B000/xxxx",
http_client=http_client,
)
await client.close()
assert http_client.is_closed
async def test_context_manager_closes_client(self) -> None:
transport = _make_transport()
http_client = httpx.AsyncClient(transport=transport)
async with SlackClient(
webhook_url="https://hooks.slack.com/services/T000/B000/xxxx",
http_client=http_client,
) as client:
assert client is not None
assert http_client.is_closed
# ---------------------------------------------------------------------------
# SlackClient dual-mode construction tests
# ---------------------------------------------------------------------------
class TestSlackClientDualMode:
"""Tests for dual-mode SlackClient (webhook vs Web API)."""
def test_can_be_created_with_webhook_only(self) -> None:
transport = _make_transport()
http_client = httpx.AsyncClient(transport=transport)
client = SlackClient(
webhook_url="https://hooks.slack.com/services/T000/B000/xxxx",
http_client=http_client,
)
assert client is not None
def test_can_be_created_with_bot_token_and_channel(self) -> None:
transport = _make_transport()
http_client = httpx.AsyncClient(transport=transport)
client = SlackClient(
bot_token="xoxb-test",
channel_id="C12345",
http_client=http_client,
)
assert client is not None
def test_can_be_created_with_all_params(self) -> None:
transport = _make_transport()
http_client = httpx.AsyncClient(transport=transport)
client = SlackClient(
webhook_url="https://hooks.slack.com/services/T000/B000/xxxx",
bot_token="xoxb-test",
channel_id="C12345",
http_client=http_client,
)
assert client is not None
def test_can_be_created_with_no_url_params(self) -> None:
transport = _make_transport()
http_client = httpx.AsyncClient(transport=transport)
client = SlackClient(http_client=http_client)
assert client is not None
# ---------------------------------------------------------------------------
# SlackClient.send_interactive_approval tests
# ---------------------------------------------------------------------------
class TestSendInteractiveApproval:
"""Tests for SlackClient.send_interactive_approval."""
async def test_returns_message_ts_on_success(self) -> None:
client = _make_web_api_client()
result = await client.send_interactive_approval(
thread_id="thread-abc",
action="Deploy to Sandbox",
details="Release v1.0.0 of my-repo",
buttons=[{"text": "Approve", "value": "approve"}, {"text": "Reject", "value": "reject"}],
)
assert isinstance(result, str)
assert result == "1234567890.123456"
async def test_returns_empty_string_on_api_error(self) -> None:
client = _make_web_api_client(status=200, body=b'{"ok": false, "error": "channel_not_found"}')
result = await client.send_interactive_approval(
thread_id="thread-abc",
action="Deploy",
details="v1.0.0",
buttons=[],
)
assert result == ""
async def test_posts_to_chat_postmessage(self) -> None:
captured_urls: list[str] = []
captured_bodies: list[dict] = []
def handler(request: httpx.Request) -> httpx.Response:
captured_urls.append(str(request.url))
captured_bodies.append(json.loads(request.content))
return httpx.Response(200, content=b'{"ok": true, "ts": "111.222"}')
transport = httpx.MockTransport(handler)
http_client = httpx.AsyncClient(transport=transport)
client = SlackClient(
bot_token="xoxb-test",
channel_id="C99999",
http_client=http_client,
)
await client.send_interactive_approval(
thread_id="thread-xyz",
action="Deploy",
details="details",
buttons=[],
)
assert any("chat.postMessage" in url for url in captured_urls)
assert captured_bodies[0]["channel"] == "C99999"
async def test_includes_thread_id_in_blocks(self) -> None:
captured_bodies: list[dict] = []
def handler(request: httpx.Request) -> httpx.Response:
captured_bodies.append(json.loads(request.content))
return httpx.Response(200, content=b'{"ok": true, "ts": "111.222"}')
transport = httpx.MockTransport(handler)
http_client = httpx.AsyncClient(transport=transport)
client = SlackClient(bot_token="xoxb-test", channel_id="C1", http_client=http_client)
await client.send_interactive_approval(
thread_id="my-thread-id",
action="Deploy",
details="v1.0.0",
buttons=[{"text": "Approve", "value": "approve"}],
)
body_str = json.dumps(captured_bodies)
assert "my-thread-id" in body_str
# ---------------------------------------------------------------------------
# SlackClient.update_message tests
# ---------------------------------------------------------------------------
class TestUpdateMessage:
"""Tests for SlackClient.update_message."""
async def test_returns_true_on_success(self) -> None:
client = _make_web_api_client(body=b'{"ok": true}')
result = await client.update_message(
message_ts="1234567890.123456",
text="Updated message",
blocks=[],
)
assert result is True
async def test_returns_false_on_api_error(self) -> None:
client = _make_web_api_client(body=b'{"ok": false, "error": "message_not_found"}')
result = await client.update_message(
message_ts="bad-ts",
text="Update",
blocks=[],
)
assert result is False
async def test_posts_to_chat_update(self) -> None:
captured_urls: list[str] = []
def handler(request: httpx.Request) -> httpx.Response:
captured_urls.append(str(request.url))
return httpx.Response(200, content=b'{"ok": true}')
transport = httpx.MockTransport(handler)
http_client = httpx.AsyncClient(transport=transport)
client = SlackClient(bot_token="xoxb-test", channel_id="C1", http_client=http_client)
await client.update_message(message_ts="ts-abc", text="Hello", blocks=[])
assert any("chat.update" in url for url in captured_urls)
# ---------------------------------------------------------------------------
# SlackClient.send_notification tests
# ---------------------------------------------------------------------------
class TestSendNotification:
"""Tests for SlackClient.send_notification."""
async def test_returns_true_via_web_api(self) -> None:
client = _make_web_api_client(body=b'{"ok": true, "ts": "111.222"}')
result = await client.send_notification(text="Build passed", blocks=[])
assert result is True
async def test_returns_true_via_webhook(self) -> None:
client = _make_client(status=200)
result = await client.send_notification(text="Build passed", blocks=[])
assert result is True
async def test_returns_false_on_web_api_error(self) -> None:
client = _make_web_api_client(body=b'{"ok": false, "error": "invalid_auth"}')
result = await client.send_notification(text="Build passed", blocks=[])
assert result is False
# ---------------------------------------------------------------------------
# _build_interactive_approval_blocks tests (pure function)
# ---------------------------------------------------------------------------
class TestBuildInteractiveApprovalBlocks:
"""Tests for _build_interactive_approval_blocks pure function."""
def test_returns_list(self) -> None:
blocks = _build_interactive_approval_blocks(
thread_id="t1",
action="Deploy to Sandbox",
details="v1.0.0",
buttons=[{"text": "Approve", "value": "approve"}],
)
assert isinstance(blocks, list)
def test_has_at_least_one_block(self) -> None:
blocks = _build_interactive_approval_blocks(
thread_id="t1",
action="Deploy",
details="details",
buttons=[],
)
assert len(blocks) >= 1
def test_action_present_in_blocks(self) -> None:
blocks = _build_interactive_approval_blocks(
thread_id="t1",
action="Deploy to Production",
details="v1.2.0",
buttons=[],
)
text = json.dumps(blocks)
assert "Deploy to Production" in text
def test_thread_id_in_button_value(self) -> None:
blocks = _build_interactive_approval_blocks(
thread_id="my-unique-thread",
action="Deploy",
details="details",
buttons=[{"text": "Approve", "value": "approve"}],
)
text = json.dumps(blocks)
assert "my-unique-thread" in text
def test_buttons_render_as_actions_block(self) -> None:
blocks = _build_interactive_approval_blocks(
thread_id="t1",
action="Deploy",
details="details",
buttons=[
{"text": "Approve", "value": "approve"},
{"text": "Reject", "value": "reject"},
],
)
block_types = [b["type"] for b in blocks]
assert "actions" in block_types
def test_empty_buttons_still_valid(self) -> None:
blocks = _build_interactive_approval_blocks(
thread_id="t1",
action="Deploy",
details="details",
buttons=[],
)
assert isinstance(blocks, list)
def test_details_present_in_blocks(self) -> None:
blocks = _build_interactive_approval_blocks(
thread_id="t1",
action="Deploy",
details="Release v2.0.0 of my-service",
buttons=[],
)
text = json.dumps(blocks)
assert "Release v2.0.0 of my-service" in text
# ---------------------------------------------------------------------------
# _build_ci_status_blocks tests (pure function)
# ---------------------------------------------------------------------------
class TestBuildCiStatusBlocks:
"""Tests for _build_ci_status_blocks pure function."""
def test_returns_list(self) -> None:
blocks = _build_ci_status_blocks(
repo="my-repo",
branch="main",
status="succeeded",
build_url="https://dev.azure.com/org/proj/_build/results?buildId=42",
)
assert isinstance(blocks, list)
def test_repo_present(self) -> None:
blocks = _build_ci_status_blocks(
repo="my-service",
branch="main",
status="succeeded",
build_url=None,
)
text = json.dumps(blocks)
assert "my-service" in text
def test_branch_present(self) -> None:
blocks = _build_ci_status_blocks(
repo="my-repo",
branch="release/v1.0.0",
status="succeeded",
build_url=None,
)
text = json.dumps(blocks)
assert "release/v1.0.0" in text
def test_status_present(self) -> None:
blocks = _build_ci_status_blocks(
repo="my-repo",
branch="main",
status="failed",
build_url=None,
)
text = json.dumps(blocks)
assert "failed" in text
def test_build_url_present_when_provided(self) -> None:
url = "https://dev.azure.com/org/proj/_build/results?buildId=99"
blocks = _build_ci_status_blocks(
repo="my-repo",
branch="main",
status="succeeded",
build_url=url,
)
text = json.dumps(blocks)
assert url in text
def test_build_url_none_does_not_crash(self) -> None:
blocks = _build_ci_status_blocks(
repo="my-repo",
branch="main",
status="succeeded",
build_url=None,
)
assert isinstance(blocks, list)
def test_all_blocks_are_dicts(self) -> None:
blocks = _build_ci_status_blocks(
repo="my-repo",
branch="main",
status="succeeded",
build_url=None,
)
assert all(isinstance(b, dict) for b in blocks)
# ---------------------------------------------------------------------------
# _build_resolved_approval_blocks tests (pure function)
# ---------------------------------------------------------------------------
class TestBuildResolvedApprovalBlocks:
"""Tests for _build_resolved_approval_blocks pure function."""
def test_returns_list(self) -> None:
blocks = _build_resolved_approval_blocks(
action="Deploy to Sandbox",
outcome="approved",
user="alice",
)
assert isinstance(blocks, list)
def test_action_present(self) -> None:
blocks = _build_resolved_approval_blocks(
action="Deploy to Production",
outcome="approved",
user="alice",
)
text = json.dumps(blocks)
assert "Deploy to Production" in text
def test_outcome_present(self) -> None:
blocks = _build_resolved_approval_blocks(
action="Deploy",
outcome="rejected",
user="bob",
)
text = json.dumps(blocks)
assert "rejected" in text
def test_user_present(self) -> None:
blocks = _build_resolved_approval_blocks(
action="Deploy",
outcome="approved",
user="charlie",
)
text = json.dumps(blocks)
assert "charlie" in text
def test_all_blocks_are_dicts(self) -> None:
blocks = _build_resolved_approval_blocks(
action="Deploy",
outcome="approved",
user="dave",
)
assert all(isinstance(b, dict) for b in blocks)