afterhours-shift-manager/tests/weekly_post/test_handler.py
Adam Moussa 99cbf3ce39
feat(schedule): align the work week with Sunday-Saturday payroll
Saturday night stays in the week that ends Saturday, and the first Flex close skips dates already sent.
2026-09-25 12:32:29 -04:00

285 lines
10 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Tests for the weekly-post Lambda handler orchestration."""
import json
from pathlib import Path
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.setenv(
"CHECKCOMPONENTS_QUEUE_URL",
"https://sqs.us-east-1.amazonaws.com/011934824531/paychex-checkcomponents",
)
@pytest.fixture
def sqs(weeklypost_app, monkeypatch):
fake = MagicMock(name="sqs")
def client(svc, **kw):
if svc == "sqs":
return fake
return MagicMock(name=svc)
monkeypatch.setattr(weeklypost_app.boto3, "client", client)
return fake
@freeze_time(MON_0800)
def test_posts_schedule_and_saves_post(weeklypost_app, schedule, seed, slack, env, sqs):
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"
# Monday 2026-06-08 is inside the week that opened Sunday 2026-06-07.
assert schedule.get_schedule_post("C_TEST")["week_start"] == "2026-06-07"
slack.chat_postMessage.assert_called()
sqs.send_message.assert_not_called()
@freeze_time(MON_0800)
def test_calculates_and_dms_pay(weeklypost_app, schedule, seed, slack, env, sqs):
# Previous Sun–Sat week includes Monday 2026-06-01, Alice's 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 the previous Sunday (Sun 2026-05-31 through Sat 2026-06-06).
assert schedule.get_pay_record("2026-05-31") 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
sqs.send_message.assert_called_once()
body = json.loads(sqs.send_message.call_args.kwargs["MessageBody"])
assert body["type"] == "checkcomponents"
assert body["kind"] == "after_hours"
assert body["windowStart"] == "2026-05-31"
assert body["windowEnd"] == "2026-06-06"
assert "payPeriodId" not in body
assert body["lines"] == [{"extension": "114", "amount": "50.00"}]
assert schedule.get_pay_record("2026-05-31")["checkcomponents_sent"] is True
@freeze_time(MON_0800)
def test_cutover_omits_sunday_already_sent(
weeklypost_app, schedule, seed, slack, env, sqs
):
# Last ISO close (Mon 2026-05-25 through Sun 2026-05-31) already sent Sunday.
# The new Sun–Sat close must drop that Sunday and report Mon–Sat.
seed.config(shift_rate="50")
seed.weekly("Monday", "114", "Alice")
seed.weekly("Sunday", "114", "Alice", shift_type="day")
schedule.save_pay_record(
"2026-05-25",
{
"week_start": "2026-05-25",
"checkcomponents_sent": True,
"breakdown": [
{
"date": "2026-05-31",
"name": "Alice",
"extension": "114",
"amount": "50",
}
],
"totals": {},
},
)
weeklypost_app.handler({"force": True}, None)
record = schedule.get_pay_record("2026-05-31")
dates = {line["date"] for line in record["breakdown"]}
assert dates == {"2026-06-01"}
assert record["window_start"] == "2026-06-01"
assert record["window_end"] == "2026-06-06"
body = json.loads(sqs.send_message.call_args.kwargs["MessageBody"])
assert body["windowStart"] == "2026-06-01"
assert body["windowEnd"] == "2026-06-06"
assert body["lines"] == [{"extension": "114", "amount": "50.00"}]
@freeze_time(MON_0800)
def test_rolls_existing_post_forward(weeklypost_app, schedule, seed, slack, env, sqs):
# 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, sqs):
# 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, sqs
):
# 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, sqs):
# 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()
sqs.send_message.assert_not_called()
@freeze_time(MON_0800)
def test_checkcomponents_failure_does_not_block_schedule_post(
weeklypost_app, schedule, seed, slack, env, monkeypatch, sqs
):
seed.config(shift_rate="50")
seed.weekly("Monday", "114", "Alice")
monkeypatch.setattr(
weeklypost_app,
"_send_checkcomponents",
MagicMock(side_effect=Exception("SQS AccessDenied")),
)
result = weeklypost_app.handler({"force": True}, None)
assert result["posted"] is True
assert schedule.get_schedule_post("C_TEST")["message_ts"] == "999.000"
@freeze_time(MON_0800)
def test_empty_queue_url_skips_checkcomponents(
weeklypost_app, schedule, seed, slack, env, sqs, monkeypatch
):
monkeypatch.setenv("CHECKCOMPONENTS_QUEUE_URL", "")
seed.config(shift_rate="50")
seed.weekly("Monday", "114", "Alice")
result = weeklypost_app.handler({"force": True}, None)
assert result["posted"] is True
sqs.send_message.assert_not_called()
assert "checkcomponents_sent" not in (schedule.get_pay_record("2026-05-31") or {})
@freeze_time(MON_0800)
def test_save_failure_does_not_enqueue_checkcomponents(
weeklypost_app, schedule, seed, slack, env, monkeypatch, sqs
):
seed.config(shift_rate="50")
seed.weekly("Monday", "114", "Alice")
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)
sqs.send_message.assert_not_called()
assert "checkcomponents_sent" not in (schedule.get_pay_record("2026-05-31") or {})
@freeze_time(MON_0800)
def test_retry_does_not_resend_checkcomponents(
weeklypost_app, schedule, seed, slack, env, sqs
):
seed.config(shift_rate="50")
seed.weekly("Monday", "114", "Alice")
weeklypost_app.handler({"force": True}, None)
weeklypost_app.handler({"force": True}, None)
sqs.send_message.assert_called_once()
assert schedule.get_pay_record("2026-05-31")["checkcomponents_sent"] is True
def test_payload_skips_fallback_and_zero(weeklypost_app):
payload = weeklypost_app.build_checkcomponents_payload(
{
"week_start": "2026-06-01",
"totals": {
"Alice": {"extension": "114", "total": "75.5"},
"Desk": {"extension": "100", "total": "50"},
"Zero": {"extension": "200", "total": "0"},
},
}
)
assert payload["kind"] == "after_hours"
assert payload["windowStart"] == "2026-06-01"
assert payload["windowEnd"] == "2026-06-07"
assert payload["lines"] == [{"extension": "114", "amount": "75.50"}]
assert "payPeriodId" not in payload
def test_weekly_post_has_no_payroll_email_path():
root = Path(__file__).resolve().parents[2]
tf_text = "".join(p.read_text() for p in (root / "terraform").glob("*.tf"))
for token in ("PAYROLL_RECIPIENTS", "SES_SENDER", "ses:", "PayrollEmailFailure"):
assert token not in tf_text, token
source = (root / "src" / "weekly-post" / "app.py").read_text()
for token in ("_send_pay_email", "_build_pay_email_html", 'boto3.client("ses")'):
assert token not in source, token