mirror of
https://github.com/Sea-Haven-Industries/meal-order-manager.git
synced 2026-09-30 21:43:13 +00:00
50 tests covering pricing pipeline (Decimal rounding, clamping, totals), Google auth (enforcement, bypass prevention, audience/domain validation, 503 on outage), email slug generation, form status with reopen_at, input validation, Slack failure resilience, reminder dedup guards, order confirmation DMs, aggregated summaries, CSV generation, and mrkdwn escaping.
868 lines
37 KiB
Python
868 lines
37 KiB
Python
"""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()
|