2026-10-17 22:05:00 +08:00
|
|
|
"""
|
|
|
|
|
Copyright 2024-2026 ReplyPier
|
|
|
|
|
|
|
|
|
|
Licensed under the Apache License, Version 2.0 (the "License");
|
|
|
|
|
you may not use this file except in compliance with the License.
|
|
|
|
|
You may obtain a copy of the License at
|
|
|
|
|
|
|
|
|
|
http://www.apache.org/licenses/LICENSE-2.0
|
|
|
|
|
|
|
|
|
|
Unless required by applicable law or agreed to in writing, software
|
|
|
|
|
distributed under the License is distributed on an "AS IS" BASIS,
|
|
|
|
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
|
|
|
See the License for the specific language governing permissions and
|
|
|
|
|
limitations under the License.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
import hashlib
|
|
|
|
|
import hmac
|
|
|
|
|
import time
|
|
|
|
|
from unittest.mock import AsyncMock, MagicMock
|
|
|
|
|
|
|
|
|
|
import pytest
|
|
|
|
|
|
|
|
|
|
import app.api.webhooks.slack as slack_webhook
|
|
|
|
|
import app.services.slack_events as slack_events
|
|
|
|
|
from app.api.webhooks.slack import _handle_lifecycle_event
|
|
|
|
|
from app.channels import get_adapter
|
|
|
|
|
from app.channels.slack import SlackAdapter, build_home_view, verify_slack_signature
|
|
|
|
|
from app.core.config import settings
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def make_event(event: dict, team_id="T111", event_id="Ev1"):
|
|
|
|
|
return {"type": "event_callback", "team_id": team_id, "event_id": event_id, "event": event}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
MENTION_EVENT = make_event({
|
|
|
|
|
"type": "app_mention",
|
|
|
|
|
"user": "U42",
|
|
|
|
|
"text": "<@UBOT> what are your opening hours?",
|
|
|
|
|
"channel": "C9",
|
|
|
|
|
"ts": "1720000000.000100",
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
DM_EVENT = make_event({
|
|
|
|
|
"type": "message",
|
|
|
|
|
"channel_type": "im",
|
|
|
|
|
"user": "U42",
|
|
|
|
|
"text": "hello there",
|
|
|
|
|
"channel": "D7",
|
|
|
|
|
"ts": "1720000001.000200",
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.fixture
|
|
|
|
|
def adapter():
|
|
|
|
|
return SlackAdapter()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_registry_resolves_slack():
|
|
|
|
|
assert isinstance(get_adapter("slack"), SlackAdapter)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_parse_mention_threads_conversation(adapter):
|
|
|
|
|
messages = adapter.parse_inbound(MENTION_EVENT)
|
|
|
|
|
assert len(messages) == 1
|
|
|
|
|
m = messages[0]
|
|
|
|
|
assert m.external_account_id == "T111"
|
|
|
|
|
assert m.external_conversation_id == "C9:1720000000.000100"
|
|
|
|
|
assert m.text == "what are your opening hours?" # mention stripped
|
|
|
|
|
assert m.external_message_id == "Ev1"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_parse_mention_reply_keeps_thread(adapter):
|
|
|
|
|
payload = make_event({**MENTION_EVENT["event"], "thread_ts": "1719990000.000001"})
|
|
|
|
|
m = adapter.parse_inbound(payload)[0]
|
|
|
|
|
assert m.external_conversation_id == "C9:1719990000.000001"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_parse_dm_uses_channel_as_conversation(adapter):
|
|
|
|
|
m = adapter.parse_inbound(DM_EVENT)[0]
|
|
|
|
|
assert m.external_conversation_id == "D7"
|
|
|
|
|
assert m.text == "hello there"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_bot_and_subtype_events_ignored(adapter):
|
|
|
|
|
assert adapter.parse_inbound(make_event({**DM_EVENT["event"], "bot_id": "B1"})) == []
|
|
|
|
|
assert adapter.parse_inbound(make_event({**DM_EVENT["event"], "subtype": "message_changed"})) == []
|
|
|
|
|
assert adapter.parse_inbound(make_event({"type": "reaction_added"})) == []
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_split_conversation():
|
|
|
|
|
assert SlackAdapter._split_conversation("C9:123.456") == ("C9", "123.456")
|
|
|
|
|
assert SlackAdapter._split_conversation("D7") == ("D7", None)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_format_outbound_mrkdwn(adapter):
|
|
|
|
|
assert adapter.format_outbound("**bold** text") == "*bold* text"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestLifecycleEvents:
|
|
|
|
|
"""app_uninstalled / tokens_revoked must drop the workspace's credentials,
|
|
|
|
|
but only when it's really our bot token that died."""
|
|
|
|
|
|
|
|
|
|
@pytest.fixture
|
|
|
|
|
def repo(self):
|
|
|
|
|
repo = MagicMock()
|
|
|
|
|
repo.get_by_external_id.return_value = MagicMock(name="account")
|
|
|
|
|
repo.delete.return_value = True
|
|
|
|
|
return repo
|
|
|
|
|
|
|
|
|
|
def test_uninstall_deletes_account(self, repo):
|
|
|
|
|
_handle_lifecycle_event(make_event({"type": "app_uninstalled"}), repo)
|
|
|
|
|
repo.get_by_external_id.assert_called_once_with("slack", "T111")
|
|
|
|
|
repo.delete.assert_called_once()
|
|
|
|
|
|
|
|
|
|
def test_bot_token_revoked_deletes_account(self, repo):
|
|
|
|
|
payload = make_event({"type": "tokens_revoked", "tokens": {"bot": ["B123"]}})
|
|
|
|
|
_handle_lifecycle_event(payload, repo)
|
|
|
|
|
repo.delete.assert_called_once()
|
|
|
|
|
|
|
|
|
|
def test_user_token_revoke_keeps_account(self, repo):
|
|
|
|
|
"""We only ever store a bot token — a user-token revoke leaves the
|
|
|
|
|
workspace working, so deleting it would disconnect a healthy install."""
|
|
|
|
|
payload = make_event({"type": "tokens_revoked", "tokens": {"oauth": ["U123"]}})
|
|
|
|
|
_handle_lifecycle_event(payload, repo)
|
|
|
|
|
repo.delete.assert_not_called()
|
|
|
|
|
|
|
|
|
|
def test_unknown_team_is_noop(self, repo):
|
|
|
|
|
repo.get_by_external_id.return_value = None
|
|
|
|
|
_handle_lifecycle_event(make_event({"type": "app_uninstalled"}), repo)
|
|
|
|
|
repo.delete.assert_not_called()
|
|
|
|
|
|
|
|
|
|
def test_missing_team_id_is_noop(self, repo):
|
|
|
|
|
payload = make_event({"type": "app_uninstalled"}, team_id="")
|
|
|
|
|
_handle_lifecycle_event(payload, repo)
|
|
|
|
|
repo.get_by_external_id.assert_not_called()
|
|
|
|
|
repo.delete.assert_not_called()
|
|
|
|
|
|
|
|
|
|
def test_failed_delete_does_not_raise(self, repo):
|
|
|
|
|
"""delete() reports failure by returning False; Slack still needs a 200."""
|
|
|
|
|
repo.delete.return_value = False
|
|
|
|
|
_handle_lifecycle_event(make_event({"type": "app_uninstalled"}), repo)
|
|
|
|
|
repo.delete.assert_called_once()
|
|
|
|
|
|
|
|
|
|
def test_lifecycle_events_are_not_messages(self, adapter):
|
|
|
|
|
"""They must stay out of the message path — parse_inbound yields nothing,
|
|
|
|
|
which is why the route resolves the team off the envelope instead."""
|
|
|
|
|
assert adapter.parse_inbound(make_event({"type": "app_uninstalled"})) == []
|
|
|
|
|
assert adapter.parse_inbound(make_event({"type": "tokens_revoked"})) == []
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestSignature:
|
|
|
|
|
def _sign(self, body: bytes, secret: str, ts: str) -> str:
|
|
|
|
|
return "v0=" + hmac.new(secret.encode(), f"v0:{ts}:".encode() + body, hashlib.sha256).hexdigest()
|
|
|
|
|
|
|
|
|
|
def test_valid(self, monkeypatch):
|
|
|
|
|
monkeypatch.setattr(settings, "SLACK_SIGNING_SECRET", "sek")
|
|
|
|
|
ts = str(int(time.time()))
|
|
|
|
|
body = b'{"type":"event_callback"}'
|
|
|
|
|
headers = {"x-slack-request-timestamp": ts,
|
|
|
|
|
"x-slack-signature": self._sign(body, "sek", ts)}
|
|
|
|
|
assert verify_slack_signature(headers, body) is True
|
|
|
|
|
|
|
|
|
|
def test_wrong_secret_or_missing(self, monkeypatch):
|
|
|
|
|
monkeypatch.setattr(settings, "SLACK_SIGNING_SECRET", "sek")
|
|
|
|
|
ts = str(int(time.time()))
|
|
|
|
|
body = b"{}"
|
|
|
|
|
assert verify_slack_signature(
|
|
|
|
|
{"x-slack-request-timestamp": ts,
|
|
|
|
|
"x-slack-signature": self._sign(body, "other", ts)}, body) is False
|
|
|
|
|
assert verify_slack_signature({}, body) is False
|
|
|
|
|
|
|
|
|
|
def test_replay_rejected(self, monkeypatch):
|
|
|
|
|
monkeypatch.setattr(settings, "SLACK_SIGNING_SECRET", "sek")
|
|
|
|
|
old_ts = str(int(time.time()) - 3600)
|
|
|
|
|
body = b"{}"
|
|
|
|
|
headers = {"x-slack-request-timestamp": old_ts,
|
|
|
|
|
"x-slack-signature": self._sign(body, "sek", old_ts)}
|
|
|
|
|
assert verify_slack_signature(headers, body) is False
|
|
|
|
|
|
|
|
|
|
def test_unconfigured_secret_fails_closed(self, monkeypatch):
|
|
|
|
|
monkeypatch.setattr(settings, "SLACK_SIGNING_SECRET", "")
|
|
|
|
|
assert verify_slack_signature({"x-slack-request-timestamp": "1",
|
|
|
|
|
"x-slack-signature": "v0=x"}, b"{}") is False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestSlackProfileAndTyping:
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_fetch_profile_resolves_real_name(self, adapter, monkeypatch):
|
|
|
|
|
from unittest.mock import AsyncMock, MagicMock
|
|
|
|
|
acc = MagicMock()
|
|
|
|
|
monkeypatch.setattr(adapter, "_access_token", lambda a: "tok")
|
|
|
|
|
monkeypatch.setattr("app.channels.slack.slack_api", AsyncMock(return_value={
|
|
|
|
|
"ok": True, "user": {"real_name": "Ada Lovelace",
|
|
|
|
|
"profile": {"display_name": "ada", "email": "ada@acme.com"}}}))
|
|
|
|
|
prof = await adapter.fetch_profile(acc, "U09UPKP7")
|
|
|
|
|
assert prof["name"] == "Ada Lovelace"
|
|
|
|
|
assert prof["email"] == "ada@acme.com"
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_fetch_profile_handles_error(self, adapter, monkeypatch):
|
|
|
|
|
from unittest.mock import AsyncMock, MagicMock
|
|
|
|
|
monkeypatch.setattr(adapter, "_access_token", lambda a: "tok")
|
|
|
|
|
monkeypatch.setattr("app.channels.slack.slack_api",
|
|
|
|
|
AsyncMock(return_value={"ok": False, "error": "missing_scope"}))
|
|
|
|
|
assert await adapter.fetch_profile(MagicMock(), "U0") == {}
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_typing_placeholder_then_edit(self, adapter, monkeypatch):
|
2026-10-29 09:46:21 +08:00
|
|
|
from unittest.mock import MagicMock
|
2026-10-17 22:05:00 +08:00
|
|
|
import app.channels.slack as slk
|
|
|
|
|
acc = MagicMock(); acc.id = "acc1"
|
|
|
|
|
conv = MagicMock(external_conversation_id="D7")
|
|
|
|
|
monkeypatch.setattr(adapter, "_access_token", lambda a: "tok")
|
|
|
|
|
calls = []
|
|
|
|
|
async def fake_api(method, token, payload):
|
|
|
|
|
calls.append((method, payload))
|
|
|
|
|
if method == "chat.postMessage":
|
|
|
|
|
return {"ok": True, "ts": "111.222"}
|
|
|
|
|
if method == "chat.update":
|
|
|
|
|
return {"ok": True, "ts": payload["ts"]}
|
|
|
|
|
return {"ok": False}
|
|
|
|
|
monkeypatch.setattr(slk, "slack_api", fake_api)
|
|
|
|
|
|
|
|
|
|
await adapter.send_typing(acc, conv) # posts placeholder
|
|
|
|
|
result = await adapter.send_text(acc, conv, "Here is your answer") # edits it
|
|
|
|
|
assert result.ok and result.external_message_id == "111.222"
|
|
|
|
|
methods = [m for m, _ in calls]
|
|
|
|
|
assert methods == ["chat.postMessage", "chat.update"] # not a second post
|
|
|
|
|
# placeholder consumed
|
|
|
|
|
assert slk._typing_placeholders.get(("acc1", "D7")) is None
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_placeholder_deleted_when_update_fails(self, adapter, monkeypatch):
|
|
|
|
|
"""When chat.update fails, the stale '_typing…_' is deleted and a fresh
|
|
|
|
|
message is posted — no dangling placeholder beside the reply."""
|
|
|
|
|
from unittest.mock import MagicMock
|
|
|
|
|
import app.channels.slack as slk
|
|
|
|
|
acc = MagicMock(); acc.id = "acc9"
|
|
|
|
|
conv = MagicMock(external_conversation_id="D9")
|
|
|
|
|
monkeypatch.setattr(adapter, "_access_token", lambda a: "tok")
|
|
|
|
|
calls = []
|
|
|
|
|
async def fake_api(method, token, payload):
|
|
|
|
|
calls.append((method, payload))
|
|
|
|
|
if method == "chat.postMessage":
|
|
|
|
|
return {"ok": True, "ts": "555.666"}
|
|
|
|
|
if method == "chat.update":
|
|
|
|
|
return {"ok": False, "error": "cant_update_message"}
|
|
|
|
|
return {"ok": True}
|
|
|
|
|
monkeypatch.setattr(slk, "slack_api", fake_api)
|
|
|
|
|
|
|
|
|
|
await adapter.send_typing(acc, conv)
|
|
|
|
|
result = await adapter.send_text(acc, conv, "The answer")
|
|
|
|
|
methods = [m for m, _ in calls]
|
|
|
|
|
assert methods == ["chat.postMessage", "chat.update", "chat.delete", "chat.postMessage"]
|
|
|
|
|
assert result.ok
|
|
|
|
|
assert slk._typing_placeholders.get(("acc9", "D9")) is None
|
|
|
|
|
|
|
|
|
|
def test_prune_placeholders_drops_stale(self, monkeypatch):
|
|
|
|
|
import app.channels.slack as slk
|
|
|
|
|
slk._typing_placeholders.clear()
|
|
|
|
|
slk._typing_placeholders[("a", "c1")] = ("1.1", 1000.0)
|
|
|
|
|
slk._typing_placeholders[("a", "c2")] = ("2.2", 1000.0 + slk._PLACEHOLDER_TTL_SECONDS + 1)
|
|
|
|
|
slk._prune_placeholders(1000.0 + slk._PLACEHOLDER_TTL_SECONDS + 2)
|
|
|
|
|
assert ("a", "c1") not in slk._typing_placeholders # stale, pruned
|
|
|
|
|
assert ("a", "c2") in slk._typing_placeholders # fresh, kept
|
|
|
|
|
slk._typing_placeholders.clear()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_parse_dm_with_thread_keys_by_thread(adapter):
|
|
|
|
|
"""Assistant-sidebar messages are DMs carrying the assistant thread ts — they
|
|
|
|
|
key by thread so the reply posts back into that thread."""
|
|
|
|
|
ev = make_event({"type": "message", "channel_type": "im", "user": "U42",
|
|
|
|
|
"text": "hi", "channel": "D7", "ts": "1720000002.0003",
|
|
|
|
|
"thread_ts": "1719999999.0001"})
|
|
|
|
|
m = adapter.parse_inbound(ev)[0]
|
|
|
|
|
assert m.external_conversation_id == "D7:1719999999.0001"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
DASH = "https://app.replypier.site"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestHomeView:
|
|
|
|
|
def test_intro_and_dashboard_link_always_present(self):
|
|
|
|
|
import json
|
|
|
|
|
blob = json.dumps(build_home_view(None, DASH))
|
|
|
|
|
assert "AI customer support" in blob # what it does, up top
|
|
|
|
|
assert DASH in blob # dashboard link
|
|
|
|
|
assert "How to use me" in blob
|
|
|
|
|
|
|
|
|
|
def test_no_agent_shows_connect_prompt(self):
|
|
|
|
|
import json
|
|
|
|
|
v = build_home_view(None, DASH)
|
|
|
|
|
assert v["type"] == "home"
|
|
|
|
|
assert "No agent connected" in json.dumps(v)
|
|
|
|
|
|
|
|
|
|
def test_connected_agent_card_and_truncation(self):
|
|
|
|
|
import json
|
|
|
|
|
v = build_home_view({"name": "Support", "is_active": True,
|
|
|
|
|
"instruction": "x" * 300, "photo_url": None}, DASH)
|
|
|
|
|
blob = json.dumps(v)
|
|
|
|
|
assert "Connected agent" in blob and "Support" in blob
|
|
|
|
|
section_texts = [b["text"]["text"] for b in v["blocks"] if b.get("type") == "section"]
|
|
|
|
|
long_text = next(t for t in section_texts if t.startswith("x"))
|
|
|
|
|
assert long_text.endswith("…") and len(long_text) <= 250
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestAppSurfaceDispatch:
|
|
|
|
|
"""Webhook fan-out: the right event schedules the right background handler."""
|
|
|
|
|
|
|
|
|
|
def _repo(self, active=True):
|
|
|
|
|
account = MagicMock(); account.id = "acc1"; account.is_active = active
|
|
|
|
|
repo = MagicMock(); repo.get_by_external_id.return_value = account
|
|
|
|
|
return repo
|
|
|
|
|
|
|
|
|
|
def test_messages_tab_open_schedules_welcome(self):
|
|
|
|
|
bg = MagicMock()
|
|
|
|
|
slack_webhook._dispatch_app_surface_event(
|
|
|
|
|
{"type": "app_home_opened", "user": "U1", "channel": "D1", "tab": "messages"},
|
|
|
|
|
"T1", self._repo(), bg)
|
|
|
|
|
args = bg.add_task.call_args[0]
|
|
|
|
|
assert args[0] is slack_webhook.deliver_home_welcome
|
|
|
|
|
assert args[1:] == ("acc1", "U1", "D1")
|
|
|
|
|
|
|
|
|
|
def test_missing_tab_defaults_to_welcome(self):
|
|
|
|
|
bg = MagicMock()
|
|
|
|
|
slack_webhook._dispatch_app_surface_event(
|
|
|
|
|
{"type": "app_home_opened", "user": "U1", "channel": "D1"}, "T1", self._repo(), bg)
|
|
|
|
|
assert bg.add_task.call_args[0][0] is slack_webhook.deliver_home_welcome
|
|
|
|
|
|
|
|
|
|
def test_home_tab_open_schedules_home_publish(self):
|
|
|
|
|
bg = MagicMock()
|
|
|
|
|
slack_webhook._dispatch_app_surface_event(
|
|
|
|
|
{"type": "app_home_opened", "user": "U1", "tab": "home"}, "T1", self._repo(), bg)
|
|
|
|
|
args = bg.add_task.call_args[0]
|
|
|
|
|
assert args[0] is slack_webhook.publish_agent_home and args[1:] == ("acc1", "U1")
|
|
|
|
|
|
|
|
|
|
def test_assistant_started_schedules_handler(self):
|
|
|
|
|
bg = MagicMock()
|
|
|
|
|
slack_webhook._dispatch_app_surface_event(
|
|
|
|
|
{"type": "assistant_thread_started",
|
|
|
|
|
"assistant_thread": {"channel_id": "D2", "thread_ts": "111.222"}},
|
|
|
|
|
"T1", self._repo(), bg)
|
|
|
|
|
args = bg.add_task.call_args[0]
|
|
|
|
|
assert args[0] is slack_webhook.handle_assistant_thread_started
|
|
|
|
|
assert args[1:] == ("acc1", "D2", "111.222")
|
|
|
|
|
|
|
|
|
|
def test_context_changed_is_noop(self):
|
|
|
|
|
bg = MagicMock()
|
|
|
|
|
slack_webhook._dispatch_app_surface_event(
|
|
|
|
|
{"type": "assistant_thread_context_changed"}, "T1", self._repo(), bg)
|
|
|
|
|
bg.add_task.assert_not_called()
|
|
|
|
|
|
|
|
|
|
def test_inactive_team_is_noop(self):
|
|
|
|
|
bg = MagicMock()
|
|
|
|
|
slack_webhook._dispatch_app_surface_event(
|
|
|
|
|
{"type": "app_home_opened", "user": "U1", "tab": "messages"},
|
|
|
|
|
"T1", self._repo(active=False), bg)
|
|
|
|
|
bg.add_task.assert_not_called()
|
|
|
|
|
|
|
|
|
|
def test_missing_team_id_is_noop(self):
|
|
|
|
|
bg = MagicMock()
|
|
|
|
|
slack_webhook._dispatch_app_surface_event(
|
|
|
|
|
{"type": "app_home_opened", "user": "U1"}, "", self._repo(), bg)
|
|
|
|
|
bg.add_task.assert_not_called()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestHomeWelcomeDelivery:
|
|
|
|
|
def _patch_common(self, monkeypatch):
|
|
|
|
|
db = MagicMock()
|
|
|
|
|
monkeypatch.setattr(slack_events, "SessionLocal", lambda: db)
|
|
|
|
|
account = MagicMock(); account.is_active = True
|
|
|
|
|
repo = MagicMock(); repo.get_by_id.return_value = account
|
|
|
|
|
monkeypatch.setattr(slack_events, "ChannelAccountRepository", lambda d: repo)
|
|
|
|
|
monkeypatch.setattr(slack_events, "_assigned_agent", lambda d, a: None)
|
|
|
|
|
monkeypatch.setattr(slack_events.SlackAdapter, "_access_token", staticmethod(lambda a: "tok"))
|
|
|
|
|
api = AsyncMock()
|
|
|
|
|
monkeypatch.setattr(slack_events, "slack_api", api)
|
|
|
|
|
return api
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_welcome_sent_once(self, monkeypatch):
|
|
|
|
|
api = self._patch_common(monkeypatch)
|
|
|
|
|
redis = MagicMock()
|
|
|
|
|
redis.set.side_effect = [True, None] # first claims the slot, second is taken
|
|
|
|
|
monkeypatch.setattr(slack_events, "get_redis", lambda: redis)
|
|
|
|
|
|
|
|
|
|
await slack_events.deliver_home_welcome("acc1", "U1", "D1")
|
|
|
|
|
await slack_events.deliver_home_welcome("acc1", "U1", "D1")
|
|
|
|
|
|
|
|
|
|
assert api.await_count == 1
|
|
|
|
|
assert api.await_args[0][0] == "chat.postMessage"
|
|
|
|
|
assert api.await_args[0][2]["channel"] == "D1"
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_no_channel_no_send(self, monkeypatch):
|
|
|
|
|
api = self._patch_common(monkeypatch)
|
|
|
|
|
monkeypatch.setattr(slack_events, "get_redis", lambda: None)
|
|
|
|
|
await slack_events.deliver_home_welcome("acc1", "U1", "")
|
|
|
|
|
api.assert_not_awaited()
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_assistant_started_sets_status_prompts_and_welcome(self, monkeypatch):
|
|
|
|
|
self._patch_common(monkeypatch)
|
|
|
|
|
status = AsyncMock(); prompts = AsyncMock()
|
|
|
|
|
monkeypatch.setattr(slack_events, "set_assistant_status", status)
|
|
|
|
|
monkeypatch.setattr(slack_events, "set_suggested_prompts", prompts)
|
|
|
|
|
api = AsyncMock()
|
|
|
|
|
monkeypatch.setattr(slack_events, "slack_api", api)
|
|
|
|
|
|
|
|
|
|
await slack_events.handle_assistant_thread_started("acc1", "D2", "111.222")
|
|
|
|
|
|
|
|
|
|
status.assert_awaited_once()
|
|
|
|
|
prompts.assert_awaited_once()
|
|
|
|
|
api.assert_awaited_once()
|
|
|
|
|
assert api.await_args[0][0] == "chat.postMessage"
|
|
|
|
|
assert api.await_args[0][2].get("thread_ts") == "111.222"
|