proposal-system/lambdas/tests/test_suggestions.py

289 lines
11 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."""
# Fix: CONC-L1 — post_line_items now fetches the proposal's rowVersion
# first and echoes it as proposalVersion on the guarded bulk PUT.
get_response = MagicMock()
get_response.status_code = 200
get_response.json.return_value = {"rowVersion": "AAAAAAAAAAE="}
put_response = MagicMock()
put_response.status_code = 200
mock_request.side_effect = [get_response, put_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)
# GET (token fetch) + PUT (guarded bulk update)
assert mock_request.call_count == 2
call_kwargs = mock_request.call_args
payload = call_kwargs.kwargs.get("json") or call_kwargs[1].get("json")
# The guarded PUT must echo the proposal's version token
assert payload["proposalVersion"] == "AAAAAAAAAAE="
# 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, "_retry_request")
def test_post_line_items_retries_once_on_conflict(self, mock_request):
"""CONC-L1: a 409 conflict refetches the token and retries the PUT once."""
get_1 = MagicMock(status_code=200)
get_1.json.return_value = {"rowVersion": "AAAAAAAAAAE="}
put_conflict = MagicMock(status_code=409, text="conflict")
get_2 = MagicMock(status_code=200)
get_2.json.return_value = {"rowVersion": "AAAAAAAAAAI="}
put_ok = MagicMock(status_code=200)
mock_request.side_effect = [get_1, put_conflict, get_2, put_ok]
suggestions_app.post_line_items(
"abc",
[
{
"description": "AI item",
"quantity": 1,
"unit": "each",
"totalPrice": 100,
"pricingMode": "TotalPrice",
}
],
[],
)
assert mock_request.call_count == 4
final_payload = mock_request.call_args.kwargs.get(
"json"
) or mock_request.call_args[1].get("json")
assert final_payload["proposalVersion"] == "AAAAAAAAAAI="
@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