afterhours-shift-manager/tests/slack_bot/test_late_pickup.py
Adam Moussa 88e782c205
Some checks are pending
Deploy / deploy (push) Waiting to run
Deploy / release (push) Blocked by required conditions
Add holiday shifts with 3CX routing and late-pickup approval (#121)
Holiday day-shifts (08:00-17:00 ET) with N slots and 1.5x pay. A new
afterhours-holiday-router Lambda, fired by per-holiday EventBridge Scheduler
one-offs, repoints IVR 800 (key-0 + no-input/timeout) to holiday queue 802 and
sets 802's membership to the day's assignees (ext 100 fallback when unfilled),
reverting at 17:00. Pickups after a shift starts go through an admin Approve/Deny
flow for both regular and holiday shifts. Pay (weekly post + /oncall pay) shows
holiday rates distinctly.

Adds HOLIDAY and PICKUP_REQUEST DynamoDB record types, scheduler IAM scoped to
holiday-* schedules with conditioned PassRole, and the holiday-router function
with a 60-day log group and error alarm.
2026-06-17 11:14:29 -04:00

374 lines
15 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 late-pickup approval flow (Phase 8) — picking up a shift that
has already started requires admin approval, for BOTH regular and holiday shifts.
"""
import pytest
from freezegun import freeze_time
# Monday 2026-06-01. Night shift starts 17:00, ends next day 08:00.
MON_BEFORE = "2026-06-01 12:00:00" # 08:00 ET — before the night shift starts
MON_AFTER = "2026-06-01 22:00:00" # 18:00 ET — night shift already started
MON_NEXT_DAY = "2026-06-02 13:00:00" # 09:00 ET — Monday night shift has ended
# Saturday 2026-07-04 holiday day shift: 08:00–17:00 ET.
SAT_BEFORE = "2026-07-04 11:00:00" # 07:00 ET — before the day shift starts
SAT_DURING = "2026-07-04 16:00:00" # 12:00 ET — day shift in progress
SAT_AFTER = "2026-07-04 22:00:00" # 18:00 ET — day shift has ended
def _admins(schedule_seed):
schedule_seed.config(admin_users=["U_ADMIN1", "U_ADMIN2"])
def _dm_channels(client):
return [c.kwargs.get("channel") for c in client.chat_postMessage.call_args_list]
# ── _handle_pick: regular shift, after start → approval ──────────────────
class TestRegularLatePick:
@freeze_time(MON_AFTER)
def test_creates_request_and_dms_admins(
self, slackbot_app, schedule, seed, respond, client, routing_spy, text_of
):
seed.roster("114", "Alice", slack_user_id="U_ALICE")
_admins(seed)
slackbot_app._handle_pick(
respond, schedule, "U_ALICE", "pick today", "C1", client, "C_TEST"
)
req = schedule.get_pickup_request("2026-06-01", "night", "114")
assert req is not None and req["status"] == "pending"
assert req["is_holiday"] is False
# Not claimed yet — no override, no 3CX.
assert schedule.get_override("2026-06-01") is None
routing_spy.assert_not_called()
# DM'd both admins.
assert set(_dm_channels(client)) == {"U_ADMIN1", "U_ADMIN2"}
assert "admin approval" in text_of(respond).lower()
@freeze_time(MON_NEXT_DAY)
def test_after_end_rejected(
self, slackbot_app, schedule, seed, respond, client, text_of
):
# A night shift ends the next morning at 08:00, by which point the date
# is in the past — the past-date guard rejects it (no approval request).
seed.roster("114", "Alice", slack_user_id="U_ALICE")
_admins(seed)
slackbot_app._handle_pick(
respond, schedule, "U_ALICE", "pick 2026-06-01", "C1", client, "C_TEST"
)
assert "past" in text_of(respond).lower()
assert schedule.get_pickup_request("2026-06-01", "night", "114") is None
@freeze_time(MON_BEFORE)
def test_before_start_immediate(
self, slackbot_app, schedule, seed, respond, client, routing_spy, text_of
):
seed.roster("114", "Alice", slack_user_id="U_ALICE")
_admins(seed)
slackbot_app._handle_pick(
respond, schedule, "U_ALICE", "pick today", "C1", client, "C_TEST"
)
# Immediate claim, no approval request.
assert schedule.get_override("2026-06-01")["extension"] == "114"
assert schedule.get_pickup_request("2026-06-01", "night", "114") is None
assert "picked up" in text_of(respond).lower()
@freeze_time(MON_AFTER)
def test_no_admins_rolls_back(
self, slackbot_app, schedule, seed, respond, client, text_of
):
seed.roster("114", "Alice", slack_user_id="U_ALICE")
# No admin_users configured.
slackbot_app._handle_pick(
respond, schedule, "U_ALICE", "pick today", "C1", client, "C_TEST"
)
assert schedule.get_pickup_request("2026-06-01", "night", "114") is None
assert "couldn't reach any admin" in text_of(respond).lower()
# ── _handle_pick: holiday shift, after start → approval ──────────────────
class TestHolidayLatePick:
@freeze_time(SAT_DURING)
def test_creates_holiday_request(
self, slackbot_app, schedule, seed, respond, client, text_of
):
seed.roster("114", "Alice", slack_user_id="U_ALICE")
_admins(seed)
seed.holiday("2026-07-04", slots=2, label="Independence Day")
slackbot_app._handle_pick(
respond, schedule, "U_ALICE", "pick 2026-07-04", "C1", client, "C_TEST"
)
req = schedule.get_pickup_request("2026-07-04", "day", "114")
assert req is not None and req["is_holiday"] is True
# Slot not claimed yet.
assert schedule.get_holiday("2026-07-04")["assignees"] == {}
@freeze_time(SAT_BEFORE)
def test_before_window_immediate_claim(
self, slackbot_app, schedule, seed, respond, client, text_of
):
seed.roster("114", "Alice", slack_user_id="U_ALICE")
_admins(seed)
seed.holiday("2026-07-04", slots=2, label="Independence Day")
slackbot_app._handle_pick(
respond, schedule, "U_ALICE", "pick 2026-07-04", "C1", client, "C_TEST"
)
assert "114" in schedule.get_holiday("2026-07-04")["assignees"]
assert "picked up a slot" in text_of(respond).lower()
@freeze_time(SAT_AFTER)
def test_after_window_rejected(
self, slackbot_app, schedule, seed, respond, client, text_of
):
seed.roster("114", "Alice", slack_user_id="U_ALICE")
_admins(seed)
seed.holiday("2026-07-04", slots=2, label="Independence Day")
slackbot_app._handle_pick(
respond, schedule, "U_ALICE", "pick 2026-07-04", "C1", client, "C_TEST"
)
assert "already ended" in text_of(respond).lower()
assert schedule.get_pickup_request("2026-07-04", "day", "114") is None
# ── handle_pickup button: late pickup gating ─────────────────────────────
class TestButtonLatePick:
@freeze_time(MON_AFTER)
def test_button_after_start_requests_approval(
self, slackbot_app, schedule, seed, respond, client
):
seed.roster("114", "Alice", slack_user_id="U_ALICE")
_admins(seed)
body = {
"actions": [{"action_id": "pickup_2026-06-01"}],
"user": {"id": "U_ALICE"},
"channel": {"id": "C1"},
}
slackbot_app.handle_pickup(body, respond, client, schedule, "C_TEST")
assert schedule.get_pickup_request("2026-06-01", "night", "114") is not None
# The clicker is told via an ephemeral message.
assert client.chat_postEphemeral.called
@freeze_time(SAT_DURING)
def test_button_holiday_late_request(
self, slackbot_app, schedule, seed, respond, client
):
seed.roster("114", "Alice", slack_user_id="U_ALICE")
_admins(seed)
seed.holiday("2026-07-04", slots=2, label="Independence Day")
body = {
"actions": [{"action_id": "pickup_2026-07-04_day"}],
"user": {"id": "U_ALICE"},
"channel": {"id": "C1"},
}
slackbot_app.handle_pickup(body, respond, client, schedule, "C_TEST")
req = schedule.get_pickup_request("2026-07-04", "day", "114")
assert req is not None and req["is_holiday"] is True
# ── approve / deny handlers ──────────────────────────────────────────────
def _approve_body(date_str, ext, suffix="", user_id="U_ADMIN1"):
return {
"actions": [{"action_id": f"pickup_approve_{date_str}{suffix}_{ext}"}],
"user": {"id": user_id},
"channel": {"id": "D1"},
}
def _deny_body(date_str, ext, suffix="", user_id="U_ADMIN1"):
return {
"actions": [{"action_id": f"pickup_deny_{date_str}{suffix}_{ext}"}],
"user": {"id": user_id},
"channel": {"id": "D1"},
}
def _resolved_text(respond):
return respond.call_args.kwargs["blocks"][0]["text"]["text"]
class TestApproveRegular:
@pytest.fixture(autouse=True)
def _seed_admins(self, seed):
_admins(seed)
@freeze_time(MON_AFTER)
def test_approve_claims_and_repoints(
self, slackbot_app, schedule, seed, respond, client, routing_spy
):
seed.roster("114", "Alice", slack_user_id="U_ALICE")
emp = {"extension": "114", "name": "Alice", "slack_user_id": "U_ALICE"}
schedule.create_pickup_request(
"2026-06-01", "night", emp, 1_900_000_000, is_holiday=False
)
slackbot_app.handle_pickup_approve(
_approve_body("2026-06-01", "114"), respond, client, schedule, "C_TEST"
)
assert schedule.get_override("2026-06-01")["extension"] == "114"
assert schedule.get_pickup_request("2026-06-01", "night", "114") is None
# Today + active night → 3CX repointed.
routing_spy.assert_called_once_with("114")
assert "U_ALICE" in _dm_channels(client) # requester notified
assert "approved" in _resolved_text(respond).lower()
@freeze_time(MON_AFTER)
def test_non_admin_cannot_approve(
self, slackbot_app, schedule, seed, respond, client, routing_spy
):
seed.roster("114", "Alice", slack_user_id="U_ALICE")
emp = {"extension": "114", "name": "Alice", "slack_user_id": "U_ALICE"}
schedule.create_pickup_request(
"2026-06-01", "night", emp, 1_900_000_000, is_holiday=False
)
# U_ALICE (the requester) is NOT in admin_users — must be rejected with
# no assignment, no 3CX repoint, and the request left pending.
slackbot_app.handle_pickup_approve(
_approve_body("2026-06-01", "114", user_id="U_ALICE"),
respond,
client,
schedule,
"C_TEST",
)
assert schedule.get_override("2026-06-01") is None
assert schedule.get_pickup_request("2026-06-01", "night", "114") is not None
routing_spy.assert_not_called()
@freeze_time(MON_AFTER)
def test_second_approve_no_longer_pending(
self, slackbot_app, schedule, seed, respond, client
):
emp = {"extension": "114", "name": "Alice", "slack_user_id": "U_ALICE"}
schedule.create_pickup_request(
"2026-06-01", "night", emp, 1_900_000_000, is_holiday=False
)
slackbot_app.handle_pickup_approve(
_approve_body("2026-06-01", "114"), respond, client, schedule, "C_TEST"
)
# A second admin clicks the (now-resolved) request.
slackbot_app.handle_pickup_approve(
_approve_body("2026-06-01", "114", user_id="U_ADMIN2"),
respond,
client,
schedule,
"C_TEST",
)
assert "no longer pending" in _resolved_text(respond).lower()
@freeze_time(MON_NEXT_DAY)
def test_approve_after_end_expires(
self, slackbot_app, schedule, respond, client, routing_spy
):
emp = {"extension": "114", "name": "Alice", "slack_user_id": "U_ALICE"}
schedule.create_pickup_request(
"2026-06-01", "night", emp, 1_900_000_000, is_holiday=False
)
slackbot_app.handle_pickup_approve(
_approve_body("2026-06-01", "114"), respond, client, schedule, "C_TEST"
)
assert "expired" in _resolved_text(respond).lower()
assert schedule.get_override("2026-06-01") is None
routing_spy.assert_not_called()
class TestApproveHoliday:
@pytest.fixture(autouse=True)
def _seed_admins(self, seed):
_admins(seed)
@freeze_time(SAT_DURING)
def test_approve_claims_slot_and_sets_agents(
self, slackbot_app, schedule, seed, respond, client, monkeypatch
):
agents = _spy_queue_agents(slackbot_app, monkeypatch)
seed.holiday("2026-07-04", slots=2, label="Independence Day")
emp = {"extension": "114", "name": "Alice", "slack_user_id": "U_ALICE"}
schedule.create_pickup_request(
"2026-07-04", "day", emp, 1_900_000_000, is_holiday=True
)
slackbot_app.handle_pickup_approve(
_approve_body("2026-07-04", "114", suffix="_day"),
respond,
client,
schedule,
"C_TEST",
)
assert "114" in schedule.get_holiday("2026-07-04")["assignees"]
# Window open → queue membership refreshed.
agents.assert_called_once()
assert agents.call_args.args[1] == "2026-07-04"
@freeze_time(SAT_DURING)
def test_approve_full_holiday_fails_gracefully(
self, slackbot_app, schedule, seed, respond, client, monkeypatch
):
_spy_queue_agents(slackbot_app, monkeypatch)
seed.holiday(
"2026-07-04",
slots=1,
label="Independence Day",
assignees={"115": {"name": "Bob", "claimed_at": "x"}},
)
emp = {"extension": "114", "name": "Alice", "slack_user_id": "U_ALICE"}
schedule.create_pickup_request(
"2026-07-04", "day", emp, 1_900_000_000, is_holiday=True
)
slackbot_app.handle_pickup_approve(
_approve_body("2026-07-04", "114", suffix="_day"),
respond,
client,
schedule,
"C_TEST",
)
assert "full or already taken" in _resolved_text(respond).lower()
assert "114" not in schedule.get_holiday("2026-07-04")["assignees"]
class TestDeny:
@pytest.fixture(autouse=True)
def _seed_admins(self, seed):
_admins(seed)
@freeze_time(MON_AFTER)
def test_deny_clears_and_notifies(self, slackbot_app, schedule, respond, client):
emp = {"extension": "114", "name": "Alice", "slack_user_id": "U_ALICE"}
schedule.create_pickup_request(
"2026-06-01", "night", emp, 1_900_000_000, is_holiday=False
)
slackbot_app.handle_pickup_deny(
_deny_body("2026-06-01", "114"), respond, client, schedule, "C_TEST"
)
assert schedule.get_pickup_request("2026-06-01", "night", "114") is None
assert schedule.get_override("2026-06-01") is None # never claimed
assert "denied" in _resolved_text(respond).lower()
assert "U_ALICE" in _dm_channels(client)
@freeze_time(MON_AFTER)
def test_deny_no_pending(self, slackbot_app, schedule, respond, client):
slackbot_app.handle_pickup_deny(
_deny_body("2026-06-01", "114"), respond, client, schedule, "C_TEST"
)
assert "no longer pending" in _resolved_text(respond).lower()
def test_parse_pickup_action(slackbot_app):
assert slackbot_app._parse_pickup_action(
"pickup_approve_2026-07-04_day_114", "pickup_approve_"
) == ("2026-07-04", "day", "114")
assert slackbot_app._parse_pickup_action(
"pickup_deny_2026-06-01_115", "pickup_deny_"
) == ("2026-06-01", "night", "115")
def _spy_queue_agents(slackbot_app, monkeypatch):
from unittest.mock import MagicMock
spy = MagicMock(name="_set_holiday_queue_agents")
monkeypatch.setattr(slackbot_app, "_set_holiday_queue_agents", spy)
return spy