diff --git a/README.md b/README.md index bfe0d93..7687fab 100644 --- a/README.md +++ b/README.md @@ -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 ` | Pick up an available shift | | `/oncall drop ` | Drop your shift (marks it available) | -| `/oncall swap @person` | Hand your shift to someone else | +| `/oncall swap @person` | Request a swap — the other person gets an Accept/Decline DM and the shift only moves once they accept | | `/oncall register ` | 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` | `` | Employee: name, extension, slack_user_id | | `WEEKLY` | `` | Default weekly schedule: extension, name | | `OVERRIDE` | `` | Date override from pickup/drop (or `OPEN`) | +| `SWAP` | `` | Pending/verified swap request: requester, target, status, `expires_at` (TTL) | | `SCHEDULE_POST` | `` | Current schedule message timestamp | | `PAY` | `` | 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 | diff --git a/src/shared/shared/blocks.py b/src/shared/shared/blocks.py index 73fbe72..b974005 100644 --- a/src/shared/shared/blocks.py +++ b/src/shared/shared/blocks.py @@ -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 = ( diff --git a/src/shared/shared/schedule.py b/src/shared/shared/schedule.py index aba7f2c..0fdca7b 100644 --- a/src/shared/shared/schedule.py +++ b/src/shared/shared/schedule.py @@ -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( diff --git a/src/slack-bot/app.py b/src/slack-bot/app.py index 0c97891..e69486f 100644 --- a/src/slack-bot/app.py +++ b/src/slack-bot/app.py @@ -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"]) + # 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 ` 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) - blocks = build_shift_change_message( - target.get("slack_user_id", user_id), - date_str, - "swapped", - target["extension"], - target["name"], - shift_type=shift_type, - ) 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." ) ) - try: - client.chat_postMessage( - channel=channel_id, blocks=blocks, text=f"Shift swapped for {date_str}" + +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."), ) - except Exception: - logger.exception("Failed to post swap notification to channel") + 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=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 diff --git a/template.yaml b/template.yaml index 3077ac2..7d2354f 100644 --- a/template.yaml +++ b/template.yaml @@ -53,6 +53,9 @@ Resources: KeyType: HASH - AttributeName: SK KeyType: RANGE + TimeToLiveSpecification: + AttributeName: expires_at + Enabled: true # --- Slack Bot Lambda --- SlackBotFunction: diff --git a/tests/shared/test_blocks.py b/tests/shared/test_blocks.py index 0deb1d4..8fdc4dc 100644 --- a/tests/shared/test_blocks.py +++ b/tests/shared/test_blocks.py @@ -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"] diff --git a/tests/shared/test_schedule.py b/tests/shared/test_schedule.py index 771ec7b..111253c 100644 --- a/tests/shared/test_schedule.py +++ b/tests/shared/test_schedule.py @@ -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 diff --git a/tests/slack_bot/test_handle_swap.py b/tests/slack_bot/test_handle_swap.py index 0f25613..f7ad544 100644 --- a/tests/slack_bot/test_handle_swap.py +++ b/tests/slack_bot/test_handle_swap.py @@ -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) diff --git a/tests/slack_bot/test_swap_accept_decline.py b/tests/slack_bot/test_swap_accept_decline.py new file mode 100644 index 0000000..6498ee6 --- /dev/null +++ b/tests/slack_bot/test_swap_accept_decline.py @@ -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()