meal-order-manager/tests/test_aggregate_orders.py
Adam Moussa f48a82c476
Some checks are pending
Deploy API / Resolve target (push) Waiting to run
Deploy API / Deploy API to (push) Blocked by required conditions
feat(api): serve meals on ECS Fargate instead of Lambda (PLAT-215) (#199)
* feat(api): serve meals on ECS Fargate instead of Lambda

Keep the Flask app always-on with in-process jobs so CloudFront no longer fronts a cold-start API Gateway.

* fix(jobs): run delayed close and reminder deliveries

Wall-clock skip windows dropped the only weekly SQS attempt when Scheduler already fired in Eastern time. Dev schedules stay disabled.

* fix(api): return JSON objects and stop logging job payloads

Flask now jsonify-s handler dicts so API responses are not HTML, and the worker logs only event and status.

* fix(ci): restore the reusable workflow so the required check is named ci / ci

Inlining the job reported `ci` instead of the org ruleset's `ci / ci`.

* fix(secrets): drop unused os import so ruff check passes

* style: apply ruff format so ci-python-app lint passes

* fix(infra): give meals its own VPC because prod has none

* chore(security): re-key ALB SG checkov suppression after vpc.tf
2026-09-21 19:34:24 +00:00

613 lines
22 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",
)
monkeypatch.setenv(
"CHECKCOMPONENTS_QUEUE_URL",
"https://sqs.us-east-1.amazonaws.com/011934824531/paychex-checkcomponents",
)
@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_sqs = MagicMock()
mock_client.side_effect = lambda svc, **kw: {
"s3": mock_s3,
"sqs": mock_sqs,
}.get(svc, MagicMock())
import importlib
import server.jobs.aggregate as mod
importlib.reload(mod)
# Inject mocked clients so tests can assert on them
mod._s3 = mock_s3
mod._sqs = mock_sqs
mod._dispatch_job = MagicMock()
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("server.jobs.aggregate.get_summary")
@patch("server.jobs.aggregate.get_orders")
@patch("server.jobs.aggregate.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()
handler_module._sqs.send_message.assert_not_called()
class TestAggregateNoOrders:
"""test_aggregate_no_orders — no orders returns no_orders status."""
@patch("server.jobs.aggregate.get_summary")
@patch("server.jobs.aggregate.get_orders")
@patch("server.jobs.aggregate.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()
handler_module._sqs.send_message.assert_not_called()
class TestAggregateHappyPath:
"""test_aggregate_happy_path — orders exist, builds summary, uploads CSVs, saves, triggers Slack."""
@patch("server.jobs.aggregate.put_summary")
@patch("server.jobs.aggregate.get_summary")
@patch("server.jobs.aggregate.get_orders")
@patch("server.jobs.aggregate.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 3 S3 uploads (order summary CSV + payroll CSV + weekly PDF)
s3_calls = handler_module._s3.put_object.call_args_list
assert len(s3_calls) == 3, "Should upload 2 CSVs + 1 PDF to S3"
by_key = {call.kwargs["Key"]: call.kwargs for call in s3_calls}
assert "reports/2026-W20/order-summary.csv" in by_key
assert "reports/2026-W20/payroll-deductions.csv" in by_key
assert "reports/2026-W20/weekly-summary-2026-W20.pdf" in by_key
for kwargs in by_key.values():
assert kwargs["Bucket"] == "test-bucket"
# CSVs are text/csv; the PDF is application/pdf with real PDF bytes
assert by_key["reports/2026-W20/order-summary.csv"]["ContentType"] == "text/csv"
assert (
by_key["reports/2026-W20/payroll-deductions.csv"]["ContentType"]
== "text/csv"
)
pdf_kwargs = by_key["reports/2026-W20/weekly-summary-2026-W20.pdf"]
assert pdf_kwargs["ContentType"] == "application/pdf"
assert pdf_kwargs["Body"][:5] == b"%PDF-"
# 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
assert (
saved_summary["weekly_summary_pdf_s3_key"]
== "reports/2026-W20/weekly-summary-2026-W20.pdf"
)
# Verify Slack notifier job is dispatched
handler_module._dispatch_job.assert_called_once_with(
{"event": "orders_aggregated", "week": "2026-W20"}
)
handler_module._sqs.send_message.assert_called_once()
send_kwargs = handler_module._sqs.send_message.call_args.kwargs
assert send_kwargs["QueueUrl"] == os.environ["CHECKCOMPONENTS_QUEUE_URL"]
body = json.loads(send_kwargs["MessageBody"])
assert body["type"] == "checkcomponents"
assert body["kind"] == "meal_deduction"
assert body["week"] == "WEEK#2026-W20"
assert "payPeriodId" not in body
emails = {line["employee_email"] for line in body["lines"]}
assert emails == {"alice@x.com", "bob@x.com"}
class TestCheckcomponentsPayload:
def test_prefixes_week_and_omits_flex_fields(self, handler_module):
orders = [
_make_order("Alice", "alice@x.com", [_make_item("Burger")], total=10.5),
_make_order("Skip", "", [_make_item("Pasta")], total=9),
]
payload = handler_module.build_checkcomponents_payload("2026-W20", orders)
assert payload == {
"type": "checkcomponents",
"kind": "meal_deduction",
"week": "WEEK#2026-W20",
"lines": [{"employee_email": "alice@x.com", "amount": "10.50"}],
}
def test_keeps_existing_week_hash_prefix(self, handler_module):
payload = handler_module.build_checkcomponents_payload(
"WEEK#2026-W20",
[_make_order("Alice", "alice@x.com", [_make_item("Burger")], total=1)],
)
assert payload["week"] == "WEEK#2026-W20"
def test_rounds_amount_half_up(self, handler_module):
payload = handler_module.build_checkcomponents_payload(
"2026-W20",
[
_make_order(
"Alice",
"alice@x.com",
[_make_item("Burger")],
total=Decimal("10.025"),
)
],
)
assert payload["lines"][0]["amount"] == "10.03"
class TestCheckcomponentsSend:
@patch("server.jobs.aggregate.put_summary")
@patch("server.jobs.aggregate.get_summary")
@patch("server.jobs.aggregate.get_orders")
@patch("server.jobs.aggregate.current_week", return_value="2026-W20")
def test_sqs_failure_still_aggregates_and_notifies(
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")], total=10)
]
handler_module._sqs.send_message.side_effect = Exception("AccessDenied")
result = handler_module.lambda_handler({}, None)
assert result["status"] == "aggregated"
mock_put_summary.assert_called_once()
handler_module._dispatch_job.assert_called_once()
@patch("server.jobs.aggregate.put_summary")
@patch("server.jobs.aggregate.get_summary")
@patch("server.jobs.aggregate.get_orders")
@patch("server.jobs.aggregate.current_week", return_value="2026-W20")
def test_empty_queue_url_skips_send(
self,
mock_week,
mock_orders,
mock_get_summary,
mock_put_summary,
handler_module,
monkeypatch,
):
monkeypatch.setenv("CHECKCOMPONENTS_QUEUE_URL", "")
mock_get_summary.return_value = None
mock_orders.return_value = [
_make_order("Alice", "alice@x.com", [_make_item("Burger")], total=10)
]
result = handler_module.lambda_handler({}, None)
assert result["status"] == "aggregated"
handler_module._sqs.send_message.assert_not_called()
handler_module._dispatch_job.assert_called_once()