proposal-system/lambdas/tests/test_suggestions.py
Adam Moussa 3d050bcf8e
Some checks are pending
Deploy / Deploy to AWS (push) Waiting to run
fix(lambdas): SigV4-sign internal API calls and bundle Lambda dependencies (#122)
The .NET API Lambda Function URL uses authType=AWS_IAM, but the four workload
Lambdas (suggestions, pdf-extract, pdf-generate, library-ingest) sent unsigned
requests with only X-Internal-Api-Key -> every internal call 403s. They also
used bare fromAsset() with no pip bundling -> ImportError at cold start. Both
made the SQS->Lambda->API pipeline non-functional when deployed (v1 pre-flight).

- Add _sign_request_headers (botocore SigV4Auth, service "lambda"); serialize the
  JSON body once and send via httpx content= so the signed payload hash matches
  the bytes sent; preserve X-Internal-Api-Key for the app-layer check. Sign per
  retry attempt to avoid SigV4 timestamp expiry on slow retries.
- Add CDK pip bundling (--platform manylinux2014_aarch64 --only-binary=:all:) to
  all four Lambdas so ARM64 wheels (reportlab, Pillow, pdfplumber) ship.
- Converge _retry_request across all four (fixes possibly-undefined return in
  pdf-extract/pdf-generate).
- Add SigV4 signing regression tests.

Verified: ruff clean, infra tsc clean, aarch64 wheels resolve for all four,
23 pytest pass. GPT-4.1 cross-family review: no BLOCK (FIX + NIT applied).
2026-06-12 17:13:08 -04:00

251 lines
9.3 KiB
Python

"""Tests for suggestions Lambda handler.
QA-C6 (partial): Verifies SQS batch processing, idempotency guard
(skips when AI items exist), and error handling.
"""
import importlib
import sys
import os
from unittest.mock import MagicMock, patch
from helpers import make_sqs_event
# Import suggestions app under a unique module name to avoid collision with
# pdf-generate/app.py (both are named 'app').
_suggestions_dir = os.path.join(os.path.dirname(__file__), "..", "suggestions")
_spec = importlib.util.spec_from_file_location(
"suggestions_app", os.path.join(_suggestions_dir, "app.py")
)
suggestions_app = importlib.util.module_from_spec(_spec)
sys.modules["suggestions_app"] = suggestions_app
_spec.loader.exec_module(suggestions_app)
class TestSuggestionsHandler:
"""Test the SQS handler entry point for suggestions Lambda."""
@patch.object(suggestions_app, "process_suggestion")
def test_handler_processes_sqs_record(self, mock_process):
"""QA-C6: Handler extracts proposalId and trigger from SQS body."""
event = make_sqs_event(
{"payload": {"proposalId": "abc-123", "trigger": "generate"}}
)
result = suggestions_app.handler(event, None)
mock_process.assert_called_once_with("abc-123", "generate")
assert result["batchItemFailures"] == []
@patch.object(suggestions_app, "process_suggestion")
def test_handler_defaults_trigger_to_generate(self, mock_process):
"""QA-C6: Handler defaults trigger to 'generate' when not specified."""
event = make_sqs_event({"payload": {"proposalId": "abc-123"}})
result = suggestions_app.handler(event, None)
mock_process.assert_called_once_with("abc-123", "generate")
assert result["batchItemFailures"] == []
@patch.object(suggestions_app, "process_suggestion")
def test_handler_returns_batch_failures_on_error(self, mock_process):
"""QA-C6: Handler returns failed message IDs for partial batch failure."""
mock_process.side_effect = [None, RuntimeError("Bedrock timeout")]
event = make_sqs_event(
{"payload": {"proposalId": "id-1", "trigger": "generate"}},
{"payload": {"proposalId": "id-2", "trigger": "generate"}},
)
result = suggestions_app.handler(event, None)
assert len(result["batchItemFailures"]) == 1
assert result["batchItemFailures"][0]["itemIdentifier"] == "msg-1"
@patch.object(suggestions_app, "process_suggestion")
def test_handler_handles_malformed_body(self, mock_process):
"""QA-C6: Handler reports failure for records with invalid JSON."""
event = {
"Records": [
{
"messageId": "msg-bad",
"body": "{invalid json",
"receiptHandle": "handle-0",
}
]
}
result = suggestions_app.handler(event, None)
assert len(result["batchItemFailures"]) == 1
mock_process.assert_not_called()
class TestProcessSuggestion:
"""Test the core suggestion generation logic."""
@patch.object(suggestions_app, "store_similar_references")
@patch.object(suggestions_app, "post_line_items")
@patch.object(suggestions_app, "generate_line_items")
@patch.object(suggestions_app, "retrieve_similar")
@patch.object(suggestions_app, "fetch_line_items")
@patch.object(suggestions_app, "fetch_proposal")
def test_process_suggestion_full_flow(
self,
mock_fetch_proposal,
mock_fetch_items,
mock_retrieve,
mock_generate,
mock_post,
mock_store_refs,
):
"""QA-C6: Full suggestion flow fetches proposal, retrieves similar, generates, posts."""
mock_fetch_proposal.return_value = {
"id": "abc",
"scopeOfWork": "Replace HVAC system",
"serviceCategory": "HVAC",
"priority": "Standard",
"status": "InReview",
}
mock_fetch_items.return_value = []
mock_retrieve.return_value = [
{"content": "similar doc", "score": 0.9, "metadata": {}, "sourceUri": ""}
]
mock_generate.return_value = [
{
"description": "Ductwork",
"quantity": 100,
"unit": "linear ft",
"unitPrice": 15.0,
"totalPrice": 1500.0,
"pricingMode": "UnitPrice",
},
]
suggestions_app.process_suggestion("abc", "generate")
mock_fetch_proposal.assert_called_once_with("abc")
mock_fetch_items.assert_called_once_with("abc")
mock_retrieve.assert_called_once()
mock_generate.assert_called_once()
mock_post.assert_called_once()
@patch.object(suggestions_app, "fetch_proposal")
def test_process_suggestion_skips_when_proposal_not_found(self, mock_fetch):
"""QA-C6: Skips processing when proposal is not found (returns early)."""
mock_fetch.return_value = None
# Should not raise - just logs and returns
suggestions_app.process_suggestion("nonexistent", "generate")
@patch.object(suggestions_app, "_retry_request")
def test_post_line_items_preserves_non_ai_items(self, mock_request):
"""QA-C6: Existing non-AI items are preserved when new suggestions are generated."""
mock_response = MagicMock()
mock_response.status_code = 200
mock_request.return_value = mock_response
existing_items = [
{
"id": "manual-1",
"description": "Manual item",
"quantity": 1,
"unit": "each",
"totalPrice": 500,
"pricingMode": "TotalPrice",
"source": "Manual",
},
{
"id": "ai-1",
"description": "Old AI item",
"quantity": 1,
"unit": "each",
"totalPrice": 200,
"pricingMode": "TotalPrice",
"source": "AI",
},
]
new_items = [
{
"description": "New AI item",
"quantity": 2,
"unit": "hours",
"unitPrice": 100,
"totalPrice": 200,
"pricingMode": "UnitPrice",
},
]
suggestions_app.post_line_items("abc", new_items, existing_items)
# Verify the API was called
mock_request.assert_called_once()
call_kwargs = mock_request.call_args
payload = call_kwargs.kwargs.get("json") or call_kwargs[1].get("json")
# Should have 2 items: 1 preserved Manual + 1 new AI (old AI items are replaced)
assert len(payload["lineItems"]) == 2
sources = [li["source"] for li in payload["lineItems"]]
assert "Manual" in sources
assert "AI" in sources
# The manual item should be first (preserved), AI item second (new)
assert payload["lineItems"][0]["source"] == "Manual"
assert payload["lineItems"][0]["description"] == "Manual item"
assert payload["lineItems"][1]["source"] == "AI"
assert payload["lineItems"][1]["description"] == "New AI item"
@patch.object(suggestions_app, "post_line_items")
@patch.object(suggestions_app, "generate_line_items")
@patch.object(suggestions_app, "retrieve_similar")
@patch.object(suggestions_app, "fetch_line_items")
@patch.object(suggestions_app, "fetch_proposal")
def test_process_suggestion_skips_when_no_items_and_no_suggestions(
self,
mock_fetch_proposal,
mock_fetch_items,
mock_retrieve,
mock_generate,
mock_post,
):
"""QA-C6: Skips status update when no existing items and no suggestions generated."""
mock_fetch_proposal.return_value = {
"id": "abc",
"scopeOfWork": "Vague scope",
"serviceCategory": "General",
"priority": "Standard",
}
mock_fetch_items.return_value = []
mock_retrieve.return_value = []
mock_generate.return_value = []
suggestions_app.process_suggestion("abc", "generate")
mock_post.assert_not_called()
class TestRetrySafety:
"""Test retry and API communication helpers."""
def test_get_api_key_caches_result(self):
"""QA-C6: API key is fetched once and cached for subsequent calls."""
# Reset cached key
suggestions_app._cached_api_key = None
mock_secrets = MagicMock()
mock_secrets.get_secret_value.return_value = {"SecretString": "test-key"}
original_client = suggestions_app.secrets_client
original_arn = suggestions_app.INTERNAL_API_KEY_SECRET_ARN
suggestions_app.secrets_client = mock_secrets
suggestions_app.INTERNAL_API_KEY_SECRET_ARN = (
"arn:aws:secretsmanager:us-east-1:123:secret:key"
)
try:
key1 = suggestions_app._get_api_key()
key2 = suggestions_app._get_api_key()
assert key1 == "test-key"
assert key2 == "test-key"
# Should only call secrets manager once (cached)
mock_secrets.get_secret_value.assert_called_once()
finally:
suggestions_app.INTERNAL_API_KEY_SECRET_ARN = original_arn
suggestions_app.secrets_client = original_client
suggestions_app._cached_api_key = None