mirror of
https://github.com/Sea-Haven-Industries/meal-order-manager.git
synced 2026-09-30 06:33:12 +00:00
Some checks failed
Deploy / deploy (push) Has been cancelled
* fix(test): mock get_settings/get_roster in aggregated tests + enable CI tests (INFRA-72) handle_orders_aggregated now delivers the summary via admin DMs (PR #15), adding get_settings/get_roster calls the aggregated tests never mocked, so they hit live DynamoDB. Mock both and assert the message content on the send_dm path. Set run-tests: true so the suite actually runs in CI. * fix(test): default AWS region in conftest so CI collection doesn't hit NoRegionError (INFRA-72) Handlers build boto3 clients at module load; CI runners have no AWS config, so test collection raised NoRegionError once the suite actually ran. Set a region default before imports (offline client construction; calls are mocked). * fix(test): add repo root to sys.path so CI's bare pytest collects functions.* (INFRA-72) test_aggregate_orders imports functions.aggregate_orders.handler, which needs the repo root on sys.path. python -m pytest injects CWD automatically but CI runs pytest directly, so these 11 tests errored at collection in CI only.
423 lines
19 KiB
Python
423 lines
19 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.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"
|
|
)
|