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.
394 lines
16 KiB
Python
394 lines
16 KiB
Python
"""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"
|