diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..ec2b6b5 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,11 @@ +"""Pytest configuration — add shared layer source to sys.path so handler imports resolve.""" + +import os +import sys + +# Add the shared layer source directory so `from shared.db import ...` works +# without requiring a real Lambda layer or .aws-sam build. +_shared_layer_dir = os.path.join( + os.path.dirname(__file__), os.pardir, "src", "shared" +) +sys.path.insert(0, os.path.abspath(_shared_layer_dir)) diff --git a/tests/test_aggregate_orders.py b/tests/test_aggregate_orders.py new file mode 100644 index 0000000..33eed03 --- /dev/null +++ b/tests/test_aggregate_orders.py @@ -0,0 +1,394 @@ +"""Unit tests for functions/aggregate_orders/handler.py.""" + +import csv +import io +import json +import os +from decimal import Decimal +from unittest.mock import MagicMock, patch + +import pytest + + +# --------------------------------------------------------------------------- +# Helpers — reusable order builders +# --------------------------------------------------------------------------- + +def _make_order(name: str, email: str, items: list[dict], total: float | None = None) -> dict: + """Build a minimal order dict matching DynamoDB shape.""" + if total is None: + total = sum( + float(i.get("subtotal", float(i.get("price", 0)) * int(i.get("quantity", 0)))) + for i in items + ) + return { + "employee_name": name, + "employee_email": email, + "items": items, + "total": Decimal(str(total)), + } + + +def _make_item( + name: str, + quantity: int = 1, + price: float = 10.0, + bulk_price: float | None = None, + subtotal: float | None = None, +) -> dict: + """Build a minimal item dict.""" + item = {"name": name, "quantity": quantity, "price": price} + if bulk_price is not None: + item["bulk_price"] = bulk_price + if subtotal is not None: + item["subtotal"] = subtotal + return item + + +# --------------------------------------------------------------------------- +# Import handler AFTER patching boto3 + env vars so module-level clients +# don't try to hit real AWS. +# --------------------------------------------------------------------------- + +@pytest.fixture(autouse=True) +def _patch_env(monkeypatch): + monkeypatch.setenv("TABLE_NAME", "test-table") + monkeypatch.setenv("REPORTS_BUCKET", "test-bucket") + monkeypatch.setenv("SLACK_NOTIFIER_ARN", "arn:aws:lambda:us-east-1:123456789012:function:test-notifier") + + +@pytest.fixture() +def handler_module(): + """Import the handler with boto3 patched at module level.""" + with patch("boto3.client") as mock_client, patch("boto3.resource"): + mock_s3 = MagicMock() + mock_lambda = MagicMock() + mock_client.side_effect = lambda svc, **kw: {"s3": mock_s3, "lambda": mock_lambda}[svc] + + import importlib + import functions.aggregate_orders.handler as mod + importlib.reload(mod) + + # Inject mocked clients so tests can assert on them + mod._s3 = mock_s3 + mod._lambda = mock_lambda + yield mod + + +# =================================================================== +# Summary Building (Critical + High) +# =================================================================== + + +class TestBuildSummarySingleOrder: + """test_build_summary_single_order — one order with 2 items.""" + + def test_build_summary_single_order(self, handler_module): + orders = [ + _make_order("Alice Smith", "alice@example.com", [ + _make_item("Chicken Parm", quantity=1, price=12.00, bulk_price=10.00), + _make_item("Caesar Salad", quantity=1, price=8.00, bulk_price=6.50), + ]), + ] + summary = handler_module.build_summary(orders, "2026-W20") + + assert summary["week"] == "2026-W20", "Week should be passed through" + assert summary["total_employees"] == 1, "Should count 1 employee" + assert summary["total_meals"] == 2, "Should count 2 total meals" + + meals_by_name = {m["meal"]: m for m in summary["meals"]} + assert "Chicken Parm" in meals_by_name, "Chicken Parm should appear" + assert "Caesar Salad" in meals_by_name, "Caesar Salad should appear" + + chicken = meals_by_name["Chicken Parm"] + assert chicken["quantity"] == 1 + assert chicken["unit_price"] == 10.00, "unit_price should use bulk_price" + assert chicken["employee_unit_price"] == 12.00, "employee_unit_price should use price" + assert chicken["line_total"] == 10.00, "line_total = bulk_price * qty" + assert chicken["employee_line_total"] == 12.00, "employee_line_total = price * qty" + + assert summary["grand_total"] == 16.50, "grand_total should sum bulk line totals" + assert summary["employee_total"] == 20.00, "employee_total should sum employee line totals" + + +class TestBuildSummaryMultipleOrdersSameMeal: + """test_build_summary_multiple_orders_same_meal — 3 employees order the same meal.""" + + def test_build_summary_multiple_orders_same_meal(self, handler_module): + orders = [ + _make_order("Alice", "a@x.com", [_make_item("Burger", quantity=1, price=10.00, bulk_price=8.00)]), + _make_order("Bob", "b@x.com", [_make_item("Burger", quantity=2, price=10.00, bulk_price=8.00)]), + _make_order("Carol", "c@x.com", [_make_item("Burger", quantity=1, price=10.00, bulk_price=8.00)]), + ] + summary = handler_module.build_summary(orders, "2026-W20") + + assert len(summary["meals"]) == 1, "All three ordered the same meal — should aggregate to 1 entry" + burger = summary["meals"][0] + assert burger["quantity"] == 4, "Total quantity should be 1 + 2 + 1 = 4" + assert burger["line_total"] == 32.00, "line_total = 8.00 * 4" + assert burger["employee_line_total"] == 40.00, "employee_line_total = 10.00 * 4" + + +class TestBuildSummarySortedAlphabetically: + """test_build_summary_sorted_alphabetically — meals appear in alphabetical order.""" + + def test_build_summary_sorted_alphabetically(self, handler_module): + orders = [ + _make_order("Alice", "a@x.com", [ + _make_item("Ziti", quantity=1, price=10.00), + _make_item("Apple Pie", quantity=1, price=5.00), + _make_item("Meatloaf", quantity=1, price=12.00), + ]), + ] + summary = handler_module.build_summary(orders, "2026-W20") + + meal_names = [m["meal"] for m in summary["meals"]] + assert meal_names == ["Apple Pie", "Meatloaf", "Ziti"], ( + "Meals should be sorted alphabetically" + ) + + +class TestBuildSummaryGrandTotalVsEmployeeTotal: + """test_build_summary_grand_total_vs_employee_total — grand_total uses bulk_price, employee_total uses price.""" + + def test_build_summary_grand_total_vs_employee_total(self, handler_module): + orders = [ + _make_order("Alice", "a@x.com", [ + _make_item("Steak", quantity=2, price=15.00, bulk_price=11.00), + ]), + _make_order("Bob", "b@x.com", [ + _make_item("Pasta", quantity=1, price=9.00, bulk_price=7.00), + ]), + ] + summary = handler_module.build_summary(orders, "2026-W20") + + # Steak: bulk 11*2=22, employee 15*2=30 + # Pasta: bulk 7*1=7, employee 9*1=9 + assert summary["grand_total"] == 29.00, "grand_total should use bulk_price (22 + 7)" + assert summary["employee_total"] == 39.00, "employee_total should use employee_price (30 + 9)" + assert summary["grand_total"] != summary["employee_total"], ( + "grand_total and employee_total must differ when bulk != employee price" + ) + + +class TestBuildSummaryRounding: + """test_build_summary_rounding — totals rounded to 2 decimal places.""" + + def test_build_summary_rounding(self, handler_module): + # Use prices that produce repeating decimals when multiplied + orders = [ + _make_order("Alice", "a@x.com", [ + _make_item("Soup", quantity=3, price=3.33, bulk_price=2.77), + ]), + ] + summary = handler_module.build_summary(orders, "2026-W20") + + soup = summary["meals"][0] + # 2.77 * 3 = 8.31 (rounded) + assert soup["line_total"] == 8.31, "line_total should be rounded to 2 decimals" + # 3.33 * 3 = 9.99 + assert soup["employee_line_total"] == 9.99, "employee_line_total should be rounded to 2 decimals" + assert summary["grand_total"] == 8.31 + assert summary["employee_total"] == 9.99 + + # Verify they are actually rounded (no extra decimal digits) + assert summary["grand_total"] == round(summary["grand_total"], 2) + assert summary["employee_total"] == round(summary["employee_total"], 2) + + +# =================================================================== +# CSV Generation (High) +# =================================================================== + + +class TestBuildOrderSummaryCsv: + """test_build_order_summary_csv — correct header, meal rows, total row format.""" + + def test_build_order_summary_csv(self, handler_module): + orders = [ + _make_order("Alice", "a@x.com", [ + _make_item("Burger", quantity=2, price=10.00, bulk_price=8.00), + _make_item("Fries", quantity=1, price=5.00, bulk_price=4.00), + ]), + ] + summary = handler_module.build_summary(orders, "2026-W20") + csv_str = handler_module.build_order_summary_csv(summary) + + reader = csv.reader(io.StringIO(csv_str)) + rows = list(reader) + + # Header + assert rows[0] == ["Meal", "Quantity", "Unit Price", "Line Total"], ( + "First row should be the header" + ) + + # Meal rows (alphabetical: Burger, Fries) + assert rows[1][0] == "Burger" + assert rows[1][1] == "2" + assert rows[1][2] == "$8.00" + assert rows[1][3] == "$16.00" + + assert rows[2][0] == "Fries" + assert rows[2][1] == "1" + assert rows[2][2] == "$4.00" + assert rows[2][3] == "$4.00" + + # Empty separator row then total row + assert rows[3] == [], "Separator should be an empty row" + assert rows[4][0] == "TOTAL" + assert rows[4][1] == "3", "Total quantity should be 3" + assert rows[4][3] == "$20.00", "Total should be grand_total" + + +class TestBuildPayrollCsv: + """test_build_payroll_csv — correct header, sorted employees, item format, total deduction.""" + + def test_build_payroll_csv(self, handler_module): + orders = [ + _make_order("Zara Adams", "zara@x.com", [ + _make_item("Pasta", quantity=2, price=9.00, subtotal=18.00), + ], total=18.00), + _make_order("Alice Brown", "alice@x.com", [ + _make_item("Burger", quantity=1, price=10.00, subtotal=10.00), + _make_item("Fries", quantity=2, price=5.00, subtotal=10.00), + ], total=20.00), + ] + csv_str = handler_module.build_payroll_csv(orders) + + reader = csv.reader(io.StringIO(csv_str)) + rows = list(reader) + + # Header + assert rows[0] == ["Employee Name", "Employee Email", "Items Ordered", "Total Deduction"] + + # Sorted alphabetically by employee_name: Alice Brown before Zara Adams + assert rows[1][0] == "Alice Brown", "Employees should be sorted by name" + assert rows[1][1] == "alice@x.com" + assert "Burger x1 ($10.00)" in rows[1][2] + assert "Fries x2 ($10.00)" in rows[1][2] + assert "; " in rows[1][2], "Items should be separated by '; '" + assert rows[1][3] == "$20.00" + + assert rows[2][0] == "Zara Adams" + assert rows[2][1] == "zara@x.com" + assert "Pasta x2 ($18.00)" in rows[2][2] + assert rows[2][3] == "$18.00" + + +class TestBuildPayrollCsvSubtotalFallback: + """test_build_payroll_csv_subtotal_fallback — when item lacks 'subtotal', falls back to price*quantity.""" + + def test_build_payroll_csv_subtotal_fallback(self, handler_module): + orders = [ + _make_order("Alice Brown", "alice@x.com", [ + _make_item("Burger", quantity=3, price=10.00), # no subtotal key + ], total=30.00), + ] + csv_str = handler_module.build_payroll_csv(orders) + + reader = csv.reader(io.StringIO(csv_str)) + rows = list(reader) + + # price(10) * quantity(3) = 30.00 + assert "Burger x3 ($30.00)" in rows[1][2], ( + "Without 'subtotal' key, should fall back to price * quantity" + ) + + +# =================================================================== +# Lambda Handler Flow (High) +# =================================================================== + + +class TestAggregateAlreadyAggregated: + """test_aggregate_already_aggregated — summary exists, returns early, no S3 upload.""" + + @patch("functions.aggregate_orders.handler.get_summary") + @patch("functions.aggregate_orders.handler.get_orders") + @patch("functions.aggregate_orders.handler.current_week", return_value="2026-W20") + def test_aggregate_already_aggregated( + self, mock_week, mock_orders, mock_summary, handler_module + ): + mock_summary.return_value = {"week": "2026-W20", "meals": []} + + result = handler_module.lambda_handler({}, None) + + assert result["status"] == "already_aggregated", "Should return already_aggregated status" + assert result["week"] == "2026-W20" + handler_module._s3.put_object.assert_not_called() + mock_orders.assert_not_called() + + +class TestAggregateNoOrders: + """test_aggregate_no_orders — no orders returns no_orders status.""" + + @patch("functions.aggregate_orders.handler.get_summary") + @patch("functions.aggregate_orders.handler.get_orders") + @patch("functions.aggregate_orders.handler.current_week", return_value="2026-W20") + def test_aggregate_no_orders( + self, mock_week, mock_orders, mock_summary, handler_module + ): + mock_summary.return_value = None + mock_orders.return_value = [] + + result = handler_module.lambda_handler({}, None) + + assert result["status"] == "no_orders", "Should return no_orders when order list is empty" + assert result["week"] == "2026-W20" + handler_module._s3.put_object.assert_not_called() + + +class TestAggregateHappyPath: + """test_aggregate_happy_path — orders exist, builds summary, uploads CSVs, saves, triggers Slack.""" + + @patch("functions.aggregate_orders.handler.put_summary") + @patch("functions.aggregate_orders.handler.get_summary") + @patch("functions.aggregate_orders.handler.get_orders") + @patch("functions.aggregate_orders.handler.current_week", return_value="2026-W20") + def test_aggregate_happy_path( + self, mock_week, mock_orders, mock_get_summary, mock_put_summary, handler_module + ): + mock_get_summary.return_value = None + mock_orders.return_value = [ + _make_order("Alice", "alice@x.com", [ + _make_item("Burger", quantity=1, price=10.00, bulk_price=8.00, subtotal=10.00), + ], total=10.00), + _make_order("Bob", "bob@x.com", [ + _make_item("Pasta", quantity=2, price=9.00, bulk_price=7.00, subtotal=18.00), + ], total=18.00), + ] + + result = handler_module.lambda_handler({}, None) + + # Verify return + assert result["status"] == "aggregated" + assert result["week"] == "2026-W20" + assert result["total_employees"] == 2 + + # Verify 2 S3 uploads (order summary CSV + payroll CSV) + s3_calls = handler_module._s3.put_object.call_args_list + assert len(s3_calls) == 2, "Should upload exactly 2 CSVs to S3" + + s3_keys = [call.kwargs["Key"] for call in s3_calls] + assert "reports/2026-W20/order-summary.csv" in s3_keys + assert "reports/2026-W20/payroll-deductions.csv" in s3_keys + + for call in s3_calls: + assert call.kwargs["Bucket"] == "test-bucket" + assert call.kwargs["ContentType"] == "text/csv" + + # Verify summary saved to DynamoDB + mock_put_summary.assert_called_once() + saved_summary = mock_put_summary.call_args[0][1] + assert saved_summary["week"] == "2026-W20" + assert "order_csv_s3_key" in saved_summary + assert "payroll_csv_s3_key" in saved_summary + + # Verify Slack notifier Lambda invoked asynchronously + handler_module._lambda.invoke.assert_called_once() + invoke_kwargs = handler_module._lambda.invoke.call_args.kwargs + assert invoke_kwargs["FunctionName"] == os.environ["SLACK_NOTIFIER_ARN"] + assert invoke_kwargs["InvocationType"] == "Event" + payload = json.loads(invoke_kwargs["Payload"]) + assert payload["event"] == "orders_aggregated" + assert payload["week"] == "2026-W20" diff --git a/tests/test_slack_notifier.py b/tests/test_slack_notifier.py new file mode 100644 index 0000000..c5ffd52 --- /dev/null +++ b/tests/test_slack_notifier.py @@ -0,0 +1,380 @@ +"""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 ' -> 'A & B <C>'.""" + assert handler._escape_mrkdwn("A & B ") == "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" + ) diff --git a/tests/test_submit_order.py b/tests/test_submit_order.py new file mode 100644 index 0000000..1af1f4c --- /dev/null +++ b/tests/test_submit_order.py @@ -0,0 +1,868 @@ +"""Unit tests for functions/submit_order/handler.py + +All external dependencies (DynamoDB, SSM, Secrets Manager, Lambda invoke) are +mocked — no real AWS calls are made. +""" + +import json +import os +import sys +import urllib.error +from datetime import datetime +from unittest.mock import MagicMock, patch +from zoneinfo import ZoneInfo + +import pytest + +# --------------------------------------------------------------------------- +# Environment variables required by the handler at import time +# --------------------------------------------------------------------------- +os.environ.setdefault("TABLE_NAME", "test-orders-table") +os.environ.setdefault("FORM_API_KEY_SECRET", "test/form-api-key") +os.environ.setdefault("SLACK_NOTIFIER_ARN", "arn:aws:lambda:us-east-1:000000000000:function:test-slack-notifier") +os.environ.setdefault("GOOGLE_CLIENT_ID_PARAM", "") + +import importlib.util + +_handler_path = os.path.join(os.path.dirname(__file__), os.pardir, "functions", "submit_order", "handler.py") +_spec = importlib.util.spec_from_file_location("submit_order_handler", os.path.abspath(_handler_path)) +submit_order_handler = importlib.util.module_from_spec(_spec) +sys.modules["submit_order_handler"] = submit_order_handler +_spec.loader.exec_module(submit_order_handler) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- +EASTERN = ZoneInfo("America/New_York") +TEST_API_KEY = "test-api-key-12345" +VALID_GOOGLE_CLIENT_ID = "123456789.apps.googleusercontent.com" + + +def _make_event( + method="POST", + path="/submit", + body=None, + headers=None, + path_parameters=None, +): + """Build an API Gateway v2 HTTP-format event.""" + event = { + "requestContext": { + "http": { + "method": method, + "path": path, + } + }, + "rawPath": path, + "headers": headers or {}, + "body": json.dumps(body) if body is not None else "{}", + "pathParameters": path_parameters or {}, + } + return event + + +def _submit_event(items, api_key=TEST_API_KEY, employee_name="Test User", + employee_email="test.user@seahavenind.com", extra_body=None): + """Shortcut for a typical POST /submit event with items.""" + body = { + "employee_name": employee_name, + "employee_email": employee_email, + "items": items, + } + if extra_body: + body.update(extra_body) + return _make_event( + method="POST", + path="/submit", + body=body, + headers={"x-api-key": api_key}, + ) + + +def _parse_response(result): + """Parse the Lambda response dict into (status_code, body_dict).""" + return result["statusCode"], json.loads(result["body"]) + + +def _make_items(retail_prices_and_qtys): + """Build item list from [(retail_price, quantity), ...].""" + items = [] + for i, (price, qty) in enumerate(retail_prices_and_qtys): + items.append({ + "name": f"Meal {i + 1}", + "retail_price": price, + "quantity": qty, + }) + return items + + +# --------------------------------------------------------------------------- +# Module-level patches that must be active before the handler is imported +# --------------------------------------------------------------------------- +# We patch boto3.client at the handler-module level so the module-level +# `_lambda = boto3.client("lambda")` call gets a mock. +# --------------------------------------------------------------------------- + +# We need to reset module-level caches between tests to avoid cross-test +# leakage. The handler module caches _api_key, _settings, _google_client_id. + + +@pytest.fixture(autouse=True) +def _reset_handler_caches(): + """Reset handler module-level caches before each test.""" + submit_order_handler._api_key = None + submit_order_handler._settings = None + submit_order_handler._settings_ts = 0.0 + submit_order_handler._google_client_id = None + submit_order_handler._google_client_id_ts = 0.0 + yield + + +@pytest.fixture(autouse=True) +def _reset_shared_caches(): + """Reset shared.secrets cache before each test.""" + from shared import secrets + secrets._cache = {} + yield + + +# =========================================================================== +# PRICING PIPELINE (Critical) +# =========================================================================== + +@patch("submit_order_handler._lambda") +@patch("submit_order_handler.put_order") +@patch("submit_order_handler.get_form_status", return_value="open") +@patch("submit_order_handler.current_week", return_value="2026-W20") +@patch("submit_order_handler.get_settings", return_value={"bulk_discount_percent": 10, "company_subsidy_percent": 50}) +@patch("submit_order_handler.get_secret", return_value=TEST_API_KEY) +@patch("submit_order_handler._get_google_client_id", return_value="") +def test_discount_two_step_rounding( + mock_gcid, mock_secret, mock_settings, mock_week, mock_status, mock_put, mock_lam +): + """Retail $10.25, 10% bulk, 50% subsidy. + + Step 1: bulk_price = 10.25 * 0.90 = 9.225 -> 9.23 (ROUND_HALF_UP) + Step 2: emp_price = 9.23 * 0.50 = 4.615 -> 4.62 (ROUND_HALF_UP) + Subtotal for qty=3: 4.62 * 3 = 13.86 + """ + from submit_order_handler import lambda_handler + + items = _make_items([(10.25, 3)]) + event = _submit_event(items) + result = lambda_handler(event, None) + status, body = _parse_response(result) + + assert status == 200, f"Expected 200, got {status}: {body}" + + saved_order = mock_put.call_args[0][2] + item = saved_order["items"][0] + + assert item["bulk_price"] == 9.23, ( + f"bulk_price should be 9.23 (10.25 * 0.90 rounded HALF_UP), got {item['bulk_price']}" + ) + assert item["price"] == 4.62, ( + f"emp_price should be 4.62 (9.23 * 0.50 rounded HALF_UP), got {item['price']}" + ) + assert item["subtotal"] == 13.86, ( + f"subtotal should be 13.86 (4.62 * 3), got {item['subtotal']}" + ) + + +@patch("submit_order_handler._lambda") +@patch("submit_order_handler.put_order") +@patch("submit_order_handler.get_form_status", return_value="open") +@patch("submit_order_handler.current_week", return_value="2026-W20") +@patch("submit_order_handler.get_settings", return_value={"bulk_discount_percent": 50, "company_subsidy_percent": 0}) +@patch("submit_order_handler.get_secret", return_value=TEST_API_KEY) +@patch("submit_order_handler._get_google_client_id", return_value="") +def test_discount_rounding_half_up_boundary( + mock_gcid, mock_secret, mock_settings, mock_week, mock_status, mock_put, mock_lam +): + """Retail $10.05, 50% bulk, 0% subsidy. + + bulk_price = 10.05 * 0.50 = 5.025 -> 5.03 (ROUND_HALF_UP, not 5.02 banker's) + """ + from submit_order_handler import lambda_handler + + items = _make_items([(10.05, 1)]) + event = _submit_event(items) + result = lambda_handler(event, None) + status, body = _parse_response(result) + + assert status == 200, f"Expected 200, got {status}: {body}" + + saved_order = mock_put.call_args[0][2] + item = saved_order["items"][0] + + assert item["bulk_price"] == 5.03, ( + f"bulk_price should be 5.03 (ROUND_HALF_UP for .025), got {item['bulk_price']}" + ) + assert item["price"] == 5.03, ( + f"emp_price should equal bulk_price when subsidy is 0%, got {item['price']}" + ) + + +@patch("submit_order_handler._lambda") +@patch("submit_order_handler.put_order") +@patch("submit_order_handler.get_form_status", return_value="open") +@patch("submit_order_handler.current_week", return_value="2026-W20") +@patch("submit_order_handler.get_settings", return_value={"bulk_discount_percent": -5, "company_subsidy_percent": 150}) +@patch("submit_order_handler.get_secret", return_value=TEST_API_KEY) +@patch("submit_order_handler._get_google_client_id", return_value="") +def test_discount_clamping( + mock_gcid, mock_secret, mock_settings, mock_week, mock_status, mock_put, mock_lam +): + """Negative bulk discount clamped to 0, subsidy >100 clamped to 100 (free).""" + from submit_order_handler import lambda_handler + + items = _make_items([(20.00, 1)]) + event = _submit_event(items) + result = lambda_handler(event, None) + status, body = _parse_response(result) + + assert status == 200, f"Expected 200, got {status}: {body}" + + saved_order = mock_put.call_args[0][2] + item = saved_order["items"][0] + + assert item["bulk_price"] == 20.00, ( + f"bulk_price should be 20.00 (bulk_discount clamped to 0%), got {item['bulk_price']}" + ) + assert item["price"] == 0.00, ( + f"emp_price should be 0.00 (subsidy clamped to 100%), got {item['price']}" + ) + assert saved_order["total"] == 0.00, ( + f"total should be 0.00 for free items, got {saved_order['total']}" + ) + + +@patch("submit_order_handler._lambda") +@patch("submit_order_handler.put_order") +@patch("submit_order_handler.get_form_status", return_value="open") +@patch("submit_order_handler.current_week", return_value="2026-W20") +@patch("submit_order_handler.get_settings", return_value={"bulk_discount_percent": 0, "company_subsidy_percent": 0}) +@patch("submit_order_handler.get_secret", return_value=TEST_API_KEY) +@patch("submit_order_handler._get_google_client_id", return_value="") +def test_discount_both_zero( + mock_gcid, mock_secret, mock_settings, mock_week, mock_status, mock_put, mock_lam +): + """When both discounts are 0%, emp_price equals retail price.""" + from submit_order_handler import lambda_handler + + items = _make_items([(15.99, 2)]) + event = _submit_event(items) + result = lambda_handler(event, None) + status, body = _parse_response(result) + + assert status == 200, f"Expected 200, got {status}: {body}" + + saved_order = mock_put.call_args[0][2] + item = saved_order["items"][0] + + assert item["retail_price"] == 15.99, f"retail_price mismatch: {item['retail_price']}" + assert item["bulk_price"] == 15.99, ( + f"bulk_price should equal retail when bulk discount is 0%, got {item['bulk_price']}" + ) + assert item["price"] == 15.99, ( + f"emp_price should equal retail when both discounts are 0%, got {item['price']}" + ) + assert item["subtotal"] == 31.98, ( + f"subtotal should be 15.99 * 2 = 31.98, got {item['subtotal']}" + ) + + +@patch("submit_order_handler._lambda") +@patch("submit_order_handler.put_order") +@patch("submit_order_handler.get_form_status", return_value="open") +@patch("submit_order_handler.current_week", return_value="2026-W20") +@patch("submit_order_handler.get_settings", return_value={"bulk_discount_percent": 10, "company_subsidy_percent": 25}) +@patch("submit_order_handler.get_secret", return_value=TEST_API_KEY) +@patch("submit_order_handler._get_google_client_id", return_value="") +def test_total_summation_multiple_items( + mock_gcid, mock_secret, mock_settings, mock_week, mock_status, mock_put, mock_lam +): + """Total is the sum of individually rounded subtotals, not a global multiply. + + Item A: retail=$10.00, bulk=10.00*0.90=9.00, emp=9.00*0.75=6.75, qty=2 -> subtotal=13.50 + Item B: retail=$7.33, bulk=7.33*0.90=6.60, emp=6.60*0.75=4.95, qty=1 -> subtotal=4.95 + Total = 13.50 + 4.95 = 18.45 + """ + from submit_order_handler import lambda_handler + + items = _make_items([(10.00, 2), (7.33, 1)]) + event = _submit_event(items) + result = lambda_handler(event, None) + status, body = _parse_response(result) + + assert status == 200, f"Expected 200, got {status}: {body}" + + saved_order = mock_put.call_args[0][2] + + item_a = saved_order["items"][0] + assert item_a["subtotal"] == 13.50, f"Item A subtotal expected 13.50, got {item_a['subtotal']}" + + item_b = saved_order["items"][1] + assert item_b["subtotal"] == 4.95, f"Item B subtotal expected 4.95, got {item_b['subtotal']}" + + assert saved_order["total"] == 18.45, ( + f"total should be sum of rounded subtotals (13.50 + 4.95 = 18.45), got {saved_order['total']}" + ) + + +# =========================================================================== +# AUTHENTICATION (Critical + High) +# =========================================================================== + +@patch("submit_order_handler._lambda") +@patch("submit_order_handler.put_order") +@patch("submit_order_handler.get_form_status", return_value="open") +@patch("submit_order_handler.current_week", return_value="2026-W20") +@patch("submit_order_handler.get_settings", return_value={"bulk_discount_percent": 0, "company_subsidy_percent": 0}) +@patch("submit_order_handler.get_secret", return_value=TEST_API_KEY) +@patch("submit_order_handler._get_google_client_id", return_value=VALID_GOOGLE_CLIENT_ID) +def test_google_auth_required_when_configured( + mock_gcid, mock_secret, mock_settings, mock_week, mock_status, mock_put, mock_lam +): + """When Google auth is configured and no token is provided, return 403.""" + from submit_order_handler import lambda_handler + + items = _make_items([(10.00, 1)]) + # Body has name/email but no google_id_token + event = _submit_event(items) + result = lambda_handler(event, None) + status, body = _parse_response(result) + + assert status == 403, f"Expected 403, got {status}: {body}" + assert "Google authentication is required" in body["error"], ( + f"Expected 'Google authentication is required' in error, got: {body['error']}" + ) + mock_put.assert_not_called() + + +@patch("submit_order_handler._lambda") +@patch("submit_order_handler.put_order") +@patch("submit_order_handler.get_form_status", return_value="open") +@patch("submit_order_handler.current_week", return_value="2026-W20") +@patch("submit_order_handler.get_settings", return_value={"bulk_discount_percent": 0, "company_subsidy_percent": 0}) +@patch("submit_order_handler.get_secret", return_value=TEST_API_KEY) +@patch("submit_order_handler._get_google_client_id", return_value=VALID_GOOGLE_CLIENT_ID) +def test_google_auth_bypass_prevention( + mock_gcid, mock_secret, mock_settings, mock_week, mock_status, mock_put, mock_lam +): + """Google auth enabled + name/email in body but no token -> 403 (can't bypass).""" + from submit_order_handler import lambda_handler + + body = { + "employee_name": "Attacker Name", + "employee_email": "attacker@seahavenind.com", + "items": _make_items([(10.00, 1)]), + # No google_id_token — trying to bypass with manual name/email + } + event = _make_event( + method="POST", path="/submit", + body=body, headers={"x-api-key": TEST_API_KEY}, + ) + result = lambda_handler(event, None) + status, body_resp = _parse_response(result) + + assert status == 403, f"Expected 403 (bypass prevented), got {status}: {body_resp}" + mock_put.assert_not_called() + + +@patch("submit_order_handler._lambda") +@patch("submit_order_handler.put_order") +@patch("submit_order_handler.get_form_status", return_value="open") +@patch("submit_order_handler.current_week", return_value="2026-W20") +@patch("submit_order_handler.get_settings", return_value={"bulk_discount_percent": 0, "company_subsidy_percent": 0}) +@patch("submit_order_handler.get_secret", return_value=TEST_API_KEY) +@patch("submit_order_handler._get_google_client_id", return_value=VALID_GOOGLE_CLIENT_ID) +@patch("submit_order_handler.urllib.request.urlopen") +def test_google_token_audience_mismatch( + mock_urlopen, mock_gcid, mock_secret, mock_settings, + mock_week, mock_status, mock_put, mock_lam +): + """Token with wrong audience -> 403.""" + from submit_order_handler import lambda_handler + + # Simulate Google returning token info with wrong audience + mock_resp = MagicMock() + mock_resp.read.return_value = json.dumps({ + "aud": "wrong-client-id.apps.googleusercontent.com", + "hd": "seahavenind.com", + "name": "Test User", + "email": "test@seahavenind.com", + }).encode() + mock_resp.__enter__ = MagicMock(return_value=mock_resp) + mock_resp.__exit__ = MagicMock(return_value=False) + mock_urlopen.return_value = mock_resp + + items = _make_items([(10.00, 1)]) + event = _submit_event(items, extra_body={"google_id_token": "fake-token"}) + result = lambda_handler(event, None) + status, body = _parse_response(result) + + assert status == 403, f"Expected 403 for audience mismatch, got {status}: {body}" + mock_put.assert_not_called() + + +@patch("submit_order_handler._lambda") +@patch("submit_order_handler.put_order") +@patch("submit_order_handler.get_form_status", return_value="open") +@patch("submit_order_handler.current_week", return_value="2026-W20") +@patch("submit_order_handler.get_settings", return_value={"bulk_discount_percent": 0, "company_subsidy_percent": 0}) +@patch("submit_order_handler.get_secret", return_value=TEST_API_KEY) +@patch("submit_order_handler._get_google_client_id", return_value=VALID_GOOGLE_CLIENT_ID) +@patch("submit_order_handler.urllib.request.urlopen") +def test_google_token_domain_mismatch( + mock_urlopen, mock_gcid, mock_secret, mock_settings, + mock_week, mock_status, mock_put, mock_lam +): + """Token with wrong hosted domain -> 403.""" + from submit_order_handler import lambda_handler + + mock_resp = MagicMock() + mock_resp.read.return_value = json.dumps({ + "aud": VALID_GOOGLE_CLIENT_ID, + "hd": "evil-corp.com", + "name": "Evil User", + "email": "evil@evil-corp.com", + }).encode() + mock_resp.__enter__ = MagicMock(return_value=mock_resp) + mock_resp.__exit__ = MagicMock(return_value=False) + mock_urlopen.return_value = mock_resp + + items = _make_items([(10.00, 1)]) + event = _submit_event(items, extra_body={"google_id_token": "fake-token"}) + result = lambda_handler(event, None) + status, body = _parse_response(result) + + assert status == 403, f"Expected 403 for domain mismatch, got {status}: {body}" + mock_put.assert_not_called() + + +@patch("submit_order_handler._lambda") +@patch("submit_order_handler.put_order") +@patch("submit_order_handler.get_form_status", return_value="open") +@patch("submit_order_handler.current_week", return_value="2026-W20") +@patch("submit_order_handler.get_settings", return_value={"bulk_discount_percent": 0, "company_subsidy_percent": 0}) +@patch("submit_order_handler.get_secret", return_value=TEST_API_KEY) +@patch("submit_order_handler._get_google_client_id", return_value=VALID_GOOGLE_CLIENT_ID) +@patch("submit_order_handler.urllib.request.urlopen", side_effect=urllib.error.URLError("Connection refused")) +def test_google_token_service_unavailable( + mock_urlopen, mock_gcid, mock_secret, mock_settings, + mock_week, mock_status, mock_put, mock_lam +): + """URLError from Google tokeninfo -> 503.""" + from submit_order_handler import lambda_handler + + items = _make_items([(10.00, 1)]) + event = _submit_event(items, extra_body={"google_id_token": "fake-token"}) + result = lambda_handler(event, None) + status, body = _parse_response(result) + + assert status == 503, f"Expected 503 for service unavailable, got {status}: {body}" + assert "temporarily unavailable" in body["error"], ( + f"Expected 'temporarily unavailable' in error, got: {body['error']}" + ) + mock_put.assert_not_called() + + +@patch("submit_order_handler._lambda") +@patch("submit_order_handler.put_order") +@patch("submit_order_handler.get_form_status", return_value="open") +@patch("submit_order_handler.current_week", return_value="2026-W20") +@patch("submit_order_handler.get_settings", return_value={"bulk_discount_percent": 0, "company_subsidy_percent": 0}) +@patch("submit_order_handler.get_secret", return_value=TEST_API_KEY) +@patch("submit_order_handler._get_google_client_id", return_value=VALID_GOOGLE_CLIENT_ID) +@patch("submit_order_handler.urllib.request.urlopen") +def test_google_token_valid_success( + mock_urlopen, mock_gcid, mock_secret, mock_settings, + mock_week, mock_status, mock_put, mock_lam +): + """Valid Google token -> order saved with token's name/email.""" + from submit_order_handler import lambda_handler + + mock_resp = MagicMock() + mock_resp.read.return_value = json.dumps({ + "aud": VALID_GOOGLE_CLIENT_ID, + "hd": "seahavenind.com", + "name": "Adam Moussa", + "email": "adam.moussa@seahavenind.com", + }).encode() + mock_resp.__enter__ = MagicMock(return_value=mock_resp) + mock_resp.__exit__ = MagicMock(return_value=False) + mock_urlopen.return_value = mock_resp + + items = _make_items([(10.00, 1)]) + # Body has different name/email — should be overridden by token + event = _submit_event( + items, + employee_name="Wrong Name", + employee_email="wrong@seahavenind.com", + extra_body={"google_id_token": "valid-token-abc"}, + ) + result = lambda_handler(event, None) + status, body = _parse_response(result) + + assert status == 200, f"Expected 200, got {status}: {body}" + + saved_order = mock_put.call_args[0][2] + assert saved_order["employee_name"] == "Adam Moussa", ( + f"Name should come from Google token, got: {saved_order['employee_name']}" + ) + assert saved_order["employee_email"] == "adam.moussa@seahavenind.com", ( + f"Email should come from Google token, got: {saved_order['employee_email']}" + ) + + +@patch("submit_order_handler._lambda") +@patch("submit_order_handler.put_order") +@patch("submit_order_handler.get_form_status", return_value="open") +@patch("submit_order_handler.current_week", return_value="2026-W20") +@patch("submit_order_handler.get_settings", return_value={"bulk_discount_percent": 0, "company_subsidy_percent": 0}) +@patch("submit_order_handler.get_secret", return_value=TEST_API_KEY) +@patch("submit_order_handler._get_google_client_id", return_value="") +def test_manual_fallback_when_google_not_configured( + mock_gcid, mock_secret, mock_settings, mock_week, mock_status, mock_put, mock_lam +): + """No Google client ID configured -> manual name/email accepted and order saved.""" + from submit_order_handler import lambda_handler + + items = _make_items([(12.00, 1)]) + event = _submit_event(items, employee_name="Manual User", employee_email="manual@seahavenind.com") + result = lambda_handler(event, None) + status, body = _parse_response(result) + + assert status == 200, f"Expected 200, got {status}: {body}" + + saved_order = mock_put.call_args[0][2] + assert saved_order["employee_name"] == "Manual User", ( + f"Name should be from manual input, got: {saved_order['employee_name']}" + ) + assert saved_order["employee_email"] == "manual@seahavenind.com", ( + f"Email should be from manual input, got: {saved_order['employee_email']}" + ) + + +@patch("submit_order_handler._lambda") +@patch("submit_order_handler.put_order") +@patch("submit_order_handler.get_form_status", return_value="open") +@patch("submit_order_handler.current_week", return_value="2026-W20") +@patch("submit_order_handler.get_settings", return_value={"bulk_discount_percent": 0, "company_subsidy_percent": 0}) +@patch("submit_order_handler.get_secret", return_value=TEST_API_KEY) +@patch("submit_order_handler._get_google_client_id", return_value="") +def test_submit_invalid_api_key( + mock_gcid, mock_secret, mock_settings, mock_week, mock_status, mock_put, mock_lam +): + """Wrong x-api-key -> 403.""" + from submit_order_handler import lambda_handler + + items = _make_items([(10.00, 1)]) + event = _submit_event(items, api_key="wrong-api-key") + result = lambda_handler(event, None) + status, body = _parse_response(result) + + assert status == 403, f"Expected 403 for invalid API key, got {status}: {body}" + assert "Invalid API key" in body["error"], ( + f"Expected 'Invalid API key' in error, got: {body['error']}" + ) + mock_put.assert_not_called() + + +# =========================================================================== +# SLUG GENERATION (Critical) +# =========================================================================== + +@patch("submit_order_handler._lambda") +@patch("submit_order_handler.put_order") +@patch("submit_order_handler.get_form_status", return_value="open") +@patch("submit_order_handler.current_week", return_value="2026-W20") +@patch("submit_order_handler.get_settings", return_value={"bulk_discount_percent": 0, "company_subsidy_percent": 0}) +@patch("submit_order_handler.get_secret", return_value=TEST_API_KEY) +@patch("submit_order_handler._get_google_client_id", return_value="") +def test_slug_from_email( + mock_gcid, mock_secret, mock_settings, mock_week, mock_status, mock_put, mock_lam +): + """'Adam.Moussa@seahavenind.com' -> slug 'adam-moussa'.""" + from submit_order_handler import lambda_handler + + items = _make_items([(10.00, 1)]) + event = _submit_event(items, employee_name="Adam Moussa", employee_email="Adam.Moussa@seahavenind.com") + result = lambda_handler(event, None) + status, _ = _parse_response(result) + + assert status == 200, f"Expected 200, got {status}" + + # put_order is called with (week, slug, order_data) + slug = mock_put.call_args[0][1] + assert slug == "adam-moussa", f"Slug should be 'adam-moussa', got '{slug}'" + + +@patch("submit_order_handler._lambda") +@patch("submit_order_handler.put_order") +@patch("submit_order_handler.get_form_status", return_value="open") +@patch("submit_order_handler.current_week", return_value="2026-W20") +@patch("submit_order_handler.get_settings", return_value={"bulk_discount_percent": 0, "company_subsidy_percent": 0}) +@patch("submit_order_handler.get_secret", return_value=TEST_API_KEY) +@patch("submit_order_handler._get_google_client_id", return_value="") +def test_slug_edge_cases( + mock_gcid, mock_secret, mock_settings, mock_week, mock_status, mock_put, mock_lam +): + """Slug edge cases: multiple dots, already-hyphenated.""" + from submit_order_handler import lambda_handler + + # Test 1: "first.middle.last@x.com" -> "first-middle-last" + items = _make_items([(10.00, 1)]) + event = _submit_event(items, employee_name="First Middle Last", employee_email="first.middle.last@x.com") + lambda_handler(event, None) + slug_1 = mock_put.call_args[0][1] + assert slug_1 == "first-middle-last", ( + f"Slug for 'first.middle.last@x.com' should be 'first-middle-last', got '{slug_1}'" + ) + + # Test 2: "already-hyphenated@x.com" -> "already-hyphenated" + mock_put.reset_mock() + event = _submit_event(items, employee_name="Already Hyphenated", employee_email="already-hyphenated@x.com") + lambda_handler(event, None) + slug_2 = mock_put.call_args[0][1] + assert slug_2 == "already-hyphenated", ( + f"Slug for 'already-hyphenated@x.com' should be 'already-hyphenated', got '{slug_2}'" + ) + + +# =========================================================================== +# FORM STATUS (Critical + High) +# =========================================================================== + +@patch("submit_order_handler.get_form_status", return_value="open") +@patch("submit_order_handler.current_week", return_value="2026-W20") +def test_form_status_open(mock_week, mock_status): + """Status 'open' — response has week and status, no reopen_at.""" + from submit_order_handler import lambda_handler + + event = _make_event(method="GET", path="/form-status/2026-W20", path_parameters={"week": "2026-W20"}) + result = lambda_handler(event, None) + status, body = _parse_response(result) + + assert status == 200, f"Expected 200, got {status}: {body}" + assert body["status"] == "open", f"Expected status='open', got '{body['status']}'" + assert body["week"] == "2026-W20", f"Expected week='2026-W20', got '{body['week']}'" + assert "reopen_at" not in body, "reopen_at should NOT be present when form is open" + + +@patch("submit_order_handler.get_form_status", return_value="closed") +@patch("submit_order_handler.current_week", return_value="2026-W20") +def test_form_status_closed_reopen_at(mock_week, mock_status): + """Closed on Thursday -> reopen_at is next Monday 8am Eastern.""" + from submit_order_handler import lambda_handler, EASTERN + + # Freeze "now" to Thursday 2026-05-14 at 10:00 AM Eastern + thursday = datetime(2026, 5, 14, 10, 0, 0, tzinfo=EASTERN) + with patch("submit_order_handler.datetime") as mock_dt: + mock_dt.now.return_value = thursday + mock_dt.side_effect = lambda *a, **kw: datetime(*a, **kw) + + event = _make_event(method="GET", path="/form-status/2026-W20", path_parameters={"week": "2026-W20"}) + result = lambda_handler(event, None) + + status, body = _parse_response(result) + + assert status == 200, f"Expected 200, got {status}: {body}" + assert body["status"] == "closed", f"Expected status='closed', got '{body['status']}'" + assert "reopen_at" in body, "reopen_at should be present when form is closed" + + # Next Monday from Thursday 2026-05-14 is Monday 2026-05-18 + expected_monday = datetime(2026, 5, 18, 8, 0, 0, tzinfo=EASTERN) + expected_ts = int(expected_monday.timestamp()) + assert body["reopen_at"] == expected_ts, ( + f"reopen_at should be {expected_ts} (Mon 2026-05-18 8am ET), got {body['reopen_at']}" + ) + + +@patch("submit_order_handler.get_form_status", return_value="closed") +@patch("submit_order_handler.current_week", return_value="2026-W20") +def test_form_status_monday_before_8am(mock_week, mock_status): + """Monday before 8am -> reopen_at is TODAY at 8am, not next Monday.""" + from submit_order_handler import lambda_handler, EASTERN + + # Monday 2026-05-18 at 6:30 AM Eastern (before 8am cutoff) + monday_early = datetime(2026, 5, 18, 6, 30, 0, tzinfo=EASTERN) + with patch("submit_order_handler.datetime") as mock_dt: + mock_dt.now.return_value = monday_early + mock_dt.side_effect = lambda *a, **kw: datetime(*a, **kw) + + event = _make_event(method="GET", path="/form-status/2026-W20", path_parameters={"week": "2026-W20"}) + result = lambda_handler(event, None) + + status, body = _parse_response(result) + + assert status == 200, f"Expected 200, got {status}: {body}" + assert "reopen_at" in body, "reopen_at should be present when form is closed" + + # days_until_monday = (7 - 0) % 7 = 0, and hour < 8, so days_until_monday stays 0 + # -> reopen_at is TODAY (same Monday) at 8am + expected_today = datetime(2026, 5, 18, 8, 0, 0, tzinfo=EASTERN) + expected_ts = int(expected_today.timestamp()) + assert body["reopen_at"] == expected_ts, ( + f"reopen_at should be {expected_ts} (today Mon 2026-05-18 8am ET), got {body['reopen_at']}" + ) + + +# =========================================================================== +# VALIDATION (High) +# =========================================================================== + +@patch("submit_order_handler._lambda") +@patch("submit_order_handler.put_order") +@patch("submit_order_handler.get_form_status", return_value="open") +@patch("submit_order_handler.current_week", return_value="2026-W20") +@patch("submit_order_handler.get_settings", return_value={"bulk_discount_percent": 0, "company_subsidy_percent": 0}) +@patch("submit_order_handler.get_secret", return_value=TEST_API_KEY) +@patch("submit_order_handler._get_google_client_id", return_value="") +def test_submit_missing_name( + mock_gcid, mock_secret, mock_settings, mock_week, mock_status, mock_put, mock_lam +): + """Empty employee name -> 400.""" + from submit_order_handler import lambda_handler + + items = _make_items([(10.00, 1)]) + event = _submit_event(items, employee_name="", employee_email="test@seahavenind.com") + result = lambda_handler(event, None) + status, body = _parse_response(result) + + assert status == 400, f"Expected 400 for missing name, got {status}: {body}" + assert "name" in body["error"].lower(), ( + f"Error should mention name, got: {body['error']}" + ) + mock_put.assert_not_called() + + +@patch("submit_order_handler._lambda") +@patch("submit_order_handler.put_order") +@patch("submit_order_handler.get_form_status", return_value="open") +@patch("submit_order_handler.current_week", return_value="2026-W20") +@patch("submit_order_handler.get_settings", return_value={"bulk_discount_percent": 0, "company_subsidy_percent": 0}) +@patch("submit_order_handler.get_secret", return_value=TEST_API_KEY) +@patch("submit_order_handler._get_google_client_id", return_value="") +def test_submit_missing_email( + mock_gcid, mock_secret, mock_settings, mock_week, mock_status, mock_put, mock_lam +): + """Empty employee email -> 400.""" + from submit_order_handler import lambda_handler + + items = _make_items([(10.00, 1)]) + event = _submit_event(items, employee_name="Test User", employee_email="") + result = lambda_handler(event, None) + status, body = _parse_response(result) + + assert status == 400, f"Expected 400 for missing email, got {status}: {body}" + assert "email" in body["error"].lower(), ( + f"Error should mention email, got: {body['error']}" + ) + mock_put.assert_not_called() + + +@patch("submit_order_handler._lambda") +@patch("submit_order_handler.put_order") +@patch("submit_order_handler.get_form_status", return_value="open") +@patch("submit_order_handler.current_week", return_value="2026-W20") +@patch("submit_order_handler.get_settings", return_value={"bulk_discount_percent": 0, "company_subsidy_percent": 0}) +@patch("submit_order_handler.get_secret", return_value=TEST_API_KEY) +@patch("submit_order_handler._get_google_client_id", return_value="") +def test_submit_zero_quantity_only( + mock_gcid, mock_secret, mock_settings, mock_week, mock_status, mock_put, mock_lam +): + """All items with quantity 0 -> 400.""" + from submit_order_handler import lambda_handler + + items = _make_items([(10.00, 0), (15.00, 0)]) + event = _submit_event(items) + result = lambda_handler(event, None) + status, body = _parse_response(result) + + assert status == 400, f"Expected 400 for zero-quantity items, got {status}: {body}" + assert "at least one meal" in body["error"].lower(), ( + f"Error should mention 'at least one meal', got: {body['error']}" + ) + mock_put.assert_not_called() + + +@patch("submit_order_handler._lambda") +@patch("submit_order_handler.put_order") +@patch("submit_order_handler.get_form_status", return_value="closed") +@patch("submit_order_handler.current_week", return_value="2026-W20") +@patch("submit_order_handler.get_settings", return_value={"bulk_discount_percent": 0, "company_subsidy_percent": 0}) +@patch("submit_order_handler.get_secret", return_value=TEST_API_KEY) +@patch("submit_order_handler._get_google_client_id", return_value="") +def test_submit_form_closed( + mock_gcid, mock_secret, mock_settings, mock_week, mock_status, mock_put, mock_lam +): + """Form closed -> 410.""" + from submit_order_handler import lambda_handler + + items = _make_items([(10.00, 1)]) + event = _submit_event(items) + result = lambda_handler(event, None) + status, body = _parse_response(result) + + assert status == 410, f"Expected 410 for closed form, got {status}: {body}" + assert "closed" in body["error"].lower(), ( + f"Error should mention 'closed', got: {body['error']}" + ) + mock_put.assert_not_called() + + +@patch("submit_order_handler._lambda") +@patch("submit_order_handler.put_order") +@patch("submit_order_handler.get_form_status", return_value="not_found") +@patch("submit_order_handler.current_week", return_value="2026-W20") +@patch("submit_order_handler.get_settings", return_value={"bulk_discount_percent": 0, "company_subsidy_percent": 0}) +@patch("submit_order_handler.get_secret", return_value=TEST_API_KEY) +@patch("submit_order_handler._get_google_client_id", return_value="") +def test_submit_no_menu( + mock_gcid, mock_secret, mock_settings, mock_week, mock_status, mock_put, mock_lam +): + """No menu available -> 404.""" + from submit_order_handler import lambda_handler + + items = _make_items([(10.00, 1)]) + event = _submit_event(items) + result = lambda_handler(event, None) + status, body = _parse_response(result) + + assert status == 404, f"Expected 404 for missing menu, got {status}: {body}" + assert "no menu" in body["error"].lower(), ( + f"Error should mention 'no menu', got: {body['error']}" + ) + mock_put.assert_not_called() + + +# =========================================================================== +# RELIABILITY (High) +# =========================================================================== + +@patch("submit_order_handler._lambda") +@patch("submit_order_handler.put_order") +@patch("submit_order_handler.get_form_status", return_value="open") +@patch("submit_order_handler.current_week", return_value="2026-W20") +@patch("submit_order_handler.get_settings", return_value={"bulk_discount_percent": 0, "company_subsidy_percent": 0}) +@patch("submit_order_handler.get_secret", return_value=TEST_API_KEY) +@patch("submit_order_handler._get_google_client_id", return_value="") +def test_slack_failure_does_not_fail_order( + mock_gcid, mock_secret, mock_settings, mock_week, mock_status, mock_put, mock_lam +): + """Lambda invoke for Slack notification raises, order still saved, returns 200.""" + from submit_order_handler import lambda_handler + + mock_lam.invoke.side_effect = Exception("Lambda invoke failed: connection timeout") + + items = _make_items([(10.00, 1)]) + event = _submit_event(items) + result = lambda_handler(event, None) + status, body = _parse_response(result) + + assert status == 200, f"Expected 200 despite Slack failure, got {status}: {body}" + assert body["status"] == "ok", f"Expected status='ok', got '{body['status']}'" + mock_put.assert_called_once()