CI lacks slack_sdk, so the deferred-import-missing error fired before the no-token check and masked it. Stub slack_sdk into sys.modules so the token branch is deterministically exercised in both environments.
269 lines
9.3 KiB
Python
269 lines
9.3 KiB
Python
"""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",
|
|
)
|