"""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_lambda = MagicMock() mock_sqs = MagicMock() mock_client.side_effect = lambda svc, **kw: { "s3": mock_s3, "lambda": mock_lambda, "sqs": mock_sqs, }[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 mod._sqs = mock_sqs 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() handler_module._sqs.send_message.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() handler_module._sqs.send_message.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 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 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" 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("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_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._lambda.invoke.assert_called_once() @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_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._lambda.invoke.assert_called_once()