mirror of
https://github.com/Sea-Haven-Industries/meal-order-manager.git
synced 2026-09-30 18:13:13 +00:00
Wall-clock skip windows dropped the only weekly SQS attempt when Scheduler already fired in Eastern time. Dev schedules stay disabled.
364 lines
16 KiB
Python
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 & 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"
|
|
)
|