mirror of
https://github.com/Sea-Haven-Industries/afterhours-shift-manager.git
synced 2026-09-30 17:13:12 +00:00
* fix: delete+repost schedule on weekly rollover for bottom placement The Monday rollover was chat_update-ing in place, which only refreshes content without moving the message to the bottom. Now it chat_deletes the old post and chat_postMessages a fresh one so the schedule lands at the bottom every Monday, independent of in-week activity. Also added info-level logging to the bump handler silent return paths so skipped bumps are observable at runtime. * 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. --------- Co-authored-by: seahaven-openswe[bot] <296972425+seahaven-openswe[bot]@users.noreply.github.com> Co-authored-by: Adam Moussa <adam@seahavenind.com>
142 lines
5.2 KiB
Python
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()
|