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