afterhours-shift-manager/tests/slack_bot/test_channel_bump.py

181 lines
6.2 KiB
Python
Raw Normal View History

"""Tests for the activity bump that keeps the schedule post at the bottom of
the channel (slack-bot handle_channel_message)."""
import time
from freezegun import freeze_time
CHANNEL = "C_TEST"
MON = "2026-06-08 12:00:00"
class _SlackError(Exception):
"""Stand-in for slack_sdk.errors.SlackApiError with a `.response` dict."""
def __init__(self, code):
super().__init__(code)
self.response = {"error": code}
def _msg(channel=CHANNEL, user="U_ALICE", ts="100.500", **extra):
return {"channel": channel, "user": user, "ts": ts, **extra}
@freeze_time(MON)
def test_bumps_on_new_message(slackbot_app, schedule, seed, client):
seed.schedule_post(CHANNEL, "111.111")
client.chat_postMessage.return_value = {"ts": "222.222"}
slackbot_app.handle_channel_message(_msg(), client, schedule, CHANNEL)
client.chat_delete.assert_called_once()
assert client.chat_delete.call_args.kwargs["ts"] == "111.111"
client.chat_postMessage.assert_called_once()
# The stored ts now points at the freshly reposted message + debounce stamp.
post = schedule.get_schedule_post(CHANNEL)
assert post["message_ts"] == "222.222"
assert post.get("last_bump_ts") is not None
@freeze_time(MON)
def test_debounce_skips_within_window(slackbot_app, schedule, client):
schedule.save_schedule_post(
CHANNEL, "111.111", "2026-06-08", last_bump_ts=time.time()
)
slackbot_app.handle_channel_message(_msg(), client, schedule, CHANNEL)
client.chat_delete.assert_not_called()
client.chat_postMessage.assert_not_called()
# Stored post is untouched.
assert schedule.get_schedule_post(CHANNEL)["message_ts"] == "111.111"
@freeze_time(MON)
def test_bumps_after_debounce_window(slackbot_app, schedule, client):
old = time.time() - (slackbot_app.SCHEDULE_BUMP_DEBOUNCE_SECONDS + 60)
schedule.save_schedule_post(CHANNEL, "111.111", "2026-06-08", last_bump_ts=old)
client.chat_postMessage.return_value = {"ts": "222.222"}
slackbot_app.handle_channel_message(_msg(), client, schedule, CHANNEL)
client.chat_postMessage.assert_called_once()
assert schedule.get_schedule_post(CHANNEL)["message_ts"] == "222.222"
@freeze_time(MON)
def test_skips_when_no_stored_post(slackbot_app, schedule, client):
slackbot_app.handle_channel_message(_msg(), client, schedule, CHANNEL)
client.chat_delete.assert_not_called()
client.chat_postMessage.assert_not_called()
@freeze_time(MON)
def test_skips_bots_own_message(slackbot_app, schedule, seed, client):
seed.schedule_post(CHANNEL, "111.111")
slackbot_app.handle_channel_message(_msg(bot_id="B123"), client, schedule, CHANNEL)
client.chat_delete.assert_not_called()
client.chat_postMessage.assert_not_called()
@freeze_time(MON)
def test_skips_message_edit_and_delete_subtypes(slackbot_app, schedule, seed, client):
seed.schedule_post(CHANNEL, "111.111")
slackbot_app.handle_channel_message(
_msg(subtype="message_changed"), client, schedule, CHANNEL
)
client.chat_delete.assert_not_called()
client.chat_postMessage.assert_not_called()
@freeze_time(MON)
def test_skips_other_channel(slackbot_app, schedule, seed, client):
seed.schedule_post(CHANNEL, "111.111")
slackbot_app.handle_channel_message(
_msg(channel="C_OTHER"), client, schedule, CHANNEL
)
client.chat_delete.assert_not_called()
client.chat_postMessage.assert_not_called()
@freeze_time(MON)
def test_retried_event_is_a_noop(slackbot_app, schedule, seed, client):
# A Slack retry (X-Slack-Retry-Num set) must never trigger a second bump.
seed.schedule_post(CHANNEL, "111.111")
slackbot_app.handle_channel_message(
_msg(), client, schedule, CHANNEL, retry_num="1"
)
client.chat_delete.assert_not_called()
client.chat_postMessage.assert_not_called()
# The record is left untouched (no optimistic debounce stamp either).
post = schedule.get_schedule_post(CHANNEL)
assert post["message_ts"] == "111.111"
assert post.get("last_bump_ts") is None
@freeze_time(MON)
def test_thread_reply_is_a_noop(slackbot_app, schedule, seed, client):
# A threaded reply doesn't push the schedule down the main timeline.
seed.schedule_post(CHANNEL, "111.111")
slackbot_app.handle_channel_message(
_msg(thread_ts="050.000"), client, schedule, CHANNEL
)
client.chat_delete.assert_not_called()
client.chat_postMessage.assert_not_called()
assert schedule.get_schedule_post(CHANNEL)["message_ts"] == "111.111"
@freeze_time(MON)
def test_stamps_debounce_before_repost(slackbot_app, schedule, seed, client):
# The debounce window is stamped optimistically before the delete/repost so
# a retry mid-bump is suppressed even if this run dies before the final save.
seed.schedule_post(CHANNEL, "111.111")
captured = {}
def _delete(**kwargs):
# Mid-bump snapshot: the record already carries last_bump_ts.
captured["post"] = schedule.get_schedule_post(CHANNEL)
client.chat_delete.side_effect = _delete
client.chat_postMessage.return_value = {"ts": "222.222"}
slackbot_app.handle_channel_message(_msg(), client, schedule, CHANNEL)
assert captured["post"].get("last_bump_ts") is not None
@freeze_time(MON)
def test_skips_when_bot_not_in_channel(slackbot_app, schedule, seed, client):
seed.schedule_post(CHANNEL, "111.111")
client.chat_delete.side_effect = _SlackError("not_in_channel")
slackbot_app.handle_channel_message(_msg(), client, schedule, CHANNEL)
# No repost, and the stored post is left intact.
client.chat_postMessage.assert_not_called()
assert schedule.get_schedule_post(CHANNEL)["message_ts"] == "111.111"
@freeze_time(MON)
def test_reposts_when_old_message_already_gone(slackbot_app, schedule, seed, client):
# A stale ts (deleted manually) → delete fails non-fatally; still reposts.
seed.schedule_post(CHANNEL, "111.111")
client.chat_delete.side_effect = _SlackError("message_not_found")
client.chat_postMessage.return_value = {"ts": "222.222"}
slackbot_app.handle_channel_message(_msg(), client, schedule, CHANNEL)
client.chat_postMessage.assert_called_once()
assert schedule.get_schedule_post(CHANNEL)["message_ts"] == "222.222"