mirror of
https://github.com/Sea-Haven-Industries/afterhours-shift-manager.git
synced 2026-10-01 18:53:12 +00:00
375 lines
15 KiB
Python
375 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
|