mirror of
https://github.com/Sea-Haven-Industries/meal-order-manager.git
synced 2026-09-30 07:43:13 +00:00
* feat(api): serve meals on ECS Fargate instead of Lambda Keep the Flask app always-on with in-process jobs so CloudFront no longer fronts a cold-start API Gateway. * 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. * fix(api): return JSON objects and stop logging job payloads Flask now jsonify-s handler dicts so API responses are not HTML, and the worker logs only event and status. * fix(ci): restore the reusable workflow so the required check is named ci / ci Inlining the job reported `ci` instead of the org ruleset's `ci / ci`. * fix(secrets): drop unused os import so ruff check passes * style: apply ruff format so ci-python-app lint passes * fix(infra): give meals its own VPC because prod has none * chore(security): re-key ALB SG checkov suppression after vpc.tf
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"
|
|
)
|