mirror of
https://github.com/Sea-Haven-Industries/afterhours-shift-manager.git
synced 2026-09-30 13:43:12 +00:00
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.
374 lines
15 KiB
Python
374 lines
15 KiB
Python
"""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
|