mirror of
https://github.com/Sea-Haven-Industries/afterhours-shift-manager.git
synced 2026-09-30 10:13:11 +00:00
Saturday night stays in the week that ends Saturday, and the first Flex close skips dates already sent.
285 lines
10 KiB
Python
285 lines
10 KiB
Python
"""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
|