mirror of
https://github.com/Sea-Haven-Industries/meal-order-manager.git
synced 2026-10-01 12:53:12 +00:00
50 tests covering pricing pipeline (Decimal rounding, clamping, totals), Google auth (enforcement, bypass prevention, audience/domain validation, 503 on outage), email slug generation, form status with reopen_at, input validation, Slack failure resilience, reminder dedup guards, order confirmation DMs, aggregated summaries, CSV generation, and mrkdwn escaping.
380 lines
17 KiB
Python
380 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 MagicMock, patch
|
|
from zoneinfo import ZoneInfo
|
|
|
|
import pytest
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 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"
|
|
)
|