afterhours-shift-manager/tests/slack_bot/test_late_pickup.py

375 lines
15 KiB
Python
Raw Normal View History

"""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