"""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 json import sys import os from unittest.mock import MagicMock, patch, call import pytest 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", } 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