mirror of
https://github.com/Sea-Haven-Industries/afterhours-shift-manager.git
synced 2026-09-30 05:33:12 +00:00
Add swap-acceptance (verified-swap) flow (#87)
Some checks are pending
Deploy / deploy (push) Waiting to run
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:
parent
3a26343cb7
commit
060bd0bf3e
9 changed files with 591 additions and 44 deletions
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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 = (
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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 <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)
|
||||
|
||||
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
|
||||
|
|
|
|||
|
|
@ -53,6 +53,9 @@ Resources:
|
|||
KeyType: HASH
|
||||
- AttributeName: SK
|
||||
KeyType: RANGE
|
||||
TimeToLiveSpecification:
|
||||
AttributeName: expires_at
|
||||
Enabled: true
|
||||
|
||||
# --- Slack Bot Lambda ---
|
||||
SlackBotFunction:
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
162
tests/slack_bot/test_swap_accept_decline.py
Normal file
162
tests/slack_bot/test_swap_accept_decline.py
Normal 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()
|
||||
Loading…
Add table
Reference in a new issue