"""Unit tests for agent_team.transport.slack_live (§3.3.1, §7.1 P1). The live poster is the production ``slack_sdk`` backing for the §3.3.1 injected ``SlackPoster`` seam. These tests prove the contract entirely with mocks (no network, and ``slack_sdk`` itself is never required): the poster maps the adapter's message dict to the ``chat.postMessage`` parameters Slack accepts, the ``ts`` round-trips as the ``channel_ref`` through a real ``SlackTransport``, ``callback_id`` is dropped (not a valid postMessage param) while ``metadata`` carries the durable ``question_id``, and a missing package / token or a client failure fails loudly. """ from __future__ import annotations import importlib from typing import Any import pytest from agent_team.transport.base import QuestionSet from agent_team.transport.slack_adapter import SlackPostError, SlackTransport from agent_team.transport.slack_live import ( build_live_slack_transport, build_slack_poster, ) # --------------------------------------------------------------------------- # # Test doubles # # --------------------------------------------------------------------------- # class _FakeWebClient: """A fake ``slack_sdk.WebClient`` recording ``chat_postMessage`` kwargs.""" def __init__(self, response: dict[str, Any] | None = None) -> None: self.response = ( response if response is not None else {"ts": "169.1", "ok": True} ) self.calls: list[dict[str, Any]] = [] def chat_postMessage(self, **kwargs: Any) -> dict[str, Any]: self.calls.append(kwargs) return self.response class _DataResponse: """A ``slack_sdk.SlackResponse``-like object exposing the payload via ``.data``.""" def __init__(self, data: dict[str, Any]) -> None: self.data = data class _SlackApiErrorLike(Exception): """Stands in for ``slack_sdk.errors.SlackApiError`` (no slack_sdk needed).""" class _FailingClient: """A fake client whose ``chat_postMessage`` raises a Slack-API-like error.""" def chat_postMessage(self, **kwargs: Any) -> dict[str, Any]: raise _SlackApiErrorLike("the_dog_ate_it") def _question_set() -> QuestionSet: return QuestionSet( thread_id="task-7", question_id="q-42", turn=1, questions=["Ship it?"], context={"repo": "agent-team"}, ) # --------------------------------------------------------------------------- # # Clean import without slack_sdk # # --------------------------------------------------------------------------- # def test_module_imports_without_slack_sdk(monkeypatch: pytest.MonkeyPatch) -> None: """The module imports cleanly even when ``slack_sdk`` cannot be imported.""" import builtins real_import = builtins.__import__ def _blocked_import(name: str, *args: Any, **kwargs: Any) -> Any: if name == "slack_sdk" or name.startswith("slack_sdk."): raise ImportError("slack_sdk is blocked for this test") return real_import(name, *args, **kwargs) monkeypatch.setattr(builtins, "__import__", _blocked_import) module = importlib.reload( importlib.import_module("agent_team.transport.slack_live") ) assert hasattr(module, "build_slack_poster") assert hasattr(module, "build_live_slack_transport") # --------------------------------------------------------------------------- # # Happy path: injected fake client # # --------------------------------------------------------------------------- # def test_poster_returns_mapping_with_ts() -> None: """The poster forwards to the client and returns a mapping carrying ``ts``.""" client = _FakeWebClient() poster = build_slack_poster(client=client) result = poster( { "channel": "C123", "callback_id": "shq:q-42", "text": "hi", "blocks": [], "metadata": {"event_type": "agent_team_question"}, } ) assert result["ts"] == "169.1" def test_post_question_round_trips_ts_as_channel_ref() -> None: """Wired through a real ``SlackTransport``, ``ts`` becomes the channel_ref.""" client = _FakeWebClient() transport = SlackTransport("C123", poster=build_slack_poster(client=client)) channel_ref = transport.post_question( thread_id="task-7", question_id="q-42", turn=1, question_set=_question_set(), deadline="2026-06-18T00:00:00Z", ) assert channel_ref == "169.1" def test_callback_id_dropped_metadata_carries_question_id() -> None: """``callback_id`` is not sent; ``metadata`` carries the durable question_id.""" client = _FakeWebClient() transport = SlackTransport("C123", poster=build_slack_poster(client=client)) transport.post_question( thread_id="task-7", question_id="q-42", turn=1, question_set=_question_set(), deadline="2026-06-18T00:00:00Z", ) assert len(client.calls) == 1 kwargs = client.calls[0] # callback_id is NOT a valid chat.postMessage parameter and must be dropped. assert "callback_id" not in kwargs # metadata IS forwarded and carries the durable inbound question_id. assert kwargs["metadata"]["event_payload"]["question_id"] == "q-42" # the accepted parameters are forwarded. assert kwargs["channel"] == "C123" assert "text" in kwargs assert "blocks" in kwargs def test_convenience_transport_factory() -> None: """``build_live_slack_transport`` wires the live poster onto a transport.""" client = _FakeWebClient() transport = build_live_slack_transport("C123", client=client) channel_ref = transport.post_question( thread_id="task-7", question_id="q-42", turn=1, question_set=_question_set(), deadline="2026-06-18T00:00:00Z", ) assert channel_ref == "169.1" def test_poster_accepts_slack_response_with_data_attr() -> None: """A ``SlackResponse``-like object is coerced via its ``.data`` mapping.""" client = _FakeWebClient(response=None) client.response = _DataResponse({"ts": "169.1", "ok": True}) # type: ignore[assignment] poster = build_slack_poster(client=client) result = poster({"channel": "C123", "text": "hi"}) assert result["ts"] == "169.1" class _UnsupportedResponseClient: """A fake client returning neither a mapping nor an object with ``.data``.""" def chat_postMessage(self, **kwargs: Any) -> Any: return object() def test_poster_unsupported_response_raises_type_error() -> None: """A response that is neither a mapping nor has a mapping ``.data`` is fatal. Exercises the ``_as_mapping`` guard: a bare object (no ``ts``, no ``.data``) cannot yield a ``channel_ref``, so the poster raises ``TypeError`` with the guard's "unsupported response" message rather than silently dropping the ts. """ poster = build_slack_poster(client=_UnsupportedResponseClient()) with pytest.raises(TypeError, match="unsupported response"): poster({"channel": "C123", "text": "hi"}) # --------------------------------------------------------------------------- # # Failure modes # # --------------------------------------------------------------------------- # def test_missing_token_raises_runtime_error(monkeypatch: pytest.MonkeyPatch) -> None: """No token and no SLACK_BOT_TOKEN raises a clear RuntimeError. Stub ``slack_sdk`` into ``sys.modules`` so the deferred import SUCCEEDS and the no-token branch is what's under test. Without this the test is environment-dependent: where slack_sdk is absent (CI) the import-missing error fires first and masks the token check (local-vs-CI drift). """ import sys from types import ModuleType fake = ModuleType("slack_sdk") fake.WebClient = lambda **kw: None # type: ignore[attr-defined] monkeypatch.setitem(sys.modules, "slack_sdk", fake) monkeypatch.delenv("SLACK_BOT_TOKEN", raising=False) with pytest.raises(RuntimeError, match="Slack bot token"): build_slack_poster() def test_missing_package_raises_runtime_error( monkeypatch: pytest.MonkeyPatch, ) -> None: """A missing ``slack_sdk`` package raises a clear RuntimeError.""" import builtins real_import = builtins.__import__ def _blocked_import(name: str, *args: Any, **kwargs: Any) -> Any: if name == "slack_sdk" or name.startswith("slack_sdk."): raise ImportError("slack_sdk is blocked for this test") return real_import(name, *args, **kwargs) monkeypatch.setattr(builtins, "__import__", _blocked_import) monkeypatch.setenv("SLACK_BOT_TOKEN", "xoxb-present") with pytest.raises(RuntimeError, match="slack_sdk is unavailable"): build_slack_poster() def test_client_failure_surfaces_as_slack_post_error() -> None: """A SlackApiError-like client failure surfaces as ``SlackPostError``.""" transport = SlackTransport( "C123", poster=build_slack_poster(client=_FailingClient()) ) with pytest.raises(SlackPostError): transport.post_question( thread_id="task-7", question_id="q-42", turn=1, question_set=_question_set(), deadline="2026-06-18T00:00:00Z", )