"""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)