mirror of
https://github.com/Sea-Haven-Industries/meal-order-manager.git
synced 2026-09-30 17:03:13 +00:00
375 lines
17 KiB
Python
375 lines
17 KiB
Python
"""Unit tests for the slack_notifier handler."""
|
|
|
|
import sys
|
|
import os
|
|
from datetime import datetime
|
|
from decimal import Decimal
|
|
from unittest.mock import patch
|
|
from zoneinfo import ZoneInfo
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Ensure the handler module can be imported. The shared layer lives under
|
|
# src/shared/ and the handler lives under functions/slack_notifier/.
|
|
# ---------------------------------------------------------------------------
|
|
import importlib.util
|
|
|
|
_repo = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
sys.path.insert(0, os.path.join(_repo, "src", "shared"))
|
|
|
|
_handler_path = os.path.join(_repo, "functions", "slack_notifier", "handler.py")
|
|
_spec = importlib.util.spec_from_file_location("slack_notifier_handler", _handler_path)
|
|
handler = importlib.util.module_from_spec(_spec)
|
|
sys.modules["slack_notifier_handler"] = handler
|
|
_spec.loader.exec_module(handler)
|
|
|
|
ET = ZoneInfo("America/New_York")
|
|
|
|
|
|
# ────────────────────────────────────────────────────────────────────────────
|
|
# Helpers
|
|
# ────────────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
def _make_datetime(year, month, day, hour, minute=0):
|
|
"""Return a timezone-aware datetime in America/New_York."""
|
|
return datetime(year, month, day, hour, minute, tzinfo=ET)
|
|
|
|
|
|
def _thursday_10am():
|
|
"""2026-05-14 is a Thursday."""
|
|
return _make_datetime(2026, 5, 14, 10)
|
|
|
|
|
|
def _thursday_11am():
|
|
return _make_datetime(2026, 5, 14, 11)
|
|
|
|
|
|
def _wednesday_10am():
|
|
"""2026-05-13 is a Wednesday."""
|
|
return _make_datetime(2026, 5, 13, 10)
|
|
|
|
|
|
# ────────────────────────────────────────────────────────────────────────────
|
|
# Reminder Dedup Guard (Critical)
|
|
# ────────────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
class TestReminderDedupGuard:
|
|
@patch("slack_notifier_handler.datetime")
|
|
def test_reminder_skipped_wrong_hour(self, mock_dt):
|
|
"""Invoked at 11am Thursday ET -> returns skipped."""
|
|
mock_dt.now.return_value = _thursday_11am()
|
|
|
|
result = handler.handle_reminder({"event": "reminder"})
|
|
|
|
assert result["status"] == "skipped", "Should skip when hour is not 10"
|
|
assert "outside reminder window" in result["reason"]
|
|
|
|
@patch("slack_notifier_handler.datetime")
|
|
def test_reminder_skipped_wrong_day(self, mock_dt):
|
|
"""Invoked at 10am Wednesday ET -> returns skipped."""
|
|
mock_dt.now.return_value = _wednesday_10am()
|
|
|
|
result = handler.handle_reminder({"event": "reminder"})
|
|
|
|
assert result["status"] == "skipped", "Should skip when day is not Thursday"
|
|
assert "outside reminder window" in result["reason"]
|
|
|
|
@patch("slack_notifier_handler.send_dm")
|
|
@patch("slack_notifier_handler.get_orders", return_value=[])
|
|
@patch("slack_notifier_handler.get_roster", return_value=[])
|
|
@patch("slack_notifier_handler.current_week", return_value="2026-W19")
|
|
@patch("slack_notifier_handler.datetime")
|
|
def test_reminder_runs_at_correct_time(
|
|
self, mock_dt, mock_week, mock_roster, mock_orders, mock_dm
|
|
):
|
|
"""Invoked at 10am Thursday ET -> proceeds (does not skip)."""
|
|
mock_dt.now.return_value = _thursday_10am()
|
|
|
|
result = handler.handle_reminder({"event": "reminder"})
|
|
|
|
assert result["status"] != "skipped", "Should not skip at 10am Thursday"
|
|
|
|
@patch("slack_notifier_handler.datetime")
|
|
def test_lambda_handler_reminder_guard(self, mock_dt):
|
|
"""lambda_handler lines 32-34 also return skipped for wrong time."""
|
|
mock_dt.now.return_value = _thursday_11am()
|
|
|
|
result = handler.lambda_handler({"event": "reminder"}, None)
|
|
|
|
assert result["status"] == "skipped", (
|
|
"lambda_handler should short-circuit before calling handle_reminder"
|
|
)
|
|
assert "outside reminder window" in result["reason"]
|
|
|
|
|
|
# ────────────────────────────────────────────────────────────────────────────
|
|
# Reminder DMs (High)
|
|
# ────────────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
class TestReminderDMs:
|
|
@patch("slack_notifier_handler.send_dm")
|
|
@patch("slack_notifier_handler.get_orders")
|
|
@patch("slack_notifier_handler.get_roster")
|
|
@patch("slack_notifier_handler.current_week", return_value="2026-W19")
|
|
@patch("slack_notifier_handler.datetime")
|
|
def test_reminder_sends_to_non_ordered(
|
|
self, mock_dt, mock_week, mock_roster, mock_orders, mock_dm
|
|
):
|
|
"""Roster of 3, 1 has ordered -> DMs sent to 2 others."""
|
|
mock_dt.now.return_value = _thursday_10am()
|
|
mock_roster.return_value = [
|
|
{"email": "alice@x.com", "name": "Alice A", "slack_user_id": "U001"},
|
|
{"email": "bob@x.com", "name": "Bob B", "slack_user_id": "U002"},
|
|
{"email": "carol@x.com", "name": "Carol C", "slack_user_id": "U003"},
|
|
]
|
|
mock_orders.return_value = [
|
|
{"employee_email": "alice@x.com"},
|
|
]
|
|
|
|
result = handler.handle_reminder({"event": "reminder"})
|
|
|
|
assert result["dm_count"] == 2, "Should DM the 2 employees who haven't ordered"
|
|
assert result["missing_count"] == 2
|
|
assert mock_dm.call_count == 2
|
|
|
|
dm_user_ids = {call.args[0] for call in mock_dm.call_args_list}
|
|
assert dm_user_ids == {"U002", "U003"}, "Should DM Bob and Carol, not Alice"
|
|
|
|
@patch("slack_notifier_handler.send_dm")
|
|
@patch("slack_notifier_handler.get_orders", return_value=[])
|
|
@patch("slack_notifier_handler.get_roster")
|
|
@patch("slack_notifier_handler.current_week", return_value="2026-W19")
|
|
@patch("slack_notifier_handler.datetime")
|
|
def test_reminder_skips_no_slack_id(
|
|
self, mock_dt, mock_week, mock_roster, mock_orders, mock_dm
|
|
):
|
|
"""Employee without slack_user_id is counted as missing but not DM'd."""
|
|
mock_dt.now.return_value = _thursday_10am()
|
|
mock_roster.return_value = [
|
|
{"email": "dave@x.com", "name": "Dave D"}, # no slack_user_id
|
|
{"email": "eve@x.com", "name": "Eve E", "slack_user_id": "U005"},
|
|
]
|
|
|
|
result = handler.handle_reminder({"event": "reminder"})
|
|
|
|
assert result["missing_count"] == 2, "Both are missing (no orders at all)"
|
|
assert result["dm_count"] == 1, "Only Eve should be DM'd (Dave has no Slack ID)"
|
|
mock_dm.assert_called_once()
|
|
assert mock_dm.call_args.args[0] == "U005"
|
|
|
|
@patch("slack_notifier_handler.send_dm")
|
|
@patch("slack_notifier_handler.get_orders")
|
|
@patch("slack_notifier_handler.get_roster")
|
|
@patch("slack_notifier_handler.current_week", return_value="2026-W19")
|
|
@patch("slack_notifier_handler.datetime")
|
|
def test_reminder_case_insensitive_email(
|
|
self, mock_dt, mock_week, mock_roster, mock_orders, mock_dm
|
|
):
|
|
"""'Adam@x.com' in roster matches 'adam@x.com' in orders."""
|
|
mock_dt.now.return_value = _thursday_10am()
|
|
mock_roster.return_value = [
|
|
{"email": "Adam@x.com", "name": "Adam M", "slack_user_id": "U010"},
|
|
]
|
|
mock_orders.return_value = [
|
|
{"employee_email": "adam@x.com"},
|
|
]
|
|
|
|
result = handler.handle_reminder({"event": "reminder"})
|
|
|
|
assert result["dm_count"] == 0, (
|
|
"Adam already ordered (case-insensitive match) — no DM expected"
|
|
)
|
|
mock_dm.assert_not_called()
|
|
|
|
|
|
# ────────────────────────────────────────────────────────────────────────────
|
|
# Order Confirmed (High)
|
|
# ────────────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
class TestOrderConfirmed:
|
|
@patch("slack_notifier_handler.send_dm")
|
|
@patch("slack_notifier_handler.get_roster")
|
|
@patch("slack_notifier_handler.current_week", return_value="2026-W19")
|
|
def test_order_confirmed_dm_format(self, mock_week, mock_roster, mock_dm):
|
|
"""DM contains item breakdown with 'your cost' labels and payroll total."""
|
|
mock_roster.return_value = [
|
|
{"email": "alice@x.com", "name": "Alice Adams", "slack_user_id": "U001"},
|
|
]
|
|
|
|
event = {
|
|
"event": "order_confirmed",
|
|
"employee_email": "alice@x.com",
|
|
"employee_name": "Alice Adams",
|
|
"items": [
|
|
{"name": "Grilled Chicken", "quantity": 2, "price": 8.50},
|
|
{"name": "Caesar Salad", "quantity": 1, "price": 6.00},
|
|
],
|
|
"total": 23.00,
|
|
"week": "2026-W19",
|
|
}
|
|
|
|
handler.handle_order_confirmed(event)
|
|
|
|
mock_dm.assert_called_once()
|
|
call_kwargs = mock_dm.call_args
|
|
blocks = call_kwargs.kwargs.get("blocks") or call_kwargs[1].get("blocks")
|
|
body_text = blocks[1]["text"]["text"]
|
|
|
|
assert "your cost: $17.00" in body_text, "Should show Grilled Chicken x2 cost"
|
|
assert "your cost: $6.00" in body_text, "Should show Caesar Salad x1 cost"
|
|
assert "$23.00" in body_text, "Should show payroll deduction total"
|
|
assert "payroll deduction" in body_text.lower()
|
|
|
|
@patch("slack_notifier_handler.send_dm")
|
|
@patch("slack_notifier_handler.get_roster")
|
|
@patch("slack_notifier_handler.current_week", return_value="2026-W19")
|
|
def test_order_confirmed_no_slack_id(self, mock_week, mock_roster, mock_dm):
|
|
"""Employee not in roster -> returns {'status': 'no_slack_id'}."""
|
|
mock_roster.return_value = [] # empty roster
|
|
|
|
event = {
|
|
"event": "order_confirmed",
|
|
"employee_email": "nobody@x.com",
|
|
"employee_name": "Nobody",
|
|
"items": [],
|
|
"total": 0,
|
|
}
|
|
|
|
result = handler.handle_order_confirmed(event)
|
|
|
|
assert result["status"] == "no_slack_id"
|
|
mock_dm.assert_not_called()
|
|
|
|
@patch("slack_notifier_handler.send_dm")
|
|
@patch("slack_notifier_handler.get_roster")
|
|
@patch("slack_notifier_handler.current_week", return_value="2026-W19")
|
|
def test_order_confirmed_empty_name(self, mock_week, mock_roster, mock_dm):
|
|
"""Empty name -> greeting says 'Hey there!' not crash."""
|
|
mock_roster.return_value = [
|
|
{"email": "anon@x.com", "name": "", "slack_user_id": "U099"},
|
|
]
|
|
|
|
event = {
|
|
"event": "order_confirmed",
|
|
"employee_email": "anon@x.com",
|
|
"employee_name": "",
|
|
"items": [{"name": "Soup", "quantity": 1, "price": 5.00}],
|
|
"total": 5.00,
|
|
}
|
|
|
|
result = handler.handle_order_confirmed(event)
|
|
|
|
assert result["status"] == "confirmed", "Should not crash on empty name"
|
|
|
|
blocks = mock_dm.call_args.kwargs.get("blocks") or mock_dm.call_args[1].get(
|
|
"blocks"
|
|
)
|
|
body_text = blocks[1]["text"]["text"]
|
|
assert "Hey there!" in body_text, (
|
|
"Should greet with 'Hey there!' when name is empty"
|
|
)
|
|
|
|
|
|
# ────────────────────────────────────────────────────────────────────────────
|
|
# Orders Aggregated (High)
|
|
# ────────────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
class TestOrdersAggregated:
|
|
@patch("slack_notifier_handler.post_channel_message")
|
|
@patch("slack_notifier_handler.get_summary")
|
|
@patch("slack_notifier_handler.current_week", return_value="2026-W19")
|
|
def test_orders_aggregated_with_subsidy(self, mock_week, mock_summary, mock_post):
|
|
"""employee_total < grand_total -> message includes company subsidy line."""
|
|
mock_summary.return_value = {
|
|
"total_employees": 5,
|
|
"total_meals": 12,
|
|
"grand_total": Decimal("150.00"),
|
|
"employee_total": Decimal("120.00"),
|
|
"meals": [
|
|
{"meal": "Grilled Chicken", "quantity": Decimal("7")},
|
|
{"meal": "Veggie Bowl", "quantity": Decimal("5")},
|
|
],
|
|
}
|
|
|
|
result = handler.handle_orders_aggregated({"event": "orders_aggregated"})
|
|
|
|
assert result["status"] == "notified"
|
|
mock_post.assert_called_once()
|
|
|
|
blocks = mock_post.call_args.args[1]
|
|
section_text = blocks[1]["text"]["text"]
|
|
|
|
assert "$150.00" in section_text, "Should show grand total"
|
|
assert "$120.00" in section_text, "Should show employee payroll deductions"
|
|
assert "$30.00" in section_text, "Should show company subsidy amount"
|
|
assert "company subsidy" in section_text.lower()
|
|
|
|
@patch("slack_notifier_handler.post_channel_message")
|
|
@patch("slack_notifier_handler.get_summary")
|
|
@patch("slack_notifier_handler.current_week", return_value="2026-W19")
|
|
def test_orders_aggregated_without_subsidy(
|
|
self, mock_week, mock_summary, mock_post
|
|
):
|
|
"""Equal totals -> no subsidy line."""
|
|
mock_summary.return_value = {
|
|
"total_employees": 3,
|
|
"total_meals": 6,
|
|
"grand_total": Decimal("90.00"),
|
|
"employee_total": Decimal("90.00"),
|
|
"meals": [
|
|
{"meal": "Pasta Primavera", "quantity": Decimal("6")},
|
|
],
|
|
}
|
|
|
|
result = handler.handle_orders_aggregated({"event": "orders_aggregated"})
|
|
|
|
assert result["status"] == "notified"
|
|
blocks = mock_post.call_args.args[1]
|
|
section_text = blocks[1]["text"]["text"]
|
|
|
|
assert "company subsidy" not in section_text.lower(), (
|
|
"Should not mention subsidy when employee_total == grand_total"
|
|
)
|
|
|
|
@patch("slack_notifier_handler.post_channel_message")
|
|
@patch("slack_notifier_handler.get_summary")
|
|
@patch("slack_notifier_handler.current_week", return_value="2026-W19")
|
|
def test_orders_aggregated_no_summary(self, mock_week, mock_summary, mock_post):
|
|
"""No summary in DB -> returns {'status': 'no_summary'}."""
|
|
mock_summary.return_value = None
|
|
|
|
result = handler.handle_orders_aggregated({"event": "orders_aggregated"})
|
|
|
|
assert result["status"] == "no_summary"
|
|
mock_post.assert_not_called()
|
|
|
|
|
|
# ────────────────────────────────────────────────────────────────────────────
|
|
# Escaping (Low)
|
|
# ────────────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
class TestEscaping:
|
|
def test_escape_mrkdwn(self):
|
|
"""'A & B <C>' -> 'A & B <C>'."""
|
|
assert handler._escape_mrkdwn("A & B <C>") == "A & B <C>"
|
|
|
|
|
|
# ────────────────────────────────────────────────────────────────────────────
|
|
# Routing
|
|
# ────────────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
class TestRouting:
|
|
def test_unknown_event_type(self):
|
|
"""Unknown event type returns error message."""
|
|
result = handler.lambda_handler({"event": "bogus_event"}, None)
|
|
|
|
assert "error" in result, "Should return an error key for unknown event type"
|
|
assert "bogus_event" in result["error"], (
|
|
"Error message should include the unrecognised event type"
|
|
)
|