meal-order-manager/tests/test_slack_notifier.py
Adam Moussa d7ee6a3b74
fix(jobs): run delayed close and reminder deliveries
Wall-clock skip windows dropped the only weekly SQS attempt when Scheduler already fired in Eastern time. Dev schedules stay disabled.
2026-09-21 14:48:59 -04:00

364 lines
16 KiB
Python

"""Unit tests for the slack_notifier handler."""
import sys
import os
from decimal import Decimal
from unittest.mock import patch
# ---------------------------------------------------------------------------
# Ensure the handler module can be imported. The shared layer lives under
# src/shared/ and the handler lives under src/server/jobs/.
# ---------------------------------------------------------------------------
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, "src", "server", "jobs", "notify.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)
# ────────────────────────────────────────────────────────────────────────────
# Reminder delayed SQS delivery
# ────────────────────────────────────────────────────────────────────────────
class TestReminderDelayedDelivery:
@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")
def test_reminder_runs_after_scheduled_hour(
self, mock_week, mock_roster, mock_orders, mock_dm
):
result = handler.handle_reminder({"event": "reminder"})
assert result["status"] != "skipped"
@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")
def test_lambda_handler_reminder_does_not_skip(
self, mock_week, mock_roster, mock_orders, mock_dm
):
result = handler.lambda_handler({"event": "reminder"}, None)
assert result["status"] != "skipped"
# ────────────────────────────────────────────────────────────────────────────
# 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")
def test_reminder_sends_to_non_ordered(
self, mock_week, mock_roster, mock_orders, mock_dm
):
"""Roster of 3, 1 has ordered -> DMs sent to 2 others."""
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")
def test_reminder_skips_no_slack_id(
self, mock_week, mock_roster, mock_orders, mock_dm
):
"""Employee without slack_user_id is counted as missing but not DM'd."""
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")
def test_reminder_case_insensitive_email(
self, mock_week, mock_roster, mock_orders, mock_dm
):
"""'Adam@x.com' in roster matches 'adam@x.com' in orders."""
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.send_dm")
@patch("slack_notifier_handler.get_roster")
@patch("slack_notifier_handler.get_settings")
@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_settings, mock_roster, mock_dm
):
"""employee_total < grand_total -> admin DM 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")},
],
}
mock_settings.return_value = {"admin_emails": ["admin@x.com"]}
mock_roster.return_value = [
{"email": "admin@x.com", "name": "Admin A", "slack_user_id": "U100"},
]
result = handler.handle_orders_aggregated({"event": "orders_aggregated"})
assert result["status"] == "notified"
mock_dm.assert_called_once()
blocks = mock_dm.call_args.args[2]
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.send_dm")
@patch("slack_notifier_handler.get_roster")
@patch("slack_notifier_handler.get_settings")
@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_settings, mock_roster, mock_dm
):
"""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")},
],
}
mock_settings.return_value = {"admin_emails": ["admin@x.com"]}
mock_roster.return_value = [
{"email": "admin@x.com", "name": "Admin A", "slack_user_id": "U100"},
]
result = handler.handle_orders_aggregated({"event": "orders_aggregated"})
assert result["status"] == "notified"
blocks = mock_dm.call_args.args[2]
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()
@patch("slack_notifier_handler.send_dm")
@patch("slack_notifier_handler.get_roster")
@patch("slack_notifier_handler.get_settings")
@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_dms_admins(
self, mock_week, mock_summary, mock_post, mock_settings, mock_roster, mock_dm
):
"""Admins with a Slack ID get DM'd the summary; others are skipped."""
mock_summary.return_value = {
"total_employees": 2,
"total_meals": 4,
"grand_total": Decimal("60.00"),
"employee_total": Decimal("60.00"),
"meals": [
{"meal": "Grilled Chicken", "quantity": Decimal("4")},
],
}
mock_settings.return_value = {
"admin_emails": ["Admin@x.com", "noslack@x.com", "missing@x.com"],
}
mock_roster.return_value = [
{"email": "admin@x.com", "name": "Admin A", "slack_user_id": "U100"},
{"email": "noslack@x.com", "name": "No Slack"}, # no slack_user_id
]
result = handler.handle_orders_aggregated({"event": "orders_aggregated"})
assert result["status"] == "notified"
assert result["admin_dm_count"] == 1, "Only the admin with a Slack ID is DM'd"
mock_dm.assert_called_once()
assert mock_dm.call_args.args[0] == "U100"
# ────────────────────────────────────────────────────────────────────────────
# Escaping (Low)
# ────────────────────────────────────────────────────────────────────────────
class TestEscaping:
def test_escape_mrkdwn(self):
"""'A & B <C>' -> 'A &amp; B &lt;C&gt;'."""
assert handler._escape_mrkdwn("A & B <C>") == "A &amp; B &lt;C&gt;"
# ────────────────────────────────────────────────────────────────────────────
# 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"
)