Add unit tests for submit, notify, and aggregate handlers

50 tests covering pricing pipeline (Decimal rounding, clamping, totals),
Google auth (enforcement, bypass prevention, audience/domain validation,
503 on outage), email slug generation, form status with reopen_at,
input validation, Slack failure resilience, reminder dedup guards,
order confirmation DMs, aggregated summaries, CSV generation, and
mrkdwn escaping.
This commit is contained in:
Adam Moussa 2026-05-13 12:59:22 -04:00
parent 09593b1d91
commit fa2b4b7616
4 changed files with 1653 additions and 0 deletions

11
tests/conftest.py Normal file
View file

@ -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))

View file

@ -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"

View file

@ -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 <C>' -> 'A &amp; B &lt;C&gt;'."""
assert handler._escape_mrkdwn("A & B <C>") == "A &amp; B &lt;C&gt;"
# ────────────────────────────────────────────────────────────────────────────
# 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"
)

868
tests/test_submit_order.py Normal file
View file

@ -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()