Add swap-acceptance (verified-swap) flow (#87)
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
This commit is contained in:
Adam Moussa 2026-06-01 19:22:55 -04:00 • committed by GitHub
parent 3a26343cb7
commit 060bd0bf3e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 591 additions and 44 deletions

View file

@ -16,7 +16,7 @@ The weekly schedule post is updated live when shifts change, and the previous we
| `/oncall next` | Show next week's schedule |
| `/oncall pick <date>` | Pick up an available shift |
| `/oncall drop <date>` | Drop your shift (marks it available) |
| `/oncall swap <date> @person` | Hand your shift to someone else |
| `/oncall swap <date> @person` | Request a swap — the other person gets an Accept/Decline DM and the shift only moves once they accept |
| `/oncall register <ext>` | Link your Slack account to your phone extension |
| `/oncall roster` | Show all employees and their link status |
| `/oncall pay` | Show last week's bonus pay summary |
@ -79,10 +79,15 @@ Single table with `PK` / `SK` keys:
| `ROSTER` | `<extension>` | Employee: name, extension, slack_user_id |
| `WEEKLY` | `<DayName>` | Default weekly schedule: extension, name |
| `OVERRIDE` | `<YYYY-MM-DD>` | Date override from pickup/drop (or `OPEN`) |
| `SWAP` | `<YYYY-MM-DD>` | Pending/verified swap request: requester, target, status, `expires_at` (TTL) |
| `SCHEDULE_POST` | `<channel_id>` | Current schedule message timestamp |
| `PAY` | `<YYYY-MM-DD>` | Weekly pay record (Monday date key) |
| `CONFIG` | `CONFIG` | Settings: shift_rate, fallback_extension, admin_users |
Weekend day-shift rows use a `-DAY` suffix on the SK (e.g. `OVERRIDE` / `2026-04-05-DAY`). The table has TTL enabled on `expires_at` so abandoned pending swaps self-clean.
**Swap flow:** `/oncall swap` writes a `pending` `SWAP` record and DMs the target Accept/Decline buttons; it does **not** reassign the shift. On Accept, the override is written, 3CX is repointed if it's the active shift, and the record is marked `verified`. On Decline (or once the shift has started) the request is dropped and the shift stays with the original owner.
### Secrets Manager
| Secret | Description |

View file

@ -149,6 +149,54 @@ def build_shift_change_message(
return [{"type": "section", "text": {"type": "mrkdwn", "text": text}}]
def build_swap_request_blocks(
requester_slack: str, date_str: str, shift_type: str = "night"
) -> list[dict]:
"""Build the interactive Accept / Decline message DMed to a swap target."""
dt = datetime.strptime(date_str, "%Y-%m-%d")
day_label = dt.strftime("%A, %b %-d")
type_label = (
f" ({SHIFT_LABELS.get(shift_type, shift_type)})"
if dt.strftime("%A") in WEEKEND_DAYS
else ""
)
action_suffix = "_day" if shift_type == "day" else ""
return [
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": (
f"<@{requester_slack}> wants you to cover the "
f"*{day_label}*{type_label} shift. Accept to take it on."
),
},
},
{
"type": "actions",
"elements": [
{
"type": "button",
"text": {"type": "plain_text", "text": "Accept"},
"action_id": f"swap_accept_{date_str}{action_suffix}",
"style": "primary",
},
{
"type": "button",
"text": {"type": "plain_text", "text": "Decline"},
"action_id": f"swap_decline_{date_str}{action_suffix}",
"style": "danger",
},
],
},
]
def build_swap_resolved_blocks(text: str) -> list[dict]:
"""A plain section used to replace a swap DM's buttons once it's resolved."""
return [{"type": "section", "text": {"type": "mrkdwn", "text": text}}]
def build_help_blocks(is_admin: bool = False) -> list[dict]:
"""Build help message blocks."""
text = (

View file

@ -130,6 +130,60 @@ class ShiftSchedule:
sk = f"{date_str}-DAY" if shift_type == "day" else date_str
self.table.delete_item(Key={"PK": "OVERRIDE", "SK": sk})
# ── Swap requests ───────────────────────────────────────────────────
def create_pending_swap(
self,
date_str: str,
shift_type: str,
requester: dict,
target: dict,
expires_at: int,
) -> None:
"""Create (or supersede) a pending swap request for a shift.
One swap per shift (unique SK), so a new request overwrites any prior
pending one. ``expires_at`` is an epoch timestamp used for DynamoDB TTL.
"""
sk = f"{date_str}-DAY" if shift_type == "day" else date_str
self.table.put_item(
Item={
"PK": "SWAP",
"SK": sk,
"shift_type": shift_type,
"status": "pending",
"requester_ext": requester["extension"],
"requester_name": requester["name"],
"requester_slack": requester.get("slack_user_id", ""),
"target_ext": target["extension"],
"target_name": target["name"],
"target_slack": target.get("slack_user_id", ""),
"created_at": datetime.now(EASTERN).isoformat(),
"expires_at": expires_at,
}
)
def get_swap(self, date_str: str, shift_type: str = "night") -> dict | None:
sk = f"{date_str}-DAY" if shift_type == "day" else date_str
resp = self.table.get_item(Key={"PK": "SWAP", "SK": sk})
return resp.get("Item")
def mark_swap_verified(self, date_str: str, shift_type: str = "night") -> None:
sk = f"{date_str}-DAY" if shift_type == "day" else date_str
self.table.update_item(
Key={"PK": "SWAP", "SK": sk},
UpdateExpression="SET #s = :v, accepted_at = :a",
ExpressionAttributeNames={"#s": "status"},
ExpressionAttributeValues={
":v": "verified",
":a": datetime.now(EASTERN).isoformat(),
},
)
def clear_swap(self, date_str: str, shift_type: str = "night") -> None:
sk = f"{date_str}-DAY" if shift_type == "day" else date_str
self.table.delete_item(Key={"PK": "SWAP", "SK": sk})
# ── Resolution ──────────────────────────────────────────────────────
def resolve_shift(

View file

@ -19,6 +19,8 @@ from shared.blocks import (
build_pay_summary_blocks,
build_roster_blocks,
build_shift_change_message,
build_swap_request_blocks,
build_swap_resolved_blocks,
build_week_schedule,
)
from shared.ring_scheduler import update_queue_routing
@ -103,6 +105,16 @@ def _is_active_shift_type(shift_type: str) -> bool:
return determine_shift_type() == shift_type
def _shift_start(date_str: str, shift_type: str) -> datetime:
"""The datetime (ET) a shift begins — weekend day at 08:00, otherwise 17:00."""
d = datetime.strptime(date_str, "%Y-%m-%d").replace(tzinfo=EASTERN)
return d.replace(hour=8 if shift_type == "day" else 17)
def _shift_started(date_str: str, shift_type: str) -> bool:
return datetime.now(EASTERN) >= _shift_start(date_str, shift_type)
def _shift_type_label(day_name: str, shift_type: str) -> str:
if day_name not in WEEKEND_DAYS:
return ""
@ -580,37 +592,170 @@ def _handle_swap(
respond(text=f"Extension `{target_text}` not found in the roster.")
return
schedule.set_override(date_str, target["extension"], target["name"], shift_type)
if target["extension"] == employee["extension"]:
respond(text="That shift is already yours — nothing to swap.")
return
if is_today(date_str) and _is_active_shift_type(shift_type):
_update_3cx_routing(target["extension"])
blocks = build_shift_change_message(
target.get("slack_user_id", user_id),
date_str,
"swapped",
target["extension"],
target["name"],
shift_type=shift_type,
# The target must be linked to Slack so we can DM them the request.
if not target.get("slack_user_id"):
respond(
text=f"*{target['name']}* (Ext {target['extension']}) isn't linked to Slack yet — "
"they need to run `/oncall register <extension>` before they can be swapped a shift."
)
return
# Create a pending swap and DM the target Accept/Decline. The shift does NOT
# move until they accept — the original owner stays responsible until then.
expires_at = int(_shift_start(date_str, shift_type).timestamp())
schedule.create_pending_swap(date_str, shift_type, employee, target, expires_at)
date_label = date.strftime("%A, %b %-d")
shift_label = _shift_type_label(day_name, shift_type)
try:
client.chat_postMessage(
channel=target["slack_user_id"],
blocks=build_swap_request_blocks(user_id, date_str, shift_type),
text=f"{employee['name']} wants to swap you the {date_str} shift",
)
except Exception:
logger.exception("Failed to DM swap request to target")
respond(
text=f"Couldn't reach *{target['name']}* on Slack to send the request. Try again later."
)
return
respond(
text=(
f"Swapped *{date_label}*{shift_label} to {target['name']} "
f"(Ext {target['extension']})."
f"Swap request sent to <@{target['slack_user_id']}> for "
f"*{date_label}*{shift_label}. The shift moves to them once they accept."
)
)
def _parse_swap_action(action_id: str, prefix: str) -> tuple[str, str]:
"""Split a swap action_id into (date_str, shift_type)."""
remainder = action_id[len(prefix) :]
if remainder.endswith("_day"):
return remainder[:-4], "day"
return remainder, "night"
def handle_swap_accept(body, respond, client, schedule, schedule_channel):
"""Target accepted a swap — apply the override and mark it verified."""
date_str, shift_type = _parse_swap_action(
body["actions"][0]["action_id"], "swap_accept_"
)
user_id = body["user"]["id"]
swap = schedule.get_swap(date_str, shift_type)
if (
not swap
or swap.get("status") != "pending"
or swap.get("target_slack") != user_id
):
respond(
replace_original=True,
blocks=build_swap_resolved_blocks("This swap request is no longer valid."),
)
return
if _shift_started(date_str, shift_type):
schedule.clear_swap(date_str, shift_type)
respond(
replace_original=True,
blocks=build_swap_resolved_blocks(
"This swap request has expired — the shift has already started."
),
)
return
schedule.set_override(date_str, swap["target_ext"], swap["target_name"], shift_type)
if is_today(date_str) and _is_active_shift_type(shift_type):
_update_3cx_routing(swap["target_ext"])
schedule.mark_swap_verified(date_str, shift_type)
day_name = datetime.strptime(date_str, "%Y-%m-%d").strftime("%A")
date_label = datetime.strptime(date_str, "%Y-%m-%d").strftime("%A, %b %-d")
shift_label = _shift_type_label(day_name, shift_type)
respond(
replace_original=True,
blocks=build_swap_resolved_blocks(
f"You're now covering the *{date_label}*{shift_label} shift. Thanks!"
),
)
if swap.get("requester_slack"):
try:
client.chat_postMessage(
channel=channel_id, blocks=blocks, text=f"Shift swapped for {date_str}"
channel=swap["requester_slack"],
text=f"<@{user_id}> accepted your swap — they're now on the *{date_label}*{shift_label} shift.",
)
except Exception:
logger.exception("Failed to DM swap requester on accept")
if schedule_channel:
blocks = build_shift_change_message(
user_id,
date_str,
"swapped",
swap["target_ext"],
swap["target_name"],
shift_type=shift_type,
)
try:
client.chat_postMessage(
channel=schedule_channel,
blocks=blocks,
text=f"Shift swapped for {date_str}",
)
except Exception:
logger.exception("Failed to post swap notification to channel")
_refresh_schedule_post(schedule, schedule_channel, client)
def handle_swap_decline(body, respond, client, schedule, schedule_channel):
"""Target declined a swap — clear it and notify the requester."""
date_str, shift_type = _parse_swap_action(
body["actions"][0]["action_id"], "swap_decline_"
)
user_id = body["user"]["id"]
swap = schedule.get_swap(date_str, shift_type)
if (
not swap
or swap.get("status") != "pending"
or swap.get("target_slack") != user_id
):
respond(
replace_original=True,
blocks=build_swap_resolved_blocks("This swap request is no longer valid."),
)
return
schedule.clear_swap(date_str, shift_type)
day_name = datetime.strptime(date_str, "%Y-%m-%d").strftime("%A")
date_label = datetime.strptime(date_str, "%Y-%m-%d").strftime("%A, %b %-d")
shift_label = _shift_type_label(day_name, shift_type)
respond(
replace_original=True,
blocks=build_swap_resolved_blocks(
f"You declined the *{date_label}*{shift_label} swap. No change."
),
)
if swap.get("requester_slack"):
try:
client.chat_postMessage(
channel=swap["requester_slack"],
text=f"<@{user_id}> declined your swap for *{date_label}*{shift_label} — it's still your shift.",
)
except Exception:
logger.exception("Failed to DM swap requester on decline")
def _handle_admin(respond, schedule, user_id, text, is_admin, client, schedule_channel):
if not is_admin:
respond(text="Admin commands are restricted. Contact an administrator.")
@ -784,4 +929,14 @@ def create_app(
ack()
handle_pickup(body, respond, client, schedule, schedule_channel)
@app.action(re.compile(r"^swap_accept_"))
def handle_swap_accept_button(ack, body, client, respond):
ack()
handle_swap_accept(body, respond, client, schedule, schedule_channel)
@app.action(re.compile(r"^swap_decline_"))
def handle_swap_decline_button(ack, body, client, respond):
ack()
handle_swap_decline(body, respond, client, schedule, schedule_channel)
return app

View file

@ -53,6 +53,9 @@ Resources:
KeyType: HASH
- AttributeName: SK
KeyType: RANGE
TimeToLiveSpecification:
AttributeName: expires_at
Enabled: true
# --- Slack Bot Lambda ---
SlackBotFunction:

View file

@ -7,6 +7,8 @@ from shared.blocks import (
build_pay_summary_blocks,
build_roster_blocks,
build_shift_change_message,
build_swap_request_blocks,
build_swap_resolved_blocks,
build_week_schedule,
)
@ -93,6 +95,43 @@ class TestBuildPaySummaryBlocks:
assert "Alice" in text and "$50.00" in text and "1 shift" in text
class TestBuildSwapRequestBlocks:
def _action_ids(self, blocks):
return [
e["action_id"]
for b in blocks
if b["type"] == "actions"
for e in b["elements"]
]
def test_weekday_request_has_accept_decline(self):
blocks = build_swap_request_blocks("U_REQ", "2026-06-03", "night")
assert "<@U_REQ>" in blocks[0]["text"]["text"]
ids = self._action_ids(blocks)
assert ids == ["swap_accept_2026-06-03", "swap_decline_2026-06-03"]
def test_weekend_day_request_uses_day_suffix_and_label(self):
blocks = build_swap_request_blocks("U_REQ", "2026-06-06", "day")
assert "Day (8am" in blocks[0]["text"]["text"]
ids = self._action_ids(blocks)
assert ids == ["swap_accept_2026-06-06_day", "swap_decline_2026-06-06_day"]
def test_button_styles(self):
elements = build_swap_request_blocks("U_REQ", "2026-06-03", "night")[1][
"elements"
]
assert elements[0]["style"] == "primary" # Accept
assert elements[1]["style"] == "danger" # Decline
class TestBuildSwapResolvedBlocks:
def test_renders_text_no_buttons(self):
blocks = build_swap_resolved_blocks("All done.")
assert blocks == [
{"type": "section", "text": {"type": "mrkdwn", "text": "All done."}}
]
class TestBuildHelpBlocks:
def test_non_admin_excludes_admin_section(self):
text = build_help_blocks(is_admin=False)[0]["text"]["text"]

View file

@ -218,3 +218,43 @@ class TestPayAndConfigAndPosts:
def test_shiftschedule_uses_env_table_name(monkeypatch, dynamodb_table):
# Constructed with no arg → reads SHIFT_TABLE.
assert ShiftSchedule().table.name == "afterhours-shifts"
class TestSwaps:
REQ = {"extension": "114", "name": "Alice", "slack_user_id": "U_ALICE"}
TGT = {"extension": "115", "name": "Bob", "slack_user_id": "U_BOB"}
def test_create_and_get_pending_swap(self, schedule):
schedule.create_pending_swap(WED, "night", self.REQ, self.TGT, 1_900_000_000)
swap = schedule.get_swap(WED)
assert swap["status"] == "pending"
assert swap["requester_ext"] == "114" and swap["requester_slack"] == "U_ALICE"
assert swap["target_ext"] == "115" and swap["target_slack"] == "U_BOB"
assert swap["expires_at"] == 1_900_000_000
assert swap["created_at"] # stamped
def test_get_missing_swap(self, schedule):
assert schedule.get_swap(WED) is None
def test_create_supersedes_existing(self, schedule):
schedule.create_pending_swap(WED, "night", self.REQ, self.TGT, 1)
other = {"extension": "116", "name": "Carol", "slack_user_id": "U_CAROL"}
schedule.create_pending_swap(WED, "night", self.REQ, other, 2)
swap = schedule.get_swap(WED)
assert swap["target_ext"] == "116" # overwritten, only one record
def test_day_and_night_swaps_independent(self, schedule):
schedule.create_pending_swap(SAT, "day", self.REQ, self.TGT, 1)
assert schedule.get_swap(SAT, "day")["target_ext"] == "115"
assert schedule.get_swap(SAT, "night") is None
def test_mark_verified(self, schedule):
schedule.create_pending_swap(WED, "night", self.REQ, self.TGT, 1)
schedule.mark_swap_verified(WED)
swap = schedule.get_swap(WED)
assert swap["status"] == "verified" and swap["accepted_at"]
def test_clear_swap(self, schedule):
schedule.create_pending_swap(WED, "night", self.REQ, self.TGT, 1)
schedule.clear_swap(WED)
assert schedule.get_swap(WED) is None

View file

@ -1,4 +1,5 @@
"""Tests for slack-bot _handle_swap."""
"""Tests for slack-bot _handle_swap — now creates a pending swap + DMs the target
instead of reassigning immediately."""
from freezegun import freeze_time
@ -12,87 +13,127 @@ def _setup(seed):
@freeze_time(MON)
def test_swap_to_mention(
def test_request_creates_pending_and_dms_target(
slackbot_app, schedule, seed, respond, client, routing_spy, text_of
):
_setup(seed)
slackbot_app._handle_swap(
respond, schedule, "U_ALICE", "swap today <@U_BOB>", "C1", client, None
respond, schedule, "U_ALICE", "swap today <@U_BOB>", "C1", client, "C_TEST"
)
override = schedule.get_override("2026-06-01")
assert override["extension"] == "115" and override["name"] == "Bob"
assert "Swapped" in text_of(respond)
routing_spy.assert_called_once_with("115")
client.chat_postMessage.assert_called_once()
swap = schedule.get_swap("2026-06-01")
assert swap["status"] == "pending" and swap["target_ext"] == "115"
# Not applied yet — the shift still belongs to Alice.
assert schedule.get_override("2026-06-01") is None
routing_spy.assert_not_called()
# DM'd the target, told the requester.
assert client.chat_postMessage.call_args.kwargs["channel"] == "U_BOB"
assert "Swap request sent" in text_of(respond)
@freeze_time(MON)
def test_swap_to_extension(slackbot_app, schedule, seed, respond, client, routing_spy):
def test_request_by_extension(
slackbot_app, schedule, seed, respond, client, routing_spy
):
_setup(seed)
slackbot_app._handle_swap(
respond, schedule, "U_ALICE", "swap today 115", "C1", client, None
respond, schedule, "U_ALICE", "swap today 115", "C1", client, "C_TEST"
)
assert schedule.get_override("2026-06-01")["extension"] == "115"
assert schedule.get_swap("2026-06-01")["target_ext"] == "115"
@freeze_time(MON)
def test_swap_not_your_shift(
slackbot_app, schedule, seed, respond, client, routing_spy, text_of
def test_later_request_supersedes(slackbot_app, schedule, seed, respond, client):
_setup(seed)
seed.roster("116", "Carol", slack_user_id="U_CAROL")
slackbot_app._handle_swap(
respond, schedule, "U_ALICE", "swap today <@U_BOB>", "C1", client, "C_TEST"
)
slackbot_app._handle_swap(
respond, schedule, "U_ALICE", "swap today <@U_CAROL>", "C1", client, "C_TEST"
)
assert schedule.get_swap("2026-06-01")["target_ext"] == "116"
@freeze_time(MON)
def test_target_not_linked_to_slack(
slackbot_app, schedule, seed, respond, client, text_of
):
seed.roster("114", "Alice", slack_user_id="U_ALICE")
seed.roster("115", "Bob") # registered but never linked Slack
seed.weekly("Monday", "114", "Alice")
slackbot_app._handle_swap(
respond, schedule, "U_ALICE", "swap today 115", "C1", client, "C_TEST"
)
assert "isn't linked to Slack" in text_of(respond)
assert schedule.get_swap("2026-06-01") is None
@freeze_time(MON)
def test_swap_to_self_rejected(slackbot_app, schedule, seed, respond, client, text_of):
_setup(seed)
slackbot_app._handle_swap(
respond, schedule, "U_ALICE", "swap today 114", "C1", client, "C_TEST"
)
assert "already yours" in text_of(respond).lower()
@freeze_time(MON)
def test_not_your_shift(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.weekly("Monday", "115", "Bob") # Bob's shift, not Alice's
slackbot_app._handle_swap(
respond, schedule, "U_ALICE", "swap today <@U_BOB>", "C1", client, None
respond, schedule, "U_ALICE", "swap today <@U_BOB>", "C1", client, "C_TEST"
)
assert "not your shift" in text_of(respond).lower()
routing_spy.assert_not_called()
assert schedule.get_swap("2026-06-01") is None
@freeze_time(MON)
def test_swap_target_mention_unregistered(
def test_target_mention_unregistered(
slackbot_app, schedule, seed, respond, client, text_of
):
seed.roster("114", "Alice", slack_user_id="U_ALICE")
seed.weekly("Monday", "114", "Alice")
slackbot_app._handle_swap(
respond, schedule, "U_ALICE", "swap today <@U_GHOST>", "C1", client, None
respond, schedule, "U_ALICE", "swap today <@U_GHOST>", "C1", client, "C_TEST"
)
assert "isn't registered" in text_of(respond)
@freeze_time(MON)
def test_swap_target_extension_not_found(
def test_target_extension_not_found(
slackbot_app, schedule, seed, respond, client, text_of
):
seed.roster("114", "Alice", slack_user_id="U_ALICE")
seed.weekly("Monday", "114", "Alice")
slackbot_app._handle_swap(
respond, schedule, "U_ALICE", "swap today 999", "C1", client, None
respond, schedule, "U_ALICE", "swap today 999", "C1", client, "C_TEST"
)
assert "not found" in text_of(respond).lower()
@freeze_time(MON)
def test_swap_past_date(slackbot_app, schedule, seed, respond, client, text_of):
def test_past_date(slackbot_app, schedule, seed, respond, client, text_of):
_setup(seed)
slackbot_app._handle_swap(
respond, schedule, "U_ALICE", "swap 2026-05-01 <@U_BOB>", "C1", client, None
respond, schedule, "U_ALICE", "swap 2026-05-01 <@U_BOB>", "C1", client, "C_TEST"
)
assert "past" in text_of(respond).lower()
@freeze_time(MON)
def test_swap_unregistered_requester(slackbot_app, schedule, respond, client, text_of):
def test_unregistered_requester(slackbot_app, schedule, respond, client, text_of):
slackbot_app._handle_swap(
respond, schedule, "U_NOBODY", "swap today 115", "C1", client, None
respond, schedule, "U_NOBODY", "swap today 115", "C1", client, "C_TEST"
)
assert "not registered" in text_of(respond).lower()
@freeze_time(MON)
def test_swap_usage(slackbot_app, schedule, respond, client, text_of):
def test_usage(slackbot_app, schedule, respond, client, text_of):
slackbot_app._handle_swap(
respond, schedule, "U_ALICE", "swap today", "C1", client, None
respond, schedule, "U_ALICE", "swap today", "C1", client, "C_TEST"
)
assert "Usage" in text_of(respond)

View file

@ -0,0 +1,162 @@
"""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()