mirror of
https://github.com/Sea-Haven-Industries/afterhours-shift-manager.git
synced 2026-09-30 14:53: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
159 lines
5.7 KiB
Python
159 lines
5.7 KiB
Python
"""Tests for shared.blocks — Block Kit builders."""
|
|
|
|
from freezegun import freeze_time
|
|
|
|
from shared.blocks import (
|
|
build_help_blocks,
|
|
build_pay_summary_blocks,
|
|
build_roster_blocks,
|
|
build_shift_change_message,
|
|
build_swap_request_blocks,
|
|
build_swap_resolved_blocks,
|
|
build_week_schedule,
|
|
)
|
|
|
|
|
|
def _all_action_ids(blocks):
|
|
ids = []
|
|
for b in blocks:
|
|
if b.get("type") == "actions":
|
|
ids.extend(e["action_id"] for e in b["elements"])
|
|
return ids
|
|
|
|
|
|
class TestBuildWeekSchedule:
|
|
@freeze_time("2026-06-01 12:00:00") # Monday
|
|
def test_header_and_section_present(self, schedule):
|
|
blocks = build_week_schedule(schedule)
|
|
assert blocks[0]["type"] == "header"
|
|
assert "After-Hours Schedule" in blocks[0]["text"]["text"]
|
|
assert blocks[1]["type"] == "section"
|
|
|
|
@freeze_time("2026-06-01 12:00:00")
|
|
def test_all_available_produces_pickup_buttons(self, schedule):
|
|
# Empty schedule → every shift is available → pickup buttons exist,
|
|
# including a weekend day button with the _day suffix.
|
|
blocks = build_week_schedule(schedule)
|
|
action_ids = _all_action_ids(blocks)
|
|
assert "pickup_2026-06-03" in action_ids # Wednesday night
|
|
assert "pickup_2026-06-06_day" in action_ids # Saturday day shift
|
|
assert "pickup_2026-06-06" in action_ids # Saturday night shift
|
|
|
|
@freeze_time("2026-06-01 12:00:00")
|
|
def test_assigned_shift_has_no_pickup_button(self, schedule, seed):
|
|
seed.weekly("Wednesday", "114", "Alice")
|
|
blocks = build_week_schedule(schedule)
|
|
assert "pickup_2026-06-03" not in _all_action_ids(blocks)
|
|
assert "Alice (Ext 114)" in blocks[1]["text"]["text"]
|
|
|
|
|
|
class TestBuildShiftChangeMessage:
|
|
def test_picked_up_weekday(self):
|
|
blocks = build_shift_change_message(
|
|
"U1", "2026-06-03", "picked_up", "114", "Alice"
|
|
)
|
|
text = blocks[0]["text"]["text"]
|
|
assert "<@U1>" in text and "picked up" in text and "Ext 114" in text
|
|
# Weekday → no (Day/Night) label
|
|
assert "(Night" not in text
|
|
|
|
def test_dropped_shows_available(self):
|
|
blocks = build_shift_change_message(
|
|
"U1", "2026-06-03", "dropped", "114", "Alice"
|
|
)
|
|
assert "Available" in blocks[0]["text"]["text"]
|
|
|
|
def test_swapped_text(self):
|
|
blocks = build_shift_change_message("U2", "2026-06-03", "swapped", "115", "Bob")
|
|
assert "swapped" in blocks[0]["text"]["text"]
|
|
|
|
def test_weekend_includes_shift_label(self):
|
|
blocks = build_shift_change_message(
|
|
"U1", "2026-06-06", "picked_up", "200", "Alice", shift_type="day"
|
|
)
|
|
assert "Day (8am" in blocks[0]["text"]["text"]
|
|
|
|
|
|
class TestBuildPaySummaryBlocks:
|
|
def test_renders_breakdown_and_totals(self):
|
|
breakdown = [
|
|
{
|
|
"day": "Mon",
|
|
"date_label": "Jun 1",
|
|
"name": "Alice",
|
|
"extension": "114",
|
|
"rate": 50.0,
|
|
}
|
|
]
|
|
totals = {
|
|
"Alice": {"shifts": 1, "total": 50.0, "extension": "114", "rate": 50.0}
|
|
}
|
|
blocks = build_pay_summary_blocks("Jun 1 to Jun 7", breakdown, totals)
|
|
assert blocks[0]["type"] == "header"
|
|
assert "Jun 1 to Jun 7" in blocks[0]["text"]["text"]
|
|
text = blocks[1]["text"]["text"]
|
|
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"]
|
|
assert "Admin Commands" not in text
|
|
|
|
def test_admin_includes_admin_section(self):
|
|
text = build_help_blocks(is_admin=True)[0]["text"]["text"]
|
|
assert "Admin Commands" in text
|
|
|
|
|
|
class TestBuildRosterBlocks:
|
|
def test_empty_roster(self):
|
|
text = build_roster_blocks([])[0]["text"]["text"]
|
|
assert "No employees" in text
|
|
|
|
def test_linked_and_unlinked(self):
|
|
roster = [
|
|
{"SK": "115", "name": "Bob", "slack_user_id": ""},
|
|
{"SK": "114", "name": "Alice", "slack_user_id": "U_ALICE"},
|
|
]
|
|
text = build_roster_blocks(roster)[0]["text"]["text"]
|
|
# Sorted by extension → Alice (114) appears before Bob (115)
|
|
assert text.index("Alice") < text.index("Bob")
|
|
assert "<@U_ALICE>" in text
|
|
assert "_not linked_" in text
|