proposal-system/lambdas/tests/test_suggestions.py
Adam Moussa 01fe003a6d fix: wire test suites into CI, fix stale tests from Phase 1-2 fixes
- Add web-test job (vitest) and python-test job (pytest) to CI workflow
- dotnet reusable workflow already runs tests by default
- Update InternalApiKeyMiddleware tests for API-C1/API-H1 fixes:
  invalid key now returns 401 (not pass-through), valid key on
  disallowed path returns 403
- Fix suggestions test: include status field for LAM-H4 idempotency guard
- Total: 108 tests (77 .NET, 12 web, 19 Python) all passing
2026-05-27 18:18:44 -04:00

218 lines
8.8 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 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",
"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