afterhours-shift-manager/tests/slack_bot/test_handle_holiday_actions.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

229 lines
8.8 KiB
Python

"""Tests for holiday-aware pick / drop / swap paths via get_shift_context (Phase 8).
These cover the *before-window* (immediate) behaviour; late (in-window) pickups
are covered in test_late_pickup.py.
"""
from freezegun import freeze_time
# Wednesday 2026-07-01 12:00 ET — well before the 2026-07-04 holiday window.
WED = "2026-07-01 16:00:00"
def _resolved_text(respond):
return respond.call_args.kwargs["blocks"][0]["text"]["text"]
def _channels(client):
return [c.kwargs.get("channel") for c in client.chat_postMessage.call_args_list]
# ── pick ─────────────────────────────────────────────────────────────────
class TestHolidayPick:
@freeze_time(WED)
def test_claims_slot(
self, slackbot_app, schedule, seed, respond, client, routing_spy, text_of
):
seed.roster("114", "Alice", slack_user_id="U_ALICE")
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()
# Holiday claims never repoint the per-extension routing.
routing_spy.assert_not_called()
client.chat_postMessage.assert_called_once()
@freeze_time(WED)
def test_two_pickers_both_get_slots(
self, slackbot_app, schedule, seed, respond, client
):
seed.roster("114", "Alice", slack_user_id="U_ALICE")
seed.roster("115", "Bob", slack_user_id="U_BOB")
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"
)
slackbot_app._handle_pick(
respond, schedule, "U_BOB", "pick 2026-07-04", "C1", client, "C_TEST"
)
assignees = schedule.get_holiday("2026-07-04")["assignees"]
assert set(assignees) == {"114", "115"}
@freeze_time(WED)
def test_full_holiday_rejected(
self, slackbot_app, schedule, seed, respond, client, text_of
):
seed.roster("116", "Carol", slack_user_id="U_CAROL")
seed.holiday(
"2026-07-04",
slots=1,
label="Independence Day",
assignees={"114": {"name": "Alice", "claimed_at": "x"}},
)
slackbot_app._handle_pick(
respond, schedule, "U_CAROL", "pick 2026-07-04", "C1", client, "C_TEST"
)
assert "full" in text_of(respond).lower()
assert "116" not in schedule.get_holiday("2026-07-04")["assignees"]
@freeze_time(WED)
def test_already_on_holiday(
self, slackbot_app, schedule, seed, respond, client, text_of
):
seed.roster("114", "Alice", slack_user_id="U_ALICE")
seed.holiday(
"2026-07-04",
slots=2,
label="Independence Day",
assignees={"114": {"name": "Alice", "claimed_at": "x"}},
)
slackbot_app._handle_pick(
respond, schedule, "U_ALICE", "pick 2026-07-04", "C1", client, "C_TEST"
)
assert "already on" in text_of(respond).lower()
# ── drop ─────────────────────────────────────────────────────────────────
class TestHolidayDrop:
@freeze_time(WED)
def test_releases_slot(
self, slackbot_app, schedule, seed, respond, client, text_of
):
seed.roster("114", "Alice", slack_user_id="U_ALICE")
seed.holiday(
"2026-07-04",
slots=2,
label="Independence Day",
assignees={"114": {"name": "Alice", "claimed_at": "x"}},
)
slackbot_app._handle_drop(
respond, schedule, "U_ALICE", "drop 2026-07-04", "C1", client, "C_TEST"
)
assert "114" not in schedule.get_holiday("2026-07-04")["assignees"]
assert "dropped your slot" in text_of(respond).lower()
@freeze_time(WED)
def test_not_on_holiday_falls_through(
self, slackbot_app, schedule, seed, respond, client, text_of
):
# Alice holds no holiday slot → drop falls through to regular resolution,
# which reports it's not her (night) shift.
seed.roster("114", "Alice", slack_user_id="U_ALICE")
seed.holiday(
"2026-07-04",
slots=2,
label="Independence Day",
assignees={"115": {"name": "Bob", "claimed_at": "x"}},
)
slackbot_app._handle_drop(
respond, schedule, "U_ALICE", "drop 2026-07-04", "C1", client, "C_TEST"
)
assert "115" in schedule.get_holiday("2026-07-04")["assignees"] # untouched
assert "not your shift" in text_of(respond).lower()
@freeze_time("2026-07-03 16:00:00") # Friday — Saturday 08:00 holiday <24h away
def test_drop_lock_within_24h(
self, slackbot_app, schedule, seed, respond, client, text_of
):
seed.roster("114", "Alice", slack_user_id="U_ALICE")
seed.holiday(
"2026-07-04",
slots=2,
label="Independence Day",
assignees={"114": {"name": "Alice", "claimed_at": "x"}},
)
slackbot_app._handle_drop(
respond, schedule, "U_ALICE", "drop 2026-07-04", "C1", client, "C_TEST"
)
assert "under 24 hours" in text_of(respond).lower()
assert "114" in schedule.get_holiday("2026-07-04")["assignees"] # not dropped
# ── swap ─────────────────────────────────────────────────────────────────
class TestHolidaySwap:
@freeze_time(WED)
def test_request_creates_pending_day_swap(
self, slackbot_app, schedule, seed, respond, client, text_of
):
seed.roster("114", "Alice", slack_user_id="U_ALICE")
seed.roster("115", "Bob", slack_user_id="U_BOB")
seed.holiday(
"2026-07-04",
slots=2,
label="Independence Day",
assignees={"114": {"name": "Alice", "claimed_at": "x"}},
)
slackbot_app._handle_swap(
respond,
schedule,
"U_ALICE",
"swap 2026-07-04 <@U_BOB>",
"C1",
client,
"C_TEST",
)
swap = schedule.get_swap("2026-07-04", "day")
assert swap is not None and swap["status"] == "pending"
assert swap["target_ext"] == "115" and swap["requester_ext"] == "114"
# DM'd the target.
assert "U_BOB" in _channels(client)
assert "Swap request sent" in text_of(respond)
@freeze_time(WED)
def test_accept_moves_slot(self, slackbot_app, schedule, seed, respond, client):
seed.roster("114", "Alice", slack_user_id="U_ALICE")
seed.roster("115", "Bob", slack_user_id="U_BOB")
seed.holiday(
"2026-07-04",
slots=2,
label="Independence Day",
assignees={"114": {"name": "Alice", "claimed_at": "x"}},
)
req = {"extension": "114", "name": "Alice", "slack_user_id": "U_ALICE"}
tgt = {"extension": "115", "name": "Bob", "slack_user_id": "U_BOB"}
schedule.create_pending_swap("2026-07-04", "day", req, tgt, 1_900_000_000)
body = {
"actions": [{"action_id": "swap_accept_2026-07-04_day"}],
"user": {"id": "U_BOB"},
"channel": {"id": "D1"},
}
slackbot_app.handle_swap_accept(body, respond, client, schedule, "C_TEST")
assignees = schedule.get_holiday("2026-07-04")["assignees"]
assert "115" in assignees and "114" not in assignees
assert schedule.get_swap("2026-07-04", "day")["status"] == "verified"
# No plain override was written for the holiday day.
assert schedule.get_override("2026-07-04", "day") is None
@freeze_time(WED)
def test_not_your_holiday_swap_rejected(
self, slackbot_app, schedule, seed, respond, client, text_of
):
seed.roster("116", "Carol", slack_user_id="U_CAROL")
seed.roster("115", "Bob", slack_user_id="U_BOB")
seed.holiday(
"2026-07-04",
slots=2,
label="Independence Day",
assignees={"114": {"name": "Alice", "claimed_at": "x"}},
)
slackbot_app._handle_swap(
respond,
schedule,
"U_CAROL",
"swap 2026-07-04 <@U_BOB>",
"C1",
client,
"C_TEST",
)
assert "not your shift" in text_of(respond).lower()
assert schedule.get_swap("2026-07-04", "day") is None