"""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