mirror of
https://github.com/Sea-Haven-Industries/proposal-system.git
synced 2026-09-30 22:53:13 +00:00
289 lines
11 KiB
Python
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
|