mirror of
https://github.com/Sea-Haven-Industries/afterhours-shift-manager.git
synced 2026-09-30 23:03:11 +00:00
Some checks are pending
Deploy / Deploy to prod (push) Waiting to run
* fix(cutover): write Slack secrets into empty Terraform shells DescribeSecret succeeds on HCP-created shells with no version, so skip-if-exists left roster and Slack tokens unset. * feat(infra): migrate afterhours to HCP Terraform (PLAT-74) Replace the mgmt SAM stack with a prod-only HCP workspace, in-repo hcptf IAM, stub Lambdas, and zip CD on push to main. * fix(cutover): retry DDB unprocessed items and skip past at() holidays Unprocessed BatchWriteItem rows and leftover past at() schedules would drop roster data or abort holiday recreation during prod cutover.
244 lines
8.7 KiB
Python
244 lines
8.7 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"
|
|
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 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
|
|
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-06-01"
|
|
assert body["windowEnd"] == "2026-06-07"
|
|
assert "payPeriodId" not in body
|
|
assert body["lines"] == [{"extension": "114", "amount": "50.00"}]
|
|
assert schedule.get_pay_record("2026-06-01")["checkcomponents_sent"] is True
|
|
|
|
|
|
@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-06-01") 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-06-01") 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-06-01")["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
|