mirror of
https://github.com/Sea-Haven-Industries/afterhours-shift-manager.git
synced 2026-09-30 19:33:12 +00:00
Some checks are pending
Deploy / deploy (push) Waiting to run
/oncall swap no longer reassigns immediately. It now writes a pending SWAP record and DMs the target Accept/Decline buttons; the shift only moves once they accept. - schedule.py: create_pending_swap / get_swap / mark_swap_verified / clear_swap (PK=SWAP, date/shift SK mirroring OVERRIDE, status + timestamps + expires_at for TTL). A new request supersedes a prior pending one. - app.py: _handle_swap creates the pending swap + DMs the target (requires the target be Slack-linked; rejects self-swap). New module-level handle_swap_accept / handle_swap_decline + two @app.action registrations. Accept writes the override, repoints 3CX when it's the active shift, marks the swap verified, notifies the channel + requester. Decline clears it and DMs the requester. Lazy expiry: accept is rejected once the shift has started (_shift_start/_shift_started). - blocks.py: build_swap_request_blocks (Accept/Decline) + build_swap_resolved_blocks. - template.yaml: enable DynamoDB TTL on expires_at so abandoned pending swaps self-clean. - tests: swap schedule methods, swap blocks, rewritten test_handle_swap (pending + DM, no immediate override), new test_swap_accept_decline. 174 passed. - README: swap behavior + SWAP item type + TTL. The verified SWAP status is what #84 (24h drop guard) will query. Closes #83
162 lines
6 KiB
Python
162 lines
6 KiB
Python
"""Tests for slack-bot handle_swap_accept / handle_swap_decline."""
|
|
|
|
from freezegun import freeze_time
|
|
|
|
# Monday 2026-06-01 08:00 ET — weekday, active shift is night, before 17:00 start.
|
|
MON = "2026-06-01 12:00:00"
|
|
# Same Monday but 18:00 ET — the night shift has already started.
|
|
MON_AFTER_START = "2026-06-01 22:00:00"
|
|
|
|
REQ = {"extension": "114", "name": "Alice", "slack_user_id": "U_ALICE"}
|
|
TGT = {"extension": "115", "name": "Bob", "slack_user_id": "U_BOB"}
|
|
|
|
|
|
def _accept_body(date_str, user_id="U_BOB", suffix=""):
|
|
return {
|
|
"actions": [{"action_id": f"swap_accept_{date_str}{suffix}"}],
|
|
"user": {"id": user_id},
|
|
"channel": {"id": "D1"},
|
|
}
|
|
|
|
|
|
def _decline_body(date_str, user_id="U_BOB", suffix=""):
|
|
return {
|
|
"actions": [{"action_id": f"swap_decline_{date_str}{suffix}"}],
|
|
"user": {"id": user_id},
|
|
"channel": {"id": "D1"},
|
|
}
|
|
|
|
|
|
def _pending(schedule, date_str, shift_type="night", target=TGT):
|
|
schedule.create_pending_swap(date_str, shift_type, REQ, target, 1_900_000_000)
|
|
|
|
|
|
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]
|
|
|
|
|
|
class TestAccept:
|
|
@freeze_time(MON)
|
|
def test_applies_override_marks_verified_notifies(
|
|
self, slackbot_app, schedule, respond, client, routing_spy
|
|
):
|
|
_pending(schedule, "2026-06-01")
|
|
slackbot_app.handle_swap_accept(
|
|
_accept_body("2026-06-01"), respond, client, schedule, "C_TEST"
|
|
)
|
|
assert schedule.get_override("2026-06-01")["extension"] == "115"
|
|
assert schedule.get_swap("2026-06-01")["status"] == "verified"
|
|
# Today + active night → 3CX repointed to the new holder.
|
|
routing_spy.assert_called_once_with("115")
|
|
# Channel notification + requester DM both sent.
|
|
assert "C_TEST" in _channels(client) and "U_ALICE" in _channels(client)
|
|
assert "now covering" in _resolved_text(respond).lower()
|
|
|
|
@freeze_time(MON)
|
|
def test_future_shift_no_3cx(
|
|
self, slackbot_app, schedule, respond, client, routing_spy
|
|
):
|
|
_pending(schedule, "2026-06-03") # Wednesday
|
|
slackbot_app.handle_swap_accept(
|
|
_accept_body("2026-06-03"), respond, client, schedule, "C_TEST"
|
|
)
|
|
assert schedule.get_override("2026-06-03")["extension"] == "115"
|
|
routing_spy.assert_not_called()
|
|
|
|
@freeze_time(MON)
|
|
def test_wrong_clicker_rejected(
|
|
self, slackbot_app, schedule, respond, client, routing_spy
|
|
):
|
|
_pending(schedule, "2026-06-01")
|
|
slackbot_app.handle_swap_accept(
|
|
_accept_body("2026-06-01", user_id="U_STRANGER"),
|
|
respond,
|
|
client,
|
|
schedule,
|
|
"C_TEST",
|
|
)
|
|
assert schedule.get_override("2026-06-01") is None
|
|
assert "no longer valid" in _resolved_text(respond).lower()
|
|
routing_spy.assert_not_called()
|
|
|
|
@freeze_time(MON)
|
|
def test_no_pending_swap(self, slackbot_app, schedule, respond, client):
|
|
slackbot_app.handle_swap_accept(
|
|
_accept_body("2026-06-01"), respond, client, schedule, "C_TEST"
|
|
)
|
|
assert "no longer valid" in _resolved_text(respond).lower()
|
|
|
|
@freeze_time(MON)
|
|
def test_already_verified_swap_not_reapplied(
|
|
self, slackbot_app, schedule, respond, client
|
|
):
|
|
_pending(schedule, "2026-06-01")
|
|
schedule.mark_swap_verified("2026-06-01")
|
|
slackbot_app.handle_swap_accept(
|
|
_accept_body("2026-06-01"), respond, client, schedule, "C_TEST"
|
|
)
|
|
assert "no longer valid" in _resolved_text(respond).lower()
|
|
assert schedule.get_override("2026-06-01") is None
|
|
|
|
@freeze_time(MON_AFTER_START)
|
|
def test_expired_after_shift_start(
|
|
self, slackbot_app, schedule, respond, client, routing_spy
|
|
):
|
|
_pending(schedule, "2026-06-01")
|
|
slackbot_app.handle_swap_accept(
|
|
_accept_body("2026-06-01"), respond, client, schedule, "C_TEST"
|
|
)
|
|
assert "expired" in _resolved_text(respond).lower()
|
|
assert schedule.get_override("2026-06-01") is None
|
|
assert schedule.get_swap("2026-06-01") is None # cleared
|
|
routing_spy.assert_not_called()
|
|
|
|
@freeze_time(MON)
|
|
def test_weekend_day_suffix(self, slackbot_app, schedule, respond, client):
|
|
_pending(schedule, "2026-06-06", shift_type="day")
|
|
slackbot_app.handle_swap_accept(
|
|
_accept_body("2026-06-06", suffix="_day"),
|
|
respond,
|
|
client,
|
|
schedule,
|
|
"C_TEST",
|
|
)
|
|
assert schedule.get_override("2026-06-06", "day")["extension"] == "115"
|
|
assert schedule.get_swap("2026-06-06", "day")["status"] == "verified"
|
|
|
|
|
|
class TestDecline:
|
|
@freeze_time(MON)
|
|
def test_clears_and_dms_requester(self, slackbot_app, schedule, respond, client):
|
|
_pending(schedule, "2026-06-01")
|
|
slackbot_app.handle_swap_decline(
|
|
_decline_body("2026-06-01"), respond, client, schedule, "C_TEST"
|
|
)
|
|
assert schedule.get_swap("2026-06-01") is None
|
|
assert schedule.get_override("2026-06-01") is None # never applied
|
|
assert "declined" in _resolved_text(respond).lower()
|
|
assert "U_ALICE" in _channels(client) # requester notified
|
|
|
|
@freeze_time(MON)
|
|
def test_wrong_clicker_rejected(self, slackbot_app, schedule, respond, client):
|
|
_pending(schedule, "2026-06-01")
|
|
slackbot_app.handle_swap_decline(
|
|
_decline_body("2026-06-01", user_id="U_STRANGER"),
|
|
respond,
|
|
client,
|
|
schedule,
|
|
"C_TEST",
|
|
)
|
|
assert "no longer valid" in _resolved_text(respond).lower()
|
|
assert schedule.get_swap("2026-06-01") is not None # untouched
|
|
|
|
@freeze_time(MON)
|
|
def test_no_pending_swap(self, slackbot_app, schedule, respond, client):
|
|
slackbot_app.handle_swap_decline(
|
|
_decline_body("2026-06-01"), respond, client, schedule, "C_TEST"
|
|
)
|
|
assert "no longer valid" in _resolved_text(respond).lower()
|