afterhours-shift-manager/tests/shared/test_blocks.py
Adam Moussa 470e00affb
Some checks are pending
Deploy API / Deploy API to dev (push) Waiting to run
Deploy API / Deploy API to prod (push) Waiting to run
feat(schedule): align the work week with Sunday-Saturday payroll (DEV-300) (#282)
* feat(schedule): align the work week with Sunday-Saturday payroll

Saturday night stays in the week that ends Saturday, and the first Flex close skips dates already sent.

* fix(slack-bot): show the last pay close on Sunday

The Monday 7am row for the week that just ended is not written yet, so /oncall pay now falls back to the prior close.
2026-09-25 17:56:46 +00:00

489 lines
18 KiB
Python

"""Tests for shared.blocks — Block Kit builders."""
from freezegun import freeze_time
from shared.blocks import (
build_admin_overview,
build_help_blocks,
build_holiday_add_modal,
build_holiday_added_blocks,
build_override_modal,
build_pay_summary_blocks,
build_pickup_request_blocks,
build_pickup_resolved_blocks,
build_roster_blocks,
build_shift_change_message,
build_swap_request_blocks,
build_swap_resolved_blocks,
build_week_schedule,
markdown_to_mrkdwn,
)
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"]
# Monday 2026-06-01 belongs to the week that opened Sunday May 31.
assert blocks[0]["text"]["text"] == "After-Hours Schedule — May 31 to Jun 13"
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"]
@freeze_time("2026-06-01 12:00:00")
def test_weekday_night_row_shows_glyph_without_time_label(self, schedule, seed):
# Weekdays are night-only, so the moon glyph alone differentiates the row;
# the verbose time-range label is reserved for weekends where both shifts
# coexist (and would otherwise repeat on ~10 weekday rows).
seed.weekly("Wednesday", "114", "Alice")
text = build_week_schedule(schedule)[1]["text"]["text"]
wednesday_line = next(
line for line in text.splitlines() if "Alice (Ext 114)" in line
)
assert ":crescent_moon:" in wednesday_line
assert "Night (5pm" not in wednesday_line
@freeze_time("2026-06-01 12:00:00")
def test_weekend_night_row_keeps_glyph_and_time_label(self, schedule, seed):
# Weekends carry both shifts, so the night row keeps the full label.
seed.weekly("Saturday", "200", "Alice")
text = build_week_schedule(schedule)[1]["text"]["text"]
saturday_line = next(
line for line in text.splitlines() if "Alice (Ext 200)" in line
)
assert ":crescent_moon:" in saturday_line
assert "Night (5pm" in saturday_line
@freeze_time("2026-06-01 12:00:00")
def test_weekend_day_row_shows_day_glyph_and_label(self, schedule, seed):
seed.weekly("Saturday", "200", "Alice", shift_type="day")
text = build_week_schedule(schedule)[1]["text"]["text"]
assert ":sunny:" in text
assert "Day (8am" in text
@freeze_time("2026-06-01 12:00:00")
def test_holiday_on_weekday_renders_badge_and_open_slots(self, schedule, seed):
# A holiday can land on a weekday (here a Thursday) and shows the badge,
# the multiplier, and a day-shift pickup button for its open slots.
seed.holiday("2026-06-04", slots=2, label="Test Holiday")
blocks = build_week_schedule(schedule)
text = blocks[1]["text"]["text"]
assert "Holiday" in text
assert "Test Holiday" in text
assert "1.5x" in text
assert "2 open slots" in text
assert "pickup_2026-06-04_day" in _all_action_ids(blocks)
@freeze_time("2026-06-01 12:00:00")
def test_holiday_lists_assignees_and_remaining_slots(self, schedule, seed):
seed.holiday(
"2026-06-04",
slots=2,
label="Test Holiday",
assignees={"114": {"name": "Alice", "claimed_at": "x"}},
)
blocks = build_week_schedule(schedule)
text = blocks[1]["text"]["text"]
assert "Alice (Ext 114)" in text
assert "1 open slot" in text # one of two slots filled
@freeze_time("2026-06-01 12:00:00")
def test_full_holiday_has_no_pickup_button(self, schedule, seed):
seed.holiday(
"2026-06-04",
slots=1,
label="Test Holiday",
assignees={"114": {"name": "Alice", "claimed_at": "x"}},
)
blocks = build_week_schedule(schedule)
assert "pickup_2026-06-04_day" not in _all_action_ids(blocks)
@freeze_time("2026-06-01 12:00:00")
def test_holiday_pickup_button_label_says_holiday(self, schedule, seed):
seed.holiday("2026-06-04", slots=2, label="Test Holiday")
blocks = build_week_schedule(schedule)
labels = [
e["text"]["text"]
for b in blocks
if b.get("type") == "actions"
for e in b["elements"]
]
assert any("Holiday" in lbl for lbl in labels)
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 is night-only → the notification still labels the after-hours shift
assert "Night (5pm" 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
def test_holiday_row_shows_effective_rate_and_note(self):
breakdown = [
{
"day": "Thu",
"date_label": "Jul 4",
"name": "Alice",
"extension": "114",
"rate": 75.0, # effective (1.5x of 50)
"is_holiday": True,
"multiplier": 1.5,
}
]
totals = {
"Alice": {
"shifts": 1,
"total": 75.0,
"extension": "114",
"rate": 50.0,
"holiday_shifts": 1,
}
}
blocks = build_pay_summary_blocks("Jun 29 to Jul 5", breakdown, totals)
text = blocks[1]["text"]["text"]
assert "$75.00" in text # effective rate on the breakdown line
assert "1.5x" in text
assert "Holiday" in text
assert "incl. 1 holiday" in text # totals note
def test_regular_row_has_no_holiday_decorations(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}
}
text = build_pay_summary_blocks("Jun 1 to Jun 7", breakdown, totals)[1]["text"][
"text"
]
assert "Holiday" not in text
assert "incl." not 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 TestBuildHolidayAddedBlocks:
def test_header_context_and_section(self):
blocks = build_holiday_added_blocks("2026-07-04", "Independence Day", 2, 1.5)
assert blocks[0]["type"] == "header"
assert "Independence Day" in blocks[0]["text"]["text"]
assert blocks[1]["type"] == "context"
section = blocks[-1]["text"]["text"]
assert "2 slots" in section
assert "1.5x" in section
assert "Holiday" in section
def test_single_slot_is_singular(self):
section = build_holiday_added_blocks("2026-07-04", "Indep", 1, 2)[-1]["text"][
"text"
]
assert "1 slot" in section and "1 slots" not in section
assert "2x" in section # whole-number multiplier drops the .0
def test_date_label_override(self):
blocks = build_holiday_added_blocks(
"2026-07-04", "Indep", 1, 1.5, date_label="July 4th"
)
assert blocks[1]["elements"][0]["text"] == "July 4th"
class TestBuildPickupRequestBlocks:
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_encodes_date_and_ext(self):
blocks = build_pickup_request_blocks(
"U_REQ", "Alice", "2026-06-03", "night", "114"
)
text = blocks[0]["text"]["text"]
assert "<@U_REQ>" in text and "Ext 114" in text
assert "already started" in text
ids = self._action_ids(blocks)
assert ids == ["pickup_approve_2026-06-03_114", "pickup_deny_2026-06-03_114"]
def test_weekend_day_request_uses_day_suffix(self):
blocks = build_pickup_request_blocks(
"U_REQ", "Alice", "2026-06-06", "day", "114"
)
assert "Day (8am" in blocks[0]["text"]["text"]
ids = self._action_ids(blocks)
assert ids == [
"pickup_approve_2026-06-06_day_114",
"pickup_deny_2026-06-06_day_114",
]
def test_holiday_request_shows_badge_and_day_suffix(self):
blocks = build_pickup_request_blocks(
"U_REQ", "Alice", "2026-07-04", "day", "114", is_holiday=True
)
assert "Holiday" in blocks[0]["text"]["text"]
ids = self._action_ids(blocks)
assert ids == [
"pickup_approve_2026-07-04_day_114",
"pickup_deny_2026-07-04_day_114",
]
def test_button_styles(self):
elements = build_pickup_request_blocks(
"U_REQ", "Alice", "2026-06-03", "night", "114"
)[1]["elements"]
assert elements[0]["style"] == "primary" # Approve
assert elements[1]["style"] == "danger" # Deny
class TestBuildPickupResolvedBlocks:
def test_renders_text_no_buttons(self):
blocks = build_pickup_resolved_blocks("Approved.")
assert blocks == [
{"type": "section", "text": {"type": "mrkdwn", "text": "Approved."}}
]
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
def test_admin_includes_holiday_commands(self):
text = build_help_blocks(is_admin=True)[0]["text"]["text"]
assert "holiday add" in text
assert "holiday remove" in text
assert "holiday list" in text
def test_non_admin_excludes_holiday_commands(self):
text = build_help_blocks(is_admin=False)[0]["text"]["text"]
assert "holiday add" not 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
def _block_ids(view):
return {b["block_id"] for b in view["blocks"] if b.get("block_id")}
class TestBuildOverrideModal:
def test_callback_and_input_blocks(self):
view = build_override_modal([{"extension": "114", "name": "Alice"}])
assert view["type"] == "modal"
assert view["callback_id"] == "admin_override_submit"
assert _block_ids(view) == {"date", "extension", "shift_type"}
def test_extension_dropdown_built_from_roster(self):
view = build_override_modal(
[{"extension": "114", "name": "Alice"}, {"extension": "115", "name": "Bob"}]
)
ext_block = next(b for b in view["blocks"] if b["block_id"] == "extension")
values = [o["value"] for o in ext_block["element"]["options"]]
assert values == ["114", "115"]
def test_empty_roster_omits_options(self):
view = build_override_modal([])
ext_block = next(b for b in view["blocks"] if b["block_id"] == "extension")
assert "options" not in ext_block["element"]
class TestBuildHolidayAddModal:
def test_callback_and_input_blocks(self):
view = build_holiday_add_modal()
assert view["callback_id"] == "admin_holiday_add_submit"
assert _block_ids(view) == {"date", "slots", "multiplier", "label"}
def test_multiplier_is_optional(self):
view = build_holiday_add_modal()
mult = next(b for b in view["blocks"] if b["block_id"] == "multiplier")
assert mult["optional"] is True
class TestBuildAdminOverview:
def test_empty_overview(self):
text = build_admin_overview([], [])["text"]["text"]
assert "Nothing scheduled" in text
def test_lists_overrides_and_holidays(self):
overrides = [
{
"date": "2026-12-25",
"shift_type": "night",
"extension": "114",
"name": "Alice",
},
{
"date": "2026-12-26",
"shift_type": "day",
"extension": "OPEN",
"name": "Open",
},
]
holidays = [
{
"SK": "2026-12-25",
"slots": 2,
"assignees": {},
"multiplier": 1.5,
"label": "Christmas",
}
]
text = build_admin_overview(overrides, holidays)["text"]["text"]
assert "Alice (Ext 114)" in text
assert "(Day) — Open" in text
assert "Christmas" in text
assert "0/2 filled" in text
class TestMarkdownToMrkdwn:
def test_markdown_bold_becomes_slack_bold(self):
assert markdown_to_mrkdwn("**Big news.** text") == "*Big news.* text"
def test_markdown_italic_becomes_underscore(self):
# Single asterisks are italic in Markdown; Slack uses underscores.
assert markdown_to_mrkdwn("a *note* here") == "a _note_ here"
def test_bold_and_italic_together(self):
out = markdown_to_mrkdwn("**Bold.** Then *(an aside)*")
assert out == "*Bold.* Then _(an aside)_"
def test_links_and_bullets(self):
out = markdown_to_mrkdwn("- see [docs](http://x)\n- next")
assert "• see <http://x|docs>" in out
assert "• next" in out
def test_adversarial_input_runs_in_linear_time(self):
# py/polynomial-redos regression: possessive quantifiers must keep these
# patterns from catastrophic backtracking. Pathological inputs that would
# hang a backtracking engine complete effectively instantly here.
import time
for evil in ("*" + "*a" * 4000, "[" + "[\\(" * 4000, "[" + "](" * 4000):
start = time.perf_counter()
markdown_to_mrkdwn(evil)
assert time.perf_counter() - start < 1.0