afterhours-shift-manager/tests/weekly_post/test_handler.py
Adam Moussa 54501099e5
fix: roll back weekly repost when its ts can't be persisted
The Monday rollover deletes the old post then reposts a fresh one, but only
saved the new ts as its last step. If the save failed (or the Lambda died)
after the post landed, the async retry would read the stale, already-deleted
ts, no-op its delete, and post a second schedule — orphaning the first at the
bottom of the channel.

Wrap the save so a failure after a successful repost best-effort deletes the
fresh message before re-raising, letting the retry start clean. Mirrors the
orphan-avoidance the activity bump already has.
2026-07-10 16:28:01 -04:00

142 lines
5.2 KiB
Python

"""Tests for the weekly-post Lambda handler orchestration."""
from unittest.mock import MagicMock
import pytest
from freezegun import freeze_time
# 2026-06-08 is a Monday. Frozen to ET 08:00; handler is invoked with force=True
# to bypass the 7am DST guard except where the guard itself is under test.
MON_0800 = "2026-06-08 12:00:00"
@pytest.fixture
def slack(weeklypost_app, monkeypatch):
"""Fake Slack WebClient; chat_postMessage returns a message ts."""
fake = MagicMock(name="slack")
fake.chat_postMessage.return_value = {"ts": "999.000"}
monkeypatch.setattr(weeklypost_app, "WebClient", MagicMock(return_value=fake))
monkeypatch.setattr(weeklypost_app, "get_secret", lambda _id: "xoxb-test")
return fake
@pytest.fixture
def env(monkeypatch):
monkeypatch.setenv(
"SLACK_BOT_TOKEN_SECRET", "afterhours-shift-manager/slack-bot-token"
)
monkeypatch.setenv("PAY_REPORT_USER", "U_BOSS")
monkeypatch.delenv("PAYROLL_RECIPIENTS", raising=False) # skip SES email
@freeze_time(MON_0800)
def test_posts_schedule_and_saves_post(weeklypost_app, schedule, seed, slack, env):
result = weeklypost_app.handler({"force": True}, None)
assert result["posted"] is True
assert result["message_ts"] == "999.000"
# The new schedule post was persisted for next week's cleanup.
assert schedule.get_schedule_post("C_TEST")["message_ts"] == "999.000"
slack.chat_postMessage.assert_called()
@freeze_time(MON_0800)
def test_calculates_and_dms_pay(weeklypost_app, schedule, seed, slack, env):
# Previous week (Mon 2026-06-01) had Alice on the Monday night shift.
seed.config(shift_rate="50")
seed.weekly("Monday", "114", "Alice")
result = weeklypost_app.handler({"force": True}, None)
assert result["pay_calculated"] is True
# Pay record saved under previous Monday's key.
assert schedule.get_pay_record("2026-06-01") is not None
# A DM went to the configured pay-report user.
dm_calls = [
c
for c in slack.chat_postMessage.call_args_list
if c.kwargs.get("channel") == "U_BOSS"
]
assert dm_calls
@freeze_time(MON_0800)
def test_pay_email_failure_does_not_block_schedule_post(
weeklypost_app, schedule, seed, slack, env, monkeypatch
):
# Previous week has an assigned shift, so the pay/email path runs.
seed.config(shift_rate="50")
seed.weekly("Monday", "114", "Alice")
# SES delivery blows up (e.g. a permission/identity issue).
monkeypatch.setattr(
weeklypost_app,
"_send_pay_email",
MagicMock(side_effect=Exception("SES AccessDenied")),
)
result = weeklypost_app.handler({"force": True}, None)
# The email failure is swallowed; the schedule post still goes out.
assert result["posted"] is True
assert schedule.get_schedule_post("C_TEST")["message_ts"] == "999.000"
@freeze_time(MON_0800)
def test_rolls_existing_post_forward(weeklypost_app, schedule, seed, slack, env):
# An existing post is deleted + reposted so it lands at the bottom every
# Monday. The old ts is cleaned up and a new one is stored.
seed.schedule_post("C_TEST", "111.111")
result = weeklypost_app.handler({"force": True}, None)
slack.chat_delete.assert_called_once()
assert slack.chat_delete.call_args.kwargs["ts"] == "111.111"
slack.chat_update.assert_not_called()
slack.chat_postMessage.assert_called_once()
# New ts from the repost is stored.
assert result["message_ts"] == "999.000"
assert schedule.get_schedule_post("C_TEST")["message_ts"] == "999.000"
@freeze_time(MON_0800)
def test_reposts_when_delete_fails(weeklypost_app, schedule, seed, slack, env):
# A stored post that can no longer be deleted (e.g. was already removed
# manually) still reposts — the delete failure is non-fatal.
seed.schedule_post("C_TEST", "111.111")
slack.chat_delete.side_effect = Exception("message_not_found")
result = weeklypost_app.handler({"force": True}, None)
slack.chat_postMessage.assert_called()
assert result["message_ts"] == "999.000"
assert schedule.get_schedule_post("C_TEST")["message_ts"] == "999.000"
@freeze_time(MON_0800)
def test_rolls_back_repost_when_save_fails(
weeklypost_app, schedule, seed, slack, env, monkeypatch
):
# If persisting the fresh post's ts fails after the repost has already
# landed, the just-posted message is deleted so an async retry can't leave
# an orphaned duplicate. The error still propagates.
seed.schedule_post("C_TEST", "111.111")
monkeypatch.setattr(
weeklypost_app.ShiftSchedule,
"save_schedule_post",
MagicMock(side_effect=Exception("dynamo down")),
)
with pytest.raises(Exception, match="dynamo down"):
weeklypost_app.handler({"force": True}, None)
# Old post deleted for the rollover, then the fresh (999.000) post rolled
# back when its ts couldn't be persisted.
deleted_ts = [c.kwargs["ts"] for c in slack.chat_delete.call_args_list]
assert deleted_ts == ["111.111", "999.000"]
@freeze_time(MON_0800)
def test_skips_when_not_7am_and_not_forced(weeklypost_app, schedule, slack, env):
# Frozen hour is 08:00 ET, not 07:00 → skip unless forced.
result = weeklypost_app.handler({}, None)
assert result == {"skipped": True}
slack.chat_postMessage.assert_not_called()