afterhours-shift-manager/tests/shared/test_blocks.py
Adam Moussa 3a26343cb7
Add pytest suite and wire it into CI (#85) (#86)
* Add pytest suite and wire it into CI

Stands up the first automated tests for the repo (151 tests) and turns on
the CI test step.

- Lift slack-bot handlers out of create_app() closures to module level so
  they're unit-testable; create_app is now a thin Bolt-wiring layer. No
  behavior change (handler entrypoints and create_app signature unchanged).
- tests/ mirrors src/: shared layer (schedule, blocks, 3CX client,
  ring_scheduler, secrets) + all four Lambdas (pay math, drop/swap/pick/
  admin/register/rate, pickup button, roster sync, queue scheduler).
- All boundaries mocked: DynamoDB/SES/Secrets via moto, 3CX HTTP via
  responses, Slack via fakes, time via freezegun. No real network/AWS.
- pyproject.toml pytest config (pythonpath=src/shared, importlib mode);
  per-package conftest loads each app.py under a unique name to avoid the
  four-app.py collision. tests/requirements.txt for test-only deps.
- ci.yaml: run-tests: true (reusable workflow auto-installs deps) and lint
  the tests dir too.
- README Testing section.

Closes #85

* Add least-privilege permissions block to CI workflow

Resolves the CodeQL actions/missing-workflow-permissions alert: the CI
workflow now restricts GITHUB_TOKEN to contents: read (it only checks out,
lints, and runs tests).

* Stop logging extension numbers in 3CX queue updates

Resolves 3 high CodeQL py/clear-text-logging-sensitive-data alerts: the
queue/ring-group forwarding logs no longer include the routed extension
values (closed/holiday/extension). Non-sensitive context (resource id,
queue number) is retained.
2026-06-01 19:07:08 -04:00

120 lines
4.3 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_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 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